heedvane 0.0.0-stage → 0.2.0
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 +62 -2
- package/dist/apply.d.ts +104 -0
- package/dist/apply.js +388 -0
- package/dist/backend/client.d.ts +40 -0
- package/dist/backend/client.js +32 -0
- package/dist/backend/credentials.d.ts +15 -0
- package/dist/backend/credentials.js +76 -0
- package/dist/backend/login.d.ts +33 -0
- package/dist/backend/login.js +265 -0
- package/dist/backend/repo-remote.d.ts +15 -0
- package/dist/backend/repo-remote.js +61 -0
- package/dist/backend/sign-in-change.d.ts +12 -0
- package/dist/backend/sign-in-change.js +72 -0
- package/dist/best-effort.d.ts +4 -0
- package/dist/best-effort.js +25 -0
- package/dist/cli.d.ts +29 -0
- package/dist/cli.js +666 -0
- package/dist/clients/definitions.d.ts +113 -0
- package/dist/clients/definitions.js +427 -0
- package/dist/clients/hermes-plugin.d.ts +33 -0
- package/dist/clients/hermes-plugin.js +247 -0
- package/dist/commands/heedvane.d.ts +15 -0
- package/dist/commands/heedvane.js +74 -0
- package/dist/commands/memory.d.ts +10 -0
- package/dist/commands/memory.js +106 -0
- package/dist/config-doc.d.ts +97 -0
- package/dist/config-doc.js +465 -0
- package/dist/dispatchers/posix.d.ts +41 -0
- package/dist/dispatchers/posix.js +208 -0
- package/dist/dispatchers/snippets.d.ts +6 -0
- package/dist/dispatchers/snippets.js +39 -0
- package/dist/dispatchers/windows.d.ts +15 -0
- package/dist/dispatchers/windows.js +50 -0
- package/dist/doctor.d.ts +37 -0
- package/dist/doctor.js +303 -0
- package/dist/events/contract.d.ts +54 -0
- package/dist/events/contract.js +87 -0
- package/dist/events/normalize.d.ts +36 -0
- package/dist/events/normalize.js +143 -0
- package/dist/guards.d.ts +18 -0
- package/dist/guards.js +28 -0
- package/dist/heed-file.d.ts +78 -0
- package/dist/heed-file.js +316 -0
- package/dist/installer.d.ts +47 -0
- package/dist/installer.js +174 -0
- package/dist/listener/background.d.ts +8 -0
- package/dist/listener/background.js +36 -0
- package/dist/listener/endpoint.d.ts +26 -0
- package/dist/listener/endpoint.js +125 -0
- package/dist/listener/forward.d.ts +36 -0
- package/dist/listener/forward.js +257 -0
- package/dist/listener/serve.d.ts +2 -0
- package/dist/listener/serve.js +155 -0
- package/dist/listener/server.d.ts +48 -0
- package/dist/listener/server.js +251 -0
- package/dist/listener/spool.d.ts +28 -0
- package/dist/listener/spool.js +60 -0
- package/dist/mcp-server.d.ts +9 -0
- package/dist/mcp-server.js +187 -0
- package/dist/mcp.d.ts +57 -0
- package/dist/mcp.js +83 -0
- package/dist/memory/private-memory.d.ts +12 -0
- package/dist/memory/private-memory.js +54 -0
- package/dist/memory/table-of-contents.d.ts +10 -0
- package/dist/memory/table-of-contents.js +80 -0
- package/dist/memory/team-memory.d.ts +60 -0
- package/dist/memory/team-memory.js +54 -0
- package/dist/plan.d.ts +77 -0
- package/dist/plan.js +288 -0
- package/dist/render.d.ts +32 -0
- package/dist/render.js +132 -0
- package/dist/reply/harness-output.d.ts +57 -0
- package/dist/reply/harness-output.js +88 -0
- package/dist/reply/reply.d.ts +65 -0
- package/dist/reply/reply.js +169 -0
- package/dist/reply/verdict.d.ts +28 -0
- package/dist/reply/verdict.js +17 -0
- package/dist/skill/bundle.d.ts +23 -0
- package/dist/skill/bundle.js +102 -0
- package/dist/skill/content.d.ts +16 -0
- package/dist/skill/content.js +182 -0
- package/dist/skills/approved.d.ts +31 -0
- package/dist/skills/approved.js +123 -0
- package/dist/skills/check.d.ts +21 -0
- package/dist/skills/check.js +83 -0
- package/dist/state.d.ts +51 -0
- package/dist/state.js +118 -0
- package/dist/steering/rules.d.ts +49 -0
- package/dist/steering/rules.js +165 -0
- package/dist/steering/steer.d.ts +29 -0
- package/dist/steering/steer.js +64 -0
- package/dist/steering/violations.d.ts +27 -0
- package/dist/steering/violations.js +24 -0
- package/dist/uninstall.d.ts +23 -0
- package/dist/uninstall.js +159 -0
- package/package.json +35 -4
- package/skills/heedvane-hooks/SKILL.md +87 -0
- package/skills/heedvane-hooks/references/events.md +69 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { endpointPath, readEndpoint } from "./endpoint.js";
|
|
4
|
+
import { drainSpool } from "./spool.js";
|
|
5
|
+
import { normalize } from "../events/normalize.js";
|
|
6
|
+
import { errorMessage } from "../guards.js";
|
|
7
|
+
/** Wire-protocol version stamped by installed dispatchers (`x-heed-hook-version`). */
|
|
8
|
+
const WIRE_PROTOCOL_VERSION = 1;
|
|
9
|
+
/** Agent ids accepted on `/hook/<agentId>`. */
|
|
10
|
+
export const KNOWN_AGENTS = new Set([
|
|
11
|
+
"claude",
|
|
12
|
+
"codex",
|
|
13
|
+
"gemini",
|
|
14
|
+
"cursor",
|
|
15
|
+
"opencode",
|
|
16
|
+
"qwen-code",
|
|
17
|
+
"kimi",
|
|
18
|
+
"droid",
|
|
19
|
+
"antigravity",
|
|
20
|
+
"copilot_cli",
|
|
21
|
+
"windsurf",
|
|
22
|
+
"hermes",
|
|
23
|
+
]);
|
|
24
|
+
/** Request body cap: 5 MB. */
|
|
25
|
+
export const MAX_BODY_BYTES = 5_242_880;
|
|
26
|
+
/** Hard per-request timeout (slowloris guard): destroy the socket. */
|
|
27
|
+
const REQUEST_TIMEOUT_MS = 5_000;
|
|
28
|
+
/**
|
|
29
|
+
* Constant-time token comparison. Both sides are hashed first so the buffers
|
|
30
|
+
* always match in length (timingSafeEqual throws on length mismatch, and a
|
|
31
|
+
* length-leaking early return would defeat the point).
|
|
32
|
+
*/
|
|
33
|
+
export function safeTokenEqual(a, b) {
|
|
34
|
+
const da = createHash("sha256").update(a, "utf8").digest();
|
|
35
|
+
const db = createHash("sha256").update(b, "utf8").digest();
|
|
36
|
+
return timingSafeEqual(da, db);
|
|
37
|
+
}
|
|
38
|
+
class BodyTooLargeError extends Error {
|
|
39
|
+
constructor() {
|
|
40
|
+
super("request body exceeds the cap");
|
|
41
|
+
this.name = "BodyTooLargeError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read the full body, but only retain it while under the cap (we still drain
|
|
46
|
+
* the rest so the client's write side always completes, which keeps any HTTP
|
|
47
|
+
* client — fetch included — reliably receiving the 413 response).
|
|
48
|
+
*/
|
|
49
|
+
async function readBody(req) {
|
|
50
|
+
let size = 0;
|
|
51
|
+
const chunks = [];
|
|
52
|
+
for await (const chunk of req) {
|
|
53
|
+
const buf = chunk;
|
|
54
|
+
size += buf.length;
|
|
55
|
+
if (size <= MAX_BODY_BYTES)
|
|
56
|
+
chunks.push(buf);
|
|
57
|
+
}
|
|
58
|
+
if (size > MAX_BODY_BYTES) {
|
|
59
|
+
throw new BodyTooLargeError();
|
|
60
|
+
}
|
|
61
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
62
|
+
}
|
|
63
|
+
function sendJson(res, status, body) {
|
|
64
|
+
if (res.writableEnded || res.destroyed)
|
|
65
|
+
return;
|
|
66
|
+
if (res.headersSent) {
|
|
67
|
+
res.end();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
71
|
+
res.end(JSON.stringify(body));
|
|
72
|
+
}
|
|
73
|
+
const ROUTE_PATTERN = /^\/hook\/([A-Za-z0-9_-]+)$/;
|
|
74
|
+
/** The route's agent id, or null after answering 404/405. */
|
|
75
|
+
function routeAgent(req, res, agents) {
|
|
76
|
+
const route = ROUTE_PATTERN.exec(new URL(req.url ?? "/", "http://127.0.0.1").pathname);
|
|
77
|
+
if (route === null) {
|
|
78
|
+
sendJson(res, 404, { error: "not found" });
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const agent = route[1];
|
|
82
|
+
if (req.method !== "POST") {
|
|
83
|
+
sendJson(res, 405, { error: "method not allowed; POST only" });
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (!agents.has(agent)) {
|
|
87
|
+
sendJson(res, 404, { error: `unknown agent route: /hook/${agent}` });
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
return agent;
|
|
91
|
+
}
|
|
92
|
+
/** Constant-time token check against endpoint.env (401 on mismatch or missing header). */
|
|
93
|
+
function hasValidToken(req, token) {
|
|
94
|
+
const header = req.headers["x-heed-agent-hook-token"];
|
|
95
|
+
const provided = Array.isArray(header) ? header[0] : header;
|
|
96
|
+
return typeof provided === "string" && safeTokenEqual(provided, token);
|
|
97
|
+
}
|
|
98
|
+
/** Warn once per (agent, version) so stale installed scripts are diagnosable without dropping their data. */
|
|
99
|
+
function warnOnVersionMismatch(req, deps, agent) {
|
|
100
|
+
const versionHeader = req.headers["x-heed-hook-version"];
|
|
101
|
+
const version = Array.isArray(versionHeader) ? versionHeader[0] : versionHeader;
|
|
102
|
+
if (version === String(WIRE_PROTOCOL_VERSION))
|
|
103
|
+
return;
|
|
104
|
+
const warnKey = `${agent}\u001f${version ?? "<missing>"}`;
|
|
105
|
+
if (deps.warned.has(warnKey))
|
|
106
|
+
return;
|
|
107
|
+
deps.warned.add(warnKey);
|
|
108
|
+
deps.log(`[heedvane] /hook/${agent}: hook version ${version ?? "<missing>"} ` +
|
|
109
|
+
`(expected ${WIRE_PROTOCOL_VERSION}); accepted — reinstall hooks via ` +
|
|
110
|
+
`"npx heedvane setup" if this persists`);
|
|
111
|
+
}
|
|
112
|
+
/** The request body under the cap, or null after answering 413. Non-cap failures propagate. */
|
|
113
|
+
async function readBodyUnderCap(req, res) {
|
|
114
|
+
const timer = setTimeout(() => req.socket.destroy(), REQUEST_TIMEOUT_MS);
|
|
115
|
+
try {
|
|
116
|
+
return await readBody(req);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
if (err instanceof BodyTooLargeError) {
|
|
120
|
+
sendJson(res, 413, { error: `body exceeds ${MAX_BODY_BYTES} bytes` });
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
// Socket destroyed (timeout/slowloris) or client aborted — nothing to answer.
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** The parsed JSON body, or null after answering 400 (unparseable or not an object). */
|
|
131
|
+
function parseJsonObject(bodyText, res) {
|
|
132
|
+
let body;
|
|
133
|
+
try {
|
|
134
|
+
body = JSON.parse(bodyText);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
sendJson(res, 400, { error: "body is not valid JSON" });
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
141
|
+
sendJson(res, 400, { error: "body must be a JSON object" });
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return body;
|
|
145
|
+
}
|
|
146
|
+
/** Last-resort responder: an async failure must not hang or crash the process. */
|
|
147
|
+
function internalErrorResponder(res) {
|
|
148
|
+
return () => {
|
|
149
|
+
sendJson(res, 500, { error: "internal error" });
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
async function handleRequest(req, res, deps) {
|
|
153
|
+
const agent = routeAgent(req, res, deps.agents);
|
|
154
|
+
if (agent === null)
|
|
155
|
+
return;
|
|
156
|
+
if (!hasValidToken(req, deps.token)) {
|
|
157
|
+
sendJson(res, 401, { error: "invalid or missing x-heed-agent-hook-token" });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Wire-protocol version: accept anything, but surface stale installed scripts.
|
|
161
|
+
warnOnVersionMismatch(req, deps, agent);
|
|
162
|
+
// Slowloris guard: if the body hasn't fully arrived within 5 s, destroy the
|
|
163
|
+
// socket. Node's requestTimeout/headersTimeout (set below) cover stalls
|
|
164
|
+
// before the request handler even fires.
|
|
165
|
+
const bodyText = await readBodyUnderCap(req, res);
|
|
166
|
+
if (bodyText === null)
|
|
167
|
+
return;
|
|
168
|
+
const body = parseJsonObject(bodyText, res);
|
|
169
|
+
if (body === null)
|
|
170
|
+
return;
|
|
171
|
+
// Normalize at the door: every accepted event leaves as the canonical
|
|
172
|
+
// envelope — one taxonomy for all ingest paths (listener, spool replay).
|
|
173
|
+
const envelope = normalize(agent, body, { id: eventId(req.headers["x-heed-event-id"]) });
|
|
174
|
+
// The dispatcher prints this body as the agent's hook output; `{}` lets the agent continue.
|
|
175
|
+
sendJson(res, 200, (await deps.onEvent({ agent, body, envelope, headers: req.headers })) ?? {});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Start the loopback hook listener: binds 127.0.0.1 only (never 0.0.0.0),
|
|
179
|
+
* serves POST /hook/<agentId>, auths via x-heed-agent-hook-token against
|
|
180
|
+
* endpoint.env. Refuses to start (with remediation) when endpoint.env is
|
|
181
|
+
* missing or has no TOKEN.
|
|
182
|
+
*/
|
|
183
|
+
export async function startListener(options) {
|
|
184
|
+
const endpoint = await readEndpoint(options.root);
|
|
185
|
+
if (endpoint === null) {
|
|
186
|
+
throw new Error(`No endpoint.env at ${endpointPath(options.root)} — the listener has no port/token to serve. ` +
|
|
187
|
+
`Remediation: run "npx heedvane setup" to write ${endpointPath(options.root)} ` +
|
|
188
|
+
`with PORT and TOKEN lines (mode 0600), then start the listener again.`);
|
|
189
|
+
}
|
|
190
|
+
if (!endpoint.token) {
|
|
191
|
+
throw new Error(`endpoint.env at ${endpointPath(options.root)} has no TOKEN — every hook dispatcher would 401. ` +
|
|
192
|
+
`Remediation: run "npx heedvane setup" to (re)issue the listener token, ` +
|
|
193
|
+
`or add a HEED_AGENT_HOOK_TOKEN=<random hex> line to ${endpointPath(options.root)}.`);
|
|
194
|
+
}
|
|
195
|
+
const log = options.log ?? ((message) => console.log(message));
|
|
196
|
+
const agents = options.knownAgents ?? KNOWN_AGENTS;
|
|
197
|
+
const deps = {
|
|
198
|
+
agents,
|
|
199
|
+
token: endpoint.token,
|
|
200
|
+
onEvent: options.onEvent,
|
|
201
|
+
log,
|
|
202
|
+
warned: new Set(),
|
|
203
|
+
};
|
|
204
|
+
const server = createServer((req, res) => {
|
|
205
|
+
handleRequest(req, res, deps).catch(internalErrorResponder(res));
|
|
206
|
+
});
|
|
207
|
+
// Node-level slowloris hardening (covers stalls before the request handler
|
|
208
|
+
// fires); requestTimeout must be larger than headersTimeout.
|
|
209
|
+
server.requestTimeout = REQUEST_TIMEOUT_MS;
|
|
210
|
+
server.headersTimeout = REQUEST_TIMEOUT_MS - 500;
|
|
211
|
+
await new Promise((resolve, reject) => {
|
|
212
|
+
const onListenError = (err) => reject(err);
|
|
213
|
+
server.once("error", onListenError);
|
|
214
|
+
server.listen(endpoint.port, "127.0.0.1", () => {
|
|
215
|
+
server.off("error", onListenError);
|
|
216
|
+
server.on("error", (err) => log(`[heedvane] listener error: ${err.message}`));
|
|
217
|
+
resolve();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
const address = server.address();
|
|
221
|
+
const port = address !== null && typeof address === "object" ? address.port : endpoint.port;
|
|
222
|
+
// Events the dispatchers spooled while no listener was up enter through the same door.
|
|
223
|
+
// A replay that fails (a full disk) leaves the spool file whole for the next start and never keeps the
|
|
224
|
+
// listener, and with it the deny rules, from starting.
|
|
225
|
+
const replayed = await drainSpool(options.root, async (spooled) => {
|
|
226
|
+
let body;
|
|
227
|
+
try {
|
|
228
|
+
body = JSON.parse(spooled.payload);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
log(`[heedvane] dropped a spooled ${spooled.source} event whose payload is not JSON`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
235
|
+
return;
|
|
236
|
+
const envelope = normalize(spooled.source, body, { receivedAt: spooledTime(spooled.receivedAt), id: eventId(spooled.id) });
|
|
237
|
+
await options.onEvent({ agent: spooled.source, body, envelope, headers: {}, replayed: true });
|
|
238
|
+
}).catch((err) => {
|
|
239
|
+
log(`[heedvane] could not replay the spooled events (kept for the next start): ${errorMessage(err)}`);
|
|
240
|
+
return 0;
|
|
241
|
+
});
|
|
242
|
+
return { server, port, replayed };
|
|
243
|
+
}
|
|
244
|
+
/** The dispatcher's event id when it is a plausible one, else a fresh one (scripts older than ids). */
|
|
245
|
+
function eventId(value) {
|
|
246
|
+
return typeof value === "string" && /^[A-Za-z0-9-]{8,64}$/.test(value) ? value : randomUUID();
|
|
247
|
+
}
|
|
248
|
+
/** When the dispatcher spooled the event, or now when its stamp is not a date. */
|
|
249
|
+
function spooledTime(value) {
|
|
250
|
+
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? new Date(value).toISOString() : undefined;
|
|
251
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One spooled hook event, exactly as the dispatcher script (`spool_hook_event` in
|
|
3
|
+
* dispatchers/posix.ts, the only writer) appends it: `payload` is the raw hook JSON text
|
|
4
|
+
* the agent piped in, `source` the agent id. Cap and TTL are enforced by that writer.
|
|
5
|
+
*/
|
|
6
|
+
export interface SpooledEvent {
|
|
7
|
+
paneKey: string;
|
|
8
|
+
tabId: string;
|
|
9
|
+
worktreeId: string;
|
|
10
|
+
launchToken: string;
|
|
11
|
+
source: string;
|
|
12
|
+
receivedAt: string;
|
|
13
|
+
payload: string;
|
|
14
|
+
/** The event's unique id (absent in lines from scripts older than ids). */
|
|
15
|
+
id?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Directory under the heed root that holds per-pane spool files. */
|
|
18
|
+
export declare function spoolDir(root: string): string;
|
|
19
|
+
/** A pane spool file, or one a drain claimed (renamed away from the dispatcher) and has not finished. */
|
|
20
|
+
export declare function isPaneSpoolFile(name: string): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Drain every pane spool file: claim it by renaming it, so the dispatcher's next append starts a fresh
|
|
23
|
+
* file and none is lost between reading and emptying; parse each line as JSON and hand it to `onEvent`;
|
|
24
|
+
* then delete the claimed file. A claimed file a failed or killed drain left is drained again by the
|
|
25
|
+
* next call. Lines that fail to parse are skipped (a torn append from a killed dispatcher). Returns the
|
|
26
|
+
* number of events delivered.
|
|
27
|
+
*/
|
|
28
|
+
export declare function drainSpool(root: string, onEvent: (event: SpooledEvent) => void | Promise<void>): Promise<number>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readdir, readFile, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/** Directory under the heed root that holds per-pane spool files. */
|
|
4
|
+
export function spoolDir(root) {
|
|
5
|
+
return join(root, "spool");
|
|
6
|
+
}
|
|
7
|
+
/** One spool line, or null for a torn append from a killed dispatcher (dropped by design). */
|
|
8
|
+
function parseSpoolLine(line) {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(line);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** A pane spool file, or one a drain claimed (renamed away from the dispatcher) and has not finished. */
|
|
17
|
+
export function isPaneSpoolFile(name) {
|
|
18
|
+
return name.startsWith("pane-") && (name.endsWith(".jsonl") || name.includes(".jsonl.claimed-"));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Drain every pane spool file: claim it by renaming it, so the dispatcher's next append starts a fresh
|
|
22
|
+
* file and none is lost between reading and emptying; parse each line as JSON and hand it to `onEvent`;
|
|
23
|
+
* then delete the claimed file. A claimed file a failed or killed drain left is drained again by the
|
|
24
|
+
* next call. Lines that fail to parse are skipped (a torn append from a killed dispatcher). Returns the
|
|
25
|
+
* number of events delivered.
|
|
26
|
+
*/
|
|
27
|
+
export async function drainSpool(root, onEvent) {
|
|
28
|
+
const dir = spoolDir(root);
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = await readdir(dir);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
if (err.code === "ENOENT")
|
|
35
|
+
return 0;
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
let drained = 0;
|
|
39
|
+
for (const entry of entries.sort()) {
|
|
40
|
+
if (!isPaneSpoolFile(entry))
|
|
41
|
+
continue;
|
|
42
|
+
const claimed = entry.endsWith(".jsonl") ? `${entry}.claimed-${Date.now()}-${process.pid}` : entry;
|
|
43
|
+
const filePath = join(dir, claimed);
|
|
44
|
+
if (claimed !== entry)
|
|
45
|
+
await rename(join(dir, entry), filePath);
|
|
46
|
+
const content = await readFile(filePath, "utf8");
|
|
47
|
+
for (const line of content.split("\n")) {
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
if (!trimmed)
|
|
50
|
+
continue;
|
|
51
|
+
const event = parseSpoolLine(trimmed);
|
|
52
|
+
if (event === null)
|
|
53
|
+
continue;
|
|
54
|
+
await onEvent(event);
|
|
55
|
+
drained += 1;
|
|
56
|
+
}
|
|
57
|
+
await unlink(filePath);
|
|
58
|
+
}
|
|
59
|
+
return drained;
|
|
60
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
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;
|
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
}
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP registration entry builder per client.
|
|
3
|
+
*
|
|
4
|
+
* The heedvane MCP server is the universal integration channel — and the only
|
|
5
|
+
* policy channel for agents with no native hooks (Warp). Entries are stdio
|
|
6
|
+
* commands (`npx heedvane mcp`); an `--http` mode may be added later
|
|
7
|
+
* without changing the shape of these builders.
|
|
8
|
+
*
|
|
9
|
+
* Identity: uninstall and marker detection recognize our entries by the
|
|
10
|
+
* identity markers below (today's `npx -y heedvane mcp` args, plus the
|
|
11
|
+
* scoped and `heedvane-hooks` names development installs wrote), or by the
|
|
12
|
+
* reserved entry name `heedvane`.
|
|
13
|
+
*/
|
|
14
|
+
/** Reserved server entry name. Presence of this NAME alone marks the entry ours. */
|
|
15
|
+
export declare const HEEDVANE_SERVER_NAME = "heedvane";
|
|
16
|
+
/**
|
|
17
|
+
* Identity markers: an entry whose command/args contain any of these strings
|
|
18
|
+
* is a heedvane-managed entry, whatever name it is registered under. These are
|
|
19
|
+
* the names development builds wrote; today's entry is recognized by its whole
|
|
20
|
+
* argument list ({@link MCP_ENTRY_ARGS}), since the bare package name would
|
|
21
|
+
* also match a path like `~/Projects/heedvane`.
|
|
22
|
+
*/
|
|
23
|
+
export declare const IDENTITY_MARKERS: readonly ["@heedvane/hooks", "heedvane-hooks"];
|
|
24
|
+
/** Uninstall/detection marker for inline dispatcher snippets (hooks configs). */
|
|
25
|
+
export declare const SNIPPET_MARKER = "heedvane-agent-hook-form";
|
|
26
|
+
/** Args of the stdio MCP entry (command is always `npx`). */
|
|
27
|
+
export declare const MCP_ENTRY_ARGS: string[];
|
|
28
|
+
export interface McpServerEntry {
|
|
29
|
+
command: string;
|
|
30
|
+
args: string[];
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
/** The stdio MCP entry, for JSON-family configs (`mcpServers` / `servers` / `mcp`). */
|
|
34
|
+
export declare function mcpEntry(): McpServerEntry;
|
|
35
|
+
/**
|
|
36
|
+
* The stdio MCP entry for TOML configs. Codex reads `~/.codex/config.toml`
|
|
37
|
+
* where servers are `mcp_servers.<name>` and a slow `npx` cold start needs an
|
|
38
|
+
* explicit generous startup timeout.
|
|
39
|
+
*/
|
|
40
|
+
export declare function mcpEntryForToml(): McpServerEntry;
|
|
41
|
+
/** Per-client desired entry. Only Codex (TOML) carries the startup timeout. */
|
|
42
|
+
export declare function desiredServer(clientId: string): McpServerEntry;
|
|
43
|
+
/** Warp's MCP config is project-level and approval-gated. */
|
|
44
|
+
export declare const WARP_MCP_RELATIVE_PATH = ".warp/.mcp.json";
|
|
45
|
+
/**
|
|
46
|
+
* Honest limitation surfaced by init/doctor for Warp. Warp has no native
|
|
47
|
+
* hooks and its MCP support is project-scoped and import-gated: the user must
|
|
48
|
+
* approve the import in Warp, per project, before the server activates.
|
|
49
|
+
* Policies therefore arrive via MCP tools, not hooks.
|
|
50
|
+
*/
|
|
51
|
+
export declare const WARP_MCP_LIMITATION: string;
|
|
52
|
+
/**
|
|
53
|
+
* Is this server entry a heedvane-managed one? True when the entry NAME is
|
|
54
|
+
* the reserved `heedvane` name, or when the entry's command/args carry an
|
|
55
|
+
* identity marker. Used by detectMarker and doctor.
|
|
56
|
+
*/
|
|
57
|
+
export declare function isHeedvaneEntry(name: string, entry: unknown): boolean;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP registration entry builder per client.
|
|
3
|
+
*
|
|
4
|
+
* The heedvane MCP server is the universal integration channel — and the only
|
|
5
|
+
* policy channel for agents with no native hooks (Warp). Entries are stdio
|
|
6
|
+
* commands (`npx heedvane mcp`); an `--http` mode may be added later
|
|
7
|
+
* without changing the shape of these builders.
|
|
8
|
+
*
|
|
9
|
+
* Identity: uninstall and marker detection recognize our entries by the
|
|
10
|
+
* identity markers below (today's `npx -y heedvane mcp` args, plus the
|
|
11
|
+
* scoped and `heedvane-hooks` names development installs wrote), or by the
|
|
12
|
+
* reserved entry name `heedvane`.
|
|
13
|
+
*/
|
|
14
|
+
/** Reserved server entry name. Presence of this NAME alone marks the entry ours. */
|
|
15
|
+
export const HEEDVANE_SERVER_NAME = "heedvane";
|
|
16
|
+
/**
|
|
17
|
+
* Identity markers: an entry whose command/args contain any of these strings
|
|
18
|
+
* is a heedvane-managed entry, whatever name it is registered under. These are
|
|
19
|
+
* the names development builds wrote; today's entry is recognized by its whole
|
|
20
|
+
* argument list ({@link MCP_ENTRY_ARGS}), since the bare package name would
|
|
21
|
+
* also match a path like `~/Projects/heedvane`.
|
|
22
|
+
*/
|
|
23
|
+
export const IDENTITY_MARKERS = ["@heedvane/hooks", "heedvane-hooks"];
|
|
24
|
+
/** Uninstall/detection marker for inline dispatcher snippets (hooks configs). */
|
|
25
|
+
export const SNIPPET_MARKER = "heedvane-agent-hook-form";
|
|
26
|
+
/** The npm package name entries carry; marker + entry builder use it. */
|
|
27
|
+
const MCP_ENTRY_COMMAND = "heedvane";
|
|
28
|
+
/** Args of the stdio MCP entry (command is always `npx`). */
|
|
29
|
+
export const MCP_ENTRY_ARGS = ["-y", MCP_ENTRY_COMMAND, "mcp"];
|
|
30
|
+
/** The stdio MCP entry, for JSON-family configs (`mcpServers` / `servers` / `mcp`). */
|
|
31
|
+
export function mcpEntry() {
|
|
32
|
+
return { command: "npx", args: [...MCP_ENTRY_ARGS] };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The stdio MCP entry for TOML configs. Codex reads `~/.codex/config.toml`
|
|
36
|
+
* where servers are `mcp_servers.<name>` and a slow `npx` cold start needs an
|
|
37
|
+
* explicit generous startup timeout.
|
|
38
|
+
*/
|
|
39
|
+
export function mcpEntryForToml() {
|
|
40
|
+
return { command: "npx", args: [...MCP_ENTRY_ARGS], startup_timeout_sec: 30 };
|
|
41
|
+
}
|
|
42
|
+
/** Per-client desired entry. Only Codex (TOML) carries the startup timeout. */
|
|
43
|
+
export function desiredServer(clientId) {
|
|
44
|
+
return clientId === "codex" ? mcpEntryForToml() : mcpEntry();
|
|
45
|
+
}
|
|
46
|
+
/** Warp's MCP config is project-level and approval-gated. */
|
|
47
|
+
export const WARP_MCP_RELATIVE_PATH = ".warp/.mcp.json";
|
|
48
|
+
/**
|
|
49
|
+
* Honest limitation surfaced by init/doctor for Warp. Warp has no native
|
|
50
|
+
* hooks and its MCP support is project-scoped and import-gated: the user must
|
|
51
|
+
* approve the import in Warp, per project, before the server activates.
|
|
52
|
+
* Policies therefore arrive via MCP tools, not hooks.
|
|
53
|
+
*/
|
|
54
|
+
export const WARP_MCP_LIMITATION = "Warp limitation: Warp has no native hooks support, and its MCP servers are project-scoped and " +
|
|
55
|
+
"approval-gated. Heedvane writes a project-level .warp/.mcp.json for the current project only; " +
|
|
56
|
+
"you must approve the heedvane MCP import inside Warp (Settings > MCP) before it activates, and " +
|
|
57
|
+
"repeat that approval in every project you use it in. Hooks are unsupported in Warp — policies " +
|
|
58
|
+
"arrive via MCP tools once the server import is approved.";
|
|
59
|
+
/**
|
|
60
|
+
* Does this entry's command/args carry a heedvane identity marker? This is
|
|
61
|
+
* the STRICT check used by uninstall: an entry is only removed when its
|
|
62
|
+
* command/args prove it is ours. A name match without the marker is a
|
|
63
|
+
* renamed/replaced entry and is left alone.
|
|
64
|
+
*/
|
|
65
|
+
function hasHeedvaneMarker(entry) {
|
|
66
|
+
if (entry === null || typeof entry !== "object")
|
|
67
|
+
return false;
|
|
68
|
+
const e = entry;
|
|
69
|
+
const command = typeof e.command === "string" ? e.command : "";
|
|
70
|
+
const args = Array.isArray(e.args) ? e.args.filter((a) => typeof a === "string") : [];
|
|
71
|
+
const haystack = [command, ...args].join(" ");
|
|
72
|
+
return haystack.includes(MCP_ENTRY_ARGS.join(" ")) || IDENTITY_MARKERS.some((m) => haystack.includes(m));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Is this server entry a heedvane-managed one? True when the entry NAME is
|
|
76
|
+
* the reserved `heedvane` name, or when the entry's command/args carry an
|
|
77
|
+
* identity marker. Used by detectMarker and doctor.
|
|
78
|
+
*/
|
|
79
|
+
export function isHeedvaneEntry(name, entry) {
|
|
80
|
+
if (name === HEEDVANE_SERVER_NAME)
|
|
81
|
+
return true;
|
|
82
|
+
return hasHeedvaneMarker(entry);
|
|
83
|
+
}
|