heedvane 0.2.0 → 0.3.3
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 +20 -5
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +139 -55
- package/dist/clients/definitions.d.ts +1 -1
- package/dist/clients/definitions.js +24 -9
- package/dist/commands/memory.js +6 -1
- package/dist/doctor.js +25 -9
- package/dist/heed-file.d.ts +14 -1
- package/dist/heed-file.js +43 -20
- package/dist/installer.d.ts +9 -2
- package/dist/installer.js +30 -4
- package/dist/listener/serve.js +1 -1
- package/dist/listener/server.d.ts +2 -0
- package/dist/listener/server.js +23 -3
- package/dist/mcp.d.ts +21 -50
- package/dist/mcp.js +96 -65
- package/dist/plan.d.ts +15 -5
- package/dist/plan.js +53 -26
- package/dist/render.js +6 -7
- package/dist/reply/reply.js +2 -1
- package/dist/skill/content.d.ts +2 -2
- package/dist/skill/content.js +5 -5
- package/dist/status.d.ts +1 -0
- package/dist/status.js +29 -0
- package/dist/uninstall.d.ts +2 -0
- package/dist/uninstall.js +54 -31
- package/package.json +1 -1
- package/skills/heedvane-hooks/SKILL.md +3 -3
- package/skills/heedvane-hooks/references/events.md +2 -2
- package/dist/mcp-server.d.ts +0 -9
- package/dist/mcp-server.js +0 -187
package/dist/heed-file.d.ts
CHANGED
|
@@ -27,9 +27,19 @@ export interface HeedSkillsConfig {
|
|
|
27
27
|
/** No synced approved list, or no listener to check it: let the skill run with a warning, or deny it. */
|
|
28
28
|
whenUnknown: "allow" | "deny";
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* What leaves this machine (the installer and data modes proposal, "Proposed data modes"). `look-but-dont-keep`:
|
|
32
|
+
* Heedvane's guardrail checks read the prompt or tool call and keep only the verdict. `metrics-only`: only counts and
|
|
33
|
+
* names leave; the organization's fixed rules still run here, Heedvane's own checks and team memory do not.
|
|
34
|
+
*/
|
|
35
|
+
export declare const DATA_MODES: readonly ["look-but-dont-keep", "metrics-only"];
|
|
36
|
+
export type DataMode = (typeof DATA_MODES)[number];
|
|
30
37
|
/** The Heedvane backend the listener asks for steering, approved skills and memory. */
|
|
31
38
|
export interface HeedvaneConfig {
|
|
32
39
|
url: string;
|
|
40
|
+
/** The organization `connect --org` named: Heedvane's MCP connection URL carries it. */
|
|
41
|
+
org?: string;
|
|
42
|
+
dataMode: DataMode;
|
|
33
43
|
steering: boolean;
|
|
34
44
|
memory: boolean;
|
|
35
45
|
skills: HeedSkillsConfig;
|
|
@@ -47,7 +57,7 @@ export interface HeedFile {
|
|
|
47
57
|
* The commented control file written by `npx heedvane init`.
|
|
48
58
|
* Kept loadable: loadHeedFile(DEFAULT_HEED_YAML_TEMPLATE) yields every default.
|
|
49
59
|
*/
|
|
50
|
-
export declare const DEFAULT_HEED_YAML_TEMPLATE = "# ~/.heed/heed.yaml \u2014 Heedvane agent hooks control file (v1)\nschemaVersion: v1\n\n# Where managed scripts and spool live. Default below; one install per machine.\nroot: ~/.heed\n\n# The hook listener (Orca's pattern): loopback HTTP, token-authed, per-agent routes.\nlistener:\n port: 0 # 0 = pick a free port at first start, persist to endpoint.env\n spool: true # spool to disk when listener is unreachable (5 MB cap, 7-day TTL)\n\n# Which agents to manage. `auto` = every detected agent on the machine.\nagents: auto # or an explicit list: [claude, codex, warp]\n\n# Which integrations to install per agent where supported.\ninstall:\n hooks: true # native hook config injection (skipped where the agent has none)\n skill: true # heedvane-hooks skill bundle -> ~/.agents/skills/heedvane-hooks/\n mcp: true # heedvane MCP server registration (universal fallback, incl. Warp)\n\n# Session gating (Orca's env-gated dispatcher pattern).\ngating:\n mode: always # always = every session; pane = only sessions launched with HEED_SESSION env\n\n# The Heedvane backend (credentials: npx heedvane auth, stored in ~/.heed/credentials.json).\nheedvane:\n url: https://app.heedvane.com\n steering: true # ask the organization's steering rules before tool calls, prompts and stops\n memory: true # add your private memories (~/.heed/memory, local only) at session start\n skills:\n enforce: true # deny skills that are not on the organization's approved list\n strict: false # also require the local SKILL.md to match the approved revision\n whenUnknown: allow # no synced list (run npx heedvane skills sync) or listener down: allow or deny\n";
|
|
60
|
+
export declare const DEFAULT_HEED_YAML_TEMPLATE = "# ~/.heed/heed.yaml \u2014 Heedvane agent hooks control file (v1)\nschemaVersion: v1\n\n# Where managed scripts and spool live. Default below; one install per machine.\nroot: ~/.heed\n\n# The hook listener (Orca's pattern): loopback HTTP, token-authed, per-agent routes.\nlistener:\n port: 0 # 0 = pick a free port at first start, persist to endpoint.env\n spool: true # spool to disk when listener is unreachable (5 MB cap, 7-day TTL)\n\n# Which agents to manage. `auto` = every detected agent on the machine.\nagents: auto # or an explicit list: [claude, codex, warp]\n\n# Which integrations to install per agent where supported.\ninstall:\n hooks: true # native hook config injection (skipped where the agent has none)\n skill: true # heedvane-hooks skill bundle -> ~/.agents/skills/heedvane-hooks/\n mcp: true # heedvane MCP server registration (universal fallback, incl. Warp)\n\n# Session gating (Orca's env-gated dispatcher pattern).\ngating:\n mode: always # always = every session; pane = only sessions launched with HEED_SESSION env\n\n# The Heedvane backend (credentials: npx heedvane auth, stored in ~/.heed/credentials.json).\nheedvane:\n url: https://app.heedvane.com\n dataMode: look-but-dont-keep # what leaves this machine: look-but-dont-keep (checks read prompts and tool calls, keep only verdicts) or metrics-only (only counts and names)\n steering: true # ask the organization's steering rules before tool calls, prompts and stops\n memory: true # add your private memories (~/.heed/memory, local only) at session start\n skills:\n enforce: true # deny skills that are not on the organization's approved list\n strict: false # also require the local SKILL.md to match the approved revision\n whenUnknown: allow # no synced list (run npx heedvane skills sync) or listener down: allow or deny\n";
|
|
51
61
|
/**
|
|
52
62
|
* Parse and validate a heed.yaml document, returning the fully normalized file.
|
|
53
63
|
* Throws {@link HeedFileError} (message prefixed "invalid heed.yaml:") on any problem:
|
|
@@ -55,6 +65,8 @@ export declare const DEFAULT_HEED_YAML_TEMPLATE = "# ~/.heed/heed.yaml \u2014 He
|
|
|
55
65
|
* (top-level or nested), or wrong-typed values.
|
|
56
66
|
*/
|
|
57
67
|
export declare function loadHeedFile(text: string): HeedFile;
|
|
68
|
+
/** `text` with each `[path, value]` written in place; the file's comments stay. Validate with {@link loadHeedFile}. */
|
|
69
|
+
export declare function withSettings(text: string, settings: ReadonlyArray<readonly [string[], unknown]>): string;
|
|
58
70
|
/** `text` with `heedvane.url` set to `url` (`connect --server`); the file's comments stay. Validate with {@link loadHeedFile}. */
|
|
59
71
|
export declare function withServerUrl(text: string, url: string): string;
|
|
60
72
|
/** What the interactive installer asks: which agents, which integrations, and which Heedvane features. */
|
|
@@ -62,6 +74,7 @@ export interface InstallChoices {
|
|
|
62
74
|
/** Chosen agent ids, or "auto" for every detected agent. */
|
|
63
75
|
agents: "auto" | string[];
|
|
64
76
|
install: HeedInstallConfig;
|
|
77
|
+
dataMode: DataMode;
|
|
65
78
|
steering: boolean;
|
|
66
79
|
memory: boolean;
|
|
67
80
|
skillChecks: boolean;
|
package/dist/heed-file.js
CHANGED
|
@@ -3,6 +3,12 @@ import { join } from "node:path";
|
|
|
3
3
|
import writeFileAtomic from "write-file-atomic";
|
|
4
4
|
import { isScalar, parse, parseDocument } from "yaml";
|
|
5
5
|
import { errorMessage, isRecord } from "./guards.js";
|
|
6
|
+
/**
|
|
7
|
+
* What leaves this machine (the installer and data modes proposal, "Proposed data modes"). `look-but-dont-keep`:
|
|
8
|
+
* Heedvane's guardrail checks read the prompt or tool call and keep only the verdict. `metrics-only`: only counts and
|
|
9
|
+
* names leave; the organization's fixed rules still run here, Heedvane's own checks and team memory do not.
|
|
10
|
+
*/
|
|
11
|
+
export const DATA_MODES = ["look-but-dont-keep", "metrics-only"];
|
|
6
12
|
/** Error raised for any invalid heed.yaml. The message is user-facing: keep it precise. */
|
|
7
13
|
class HeedFileError extends Error {
|
|
8
14
|
constructor(message) {
|
|
@@ -50,6 +56,7 @@ gating:
|
|
|
50
56
|
# The Heedvane backend (credentials: npx heedvane auth, stored in ~/.heed/credentials.json).
|
|
51
57
|
heedvane:
|
|
52
58
|
url: https://app.heedvane.com
|
|
59
|
+
dataMode: look-but-dont-keep # what leaves this machine: look-but-dont-keep (checks read prompts and tool calls, keep only verdicts) or metrics-only (only counts and names)
|
|
53
60
|
steering: true # ask the organization's steering rules before tool calls, prompts and stops
|
|
54
61
|
memory: true # add your private memories (~/.heed/memory, local only) at session start
|
|
55
62
|
skills:
|
|
@@ -165,12 +172,29 @@ function validateSkills(raw) {
|
|
|
165
172
|
}
|
|
166
173
|
return out;
|
|
167
174
|
}
|
|
175
|
+
/** `heedvane.org` and `heedvane.dataMode`, when set. */
|
|
176
|
+
function validateOrgAndDataMode(map) {
|
|
177
|
+
const out = {};
|
|
178
|
+
const { org, dataMode } = map;
|
|
179
|
+
if (org !== undefined) {
|
|
180
|
+
if (typeof org !== "string" || org.trim() === "")
|
|
181
|
+
throw new HeedFileError(`"heedvane.org" must be a non-empty string, got ${describeType(org)}`);
|
|
182
|
+
out.org = org.trim();
|
|
183
|
+
}
|
|
184
|
+
if (dataMode !== undefined) {
|
|
185
|
+
const known = DATA_MODES.find((mode) => mode === dataMode);
|
|
186
|
+
if (known === undefined)
|
|
187
|
+
throw new HeedFileError(`"heedvane.dataMode" must be one of ${DATA_MODES.join(", ")}, got ${JSON.stringify(dataMode)}`);
|
|
188
|
+
out.dataMode = known;
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
168
192
|
function validateHeedvane(raw) {
|
|
169
|
-
const out = { url: "https://app.heedvane.com", steering: true, memory: true, skills: validateSkills(undefined) };
|
|
193
|
+
const out = { url: "https://app.heedvane.com", dataMode: "look-but-dont-keep", steering: true, memory: true, skills: validateSkills(undefined) };
|
|
170
194
|
if (raw === undefined)
|
|
171
195
|
return out;
|
|
172
196
|
const map = expectMapping(raw, "heedvane");
|
|
173
|
-
rejectUnknownKeys(map, ["url", "steering", "memory", "skills"], "heedvane");
|
|
197
|
+
rejectUnknownKeys(map, ["url", "org", "dataMode", "steering", "memory", "skills"], "heedvane");
|
|
174
198
|
const url = map["url"];
|
|
175
199
|
if (url !== undefined) {
|
|
176
200
|
if (typeof url !== "string" || !URL.canParse(url) || !/^https?:$/.test(new URL(url).protocol)) {
|
|
@@ -178,6 +202,7 @@ function validateHeedvane(raw) {
|
|
|
178
202
|
}
|
|
179
203
|
out.url = url.replace(/\/+$/, "");
|
|
180
204
|
}
|
|
205
|
+
Object.assign(out, validateOrgAndDataMode(map));
|
|
181
206
|
if (map["steering"] !== undefined)
|
|
182
207
|
out.steering = expectBool(map["steering"], "heedvane.steering");
|
|
183
208
|
if (map["memory"] !== undefined)
|
|
@@ -225,36 +250,34 @@ export function loadHeedFile(text) {
|
|
|
225
250
|
heedvane: validateHeedvane(raw["heedvane"]),
|
|
226
251
|
};
|
|
227
252
|
}
|
|
228
|
-
/** `text` with `
|
|
229
|
-
export function
|
|
253
|
+
/** `text` with each `[path, value]` written in place; the file's comments stay. Validate with {@link loadHeedFile}. */
|
|
254
|
+
export function withSettings(text, settings) {
|
|
230
255
|
const document = parseDocument(text);
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
current
|
|
234
|
-
|
|
235
|
-
|
|
256
|
+
for (const [path, value] of settings) {
|
|
257
|
+
const current = document.getIn(path, true);
|
|
258
|
+
if (isScalar(current) && typeof value !== "object")
|
|
259
|
+
current.value = value;
|
|
260
|
+
else
|
|
261
|
+
document.setIn(path, value);
|
|
262
|
+
}
|
|
236
263
|
return String(document);
|
|
237
264
|
}
|
|
265
|
+
/** `text` with `heedvane.url` set to `url` (`connect --server`); the file's comments stay. Validate with {@link loadHeedFile}. */
|
|
266
|
+
export function withServerUrl(text, url) {
|
|
267
|
+
return withSettings(text, [[["heedvane", "url"], url]]);
|
|
268
|
+
}
|
|
238
269
|
/** `text` with the installer's choices written into the fields that already hold them; the file's comments stay. */
|
|
239
270
|
export function withInstallChoices(text, choices) {
|
|
240
|
-
|
|
241
|
-
const fields = [
|
|
271
|
+
return withSettings(text, [
|
|
242
272
|
[["agents"], choices.agents],
|
|
243
273
|
[["install", "hooks"], choices.install.hooks],
|
|
244
274
|
[["install", "skill"], choices.install.skill],
|
|
245
275
|
[["install", "mcp"], choices.install.mcp],
|
|
276
|
+
[["heedvane", "dataMode"], choices.dataMode],
|
|
246
277
|
[["heedvane", "steering"], choices.steering],
|
|
247
278
|
[["heedvane", "memory"], choices.memory],
|
|
248
279
|
[["heedvane", "skills", "enforce"], choices.skillChecks],
|
|
249
|
-
];
|
|
250
|
-
for (const [path, value] of fields) {
|
|
251
|
-
const current = document.getIn(path, true);
|
|
252
|
-
if (isScalar(current) && typeof value !== "object")
|
|
253
|
-
current.value = value;
|
|
254
|
-
else
|
|
255
|
-
document.setIn(path, value);
|
|
256
|
-
}
|
|
257
|
-
return String(document);
|
|
280
|
+
]);
|
|
258
281
|
}
|
|
259
282
|
/** Keys earlier versions wrote into heed.yaml that nothing reads any more (policies are assigned on the server). */
|
|
260
283
|
const RETIRED_KEYS = [["heedvane", "steeringTimeoutMs"], ["policy"]];
|
package/dist/installer.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Readable, Writable } from "node:stream";
|
|
2
|
-
import type { HeedFile, InstallChoices } from "./heed-file.js";
|
|
2
|
+
import type { DataMode, HeedFile, InstallChoices } from "./heed-file.js";
|
|
3
3
|
import type { PlannedAction } from "./plan.js";
|
|
4
4
|
import { type AppliedResult } from "./render.js";
|
|
5
5
|
/** Where the questions are read and drawn: the process's terminal unless a test injects streams. */
|
|
@@ -27,7 +27,9 @@ export declare function brandIntro(server: string, org: string | undefined, stre
|
|
|
27
27
|
/** The person's choices, or null when they cancelled (Ctrl-C) at any question. */
|
|
28
28
|
export declare function askChoices(agents: AgentOption[], current: HeedFile, streams: PromptStreams): Promise<InstallChoices | null>;
|
|
29
29
|
/** Shows what the install will do and asks to go ahead; false when the person declines or cancels. */
|
|
30
|
-
export declare function confirmInstall(agents: AgentLine[], streams: PromptStreams): Promise<boolean>;
|
|
30
|
+
export declare function confirmInstall(agents: AgentLine[], details: string[], streams: PromptStreams): Promise<boolean>;
|
|
31
|
+
/** The summary's lines about the whole install: what leaves the machine, and where the MCP connection points. */
|
|
32
|
+
export declare function installDetails(heedFile: HeedFile, connector: string | undefined): string[];
|
|
31
33
|
/** Runs the install behind a spinner; the spinner ends with the outcome either way. */
|
|
32
34
|
export declare function installWithSpinner<T>(agentCount: number, work: () => Promise<T>, streams: PromptStreams): Promise<T>;
|
|
33
35
|
/** One line per agent: a green tick and what it got, or what failed and why. */
|
|
@@ -45,3 +47,8 @@ export declare function agentLines(actions: PlannedAction[], names: ReadonlyMap<
|
|
|
45
47
|
export declare function installCancelled(streams: PromptStreams): number;
|
|
46
48
|
/** The closing lines after an install. */
|
|
47
49
|
export declare function installFinished(lines: string[], streams: PromptStreams): void;
|
|
50
|
+
/** What each data mode lets leave this machine, as the installer says it. */
|
|
51
|
+
export declare const DATA_MODE_LABEL: Record<DataMode, {
|
|
52
|
+
label: string;
|
|
53
|
+
hint: string;
|
|
54
|
+
}>;
|
package/dist/installer.js
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* the answers become `InstallChoices`, which cli.ts writes into heed.yaml and applies through the same
|
|
5
5
|
* plan as every other install, so a second run starts from the last answers.
|
|
6
6
|
*/
|
|
7
|
-
import { cancel, confirm, intro, isCancel, log, multiselect, note, outro, spinner } from "@clack/prompts";
|
|
7
|
+
import { cancel, confirm, intro, isCancel, log, multiselect, note, outro, select, spinner } from "@clack/prompts";
|
|
8
8
|
import { styleText } from "node:util";
|
|
9
9
|
import { appliedFailed } from "./render.js";
|
|
10
|
+
import { MCP_NEEDS_ORG } from "./mcp.js";
|
|
10
11
|
const INTEGRATION_LABEL = { hooks: "hooks", mcp: "MCP server", skill: "skill", skip: "" };
|
|
11
12
|
/** The header: the name, what Heedvane does, and which server and organization this install connects to. */
|
|
12
13
|
export function brandIntro(server, org, streams) {
|
|
@@ -19,6 +20,9 @@ export async function askChoices(agents, current, streams) {
|
|
|
19
20
|
const picked = await pickAgents(agents, current, streams);
|
|
20
21
|
if (picked === null)
|
|
21
22
|
return null;
|
|
23
|
+
const dataMode = await pickDataMode(current.heedvane.dataMode, streams);
|
|
24
|
+
if (dataMode === null)
|
|
25
|
+
return null;
|
|
22
26
|
const install = await pickIntegrations(current.install, streams);
|
|
23
27
|
if (install === null)
|
|
24
28
|
return null;
|
|
@@ -28,18 +32,26 @@ export async function askChoices(agents, current, streams) {
|
|
|
28
32
|
return {
|
|
29
33
|
agents: sameAsDetected(picked, agents) ? "auto" : picked,
|
|
30
34
|
install,
|
|
35
|
+
dataMode,
|
|
31
36
|
steering: features.includes("steering"),
|
|
32
37
|
skillChecks: features.includes("skillChecks"),
|
|
33
38
|
memory: features.includes("memory"),
|
|
34
39
|
};
|
|
35
40
|
}
|
|
36
41
|
/** Shows what the install will do and asks to go ahead; false when the person declines or cancels. */
|
|
37
|
-
export async function confirmInstall(agents, streams) {
|
|
42
|
+
export async function confirmInstall(agents, details, streams) {
|
|
38
43
|
const footer = styleText("dim", "Choices are saved in ~/.heed/heed.yaml. Each config file is backed up before it changes.");
|
|
39
|
-
note([...agentRows(agents), "", footer].join("\n"), "Ready to install", streams);
|
|
44
|
+
note([...agentRows(agents), "", ...details, footer].join("\n"), "Ready to install", streams);
|
|
40
45
|
const answer = await confirm({ message: "Install now?", initialValue: true, ...streams });
|
|
41
46
|
return !isCancel(answer) && answer;
|
|
42
47
|
}
|
|
48
|
+
/** The summary's lines about the whole install: what leaves the machine, and where the MCP connection points. */
|
|
49
|
+
export function installDetails(heedFile, connector) {
|
|
50
|
+
const lines = [`What leaves this machine: ${DATA_MODE_LABEL[heedFile.heedvane.dataMode].label}`];
|
|
51
|
+
if (!heedFile.install.mcp)
|
|
52
|
+
return lines;
|
|
53
|
+
return [...lines, connector === undefined ? MCP_NEEDS_ORG : `Heedvane's MCP connection: ${connector}`];
|
|
54
|
+
}
|
|
43
55
|
/** Runs the install behind a spinner; the spinner ends with the outcome either way. */
|
|
44
56
|
export async function installWithSpinner(agentCount, work, streams) {
|
|
45
57
|
const progress = spinner({ output: streams.output });
|
|
@@ -118,12 +130,26 @@ async function pickAgents(agents, current, streams) {
|
|
|
118
130
|
});
|
|
119
131
|
return isCancel(answer) ? null : answer;
|
|
120
132
|
}
|
|
133
|
+
/** What each data mode lets leave this machine, as the installer says it. */
|
|
134
|
+
export const DATA_MODE_LABEL = {
|
|
135
|
+
"look-but-dont-keep": { label: "Look but don't keep", hint: "Heedvane's checks read prompts and tool calls, then keep only the verdict" },
|
|
136
|
+
"metrics-only": { label: "Metrics only", hint: "only counts and names leave; your organization's fixed rules still run here" },
|
|
137
|
+
};
|
|
138
|
+
async function pickDataMode(current, streams) {
|
|
139
|
+
const answer = await select({
|
|
140
|
+
message: "How much should leave this machine?",
|
|
141
|
+
options: Object.keys(DATA_MODE_LABEL).map((mode) => ({ value: mode, ...DATA_MODE_LABEL[mode] })),
|
|
142
|
+
initialValue: current,
|
|
143
|
+
...streams,
|
|
144
|
+
});
|
|
145
|
+
return isCancel(answer) ? null : answer;
|
|
146
|
+
}
|
|
121
147
|
async function pickIntegrations(install, streams) {
|
|
122
148
|
const answer = await multiselect({
|
|
123
149
|
message: "What should Heedvane add to each agent?",
|
|
124
150
|
options: [
|
|
125
151
|
{ value: "hooks", label: "Hooks", hint: "guardrails, skill checks and memory in every session" },
|
|
126
|
-
{ value: "mcp", label: "MCP server", hint: "Heedvane's tools inside the agent" },
|
|
152
|
+
{ value: "mcp", label: "MCP server", hint: "findings, reports and Heedvane's tools inside the agent" },
|
|
127
153
|
{ value: "skill", label: "Skill", hint: "teaches the agent how to use Heedvane" },
|
|
128
154
|
],
|
|
129
155
|
initialValues: ["hooks", "mcp", "skill"].filter((kind) => install[kind]),
|
package/dist/listener/serve.js
CHANGED
|
@@ -103,7 +103,7 @@ export async function serveHooks(home, out) {
|
|
|
103
103
|
let teamMemory;
|
|
104
104
|
if (envelope.event === "session_start") {
|
|
105
105
|
// The one wait on Heedvane in a hook: the policy and the team memory's map, fetched together.
|
|
106
|
-
[, teamMemory] = await Promise.all([syncPolicySafely(), teamMemoryAtStart(org, envelope)]);
|
|
106
|
+
[, teamMemory] = await Promise.all([syncPolicySafely(), config.dataMode === "metrics-only" ? undefined : teamMemoryAtStart(org, envelope)]);
|
|
107
107
|
if (policy !== null)
|
|
108
108
|
sessionPolicies.set(envelope.session, policy);
|
|
109
109
|
}
|
|
@@ -39,6 +39,8 @@ export interface StartedListener {
|
|
|
39
39
|
* length-leaking early return would defeat the point).
|
|
40
40
|
*/
|
|
41
41
|
export declare function safeTokenEqual(a: string, b: string): boolean;
|
|
42
|
+
/** `POST /shutdown` with the token: uninstall stops the listener it no longer has hooks for. */
|
|
43
|
+
export declare const SHUTDOWN_ROUTE = "/shutdown";
|
|
42
44
|
/**
|
|
43
45
|
* Start the loopback hook listener: binds 127.0.0.1 only (never 0.0.0.0),
|
|
44
46
|
* serves POST /hook/<agentId>, auths via x-heed-agent-hook-token against
|
package/dist/listener/server.js
CHANGED
|
@@ -71,6 +71,20 @@ function sendJson(res, status, body) {
|
|
|
71
71
|
res.end(JSON.stringify(body));
|
|
72
72
|
}
|
|
73
73
|
const ROUTE_PATTERN = /^\/hook\/([A-Za-z0-9_-]+)$/;
|
|
74
|
+
/** `POST /shutdown` with the token: uninstall stops the listener it no longer has hooks for. */
|
|
75
|
+
export const SHUTDOWN_ROUTE = "/shutdown";
|
|
76
|
+
/** True after answering a token-carrying `POST /shutdown`; the listener closes once the answer is sent. */
|
|
77
|
+
function handledShutdown(req, res, deps) {
|
|
78
|
+
if (req.method !== "POST" || new URL(req.url ?? "/", "http://127.0.0.1").pathname !== SHUTDOWN_ROUTE)
|
|
79
|
+
return false;
|
|
80
|
+
if (!hasValidToken(req, deps.token)) {
|
|
81
|
+
sendJson(res, 401, { error: "invalid or missing x-heed-agent-hook-token" });
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
res.on("finish", deps.stop);
|
|
85
|
+
sendJson(res, 200, { stopping: true });
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
74
88
|
/** The route's agent id, or null after answering 404/405. */
|
|
75
89
|
function routeAgent(req, res, agents) {
|
|
76
90
|
const route = ROUTE_PATTERN.exec(new URL(req.url ?? "/", "http://127.0.0.1").pathname);
|
|
@@ -150,6 +164,8 @@ function internalErrorResponder(res) {
|
|
|
150
164
|
};
|
|
151
165
|
}
|
|
152
166
|
async function handleRequest(req, res, deps) {
|
|
167
|
+
if (handledShutdown(req, res, deps))
|
|
168
|
+
return;
|
|
153
169
|
const agent = routeAgent(req, res, deps.agents);
|
|
154
170
|
if (agent === null)
|
|
155
171
|
return;
|
|
@@ -194,16 +210,20 @@ export async function startListener(options) {
|
|
|
194
210
|
}
|
|
195
211
|
const log = options.log ?? ((message) => console.log(message));
|
|
196
212
|
const agents = options.knownAgents ?? KNOWN_AGENTS;
|
|
213
|
+
const server = createServer((req, res) => {
|
|
214
|
+
handleRequest(req, res, deps).catch(internalErrorResponder(res));
|
|
215
|
+
});
|
|
197
216
|
const deps = {
|
|
198
217
|
agents,
|
|
199
218
|
token: endpoint.token,
|
|
200
219
|
onEvent: options.onEvent,
|
|
201
220
|
log,
|
|
202
221
|
warned: new Set(),
|
|
222
|
+
stop: () => {
|
|
223
|
+
server.close();
|
|
224
|
+
server.closeAllConnections();
|
|
225
|
+
},
|
|
203
226
|
};
|
|
204
|
-
const server = createServer((req, res) => {
|
|
205
|
-
handleRequest(req, res, deps).catch(internalErrorResponder(res));
|
|
206
|
-
});
|
|
207
227
|
// Node-level slowloris hardening (covers stalls before the request handler
|
|
208
228
|
// fires); requestTimeout must be larger than headersTimeout.
|
|
209
229
|
server.requestTimeout = REQUEST_TIMEOUT_MS;
|
package/dist/mcp.d.ts
CHANGED
|
@@ -1,57 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MCP
|
|
2
|
+
* Heedvane's MCP connection: the one MCP entry the installer registers, named `heedvane`, pointing at
|
|
3
|
+
* the organization's remote server (`<server>/api/mcp/<org>`), in the shapes the dashboard's setup writes
|
|
4
|
+
* (apps/web/src/lib/agent/mcp-setup.ts `HEEDVANE_MCP_SERVER_NAME`). The agent signs in to it in the
|
|
5
|
+
* browser on first use.
|
|
3
6
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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`.
|
|
7
|
+
* Identity: uninstall and detection recognize an entry by its name and shape together, never by a name
|
|
8
|
+
* alone: today's connection, the local helper earlier versions registered (`npx -y heedvane mcp`, under any
|
|
9
|
+
* name) and the connection under its earlier name `heedvane-findings`. Connect replaces the earlier ones.
|
|
13
10
|
*/
|
|
14
|
-
/**
|
|
11
|
+
/** The entry's name in every agent's MCP config. */
|
|
15
12
|
export declare const HEEDVANE_SERVER_NAME = "heedvane";
|
|
16
|
-
/**
|
|
17
|
-
|
|
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"];
|
|
13
|
+
/** What to do when no organization is known, so no MCP connection can be registered. */
|
|
14
|
+
export declare const MCP_NEEDS_ORG = "Heedvane's MCP connection needs your organization: run the connect command from your dashboard's Settings (it has --org), or npx heedvane connect --org <id>";
|
|
24
15
|
/** Uninstall/detection marker for inline dispatcher snippets (hooks configs). */
|
|
25
16
|
export declare const SNIPPET_MARKER = "heedvane-agent-hook-form";
|
|
26
|
-
/**
|
|
27
|
-
export declare
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
*/
|
|
17
|
+
/** The connection's URL for an organization, as the dashboard builds it (`buildHeedvaneMcpUrl`). */
|
|
18
|
+
export declare function connectorUrl(server: string, org: string): string;
|
|
19
|
+
/** The connection entry for this agent, or undefined when the installer does not register it there. `nodeDir` (POSIX only) is the installing Node's folder, for an entry that runs a local bridge. */
|
|
20
|
+
export declare function connectorEntry(clientId: string, url: string, nodeDir?: string): Record<string, unknown> | undefined;
|
|
21
|
+
/** Is this today's connection entry? Its name and a Heedvane MCP URL both have to match. */
|
|
22
|
+
export declare function isConnectorEntry(name: string, entry: unknown): boolean;
|
|
23
|
+
/** Is this an entry an earlier version wrote: the local helper under any name, or the connection under its earlier name? */
|
|
24
|
+
export declare function isLegacyEntry(name: string, entry: unknown): boolean;
|
|
25
|
+
/** Is this server entry Heedvane's, today's or an earlier version's? Used by uninstall, detection and doctor. */
|
|
57
26
|
export declare function isHeedvaneEntry(name: string, entry: unknown): boolean;
|
|
27
|
+
/** Names of the entries `match` accepts in a parsed config's MCP containers, as `container::name`. */
|
|
28
|
+
export declare function mcpEntryNames(doc: Record<string, unknown>, containerKeys: string[], match: (name: string, entry: unknown) => boolean): string[];
|
package/dist/mcp.js
CHANGED
|
@@ -1,83 +1,114 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MCP
|
|
2
|
+
* Heedvane's MCP connection: the one MCP entry the installer registers, named `heedvane`, pointing at
|
|
3
|
+
* the organization's remote server (`<server>/api/mcp/<org>`), in the shapes the dashboard's setup writes
|
|
4
|
+
* (apps/web/src/lib/agent/mcp-setup.ts `HEEDVANE_MCP_SERVER_NAME`). The agent signs in to it in the
|
|
5
|
+
* browser on first use.
|
|
3
6
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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`.
|
|
7
|
+
* Identity: uninstall and detection recognize an entry by its name and shape together, never by a name
|
|
8
|
+
* alone: today's connection, the local helper earlier versions registered (`npx -y heedvane mcp`, under any
|
|
9
|
+
* name) and the connection under its earlier name `heedvane-findings`. Connect replaces the earlier ones.
|
|
13
10
|
*/
|
|
14
|
-
/**
|
|
11
|
+
/** The entry's name in every agent's MCP config. */
|
|
15
12
|
export const HEEDVANE_SERVER_NAME = "heedvane";
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*/
|
|
23
|
-
|
|
13
|
+
/** What to do when no organization is known, so no MCP connection can be registered. */
|
|
14
|
+
export const MCP_NEEDS_ORG = "Heedvane's MCP connection needs your organization: run the connect command from your dashboard's Settings (it has --org), or npx heedvane connect --org <id>";
|
|
15
|
+
/** The connection's name before 0.3.0; connect and uninstall remove an entry under it. */
|
|
16
|
+
const EARLIER_CONNECTOR_NAME = "heedvane-findings";
|
|
17
|
+
/** Names development builds of the local helper ran under, matched in its command and arguments. */
|
|
18
|
+
const IDENTITY_MARKERS = ["@heedvane/hooks", "heedvane-hooks"];
|
|
19
|
+
/** The local helper's arguments (its command was always `npx`). */
|
|
20
|
+
const HELPER_ARGS = "-y heedvane mcp";
|
|
24
21
|
/** Uninstall/detection marker for inline dispatcher snippets (hooks configs). */
|
|
25
22
|
export const SNIPPET_MARKER = "heedvane-agent-hook-form";
|
|
26
|
-
/** The
|
|
27
|
-
|
|
28
|
-
|
|
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] };
|
|
23
|
+
/** The connection's URL for an organization, as the dashboard builds it (`buildHeedvaneMcpUrl`). */
|
|
24
|
+
export function connectorUrl(server, org) {
|
|
25
|
+
return `${server.replace(/\/+$/, "")}/api/mcp/${encodeURIComponent(org)}`;
|
|
33
26
|
}
|
|
34
27
|
/**
|
|
35
|
-
* The
|
|
36
|
-
*
|
|
37
|
-
*
|
|
28
|
+
* The connection entry per agent, in each agent's documented remote-server (Streamable HTTP) shape. Claude Code
|
|
29
|
+
* and VS Code need `type: "http"` (a typeless entry is read as stdio and skipped); Codex (`codex mcp add --url`)
|
|
30
|
+
* and Cursor take `url` alone, as the dashboard's setup writes them. Each agent signs in in the browser itself.
|
|
38
31
|
*/
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
32
|
+
const CONNECTOR_ENTRY = {
|
|
33
|
+
claude: (url) => ({ type: "http", url }),
|
|
34
|
+
vscode: (url) => ({ type: "http", url }),
|
|
35
|
+
codex: (url) => ({ url }),
|
|
36
|
+
cursor: (url) => ({ url }),
|
|
37
|
+
// Gemini CLI: `httpUrl` is Streamable HTTP (`url` is SSE); OAuth is discovered on the first 401 (`/mcp auth heedvane`).
|
|
38
|
+
gemini: (url) => ({ httpUrl: url }),
|
|
39
|
+
// Qwen Code (a Gemini CLI fork) runs the OAuth flow only for a server whose `oauth` is enabled; endpoints and client are discovered.
|
|
40
|
+
"qwen-code": (url) => ({ httpUrl: url, oauth: { enabled: true } }),
|
|
41
|
+
opencode: (url) => ({ type: "remote", url, enabled: true }),
|
|
42
|
+
warp: (url) => ({ url }),
|
|
43
|
+
// Kimi Code: a `url` without `transport` is Streamable HTTP (`/mcp-config login heedvane` signs in).
|
|
44
|
+
kimi: (url) => ({ url }),
|
|
45
|
+
droid: (url) => ({ type: "http", url }),
|
|
46
|
+
// Antigravity and Windsurf read only `serverUrl` for a remote server.
|
|
47
|
+
antigravity: (url) => ({ serverUrl: url }),
|
|
48
|
+
windsurf: (url) => ({ serverUrl: url }),
|
|
49
|
+
// Copilot CLI lists the tools it may call; `*` is every tool.
|
|
50
|
+
copilot_cli: (url) => ({ type: "http", url, tools: ["*"] }),
|
|
51
|
+
// Claude Desktop's config file runs only local (stdio) servers, so the mcp-remote bridge carries the connection and
|
|
52
|
+
// its browser sign-in. `@latest` keeps npx off an older cached copy (0.1.15 and earlier had CVE-2025-6514).
|
|
53
|
+
claude_desktop: (url, nodeDir) => bridgeEntry(["-y", "mcp-remote@latest", url], nodeDir),
|
|
54
|
+
};
|
|
48
55
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* Policies therefore arrive via MCP tools, not hooks.
|
|
56
|
+
* A local server run by npx. With the installing Node's folder, npx is named by its absolute path and that folder
|
|
57
|
+
* leads the server's PATH: Claude Desktop opened from Finder starts servers with launchd's PATH, which holds no
|
|
58
|
+
* Node from nvm, and npx finds `node` through PATH.
|
|
53
59
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
function bridgeEntry(args, nodeDir) {
|
|
61
|
+
if (nodeDir === undefined)
|
|
62
|
+
return { command: "npx", args };
|
|
63
|
+
return { command: `${nodeDir}/npx`, args, env: { PATH: [nodeDir, "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":") } };
|
|
64
|
+
}
|
|
65
|
+
/** The fields agents name a remote server's URL with. */
|
|
66
|
+
const URL_FIELDS = ["url", "httpUrl", "serverUrl"];
|
|
67
|
+
/** The connection entry for this agent, or undefined when the installer does not register it there. `nodeDir` (POSIX only) is the installing Node's folder, for an entry that runs a local bridge. */
|
|
68
|
+
export function connectorEntry(clientId, url, nodeDir) {
|
|
69
|
+
return CONNECTOR_ENTRY[clientId]?.(url, nodeDir);
|
|
70
|
+
}
|
|
71
|
+
/** Does this entry point at a Heedvane MCP URL, under any agent's name for the field or as a bridge's argument? */
|
|
72
|
+
function hasConnectorUrl(entry) {
|
|
73
|
+
if (entry === null || typeof entry !== "object")
|
|
74
|
+
return false;
|
|
75
|
+
const e = entry;
|
|
76
|
+
const args = Array.isArray(e.args) ? e.args : [];
|
|
77
|
+
return [...URL_FIELDS.map((field) => e[field]), ...args].some((url) => typeof url === "string" && url.includes("/api/mcp/"));
|
|
78
|
+
}
|
|
79
|
+
/** Is this today's connection entry? Its name and a Heedvane MCP URL both have to match. */
|
|
80
|
+
export function isConnectorEntry(name, entry) {
|
|
81
|
+
return name === HEEDVANE_SERVER_NAME && hasConnectorUrl(entry);
|
|
82
|
+
}
|
|
83
|
+
/** Is this an entry an earlier version wrote: the local helper under any name, or the connection under its earlier name? */
|
|
84
|
+
export function isLegacyEntry(name, entry) {
|
|
85
|
+
return hasHelperCommand(entry) || (name === EARLIER_CONNECTOR_NAME && hasConnectorUrl(entry));
|
|
86
|
+
}
|
|
87
|
+
/** Is this server entry Heedvane's, today's or an earlier version's? Used by uninstall, detection and doctor. */
|
|
88
|
+
export function isHeedvaneEntry(name, entry) {
|
|
89
|
+
return isConnectorEntry(name, entry) || isLegacyEntry(name, entry);
|
|
90
|
+
}
|
|
91
|
+
/** Names of the entries `match` accepts in a parsed config's MCP containers, as `container::name`. */
|
|
92
|
+
export function mcpEntryNames(doc, containerKeys, match) {
|
|
93
|
+
const names = [];
|
|
94
|
+
for (const key of new Set(containerKeys)) {
|
|
95
|
+
const container = doc[key];
|
|
96
|
+
if (container === null || typeof container !== "object" || Array.isArray(container))
|
|
97
|
+
continue;
|
|
98
|
+
for (const [name, entry] of Object.entries(container)) {
|
|
99
|
+
if (match(name, entry))
|
|
100
|
+
names.push(`${key}::${name}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return names;
|
|
104
|
+
}
|
|
105
|
+
/** Does this entry's command/args run the local helper (today's package name or a development build's)? */
|
|
106
|
+
function hasHelperCommand(entry) {
|
|
66
107
|
if (entry === null || typeof entry !== "object")
|
|
67
108
|
return false;
|
|
68
109
|
const e = entry;
|
|
69
110
|
const command = typeof e.command === "string" ? e.command : "";
|
|
70
111
|
const args = Array.isArray(e.args) ? e.args.filter((a) => typeof a === "string") : [];
|
|
71
112
|
const haystack = [command, ...args].join(" ");
|
|
72
|
-
return haystack.includes(
|
|
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);
|
|
113
|
+
return haystack.includes(HELPER_ARGS) || IDENTITY_MARKERS.some((m) => haystack.includes(m));
|
|
83
114
|
}
|