heedvane 0.2.1 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -13
- package/dist/backend/credentials.d.ts +2 -0
- package/dist/backend/credentials.js +13 -0
- package/dist/backend/repo-remote.js +2 -1
- package/dist/backend/sign-in-change.js +3 -2
- package/dist/cli.d.ts +4 -2
- package/dist/cli.js +389 -144
- package/dist/clients/definitions.d.ts +8 -4
- package/dist/clients/definitions.js +48 -13
- package/dist/clients/hermes-plugin.d.ts +5 -20
- package/dist/clients/hermes-plugin.js +97 -71
- package/dist/clients/hook-files.d.ts +22 -0
- package/dist/clients/hook-files.js +39 -0
- package/dist/clients/pi-extension.d.ts +9 -0
- package/dist/clients/pi-extension.js +41 -0
- package/dist/commands/heedvane.js +18 -3
- package/dist/commands/mcp-stub.d.ts +1 -0
- package/dist/commands/mcp-stub.js +44 -0
- package/dist/commands/memory.js +6 -1
- package/dist/dispatchers/posix.d.ts +16 -19
- package/dist/dispatchers/posix.js +71 -91
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +113 -29
- package/dist/guard.d.ts +73 -0
- package/dist/guard.js +302 -0
- package/dist/heed-file.d.ts +19 -3
- package/dist/heed-file.js +46 -23
- package/dist/hook-fallback.d.ts +73 -0
- package/dist/hook-fallback.js +61 -0
- package/dist/installer.d.ts +13 -6
- package/dist/installer.js +40 -13
- package/dist/listener/background.d.ts +48 -4
- package/dist/listener/background.js +153 -19
- package/dist/listener/endpoint.d.ts +26 -3
- package/dist/listener/endpoint.js +55 -10
- package/dist/listener/forward.d.ts +7 -1
- package/dist/listener/forward.js +40 -12
- package/dist/listener/serve.d.ts +37 -0
- package/dist/listener/serve.js +196 -32
- package/dist/listener/server.d.ts +9 -0
- package/dist/listener/server.js +59 -22
- package/dist/listener/spool.d.ts +2 -17
- package/dist/listener/spool.js +6 -3
- package/dist/mcp.d.ts +38 -48
- package/dist/mcp.js +196 -63
- package/dist/plan.d.ts +47 -8
- package/dist/plan.js +164 -52
- package/dist/render.js +8 -8
- package/dist/reply/harness-output.d.ts +9 -0
- package/dist/reply/harness-output.js +13 -0
- package/dist/reply/reply.js +2 -1
- package/dist/skill/content.d.ts +2 -2
- package/dist/skill/content.js +11 -8
- package/dist/status.d.ts +1 -0
- package/dist/status.js +29 -0
- package/dist/steering/rules.js +4 -2
- package/dist/uninstall.d.ts +7 -0
- package/dist/uninstall.js +72 -71
- package/package.json +10 -3
- package/skills/heedvane-hooks/SKILL.md +6 -6
- package/skills/heedvane-hooks/references/events.md +5 -2
- package/dist/dispatchers/windows.d.ts +0 -15
- package/dist/dispatchers/windows.js +0 -50
- package/dist/mcp-server.d.ts +0 -9
- package/dist/mcp-server.js +0 -187
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 {
|
|
10
|
+
import { removeHookFiles } from "./clients/hook-files.js";
|
|
12
11
|
import { applyEntry } from "./apply.js";
|
|
13
|
-
import { isOurHookGroup
|
|
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 {
|
|
21
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
/**
|
|
138
|
-
async function
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
|
|
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
|
-
/**
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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.
|
|
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
|
|
14
|
-
3. **Setup mechanism** — `heedvane
|
|
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
|
|
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
|
-
- [ ]
|
|
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
|
|
65
|
-
|
|
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
|
-
}
|
package/dist/mcp-server.d.ts
DELETED
|
@@ -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;
|
package/dist/mcp-server.js
DELETED
|
@@ -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
|
-
}
|