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/README.md
CHANGED
|
@@ -7,8 +7,8 @@ checks and team memory for Claude Code, Codex, Cursor, Gemini CLI, Hermes, OpenC
|
|
|
7
7
|
npx heedvane
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
The installer shows the agents it found on your machine. Tick the ones you want, choose
|
|
11
|
-
adds and does, confirm. It installs, starts Heedvane in the background and signs you in through the
|
|
10
|
+
The installer shows the agents it found on your machine. Tick the ones you want, choose how much leaves
|
|
11
|
+
your machine, choose what Heedvane adds and does, confirm. It installs, starts Heedvane in the background and signs you in through the
|
|
12
12
|
browser. It needs Node.js 22 or newer.
|
|
13
13
|
|
|
14
14
|
Your dashboard's Agents page has the same command with your server and organization filled in:
|
|
@@ -17,12 +17,27 @@ Your dashboard's Agents page has the same command with your server and organizat
|
|
|
17
17
|
npx heedvane connect --server https://app.heedvane.com --org <organization id>
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
## How much leaves your machine
|
|
21
|
+
|
|
22
|
+
| Choice | What leaves your machine |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| **Look but don't keep** (default) | Heedvane's guardrail checks read the prompt or tool call, then keep only the verdict, never the text. |
|
|
25
|
+
| **Metrics only** | Only counts and names. Your organization's fixed rules still run on your machine; Heedvane's own checks and team memory are off. |
|
|
26
|
+
|
|
27
|
+
Scripts choose with `--mode look-but-dont-keep` or `--mode metrics-only`. The choice is saved in
|
|
28
|
+
`heed.yaml` as `heedvane.dataMode`.
|
|
29
|
+
|
|
20
30
|
## What it changes
|
|
21
31
|
|
|
22
32
|
- Writes `~/.heed/heed.yaml`, your control file, with your answers. Run `npx heedvane` again to change
|
|
23
33
|
them.
|
|
24
|
-
- Adds Heedvane's hooks
|
|
25
|
-
|
|
34
|
+
- Adds Heedvane's hooks and skill to each agent you ticked. A config file it edits is backed up first.
|
|
35
|
+
- With `--org` (the dashboard's command has it), also adds Heedvane's MCP connection, named `heedvane`, for
|
|
36
|
+
your organization (findings, reports and agent feedback) in every agent you ticked. Claude Desktop gets it
|
|
37
|
+
through the `mcp-remote` bridge, run with the Node.js that installed it (run `npx heedvane connect` again after
|
|
38
|
+
removing that Node version). The agent signs in
|
|
39
|
+
through the browser the first time it uses it. It replaces the `heedvane` and
|
|
40
|
+
`heedvane-findings` entries an earlier version added.
|
|
26
41
|
- Starts Heedvane's local listener, which answers the hooks. After a restart the hooks start it again by
|
|
27
42
|
themselves, at most once a minute. Set `HEED_LISTENER_AUTOSTART=0` to turn that off.
|
|
28
43
|
|
|
@@ -49,7 +64,7 @@ own lines go to stderr and `~/.heed/listener.log`.
|
|
|
49
64
|
|
|
50
65
|
- Claude Code runs the hooks in print mode (`claude -p`), but not with `--bare`.
|
|
51
66
|
- Codex runs hooks only after they are trusted.
|
|
52
|
-
- Pi
|
|
67
|
+
- The installer does not set up Pi or Flue. Connect them with a hook of your own that sends each action to `POST /api/agent/steering` with the organization key; Heedvane answers whether to refuse it.
|
|
53
68
|
|
|
54
69
|
## Everyday commands
|
|
55
70
|
|
package/dist/cli.d.ts
CHANGED
|
@@ -25,5 +25,6 @@ declare function main(argv: string[], opts?: MainOptions): Promise<number>;
|
|
|
25
25
|
* The command that opens `url` in the default browser. Windows goes through rundll32, not `cmd /c start`:
|
|
26
26
|
* cmd would cut the sign-in URL at its first `&`.
|
|
27
27
|
*/
|
|
28
|
-
|
|
28
|
+
/** The command that opens `url`: `$BROWSER` when set (a no-op such as `true` keeps captures and tests off a real browser), else the platform's opener. */
|
|
29
|
+
export declare function browserOpener(platform: NodeJS.Platform, url: string, browser?: string): [string, string[]];
|
|
29
30
|
export { main };
|
package/dist/cli.js
CHANGED
|
@@ -7,20 +7,21 @@
|
|
|
7
7
|
import { createWriteStream, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { constants as osConstants, homedir, platform } from "node:os";
|
|
10
|
-
import {
|
|
10
|
+
import { join } from "node:path";
|
|
11
11
|
import { supportedClients } from "./clients/definitions.js";
|
|
12
12
|
import { installHermesPlugin } from "./clients/hermes-plugin.js";
|
|
13
13
|
import { applyEntry } from "./apply.js";
|
|
14
|
-
import { DEFAULT_HEED_YAML_TEMPLATE, heedvaneConfigAt, loadHeedFile, loadHeedFileAt, withInstallChoices,
|
|
15
|
-
import { planActions, desiredEntriesFor, isClientDetected } from "./plan.js";
|
|
14
|
+
import { DATA_MODES, DEFAULT_HEED_YAML_TEMPLATE, heedvaneConfigAt, loadHeedFile, loadHeedFileAt, withInstallChoices, withSettings } from "./heed-file.js";
|
|
15
|
+
import { planActions, desiredEntriesFor, heedvaneConnector, installingNodeDir, isClientDetected } from "./plan.js";
|
|
16
16
|
import { appliedFailed, capabilitySummary, renderApplied, renderDetection, renderPlan, wouldInstallSummary } from "./render.js";
|
|
17
17
|
import { runDoctor } from "./doctor.js";
|
|
18
18
|
import { installSkill } from "./skill/bundle.js";
|
|
19
19
|
import { canonicalSkillFiles, skillDigest } from "./skill/content.js";
|
|
20
20
|
import { emptyState, readState, writeStateAtomic } from "./state.js";
|
|
21
21
|
import { writeDispatcherScript } from "./dispatchers/posix.js";
|
|
22
|
-
import { removeDeferredHookGroups, removeStaleHookGroups, uninstall } from "./uninstall.js";
|
|
23
|
-
import { statusText
|
|
22
|
+
import { removeDeferredHookGroups, removeLegacyMcpEntries, removeStaleHookGroups, uninstall } from "./uninstall.js";
|
|
23
|
+
import { statusText } from "./status.js";
|
|
24
|
+
import { MCP_NEEDS_ORG } from "./mcp.js";
|
|
24
25
|
import { errorMessage } from "./guards.js";
|
|
25
26
|
import { ensureEndpoint } from "./listener/endpoint.js";
|
|
26
27
|
import { listenerLogPath, startListenerInBackground } from "./listener/background.js";
|
|
@@ -29,7 +30,7 @@ import { cmdAuth, cmdLogin, cmdSkills } from "./commands/heedvane.js";
|
|
|
29
30
|
import { cmdMemory } from "./commands/memory.js";
|
|
30
31
|
import { backendAt } from "./backend/credentials.js";
|
|
31
32
|
import { readLogin } from "./backend/login.js";
|
|
32
|
-
import { agentLines, askChoices, brandIntro, confirmInstall, installCancelled, installFinished, installWithSpinner, listenerStep, promptLine, showInstalled } from "./installer.js";
|
|
33
|
+
import { agentLines, askChoices, brandIntro, confirmInstall, installCancelled, installDetails, installFinished, installWithSpinner, listenerStep, promptLine, showInstalled } from "./installer.js";
|
|
33
34
|
const USAGE = ` heedvane-hook — cross-agent steering, guardrails & analysis
|
|
34
35
|
|
|
35
36
|
Usage: npx heedvane [command] [flags] (installed: heedvane <command>)
|
|
@@ -48,11 +49,11 @@ const USAGE = ` heedvane-hook — cross-agent steering, guardrails & analysis
|
|
|
48
49
|
skills sync install the organization's approved skills and cache the list hooks enforce
|
|
49
50
|
memory list|show|search browse this repository's team memory (a folder, one memory, or a question)
|
|
50
51
|
status quick state summary
|
|
51
|
-
mcp run the MCP stdio server (what agents register)
|
|
52
52
|
|
|
53
53
|
Flags: --dry-run --yes --json --agent <id> (repeatable) --client <id> --purge-config --project
|
|
54
54
|
--server <url> (connect/init: the Heedvane app to use, saved as heedvane.url; --url also works)
|
|
55
|
-
--org <id> (connect/init: the organization the sign-in must belong to)
|
|
55
|
+
--org <id> (connect/init: the organization the sign-in must belong to; also registers Heedvane's MCP connection)
|
|
56
|
+
--mode <look-but-dont-keep|metrics-only> (connect/init: what leaves this machine, saved as heedvane.dataMode)`;
|
|
56
57
|
function resolveHome(env) {
|
|
57
58
|
return env.HEED_HOME ?? homedir();
|
|
58
59
|
}
|
|
@@ -98,17 +99,64 @@ function parseInvocation(args) {
|
|
|
98
99
|
clients: parseRepeatedFlag(args, "--client"),
|
|
99
100
|
server: flagValue(args, "--server") ?? flagValue(args, "--url"),
|
|
100
101
|
org: flagValue(args, "--org"),
|
|
101
|
-
|
|
102
|
+
mode: flagValue(args, "--mode"),
|
|
103
|
+
positional: positionalArgs(args, VALUE_FLAGS),
|
|
102
104
|
};
|
|
103
105
|
}
|
|
106
|
+
/** Flags without a value. */
|
|
107
|
+
const SWITCH_FLAGS = ["--dry-run", "--yes", "--json", "--purge-config", "--project", "--help", "-h"];
|
|
108
|
+
/** Flags followed by a value. */
|
|
109
|
+
const VALUE_FLAGS = ["--agent", "--client", "--folder", "--offset", "--url", "--server", "--org", "--mode", "--token"];
|
|
110
|
+
/** Heedvane's own arguments: everything before `--`, after which `run` passes the rest to the agent. */
|
|
111
|
+
function ownArgs(args) {
|
|
112
|
+
const separator = args.indexOf("--");
|
|
113
|
+
return separator < 0 ? args : args.slice(0, separator);
|
|
114
|
+
}
|
|
115
|
+
/** The first argument that looks like a flag but is not one of ours (values of value flags skipped). */
|
|
116
|
+
function unknownFlag(args) {
|
|
117
|
+
for (let i = 0; i < args.length; i++) {
|
|
118
|
+
if (VALUE_FLAGS.includes(args[i]))
|
|
119
|
+
i += 1;
|
|
120
|
+
else if (args[i].startsWith("-") && !SWITCH_FLAGS.includes(args[i]))
|
|
121
|
+
return args[i];
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
/** One command's usage line from USAGE plus the flags, or the whole USAGE for no or an unknown command. */
|
|
126
|
+
function helpFor(command) {
|
|
127
|
+
const lines = USAGE.split("\n");
|
|
128
|
+
const own = command === undefined ? [] : lines.filter((line) => line.trim().startsWith(`${command} `));
|
|
129
|
+
if (own.length === 0)
|
|
130
|
+
return USAGE;
|
|
131
|
+
const flags = lines.slice(lines.findIndex((line) => line.trim().startsWith("Flags:")));
|
|
132
|
+
return [` Usage: npx heedvane ${command} [flags]`, "", ...own, "", ...flags].join("\n");
|
|
133
|
+
}
|
|
134
|
+
/** The command help is asked about: `help <command>`, `<command> --help`, or none for `-h` alone. */
|
|
135
|
+
function helpTopic([first, second]) {
|
|
136
|
+
if (first === "help")
|
|
137
|
+
return second;
|
|
138
|
+
return first === "-h" ? undefined : first;
|
|
139
|
+
}
|
|
104
140
|
async function main(argv, opts = {}) {
|
|
105
141
|
const ctx = commandContext(argv, opts);
|
|
106
142
|
const command = ctx.positional[0];
|
|
143
|
+
const own = ownArgs(ctx.args);
|
|
144
|
+
// Help never runs anything: `uninstall --help` must not uninstall.
|
|
145
|
+
if (command === "help" || own.includes("--help") || own.includes("-h")) {
|
|
146
|
+
ctx.out(helpFor(helpTopic(ctx.positional)));
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
const unknown = unknownFlag(own);
|
|
150
|
+
if (unknown !== undefined) {
|
|
151
|
+
ctx.out(` ✗ unknown flag: ${unknown} (nothing was changed)`);
|
|
152
|
+
ctx.out(helpFor(command));
|
|
153
|
+
return 2;
|
|
154
|
+
}
|
|
107
155
|
if (command === undefined && takesInstaller(ctx))
|
|
108
156
|
return cmdInstall(ctx);
|
|
109
|
-
if (!command
|
|
157
|
+
if (!command) {
|
|
110
158
|
ctx.out(USAGE);
|
|
111
|
-
return
|
|
159
|
+
return 2;
|
|
112
160
|
}
|
|
113
161
|
return routeCommand(command, ctx);
|
|
114
162
|
}
|
|
@@ -122,7 +170,7 @@ function commandContext(argv, opts) {
|
|
|
122
170
|
out: opts.write ?? ((line) => console.log(line)),
|
|
123
171
|
args,
|
|
124
172
|
readStdin: opts.readStdin ?? readProcessStdin,
|
|
125
|
-
openBrowser: opts.openBrowser ?? openSystemBrowser,
|
|
173
|
+
openBrowser: opts.openBrowser ?? ((url) => openSystemBrowser(url, env.BROWSER)),
|
|
126
174
|
interactive: opts.interactive ?? (process.stdin.isTTY === true && process.stdout.isTTY === true),
|
|
127
175
|
cwd: opts.cwd ?? process.cwd(),
|
|
128
176
|
prompts: opts.prompts ?? {},
|
|
@@ -151,10 +199,6 @@ async function routeServiceCommand(command, ctx) {
|
|
|
151
199
|
ctx.out(await statusText());
|
|
152
200
|
return 0;
|
|
153
201
|
}
|
|
154
|
-
case "mcp": {
|
|
155
|
-
runMcpStdio({ write: (line) => ctx.out(line) });
|
|
156
|
-
return 0;
|
|
157
|
-
}
|
|
158
202
|
case "listener": {
|
|
159
203
|
return cmdListener({ home: ctx.home, out: ctx.out, rest: ctx.args.slice(ctx.args.indexOf("listener") + 1) });
|
|
160
204
|
}
|
|
@@ -214,11 +258,11 @@ async function cmdInit(ctx) {
|
|
|
214
258
|
}
|
|
215
259
|
let serverEdit;
|
|
216
260
|
try {
|
|
217
|
-
serverEdit =
|
|
261
|
+
serverEdit = withFlags(home, loaded, ctx);
|
|
218
262
|
}
|
|
219
263
|
catch (err) {
|
|
220
264
|
out("");
|
|
221
|
-
out(` ✗
|
|
265
|
+
out(` ✗ ${errorMessage(err)}`);
|
|
222
266
|
return 1;
|
|
223
267
|
}
|
|
224
268
|
const { heedFile, existed } = serverEdit ?? loaded;
|
|
@@ -243,11 +287,27 @@ async function cmdInit(ctx) {
|
|
|
243
287
|
// --yes: first non-dry run has approval; proceed to write.
|
|
244
288
|
return applyAndReport(ctx, { heedFile, agents, existed, ...(serverEdit === null ? {} : { text: serverEdit.text }) });
|
|
245
289
|
}
|
|
246
|
-
/** heed.yaml
|
|
247
|
-
function
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
290
|
+
/** The heed.yaml settings `--server`, `--org` and `--mode` name; a refusal when a value cannot be used. */
|
|
291
|
+
function flagSettings({ server, org, mode }) {
|
|
292
|
+
if (mode !== undefined && !DATA_MODES.some((known) => known === mode))
|
|
293
|
+
throw new Error(`--mode ${mode} is not one of ${DATA_MODES.join(", ")}; nothing was changed`);
|
|
294
|
+
const settings = [[["heedvane", "url"], server], [["heedvane", "org"], org], [["heedvane", "dataMode"], mode]];
|
|
295
|
+
return settings.filter((setting) => setting[1] !== undefined);
|
|
296
|
+
}
|
|
297
|
+
/** heed.yaml (or the template) with the flags' settings; null when they change nothing. Throws on a value it cannot use. */
|
|
298
|
+
function withFlags(home, loaded, flags) {
|
|
299
|
+
const settings = flagSettings(flags);
|
|
300
|
+
if (settings.length === 0)
|
|
301
|
+
return null;
|
|
302
|
+
const text = withSettings(heedText(home, loaded.existed), settings);
|
|
303
|
+
let heedFile;
|
|
304
|
+
try {
|
|
305
|
+
heedFile = loadHeedFile(text);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
throw new Error(`${flags.server === undefined ? "a flag" : `--server ${flags.server}`} cannot be used: ${errorMessage(err)}`, { cause: err });
|
|
309
|
+
}
|
|
310
|
+
if (loaded.existed && JSON.stringify(heedFile.heedvane) === JSON.stringify(loaded.heedFile.heedvane))
|
|
251
311
|
return null;
|
|
252
312
|
return { heedFile, existed: loaded.existed, text };
|
|
253
313
|
}
|
|
@@ -259,7 +319,9 @@ async function applyAndReport(ctx, spec) {
|
|
|
259
319
|
if (!flags.dryRun)
|
|
260
320
|
await signInIfNeeded(ctx, spec.heedFile.heedvane.url);
|
|
261
321
|
out("");
|
|
262
|
-
out(" Next: open a new agent session
|
|
322
|
+
out(" Next: open a new agent session; Heedvane joins it at session start.");
|
|
323
|
+
if (spec.heedFile.install.mcp && heedvaneConnector(spec.heedFile) === undefined)
|
|
324
|
+
out(` ${MCP_NEEDS_ORG}`);
|
|
263
325
|
out(" Re-run `npx heedvane doctor` after any heed.yaml edit.");
|
|
264
326
|
const failed = applied.some(appliedFailed);
|
|
265
327
|
if (flags.json)
|
|
@@ -272,7 +334,13 @@ function writeControlFile({ home, out }, { heedFile, existed, text }) {
|
|
|
272
334
|
return;
|
|
273
335
|
mkdirSync(join(home, ".heed"), { recursive: true, mode: 0o700 });
|
|
274
336
|
writeFileSync(join(home, ".heed", "heed.yaml"), text ?? DEFAULT_HEED_YAML_TEMPLATE, { mode: 0o600 });
|
|
275
|
-
|
|
337
|
+
if (!existed)
|
|
338
|
+
return out(` ✓ Wrote ~/.heed/heed.yaml (your control file — edit any time)`);
|
|
339
|
+
const { url, org, dataMode } = heedFile.heedvane;
|
|
340
|
+
for (const [name, value] of [["url", url], ["org", org], ["dataMode", dataMode]]) {
|
|
341
|
+
if (value !== undefined)
|
|
342
|
+
out(` ✓ Set heedvane.${name} in ~/.heed/heed.yaml to ${value}`);
|
|
343
|
+
}
|
|
276
344
|
}
|
|
277
345
|
/** Without a sign-in to `url` (and to `--org`, when named), sign in now when a person is at the terminal, else say how. Never fails setup. */
|
|
278
346
|
/** Writes heed.yaml, applies the plan, issues the listener endpoint and records the machine state. Prints only what applying prints. */
|
|
@@ -285,9 +353,9 @@ async function installPlan(ctx, spec) {
|
|
|
285
353
|
const dispatcher = {
|
|
286
354
|
denySkillsWhenUnchecked: heedFile.heedvane.skills.enforce && heedFile.heedvane.skills.whenUnknown === "deny",
|
|
287
355
|
gating: heedFile.gating.mode,
|
|
288
|
-
nodeDir:
|
|
356
|
+
nodeDir: installingNodeDir(),
|
|
289
357
|
};
|
|
290
|
-
const applied = await applyPlan(actions, { home, env, out, dispatcher });
|
|
358
|
+
const applied = await applyPlan(actions, { home, env, out, dispatcher, connector: heedvaneConnector(heedFile) });
|
|
291
359
|
// The listener port and token every dispatcher reads; kept once issued.
|
|
292
360
|
await ensureEndpoint(join(home, ".heed"));
|
|
293
361
|
// Machine state: CLI-owned; preserve an existing endpoint.
|
|
@@ -351,9 +419,9 @@ async function cmdSetup(ctx) {
|
|
|
351
419
|
}
|
|
352
420
|
return applyAndReport(ctx, { heedFile, agents: ctx.agents, existed: true });
|
|
353
421
|
}
|
|
354
|
-
/** A person at a terminal who did not ask for the one-shot path (`--yes`, `--dry-run`, `--json`)
|
|
422
|
+
/** A person at a terminal who did not ask for the one-shot path (`--yes`, `--dry-run`, `--json`). */
|
|
355
423
|
function takesInstaller(ctx) {
|
|
356
|
-
return ctx.interactive && !ctx.flags.yes && !ctx.flags.dryRun && !ctx.flags.json
|
|
424
|
+
return ctx.interactive && !ctx.flags.yes && !ctx.flags.dryRun && !ctx.flags.json;
|
|
357
425
|
}
|
|
358
426
|
/** heed.yaml's text, or the template when there is none yet. */
|
|
359
427
|
function heedText(home, existed) {
|
|
@@ -375,7 +443,7 @@ async function cmdInstall(ctx) {
|
|
|
375
443
|
const spec = { heedFile: loadHeedFile(text), agents: choices.agents === "auto" ? [] : choices.agents, existed: base.existed, text };
|
|
376
444
|
const names = new Map(detectionRows(ctx.home, ctx.env).map((row) => [row.client.id, row.client.displayName]));
|
|
377
445
|
const planned = agentLines(planFor({ home: ctx.home, env: ctx.env, ...spec }), names);
|
|
378
|
-
if (!(await confirmInstall(planned, prompts)))
|
|
446
|
+
if (!(await confirmInstall(planned, installDetails(spec.heedFile, heedvaneConnector(spec.heedFile)), prompts)))
|
|
379
447
|
return installCancelled(prompts);
|
|
380
448
|
const notes = [];
|
|
381
449
|
const { actions, applied } = await installWithSpinner(planned.length, () => installPlan({ ...ctx, out: (line) => notes.push(line) }, spec), prompts);
|
|
@@ -383,29 +451,29 @@ async function cmdInstall(ctx) {
|
|
|
383
451
|
showInstalled(agentLines(actions, names, applied), prompts);
|
|
384
452
|
const listening = await listenerStep(() => ctx.startListener(ctx.home), listenerLogPath(join(ctx.home, ".heed")), prompts);
|
|
385
453
|
await signInIfNeeded({ ...ctx, out: promptLine(prompts) }, spec.heedFile.heedvane.url);
|
|
386
|
-
installFinished(nextSteps(listening, spec.heedFile
|
|
454
|
+
installFinished(nextSteps(listening, spec.heedFile), prompts);
|
|
387
455
|
return applied.some(appliedFailed) ? 1 : 0;
|
|
388
456
|
}
|
|
389
|
-
/** heed.yaml's current text (or the template) with `--server` applied, parsed; a message when
|
|
390
|
-
function installerBase(
|
|
391
|
-
const loaded = loadOrTemplate(home);
|
|
457
|
+
/** heed.yaml's current text (or the template) with `--server`, `--org` and `--mode` applied, parsed; a message when it cannot be used. */
|
|
458
|
+
function installerBase(ctx) {
|
|
459
|
+
const loaded = loadOrTemplate(ctx.home);
|
|
392
460
|
if (loaded.error)
|
|
393
461
|
return `~/.heed/heed.yaml does not parse: ${loaded.error}`;
|
|
394
462
|
try {
|
|
395
|
-
|
|
396
|
-
const text = server === undefined ? current : withServerUrl(current, server);
|
|
397
|
-
return { heedFile: loadHeedFile(text), existed: loaded.existed, text };
|
|
463
|
+
return withFlags(ctx.home, loaded, ctx) ?? { heedFile: loaded.heedFile, existed: loaded.existed, text: heedText(ctx.home, loaded.existed) };
|
|
398
464
|
}
|
|
399
465
|
catch (err) {
|
|
400
|
-
return
|
|
466
|
+
return errorMessage(err);
|
|
401
467
|
}
|
|
402
468
|
}
|
|
403
469
|
function agentOptions({ home, env }) {
|
|
404
470
|
return detectionRows(home, env).map(({ client, detected }) => ({ id: client.id, name: client.displayName, detected, capability: capabilitySummary(client) }));
|
|
405
471
|
}
|
|
406
|
-
function nextSteps(listening,
|
|
472
|
+
function nextSteps(listening, heedFile) {
|
|
473
|
+
const server = heedFile.heedvane.url;
|
|
407
474
|
return [
|
|
408
475
|
"Open a new agent session: Heedvane joins it at session start.",
|
|
476
|
+
...(heedFile.install.mcp && heedvaneConnector(heedFile) === undefined ? [MCP_NEEDS_ORG] : []),
|
|
409
477
|
`Watch your sessions: ${server}`,
|
|
410
478
|
...(listening ? [] : ["Heedvane starts by itself with your next agent session. To see why it did not start now: npx heedvane listener start"]),
|
|
411
479
|
"Check the install: npx heedvane doctor",
|
|
@@ -421,24 +489,38 @@ async function applyHermesPlugin(client, ctx) {
|
|
|
421
489
|
}
|
|
422
490
|
return { clientId: client.id, kind: "hooks", status: status === "installed" ? "create" : "unchanged", path };
|
|
423
491
|
}
|
|
492
|
+
/** Folds removals into an apply result: a failed removal becomes the result, any other makes an unchanged one an update. */
|
|
493
|
+
function withRemovals(items, result) {
|
|
494
|
+
let worst = result;
|
|
495
|
+
for (const item of items) {
|
|
496
|
+
if (item.status === "failed")
|
|
497
|
+
return { status: "failed", path: item.path, reason: item.detail };
|
|
498
|
+
if (worst.status === "unchanged")
|
|
499
|
+
worst = { status: "update", path: item.path };
|
|
500
|
+
}
|
|
501
|
+
return worst;
|
|
502
|
+
}
|
|
424
503
|
/** Drop our groups under event keys this client no longer installs; a failed removal becomes the result. */
|
|
425
504
|
async function withoutStaleHookGroups(client, writes, result) {
|
|
426
505
|
let worst = result;
|
|
427
506
|
for (const { target, containerKey } of writes) {
|
|
428
507
|
if (containerKey !== "hooks")
|
|
429
508
|
continue;
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
if (worst.status === "unchanged")
|
|
434
|
-
worst = { status: "update", path: item.path };
|
|
435
|
-
}
|
|
509
|
+
worst = withRemovals(await removeStaleHookGroups(client, target), worst);
|
|
510
|
+
if (worst.status === "failed")
|
|
511
|
+
return worst;
|
|
436
512
|
}
|
|
437
513
|
return worst;
|
|
438
514
|
}
|
|
515
|
+
/** After the writes: our hook groups under events no longer installed, and what an earlier version registered as MCP (the local helper, the connection's earlier name), with or without a connection to write. */
|
|
516
|
+
async function withoutLeftovers(kind, client, done) {
|
|
517
|
+
if (kind === "hooks")
|
|
518
|
+
return withoutStaleHookGroups(client, done.writes, done.result);
|
|
519
|
+
return kind === "mcp" ? withRemovals(await removeLegacyMcpEntries(client), done.result) : done.result;
|
|
520
|
+
}
|
|
439
521
|
/** Patch every desired entry into the client's config files, then refresh the dispatcher script. */
|
|
440
522
|
async function applyConfigEntries(action, client, ctx) {
|
|
441
|
-
const desireds = desiredEntriesFor(client, action.kind === "hooks" ? "hooks" : "mcp", { home: ctx.home, env: ctx.env });
|
|
523
|
+
const desireds = desiredEntriesFor(client, action.kind === "hooks" ? "hooks" : "mcp", { home: ctx.home, env: ctx.env, connector: ctx.connector });
|
|
442
524
|
// One write per file and container: every hook event lands (and is backed up) together.
|
|
443
525
|
const writes = new Map();
|
|
444
526
|
for (const d of desireds) {
|
|
@@ -457,9 +539,8 @@ async function applyConfigEntries(action, client, ctx) {
|
|
|
457
539
|
if (res.status !== "unchanged")
|
|
458
540
|
worst = res;
|
|
459
541
|
}
|
|
460
|
-
if (
|
|
461
|
-
worst = await
|
|
462
|
-
}
|
|
542
|
+
if (worst.status !== "failed" && worst.status !== "invalid")
|
|
543
|
+
worst = await withoutLeftovers(action.kind, client, { writes: writes.values(), result: worst });
|
|
463
544
|
// The dispatcher script the snippet delegates to.
|
|
464
545
|
await writeDispatcherScript(ctx.home, client.id, ctx.dispatcher);
|
|
465
546
|
return {
|
|
@@ -569,10 +650,10 @@ async function cmdListener(ctx) {
|
|
|
569
650
|
}
|
|
570
651
|
const started = await serveHooks(ctx.home, ctx.out);
|
|
571
652
|
ctx.out(` ✓ listener on 127.0.0.1:${started.port}, ${started.replayed} spooled event(s) replayed (Ctrl-C to stop)`);
|
|
653
|
+
// Ends on Ctrl-C, or when uninstall asks it to stop (POST /shutdown).
|
|
572
654
|
await new Promise((resolve) => {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
});
|
|
655
|
+
started.server.on("close", resolve);
|
|
656
|
+
process.on("SIGINT", () => started.server.close());
|
|
576
657
|
});
|
|
577
658
|
return 0;
|
|
578
659
|
}
|
|
@@ -630,12 +711,15 @@ function backendCommandContext(ctx) {
|
|
|
630
711
|
* The command that opens `url` in the default browser. Windows goes through rundll32, not `cmd /c start`:
|
|
631
712
|
* cmd would cut the sign-in URL at its first `&`.
|
|
632
713
|
*/
|
|
633
|
-
|
|
714
|
+
/** The command that opens `url`: `$BROWSER` when set (a no-op such as `true` keeps captures and tests off a real browser), else the platform's opener. */
|
|
715
|
+
export function browserOpener(platform, url, browser) {
|
|
716
|
+
if (browser)
|
|
717
|
+
return [browser, [url]];
|
|
634
718
|
const openers = { darwin: ["open", [url]], win32: ["rundll32", ["url.dll,FileProtocolHandler", url]] };
|
|
635
719
|
return openers[platform] ?? ["xdg-open", [url]];
|
|
636
720
|
}
|
|
637
|
-
function openSystemBrowser(url) {
|
|
638
|
-
const [command, args] = browserOpener(process.platform, url);
|
|
721
|
+
function openSystemBrowser(url, browser) {
|
|
722
|
+
const [command, args] = browserOpener(process.platform, url, browser);
|
|
639
723
|
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
640
724
|
child.on("error", () => undefined); // no browser opener: the printed URL is the way in
|
|
641
725
|
child.unref();
|
|
@@ -34,7 +34,7 @@ export interface ResolveContext {
|
|
|
34
34
|
/** Root a client's directory or detection marker is resolved against. */
|
|
35
35
|
export type ClientPathBase = "home" | "xdg-config" | "platform-config";
|
|
36
36
|
/** Base a writable config target is resolved against. */
|
|
37
|
-
export type PathBase = "client" | "home" | "platform-config";
|
|
37
|
+
export type PathBase = "client" | "home" | "platform-config" | "xdg-config";
|
|
38
38
|
/** One writable config file for a client. */
|
|
39
39
|
export interface ConfigTarget {
|
|
40
40
|
/** Path segments relative to {@link PathBase}. */
|
|
@@ -155,13 +155,13 @@ const SUPPORTED_CLIENTS = [
|
|
|
155
155
|
base: "client",
|
|
156
156
|
format: "json",
|
|
157
157
|
purpose: "mcp",
|
|
158
|
-
limitation: "Warp has no native hook events (hooks unavailable); ~/.warp/.mcp.json is
|
|
158
|
+
limitation: "Warp has no native hook events (hooks unavailable); ~/.warp/.mcp.json is Warp's global MCP file, and a remote server signs in through the browser when it first starts.",
|
|
159
159
|
},
|
|
160
160
|
],
|
|
161
161
|
capabilities: ["mcp", "skill"],
|
|
162
162
|
events: [],
|
|
163
163
|
mcpServersKey: "mcpServers",
|
|
164
|
-
limitation: "Warp exposes no native hook events
|
|
164
|
+
limitation: "Warp exposes no native hook events, so Heedvane adds only its MCP connection and the skill there.",
|
|
165
165
|
},
|
|
166
166
|
{
|
|
167
167
|
id: "opencode",
|
|
@@ -227,11 +227,12 @@ const SUPPORTED_CLIENTS = [
|
|
|
227
227
|
homeOverrideEnv: "KIMI_CODE_HOME",
|
|
228
228
|
configs: [
|
|
229
229
|
{ segments: ["config.toml"], base: "client", format: "toml", purpose: "hooks" },
|
|
230
|
-
|
|
230
|
+
// MCP servers live in mcp.json (Kimi Code's MCP docs); config.toml is listed with hooks, so cleanup still finds the helper 0.3.0 and earlier wrote there.
|
|
231
|
+
{ segments: ["mcp.json"], base: "client", format: "json", purpose: "mcp" },
|
|
231
232
|
],
|
|
232
233
|
capabilities: ["hooks", "mcp", "skill"],
|
|
233
234
|
events: KIMI_EVENTS,
|
|
234
|
-
mcpServersKey: "
|
|
235
|
+
mcpServersKey: "mcpServers",
|
|
235
236
|
},
|
|
236
237
|
{
|
|
237
238
|
id: "droid",
|
|
@@ -253,6 +254,8 @@ const SUPPORTED_CLIENTS = [
|
|
|
253
254
|
base: "home",
|
|
254
255
|
configs: [
|
|
255
256
|
{ segments: [".gemini", "config", "hooks.json"], base: "home", format: "json", purpose: "hooks" },
|
|
257
|
+
// Antigravity's documented global MCP file; the second is where 0.3.0 and earlier wrote, kept only so cleanup and uninstall reach it.
|
|
258
|
+
{ segments: [".gemini", "config", "mcp_config.json"], base: "home", format: "json", purpose: "mcp" },
|
|
256
259
|
{ segments: ["mcp_config.json"], base: "client", format: "json", purpose: "mcp" },
|
|
257
260
|
],
|
|
258
261
|
capabilities: ["hooks", "mcp", "skill"],
|
|
@@ -266,6 +269,8 @@ const SUPPORTED_CLIENTS = [
|
|
|
266
269
|
base: "home",
|
|
267
270
|
homeOverrideEnv: "COPILOT_HOME",
|
|
268
271
|
configs: [
|
|
272
|
+
// Copilot CLI's documented MCP file; the second is where 0.3.0 and earlier wrote, kept only so cleanup and uninstall reach it.
|
|
273
|
+
{ segments: ["mcp-config.json"], base: "client", format: "json", purpose: "mcp" },
|
|
269
274
|
{ segments: ["mcp.json"], base: "client", format: "json", purpose: "mcp" },
|
|
270
275
|
],
|
|
271
276
|
capabilities: ["mcp", "skill"],
|
|
@@ -276,8 +281,12 @@ const SUPPORTED_CLIENTS = [
|
|
|
276
281
|
id: "windsurf",
|
|
277
282
|
displayName: "Windsurf",
|
|
278
283
|
markerSegments: [".codeium", "windsurf"],
|
|
284
|
+
// Devin Desktop's folder: $XDG_CONFIG_HOME/devin, %APPDATA%\devin on Windows.
|
|
285
|
+
altMarkers: [{ segments: ["devin"], base: "xdg-config" }],
|
|
279
286
|
base: "home",
|
|
280
287
|
configs: [
|
|
288
|
+
// The file Windsurf's docs (now Devin Desktop's) name; the second is where 0.3.1 and earlier wrote, kept only so cleanup and uninstall reach it.
|
|
289
|
+
{ segments: ["devin", "mcp_config.json"], base: "xdg-config", format: "json", purpose: "mcp" },
|
|
281
290
|
{ segments: ["mcp_config.json"], base: "client", format: "json", purpose: "mcp" },
|
|
282
291
|
],
|
|
283
292
|
capabilities: ["mcp"],
|
|
@@ -324,14 +333,16 @@ function platformConfigDir(def, ctx, p) {
|
|
|
324
333
|
switch (ctx.platform) {
|
|
325
334
|
case "darwin":
|
|
326
335
|
return p.join(ctx.home, "Library", "Application Support", appName);
|
|
327
|
-
case "win32":
|
|
328
|
-
|
|
329
|
-
return p.join(absoluteOrHome(appData, ctx, p), appName);
|
|
330
|
-
}
|
|
336
|
+
case "win32":
|
|
337
|
+
return p.join(appDataRoot(ctx, p), appName);
|
|
331
338
|
default:
|
|
332
339
|
return p.join(xdgConfigRoot(ctx, p), appName);
|
|
333
340
|
}
|
|
334
341
|
}
|
|
342
|
+
/** %APPDATA%, else the default roaming folder under the home. */
|
|
343
|
+
function appDataRoot(ctx, p) {
|
|
344
|
+
return absoluteOrHome(ctx.env["APPDATA"] ?? p.join(ctx.home, "AppData", "Roaming"), ctx, p);
|
|
345
|
+
}
|
|
335
346
|
/** Treat a relative env override as home-relative so resolution never throws. */
|
|
336
347
|
function absoluteOrHome(value, ctx, p) {
|
|
337
348
|
return p.isAbsolute(value) ? value : p.join(ctx.home, value);
|
|
@@ -357,7 +368,8 @@ function resolveMarkerBase(target, ctx, p) {
|
|
|
357
368
|
case "home":
|
|
358
369
|
return p.join(ctx.home, ...segments);
|
|
359
370
|
case "xdg-config":
|
|
360
|
-
|
|
371
|
+
// As for config targets: %APPDATA% on Windows (Devin Desktop's folder for Windsurf).
|
|
372
|
+
return p.join(ctx.platform === "win32" ? appDataRoot(ctx, p) : xdgConfigRoot(ctx, p), ...segments);
|
|
361
373
|
case "platform-config":
|
|
362
374
|
return p.join(platformConfigDir(def, ctx, p), ...segments);
|
|
363
375
|
}
|
|
@@ -388,6 +400,9 @@ function resolveConfigTarget(def, target, resolve) {
|
|
|
388
400
|
case "platform-config":
|
|
389
401
|
path = p.join(platformConfigDir(def, ctx, p), ...target.segments);
|
|
390
402
|
break;
|
|
403
|
+
case "xdg-config":
|
|
404
|
+
path = p.join(ctx.platform === "win32" ? appDataRoot(ctx, p) : xdgConfigRoot(ctx, p), ...target.segments);
|
|
405
|
+
break;
|
|
391
406
|
}
|
|
392
407
|
return target.limitation === undefined
|
|
393
408
|
? { path, format: target.format, purpose: target.purpose }
|
package/dist/commands/memory.js
CHANGED
|
@@ -37,7 +37,12 @@ async function ask(ctx, request) {
|
|
|
37
37
|
return 1;
|
|
38
38
|
}
|
|
39
39
|
const root = join(ctx.home, ".heed");
|
|
40
|
-
const
|
|
40
|
+
const config = heedvaneConfigAt(root);
|
|
41
|
+
if (config.dataMode === "metrics-only") {
|
|
42
|
+
ctx.out(" ✗ team memory is off on this machine: heed.yaml heedvane.dataMode is metrics-only, so no question or repository leaves it");
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
const backend = await backendAt(root, config.url);
|
|
41
46
|
if (backend === null) {
|
|
42
47
|
ctx.out(" ✗ not signed in — run: npx heedvane login (or, headless: npx heedvane auth org < key-file)");
|
|
43
48
|
return 1;
|
package/dist/doctor.js
CHANGED
|
@@ -8,7 +8,8 @@ import { join } from "node:path";
|
|
|
8
8
|
import { supportedClients } from "./clients/definitions.js";
|
|
9
9
|
import { entryValue, parseDoc, readEntry, valuesEqual } from "./config-doc.js";
|
|
10
10
|
import { loadHeedFileAt } from "./heed-file.js";
|
|
11
|
-
import { desiredEntriesFor, hooksDeferredTo, isClientDetected } from "./plan.js";
|
|
11
|
+
import { desiredEntriesFor, heedvaneConnector, hooksDeferredTo, isClientDetected, legacyMcpEntries } from "./plan.js";
|
|
12
|
+
import { MCP_NEEDS_ORG, connectorEntry } from "./mcp.js";
|
|
12
13
|
import { readInstalledSkillDigest } from "./skill/bundle.js";
|
|
13
14
|
import { HeedStateCorruptError, readState } from "./state.js";
|
|
14
15
|
import { readApprovedSkills } from "./skills/approved.js";
|
|
@@ -109,13 +110,14 @@ void checkState;
|
|
|
109
110
|
function scopeSet(clients) {
|
|
110
111
|
return clients && clients.length > 0 ? new Set(clients) : null;
|
|
111
112
|
}
|
|
112
|
-
/** The pure-planner desired-entry lookup, resolving each client id against home/env. */
|
|
113
|
-
function defaultDesiredLookup(home, env) {
|
|
113
|
+
/** The pure-planner desired-entry lookup, resolving each client id against home/env and heed.yaml's organization. */
|
|
114
|
+
function defaultDesiredLookup(home, env, heedFile) {
|
|
115
|
+
const connector = heedFile === undefined ? undefined : heedvaneConnector(heedFile);
|
|
114
116
|
return (clientId, capability) => {
|
|
115
117
|
const client = supportedClients({ home, platform: process.platform, env }).find((c) => c.id === clientId);
|
|
116
118
|
if (!client)
|
|
117
119
|
return undefined;
|
|
118
|
-
return desiredEntriesFor(client, capability, { home, env }).map((d) => ({
|
|
120
|
+
return desiredEntriesFor(client, capability, { home, env, connector }).map((d) => ({
|
|
119
121
|
path: d.path,
|
|
120
122
|
format: d.format,
|
|
121
123
|
containerKey: d.containerKey,
|
|
@@ -160,13 +162,27 @@ function collectCapabilityStates(client, ctx) {
|
|
|
160
162
|
const supported = client.capabilities.includes(capability) || (capability === "hooks" && client.id === "hermes" && client.capabilities.includes("plugin-install"));
|
|
161
163
|
if (!supported || !heedFileWants(client, capability, ctx))
|
|
162
164
|
continue;
|
|
163
|
-
const desireds = ctx.desired(client.id, capability);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
const desireds = ctx.desired(client.id, capability) ?? [];
|
|
166
|
+
const result = capability === "mcp" ? mcpConnectionState(client, desireds, ctx.home) : hooksState(desireds, ctx.home);
|
|
167
|
+
if (result !== undefined)
|
|
168
|
+
states.push({ capability, result });
|
|
167
169
|
}
|
|
168
170
|
return states;
|
|
169
171
|
}
|
|
172
|
+
/** The hooks' registration verdict, when this client has hooks to register. */
|
|
173
|
+
function hooksState(desireds, home) {
|
|
174
|
+
return desireds.length > 0 ? registrationState(desireds, home) : undefined;
|
|
175
|
+
}
|
|
176
|
+
/** Heedvane's MCP connection: stale while an earlier version's entry is left, missing without an organization, else the registration verdict. */
|
|
177
|
+
function mcpConnectionState(client, desireds, home) {
|
|
178
|
+
const legacy = legacyMcpEntries(client);
|
|
179
|
+
if (legacy !== undefined)
|
|
180
|
+
return { state: "stale", detail: `${legacy.names.join(", ")} in ${legacy.path} is an earlier version's entry; npx heedvane connect replaces it` };
|
|
181
|
+
if (desireds.length > 0)
|
|
182
|
+
return registrationState(desireds, home);
|
|
183
|
+
// Agents the connection is not registered in have nothing to check.
|
|
184
|
+
return connectorEntry(client.id, "") === undefined ? undefined : { state: "missing", detail: MCP_NEEDS_ORG };
|
|
185
|
+
}
|
|
170
186
|
/** One detected client's registration check (aligned / stale / missing with per-entry detail). */
|
|
171
187
|
function pushRegistrationChecks(checks, client, ctx) {
|
|
172
188
|
const detected = isClientDetected(client);
|
|
@@ -233,7 +249,7 @@ export async function runDoctor(input) {
|
|
|
233
249
|
const root = join(home, ".heed");
|
|
234
250
|
const state = await readStateOrReport(root, checks);
|
|
235
251
|
// 4. Desired-entry lookup: injected, falling back to the pure planner.
|
|
236
|
-
const desired = input.desired ?? defaultDesiredLookup(home, env);
|
|
252
|
+
const desired = input.desired ?? defaultDesiredLookup(home, env, heedFile);
|
|
237
253
|
// 5. Per detected client: registration state aligned | stale | missing.
|
|
238
254
|
const scope = scopeSet(input.clients);
|
|
239
255
|
const allClients = supportedClients({ home, platform: process.platform, env });
|