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.
Files changed (65) hide show
  1. package/README.md +60 -13
  2. package/dist/backend/credentials.d.ts +2 -0
  3. package/dist/backend/credentials.js +13 -0
  4. package/dist/backend/repo-remote.js +2 -1
  5. package/dist/backend/sign-in-change.js +3 -2
  6. package/dist/cli.d.ts +4 -2
  7. package/dist/cli.js +389 -144
  8. package/dist/clients/definitions.d.ts +8 -4
  9. package/dist/clients/definitions.js +48 -13
  10. package/dist/clients/hermes-plugin.d.ts +5 -20
  11. package/dist/clients/hermes-plugin.js +97 -71
  12. package/dist/clients/hook-files.d.ts +22 -0
  13. package/dist/clients/hook-files.js +39 -0
  14. package/dist/clients/pi-extension.d.ts +9 -0
  15. package/dist/clients/pi-extension.js +41 -0
  16. package/dist/commands/heedvane.js +18 -3
  17. package/dist/commands/mcp-stub.d.ts +1 -0
  18. package/dist/commands/mcp-stub.js +44 -0
  19. package/dist/commands/memory.js +6 -1
  20. package/dist/dispatchers/posix.d.ts +16 -19
  21. package/dist/dispatchers/posix.js +71 -91
  22. package/dist/doctor.d.ts +2 -0
  23. package/dist/doctor.js +113 -29
  24. package/dist/guard.d.ts +73 -0
  25. package/dist/guard.js +302 -0
  26. package/dist/heed-file.d.ts +19 -3
  27. package/dist/heed-file.js +46 -23
  28. package/dist/hook-fallback.d.ts +73 -0
  29. package/dist/hook-fallback.js +61 -0
  30. package/dist/installer.d.ts +13 -6
  31. package/dist/installer.js +40 -13
  32. package/dist/listener/background.d.ts +48 -4
  33. package/dist/listener/background.js +153 -19
  34. package/dist/listener/endpoint.d.ts +26 -3
  35. package/dist/listener/endpoint.js +55 -10
  36. package/dist/listener/forward.d.ts +7 -1
  37. package/dist/listener/forward.js +40 -12
  38. package/dist/listener/serve.d.ts +37 -0
  39. package/dist/listener/serve.js +196 -32
  40. package/dist/listener/server.d.ts +9 -0
  41. package/dist/listener/server.js +59 -22
  42. package/dist/listener/spool.d.ts +2 -17
  43. package/dist/listener/spool.js +6 -3
  44. package/dist/mcp.d.ts +38 -48
  45. package/dist/mcp.js +196 -63
  46. package/dist/plan.d.ts +47 -8
  47. package/dist/plan.js +164 -52
  48. package/dist/render.js +8 -8
  49. package/dist/reply/harness-output.d.ts +9 -0
  50. package/dist/reply/harness-output.js +13 -0
  51. package/dist/reply/reply.js +2 -1
  52. package/dist/skill/content.d.ts +2 -2
  53. package/dist/skill/content.js +11 -8
  54. package/dist/status.d.ts +1 -0
  55. package/dist/status.js +29 -0
  56. package/dist/steering/rules.js +4 -2
  57. package/dist/uninstall.d.ts +7 -0
  58. package/dist/uninstall.js +72 -71
  59. package/package.json +10 -3
  60. package/skills/heedvane-hooks/SKILL.md +6 -6
  61. package/skills/heedvane-hooks/references/events.md +5 -2
  62. package/dist/dispatchers/windows.d.ts +0 -15
  63. package/dist/dispatchers/windows.js +0 -50
  64. package/dist/mcp-server.d.ts +0 -9
  65. 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. `plugin-install` = Hermes-style native plugin. */
19
- export type Capability = "hooks" | "mcp" | "skill" | "plugin-install";
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" | "plugin";
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 project-level, and user-level MCP requires explicit in-app approval of the import.",
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 — policies ship via MCP only, and the MCP import needs explicit user approval in Warp.",
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
- { segments: ["plugins", "heedvane-hooks"], base: "client", format: "yaml", purpose: "plugin" },
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
- { segments: ["config.toml"], base: "client", format: "toml", purpose: "mcp" },
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: "mcp_servers",
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
- const appData = ctx.env["APPDATA"] ?? p.join(ctx.home, "AppData", "Roaming");
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(xdgConfigRoot(ctx, p), ...segments);
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 { HeedGatingConfig } from "../heed-file.js";
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
- export type HermesPluginInstallStatus = "installed" | "skipped" | "unchanged";
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
- * Install the Hermes plugin into `<home>/.hermes/plugins/heedvane-hooks/`.
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 function installHermesPlugin(home: string, gating?: GatingMode): Promise<HermesPluginInstallStatus>;
33
- export {};
18
+ export declare const HERMES_PLUGIN: HookFiles;
@@ -1,6 +1,8 @@
1
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
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 the user's ~/.heed/endpoint.env (KEY=VALUE lines).
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 = 0.75
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 ~/.heed/endpoint.env (KEY=VALUE lines, mode 0600)."""
66
- path = os.path.join(os.path.expanduser("~"), ".heed", "endpoint.env")
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
- return None
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 two files that make up the Hermes plugin, ready to install. */
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
- * Install the Hermes plugin into `<home>/.hermes/plugins/heedvane-hooks/`.
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 async function installHermesPlugin(home, gating = "always") {
225
- const pluginDir = join(home, ".hermes", "plugins", HERMES_PLUGIN_NAME);
226
- if (await pathExists(pluginDir)) {
227
- const marker = await readFileOrNull(join(pluginDir, "plugin.yaml"));
228
- if (marker === null || !isHeedOwned(marker)) {
229
- return "skipped"; // foreign plugin owns this directory — never overwrite
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
- let changed = false;
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
- await writeFile(target, file.content, "utf8");
244
- changed = true;
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
+ };