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.
Files changed (60) hide show
  1. package/README.md +41 -9
  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 +2 -1
  7. package/dist/cli.js +325 -118
  8. package/dist/clients/definitions.d.ts +7 -3
  9. package/dist/clients/definitions.js +27 -7
  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/dispatchers/posix.d.ts +16 -19
  20. package/dist/dispatchers/posix.js +71 -91
  21. package/dist/doctor.d.ts +2 -0
  22. package/dist/doctor.js +96 -28
  23. package/dist/guard.d.ts +73 -0
  24. package/dist/guard.js +302 -0
  25. package/dist/heed-file.d.ts +5 -2
  26. package/dist/heed-file.js +7 -7
  27. package/dist/hook-fallback.d.ts +73 -0
  28. package/dist/hook-fallback.js +61 -0
  29. package/dist/installer.d.ts +5 -5
  30. package/dist/installer.js +13 -12
  31. package/dist/listener/background.d.ts +48 -4
  32. package/dist/listener/background.js +153 -19
  33. package/dist/listener/endpoint.d.ts +26 -3
  34. package/dist/listener/endpoint.js +55 -10
  35. package/dist/listener/forward.d.ts +7 -1
  36. package/dist/listener/forward.js +40 -12
  37. package/dist/listener/serve.d.ts +37 -0
  38. package/dist/listener/serve.js +196 -32
  39. package/dist/listener/server.d.ts +9 -0
  40. package/dist/listener/server.js +59 -22
  41. package/dist/listener/spool.d.ts +2 -17
  42. package/dist/listener/spool.js +6 -3
  43. package/dist/mcp.d.ts +23 -4
  44. package/dist/mcp.js +124 -22
  45. package/dist/plan.d.ts +35 -6
  46. package/dist/plan.js +125 -40
  47. package/dist/render.js +2 -1
  48. package/dist/reply/harness-output.d.ts +9 -0
  49. package/dist/reply/harness-output.js +13 -0
  50. package/dist/skill/content.d.ts +2 -2
  51. package/dist/skill/content.js +6 -3
  52. package/dist/status.js +1 -1
  53. package/dist/steering/rules.js +4 -2
  54. package/dist/uninstall.d.ts +7 -2
  55. package/dist/uninstall.js +49 -44
  56. package/package.json +10 -3
  57. package/skills/heedvane-hooks/SKILL.md +3 -3
  58. package/skills/heedvane-hooks/references/events.md +3 -0
  59. package/dist/dispatchers/windows.d.ts +0 -15
  60. 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. `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. */
@@ -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
- { 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",
@@ -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
- // As for config targets: %APPDATA% on Windows (Devin Desktop's folder for Windsurf).
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(ctx.platform === "win32" ? appDataRoot(ctx, p) : xdgConfigRoot(ctx, p), ...target.segments);
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 { 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
+ };
@@ -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
- await withSignInChange(root, heedvaneConfigAt(root).url, () => saveCredential(root, kind, key));
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 backend = await backendAt(root, heedvaneConfigAt(root).url);
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
+ }