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
|
@@ -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. */
|
|
@@ -34,7 +35,7 @@ export interface ResolveContext {
|
|
|
34
35
|
/** Root a client's directory or detection marker is resolved against. */
|
|
35
36
|
export type ClientPathBase = "home" | "xdg-config" | "platform-config";
|
|
36
37
|
/** Base a writable config target is resolved against. */
|
|
37
|
-
export type PathBase = "client" | "home" | "platform-config";
|
|
38
|
+
export type PathBase = "client" | "home" | "platform-config" | "xdg-config";
|
|
38
39
|
/** One writable config file for a client. */
|
|
39
40
|
export interface ConfigTarget {
|
|
40
41
|
/** Path segments relative to {@link PathBase}. */
|
|
@@ -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). */
|
|
@@ -155,13 +157,13 @@ const SUPPORTED_CLIENTS = [
|
|
|
155
157
|
base: "client",
|
|
156
158
|
format: "json",
|
|
157
159
|
purpose: "mcp",
|
|
158
|
-
limitation: "Warp has no native hook events (hooks unavailable); ~/.warp/.mcp.json is
|
|
160
|
+
limitation: "Warp has no native hook events (hooks unavailable); ~/.warp/.mcp.json is Warp's global MCP file, and a remote server signs in through the browser when it first starts.",
|
|
159
161
|
},
|
|
160
162
|
],
|
|
161
163
|
capabilities: ["mcp", "skill"],
|
|
162
164
|
events: [],
|
|
163
165
|
mcpServersKey: "mcpServers",
|
|
164
|
-
limitation: "Warp exposes no native hook events
|
|
166
|
+
limitation: "Warp exposes no native hook events, so Heedvane adds only its MCP connection and the skill there.",
|
|
165
167
|
},
|
|
166
168
|
{
|
|
167
169
|
id: "opencode",
|
|
@@ -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",
|
|
@@ -227,11 +242,12 @@ const SUPPORTED_CLIENTS = [
|
|
|
227
242
|
homeOverrideEnv: "KIMI_CODE_HOME",
|
|
228
243
|
configs: [
|
|
229
244
|
{ segments: ["config.toml"], base: "client", format: "toml", purpose: "hooks" },
|
|
230
|
-
|
|
245
|
+
// MCP servers live in mcp.json (Kimi Code's MCP docs); config.toml is listed with hooks, so cleanup still finds the helper 0.3.0 and earlier wrote there.
|
|
246
|
+
{ segments: ["mcp.json"], base: "client", format: "json", purpose: "mcp" },
|
|
231
247
|
],
|
|
232
248
|
capabilities: ["hooks", "mcp", "skill"],
|
|
233
249
|
events: KIMI_EVENTS,
|
|
234
|
-
mcpServersKey: "
|
|
250
|
+
mcpServersKey: "mcpServers",
|
|
235
251
|
},
|
|
236
252
|
{
|
|
237
253
|
id: "droid",
|
|
@@ -253,6 +269,8 @@ const SUPPORTED_CLIENTS = [
|
|
|
253
269
|
base: "home",
|
|
254
270
|
configs: [
|
|
255
271
|
{ segments: [".gemini", "config", "hooks.json"], base: "home", format: "json", purpose: "hooks" },
|
|
272
|
+
// Antigravity's documented global MCP file; the second is where 0.3.0 and earlier wrote, kept only so cleanup and uninstall reach it.
|
|
273
|
+
{ segments: [".gemini", "config", "mcp_config.json"], base: "home", format: "json", purpose: "mcp" },
|
|
256
274
|
{ segments: ["mcp_config.json"], base: "client", format: "json", purpose: "mcp" },
|
|
257
275
|
],
|
|
258
276
|
capabilities: ["hooks", "mcp", "skill"],
|
|
@@ -266,6 +284,8 @@ const SUPPORTED_CLIENTS = [
|
|
|
266
284
|
base: "home",
|
|
267
285
|
homeOverrideEnv: "COPILOT_HOME",
|
|
268
286
|
configs: [
|
|
287
|
+
// Copilot CLI's documented MCP file; the second is where 0.3.0 and earlier wrote, kept only so cleanup and uninstall reach it.
|
|
288
|
+
{ segments: ["mcp-config.json"], base: "client", format: "json", purpose: "mcp" },
|
|
269
289
|
{ segments: ["mcp.json"], base: "client", format: "json", purpose: "mcp" },
|
|
270
290
|
],
|
|
271
291
|
capabilities: ["mcp", "skill"],
|
|
@@ -276,8 +296,12 @@ const SUPPORTED_CLIENTS = [
|
|
|
276
296
|
id: "windsurf",
|
|
277
297
|
displayName: "Windsurf",
|
|
278
298
|
markerSegments: [".codeium", "windsurf"],
|
|
299
|
+
// Devin Desktop's folder: $XDG_CONFIG_HOME/devin, %APPDATA%\devin on Windows.
|
|
300
|
+
altMarkers: [{ segments: ["devin"], base: "xdg-config" }],
|
|
279
301
|
base: "home",
|
|
280
302
|
configs: [
|
|
303
|
+
// The file Windsurf's docs (now Devin Desktop's) name; the second is where 0.3.1 and earlier wrote, kept only so cleanup and uninstall reach it.
|
|
304
|
+
{ segments: ["devin", "mcp_config.json"], base: "xdg-config", format: "json", purpose: "mcp" },
|
|
281
305
|
{ segments: ["mcp_config.json"], base: "client", format: "json", purpose: "mcp" },
|
|
282
306
|
],
|
|
283
307
|
capabilities: ["mcp"],
|
|
@@ -324,14 +348,20 @@ function platformConfigDir(def, ctx, p) {
|
|
|
324
348
|
switch (ctx.platform) {
|
|
325
349
|
case "darwin":
|
|
326
350
|
return p.join(ctx.home, "Library", "Application Support", appName);
|
|
327
|
-
case "win32":
|
|
328
|
-
|
|
329
|
-
return p.join(absoluteOrHome(appData, ctx, p), appName);
|
|
330
|
-
}
|
|
351
|
+
case "win32":
|
|
352
|
+
return p.join(appDataRoot(ctx, p), appName);
|
|
331
353
|
default:
|
|
332
354
|
return p.join(xdgConfigRoot(ctx, p), appName);
|
|
333
355
|
}
|
|
334
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
|
+
}
|
|
361
|
+
/** %APPDATA%, else the default roaming folder under the home. */
|
|
362
|
+
function appDataRoot(ctx, p) {
|
|
363
|
+
return absoluteOrHome(ctx.env["APPDATA"] ?? p.join(ctx.home, "AppData", "Roaming"), ctx, p);
|
|
364
|
+
}
|
|
335
365
|
/** Treat a relative env override as home-relative so resolution never throws. */
|
|
336
366
|
function absoluteOrHome(value, ctx, p) {
|
|
337
367
|
return p.isAbsolute(value) ? value : p.join(ctx.home, value);
|
|
@@ -346,6 +376,7 @@ function clientDir(def, ctx, p) {
|
|
|
346
376
|
return p.join(ctx.home, ...def.markerSegments);
|
|
347
377
|
}
|
|
348
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`.
|
|
349
380
|
return p.join(xdgConfigRoot(ctx, p), ...def.markerSegments);
|
|
350
381
|
case "platform-config":
|
|
351
382
|
return p.join(platformConfigDir(def, ctx, p), ...def.markerSegments);
|
|
@@ -357,7 +388,7 @@ function resolveMarkerBase(target, ctx, p) {
|
|
|
357
388
|
case "home":
|
|
358
389
|
return p.join(ctx.home, ...segments);
|
|
359
390
|
case "xdg-config":
|
|
360
|
-
return p.join(
|
|
391
|
+
return p.join(configRoot(ctx, p), ...segments);
|
|
361
392
|
case "platform-config":
|
|
362
393
|
return p.join(platformConfigDir(def, ctx, p), ...segments);
|
|
363
394
|
}
|
|
@@ -388,6 +419,9 @@ function resolveConfigTarget(def, target, resolve) {
|
|
|
388
419
|
case "platform-config":
|
|
389
420
|
path = p.join(platformConfigDir(def, ctx, p), ...target.segments);
|
|
390
421
|
break;
|
|
422
|
+
case "xdg-config":
|
|
423
|
+
path = p.join(configRoot(ctx, p), ...target.segments);
|
|
424
|
+
break;
|
|
391
425
|
}
|
|
392
426
|
return target.limitation === undefined
|
|
393
427
|
? { path, format: target.format, purpose: target.purpose }
|
|
@@ -423,5 +457,6 @@ export function supportedClients(ctx) {
|
|
|
423
457
|
...(def.hookEntries === undefined ? {} : { hookEntries: def.hookEntries }),
|
|
424
458
|
...(def.hooksVersion === undefined ? {} : { hooksVersion: def.hooksVersion }),
|
|
425
459
|
...(def.runsHooksOf === undefined ? {} : { runsHooksOf: def.runsHooksOf }),
|
|
460
|
+
...(def.hookFiles === undefined ? {} : { hookFiles: def.hookFiles }),
|
|
426
461
|
}));
|
|
427
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
|
+
};
|