heedvane 0.2.1 → 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 (65) hide show
  1. package/README.md +60 -13
  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 +4 -2
  7. package/dist/cli.js +389 -144
  8. package/dist/clients/definitions.d.ts +8 -4
  9. package/dist/clients/definitions.js +48 -13
  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/commands/memory.js +6 -1
  20. package/dist/dispatchers/posix.d.ts +16 -19
  21. package/dist/dispatchers/posix.js +71 -91
  22. package/dist/doctor.d.ts +2 -0
  23. package/dist/doctor.js +113 -29
  24. package/dist/guard.d.ts +73 -0
  25. package/dist/guard.js +302 -0
  26. package/dist/heed-file.d.ts +19 -3
  27. package/dist/heed-file.js +46 -23
  28. package/dist/hook-fallback.d.ts +73 -0
  29. package/dist/hook-fallback.js +61 -0
  30. package/dist/installer.d.ts +13 -6
  31. package/dist/installer.js +40 -13
  32. package/dist/listener/background.d.ts +48 -4
  33. package/dist/listener/background.js +153 -19
  34. package/dist/listener/endpoint.d.ts +26 -3
  35. package/dist/listener/endpoint.js +55 -10
  36. package/dist/listener/forward.d.ts +7 -1
  37. package/dist/listener/forward.js +40 -12
  38. package/dist/listener/serve.d.ts +37 -0
  39. package/dist/listener/serve.js +196 -32
  40. package/dist/listener/server.d.ts +9 -0
  41. package/dist/listener/server.js +59 -22
  42. package/dist/listener/spool.d.ts +2 -17
  43. package/dist/listener/spool.js +6 -3
  44. package/dist/mcp.d.ts +38 -48
  45. package/dist/mcp.js +196 -63
  46. package/dist/plan.d.ts +47 -8
  47. package/dist/plan.js +164 -52
  48. package/dist/render.js +8 -8
  49. package/dist/reply/harness-output.d.ts +9 -0
  50. package/dist/reply/harness-output.js +13 -0
  51. package/dist/reply/reply.js +2 -1
  52. package/dist/skill/content.d.ts +2 -2
  53. package/dist/skill/content.js +11 -8
  54. package/dist/status.d.ts +1 -0
  55. package/dist/status.js +29 -0
  56. package/dist/steering/rules.js +4 -2
  57. package/dist/uninstall.d.ts +7 -0
  58. package/dist/uninstall.js +72 -71
  59. package/package.json +10 -3
  60. package/skills/heedvane-hooks/SKILL.md +6 -6
  61. package/skills/heedvane-hooks/references/events.md +5 -2
  62. package/dist/dispatchers/windows.d.ts +0 -15
  63. package/dist/dispatchers/windows.js +0 -50
  64. package/dist/mcp-server.d.ts +0 -9
  65. package/dist/mcp-server.js +0 -187
package/dist/doctor.js CHANGED
@@ -3,17 +3,20 @@
3
3
  * remediation}. The desired-entry lookup is injected (defaulting to the
4
4
  * pure planner's desiredEntriesFor) so this module never imports apply.ts.
5
5
  */
6
- import { existsSync, readFileSync } from "node:fs";
6
+ import { accessSync, constants as fsConstants, existsSync, readdirSync, readFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { supportedClients } from "./clients/definitions.js";
9
9
  import { entryValue, parseDoc, readEntry, valuesEqual } from "./config-doc.js";
10
10
  import { loadHeedFileAt } from "./heed-file.js";
11
- import { desiredEntriesFor, hooksDeferredTo, isClientDetected } from "./plan.js";
11
+ import { bridgeNodeDir, connectionOff, desiredEntriesFor, heedvaneConnector, heedvaneMcpEntries, hooksDeferredTo, isClientDetected, legacyMcpEntries, supportsCapability } from "./plan.js";
12
+ import { MCP_NEEDS_ORG, MCP_OFF_METRICS_ONLY, ORG_KEY_ENV, ORG_REMEDIATION, comparableEntry, connectorEntry, keyConnectorEntry } from "./mcp.js";
12
13
  import { readInstalledSkillDigest } from "./skill/bundle.js";
13
14
  import { HeedStateCorruptError, readState } from "./state.js";
14
15
  import { readApprovedSkills } from "./skills/approved.js";
15
- import { pingListener, readEndpoint } from "./listener/endpoint.js";
16
+ import { HEEDVANE_VERSION, listenerVerdict, probeListener, readEndpoint } from "./listener/endpoint.js";
17
+ import { settingsToRun } from "./listener/serve.js";
16
18
  import { errorMessage } from "./guards.js";
19
+ import { HOOK_NODE_FILE } from "./hook-fallback.js";
17
20
  const INIT_REMEDIATION = "run npx heedvane init";
18
21
  const SETUP_REMEDIATION = "run npx heedvane setup";
19
22
  /** Inspect one desired entry on disk; every failure path produces a detail line, never a throw. */
@@ -22,6 +25,11 @@ function entryVerdict(d, home) {
22
25
  if (!existsSync(path)) {
23
26
  return { aligned: false, present: false, detail: `${d.entryName}: missing (${d.path})` };
24
27
  }
28
+ if (d.containerKey === "") {
29
+ // Whole-file desired (Hermes' plugin files, Pi's guard extension): compared byte for byte, never parsed as a config.
30
+ const ok = readFileSync(path, "utf8") === d.entry;
31
+ return { aligned: ok, present: true, detail: `${d.entryName}: ${ok ? "aligned" : "stale"}` };
32
+ }
25
33
  let doc;
26
34
  try {
27
35
  doc = parseDoc(readFileSync(path, "utf8"), d.format);
@@ -29,16 +37,11 @@ function entryVerdict(d, home) {
29
37
  catch {
30
38
  return { aligned: false, present: true, detail: `${d.entryName}: config does not parse` };
31
39
  }
32
- if (d.containerKey === "") {
33
- // Whole-file desired (hermes plugin.yaml).
34
- const ok = readFileSync(path, "utf8") === d.entry;
35
- return { aligned: ok, present: true, detail: `${d.entryName}: ${ok ? "aligned" : "stale"}` };
36
- }
37
40
  const current = readEntry(doc, d.containerKey, d.entryName);
38
41
  if (current === undefined) {
39
42
  return { aligned: false, present: true, detail: `${d.entryName}: missing in ${d.path}` };
40
43
  }
41
- if (!valuesEqual(current, entryValue(d.containerKey, current, d.entry))) {
44
+ if (!valuesEqual(comparableEntry(current), comparableEntry(entryValue(d.containerKey, current, d.entry)))) {
42
45
  return { aligned: false, present: true, detail: `${d.entryName}: stale in ${d.path}` };
43
46
  }
44
47
  return { aligned: true, present: true, detail: `${d.entryName}: aligned` };
@@ -61,14 +64,17 @@ function registrationState(desireds, home) {
61
64
  return { state: "stale", detail: parts.join("; ") };
62
65
  return { state: "aligned", detail: parts.join("; ") };
63
66
  }
67
+ /** The oldest Node the CLI runs on, as package.json's `engines` states it: 22.5 has path.matchesGlob, which steering rules with file patterns run on. */
68
+ export const NODE_FLOOR = "22.5";
64
69
  function checkNode() {
65
- const major = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
66
- const ok = major >= 22;
70
+ const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
71
+ const [floorMajor = 0, floorMinor = 0] = NODE_FLOOR.split(".").map(Number);
72
+ const ok = major > floorMajor || (major === floorMajor && minor >= floorMinor);
67
73
  return {
68
74
  ok,
69
75
  name: "node",
70
- detail: `node ${process.versions.node} (${ok ? ">= 22" : "< 22 — heedvane requires Node 22+"})`,
71
- remediation: ok ? undefined : "upgrade Node to >= 22 (nvm install 22)",
76
+ detail: `node ${process.versions.node} (${ok ? `>= ${NODE_FLOOR}` : `< ${NODE_FLOOR} — heedvane requires Node ${NODE_FLOOR} or later`})`,
77
+ remediation: ok ? undefined : `upgrade Node to ${NODE_FLOOR} or later (nvm install ${floorMajor})`,
72
78
  };
73
79
  }
74
80
  function checkHeedFile(home) {
@@ -109,13 +115,14 @@ void checkState;
109
115
  function scopeSet(clients) {
110
116
  return clients && clients.length > 0 ? new Set(clients) : null;
111
117
  }
112
- /** The pure-planner desired-entry lookup, resolving each client id against home/env. */
113
- function defaultDesiredLookup(home, env) {
118
+ /** The pure-planner desired-entry lookup, resolving each client id against home/env and heed.yaml's organization. */
119
+ function defaultDesiredLookup(home, env, heedFile) {
120
+ const connector = heedFile === undefined ? undefined : heedvaneConnector(heedFile);
114
121
  return (clientId, capability) => {
115
122
  const client = supportedClients({ home, platform: process.platform, env }).find((c) => c.id === clientId);
116
123
  if (!client)
117
124
  return undefined;
118
- return desiredEntriesFor(client, capability, { home, env }).map((d) => ({
125
+ return desiredEntriesFor(client, capability, { home, env, connector, nodeDir: bridgeNodeDir(process.platform), gating: heedFile?.gating.mode }).map((d) => ({
119
126
  path: d.path,
120
127
  format: d.format,
121
128
  containerKey: d.containerKey,
@@ -157,16 +164,44 @@ function heedFileWants(client, capability, ctx) {
157
164
  function collectCapabilityStates(client, ctx) {
158
165
  const states = [];
159
166
  for (const capability of ["hooks", "mcp"]) {
160
- const supported = client.capabilities.includes(capability) || (capability === "hooks" && client.id === "hermes" && client.capabilities.includes("plugin-install"));
167
+ const supported = supportsCapability(client, capability);
161
168
  if (!supported || !heedFileWants(client, capability, ctx))
162
169
  continue;
163
- const desireds = ctx.desired(client.id, capability);
164
- if (!desireds || desireds.length === 0)
165
- continue;
166
- states.push({ capability, result: registrationState(desireds, ctx.home) });
170
+ const desireds = ctx.desired(client.id, capability) ?? [];
171
+ const result = capability === "mcp" ? mcpConnectionState(client, desireds, ctx) : hooksState(desireds, ctx.home);
172
+ if (result !== undefined)
173
+ states.push({ capability, result });
167
174
  }
168
175
  return states;
169
176
  }
177
+ /** The hooks' registration verdict, when this client has hooks to register. */
178
+ function hooksState(desireds, home) {
179
+ return desireds.length > 0 ? registrationState(desireds, home) : undefined;
180
+ }
181
+ /** Heedvane's MCP connection: stale while an earlier version's entry is left, missing without an organization, else the registration verdict. */
182
+ function mcpConnectionState(client, desireds, { home, env, heedFile }) {
183
+ if (heedFile !== undefined && connectionOff(heedFile) === "metrics-only") {
184
+ const left = heedvaneMcpEntries(client);
185
+ return left === undefined ? undefined : { state: "stale", detail: `${left.names.join(", ")} in ${left.path}: ${MCP_OFF_METRICS_ONLY}; npx -y heedvane connect removes it` };
186
+ }
187
+ const legacy = legacyMcpEntries(client);
188
+ if (legacy !== undefined && desireds.length === 0)
189
+ return { state: "stale", detail: `${legacy.names.join(", ")} in ${legacy.path} is an earlier version's entry; ${MCP_NEEDS_ORG}`, remediation: ORG_REMEDIATION };
190
+ if (legacy !== undefined)
191
+ return { state: "stale", detail: `${legacy.names.join(", ")} in ${legacy.path} is an earlier version's entry; npx -y heedvane connect replaces it` };
192
+ const connector = heedFile === undefined ? undefined : heedvaneConnector(heedFile);
193
+ const keyForm = connector === undefined ? undefined : keyConnectorEntry(client.id, connector);
194
+ if (desireds.length > 0)
195
+ return withKeyNote(registrationState(desireds, home), desireds.some((desired) => valuesEqual(desired.entry, keyForm)), env);
196
+ // Agents the connection is not registered in have nothing to check.
197
+ return connectorEntry(client.id, "") === undefined ? undefined : { state: "missing", detail: MCP_NEEDS_ORG, remediation: ORG_REMEDIATION };
198
+ }
199
+ /** A software factory's connection sends HEEDVANE_ORG_KEY from the agent's environment, so doctor says when its own lacks it. */
200
+ function withKeyNote(result, sendsKey, env) {
201
+ if (!sendsKey || env[ORG_KEY_ENV]?.trim())
202
+ return result;
203
+ return { ...result, detail: `${result.detail}; it sends ${ORG_KEY_ENV}, which is not set here: set it where the agent runs` };
204
+ }
170
205
  /** One detected client's registration check (aligned / stale / missing with per-entry detail). */
171
206
  function pushRegistrationChecks(checks, client, ctx) {
172
207
  const detected = isClientDetected(client);
@@ -189,11 +224,12 @@ function pushRegistrationChecks(checks, client, ctx) {
189
224
  return;
190
225
  const allAligned = states.every((s) => s.result.state === "aligned");
191
226
  const detail = states.map((s) => `${s.capability}: ${s.result.state} (${s.result.detail})`).join("; ");
227
+ const remedies = new Set(states.filter((s) => s.result.state !== "aligned").map((s) => s.result.remediation ?? SETUP_REMEDIATION));
192
228
  checks.push({
193
229
  ok: allAligned,
194
230
  name: `${client.id} registration`,
195
231
  detail,
196
- remediation: allAligned ? undefined : SETUP_REMEDIATION,
232
+ remediation: allAligned ? undefined : [...remedies].join("; "),
197
233
  });
198
234
  }
199
235
  /** The skill-digest verdict against state.json (aligned / stale / missing / unrecorded). */
@@ -233,7 +269,7 @@ export async function runDoctor(input) {
233
269
  const root = join(home, ".heed");
234
270
  const state = await readStateOrReport(root, checks);
235
271
  // 4. Desired-entry lookup: injected, falling back to the pure planner.
236
- const desired = input.desired ?? defaultDesiredLookup(home, env);
272
+ const desired = input.desired ?? defaultDesiredLookup(home, env, heedFile);
237
273
  // 5. Per detected client: registration state aligned | stale | missing.
238
274
  const scope = scopeSet(input.clients);
239
275
  const allClients = supportedClients({ home, platform: process.platform, env });
@@ -250,6 +286,7 @@ export async function runDoctor(input) {
250
286
  }
251
287
  // 7. Listener reachable — best-effort POST, warn-only, never fails the report.
252
288
  checks.push(await checkListener(root));
289
+ checks.push(checkHookNode(root));
253
290
  checks.push(await checkApprovedSkills(root, heedFile));
254
291
  const healthy = checks.every((c) => c.ok || c.warnOnly);
255
292
  return { healthy, checks, scopedTo: scope ? [...scope] : undefined };
@@ -276,6 +313,42 @@ async function checkApprovedSkills(root, heedFile) {
276
313
  return { ...base, ok: false, detail: `${list.skills.length} approved, synced ${list.syncedAt}: revocations since then are not enforced`, remediation: sync };
277
314
  return { ...base, detail: `${list.skills.length} approved, synced ${list.syncedAt}` };
278
315
  }
316
+ /** The Node every hook's fallback runs with (agent-hooks/node), which `brew upgrade` or `nvm uninstall` can remove. */
317
+ function checkHookNode(root) {
318
+ const dir = join(root, "agent-hooks");
319
+ const nodes = existsSync(join(dir, HOOK_NODE_FILE)) ? readFileSync(join(dir, HOOK_NODE_FILE), "utf8").split("\n").filter(Boolean) : [];
320
+ const base = { name: "hooks' Node", warnOnly: true };
321
+ const node = nodes.find(isExecutable);
322
+ if (node !== undefined)
323
+ return { ...base, ok: true, detail: node };
324
+ // Hooks installed before the file was written (0.3.3 and earlier) have none.
325
+ if (nodes.length === 0 && !(existsSync(dir) && readdirSync(dir).some((name) => name.endsWith("-hook.sh"))))
326
+ return { ...base, ok: true, detail: "no hooks installed" };
327
+ const missing = nodes.length === 0 ? "not recorded" : `${nodes.join(" and ")} ${nodes.length === 1 ? "is" : "are"} gone`;
328
+ return { ...base, ok: false, detail: `${missing}, so hooks that cannot reach the listener fall back to node on PATH, if there is one`, remediation: "npx heedvane connect" };
329
+ }
330
+ /** A listener the verdict keeps. One before 0.3.4, kept because no settings parse, cannot prove itself: the hooks keep their events instead. */
331
+ function keptListener(running, at) {
332
+ if (running.settings !== undefined)
333
+ return { detail: `reachable on ${at}` };
334
+ return { ok: false, detail: `an older listener (${running.version || "before 0.3.4"}) runs on ${at}, which the hooks do not use: events spool until heed.yaml parses and a listener of this version starts` };
335
+ }
336
+ /** A newer listener: this npx heedvane is older; the newer one applies the settings when it runs earlier ones. */
337
+ function newerListener(running, current, at) {
338
+ const older = `this npx heedvane (${HEEDVANE_VERSION}) is older`;
339
+ if (current !== null && running.settings !== current)
340
+ return { detail: `a newer listener (${running.version}) runs on ${at} with the earlier settings; ${older}`, remediation: "npx -y heedvane@latest listener restart" };
341
+ return { detail: `a newer listener (${running.version}) runs on ${at}; ${older}`, remediation: "npx -y heedvane@latest doctor" };
342
+ }
343
+ function isExecutable(path) {
344
+ try {
345
+ accessSync(path, fsConstants.X_OK);
346
+ return true;
347
+ }
348
+ catch {
349
+ return false; // removed, or not a program
350
+ }
351
+ }
279
352
  async function checkListener(root) {
280
353
  const base = { ok: true, name: "listener", detail: "", warnOnly: true };
281
354
  let endpoint;
@@ -294,10 +367,21 @@ async function checkListener(root) {
294
367
  if (!endpoint.port) {
295
368
  return { ...base, detail: "endpoint.env incomplete (no port) — re-run npx heedvane setup" };
296
369
  }
297
- if (await pingListener(endpoint))
298
- return { ...base, detail: `reachable on 127.0.0.1:${endpoint.port}` };
299
- return {
300
- ...base,
301
- detail: `not reachable on 127.0.0.1:${endpoint.port} — ${ENFORCEMENT_OFF}; events will spool (warn-only; never fails the report)`,
302
- };
370
+ const running = await probeListener(endpoint);
371
+ const at = `127.0.0.1:${endpoint.port}`;
372
+ // The verdict every command acts on (src/listener/background.ts ensureListener): doctor only reports it.
373
+ const current = await settingsToRun(root).then((settings) => settings.hash, () => null);
374
+ switch (listenerVerdict(running, current)) {
375
+ case "keep":
376
+ return { ...base, ...keptListener(running, at) };
377
+ case "start":
378
+ return { ...base, detail: `not reachable on ${at} — ${ENFORCEMENT_OFF}; events will spool (warn-only; never fails the report)` };
379
+ case "newer":
380
+ return { ...base, ok: false, ...newerListener(running, current, at) };
381
+ case "replace":
382
+ return { ...base, ok: false, detail: `an older listener (${running?.version || "before 0.3.4"}) runs on ${at}`, remediation: "npx heedvane listener restart" };
383
+ case "restart":
384
+ // A hand edit of heed.yaml, or a sign-in no command saw, reaches a running listener only through a restart.
385
+ return { ...base, ok: false, detail: `runs on ${at} with the settings it started with; heed.yaml or the sign-in changed since`, remediation: "npx heedvane listener restart" };
386
+ }
303
387
  }
@@ -0,0 +1,73 @@
1
+ export interface ToolCallQuestion {
2
+ tool: string;
3
+ input: unknown;
4
+ id?: string;
5
+ session?: string;
6
+ cwd?: string;
7
+ /** Shows the person a notice, where the agent can (Pi's `ctx.ui.notify`); stderr when not given. */
8
+ tell?: (notice: string) => void;
9
+ }
10
+ /**
11
+ * Heedvane's reason to refuse this tool call, or undefined to let it run. It never throws: when the listener cannot
12
+ * answer (none runs, another program holds its port, or it answers with an error) the event is spooled for it to
13
+ * replay and the tool runs, as with every agent's hooks.
14
+ */
15
+ export declare function heedvaneRefusal(call: ToolCallQuestion): Promise<string | undefined>;
16
+ /** An event no listener answered: the agent's id, the hook's JSON text, its event id and its session. */
17
+ interface Unanswered {
18
+ source: string;
19
+ payload: string;
20
+ id: string;
21
+ session: string;
22
+ }
23
+ /**
24
+ * Every agent's fallback when the listener gives no answer: keeps the event in the spool for the listener to replay,
25
+ * starts a listener (on any failure: on Windows a refused loopback connection outlasts the timeout), and says whether
26
+ * the session is yet to be told that its calls are not checked; its marker is written now. Never throws.
27
+ */
28
+ export declare function keepUnanswered(root: string, unanswered: Unanswered): boolean;
29
+ /** Pi's core: `new Agent({ beforeToolCall })`, or `agent.beforeToolCall = beforeToolCall`. */
30
+ export declare function beforeToolCall(context: {
31
+ toolCall: {
32
+ id: string;
33
+ name: string;
34
+ };
35
+ args: unknown;
36
+ }): Promise<{
37
+ block: true;
38
+ reason: string;
39
+ } | undefined>;
40
+ /** Pi Durable: `defineExtension({ name: "heedvane", hooks: [hook(ToolTask, { beforeTool })] })`. Its conversation is the session. */
41
+ export declare function beforeTool(call: {
42
+ id: string;
43
+ name: string;
44
+ arguments: unknown;
45
+ }, api?: {
46
+ conversationId?: string | number;
47
+ }): Promise<{
48
+ block: string;
49
+ } | undefined>;
50
+ interface PiToolCallEvent {
51
+ toolName: string;
52
+ toolCallId: string;
53
+ input: unknown;
54
+ }
55
+ interface PiContext {
56
+ cwd?: string;
57
+ sessionManager?: {
58
+ getSessionId?: () => string;
59
+ };
60
+ /** False in print and JSON modes, which have no UI. */
61
+ hasUI?: boolean;
62
+ ui?: {
63
+ notify?: (message: string, type?: "info" | "warning" | "error") => void;
64
+ };
65
+ }
66
+ /** The Pi coding agent's extension entry point. */
67
+ export default function heedvane(pi: {
68
+ on(event: "tool_call", handler: (event: PiToolCallEvent, ctx: PiContext) => Promise<{
69
+ block: true;
70
+ reason: string;
71
+ } | undefined>): unknown;
72
+ }): void;
73
+ export {};
package/dist/guard.js ADDED
@@ -0,0 +1,302 @@
1
+ // heedvane: Heedvane's guard for Pi. It asks the local Heedvane listener before each tool runs, and lets the tool run when it cannot ask.
2
+ //
3
+ // One file serves every Pi:
4
+ // - The Pi coding agent loads it as an extension: `npx -y heedvane connect --agent pi` copies it to
5
+ // `<Pi's agent folder>/extensions/heedvane.ts`, and `npx -y heedvane uninstall` removes it.
6
+ // - Code built on Pi's core (`@earendil-works/pi-agent-core`): `new Agent({ ..., beforeToolCall })`.
7
+ // - Pi Durable (`@earendil-works/pi-durable`), which does not run on Pi's core: `hook(ToolTask, { beforeTool })`.
8
+ // It imports only Node's own modules and src/hook-fallback.ts, which the copy carries inline, so it runs on its own.
9
+ // It is also every agent's fallback when the listener cannot answer ({@link keepUnanswered}): the dispatchers and
10
+ // Hermes run its copy `fallback.mjs` with UNANSWERED_FLAG. It spools the event for the listener to replay, starts a
11
+ // listener, tells the person once per session that calls are not checked, and lets the tool run.
12
+ import { spawn } from "node:child_process";
13
+ import { randomUUID } from "node:crypto";
14
+ import { appendFileSync, closeSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { basename, delimiter, dirname, join, sep } from "node:path";
17
+ import { setTimeout as delay } from "node:timers/promises";
18
+ import { parseEnv } from "node:util";
19
+ import { HOOK_TIMEOUT_MS, LISTENER_START, OTHER_SIGN_IN_DIR, PANE_KEY_ENV, listenerProof, PROBE_REFUSAL, PROOF_HEADER, SESSION_START_TIMEOUT_MS, SPOOL_CAP_BYTES, SPOOL_DIR, SPOOL_ENV, SPOOL_FILE_PREFIX, SPOOL_FILE_SUFFIX, SPOOL_TTL_DAYS, START_LOCK, START_LOCK_HOLDER, START_LOCK_MINUTES, RETRY_AFTER_MS, UNANSWERED_FLAG, UNCHECKED_ERROR_NOTICE, UNCHECKED_MARK_PREFIX, UNCHECKED_NOTICE } from "./hook-fallback.js";
20
+ /** heed.yaml `gating.mode`, set in the copy `connect` installs: `pane` asks only in sessions started with HEED_SESSION. Code that imports the guard always asks. */
21
+ const GATING = "always";
22
+ /** Pi's built-in tools under Claude Code's names, the names Heedvane's rules list. */
23
+ const RULE_TOOL_NAMES = { bash: "Bash", read: "Read", edit: "Edit", write: "Write", grep: "Grep", find: "Glob", ls: "LS" };
24
+ const FALLBACK_REASON = "Heedvane's rules refuse this tool call.";
25
+ /**
26
+ * Heedvane's reason to refuse this tool call, or undefined to let it run. It never throws: when the listener cannot
27
+ * answer (none runs, another program holds its port, or it answers with an error) the event is spooled for it to
28
+ * replay and the tool runs, as with every agent's hooks.
29
+ */
30
+ export async function heedvaneRefusal(call) {
31
+ let asked;
32
+ try {
33
+ if (GATING !== "always" && !process.env["HEED_SESSION"])
34
+ return undefined;
35
+ asked = { place: listenerPlace(), event: { event: hookEvent(call), id: randomUUID() } };
36
+ const question = { agent: "pi", body: JSON.stringify(asked.event.event), id: asked.event.id };
37
+ const answer = await listenerAnswer(asked.place, question).catch((err) => {
38
+ return askAgain(asked.place, question, err); // its second answer; a refusal of the listener's own is rethrown there
39
+ });
40
+ return refusalIn(answer);
41
+ }
42
+ catch (err) {
43
+ if (refusedWith(err) === undefined)
44
+ keepForTheListener(call, asked, err);
45
+ else if (noteRefused(asked.place.root, err, { source: "pi", payload: JSON.stringify(asked.event.event), id: asked.event.id, session: asked.event.event.session_id }))
46
+ tell(call, UNCHECKED_ERROR_NOTICE);
47
+ return undefined;
48
+ }
49
+ }
50
+ /** The process's folder; the home folder when it cannot say (its folder was removed). */
51
+ function cwdOrHome() {
52
+ try {
53
+ return process.cwd();
54
+ }
55
+ catch {
56
+ return homedir();
57
+ }
58
+ }
59
+ /** The call as Claude Code's PreToolUse, with Claude Code's tool names. */
60
+ function hookEvent(call) {
61
+ return {
62
+ hook_event_name: "PreToolUse",
63
+ session_id: call.session ?? process.env["HEED_SESSION"] ?? `pi-${process.pid}`,
64
+ cwd: call.cwd ?? cwdOrHome(),
65
+ tool_name: RULE_TOOL_NAMES[call.tool] ?? call.tool,
66
+ tool_input: call.input,
67
+ tool_use_id: call.id,
68
+ };
69
+ }
70
+ /**
71
+ * The port and token from HEED_AGENT_HOOK_PORT and HEED_AGENT_HOOK_TOKEN when both are set, else from the endpoint file:
72
+ * HEED_AGENT_HOOK_ENDPOINT, else endpoint.env in HEED_AGENT_HOOK_ROOT (which `heedvane run` sets), else in ~/.heed.
73
+ * An empty variable counts as unset, as in the dispatchers.
74
+ */
75
+ function listenerPlace() {
76
+ const endpointFile = process.env["HEED_AGENT_HOOK_ENDPOINT"] || join(process.env["HEED_AGENT_HOOK_ROOT"] || join(homedir(), ".heed"), "endpoint.env");
77
+ const root = dirname(endpointFile);
78
+ const fromEnv = { port: process.env["HEED_AGENT_HOOK_PORT"], token: process.env["HEED_AGENT_HOOK_TOKEN"] };
79
+ if (fromEnv.port && fromEnv.token)
80
+ return { root, ...fromEnv };
81
+ try {
82
+ const values = parseEnv(readFileSync(endpointFile, "utf8"));
83
+ return { root, port: values["HEED_AGENT_HOOK_PORT"], token: values["HEED_AGENT_HOOK_TOKEN"] };
84
+ }
85
+ catch {
86
+ return { root }; // no endpoint file: nothing to ask, the event is spooled
87
+ }
88
+ }
89
+ /**
90
+ * The listener's answer; throws when there is none: no endpoint, nothing listening, another program, or an error
91
+ * answer. An error answer from a listener that names its settings (0.3.4 on), other than 503 (it is stopping), is its
92
+ * own answer to this event: the error carries its status as `refused`, and starting another listener would change nothing.
93
+ */
94
+ async function listenerAnswer(place, { agent, body, id }) {
95
+ if (!place.port || !place.token)
96
+ throw new Error("no listener endpoint");
97
+ const route = `http://127.0.0.1:${place.port}/hook/${agent}`;
98
+ // Every call, since the port can change hands between calls; a probe on loopback costs well under a millisecond.
99
+ const probe = await fetch(route, { signal: AbortSignal.timeout(HOOK_TIMEOUT_MS) });
100
+ const about = (probe.status === 405 ? await probe.json() : {});
101
+ // Only this machine's listener names the proof of its token; a listener before 0.3.4 names none, and is never asked.
102
+ // shortcut: proof is static and readable by GET; another OS user can replay it — challenge-response (nonce + HMAC, no token on the wire) right after this PR
103
+ const proof = await listenerProof(place.token);
104
+ const proven = (answer) => answer.headers.get(PROOF_HEADER) === proof;
105
+ if (about.error !== PROBE_REFUSAL || !proven(probe))
106
+ throw new Error(`another program answers on 127.0.0.1:${place.port}`);
107
+ const response = await fetch(route, {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/json", "X-Heed-Agent-Hook-Token": place.token, "X-Heed-Hook-Version": "1", "X-Heed-Event-Id": id },
110
+ body,
111
+ // As long as the dispatcher's own ask: a session start waits for Heedvane's team memory.
112
+ signal: AbortSignal.timeout(/"hook_event_name": ?"SessionStart"/.test(body) ? SESSION_START_TIMEOUT_MS : HOOK_TIMEOUT_MS),
113
+ });
114
+ if (!proven(response))
115
+ throw new Error(`another program answers on 127.0.0.1:${place.port}`);
116
+ if (!response.ok)
117
+ throw Object.assign(new Error(`the listener answered ${response.status}`), { refused: typeof about.settings === "string" && response.status !== 503 ? response.status : undefined });
118
+ return response.json();
119
+ }
120
+ /** The listener's answer after one pause, unless `failed` was its own error answer: a listener taking over from another binds within milliseconds. */
121
+ async function askAgain(place, question, failed) {
122
+ if (refusedWith(failed) !== undefined)
123
+ throw failed;
124
+ // Nothing to ask without a port and a token (no endpoint.env: heedvane/guard without Heedvane installed), so no pause.
125
+ if (!place.port || !place.token)
126
+ throw failed ?? new Error("no listener endpoint");
127
+ await delay(RETRY_AFTER_MS);
128
+ return listenerAnswer(place, question);
129
+ }
130
+ /** The status of the listener's own error answer to this event (see {@link listenerAnswer}), or undefined. */
131
+ function refusedWith(err) {
132
+ const status = err?.refused;
133
+ return typeof status === "number" ? status : undefined;
134
+ }
135
+ /**
136
+ * A current listener answered this event with an error: the call runs, the listener's log says so, and this says
137
+ * whether the session is yet to be told (once, by its own marker). A server error (5xx) may pass, so the event is kept
138
+ * for the listener to replay; a refused request (4xx) would be refused again.
139
+ */
140
+ function noteRefused(root, err, unanswered) {
141
+ // shortcut: the kept event waits for the next listener start to replay it, upgrade if a running listener should drain its spool.
142
+ if ((refusedWith(err) ?? 0) >= 500)
143
+ attempt(() => spoolEvent(root, unanswered));
144
+ attempt(() => appendFileSync(join(root, "listener.log"), `[heedvane] ${String(err)} to a hook; the call ran unchecked\n`, { mode: 0o600 }));
145
+ return attempt(() => markTold(root, `error-${unanswered.session}`)) === true;
146
+ }
147
+ /** The reason in the listener's `{block: true, reason}`, or undefined when it lets the call run. */
148
+ function refusalIn(answer) {
149
+ if (typeof answer !== "object" || answer === null || answer.block !== true)
150
+ return undefined;
151
+ const reason = answer.reason;
152
+ return typeof reason === "string" && reason !== "" ? reason : FALLBACK_REASON;
153
+ }
154
+ /**
155
+ * Pi's call the listener did not answer, kept for it ({@link keepUnanswered}), the session told once. `asked` is
156
+ * undefined when the guard failed before it could ask: the call is then built again here and the failure logged.
157
+ * It never throws into Pi.
158
+ */
159
+ function keepForTheListener(call, asked, err) {
160
+ const { place, event } = asked ?? { place: { root: join(homedir(), ".heed") }, event: { event: hookEvent(call), id: randomUUID() } };
161
+ const unanswered = { source: "pi", payload: JSON.stringify(event.event), id: event.id, session: event.event.session_id };
162
+ if (keepUnanswered(place.root, unanswered))
163
+ tell(call, UNCHECKED_NOTICE);
164
+ if (asked === undefined)
165
+ attempt(() => appendFileSync(join(place.root, "listener.log"), `[heedvane] Pi's guard could not ask the listener (${String(err)}); the call ran unchecked and is spooled\n`, { mode: 0o600 }));
166
+ }
167
+ /** Shows the person `notice` where Pi can (its notice, or stderr); never throws into Pi. */
168
+ function tell(call, notice) {
169
+ attempt(() => (call.tell ?? ((text) => tellPerson(undefined, text)))(notice));
170
+ }
171
+ /**
172
+ * Every agent's fallback when the listener gives no answer: keeps the event in the spool for the listener to replay,
173
+ * starts a listener (on any failure: on Windows a refused loopback connection outlasts the timeout), and says whether
174
+ * the session is yet to be told that its calls are not checked; its marker is written now. Never throws.
175
+ */
176
+ export function keepUnanswered(root, unanswered) {
177
+ attempt(() => spoolEvent(root, unanswered));
178
+ attempt(() => startListener(root));
179
+ return attempt(() => markTold(root, unanswered.session)) === true;
180
+ }
181
+ /** `step`'s result, or undefined when it throws (a full disk, a read-only home, a start already under way): the tool still runs. */
182
+ function attempt(step) {
183
+ try {
184
+ return step();
185
+ }
186
+ catch {
187
+ return undefined; // the fallback's own failure never reaches the agent
188
+ }
189
+ }
190
+ /** The spool record, in the pane's file, which the listener replays when it starts; spool files past their TTL go first. */
191
+ function spoolEvent(root, { source, payload, id }) {
192
+ const pane = PANE_KEY_ENV.map((variable) => process.env[variable]).find(Boolean) ?? "";
193
+ const dir = join(root, SPOOL_DIR);
194
+ const file = join(dir, `${SPOOL_FILE_PREFIX}${pane || "default"}${SPOOL_FILE_SUFFIX}`);
195
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
196
+ attempt(() => pruneSpool(dir)); // a prune that fails (an older Node in Pi, a file another hook just removed) never costs the event
197
+ if ((statSync(file, { throwIfNoEntry: false })?.size ?? 0) >= SPOOL_CAP_BYTES)
198
+ return;
199
+ const fromEnv = Object.fromEntries(Object.entries(SPOOL_ENV).map(([field, variable]) => [field, process.env[variable] ?? ""]));
200
+ const record = { paneKey: pane, ...fromEnv, source, receivedAt: new Date().toISOString(), id, payload };
201
+ appendFileSync(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
202
+ }
203
+ /** Removes spool files, told markers and the events set aside after a change of sign-in that are older than SPOOL_TTL_DAYS. */
204
+ function pruneSpool(dir) {
205
+ const cutoff = Date.now() - SPOOL_TTL_DAYS * 86_400_000;
206
+ for (const entry of readdirSync(dir, { recursive: true, withFileTypes: true })) {
207
+ const path = join(entry.parentPath, entry.name);
208
+ const pruned = entry.name.startsWith(SPOOL_FILE_PREFIX) || entry.name.startsWith(UNCHECKED_MARK_PREFIX) || path.includes(`${sep}${OTHER_SIGN_IN_DIR}${sep}`);
209
+ if (entry.isFile() && pruned && statSync(path).mtimeMs < cutoff)
210
+ rmSync(path, { force: true });
211
+ }
212
+ }
213
+ /** Writes the session's told marker in the spool folder; false when it was there: the session has been told. */
214
+ function markTold(root, session) {
215
+ const marker = join(root, SPOOL_DIR, `${UNCHECKED_MARK_PREFIX}${session.replace(/[^A-Za-z0-9_.-]/g, "") || "default"}`);
216
+ mkdirSync(dirname(marker), { recursive: true, mode: 0o700 });
217
+ closeSync(openSync(marker, "wx", 0o600)); // throws when the marker is there
218
+ return true;
219
+ }
220
+ /** LISTENER_START from the heed folder, in the background, at most once a minute; `listener start` decides whether one must start. */
221
+ function startListener(root) {
222
+ if (process.env["HEED_LISTENER_AUTOSTART"] === "0" || basename(root) !== ".heed")
223
+ return;
224
+ const lock = join(root, START_LOCK);
225
+ const lockAge = Date.now() - (statSync(lock, { throwIfNoEntry: false })?.mtimeMs ?? Date.now());
226
+ if (lockAge > START_LOCK_MINUTES * 60_000)
227
+ rmSync(lock, { recursive: true, force: true });
228
+ mkdirSync(lock); // throws when another start holds the lock
229
+ const log = openSync(join(root, "listener.log"), "a", 0o600);
230
+ try {
231
+ // From the heed folder: in the agent's project, npx could run a `heedvane` the project's package.json names.
232
+ // The folder of the Node that runs this is the last place to find npx: an agent started from a desktop app may have none on PATH.
233
+ const path = `${process.env["PATH"] ?? ""}${delimiter}${dirname(process.execPath)}`;
234
+ const [command, ...args] = LISTENER_START;
235
+ // Windows runs npx as npx.cmd, which Node starts only through a shell. Not run on Windows in this package's tests.
236
+ const child = spawn(command, args, { cwd: root, detached: true, windowsHide: true, shell: process.platform === "win32", stdio: ["ignore", log, log], env: { ...process.env, NODE_OPTIONS: undefined, PATH: path, HEED_HOME: dirname(root) } }); // Node drops undefined variables; the agent's NODE_OPTIONS are its project's
237
+ child.on("error", () => undefined); // no npx: the next start is a minute away
238
+ child.unref();
239
+ writeFileSync(join(lock, START_LOCK_HOLDER), String(child.pid ?? "")); // a command takes the lock over once this start has ended
240
+ }
241
+ finally {
242
+ closeSync(log);
243
+ }
244
+ }
245
+ /** Pi's core: `new Agent({ beforeToolCall })`, or `agent.beforeToolCall = beforeToolCall`. */
246
+ export async function beforeToolCall(context) {
247
+ const reason = await heedvaneRefusal({ tool: context.toolCall.name, input: context.args, id: context.toolCall.id });
248
+ return reason === undefined ? undefined : { block: true, reason };
249
+ }
250
+ /** Pi Durable: `defineExtension({ name: "heedvane", hooks: [hook(ToolTask, { beforeTool })] })`. Its conversation is the session. */
251
+ export async function beforeTool(call, api) {
252
+ const session = api?.conversationId === undefined ? undefined : `pi-durable-${api.conversationId}`;
253
+ const reason = await heedvaneRefusal({ tool: call.name, input: call.arguments, id: call.id, session });
254
+ return reason === undefined ? undefined : { block: reason };
255
+ }
256
+ /** The Pi coding agent's extension entry point. */
257
+ export default function heedvane(pi) {
258
+ pi.on("tool_call", async (event, ctx) => {
259
+ const reason = await heedvaneRefusal({ tool: event.toolName, input: event.input, id: event.toolCallId, cwd: ctx?.cwd, session: sessionOf(ctx), tell: (notice) => tellPerson(ctx, notice) });
260
+ return reason === undefined ? undefined : { block: true, reason };
261
+ });
262
+ }
263
+ /** Pi's warning notice where it has a UI; on stderr in print and JSON modes, where a pipeline's log shows it. */
264
+ function tellPerson(ctx, notice) {
265
+ if (ctx?.hasUI === true && ctx.ui?.notify !== undefined)
266
+ ctx.ui.notify(`Heedvane: ${notice}`, "warning");
267
+ else
268
+ process.stderr.write(`heedvane: ${notice}\n`);
269
+ }
270
+ function sessionOf(ctx) {
271
+ try {
272
+ return ctx?.sessionManager?.getSessionId?.();
273
+ }
274
+ catch {
275
+ return undefined; // a session manager that cannot say: the process's session stands in
276
+ }
277
+ }
278
+ /**
279
+ * `node fallback.mjs --heedvane-unanswered <agent> [notice field]`: the dispatchers' and Hermes' fallback, with the
280
+ * hook's event on stdin and the answer the hook gives without the listener in HEED_HOOK_FALLBACK_ANSWER. Keeps the
281
+ * event, and prints that answer with the notice in the agent's notice field the first time in a session.
282
+ */
283
+ async function answerUnanswered([agent = "", noticeField]) {
284
+ const fallback = JSON.parse(process.env["HEED_HOOK_FALLBACK_ANSWER"] || "{}");
285
+ const payload = readFileSync(0, "utf8");
286
+ const place = listenerPlace();
287
+ const question = { agent, body: payload, id: process.env["HEED_HOOK_EVENT_ID"] || randomUUID() };
288
+ // The hook asked once already; a listener taking the port over answers the second time.
289
+ const answer = await askAgain(place, question, undefined).catch((err) => {
290
+ const unanswered = { source: agent, payload, id: question.id, session: sessionIn(payload) };
291
+ const [told, notice] = refusedWith(err) === undefined ? [keepUnanswered(place.root, unanswered), UNCHECKED_NOTICE] : [noteRefused(place.root, err, unanswered), UNCHECKED_ERROR_NOTICE];
292
+ return told && noticeField ? { [noticeField]: notice, ...fallback } : fallback;
293
+ });
294
+ process.stdout.write(`${JSON.stringify(answer)}\n`);
295
+ }
296
+ /** The hook event's session id, or "" when it names none. */
297
+ function sessionIn(payload) {
298
+ const session = attempt(() => JSON.parse(payload).session_id);
299
+ return typeof session === "string" ? session : "";
300
+ }
301
+ if (process.argv[2] === UNANSWERED_FLAG)
302
+ void answerUnanswered(process.argv.slice(3));