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
|
@@ -11,15 +11,16 @@
|
|
|
11
11
|
* This module imports nothing outside `node:path` — the package must stay
|
|
12
12
|
* standalone-publishable (no intra-monorepo imports, no Effect).
|
|
13
13
|
*/
|
|
14
|
+
import type { HookFiles } from "./hook-files.js";
|
|
14
15
|
/** Env passed to resolvers: { home, platform, env } — pure, testable. */
|
|
15
16
|
export type Env = Record<string, string | undefined>;
|
|
16
17
|
/** Config document dialects the package knows how to parse and patch. */
|
|
17
18
|
export type ClientFormat = "json" | "toml" | "jsonc" | "opencode" | "vscode" | "yaml";
|
|
18
|
-
/** Integration channels per client.
|
|
19
|
-
export type Capability = "hooks" | "mcp" | "skill"
|
|
19
|
+
/** Integration channels per client. Hooks written as whole files (Hermes' plugin, Pi's extension) are `hookFiles`. */
|
|
20
|
+
export type Capability = "hooks" | "mcp" | "skill";
|
|
20
21
|
/** What a config target is written for. Skill bundles install to the universal
|
|
21
22
|
* `~/.agents/skills/` directory and therefore carry no per-client target. */
|
|
22
|
-
export type ConfigPurpose = "hooks" | "mcp"
|
|
23
|
+
export type ConfigPurpose = "hooks" | "mcp";
|
|
23
24
|
/** Platforms whose path semantics the resolver implements. */
|
|
24
25
|
export type Platform = NodeJS.Platform;
|
|
25
26
|
/** Pure resolution context — callers pass fixture/real values explicitly. */
|
|
@@ -82,6 +83,8 @@ export interface ClientDef {
|
|
|
82
83
|
readonly hooksVersion?: number;
|
|
83
84
|
/** Another client whose installed hooks this one also runs; when both are managed, only that client's are installed. */
|
|
84
85
|
readonly runsHooksOf?: string;
|
|
86
|
+
/** Hooks written as whole files Heedvane owns in the client's folder (Hermes' plugin, Pi's extension), not config entries. */
|
|
87
|
+
readonly hookFiles?: HookFiles;
|
|
85
88
|
/** Additional detection markers (e.g. GUI ADE app-support directory when CLI and GUI share configs). */
|
|
86
89
|
readonly altMarkers?: readonly {
|
|
87
90
|
readonly segments: readonly string[];
|
|
@@ -105,6 +108,7 @@ export interface ResolvedClient {
|
|
|
105
108
|
readonly hookEntries?: HookEntryShape;
|
|
106
109
|
readonly hooksVersion?: number;
|
|
107
110
|
readonly runsHooksOf?: string;
|
|
111
|
+
readonly hookFiles?: HookFiles;
|
|
108
112
|
}
|
|
109
113
|
/**
|
|
110
114
|
* Resolve every client definition into absolute marker/config paths for the
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import { posix, win32 } from "node:path";
|
|
15
15
|
// Single owner of the canonical taxonomy: src/events/contract.ts.
|
|
16
16
|
import { CANONICAL_EVENTS } from "../events/contract.js";
|
|
17
|
+
import { HERMES_PLUGIN } from "./hermes-plugin.js";
|
|
18
|
+
import { PI_EXTENSION } from "./pi-extension.js";
|
|
17
19
|
/** Harness-neutral canonical taxonomy (v1). Native sets normalize into this.
|
|
18
20
|
* Single owner: src/events/contract.ts — re-exported here for the table. */
|
|
19
21
|
/** Claude Code's 14 native hook events (verified against its hook contract). */
|
|
@@ -188,10 +190,9 @@ const SUPPORTED_CLIENTS = [
|
|
|
188
190
|
displayName: "Hermes",
|
|
189
191
|
markerSegments: [".hermes"],
|
|
190
192
|
base: "home",
|
|
191
|
-
configs: [
|
|
192
|
-
|
|
193
|
-
],
|
|
194
|
-
capabilities: ["plugin-install", "skill"],
|
|
193
|
+
configs: [],
|
|
194
|
+
hookFiles: HERMES_PLUGIN,
|
|
195
|
+
capabilities: ["skill"],
|
|
195
196
|
events: [
|
|
196
197
|
"on_session_start",
|
|
197
198
|
"pre_llm_call",
|
|
@@ -205,6 +206,20 @@ const SUPPORTED_CLIENTS = [
|
|
|
205
206
|
mcpServersKey: "mcpServers",
|
|
206
207
|
notes: "Hermes integrates via a plugin install (~/.hermes/plugins/heedvane-hooks) using its native hook API — not a hooks.json/config.toml write.",
|
|
207
208
|
},
|
|
209
|
+
{
|
|
210
|
+
// Pi 1.x (pi.dev): its agent folder holds mcp.json and the extensions it loads; PI_CODING_AGENT_DIR replaces the folder.
|
|
211
|
+
id: "pi",
|
|
212
|
+
displayName: "Pi",
|
|
213
|
+
markerSegments: [".pi", "agent"],
|
|
214
|
+
base: "home",
|
|
215
|
+
homeOverrideEnv: "PI_CODING_AGENT_DIR",
|
|
216
|
+
configs: [{ segments: ["mcp.json"], base: "client", format: "json", purpose: "mcp" }],
|
|
217
|
+
hookFiles: PI_EXTENSION,
|
|
218
|
+
capabilities: ["mcp", "skill"],
|
|
219
|
+
events: ["tool_call"],
|
|
220
|
+
mcpServersKey: "mcpServers",
|
|
221
|
+
notes: "Pi's hooks are Heedvane's guard (src/guard.ts), installed as the extension extensions/heedvane.ts — not a hooks file.",
|
|
222
|
+
},
|
|
208
223
|
{
|
|
209
224
|
// --- Snapshot-tested rows (same table, same resolver, no bespoke tests) ---
|
|
210
225
|
id: "qwen-code",
|
|
@@ -339,6 +354,10 @@ function platformConfigDir(def, ctx, p) {
|
|
|
339
354
|
return p.join(xdgConfigRoot(ctx, p), appName);
|
|
340
355
|
}
|
|
341
356
|
}
|
|
357
|
+
/** The `xdg-config` base for a marker or a config file: the XDG root, %APPDATA% on Windows (Devin Desktop's folder for Windsurf). */
|
|
358
|
+
function configRoot(ctx, p) {
|
|
359
|
+
return ctx.platform === "win32" ? appDataRoot(ctx, p) : xdgConfigRoot(ctx, p);
|
|
360
|
+
}
|
|
342
361
|
/** %APPDATA%, else the default roaming folder under the home. */
|
|
343
362
|
function appDataRoot(ctx, p) {
|
|
344
363
|
return absoluteOrHome(ctx.env["APPDATA"] ?? p.join(ctx.home, "AppData", "Roaming"), ctx, p);
|
|
@@ -357,6 +376,7 @@ function clientDir(def, ctx, p) {
|
|
|
357
376
|
return p.join(ctx.home, ...def.markerSegments);
|
|
358
377
|
}
|
|
359
378
|
case "xdg-config":
|
|
379
|
+
// A client's own folder keeps the XDG root on Windows too (OpenCode's ~/.config/opencode); a marker or a config file under this base uses `configRoot`.
|
|
360
380
|
return p.join(xdgConfigRoot(ctx, p), ...def.markerSegments);
|
|
361
381
|
case "platform-config":
|
|
362
382
|
return p.join(platformConfigDir(def, ctx, p), ...def.markerSegments);
|
|
@@ -368,8 +388,7 @@ function resolveMarkerBase(target, ctx, p) {
|
|
|
368
388
|
case "home":
|
|
369
389
|
return p.join(ctx.home, ...segments);
|
|
370
390
|
case "xdg-config":
|
|
371
|
-
|
|
372
|
-
return p.join(ctx.platform === "win32" ? appDataRoot(ctx, p) : xdgConfigRoot(ctx, p), ...segments);
|
|
391
|
+
return p.join(configRoot(ctx, p), ...segments);
|
|
373
392
|
case "platform-config":
|
|
374
393
|
return p.join(platformConfigDir(def, ctx, p), ...segments);
|
|
375
394
|
}
|
|
@@ -401,7 +420,7 @@ function resolveConfigTarget(def, target, resolve) {
|
|
|
401
420
|
path = p.join(platformConfigDir(def, ctx, p), ...target.segments);
|
|
402
421
|
break;
|
|
403
422
|
case "xdg-config":
|
|
404
|
-
path = p.join(
|
|
423
|
+
path = p.join(configRoot(ctx, p), ...target.segments);
|
|
405
424
|
break;
|
|
406
425
|
}
|
|
407
426
|
return target.limitation === undefined
|
|
@@ -438,5 +457,6 @@ export function supportedClients(ctx) {
|
|
|
438
457
|
...(def.hookEntries === undefined ? {} : { hookEntries: def.hookEntries }),
|
|
439
458
|
...(def.hooksVersion === undefined ? {} : { hooksVersion: def.hooksVersion }),
|
|
440
459
|
...(def.runsHooksOf === undefined ? {} : { runsHooksOf: def.runsHooksOf }),
|
|
460
|
+
...(def.hookFiles === undefined ? {} : { hookFiles: def.hookFiles }),
|
|
441
461
|
}));
|
|
442
462
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { GatingMode, HookFiles } from "./hook-files.js";
|
|
2
2
|
/**
|
|
3
3
|
* The hooks this plugin registers with the Hermes host. `pre_tool_call` and `pre_llm_call` are directive
|
|
4
4
|
* hooks: Hermes acts on what they return. `on_session_end` fires once per turn despite its name;
|
|
@@ -9,25 +9,10 @@ export type HermesPluginFile = {
|
|
|
9
9
|
relativePath: string;
|
|
10
10
|
content: string;
|
|
11
11
|
};
|
|
12
|
-
|
|
13
|
-
/** heed.yaml `gating.mode`, baked into the plugin at setup as the POSIX dispatcher gets it. */
|
|
14
|
-
type GatingMode = HeedGatingConfig["mode"];
|
|
15
|
-
/** The two files that make up the Hermes plugin, ready to install. */
|
|
12
|
+
/** The files that make up the Hermes plugin, ready to install. */
|
|
16
13
|
export declare function hermesPluginFiles(gating?: GatingMode): HermesPluginFile[];
|
|
17
|
-
/** Does `<home>/.hermes/plugins/heedvane-hooks/plugin.yaml` declare our plugin? Uninstall removes only then. */
|
|
18
|
-
export declare function hermesPluginOwned(home: string): Promise<boolean>;
|
|
19
14
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* - "skipped" — a foreign plugin owns the target directory. Whether Hermes is installed at all
|
|
23
|
-
* is the planner's decision (detected, or named with `--agent`).
|
|
24
|
-
* - "unchanged" — the dir is heed-owned and every file already matches.
|
|
25
|
-
* - "installed" — files were written (fresh install or update).
|
|
26
|
-
*
|
|
27
|
-
* A heed-owned directory is one whose plugin.yaml exists and contains
|
|
28
|
-
* `name: heedvane-hooks`. Differing heed-owned files are backed up to
|
|
29
|
-
* `<file>.heedvane.backup` before being replaced. Foreign plugin.yaml files
|
|
30
|
-
* are never overwritten.
|
|
15
|
+
* Hermes' hooks: the plugin `<Hermes folder>/plugins/heedvane-hooks`, Heedvane's when its plugin.yaml declares
|
|
16
|
+
* `name: heedvane-hooks`. A folder without that manifest is another plugin's, never overwritten or removed.
|
|
31
17
|
*/
|
|
32
|
-
export declare
|
|
33
|
-
export {};
|
|
18
|
+
export declare const HERMES_PLUGIN: HookFiles;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { FALLBACK_FILE, FALLBACK_TIMEOUT_MS, HOOK_NODE_FILE, HOOK_TIMEOUT_MS, PROOF_HEADER, PROOF_MESSAGE, UNANSWERED_FLAG } from "../hook-fallback.js";
|
|
5
|
+
import { guardSource } from "./pi-extension.js";
|
|
4
6
|
const HERMES_PLUGIN_NAME = "heedvane-hooks";
|
|
5
7
|
/**
|
|
6
8
|
* The hooks this plugin registers with the Hermes host. `pre_tool_call` and `pre_llm_call` are directive
|
|
@@ -32,26 +34,39 @@ Reports coding-agent lifecycle events to the local heedvane listener
|
|
|
32
34
|
The listener's answer goes back to the host: a tool block on pre_tool_call,
|
|
33
35
|
turn context on pre_llm_call. Best-effort by contract: a slow, absent, or
|
|
34
36
|
misconfigured listener must never block the host or raise into it, so any
|
|
35
|
-
failure answers None and the host carries on.
|
|
37
|
+
failure answers None and the host carries on. When the listener gives no
|
|
38
|
+
answer, Heedvane's one fallback (fallback.mjs beside this file, the other
|
|
39
|
+
agents' hooks' own, run with the Node that last installed the hooks, or node on
|
|
40
|
+
PATH once that one is gone) keeps the event for the listener to replay and
|
|
41
|
+
starts a listener.
|
|
36
42
|
Configuration comes from the HEED_AGENT_HOOK_PORT / HEED_AGENT_HOOK_TOKEN
|
|
37
|
-
env vars, falling back to
|
|
43
|
+
env vars, falling back to endpoint.env (KEY=VALUE lines) where the dispatchers
|
|
44
|
+
find it.
|
|
38
45
|
Which sessions report follows heed.yaml gating.mode, baked in at setup: with
|
|
39
46
|
"pane" only sessions launched with HEED_SESSION set, with "always" every one.
|
|
40
47
|
"""
|
|
41
48
|
|
|
49
|
+
import hashlib
|
|
50
|
+
import hmac
|
|
42
51
|
import json
|
|
43
52
|
import os
|
|
53
|
+
import shutil
|
|
54
|
+
import subprocess
|
|
44
55
|
import urllib.error
|
|
45
56
|
import urllib.request
|
|
57
|
+
import uuid
|
|
46
58
|
|
|
47
59
|
# The hooks registered with the host via register(ctx).
|
|
48
60
|
EVENTS = (
|
|
49
61
|
${HERMES_EVENTS.map((event) => ` "${event}",\n`).join("")})
|
|
50
62
|
|
|
51
|
-
REQUEST_TIMEOUT_S =
|
|
63
|
+
REQUEST_TIMEOUT_S = ${HOOK_TIMEOUT_MS / 1000}
|
|
52
64
|
|
|
53
65
|
GATING = "${gating}"
|
|
54
66
|
|
|
67
|
+
FALLBACK = os.path.join(os.path.dirname(os.path.abspath(__file__)), "${FALLBACK_FILE}")
|
|
68
|
+
FALLBACK_TIMEOUT_S = ${FALLBACK_TIMEOUT_MS / 1000}
|
|
69
|
+
|
|
55
70
|
|
|
56
71
|
def _endpoint_from_env():
|
|
57
72
|
port = os.environ.get("HEED_AGENT_HOOK_PORT")
|
|
@@ -61,9 +76,19 @@ def _endpoint_from_env():
|
|
|
61
76
|
return None
|
|
62
77
|
|
|
63
78
|
|
|
79
|
+
def _heed_root():
|
|
80
|
+
"""The heed folder as the dispatchers find it: HEED_AGENT_HOOK_ROOT (which \`heedvane run\` sets), else ~/.heed."""
|
|
81
|
+
return os.environ.get("HEED_AGENT_HOOK_ROOT") or os.path.join(os.path.expanduser("~"), ".heed")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _endpoint_file():
|
|
85
|
+
"""endpoint.env as the dispatchers find it: HEED_AGENT_HOOK_ENDPOINT, else in the heed folder."""
|
|
86
|
+
return os.environ.get("HEED_AGENT_HOOK_ENDPOINT") or os.path.join(_heed_root(), "endpoint.env")
|
|
87
|
+
|
|
88
|
+
|
|
64
89
|
def _endpoint_from_home_file():
|
|
65
|
-
"""Parse
|
|
66
|
-
path =
|
|
90
|
+
"""Parse endpoint.env (KEY=VALUE lines, mode 0600)."""
|
|
91
|
+
path = _endpoint_file()
|
|
67
92
|
try:
|
|
68
93
|
with open(path, "r", encoding="utf-8") as handle:
|
|
69
94
|
lines = handle.read().splitlines()
|
|
@@ -98,15 +123,19 @@ def _post_to_listener(hook_event_name, cwd, payload):
|
|
|
98
123
|
Best-effort: never raises."""
|
|
99
124
|
if GATING != "always" and not os.environ.get("HEED_SESSION"):
|
|
100
125
|
return None
|
|
101
|
-
endpoint = _resolve_endpoint()
|
|
102
|
-
if endpoint is None:
|
|
103
|
-
return None
|
|
104
|
-
port, token = endpoint
|
|
105
126
|
# Flat like every other harness: the host's fields (tool_name, tool_input, ...) at the top level.
|
|
106
127
|
event = dict(payload)
|
|
107
128
|
event["hook_event_name"] = hook_event_name
|
|
108
129
|
event["cwd"] = cwd
|
|
109
130
|
body = json.dumps(event, default=str).encode("utf-8")
|
|
131
|
+
# Sent live and kept with the event, so Heedvane drops a replayed copy of one the listener did receive.
|
|
132
|
+
event_id = uuid.uuid4().hex
|
|
133
|
+
endpoint = _resolve_endpoint()
|
|
134
|
+
if endpoint is None:
|
|
135
|
+
return _keep_unanswered(body, event_id)
|
|
136
|
+
port, token = endpoint
|
|
137
|
+
# The proof of the token (src/hook-fallback.ts listenerProof), also for a port and token pinned in the environment.
|
|
138
|
+
proof = hmac.new(token.encode("utf-8"), ${JSON.stringify(PROOF_MESSAGE)}.encode("utf-8"), hashlib.sha256).hexdigest()[:32]
|
|
110
139
|
request = urllib.request.Request(
|
|
111
140
|
"http://127.0.0.1:" + str(port) + "/hook/hermes",
|
|
112
141
|
data=body,
|
|
@@ -114,17 +143,57 @@ def _post_to_listener(hook_event_name, cwd, payload):
|
|
|
114
143
|
"Content-Type": "application/json",
|
|
115
144
|
"X-Heed-Agent-Hook-Token": token,
|
|
116
145
|
"X-Heed-Hook-Version": "1",
|
|
146
|
+
"X-Heed-Event-Id": event_id,
|
|
117
147
|
},
|
|
118
148
|
method="POST",
|
|
119
149
|
)
|
|
120
150
|
try:
|
|
121
151
|
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_S) as response:
|
|
152
|
+
# Only this machine's listener names the proof from endpoint.env (0.3.4 on); any other answer goes to the
|
|
153
|
+
# fallback, which tells an older listener from another program by its probe.
|
|
154
|
+
if response.headers.get("${PROOF_HEADER}") != proof:
|
|
155
|
+
return _keep_unanswered(body, event_id)
|
|
122
156
|
return json.loads(response.read().decode("utf-8"))
|
|
123
|
-
except (OSError, urllib.error.URLError, ValueError):
|
|
157
|
+
except (OSError, urllib.error.URLError, ValueError) as error:
|
|
124
158
|
# OSError covers socket errors/timeouts (URLError subclasses it);
|
|
125
159
|
# ValueError covers a malformed port or URL and an answer that is not
|
|
126
|
-
# JSON. Silent by design: a hook must never raise into the host.
|
|
127
|
-
|
|
160
|
+
# JSON. Silent by design: a hook must never raise into the host. Any of
|
|
161
|
+
# them is no answer: on Windows a refused connection times out.
|
|
162
|
+
return _keep_unanswered(body, event_id)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _node():
|
|
166
|
+
"""The Node that last installed the hooks, as the dispatchers find it (agent-hooks/node in
|
|
167
|
+
the heed folder: its PATH link, then itself); node on PATH once both are gone."""
|
|
168
|
+
try:
|
|
169
|
+
with open(os.path.join(_heed_root(), "agent-hooks", "${HOOK_NODE_FILE}"), "r", encoding="utf-8") as handle:
|
|
170
|
+
nodes = handle.read().splitlines()
|
|
171
|
+
except OSError:
|
|
172
|
+
nodes = []
|
|
173
|
+
return next((node for node in nodes if node and os.access(node, os.X_OK)), None) or shutil.which("node")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _keep_unanswered(body, event_id):
|
|
177
|
+
"""The listener gave no answer: the fallback asks it once more (one taking
|
|
178
|
+
the port over answers then), else keeps the event for it to replay and
|
|
179
|
+
starts a listener. The listener's answer, or None. Its own failure never
|
|
180
|
+
reaches the host."""
|
|
181
|
+
# The agent's NODE_OPTIONS are its project's (a --require of its own code), not the fallback's.
|
|
182
|
+
env = {key: value for key, value in os.environ.items() if key != "NODE_OPTIONS"}
|
|
183
|
+
try:
|
|
184
|
+
ran = subprocess.run(
|
|
185
|
+
[_node() or "node", FALLBACK, "${UNANSWERED_FLAG}", "hermes"],
|
|
186
|
+
input=body,
|
|
187
|
+
stdout=subprocess.PIPE,
|
|
188
|
+
stderr=subprocess.DEVNULL,
|
|
189
|
+
env=dict(env, HEED_HOOK_EVENT_ID=event_id),
|
|
190
|
+
timeout=FALLBACK_TIMEOUT_S,
|
|
191
|
+
# No console window on Windows when Hermes runs without one.
|
|
192
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
193
|
+
)
|
|
194
|
+
return json.loads(ran.stdout.decode("utf-8") or "{}") or None
|
|
195
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
196
|
+
return None # no Node, it ran out of time, or it printed no answer: the host carries on
|
|
128
197
|
|
|
129
198
|
|
|
130
199
|
def _extract(payload):
|
|
@@ -168,32 +237,14 @@ def register(ctx):
|
|
|
168
237
|
for event in EVENTS:
|
|
169
238
|
ctx.register_hook(event, _make_hook(event))
|
|
170
239
|
`;
|
|
171
|
-
/** The
|
|
240
|
+
/** The files that make up the Hermes plugin, ready to install. */
|
|
172
241
|
export function hermesPluginFiles(gating = "always") {
|
|
173
242
|
return [
|
|
174
243
|
{ relativePath: "plugin.yaml", content: PLUGIN_YAML },
|
|
175
244
|
{ relativePath: "__init__.py", content: pluginInitPy(gating) },
|
|
245
|
+
{ relativePath: FALLBACK_FILE, content: guardSource() },
|
|
176
246
|
];
|
|
177
247
|
}
|
|
178
|
-
async function pathExists(path) {
|
|
179
|
-
try {
|
|
180
|
-
await stat(path);
|
|
181
|
-
return true;
|
|
182
|
-
}
|
|
183
|
-
catch {
|
|
184
|
-
return false;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
async function readFileOrNull(path) {
|
|
188
|
-
try {
|
|
189
|
-
return await readFile(path, "utf8");
|
|
190
|
-
}
|
|
191
|
-
catch (err) {
|
|
192
|
-
if (err.code === "ENOENT")
|
|
193
|
-
return null;
|
|
194
|
-
throw err;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
248
|
function isHeedOwned(pluginYaml) {
|
|
198
249
|
try {
|
|
199
250
|
const manifest = parseYaml(pluginYaml);
|
|
@@ -203,45 +254,20 @@ function isHeedOwned(pluginYaml) {
|
|
|
203
254
|
return false; // unparseable manifest — not provably ours
|
|
204
255
|
}
|
|
205
256
|
}
|
|
206
|
-
/** Does `<home>/.hermes/plugins/heedvane-hooks/plugin.yaml` declare our plugin? Uninstall removes only then. */
|
|
207
|
-
export async function hermesPluginOwned(home) {
|
|
208
|
-
const manifest = await readFileOrNull(join(home, ".hermes", "plugins", HERMES_PLUGIN_NAME, "plugin.yaml"));
|
|
209
|
-
return manifest !== null && isHeedOwned(manifest);
|
|
210
|
-
}
|
|
211
257
|
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
* - "skipped" — a foreign plugin owns the target directory. Whether Hermes is installed at all
|
|
215
|
-
* is the planner's decision (detected, or named with `--agent`).
|
|
216
|
-
* - "unchanged" — the dir is heed-owned and every file already matches.
|
|
217
|
-
* - "installed" — files were written (fresh install or update).
|
|
218
|
-
*
|
|
219
|
-
* A heed-owned directory is one whose plugin.yaml exists and contains
|
|
220
|
-
* `name: heedvane-hooks`. Differing heed-owned files are backed up to
|
|
221
|
-
* `<file>.heedvane.backup` before being replaced. Foreign plugin.yaml files
|
|
222
|
-
* are never overwritten.
|
|
258
|
+
* Hermes' hooks: the plugin `<Hermes folder>/plugins/heedvane-hooks`, Heedvane's when its plugin.yaml declares
|
|
259
|
+
* `name: heedvane-hooks`. A folder without that manifest is another plugin's, never overwritten or removed.
|
|
223
260
|
*/
|
|
224
|
-
export
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
261
|
+
export const HERMES_PLUGIN = {
|
|
262
|
+
name: "hermes plugin",
|
|
263
|
+
path: (agentFolder) => join(agentFolder, "plugins", HERMES_PLUGIN_NAME),
|
|
264
|
+
files: hermesPluginFiles,
|
|
265
|
+
owned: (dir) => {
|
|
266
|
+
try {
|
|
267
|
+
return isHeedOwned(readFileSync(join(dir, "plugin.yaml"), "utf8"));
|
|
230
268
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
await mkdir(pluginDir, { recursive: true });
|
|
234
|
-
for (const file of hermesPluginFiles(gating)) {
|
|
235
|
-
const target = join(pluginDir, file.relativePath);
|
|
236
|
-
const current = await readFileOrNull(target);
|
|
237
|
-
if (current === file.content)
|
|
238
|
-
continue;
|
|
239
|
-
if (current !== null) {
|
|
240
|
-
// Heed-owned but drifted: keep the old bytes for operator recovery.
|
|
241
|
-
await writeFile(`${target}.heedvane.backup`, current, "utf8");
|
|
269
|
+
catch {
|
|
270
|
+
return false; // no manifest: not provably ours
|
|
242
271
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
return changed ? "installed" : "unchanged";
|
|
247
|
-
}
|
|
272
|
+
},
|
|
273
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HeedGatingConfig } from "../heed-file.js";
|
|
2
|
+
export type GatingMode = HeedGatingConfig["mode"];
|
|
3
|
+
export interface HookFiles {
|
|
4
|
+
/** What uninstall reports it as. */
|
|
5
|
+
readonly name: string;
|
|
6
|
+
/** Where the files go, in the agent's folder. */
|
|
7
|
+
path(agentFolder: string): string;
|
|
8
|
+
/** The files, by path relative to {@link path} ("" for the path itself), with heed.yaml's gating set in them. */
|
|
9
|
+
files(gating: GatingMode): Array<{
|
|
10
|
+
relativePath: string;
|
|
11
|
+
content: string;
|
|
12
|
+
}>;
|
|
13
|
+
/** Whether Heedvane wrote what is at the path: only then is it replaced or removed. */
|
|
14
|
+
owned(path: string): boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Writes the files, unless something Heedvane did not write holds the path ("skipped"). A file of Heedvane's that
|
|
18
|
+
* differs (edited by hand, or an earlier version's) is kept as `<file>.heedvane.backup` before it is replaced.
|
|
19
|
+
*/
|
|
20
|
+
export declare function installHookFiles(hookFiles: HookFiles, path: string, gating?: GatingMode): Promise<"installed" | "unchanged" | "skipped">;
|
|
21
|
+
/** Removes the path when Heedvane wrote it; leaves anything else. */
|
|
22
|
+
export declare function removeHookFiles(hookFiles: HookFiles, path: string): Promise<"removed" | "skipped" | "absent">;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hooks an agent gets as whole files Heedvane owns, Hermes' plugin or Pi's extension, instead of entries in its config.
|
|
3
|
+
* The agent's definition names its {@link HookFiles} (definitions.ts `hookFiles`), and plan, apply, uninstall and doctor
|
|
4
|
+
* read only that. The files sit at one path: a folder of them (Hermes), or the file itself (Pi).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import writeFileAtomic from "write-file-atomic";
|
|
10
|
+
/**
|
|
11
|
+
* Writes the files, unless something Heedvane did not write holds the path ("skipped"). A file of Heedvane's that
|
|
12
|
+
* differs (edited by hand, or an earlier version's) is kept as `<file>.heedvane.backup` before it is replaced.
|
|
13
|
+
*/
|
|
14
|
+
export async function installHookFiles(hookFiles, path, gating = "always") {
|
|
15
|
+
if (existsSync(path) && !hookFiles.owned(path))
|
|
16
|
+
return "skipped";
|
|
17
|
+
let changed = false;
|
|
18
|
+
for (const file of hookFiles.files(gating)) {
|
|
19
|
+
const target = join(path, file.relativePath);
|
|
20
|
+
const current = existsSync(target) ? readFileSync(target, "utf8") : null;
|
|
21
|
+
if (current === file.content)
|
|
22
|
+
continue;
|
|
23
|
+
if (current !== null)
|
|
24
|
+
await writeFileAtomic(`${target}.heedvane.backup`, current);
|
|
25
|
+
await mkdir(dirname(target), { recursive: true });
|
|
26
|
+
await writeFileAtomic(target, file.content);
|
|
27
|
+
changed = true;
|
|
28
|
+
}
|
|
29
|
+
return changed ? "installed" : "unchanged";
|
|
30
|
+
}
|
|
31
|
+
/** Removes the path when Heedvane wrote it; leaves anything else. */
|
|
32
|
+
export async function removeHookFiles(hookFiles, path) {
|
|
33
|
+
if (!existsSync(path))
|
|
34
|
+
return "absent";
|
|
35
|
+
if (!hookFiles.owned(path))
|
|
36
|
+
return "skipped";
|
|
37
|
+
await rm(path, { recursive: true, force: true });
|
|
38
|
+
return "removed";
|
|
39
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { GatingMode, HookFiles } from "./hook-files.js";
|
|
2
|
+
/**
|
|
3
|
+
* The guard as one plain JavaScript file, with heed.yaml's gating set: Pi's extension, and every agent's fallback
|
|
4
|
+
* (`fallback.mjs`, which the dispatchers and Hermes run with Node). It is the compiled guard, or its source with the
|
|
5
|
+
* types stripped when Heedvane runs from source.
|
|
6
|
+
*/
|
|
7
|
+
export declare function guardSource(gating?: GatingMode): string;
|
|
8
|
+
/** Pi's hooks: Heedvane's guard as the extension `<Pi's agent folder>/extensions/heedvane.ts`, Heedvane's when it begins with the guard's mark. */
|
|
9
|
+
export declare const PI_EXTENSION: HookFiles;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
// A namespace import: Node 22.0 to 22.12 have no stripTypeScriptTypes, and naming it in an import fails the whole CLI at load.
|
|
3
|
+
import * as nodeModule from "node:module";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
/** How the guard (src/guard.ts) begins: a file in Pi's extensions folder that begins so is Heedvane's. */
|
|
7
|
+
const OWNER_MARK = "// heedvane: Heedvane's guard for Pi.";
|
|
8
|
+
/** The guard's gating line (typed in its source, plain once compiled), which the installed copy sets from heed.yaml. */
|
|
9
|
+
const GATING_LINE = /^const GATING(?:: string)? = "always";$/m;
|
|
10
|
+
/** The guard's one import of Heedvane's own code, which the copy carries inline: Pi loads the file on its own. */
|
|
11
|
+
const FALLBACK_IMPORT = /^import \{[^}]*\} from "\.\/hook-fallback\.js";$/m;
|
|
12
|
+
/**
|
|
13
|
+
* The guard as one plain JavaScript file, with heed.yaml's gating set: Pi's extension, and every agent's fallback
|
|
14
|
+
* (`fallback.mjs`, which the dispatchers and Hermes run with Node). It is the compiled guard, or its source with the
|
|
15
|
+
* types stripped when Heedvane runs from source.
|
|
16
|
+
*/
|
|
17
|
+
export function guardSource(gating = "always") {
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const compiled = existsSync(join(here, "..", "guard.js"));
|
|
20
|
+
const source = (name) => readFileSync(join(here, "..", `${name}.${compiled ? "js" : "ts"}`), "utf8");
|
|
21
|
+
const guard = source("guard");
|
|
22
|
+
if (!guard.startsWith(OWNER_MARK) || !GATING_LINE.test(guard) || !FALLBACK_IMPORT.test(guard))
|
|
23
|
+
throw new Error("the Pi guard lost its owner mark, its gating line or its fallback import");
|
|
24
|
+
// Replacer functions: the inlined text has `$` signs that a replacement string would read as patterns.
|
|
25
|
+
const whole = guard.replace(FALLBACK_IMPORT, () => source("hook-fallback")).replace(GATING_LINE, () => `const GATING = ${JSON.stringify(gating)};`);
|
|
26
|
+
return compiled ? whole : nodeModule.stripTypeScriptTypes(whole);
|
|
27
|
+
}
|
|
28
|
+
/** Pi's hooks: Heedvane's guard as the extension `<Pi's agent folder>/extensions/heedvane.ts`, Heedvane's when it begins with the guard's mark. */
|
|
29
|
+
export const PI_EXTENSION = {
|
|
30
|
+
name: "pi extension",
|
|
31
|
+
path: (agentFolder) => join(agentFolder, "extensions", "heedvane.ts"),
|
|
32
|
+
files: (gating) => [{ relativePath: "", content: guardSource(gating) }],
|
|
33
|
+
owned: (path) => {
|
|
34
|
+
try {
|
|
35
|
+
return readFileSync(path, "utf8").startsWith(OWNER_MARK);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false; // unreadable: nothing provably ours
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -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
|
+
}
|