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.
- package/README.md +41 -9
- package/dist/backend/credentials.d.ts +2 -0
- package/dist/backend/credentials.js +13 -0
- package/dist/backend/repo-remote.js +2 -1
- package/dist/backend/sign-in-change.js +3 -2
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +325 -118
- package/dist/clients/definitions.d.ts +7 -3
- package/dist/clients/definitions.js +27 -7
- package/dist/clients/hermes-plugin.d.ts +5 -20
- package/dist/clients/hermes-plugin.js +97 -71
- package/dist/clients/hook-files.d.ts +22 -0
- package/dist/clients/hook-files.js +39 -0
- package/dist/clients/pi-extension.d.ts +9 -0
- package/dist/clients/pi-extension.js +41 -0
- package/dist/commands/heedvane.js +18 -3
- package/dist/commands/mcp-stub.d.ts +1 -0
- package/dist/commands/mcp-stub.js +44 -0
- package/dist/dispatchers/posix.d.ts +16 -19
- package/dist/dispatchers/posix.js +71 -91
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +96 -28
- package/dist/guard.d.ts +73 -0
- package/dist/guard.js +302 -0
- package/dist/heed-file.d.ts +5 -2
- package/dist/heed-file.js +7 -7
- package/dist/hook-fallback.d.ts +73 -0
- package/dist/hook-fallback.js +61 -0
- package/dist/installer.d.ts +5 -5
- package/dist/installer.js +13 -12
- package/dist/listener/background.d.ts +48 -4
- package/dist/listener/background.js +153 -19
- package/dist/listener/endpoint.d.ts +26 -3
- package/dist/listener/endpoint.js +55 -10
- package/dist/listener/forward.d.ts +7 -1
- package/dist/listener/forward.js +40 -12
- package/dist/listener/serve.d.ts +37 -0
- package/dist/listener/serve.js +196 -32
- package/dist/listener/server.d.ts +9 -0
- package/dist/listener/server.js +59 -22
- package/dist/listener/spool.d.ts +2 -17
- package/dist/listener/spool.js +6 -3
- package/dist/mcp.d.ts +23 -4
- package/dist/mcp.js +124 -22
- package/dist/plan.d.ts +35 -6
- package/dist/plan.js +125 -40
- package/dist/render.js +2 -1
- package/dist/reply/harness-output.d.ts +9 -0
- package/dist/reply/harness-output.js +13 -0
- package/dist/skill/content.d.ts +2 -2
- package/dist/skill/content.js +6 -3
- package/dist/status.js +1 -1
- package/dist/steering/rules.js +4 -2
- package/dist/uninstall.d.ts +7 -2
- package/dist/uninstall.js +49 -44
- package/package.json +10 -3
- package/skills/heedvane-hooks/SKILL.md +3 -3
- package/skills/heedvane-hooks/references/events.md +3 -0
- package/dist/dispatchers/windows.d.ts +0 -15
- 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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* on
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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):
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
201
|
-
async function
|
|
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 {
|
|
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 =
|
|
67
|
-
const
|
|
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 ?
|
|
72
|
-
remediation: ok ? undefined :
|
|
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
|
|
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
|
|
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 :
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
}
|
package/dist/guard.d.ts
ADDED
|
@@ -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 {};
|