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
@@ -22,7 +22,6 @@ export async function cmdLogin(ctx) {
22
22
  ctx.out(` ⚠ ${url} does not accept the sign-in on its agent routes yet (401): steering, skills and event forwarding stay off with it.`);
23
23
  ctx.out(" while signed in, no organization key is used; to use one until Heedvane accepts the sign-in, delete ~/.heed/login.json and run npx heedvane auth org < key-file");
24
24
  }
25
- ctx.out(" a running listener picks it up when restarted: npx heedvane listener start");
26
25
  return 0;
27
26
  }
28
27
  catch (err) {
@@ -30,6 +29,16 @@ export async function cmdLogin(ctx) {
30
29
  return 1;
31
30
  }
32
31
  }
32
+ /** heed.yaml's Heedvane URL, or null after saying that heed.yaml does not parse. */
33
+ function serverUrl(root, out) {
34
+ try {
35
+ return heedvaneConfigAt(root).url;
36
+ }
37
+ catch (err) {
38
+ out(` ✗ ~/.heed/heed.yaml does not parse (${errorMessage(err)}); fix it, then run this again`);
39
+ return null;
40
+ }
41
+ }
33
42
  /** `npx heedvane auth org`: the key arrives on stdin, so it never lands in shell history. */
34
43
  export async function cmdAuth(ctx) {
35
44
  const kind = ctx.words[0];
@@ -43,7 +52,10 @@ export async function cmdAuth(ctx) {
43
52
  return 1;
44
53
  }
45
54
  const root = join(ctx.home, ".heed");
46
- await withSignInChange(root, heedvaneConfigAt(root).url, () => saveCredential(root, kind, key));
55
+ const url = serverUrl(root, ctx.out);
56
+ if (url === null)
57
+ return 1;
58
+ await withSignInChange(root, url, () => saveCredential(root, kind, key));
47
59
  ctx.out(` ✓ ${kind} key saved to ~/.heed/credentials.json (mode 0600)`);
48
60
  return 0;
49
61
  }
@@ -54,7 +66,10 @@ export async function cmdSkills(ctx) {
54
66
  return 2;
55
67
  }
56
68
  const root = join(ctx.home, ".heed");
57
- const backend = await backendAt(root, heedvaneConfigAt(root).url);
69
+ const url = serverUrl(root, ctx.out);
70
+ if (url === null)
71
+ return 1;
72
+ const backend = await backendAt(root, url);
58
73
  if (backend === null) {
59
74
  ctx.out(" ✗ not signed in — run: npx heedvane login (or, headless: npx heedvane auth org < key-file)");
60
75
  return 1;
@@ -0,0 +1 @@
1
+ export declare function cmdMcpStub(): Promise<number>;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `npx heedvane mcp`: 0.1.0 to 0.2.1 registered this local helper in agent configs, and npx now starts this version
3
+ * for them. It answers as an MCP server with no tools (newline-delimited JSON-RPC on stdio), so those agents still
4
+ * start cleanly, says on stderr how to connect, and ends when the agent closes stdin. `connect` replaces the entry with
5
+ * Heedvane's MCP connection, and `doctor` names it.
6
+ */
7
+ import { createInterface } from "node:readline";
8
+ import { isRecord } from "../guards.js";
9
+ const LATEST_PROTOCOL = "2025-06-18";
10
+ export async function cmdMcpStub() {
11
+ process.stderr.write("heedvane: this local MCP helper was replaced by Heedvane's MCP connection. Run: npx -y heedvane connect --org <organization id>\n");
12
+ for await (const line of createInterface({ input: process.stdin })) {
13
+ const answer = answerTo(line);
14
+ if (answer !== undefined)
15
+ process.stdout.write(`${JSON.stringify(answer)}\n`);
16
+ }
17
+ return 0;
18
+ }
19
+ /** The answer to one request; none for a notification or a line that is not a request. */
20
+ function answerTo(line) {
21
+ let request;
22
+ try {
23
+ request = JSON.parse(line);
24
+ }
25
+ catch {
26
+ return undefined; // not JSON-RPC: nothing to answer
27
+ }
28
+ if (!isRecord(request) || request["id"] === undefined)
29
+ return undefined;
30
+ const id = request["id"];
31
+ const params = isRecord(request["params"]) ? request["params"] : {};
32
+ switch (request["method"]) {
33
+ case "initialize": {
34
+ const protocolVersion = typeof params["protocolVersion"] === "string" ? params["protocolVersion"] : LATEST_PROTOCOL;
35
+ return { jsonrpc: "2.0", id, result: { protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "heedvane", version: "replaced" } } };
36
+ }
37
+ case "tools/list":
38
+ return { jsonrpc: "2.0", id, result: { tools: [] } };
39
+ case "ping":
40
+ return { jsonrpc: "2.0", id, result: {} };
41
+ default:
42
+ return { jsonrpc: "2.0", id, error: { code: -32601, message: "Method not found" } };
43
+ }
44
+ }
@@ -37,7 +37,12 @@ async function ask(ctx, request) {
37
37
  return 1;
38
38
  }
39
39
  const root = join(ctx.home, ".heed");
40
- const backend = await backendAt(root, heedvaneConfigAt(root).url);
40
+ const config = heedvaneConfigAt(root);
41
+ if (config.dataMode === "metrics-only") {
42
+ ctx.out(" ✗ team memory is off on this machine: heed.yaml heedvane.dataMode is metrics-only, so no question or repository leaves it");
43
+ return 1;
44
+ }
45
+ const backend = await backendAt(root, config.url);
41
46
  if (backend === null) {
42
47
  ctx.out(" ✗ not signed in — run: npx heedvane login (or, headless: npx heedvane auth org < key-file)");
43
48
  return 1;
@@ -7,22 +7,16 @@ import type { HeedGatingConfig } from "../heed-file.js";
7
7
  * configs, so policy updates rewrite only this file. Observe-only v1: every path
8
8
  * exits 0; the agent is never blocked and never sees stderr noise.
9
9
  *
10
- * Spool fallback (Orca's spool_hook_event): appends one JSON line to
11
- * <heed root>/spool/pane-<key>.jsonl, beside endpoint.env (HEED_AGENT_HOOK_ENDPOINT, else
12
- * $HEED_AGENT_HOOK_ROOT or ~/.heed), 5 MB cap per pane file, 7-day TTL via
13
- * `find -mtime`, 0600 perms, JSON-escaped envelope carrying
14
- * paneKey/tabId/worktreeId/launchToken/source/receivedAt/payload.
10
+ * Live path: curl POST to http://127.0.0.1:$HEED_AGENT_HOOK_PORT/hook/<agent> with X-Heed-Agent-Hook-Token and
11
+ * X-Heed-Hook-Version: 1, within the timeouts of src/hook-fallback.ts. The listener answers from this machine alone;
12
+ * only a session start waits longer, for the session's one fetch of the steering policy.
15
13
  *
16
- * Live path: curl POST to http://127.0.0.1:$HEED_AGENT_HOOK_PORT/hook/<agent> with
17
- * X-Heed-Agent-Hook-Token and X-Heed-Hook-Version: 1, 0.5s connect / 0.8s max, falling back to spool
18
- * on any failure. The listener answers from this machine alone; only a session start waits (2s max)
19
- * for the session's one fetch of the steering policy.
20
- *
21
- * Start on the fly: when nothing listens on the port (curl exit 7, connection refused), the event is
22
- * spooled and `npx -y heedvane listener start` is started in the background, at most once a minute
23
- * (a `listener.starting` lock directory older than a minute is cleared). The listener replays the
24
- * spool when it starts, so steering comes back by itself after a restart; no login service is needed.
25
- * `HEED_LISTENER_AUTOSTART=0` in the agent's environment turns this off.
14
+ * Fallback: when the listener gives no answer (no endpoint, nothing listens, it times out, or it answers an error),
15
+ * the script runs Heedvane's one fallback, `fallback.mjs` beside it (src/guard.ts), with the Node that installed the
16
+ * hooks (named in `node` beside it), or with node on PATH once that one is gone. That keeps the event in the spool beside endpoint.env for the listener to replay, starts
17
+ * `npx -y heedvane listener start` (which decides itself whether a listener must start or take over), and adds the
18
+ * notice the first time in a session. When the fallback itself fails, the agent still gets the fallback answer.
19
+ * `HEED_LISTENER_AUTOSTART=0` in the agent's environment turns the start off.
26
20
  */
27
21
  export interface DispatcherOptions {
28
22
  /** heed.yaml `heedvane.skills.whenUnknown: deny`: with no listener to check it, deny a skill use. */
@@ -30,12 +24,15 @@ export interface DispatcherOptions {
30
24
  /** heed.yaml `gating.mode`: `pane` (the default) runs the hooks only in sessions launched with
31
25
  * HEED_SESSION set; `always` runs them in every session. */
32
26
  gating?: HeedGatingConfig["mode"];
33
- /** The folder holding node and npx at install time, appended to PATH for the start on the fly:
34
- * an agent launched from a desktop app may run its hooks without them on PATH. */
35
- nodeDir?: string;
36
27
  }
37
28
  export declare function dispatcherScript(agentId: string, options?: DispatcherOptions): string;
38
29
  /** The on-disk dispatcher directory under the heed root. */
39
30
  export declare function agentHooksDir(home: string): string;
40
- /** Write (or update) the dispatcher script for one agent. Idempotent, mode 0700. */
31
+ /** Write (or update) the dispatcher script for one agent, mode 0700, and the fallback it runs beside it. Idempotent. */
41
32
  export declare function writeDispatcherScript(home: string, agent: string, options?: DispatcherOptions): Promise<void>;
33
+ /**
34
+ * Names the Node running this install in `node` beside the dispatchers, for every hook's fallback (the dispatchers' and
35
+ * Hermes'). It is the PATH entry that links to that Node when there is one: Homebrew's /opt/homebrew/bin/node outlives
36
+ * `brew upgrade`, the Cellar path it links to does not. Git Bash on Windows runs C:/… paths, so no backslashes.
37
+ */
38
+ export declare function writeHookNode(home: string, path?: string): Promise<void>;
@@ -1,6 +1,10 @@
1
+ import { realpathSync } from "node:fs";
1
2
  import { chmod, mkdir, readFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
+ import { basename, delimiter, join } from "node:path";
3
4
  import writeFileAtomic from "write-file-atomic";
5
+ import { guardSource } from "../clients/pi-extension.js";
6
+ import { FALLBACK_FILE, HOOK_CONNECT_TIMEOUT_MS, HOOK_NODE_FILE, HOOK_TIMEOUT_MS, PROOF_KEY, RETRY_AFTER_MS, SESSION_START_TIMEOUT_MS, UNANSWERED_FLAG } from "../hook-fallback.js";
7
+ import { USER_NOTICE_FIELD } from "../reply/harness-output.js";
4
8
  import { validateAgentId } from "./snippets.js";
5
9
  /** The stdout a Claude Code PreToolUse hook prints to deny (src/reply/harness-output.ts owns the shape). */
6
10
  const UNCHECKED_SKILL_DENY = JSON.stringify({
@@ -15,7 +19,7 @@ export function dispatcherScript(agentId, options = {}) {
15
19
  // Only Claude Code's deny shape is verified; other agents keep the fail-open fallback.
16
20
  const denySkills = options.denySkillsWhenUnchecked === true && agentId === "claude";
17
21
  const gating = options.gating ?? "always";
18
- const nodeDir = (options.nodeDir ?? "").replaceAll("'", "'\\''");
22
+ const noticeField = USER_NOTICE_FIELD[agentId];
19
23
  return `#!/bin/sh
20
24
  # heedvane-agent-hook dispatcher for ${agentId} — generated by heedvane-hook. Do not hand-edit.
21
25
  # Rewritten wholesale by \`npx heedvane setup\`; policy updates never touch agent configs.
@@ -28,9 +32,6 @@ umask 077
28
32
 
29
33
  HEED_HOOK_AGENT="${agentId}"
30
34
  HEED_HOOK_VERSION=1
31
- HEED_HOOK_SPOOL_CAP=5242880
32
- HEED_HOOK_SPOOL_TTL_DAYS=7
33
- HEED_HOOK_NODE_DIR='${nodeDir}'
34
35
 
35
36
  [ -n "\${HOME:-}" ] || exit 0
36
37
 
@@ -42,70 +43,21 @@ HEED_HOOK_GATING="${gating}"
42
43
  # endpoint.env (written by init/setup/listener start) sits in the heed root; the spool beside it.
43
44
  HEED_HOOK_ENDPOINT_FILE="\${HEED_AGENT_HOOK_ENDPOINT:-\${HEED_AGENT_HOOK_ROOT:-$HOME/.heed}/endpoint.env}"
44
45
 
45
- heed_spool_dir() {
46
- printf '%s\\n' "\${HEED_HOOK_ENDPOINT_FILE%/*}/spool"
47
- }
48
-
49
- # Escape a string for embedding inside a JSON document (POSIX awk).
50
- # Encodes backslash, double quote, tab and CR; newlines are re-encoded per record.
51
- # Other C0 control chars survive as-is — payloads are harness JSON, and v1
52
- # treats a best-effort escape as acceptable for spool records.
53
- json_escape() {
54
- printf '%s' "$1" | LC_ALL=C awk '
55
- NR > 1 { printf "\\\\n" }
56
- {
57
- gsub(/\\\\/, "\\\\\\\\")
58
- gsub(/"/, "\\\\\\"")
59
- gsub(/\\t/, "\\\\t")
60
- gsub(/\\r/, "\\\\r")
61
- printf "%s", $0
62
- }'
63
- }
64
-
65
- spool_hook_event() {
66
- _body="\${1:-}"
67
- [ -n "$_body" ] || return 0
68
- _dir="$(heed_spool_dir)"
69
- mkdir -p "$_dir" 2>/dev/null || return 0
70
- _key="\${HEED_PANE_KEY:-\${HEED_SESSION:-}}"
71
- [ -n "$_key" ] || _key="default"
72
- _file="$_dir/pane-$_key.jsonl"
73
-
74
- # 7-day TTL: prune stale pane spool files, and the events set aside after a change of sign-in (other-sign-in/), on every write (cheap).
75
- find "$_dir" -type f \\( -name 'pane-*.jsonl*' -o -path '*/other-sign-in/*' \\) -mtime "+$HEED_HOOK_SPOOL_TTL_DAYS" -delete 2>/dev/null || :
76
-
77
- # 5 MB cap: once a pane file is over budget, drop further events (never grow unbounded).
78
- if [ -f "$_file" ]; then
79
- _size="$(wc -c < "$_file" 2>/dev/null || echo 0)"
80
- [ "$_size" -lt "$HEED_HOOK_SPOOL_CAP" ] || return 0
81
- fi
82
-
83
- _received="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u)"
84
- _envelope="{\\"paneKey\\":\\"$(json_escape "\${HEED_PANE_KEY:-\${HEED_SESSION:-}}")\\""
85
- _envelope="$_envelope,\\"tabId\\":\\"$(json_escape "\${HEED_TAB_ID:-}")\\""
86
- _envelope="$_envelope,\\"worktreeId\\":\\"$(json_escape "\${HEED_WORKTREE_ID:-}")\\""
87
- _envelope="$_envelope,\\"launchToken\\":\\"$(json_escape "\${HEED_LAUNCH_TOKEN:-}")\\""
88
- _envelope="$_envelope,\\"source\\":\\"$(json_escape "$HEED_HOOK_AGENT")\\""
89
- _envelope="$_envelope,\\"receivedAt\\":\\"$(json_escape "$_received")\\""
90
- _envelope="$_envelope,\\"id\\":\\"$(json_escape "\${heed_event_id:-}")\\""
91
- _envelope="$_envelope,\\"payload\\":\\"$(json_escape "$_body")\\"}"
92
-
93
- printf '%s\\n' "$_envelope" >> "$_file" 2>/dev/null || return 0
94
- chmod 600 "$_file" 2>/dev/null || :
95
- return 0
96
- }
97
-
98
- # Nothing listens (connection refused): start the listener in the background, at most one try a
99
- # minute, so steering comes back by itself after a restart. It replays the spooled event at its start.
100
- heed_start_listener() {
101
- [ "\${HEED_LISTENER_AUTOSTART:-1}" != "0" ] || return 0
102
- _root="\${HEED_HOOK_ENDPOINT_FILE%/*}"
103
- case "$_root" in */.heed) ;; *) return 0 ;; esac
104
- _lock="$_root/listener.starting"
105
- find "$_lock" -maxdepth 0 -mmin +1 -exec rmdir {} \\; 2>/dev/null || :
106
- mkdir "$_lock" 2>/dev/null || return 0
107
- (PATH="$PATH:$HEED_HOOK_NODE_DIR" HEED_HOME="\${_root%/.heed}" nohup npx -y heedvane listener start < /dev/null >> "$_root/listener.log" 2>&1 &) 2>/dev/null || :
108
- return 0
46
+ # The listener gave no answer: Heedvane's one fallback keeps the event and adds the notice once a session (see the
47
+ # header of src/dispatchers/posix.ts). Its own failure never blocks the agent: the answer is then $heed_fallback.
48
+ heed_unanswered() {
49
+ # The Node that last installed the hooks, by its PATH link and then itself; node on PATH once both are gone (brew upgrade, nvm uninstall).
50
+ heed_node=""
51
+ { while IFS= read -r heed_candidate || [ -n "$heed_candidate" ]; do
52
+ if [ -x "$heed_candidate" ]; then heed_node="$heed_candidate"; break; fi
53
+ done < "\${0%/*}/${HOOK_NODE_FILE}"; } 2>/dev/null || :
54
+ [ -n "$heed_node" ] || heed_node="$(command -v node 2>/dev/null)" || :
55
+ # The agent's NODE_OPTIONS are its project's (a --require of its own code), not the fallback's.
56
+ _answer="$(printf '%s' "$payload" | NODE_OPTIONS='' HEED_HOOK_FALLBACK_ANSWER="$heed_fallback" HEED_HOOK_EVENT_ID="\${heed_event_id:-}" "$heed_node" "\${0%/*}/${FALLBACK_FILE}" ${UNANSWERED_FLAG} "$HEED_HOOK_AGENT"${noticeField === undefined ? "" : ` ${noticeField}`} 2>/dev/null)" || :
57
+ case "$_answer" in
58
+ "{"*) heed_answer "$_answer" ;;
59
+ esac
60
+ heed_answer "$heed_fallback"
109
61
  }
110
62
 
111
63
  # The hook payload arrives on stdin. command -p keeps cat working under a stripped PATH.
@@ -142,36 +94,40 @@ heed_answer() {
142
94
 
143
95
  [ -n "$payload" ] || heed_answer '{}'
144
96
 
145
- # Port/token come from endpoint.env (HEED_AGENT_HOOK_PORT / HEED_AGENT_HOOK_TOKEN lines).
97
+ # Port/token: from the environment when both are set, else from endpoint.env (HEED_AGENT_HOOK_PORT / HEED_AGENT_HOOK_TOKEN lines).
146
98
  # shellcheck disable=SC1090
147
- if [ -r "$HEED_HOOK_ENDPOINT_FILE" ]; then
99
+ if { [ -z "\${HEED_AGENT_HOOK_PORT:-}" ] || [ -z "\${HEED_AGENT_HOOK_TOKEN:-}" ]; } && [ -r "$HEED_HOOK_ENDPOINT_FILE" ]; then
148
100
  . "$HEED_HOOK_ENDPOINT_FILE" 2>/dev/null || :
149
101
  fi
150
102
 
151
- # No listener contract (endpoint env, port, token): spool instead, still exit 0.
103
+ # No listener contract (endpoint env, port, token): the fallback, still exit 0.
152
104
  if [ -z "\${HEED_AGENT_HOOK_PORT:-}" ] || [ -z "\${HEED_AGENT_HOOK_TOKEN:-}" ]; then
153
- spool_hook_event "$payload"
154
- heed_answer "$heed_fallback"
105
+ heed_unanswered
155
106
  fi
156
107
 
157
108
  # A local answer takes milliseconds. Only a session start waits for Heedvane (its one policy fetch).
158
- heed_max_time=0.8
109
+ heed_max_time=${HOOK_TIMEOUT_MS / 1000}
159
110
  case "$payload" in
160
- *'"hook_event_name":"SessionStart"'*|*'"hook_event_name": "SessionStart"'*) heed_max_time=2 ;;
111
+ *'"hook_event_name":"SessionStart"'*|*'"hook_event_name": "SessionStart"'*) heed_max_time=${SESSION_START_TIMEOUT_MS / 1000} ;;
161
112
  esac
162
113
 
163
- reply="$(printf '%s' "$payload" | curl -sS --fail -X POST "http://127.0.0.1:\${HEED_AGENT_HOOK_PORT}/hook/${agentId}" \\
164
- --connect-timeout 0.5 --max-time "$heed_max_time" --noproxy 127.0.0.1 \\
114
+ reply="$(printf '%s' "$payload" | curl -sS --fail -D - -X POST "http://127.0.0.1:\${HEED_AGENT_HOOK_PORT}/hook/${agentId}" \\
115
+ --connect-timeout ${HOOK_CONNECT_TIMEOUT_MS / 1000} --max-time "$heed_max_time" --noproxy 127.0.0.1 \\
116
+ -H "Expect:" \\
165
117
  -H "Content-Type: application/json" \\
166
118
  -H "X-Heed-Agent-Hook-Token: \${HEED_AGENT_HOOK_TOKEN}" \\
167
119
  -H "X-Heed-Hook-Version: \${HEED_HOOK_VERSION}" \\
168
120
  -H "X-Heed-Event-Id: \${heed_event_id:-}" \\
169
- --data-binary @- 2>/dev/null)" || {
170
- heed_curl_exit=$?
171
- spool_hook_event "$payload"
172
- [ "$heed_curl_exit" -ne 7 ] || heed_start_listener
173
- heed_answer "$heed_fallback"
174
- }
121
+ --data-binary @- 2>/dev/null)" || heed_unanswered
122
+
123
+ # Only this machine's listener names the proof from endpoint.env (0.3.4 on); any other answer goes to the fallback, which
124
+ # keeps the event. A port and token pinned in the environment come without the proof line, unless ${PROOF_KEY} is pinned
125
+ # too: the fallback derives it from the token.
126
+ # shortcut: pinned without ${PROOF_KEY}, every hook pays a Node start and the fallback's ${RETRY_AFTER_MS} ms pause before it asks again (sh has no HMAC that keeps the token off argv); derive the proof here if such setups show up.
127
+ reply="$(printf '%s\\n' "$reply" | tr -d '\\r')"
128
+ heed_proof="$(printf '%s\\n' "$reply" | sed -n '/^$/q; s/^[Xx]-[Hh][Ee][Ee][Dd]-[Ll][Ii][Ss][Tt][Ee][Nn][Ee][Rr]: //p')"
129
+ [ -n "\${${PROOF_KEY}:-}" ] && [ "$heed_proof" = "\$${PROOF_KEY}" ] || heed_unanswered
130
+ reply="$(printf '%s\\n' "$reply" | sed '1,/^$/d')"
175
131
 
176
132
  # The listener's answer is the agent's hook output (skill check, steering, recalled memory).
177
133
  case "$reply" in
@@ -184,21 +140,45 @@ esac
184
140
  export function agentHooksDir(home) {
185
141
  return join(home, ".heed", "agent-hooks");
186
142
  }
187
- /** Write (or update) the dispatcher script for one agent. Idempotent, mode 0700. */
143
+ /** Write (or update) the dispatcher script for one agent, mode 0700, and the fallback it runs beside it. Idempotent. */
188
144
  export async function writeDispatcherScript(home, agent, options = {}) {
189
145
  validateAgentId(agent);
190
146
  const dir = agentHooksDir(home);
191
147
  await mkdir(dir, { recursive: true, mode: 0o700 });
192
- const target = join(dir, `${agent}-hook.sh`);
193
- const content = dispatcherScript(agent, options);
194
- const existing = await currentDispatcherScript(target);
195
- if (existing === content)
148
+ await writeWhenChanged(join(dir, `${agent}-hook.sh`), dispatcherScript(agent, options));
149
+ await Promise.resolve()
150
+ .then(() => writeWhenChanged(join(dir, FALLBACK_FILE), guardSource()))
151
+ .catch(() => undefined); // without its fallback a dispatcher still answers, with the plain fallback answer
152
+ }
153
+ /**
154
+ * Names the Node running this install in `node` beside the dispatchers, for every hook's fallback (the dispatchers' and
155
+ * Hermes'). It is the PATH entry that links to that Node when there is one: Homebrew's /opt/homebrew/bin/node outlives
156
+ * `brew upgrade`, the Cellar path it links to does not. Git Bash on Windows runs C:/… paths, so no backslashes.
157
+ */
158
+ export async function writeHookNode(home, path = process.env["PATH"] ?? "") {
159
+ const real = realpathOrNull(process.execPath);
160
+ const linked = path.split(delimiter).map((dir) => join(dir, basename(process.execPath))).find((candidate) => real !== null && realpathOrNull(candidate) === real);
161
+ // The link first, as it outlives `brew upgrade`; then the Node itself, which outlives a per-shell link (fnm's ends with its shell).
162
+ const nodes = [...new Set([linked, real ?? process.execPath])].filter((node) => node !== undefined);
163
+ await mkdir(agentHooksDir(home), { recursive: true, mode: 0o700 });
164
+ await writeWhenChanged(join(agentHooksDir(home), HOOK_NODE_FILE), nodes.map((node) => `${node.replaceAll("\\", "/")}\n`).join(""));
165
+ }
166
+ function realpathOrNull(path) {
167
+ try {
168
+ return realpathSync(path);
169
+ }
170
+ catch {
171
+ return null; // not there: not the Node that runs this
172
+ }
173
+ }
174
+ async function writeWhenChanged(target, content) {
175
+ if ((await currentText(target)) === content)
196
176
  return; // already current
197
177
  await writeFileAtomic(target, content, { mode: 0o700 });
198
178
  await chmod(target, 0o700);
199
179
  }
200
- /** The dispatcher script's current text, or null when absent (it will be written below). */
201
- async function currentDispatcherScript(target) {
180
+ /** The file's current text, or null when absent (it will be written). */
181
+ async function currentText(target) {
202
182
  try {
203
183
  return await readFile(target, "utf8");
204
184
  }
package/dist/doctor.d.ts CHANGED
@@ -34,4 +34,6 @@ export interface DoctorInput {
34
34
  /** Desired-entry lookup injection; defaults to the pure planner. */
35
35
  desired?: DesiredEntryLookup;
36
36
  }
37
+ /** 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. */
38
+ export declare const NODE_FLOOR = "22.5";
37
39
  export declare function runDoctor(input: DoctorInput): Promise<DoctorReport>;