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.
- package/README.md +60 -13
- 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 +4 -2
- package/dist/cli.js +389 -144
- package/dist/clients/definitions.d.ts +8 -4
- package/dist/clients/definitions.js +48 -13
- 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/commands/memory.js +6 -1
- 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 +113 -29
- package/dist/guard.d.ts +73 -0
- package/dist/guard.js +302 -0
- package/dist/heed-file.d.ts +19 -3
- package/dist/heed-file.js +46 -23
- package/dist/hook-fallback.d.ts +73 -0
- package/dist/hook-fallback.js +61 -0
- package/dist/installer.d.ts +13 -6
- package/dist/installer.js +40 -13
- 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 +38 -48
- package/dist/mcp.js +196 -63
- package/dist/plan.d.ts +47 -8
- package/dist/plan.js +164 -52
- package/dist/render.js +8 -8
- package/dist/reply/harness-output.d.ts +9 -0
- package/dist/reply/harness-output.js +13 -0
- package/dist/reply/reply.js +2 -1
- package/dist/skill/content.d.ts +2 -2
- package/dist/skill/content.js +11 -8
- package/dist/status.d.ts +1 -0
- package/dist/status.js +29 -0
- package/dist/steering/rules.js +4 -2
- package/dist/uninstall.d.ts +7 -0
- package/dist/uninstall.js +72 -71
- package/package.json +10 -3
- package/skills/heedvane-hooks/SKILL.md +6 -6
- package/skills/heedvane-hooks/references/events.md +5 -2
- package/dist/dispatchers/windows.d.ts +0 -15
- package/dist/dispatchers/windows.js +0 -50
- package/dist/mcp-server.d.ts +0 -9
- 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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/commands/memory.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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>;
|