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,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>;
package/dist/doctor.js CHANGED
@@ -3,18 +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, heedvaneConnector, hooksDeferredTo, isClientDetected, legacyMcpEntries } from "./plan.js";
12
- import { MCP_NEEDS_ORG, connectorEntry } from "./mcp.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";
13
13
  import { readInstalledSkillDigest } from "./skill/bundle.js";
14
14
  import { HeedStateCorruptError, readState } from "./state.js";
15
15
  import { readApprovedSkills } from "./skills/approved.js";
16
- 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";
17
18
  import { errorMessage } from "./guards.js";
19
+ import { HOOK_NODE_FILE } from "./hook-fallback.js";
18
20
  const INIT_REMEDIATION = "run npx heedvane init";
19
21
  const SETUP_REMEDIATION = "run npx heedvane setup";
20
22
  /** Inspect one desired entry on disk; every failure path produces a detail line, never a throw. */
@@ -23,6 +25,11 @@ function entryVerdict(d, home) {
23
25
  if (!existsSync(path)) {
24
26
  return { aligned: false, present: false, detail: `${d.entryName}: missing (${d.path})` };
25
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
+ }
26
33
  let doc;
27
34
  try {
28
35
  doc = parseDoc(readFileSync(path, "utf8"), d.format);
@@ -30,16 +37,11 @@ function entryVerdict(d, home) {
30
37
  catch {
31
38
  return { aligned: false, present: true, detail: `${d.entryName}: config does not parse` };
32
39
  }
33
- if (d.containerKey === "") {
34
- // Whole-file desired (hermes plugin.yaml).
35
- const ok = readFileSync(path, "utf8") === d.entry;
36
- return { aligned: ok, present: true, detail: `${d.entryName}: ${ok ? "aligned" : "stale"}` };
37
- }
38
40
  const current = readEntry(doc, d.containerKey, d.entryName);
39
41
  if (current === undefined) {
40
42
  return { aligned: false, present: true, detail: `${d.entryName}: missing in ${d.path}` };
41
43
  }
42
- if (!valuesEqual(current, entryValue(d.containerKey, current, d.entry))) {
44
+ if (!valuesEqual(comparableEntry(current), comparableEntry(entryValue(d.containerKey, current, d.entry)))) {
43
45
  return { aligned: false, present: true, detail: `${d.entryName}: stale in ${d.path}` };
44
46
  }
45
47
  return { aligned: true, present: true, detail: `${d.entryName}: aligned` };
@@ -62,14 +64,17 @@ function registrationState(desireds, home) {
62
64
  return { state: "stale", detail: parts.join("; ") };
63
65
  return { state: "aligned", detail: parts.join("; ") };
64
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";
65
69
  function checkNode() {
66
- const major = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
67
- 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);
68
73
  return {
69
74
  ok,
70
75
  name: "node",
71
- detail: `node ${process.versions.node} (${ok ? ">= 22" : "< 22 — heedvane requires Node 22+"})`,
72
- 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})`,
73
78
  };
74
79
  }
75
80
  function checkHeedFile(home) {
@@ -117,7 +122,7 @@ function defaultDesiredLookup(home, env, heedFile) {
117
122
  const client = supportedClients({ home, platform: process.platform, env }).find((c) => c.id === clientId);
118
123
  if (!client)
119
124
  return undefined;
120
- return desiredEntriesFor(client, capability, { home, env, connector }).map((d) => ({
125
+ return desiredEntriesFor(client, capability, { home, env, connector, nodeDir: bridgeNodeDir(process.platform), gating: heedFile?.gating.mode }).map((d) => ({
121
126
  path: d.path,
122
127
  format: d.format,
123
128
  containerKey: d.containerKey,
@@ -159,11 +164,11 @@ function heedFileWants(client, capability, ctx) {
159
164
  function collectCapabilityStates(client, ctx) {
160
165
  const states = [];
161
166
  for (const capability of ["hooks", "mcp"]) {
162
- const supported = client.capabilities.includes(capability) || (capability === "hooks" && client.id === "hermes" && client.capabilities.includes("plugin-install"));
167
+ const supported = supportsCapability(client, capability);
163
168
  if (!supported || !heedFileWants(client, capability, ctx))
164
169
  continue;
165
170
  const desireds = ctx.desired(client.id, capability) ?? [];
166
- const result = capability === "mcp" ? mcpConnectionState(client, desireds, ctx.home) : hooksState(desireds, ctx.home);
171
+ const result = capability === "mcp" ? mcpConnectionState(client, desireds, ctx) : hooksState(desireds, ctx.home);
167
172
  if (result !== undefined)
168
173
  states.push({ capability, result });
169
174
  }
@@ -174,14 +179,28 @@ function hooksState(desireds, home) {
174
179
  return desireds.length > 0 ? registrationState(desireds, home) : undefined;
175
180
  }
176
181
  /** Heedvane's MCP connection: stale while an earlier version's entry is left, missing without an organization, else the registration verdict. */
177
- function mcpConnectionState(client, desireds, home) {
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
+ }
178
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 };
179
190
  if (legacy !== undefined)
180
- return { state: "stale", detail: `${legacy.names.join(", ")} in ${legacy.path} is an earlier version's entry; npx heedvane connect replaces it` };
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);
181
194
  if (desireds.length > 0)
182
- return registrationState(desireds, home);
195
+ return withKeyNote(registrationState(desireds, home), desireds.some((desired) => valuesEqual(desired.entry, keyForm)), env);
183
196
  // Agents the connection is not registered in have nothing to check.
184
- return connectorEntry(client.id, "") === undefined ? undefined : { state: "missing", detail: MCP_NEEDS_ORG };
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` };
185
204
  }
186
205
  /** One detected client's registration check (aligned / stale / missing with per-entry detail). */
187
206
  function pushRegistrationChecks(checks, client, ctx) {
@@ -205,11 +224,12 @@ function pushRegistrationChecks(checks, client, ctx) {
205
224
  return;
206
225
  const allAligned = states.every((s) => s.result.state === "aligned");
207
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));
208
228
  checks.push({
209
229
  ok: allAligned,
210
230
  name: `${client.id} registration`,
211
231
  detail,
212
- remediation: allAligned ? undefined : SETUP_REMEDIATION,
232
+ remediation: allAligned ? undefined : [...remedies].join("; "),
213
233
  });
214
234
  }
215
235
  /** The skill-digest verdict against state.json (aligned / stale / missing / unrecorded). */
@@ -266,6 +286,7 @@ export async function runDoctor(input) {
266
286
  }
267
287
  // 7. Listener reachable — best-effort POST, warn-only, never fails the report.
268
288
  checks.push(await checkListener(root));
289
+ checks.push(checkHookNode(root));
269
290
  checks.push(await checkApprovedSkills(root, heedFile));
270
291
  const healthy = checks.every((c) => c.ok || c.warnOnly);
271
292
  return { healthy, checks, scopedTo: scope ? [...scope] : undefined };
@@ -292,6 +313,42 @@ async function checkApprovedSkills(root, heedFile) {
292
313
  return { ...base, ok: false, detail: `${list.skills.length} approved, synced ${list.syncedAt}: revocations since then are not enforced`, remediation: sync };
293
314
  return { ...base, detail: `${list.skills.length} approved, synced ${list.syncedAt}` };
294
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
+ }
295
352
  async function checkListener(root) {
296
353
  const base = { ok: true, name: "listener", detail: "", warnOnly: true };
297
354
  let endpoint;
@@ -310,10 +367,21 @@ async function checkListener(root) {
310
367
  if (!endpoint.port) {
311
368
  return { ...base, detail: "endpoint.env incomplete (no port) — re-run npx heedvane setup" };
312
369
  }
313
- if (await pingListener(endpoint))
314
- return { ...base, detail: `reachable on 127.0.0.1:${endpoint.port}` };
315
- return {
316
- ...base,
317
- detail: `not reachable on 127.0.0.1:${endpoint.port} — ${ENFORCEMENT_OFF}; events will spool (warn-only; never fails the report)`,
318
- };
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
+ }
319
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 {};