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
package/dist/uninstall.js CHANGED
@@ -4,21 +4,21 @@
4
4
  * byte-identical. heed.yaml is NEVER touched (the user's file) unless
5
5
  * --purge-config is passed, and even then it is deleted, not rewritten.
6
6
  */
7
- import { existsSync, readFileSync } from "node:fs";
8
7
  import { rm } from "node:fs/promises";
9
8
  import { join } from "node:path";
10
9
  import { supportedClients } from "./clients/definitions.js";
11
- import { hermesPluginOwned } from "./clients/hermes-plugin.js";
10
+ import { removeHookFiles } from "./clients/hook-files.js";
12
11
  import { applyEntry } from "./apply.js";
13
- import { isOurHookGroup, parseDoc } from "./config-doc.js";
14
- import { isHeedvaneEntry } from "./mcp.js";
15
- import { hooksTarget, isClientDetected } from "./plan.js";
12
+ import { isOurHookGroup } from "./config-doc.js";
13
+ import { isHeedvaneEntry, isLegacyEntry, mcpEntryNames } from "./mcp.js";
14
+ import { hooksTarget, isClientDetected, mcpContainers, parseClientDoc } from "./plan.js";
16
15
  import { skillInstallDir } from "./skill/bundle.js";
17
16
  import { statePath } from "./state.js";
18
17
  import { unlinkOptional } from "./best-effort.js";
19
- import { pingListener, readEndpoint } from "./listener/endpoint.js";
20
- import { SHUTDOWN_ROUTE } from "./listener/server.js";
21
- import { setTimeout as delay } from "node:timers/promises";
18
+ import { endpointPath, pingListener, readEndpoint } from "./listener/endpoint.js";
19
+ import { errorMessage } from "./guards.js";
20
+ import { stopRunningListener } from "./listener/background.js";
21
+ import { FALLBACK_FILE, HOOK_NODE_FILE } from "./hook-fallback.js";
22
22
  /** Hook events carrying one of our groups, with the user's own groups that must survive. */
23
23
  function ourHookEvents(doc) {
24
24
  const hooks = doc["hooks"];
@@ -28,28 +28,6 @@ function ourHookEvents(doc) {
28
28
  .filter(([, groups]) => Array.isArray(groups) && groups.some(isOurHookGroup))
29
29
  .map(([event, groups]) => ({ event, theirs: groups.filter((group) => !isOurHookGroup(group)) }));
30
30
  }
31
- function ourMcpEntryNames(doc, containerKeys) {
32
- const names = [];
33
- for (const key of containerKeys) {
34
- const c = doc[key];
35
- if (c === null || typeof c !== "object" || Array.isArray(c))
36
- continue;
37
- for (const [k, v] of Object.entries(c)) {
38
- if (isHeedvaneEntry(k, v))
39
- names.push(`${key}::${k}`);
40
- }
41
- }
42
- return names;
43
- }
44
- /** The doc's parsed value, or null when the config is absent or unparseable — nothing marker-verified to remove. */
45
- function parseClientDoc(path, format) {
46
- try {
47
- return parseDoc(readFileSync(path, "utf8"), format).value;
48
- }
49
- catch {
50
- return null; // absent or unparseable — nothing marker-verified to remove
51
- }
52
- }
53
31
  /**
54
32
  * Remove our hook groups from every event of one parsed config except those in `keep`, leaving the
55
33
  * user's own groups. Setup passes the events it installs, so a key an earlier version wrote under a
@@ -81,19 +59,40 @@ export async function removeDeferredHookGroups(client) {
81
59
  const doc = parseClientDoc(target.path, target.format);
82
60
  return doc === null ? [] : removeOurHookGroups(client, target, { doc, keep: new Set() });
83
61
  }
62
+ /** Remove the MCP entries `match` accepts from one parsed config; one item per removal. */
63
+ async function removeMcpEntries(client, target, scope) {
64
+ const items = [];
65
+ for (const qualified of mcpEntryNames(scope.doc, mcpContainers(client), scope.match)) {
66
+ const [containerKey, entryName] = qualified.split("::");
67
+ const res = await applyEntry(target, { containerKey, remove: entryName });
68
+ items.push({ clientId: client.id, name: `MCP ${entryName}`, path: target.path, status: res.status === "update" ? "removed" : "failed", detail: res.reason });
69
+ }
70
+ return items;
71
+ }
72
+ /**
73
+ * Setup's half for MCP: the entries an earlier version registered (the local helper, the connection's earlier name),
74
+ * removed from every config; under metrics only (`match` = isHeedvaneEntry), today's connection too.
75
+ */
76
+ export async function removeLegacyMcpEntries(client, match = isLegacyEntry) {
77
+ const items = [];
78
+ for (const target of client.configs) {
79
+ if (target.format === "yaml")
80
+ continue;
81
+ const doc = parseClientDoc(target.path, target.format);
82
+ if (doc !== null)
83
+ items.push(...(await removeMcpEntries(client, { path: target.path, format: target.format }, { doc, match })));
84
+ }
85
+ return items;
86
+ }
84
87
  /** Remove every marker-verified entry of one config file; one item per removal. */
85
88
  async function removeMarkedEntries(client, target, doc) {
86
89
  // Both kinds can live in the same file (claude settings.json carries
87
90
  // hooks AND mcpServers) — remove ours from every container.
88
91
  const items = await removeOurHookGroups(client, target, { doc, keep: new Set() });
89
- for (const qualified of ourMcpEntryNames(doc, [client.mcpServersKey, "mcp_servers", "mcp"])) {
90
- const [containerKey, entryName] = qualified.split("::");
91
- const res = await applyEntry(target, { containerKey, remove: entryName });
92
- items.push({ clientId: client.id, name: `MCP ${entryName}`, path: target.path, status: res.status === "update" ? "removed" : "failed", detail: res.reason });
93
- }
92
+ items.push(...(await removeMcpEntries(client, target, { doc, match: isHeedvaneEntry })));
94
93
  return items;
95
94
  }
96
- /** One non-hermes client's config files: parse each and remove our marked entries. */
95
+ /** One client's config files: parse each and remove our marked entries. */
97
96
  async function uninstallClientConfigs(client, items) {
98
97
  for (const target of client.configs) {
99
98
  if (target.format === "yaml")
@@ -116,12 +115,9 @@ export async function uninstall(input) {
116
115
  if (!existsMarker(client))
117
116
  continue;
118
117
  }
119
- if (client.id !== "hermes") {
120
- await uninstallClientConfigs(client, items);
121
- }
122
- else {
123
- items.push(await uninstallHermesPlugin(home));
124
- }
118
+ await uninstallClientConfigs(client, items);
119
+ if (client.hookFiles !== undefined)
120
+ items.push(await uninstallHookFiles(client, client.hookFiles));
125
121
  // Agent-hook dispatcher scripts owned by this client.
126
122
  const script = join(home, ".heed", "agent-hooks", `${client.id}-hook.sh`);
127
123
  await unlinkOptional(script);
@@ -130,19 +126,16 @@ export async function uninstall(input) {
130
126
  items.push(...(await removeSharedFiles(home, scope !== null)));
131
127
  if (input.purgeConfig) {
132
128
  await unlinkOptional(join(home, ".heed", "heed.yaml"));
129
+ await unlinkOptional(join(home, ".heed", "heed.yaml.last-good")); // the listener's copy of it
133
130
  items.push({ clientId: "*", name: "heed.yaml (purged)", path: join(home, ".heed", "heed.yaml"), status: "removed" });
134
131
  }
135
132
  return items;
136
133
  }
137
- /** The Hermes plugin dir, removed only when its plugin.yaml declares our plugin. */
138
- async function uninstallHermesPlugin(home) {
139
- const pluginDir = join(home, ".hermes", "plugins", "heedvane-hooks");
140
- const item = { clientId: "hermes", name: "hermes plugin", path: pluginDir };
141
- if (await hermesPluginOwned(home)) {
142
- await rm(pluginDir, { recursive: true, force: true });
143
- return { ...item, status: "removed" };
144
- }
145
- return existsSync(pluginDir) ? { ...item, status: "skipped", detail: "not ours — left untouched" } : { ...item, status: "absent" };
134
+ /** Hooks written as whole files (Hermes' plugin, Pi's extension), removed only when Heedvane wrote them. */
135
+ async function uninstallHookFiles(client, hookFiles) {
136
+ const path = hookFiles.path(client.markerPath);
137
+ const status = await removeHookFiles(hookFiles, path);
138
+ return { clientId: client.id, name: hookFiles.name, path, status, ...(status === "skipped" ? { detail: "not ours — left untouched" } : {}) };
146
139
  }
147
140
  /** The skill bundle and state.json every agent shares: a scoped uninstall keeps them. */
148
141
  async function removeSharedFiles(home, scoped) {
@@ -152,33 +145,41 @@ async function removeSharedFiles(home, scoped) {
152
145
  }
153
146
  await rm(skillDir, { recursive: true, force: true });
154
147
  await unlinkOptional(statePath(join(home, ".heed")));
148
+ await unlinkOptional(join(home, ".heed", "agent-hooks", FALLBACK_FILE)); // every dispatcher's, beside their scripts
149
+ await unlinkOptional(join(home, ".heed", "agent-hooks", HOOK_NODE_FILE));
155
150
  return [
156
151
  await stopListener(join(home, ".heed")),
157
152
  { clientId: "*", name: "skill bundle", path: skillDir, status: "removed" },
158
153
  { clientId: "*", name: "state.json", path: statePath(join(home, ".heed")), status: "removed" },
159
154
  ];
160
155
  }
161
- /** How long uninstall waits for the listener to stop answering after asking it to stop. */
162
- const STOP_WAIT_MS = 3_000;
163
- const STOP_POLL_MS = 100;
164
- /** The background listener answers hooks that are gone now: ask it to stop (POST /shutdown) and wait until it has. */
165
- async function stopListener(root) {
166
- const endpoint = await readEndpoint(root).catch(() => null);
167
- const item = { clientId: "*", name: "listener", path: `127.0.0.1:${endpoint?.port ?? 0}` };
168
- if (endpoint === null || !(await pingListener(endpoint)))
169
- return { ...item, status: "absent" };
170
- const asked = await fetch(`http://127.0.0.1:${endpoint.port}${SHUTDOWN_ROUTE}`, {
171
- method: "POST",
172
- headers: { "x-heed-agent-hook-token": endpoint.token },
173
- signal: AbortSignal.timeout(STOP_WAIT_MS),
174
- }).catch(() => null);
175
- for (let waited = 0; waited < STOP_WAIT_MS; waited += STOP_POLL_MS) {
176
- if (!(await pingListener(endpoint)))
177
- return { ...item, status: "removed" };
178
- await delay(STOP_POLL_MS);
156
+ /** Uninstall asks the background listener to stop (POST /shutdown) and waits until it has. */
157
+ export async function stopListener(root) {
158
+ const unknownPort = (reason) => ({
159
+ clientId: "*",
160
+ name: "listener",
161
+ path: endpointPath(root),
162
+ status: "failed",
163
+ detail: `${reason}, so a listener still running cannot be asked to stop; it stops at logout or restart. Delete the file to clear this`,
164
+ });
165
+ let endpoint;
166
+ try {
167
+ endpoint = await readEndpoint(root);
168
+ }
169
+ catch (err) {
170
+ return unknownPort(`cannot read it (${errorMessage(err)})`);
179
171
  }
180
- // A listener from 0.2.0 or earlier has no stop route (404); it ends at logout or restart.
181
- const detail = asked?.status === 404 ? "an older listener is still running; it stops at logout or restart" : "the listener did not stop; it stops at logout or restart";
172
+ if (endpoint?.port === 0)
173
+ return unknownPort("it names no port");
174
+ if (endpoint === null)
175
+ return { clientId: "*", name: "listener", path: endpointPath(root), status: "absent" };
176
+ const item = { clientId: "*", name: "listener", path: `127.0.0.1:${endpoint.port}` };
177
+ if (!(await pingListener(endpoint)))
178
+ return { ...item, status: "absent" };
179
+ const stopped = await stopRunningListener(endpoint);
180
+ if (stopped === "stopped")
181
+ return { ...item, status: "removed" };
182
+ const detail = stopped === "no stop route" ? "an older listener is still running; it stops at logout or restart" : "the listener did not stop; it stops at logout or restart";
182
183
  return { ...item, status: "failed", detail };
183
184
  }
184
185
  function existsMarker(client) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heedvane",
3
- "version": "0.2.1",
3
+ "version": "0.3.4",
4
4
  "description": "Heedvane for coding agents: one command installs steering, guardrails, team memory and skill checks into Claude Code, Codex, Cursor, Gemini CLI and more.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -8,15 +8,22 @@
8
8
  "heedvane": "dist/cli.js",
9
9
  "heedvane-hook": "dist/cli.js"
10
10
  },
11
+ "exports": {
12
+ "./guard": {
13
+ "types": "./dist/guard.d.ts",
14
+ "default": "./dist/guard.js"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
11
18
  "files": [
12
19
  "dist",
13
20
  "skills"
14
21
  ],
15
22
  "engines": {
16
- "node": ">=22"
23
+ "node": ">=22.5"
17
24
  },
18
25
  "scripts": {
19
- "test": "node --experimental-strip-types --disable-warning=ExperimentalWarning --import ./tests/register-ts-resolver.mjs --test \"tests/**/*.test.ts\"",
26
+ "test": "node --experimental-strip-types --disable-warning=ExperimentalWarning --import ./tests/register-ts-resolver.mjs --import ./tests/temp-cleanup.mjs --test \"tests/**/*.test.ts\"",
20
27
  "typecheck": "tsc -p tsconfig.json --noEmit",
21
28
  "typecheck:tests": "tsc --noEmit -p tsconfig.tests.json",
22
29
  "build": "tsc -p tsconfig.json",
@@ -7,13 +7,13 @@ description: Use when a user asks about heedvane hooks status, events, or how th
7
7
 
8
8
  ## Overview
9
9
 
10
- Heedvane hooks give coding agents (Claude Code, Codex, Gemini CLI, Cursor, Warp, OpenCode, Hermes) a shared steering/guardrail/analysis channel without touching their internals. Three strictly separated layers:
10
+ Heedvane hooks give coding agents (Claude Code, Codex, Gemini CLI, Cursor, Warp, OpenCode, Hermes, Pi) a shared steering/guardrail/analysis channel without touching their internals. Three strictly separated layers:
11
11
 
12
12
  1. **Hook contract** — a harness-neutral event taxonomy (`pre_tool_use`, `post_tool_use`, …), one JSON envelope, one normalizer. See `references/events.md`.
13
- 2. **Per-agent adapters** — native hook config injection where the harness has hooks (Claude, Codex, Gemini, Cursor); MCP registration as the universal fallback (Warp has no native hooks — policy arrives via MCP); an honest skip where neither exists.
14
- 3. **Setup mechanism** — `heedvane-hook` CLI: `init` / `setup` / `doctor` / `uninstall` with backup → atomic write → read-back verify. Human intent lives in `~/.heed/heed.yaml`; machine state in `~/.heed/state.json`.
13
+ 2. **Per-agent adapters** — native hook config injection where the harness has hooks (Claude, Codex, Gemini, Cursor); Heedvane's MCP connection in every agent (Claude Desktop through the mcp-remote bridge); an honest skip where an agent has no hooks (Warp).
14
+ 3. **Setup mechanism** — `npx heedvane`: `connect` / `setup` / `doctor` / `uninstall` with backup → atomic write → read-back verify. Human intent lives in `~/.heed/heed.yaml`; machine state in `~/.heed/state.json`.
15
15
 
16
- Which sessions run the hooks follows `heed.yaml` `gating.mode`: with `always` (the default) every session is governed, with `pane` only sessions launched with `HEED_SESSION` set. The dispatcher script (for Hermes, its plugin) decides, from the mode `setup` bakes into it, and always exits 0. Observability never blocks the agent.
16
+ Which sessions run the hooks follows `heed.yaml` `gating.mode`: with `always` (the default) every session is governed, with `pane` only sessions launched with `HEED_SESSION` set. The dispatcher script (for Hermes, its plugin; for Pi, Heedvane's guard extension) decides, from the mode `setup` bakes into it, and always exits 0. Observability never blocks the agent.
17
17
 
18
18
  ## When to Use
19
19
 
@@ -73,7 +73,7 @@ Backend failures never block the agent: without Heedvane's answer, the locally c
73
73
 
74
74
  - **Do not rewrite `~/.claude/settings.json` (or any agent config) manually while hooks are installed.** Hand edits fight the compiler; the next `setup` will either back up your change or report the file invalid. Edit `heed.yaml` instead.
75
75
  - **Spool files are diagnostics, not policy.** When the listener is unreachable, hook payloads spool to `~/.heed/spool/` (5 MB cap, 7-day TTL) so nothing is lost — but spool presence is not enforcement, and clearing it loses observability history only.
76
- - Warp gets policies **via MCP only** — that is the designed happy path, not an error to fix.
76
+ - Warp has no native hooks, so Heedvane adds only its MCP connection and the skill there — that is expected, not an error to fix.
77
77
  - If `doctor` reports `invalid` on a config, the file does not parse; heedvane never overwrites malformed configs. Fix or move the file aside yourself, then re-run.
78
78
  - Re-running `setup` after no changes reports "already current" — idempotence is a feature, not a stalled run.
79
79
 
@@ -84,4 +84,4 @@ Backend failures never block the agent: without Heedvane's answer, the locally c
84
84
  - [ ] `~/.heed/state.json` skill digest matches the installed skill
85
85
  - [ ] A new agent session (launched with `HEED_SESSION` set, or any session with `gating.mode: always`) produces events in the listener log or `~/.heed/spool/`
86
86
  - [ ] Backups exist: `<config>.heedvane.backup` next to every patched config
87
- - [ ] `/heedvane-status` in a fresh agent session reports the same agents as `doctor`
87
+ - [ ] `npx heedvane status` reports state.json ok and the listener's endpoint
@@ -57,12 +57,15 @@ fields are truncated at ingest, never dropped.
57
57
  | `notification` | `Notification` | `Notification` | — | — |
58
58
  | `pre_compact` | `PreCompact` | `PreCompress` | — | — |
59
59
 
60
+ Pi's guard (the extension `connect` installs, or `heedvane/guard` in code built on Pi) sends only
61
+ `pre_tool_use`, as Claude Code's `PreToolUse` with Claude Code's tool names.
62
+
60
63
  Hermes' `on_session_end` fires once per turn despite its name, so it passes
61
64
  through as `unknown_event`, as does `post_approval_response`. Cursor's session
62
65
  is its `conversation_id`.
63
66
 
64
- Warp emits no native events at all — it has no hooks. Policy for Warp
65
- arrives via MCP tool calls; nothing in this table applies to it.
67
+ Warp emits no native events at all — it has no hooks, so nothing in this
68
+ table applies to it.
66
69
 
67
70
  Payload keys per source keep their harness names inside `payload` (e.g.
68
71
  Claude's `tool_input`, Codex's `tool_input`) after normalization of the
@@ -1,15 +0,0 @@
1
- import type { DispatcherOptions } from "./posix.js";
2
- /**
3
- * Windows .cmd launcher (best-effort tier — the plan marks Windows untested until
4
- * CI covers it). It:
5
- * 1. in heed.yaml's default `gating.mode: pane`, no-ops unless HEED_SESSION is set; with
6
- * `always`, runs in every session (the POSIX dispatcher decides the same way);
7
- * 2. resolves the shared POSIX dispatcher via the two-tier lookup
8
- * (HEED_AGENT_HOOK_ROOT → %HOME% → %USERPROFILE%);
9
- * 3. prefers running that dispatcher through any POSIX shell on PATH (Git for
10
- * Windows / MSYS2 / BusyBox) — hook stdin passes through, so spooling still
11
- * works with the listener down;
12
- * 4. otherwise posts via curl.exe with the same headers as the POSIX dispatcher and prints
13
- * the listener's answer (nothing, which lets the agent continue, when it fails).
14
- */
15
- export declare function windowsLauncher(agentId: string, options?: Pick<DispatcherOptions, "gating">): string;
@@ -1,50 +0,0 @@
1
- import { validateAgentId } from "./snippets.js";
2
- /**
3
- * Windows .cmd launcher (best-effort tier — the plan marks Windows untested until
4
- * CI covers it). It:
5
- * 1. in heed.yaml's default `gating.mode: pane`, no-ops unless HEED_SESSION is set; with
6
- * `always`, runs in every session (the POSIX dispatcher decides the same way);
7
- * 2. resolves the shared POSIX dispatcher via the two-tier lookup
8
- * (HEED_AGENT_HOOK_ROOT → %HOME% → %USERPROFILE%);
9
- * 3. prefers running that dispatcher through any POSIX shell on PATH (Git for
10
- * Windows / MSYS2 / BusyBox) — hook stdin passes through, so spooling still
11
- * works with the listener down;
12
- * 4. otherwise posts via curl.exe with the same headers as the POSIX dispatcher and prints
13
- * the listener's answer (nothing, which lets the agent continue, when it fails).
14
- */
15
- export function windowsLauncher(agentId, options = {}) {
16
- validateAgentId(agentId);
17
- const gate = (options.gating ?? "always") === "always" ? "rem heed.yaml gating.mode: always, so every session runs the hooks." : "if not defined HEED_SESSION exit /b 0";
18
- return `@echo off
19
- setlocal EnableExtensions
20
- rem heedvane-agent-hook launcher for ${agentId} — generated by heedvane-hook. Do not hand-edit.
21
- rem Marker: heedvane-agent-hook-form
22
- rem Every path exits 0. The listener's answer (steering, skill check) is printed as the hook output.
23
-
24
- ${gate}
25
-
26
- set "HEED_HOOK_SCRIPT="
27
- if defined HEED_AGENT_HOOK_ROOT if exist "%HEED_AGENT_HOOK_ROOT%\\agent-hooks\\${agentId}-hook.sh" set "HEED_HOOK_SCRIPT=%HEED_AGENT_HOOK_ROOT%\\agent-hooks\\${agentId}-hook.sh"
28
- if not defined HEED_HOOK_SCRIPT if defined HOME if exist "%HOME%\\.heed\\agent-hooks\\${agentId}-hook.sh" set "HEED_HOOK_SCRIPT=%HOME%\\.heed\\agent-hooks\\${agentId}-hook.sh"
29
- if not defined HEED_HOOK_SCRIPT if defined USERPROFILE if exist "%USERPROFILE%\\.heed\\agent-hooks\\${agentId}-hook.sh" set "HEED_HOOK_SCRIPT=%USERPROFILE%\\.heed\\agent-hooks\\${agentId}-hook.sh"
30
- if not defined HEED_HOOK_SCRIPT exit /b 0
31
-
32
- rem Preferred: the shared POSIX dispatcher via any available POSIX shell.
33
- rem Hook stdin passes through to the script, which spools when the listener is down.
34
- where sh >nul 2>&1
35
- if %errorlevel%==0 (
36
- sh "%HEED_HOOK_SCRIPT%"
37
- exit /b 0
38
- )
39
-
40
- rem No POSIX shell: post via curl.exe with the same headers as the POSIX dispatcher.
41
- if not defined HEED_AGENT_HOOK_PORT exit /b 0
42
- if not defined HEED_AGENT_HOOK_TOKEN exit /b 0
43
-
44
- set "HEED_HOOK_INPUT=%TEMP%\\heed-hook-%RANDOM%-%RANDOM%.json"
45
- more > "%HEED_HOOK_INPUT%" 2>nul
46
- curl.exe -s --fail -X POST "http://127.0.0.1:%HEED_AGENT_HOOK_PORT%/hook/${agentId}" --connect-timeout 0.5 --max-time 1.5 --noproxy 127.0.0.1 -H "Content-Type: application/json" -H "X-Heed-Agent-Hook-Token: %HEED_AGENT_HOOK_TOKEN%" -H "X-Heed-Hook-Version: 1" --data-binary "@%HEED_HOOK_INPUT%"
47
- del "%HEED_HOOK_INPUT%" >nul 2>&1
48
- exit /b 0
49
- `;
50
- }
@@ -1,9 +0,0 @@
1
- export interface McpIo {
2
- write: (line: string) => void;
3
- log: (line: string) => void;
4
- }
5
- export declare function statusText(): Promise<string>;
6
- /** Entry point for the `mcp` subcommand. */
7
- export declare function runMcpStdio(io: Pick<McpIo, "write"> & {
8
- log?: (line: string) => void;
9
- }): void;
@@ -1,187 +0,0 @@
1
- /**
2
- * `npx heedvane mcp` — the MCP stdio server stub the installers register.
3
- * v1 exposes two tools (status, doctor) over line-delimited JSON-RPC.
4
- */
5
- import { createInterface } from "node:readline";
6
- import { runDoctor } from "./doctor.js";
7
- import { cmdMemory } from "./commands/memory.js";
8
- const MCP_SERVER_INFO = { name: "heedvane-hooks", version: "0.1.0" };
9
- const TOOLS = [
10
- {
11
- name: "heedvane_status",
12
- description: "Report the live heedvane hook installation state for this machine (agents, skill, listener).",
13
- inputSchema: { type: "object", properties: {} },
14
- },
15
- {
16
- name: "heedvane_doctor",
17
- description: "Run the scoped heedvane doctor and return its checks with remediations.",
18
- inputSchema: {
19
- type: "object",
20
- properties: { clients: { type: "array", items: { type: "string" } } },
21
- },
22
- },
23
- {
24
- name: "heedvane_memory_list",
25
- description: "List team memory folders and entries for the current repository.",
26
- inputSchema: {
27
- type: "object",
28
- properties: {
29
- folder: { type: "string", description: "Optional folder name to filter by." },
30
- cwd: { type: "string", description: "Optional repository checkout path." },
31
- },
32
- },
33
- },
34
- {
35
- name: "heedvane_memory_show",
36
- description: "Read the full markdown content of a team memory entry by id.",
37
- inputSchema: {
38
- type: "object",
39
- required: ["id"],
40
- properties: {
41
- id: { type: "string", description: "The team memory id." },
42
- cwd: { type: "string", description: "Optional repository checkout path." },
43
- },
44
- },
45
- },
46
- {
47
- name: "heedvane_memory_search",
48
- description: "Semantically search team memory for the current repository.",
49
- inputSchema: {
50
- type: "object",
51
- required: ["query"],
52
- properties: {
53
- query: { type: "string", description: "The question or search query." },
54
- folder: { type: "string", description: "Optional folder name to restrict search." },
55
- cwd: { type: "string", description: "Optional repository checkout path." },
56
- },
57
- },
58
- },
59
- ];
60
- /** Run a team memory command and return its collected lines. */
61
- async function runMemoryTool(words, flags, cwd) {
62
- const lines = [];
63
- await cmdMemory({
64
- home: process.env.HEED_HOME ?? homeDir(),
65
- out: (line) => lines.push(line),
66
- words,
67
- flag: (name) => flags[name],
68
- cwd: typeof cwd === "string" && cwd.trim() !== "" ? cwd : process.cwd(),
69
- });
70
- return lines.join("\n");
71
- }
72
- function handleMemoryToolCall(name, args, reply) {
73
- if (name === "heedvane_memory_list") {
74
- const folder = typeof args.folder === "string" && args.folder.trim() !== "" ? [args.folder.trim()] : [];
75
- void runMemoryTool(["list", ...folder], {}, args.cwd).then((text) => reply(textContent(text)));
76
- return true;
77
- }
78
- if (name === "heedvane_memory_show") {
79
- const id = typeof args.id === "string" ? args.id.trim() : "";
80
- void runMemoryTool(["show", ...(id ? [id] : [])], {}, args.cwd).then((text) => reply(textContent(text)));
81
- return true;
82
- }
83
- if (name === "heedvane_memory_search") {
84
- const query = typeof args.query === "string" ? args.query.trim() : "";
85
- const folder = typeof args.folder === "string" && args.folder.trim() !== "" ? args.folder.trim() : undefined;
86
- void runMemoryTool(["search", ...(query ? [query] : [])], { "--folder": folder }, args.cwd).then((text) => reply(textContent(text)));
87
- return true;
88
- }
89
- return false;
90
- }
91
- /** The tools/call branch: reply with the tool's text content, or a unknown-tool error. */
92
- function handleToolCall(msg, reply, fail) {
93
- const name = msg.params?.name;
94
- const args = msg.params?.arguments ?? {};
95
- if (name === "heedvane_status") {
96
- void statusText().then((text) => reply(textContent(text)));
97
- return;
98
- }
99
- if (name === "heedvane_doctor") {
100
- const clients = args.clients;
101
- const input = { home: process.env.HEED_HOME ?? homeDir(), env: process.env };
102
- if (Array.isArray(clients))
103
- input.clients = clients.map(String);
104
- void runDoctor(input).then((report) => reply(textContent(JSON.stringify(report, null, 2))));
105
- return;
106
- }
107
- if (handleMemoryToolCall(name, args, reply))
108
- return;
109
- fail(-32602, `unknown tool: ${String(name)}`);
110
- }
111
- function handleRpc(msg, io) {
112
- const { method, id } = msg;
113
- if (!method || id === undefined)
114
- return; // notification: ignore silently
115
- const reply = (result) => io.write(JSON.stringify({ jsonrpc: "2.0", id, result }));
116
- const fail = (code, message) => io.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }));
117
- switch (method) {
118
- case "initialize":
119
- reply({ protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: MCP_SERVER_INFO });
120
- return;
121
- case "tools/list":
122
- reply({ tools: TOOLS });
123
- return;
124
- case "tools/call":
125
- handleToolCall(msg, reply, fail);
126
- return;
127
- default:
128
- fail(-32601, `method not supported: ${method}`);
129
- }
130
- }
131
- function textContent(text) {
132
- return { content: [{ type: "text", text }] };
133
- }
134
- import { homedir } from "node:os";
135
- import { join } from "node:path";
136
- import { HeedStateCorruptError, readState } from "./state.js";
137
- import { readEndpoint } from "./listener/endpoint.js";
138
- function homeDir() {
139
- return homedir();
140
- }
141
- export async function statusText() {
142
- const home = process.env.HEED_HOME ?? homeDir();
143
- const root = join(home, ".heed");
144
- const lines = [`heedvane hooks status (home: ${home})`];
145
- try {
146
- const state = await readState(root);
147
- if (state !== null) {
148
- const skill = state.skill?.digest.slice(0, 12) ?? "not installed";
149
- lines.push(` state.json: ok (protocol v${state.protocolVersion}, skill ${skill})`);
150
- }
151
- else {
152
- lines.push(" state.json: absent (run npx heedvane init)");
153
- }
154
- }
155
- catch (error) {
156
- if (!(error instanceof HeedStateCorruptError))
157
- throw error;
158
- lines.push(` state.json: ${error.message}`);
159
- }
160
- const endpoint = await readEndpoint(root);
161
- lines.push(` listener: ${endpoint ? `endpoint.env present (port ${endpoint.port})` : "not configured — events spool to ~/.heed/spool"}`);
162
- lines.push(" next: npx heedvane doctor for scoped checks");
163
- return lines.join("\n");
164
- }
165
- /** One stdin line → one RPC dispatch. A blank or unparseable line never crashes the host's transport. */
166
- function dispatchLine(line, io) {
167
- const trimmed = line.trim();
168
- if (trimmed === "")
169
- return;
170
- try {
171
- handleRpc(JSON.parse(trimmed), io);
172
- }
173
- catch {
174
- return; // unparseable line: ignore (never crash the host's transport)
175
- }
176
- }
177
- /** Entry point for the `mcp` subcommand. */
178
- export function runMcpStdio(io) {
179
- const log = io.log ?? (() => { });
180
- const rl = createInterface({ input: process.stdin });
181
- rl.on("line", (line) => {
182
- dispatchLine(line, { write: io.write, log });
183
- });
184
- rl.on("close", () => {
185
- log("heedvane mcp: stdin closed");
186
- });
187
- }