heedvane 0.2.1 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -13
- package/dist/backend/credentials.d.ts +2 -0
- package/dist/backend/credentials.js +13 -0
- package/dist/backend/repo-remote.js +2 -1
- package/dist/backend/sign-in-change.js +3 -2
- package/dist/cli.d.ts +4 -2
- package/dist/cli.js +389 -144
- package/dist/clients/definitions.d.ts +8 -4
- package/dist/clients/definitions.js +48 -13
- package/dist/clients/hermes-plugin.d.ts +5 -20
- package/dist/clients/hermes-plugin.js +97 -71
- package/dist/clients/hook-files.d.ts +22 -0
- package/dist/clients/hook-files.js +39 -0
- package/dist/clients/pi-extension.d.ts +9 -0
- package/dist/clients/pi-extension.js +41 -0
- package/dist/commands/heedvane.js +18 -3
- package/dist/commands/mcp-stub.d.ts +1 -0
- package/dist/commands/mcp-stub.js +44 -0
- package/dist/commands/memory.js +6 -1
- package/dist/dispatchers/posix.d.ts +16 -19
- package/dist/dispatchers/posix.js +71 -91
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +113 -29
- package/dist/guard.d.ts +73 -0
- package/dist/guard.js +302 -0
- package/dist/heed-file.d.ts +19 -3
- package/dist/heed-file.js +46 -23
- package/dist/hook-fallback.d.ts +73 -0
- package/dist/hook-fallback.js +61 -0
- package/dist/installer.d.ts +13 -6
- package/dist/installer.js +40 -13
- package/dist/listener/background.d.ts +48 -4
- package/dist/listener/background.js +153 -19
- package/dist/listener/endpoint.d.ts +26 -3
- package/dist/listener/endpoint.js +55 -10
- package/dist/listener/forward.d.ts +7 -1
- package/dist/listener/forward.js +40 -12
- package/dist/listener/serve.d.ts +37 -0
- package/dist/listener/serve.js +196 -32
- package/dist/listener/server.d.ts +9 -0
- package/dist/listener/server.js +59 -22
- package/dist/listener/spool.d.ts +2 -17
- package/dist/listener/spool.js +6 -3
- package/dist/mcp.d.ts +38 -48
- package/dist/mcp.js +196 -63
- package/dist/plan.d.ts +47 -8
- package/dist/plan.js +164 -52
- package/dist/render.js +8 -8
- package/dist/reply/harness-output.d.ts +9 -0
- package/dist/reply/harness-output.js +13 -0
- package/dist/reply/reply.js +2 -1
- package/dist/skill/content.d.ts +2 -2
- package/dist/skill/content.js +11 -8
- package/dist/status.d.ts +1 -0
- package/dist/status.js +29 -0
- package/dist/steering/rules.js +4 -2
- package/dist/uninstall.d.ts +7 -0
- package/dist/uninstall.js +72 -71
- package/package.json +10 -3
- package/skills/heedvane-hooks/SKILL.md +6 -6
- package/skills/heedvane-hooks/references/events.md +5 -2
- package/dist/dispatchers/windows.d.ts +0 -15
- package/dist/dispatchers/windows.js +0 -50
- package/dist/mcp-server.d.ts +0 -9
- package/dist/mcp-server.js +0 -187
package/dist/cli.js
CHANGED
|
@@ -1,35 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* heedvane
|
|
4
|
-
* listener | status |
|
|
3
|
+
* heedvane — command routing: connect | init | setup | login | auth | doctor |
|
|
4
|
+
* uninstall | listener | run | status | skills | memory. Plain argv parsing (no commander); all state
|
|
5
5
|
* mutations flow through plan → apply; --dry-run writes nothing.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync } 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
|
-
import {
|
|
12
|
+
import { installHookFiles } from "./clients/hook-files.js";
|
|
13
13
|
import { applyEntry } from "./apply.js";
|
|
14
|
-
import {
|
|
15
|
-
import { planActions, desiredEntriesFor, isClientDetected } from "./plan.js";
|
|
14
|
+
import { DATA_MODES, DEFAULT_HEED_YAML_TEMPLATE, loadHeedFile, loadHeedFileAt, withInstallChoices, withSettings } from "./heed-file.js";
|
|
15
|
+
import { bridgeNodeDir, connectionNote, planActions, desiredEntriesFor, hasOwnConnection, heedvaneConnector, isClientDetected, legacyOrganization, mcpLeftovers, sendsOrgKey } 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
|
-
import { writeDispatcherScript } from "./dispatchers/posix.js";
|
|
22
|
-
import { removeDeferredHookGroups, removeStaleHookGroups, uninstall } from "./uninstall.js";
|
|
23
|
-
import { statusText
|
|
21
|
+
import { writeDispatcherScript, writeHookNode } from "./dispatchers/posix.js";
|
|
22
|
+
import { removeDeferredHookGroups, removeLegacyMcpEntries, removeStaleHookGroups, uninstall } from "./uninstall.js";
|
|
23
|
+
import { statusText } from "./status.js";
|
|
24
24
|
import { errorMessage } from "./guards.js";
|
|
25
|
-
import { ensureEndpoint } from "./listener/endpoint.js";
|
|
26
|
-
import { listenerLogPath,
|
|
27
|
-
import { serveHooks } from "./listener/serve.js";
|
|
25
|
+
import { ensureEndpoint, HEEDVANE_VERSION } from "./listener/endpoint.js";
|
|
26
|
+
import { ensureListener, listenerLogPath, PORT_TAKEN_EXIT, stopCommand } from "./listener/background.js";
|
|
27
|
+
import { ListenerAlreadyRuns, serveHooks, settingsToRun } from "./listener/serve.js";
|
|
28
|
+
import { keyConnectorEntry, ORG_KEY_ENV } from "./mcp.js";
|
|
28
29
|
import { cmdAuth, cmdLogin, cmdSkills } from "./commands/heedvane.js";
|
|
30
|
+
import { cmdMcpStub } from "./commands/mcp-stub.js";
|
|
31
|
+
import writeFileAtomic from "write-file-atomic";
|
|
29
32
|
import { cmdMemory } from "./commands/memory.js";
|
|
30
33
|
import { backendAt } from "./backend/credentials.js";
|
|
31
34
|
import { readLogin } from "./backend/login.js";
|
|
32
|
-
import { agentLines, askChoices, brandIntro, confirmInstall, installCancelled, installFinished, installWithSpinner, listenerStep, promptLine, showInstalled } from "./installer.js";
|
|
35
|
+
import { agentLines, askChoices, brandIntro, confirmInstall, installCancelled, installDetails, installFinished, installWithSpinner, listenerStep, promptLine, showInstalled } from "./installer.js";
|
|
33
36
|
const USAGE = ` heedvane-hook — cross-agent steering, guardrails & analysis
|
|
34
37
|
|
|
35
38
|
Usage: npx heedvane [command] [flags] (installed: heedvane <command>)
|
|
@@ -42,17 +45,17 @@ const USAGE = ` heedvane-hook — cross-agent steering, guardrails & analysis
|
|
|
42
45
|
setup plan + apply from an existing heed.yaml
|
|
43
46
|
doctor scoped, remediation-carrying health check
|
|
44
47
|
uninstall marker-verified removal (never touches heed.yaml)
|
|
45
|
-
listener listener controls (start): answers hooks with skill checks, steering and memory
|
|
48
|
+
listener listener controls (start, restart): answers hooks with skill checks, steering and memory
|
|
46
49
|
login sign in to Heedvane in the browser (~/.heed/login.json, 0600; refreshed automatically)
|
|
47
50
|
auth org CI/headless: store an organization API key read from stdin (~/.heed/credentials.json, 0600)
|
|
48
51
|
skills sync install the organization's approved skills and cache the list hooks enforce
|
|
49
52
|
memory list|show|search browse this repository's team memory (a folder, one memory, or a question)
|
|
50
53
|
status quick state summary
|
|
51
|
-
mcp run the MCP stdio server (what agents register)
|
|
52
54
|
|
|
53
55
|
Flags: --dry-run --yes --json --agent <id> (repeatable) --client <id> --purge-config --project
|
|
54
56
|
--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)
|
|
57
|
+
--org <id> (connect/init: the organization the sign-in must belong to; also registers Heedvane's MCP connection)
|
|
58
|
+
--mode <look-but-dont-keep|metrics-only> (connect/init: what leaves this machine, saved as heedvane.dataMode)`;
|
|
56
59
|
function resolveHome(env) {
|
|
57
60
|
return env.HEED_HOME ?? homedir();
|
|
58
61
|
}
|
|
@@ -98,25 +101,30 @@ function parseInvocation(args) {
|
|
|
98
101
|
clients: parseRepeatedFlag(args, "--client"),
|
|
99
102
|
server: flagValue(args, "--server") ?? flagValue(args, "--url"),
|
|
100
103
|
org: flagValue(args, "--org"),
|
|
104
|
+
mode: flagValue(args, "--mode"),
|
|
101
105
|
positional: positionalArgs(args, VALUE_FLAGS),
|
|
102
106
|
};
|
|
103
107
|
}
|
|
104
108
|
/** Flags without a value. */
|
|
105
109
|
const SWITCH_FLAGS = ["--dry-run", "--yes", "--json", "--purge-config", "--project", "--help", "-h"];
|
|
106
110
|
/** Flags followed by a value. */
|
|
107
|
-
const VALUE_FLAGS = ["--agent", "--client", "--folder", "--offset", "--url", "--server", "--org", "--token"];
|
|
111
|
+
const VALUE_FLAGS = ["--agent", "--client", "--folder", "--offset", "--url", "--server", "--org", "--mode", "--token"];
|
|
108
112
|
/** Heedvane's own arguments: everything before `--`, after which `run` passes the rest to the agent. */
|
|
109
113
|
function ownArgs(args) {
|
|
110
114
|
const separator = args.indexOf("--");
|
|
111
115
|
return separator < 0 ? args : args.slice(0, separator);
|
|
112
116
|
}
|
|
113
|
-
/**
|
|
114
|
-
function
|
|
117
|
+
/** What is wrong with the flags: one that is not ours, or a value flag without its value (a clipped paste); undefined when nothing is. */
|
|
118
|
+
function flagProblem(args) {
|
|
115
119
|
for (let i = 0; i < args.length; i++) {
|
|
116
|
-
|
|
120
|
+
const arg = args[i];
|
|
121
|
+
if (VALUE_FLAGS.includes(arg)) {
|
|
117
122
|
i += 1;
|
|
118
|
-
|
|
119
|
-
|
|
123
|
+
if (args[i] === undefined || args[i].startsWith("-"))
|
|
124
|
+
return `${arg} needs a value`;
|
|
125
|
+
}
|
|
126
|
+
else if (arg.startsWith("-") && !SWITCH_FLAGS.includes(arg))
|
|
127
|
+
return `unknown flag: ${arg}`;
|
|
120
128
|
}
|
|
121
129
|
return undefined;
|
|
122
130
|
}
|
|
@@ -144,9 +152,9 @@ async function main(argv, opts = {}) {
|
|
|
144
152
|
ctx.out(helpFor(helpTopic(ctx.positional)));
|
|
145
153
|
return 0;
|
|
146
154
|
}
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
149
|
-
ctx.out(` ✗
|
|
155
|
+
const problem = flagProblem(own);
|
|
156
|
+
if (problem !== undefined) {
|
|
157
|
+
ctx.out(` ✗ ${problem} (nothing was changed)`);
|
|
150
158
|
ctx.out(helpFor(command));
|
|
151
159
|
return 2;
|
|
152
160
|
}
|
|
@@ -168,11 +176,11 @@ function commandContext(argv, opts) {
|
|
|
168
176
|
out: opts.write ?? ((line) => console.log(line)),
|
|
169
177
|
args,
|
|
170
178
|
readStdin: opts.readStdin ?? readProcessStdin,
|
|
171
|
-
openBrowser: opts.openBrowser ?? openSystemBrowser,
|
|
179
|
+
openBrowser: opts.openBrowser ?? ((url) => openSystemBrowser(url, env.BROWSER)),
|
|
172
180
|
interactive: opts.interactive ?? (process.stdin.isTTY === true && process.stdout.isTTY === true),
|
|
173
181
|
cwd: opts.cwd ?? process.cwd(),
|
|
174
182
|
prompts: opts.prompts ?? {},
|
|
175
|
-
|
|
183
|
+
ensureListener: opts.ensureListener ?? ((home) => ensureListener(home, fileURLToPath(import.meta.url), env)),
|
|
176
184
|
};
|
|
177
185
|
}
|
|
178
186
|
async function routeCommand(command, ctx) {
|
|
@@ -197,20 +205,23 @@ async function routeServiceCommand(command, ctx) {
|
|
|
197
205
|
ctx.out(await statusText());
|
|
198
206
|
return 0;
|
|
199
207
|
}
|
|
200
|
-
case "mcp": {
|
|
201
|
-
runMcpStdio({ write: (line) => ctx.out(line) });
|
|
202
|
-
return 0;
|
|
203
|
-
}
|
|
204
208
|
case "listener": {
|
|
205
|
-
return cmdListener({
|
|
209
|
+
return cmdListener({ ...ctx, rest: ctx.args.slice(ctx.args.indexOf("listener") + 1) });
|
|
206
210
|
}
|
|
207
211
|
case "run":
|
|
208
|
-
return cmdRun({ home: ctx.home, env: ctx.env, command: ctx.args.slice(ctx.args.indexOf("--") + 1), separated: ctx.args.includes("--") });
|
|
209
|
-
case "login":
|
|
210
|
-
|
|
211
|
-
|
|
212
|
+
return cmdRun({ home: ctx.home, env: ctx.env, ensureListener: ctx.ensureListener, command: ctx.args.slice(ctx.args.indexOf("--") + 1), separated: ctx.args.includes("--") });
|
|
213
|
+
case "login": {
|
|
214
|
+
const code = await cmdLogin(backendCommandContext(ctx));
|
|
215
|
+
return code === 0 ? afterSignIn(ctx, await switchConnections(ctx, "browser")) : code;
|
|
216
|
+
}
|
|
217
|
+
case "auth": {
|
|
218
|
+
const code = await cmdAuth(backendCommandContext(ctx));
|
|
219
|
+
return code === 0 && ctx.positional[1] === "org" ? afterSignIn(ctx, await switchConnections(ctx, "key")) : code;
|
|
220
|
+
}
|
|
212
221
|
case "skills":
|
|
213
|
-
return
|
|
222
|
+
return cmdSkills(backendCommandContext(ctx));
|
|
223
|
+
case "mcp":
|
|
224
|
+
return cmdMcpStub();
|
|
214
225
|
case "memory":
|
|
215
226
|
return cmdMemory({ home: ctx.home, out: ctx.out, words: ctx.positional.slice(1), flag: (name) => flagValue(ctx.args, name), cwd: ctx.cwd });
|
|
216
227
|
default:
|
|
@@ -260,11 +271,11 @@ async function cmdInit(ctx) {
|
|
|
260
271
|
}
|
|
261
272
|
let serverEdit;
|
|
262
273
|
try {
|
|
263
|
-
serverEdit =
|
|
274
|
+
serverEdit = withFlags(loaded, ctx);
|
|
264
275
|
}
|
|
265
276
|
catch (err) {
|
|
266
277
|
out("");
|
|
267
|
-
out(` ✗
|
|
278
|
+
out(` ✗ ${errorMessage(err)}`);
|
|
268
279
|
return 1;
|
|
269
280
|
}
|
|
270
281
|
const { heedFile, existed } = serverEdit ?? loaded;
|
|
@@ -289,53 +300,100 @@ async function cmdInit(ctx) {
|
|
|
289
300
|
// --yes: first non-dry run has approval; proceed to write.
|
|
290
301
|
return applyAndReport(ctx, { heedFile, agents, existed, ...(serverEdit === null ? {} : { text: serverEdit.text }) });
|
|
291
302
|
}
|
|
292
|
-
/** heed.yaml
|
|
293
|
-
function
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
303
|
+
/** The heed.yaml settings `--server`, `--org` and `--mode` name; a refusal when a value cannot be used. */
|
|
304
|
+
function flagSettings({ server, org, mode }) {
|
|
305
|
+
if (mode !== undefined && !DATA_MODES.some((known) => known === mode))
|
|
306
|
+
throw new Error(`--mode ${mode} is not one of ${DATA_MODES.join(", ")}; nothing was changed`);
|
|
307
|
+
const settings = [[["heedvane", "url"], server], [["heedvane", "org"], org], [["heedvane", "dataMode"], mode]];
|
|
308
|
+
return settings.filter((setting) => setting[1] !== undefined);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* heed.yaml (or the template) with the flags' settings; null when they change nothing. Throws on a value it cannot use.
|
|
312
|
+
* Without an organization or a saved login, one an earlier version's connection names counts as `--org`, so connect can replace it.
|
|
313
|
+
*/
|
|
314
|
+
function withFlags(loaded, flags) {
|
|
315
|
+
const { home, env } = flags;
|
|
316
|
+
const server = flags.server ?? loaded.heedFile.heedvane.url;
|
|
317
|
+
// Only when nothing else names one: a saved login says which organization this machine belongs to.
|
|
318
|
+
const earlierOrg = flags.org === undefined && loaded.heedFile.heedvane.org === undefined && !existsSync(join(home, ".heed", "login.json")) ? legacyOrganization(supportedClients({ home, platform: platform(), env }), server) : undefined;
|
|
319
|
+
const settings = flagSettings({ ...flags, org: flags.org ?? earlierOrg });
|
|
320
|
+
if (settings.length === 0)
|
|
321
|
+
return null;
|
|
322
|
+
const text = withSettings(heedText(home, loaded.existed), settings);
|
|
323
|
+
let heedFile;
|
|
324
|
+
try {
|
|
325
|
+
heedFile = loadHeedFile(text);
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
throw new Error(`${flags.server === undefined ? "a flag" : `--server ${flags.server}`} cannot be used: ${errorMessage(err)}`, { cause: err });
|
|
329
|
+
}
|
|
330
|
+
if (loaded.existed && JSON.stringify(heedFile.heedvane) === JSON.stringify(loaded.heedFile.heedvane))
|
|
297
331
|
return null;
|
|
298
332
|
return { heedFile, existed: loaded.existed, text };
|
|
299
333
|
}
|
|
334
|
+
/** A sign-in that is not to the organization asked for: nothing is applied, but the sign-in it tried is saved, so the listener runs with it. */
|
|
335
|
+
async function signInRefused({ out, flags, ensureListener, home }) {
|
|
336
|
+
out(" Nothing was changed; run this command again once signed in to that organization.");
|
|
337
|
+
const stillRunning = flags.dryRun ? undefined : sayListenerFully(await ensureListener(home), (line) => out(` ${line}`));
|
|
338
|
+
if (flags.json)
|
|
339
|
+
outJson(out, { status: "failed", plannedActions: [], appliedActions: [], ...(stillRunning === undefined ? {} : { listener: stillRunning }) });
|
|
340
|
+
return 1;
|
|
341
|
+
}
|
|
300
342
|
async function applyAndReport(ctx, spec) {
|
|
301
343
|
const { out, flags } = ctx;
|
|
344
|
+
if (!(await signedInToOrg(ctx, spec.heedFile.heedvane)))
|
|
345
|
+
return signInRefused(ctx);
|
|
302
346
|
const { actions, applied } = await installPlan(ctx, spec);
|
|
303
347
|
for (const line of renderApplied(applied))
|
|
304
348
|
out(line);
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
out(
|
|
308
|
-
out
|
|
309
|
-
|
|
310
|
-
const failed = applied.some(appliedFailed);
|
|
349
|
+
const signedIn = flags.dryRun || (await signInIfNeeded(ctx, spec.heedFile.heedvane));
|
|
350
|
+
// Last, after the sign-in, so the listener runs with it.
|
|
351
|
+
const stillRunning = flags.dryRun ? undefined : sayListener(await ctx.ensureListener(ctx.home), (line) => out(` ${line}`));
|
|
352
|
+
printNextSteps(out, spec.heedFile, stillRunning);
|
|
353
|
+
const failed = applied.some(appliedFailed) || !signedIn || stillRunning !== undefined;
|
|
311
354
|
if (flags.json)
|
|
312
|
-
outJson(out, { status: failed ? "failed" : "applied", plannedActions: actions, appliedActions: applied });
|
|
355
|
+
outJson(out, { status: failed ? "failed" : "applied", plannedActions: actions, appliedActions: applied, ...(stillRunning === undefined ? {} : { listener: stillRunning }) });
|
|
313
356
|
return failed ? 1 : 0;
|
|
314
357
|
}
|
|
315
|
-
/**
|
|
316
|
-
function
|
|
317
|
-
|
|
358
|
+
/** What connect says last: an old listener that did not stop, and what to do next. */
|
|
359
|
+
function printNextSteps(out, heedFile, stillRunning) {
|
|
360
|
+
out("");
|
|
361
|
+
if (stillRunning !== undefined)
|
|
362
|
+
out(` ✗ ${stillRunning}`);
|
|
363
|
+
out(` Next: ${stillRunning === undefined ? "open" : "once it is stopped, open"} a new agent session; Heedvane joins it at session start.`);
|
|
364
|
+
const note = connectionNote(heedFile);
|
|
365
|
+
if (note !== undefined)
|
|
366
|
+
out(` ${note}`);
|
|
367
|
+
out(" Re-run `npx heedvane doctor` after any heed.yaml edit.");
|
|
368
|
+
}
|
|
369
|
+
/** Writes heed.yaml when it is missing or this run changed it, in one atomic step, and names the settings that changed. */
|
|
370
|
+
function writeControlFile({ home, out }, { heedFile, existed, text }, before) {
|
|
371
|
+
const path = join(home, ".heed", "heed.yaml");
|
|
372
|
+
if (existed && (text === undefined || text === readFileSync(path, "utf8")))
|
|
318
373
|
return;
|
|
319
374
|
mkdirSync(join(home, ".heed"), { recursive: true, mode: 0o700 });
|
|
320
|
-
|
|
321
|
-
|
|
375
|
+
writeFileAtomic.sync(path, text ?? DEFAULT_HEED_YAML_TEMPLATE, { mode: 0o600 });
|
|
376
|
+
if (!existed)
|
|
377
|
+
return out(` ✓ Wrote ~/.heed/heed.yaml (your control file — edit any time)`);
|
|
378
|
+
const { url, org, dataMode } = heedFile.heedvane;
|
|
379
|
+
for (const [name, value] of [["url", url], ["org", org], ["dataMode", dataMode]]) {
|
|
380
|
+
if (value !== undefined && value !== before?.[name])
|
|
381
|
+
out(` ✓ Set heedvane.${name} in ~/.heed/heed.yaml to ${value}`);
|
|
382
|
+
}
|
|
322
383
|
}
|
|
323
|
-
/** 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. */
|
|
324
384
|
/** Writes heed.yaml, applies the plan, issues the listener endpoint and records the machine state. Prints only what applying prints. */
|
|
325
385
|
async function installPlan(ctx, spec) {
|
|
326
386
|
const { home, env, out } = ctx;
|
|
327
387
|
const { heedFile, agents } = spec;
|
|
328
388
|
// The control file comes first (mock order): "✓ Wrote ~/.heed/heed.yaml".
|
|
329
|
-
|
|
389
|
+
const before = spec.existed ? loadOrTemplate(home).heedFile.heedvane : undefined;
|
|
390
|
+
writeControlFile(ctx, spec, before);
|
|
330
391
|
const actions = planFor({ home, env, heedFile, agents });
|
|
331
|
-
const
|
|
332
|
-
denySkillsWhenUnchecked: heedFile.heedvane.skills.enforce && heedFile.heedvane.skills.whenUnknown === "deny",
|
|
333
|
-
gating: heedFile.gating.mode,
|
|
334
|
-
nodeDir: dirname(process.execPath),
|
|
335
|
-
};
|
|
336
|
-
const applied = await applyPlan(actions, { home, env, out, dispatcher });
|
|
392
|
+
const applied = await applyPlan(actions, applyContext({ home, env, out }, heedFile));
|
|
337
393
|
// The listener port and token every dispatcher reads; kept once issued.
|
|
338
394
|
await ensureEndpoint(join(home, ".heed"));
|
|
395
|
+
// The Node every hook's fallback runs with: the one running this install.
|
|
396
|
+
await writeHookNode(home, env["PATH"]);
|
|
339
397
|
// Machine state: CLI-owned; preserve an existing endpoint.
|
|
340
398
|
const root = join(home, ".heed");
|
|
341
399
|
const existing = await readState(root);
|
|
@@ -347,25 +405,130 @@ async function installPlan(ctx, spec) {
|
|
|
347
405
|
await writeStateAtomic(root, state);
|
|
348
406
|
return { actions, applied };
|
|
349
407
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
408
|
+
/**
|
|
409
|
+
* What a command says after {@link ensureListener}: a line, and `stillRunning` (the command exits 1) when the listener
|
|
410
|
+
* at the port did not stop when asked.
|
|
411
|
+
*/
|
|
412
|
+
function listenerReport(outcome) {
|
|
413
|
+
switch (outcome.kind) {
|
|
414
|
+
case "not set up":
|
|
415
|
+
case "running":
|
|
416
|
+
return {};
|
|
417
|
+
case "started":
|
|
418
|
+
return { line: "✓ started the listener" };
|
|
419
|
+
case "replaced":
|
|
420
|
+
return { line: `✓ replaced an older listener with this version (${HEEDVANE_VERSION})` };
|
|
421
|
+
case "restarted":
|
|
422
|
+
return { line: "✓ restarted the listener with the new settings" };
|
|
423
|
+
case "newer":
|
|
424
|
+
// This older CLI never replaces it (that would downgrade it); the newer one restarts itself with these settings.
|
|
425
|
+
if (outcome.earlierSettings)
|
|
426
|
+
return { stillRunning: `A newer Heedvane listener (${outcome.version}) runs with the earlier settings, and this npx heedvane (${HEEDVANE_VERSION}) is older, so it did not replace it. Apply the settings with the newer one: npx -y heedvane@latest listener restart` };
|
|
427
|
+
return { line: `· kept the newer listener that runs (${outcome.version}); this npx heedvane (${HEEDVANE_VERSION}) is older: npx -y heedvane@latest` };
|
|
428
|
+
case "did not start":
|
|
429
|
+
// With a reason (no settings to run), a hook's start would end the same way.
|
|
430
|
+
return { line: outcome.why !== undefined ? `· the listener did not start: ${outcome.why}` : `· the listener did not start (${outcome.log} says why); the hooks start one with the next agent event` };
|
|
431
|
+
case "did not stop":
|
|
432
|
+
return { stillRunning: `A Heedvane listener on 127.0.0.1:${outcome.port} still answers your agents and did not stop when asked. Stop it: ${stopCommand(outcome.port)}` };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/** Says what making sure of the listener did; the old listener that did not stop, if any. */
|
|
436
|
+
function sayListener(outcome, say) {
|
|
437
|
+
const { line, stillRunning } = listenerReport(outcome);
|
|
438
|
+
if (line !== undefined)
|
|
439
|
+
say(line);
|
|
440
|
+
return stillRunning;
|
|
441
|
+
}
|
|
442
|
+
/** {@link sayListener}, with the old listener that did not stop said as a failure. */
|
|
443
|
+
function sayListenerFully(outcome, say) {
|
|
444
|
+
const stillRunning = sayListener(outcome, say);
|
|
445
|
+
if (stillRunning !== undefined)
|
|
446
|
+
say(`✗ ${stillRunning}`);
|
|
447
|
+
return stillRunning;
|
|
448
|
+
}
|
|
449
|
+
/** After `login` or `auth org` (exit `code`): the listener runs with the new sign-in; 1 when the old one did not stop. */
|
|
450
|
+
async function afterSignIn(ctx, code) {
|
|
451
|
+
return sayListenerFully(await ctx.ensureListener(ctx.home), (line) => ctx.out(` ${line}`)) === undefined ? code : 1;
|
|
452
|
+
}
|
|
453
|
+
/** What applying needs from heed.yaml: the settings baked into each dispatcher and Heedvane's MCP connection URL. */
|
|
454
|
+
function applyContext({ home, env, out }, heedFile) {
|
|
455
|
+
const dispatcher = {
|
|
456
|
+
denySkillsWhenUnchecked: heedFile.heedvane.skills.enforce && heedFile.heedvane.skills.whenUnknown === "deny",
|
|
457
|
+
gating: heedFile.gating.mode,
|
|
458
|
+
};
|
|
459
|
+
return { home, env, out, dispatcher, connector: heedvaneConnector(heedFile), mcpLeftovers: mcpLeftovers(heedFile) };
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* A change of sign-in switches the Heedvane connections connect installed to the form it needs: `auth org` (a software
|
|
463
|
+
* factory, whose image was often built without the key) to sending HEEDVANE_ORG_KEY, `login` back to the browser
|
|
464
|
+
* sign-in. Only agents with a key form switch; it adds no connection and leaves a user's own entry alone.
|
|
465
|
+
*/
|
|
466
|
+
async function switchConnections({ home, env, out }, to) {
|
|
467
|
+
const loaded = loadOrTemplate(home);
|
|
468
|
+
const connector = loaded.existed && loaded.error === undefined ? heedvaneConnector(loaded.heedFile) : undefined;
|
|
469
|
+
// No connection to switch: none was asked for, no organization, or metrics only.
|
|
470
|
+
if (connector === undefined)
|
|
471
|
+
return 0;
|
|
472
|
+
const clients = supportedClients({ home, platform: platform(), env });
|
|
473
|
+
const switching = planFor({ home, env, heedFile: loaded.heedFile, agents: [] }).filter((action) => {
|
|
474
|
+
const client = clients.find((candidate) => candidate.id === action.clientId);
|
|
475
|
+
if (action.kind !== "mcp" || action.operation !== "update" || client === undefined || !hasOwnConnection(client))
|
|
476
|
+
return false;
|
|
477
|
+
return keyConnectorEntry(client.id, connector) !== undefined && sendsOrgKey(client) === (to === "browser");
|
|
478
|
+
});
|
|
479
|
+
const applied = await applyPlan(switching, applyContext({ home, env, out }, loaded.heedFile));
|
|
480
|
+
const keySet = Boolean(env[ORG_KEY_ENV]?.trim());
|
|
481
|
+
for (const result of applied)
|
|
482
|
+
out(switchedLine(result, { name: clients.find((client) => client.id === result.clientId)?.displayName ?? result.clientId, to, keySet }));
|
|
483
|
+
return applied.some(appliedFailed) ? 1 : 0;
|
|
484
|
+
}
|
|
485
|
+
function switchedLine(result, { name, to, keySet }) {
|
|
486
|
+
if (appliedFailed(result))
|
|
487
|
+
return ` ✗ ${name}: Heedvane's connection did not switch (${result.reason ?? result.status})`;
|
|
488
|
+
if (to === "browser")
|
|
489
|
+
return ` ✓ ${name}: Heedvane's connection signs in in the browser again`;
|
|
490
|
+
// The key came from a file: the agent sends it only where the variable is set.
|
|
491
|
+
if (!keySet)
|
|
492
|
+
return ` ! ${name}: Heedvane's connection now sends ${ORG_KEY_ENV}, which is not set here; set it where the agent runs`;
|
|
493
|
+
return ` ✓ ${name}: Heedvane's connection now sends ${ORG_KEY_ENV}`;
|
|
494
|
+
}
|
|
495
|
+
/** Without a sign-in to the server, sign in now when a person is at the terminal, else say how. False when that sign-in is another organization's. */
|
|
496
|
+
async function signInIfNeeded(ctx, target) {
|
|
497
|
+
if ((await backendAt(join(ctx.home, ".heed"), target.url)) !== null)
|
|
498
|
+
return true;
|
|
354
499
|
if (!ctx.interactive) {
|
|
355
500
|
ctx.out(" Next: sign in with `npx heedvane login` (CI and headless machines: npx heedvane auth org < key-file).");
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
await cmdLogin({ home: ctx.home, out: ctx.out, words: [], project: false, readStdin: async () => "", openBrowser: ctx.openBrowser });
|
|
359
|
-
if (!(await onRequestedOrg(root, ctx.org))) {
|
|
360
|
-
ctx.out(` ✗ This sign-in belongs to another organization than ${ctx.org}. Run \`npx heedvane login\` and pick that organization on the consent page.`);
|
|
501
|
+
return true;
|
|
361
502
|
}
|
|
503
|
+
return (await signIn(ctx)) && isOrgSignIn(ctx, target);
|
|
362
504
|
}
|
|
363
|
-
/**
|
|
364
|
-
|
|
505
|
+
/**
|
|
506
|
+
* Before anything is written: a saved sign-in to another organization than the named one would send the hooks'
|
|
507
|
+
* events there while the MCP connection points here. At a terminal it signs in again first. True when the run may write.
|
|
508
|
+
*/
|
|
509
|
+
async function signedInToOrg(ctx, target) {
|
|
510
|
+
if (ctx.interactive && !ctx.flags.dryRun && (await otherOrganization(ctx.home, target)) !== undefined)
|
|
511
|
+
await signIn(ctx);
|
|
512
|
+
return isOrgSignIn(ctx, target);
|
|
513
|
+
}
|
|
514
|
+
/** False, saying how to fix it, when the saved sign-in is another organization's. */
|
|
515
|
+
async function isOrgSignIn(ctx, target) {
|
|
516
|
+
const other = await otherOrganization(ctx.home, target);
|
|
517
|
+
if (other !== undefined)
|
|
518
|
+
ctx.out(` ✗ The saved sign-in is for organization ${other}, not ${target.org}. Run \`npx heedvane login\` and pick ${target.org} on the consent page.`);
|
|
519
|
+
return other === undefined;
|
|
520
|
+
}
|
|
521
|
+
/** The saved login's organization when it is a different, known one at the same server; a key or an unnamed organization is not. */
|
|
522
|
+
async function otherOrganization(home, { url, org }) {
|
|
365
523
|
if (org === undefined)
|
|
366
|
-
return
|
|
367
|
-
const saved = await readLogin(
|
|
368
|
-
return saved?.
|
|
524
|
+
return undefined;
|
|
525
|
+
const saved = await readLogin(join(home, ".heed")).catch(() => null);
|
|
526
|
+
return saved?.url === url && saved.organizationId !== undefined && saved.organizationId !== org ? saved.organizationId : undefined;
|
|
527
|
+
}
|
|
528
|
+
/** The browser sign-in; it prints its own result. */
|
|
529
|
+
/** True when the browser sign-in succeeded. */
|
|
530
|
+
async function signIn(ctx) {
|
|
531
|
+
return (await cmdLogin({ home: ctx.home, out: ctx.out, words: [], project: false, readStdin: async () => "", openBrowser: ctx.openBrowser })) === 0;
|
|
369
532
|
}
|
|
370
533
|
async function cmdSetup(ctx) {
|
|
371
534
|
const { home, out } = ctx;
|
|
@@ -405,6 +568,8 @@ function takesInstaller(ctx) {
|
|
|
405
568
|
function heedText(home, existed) {
|
|
406
569
|
return existed ? readFileSync(join(home, ".heed", "heed.yaml"), "utf8") : DEFAULT_HEED_YAML_TEMPLATE;
|
|
407
570
|
}
|
|
571
|
+
/** The outcomes after which a listener answers the hooks. */
|
|
572
|
+
const LISTENING = new Set(["running", "started", "replaced", "restarted", "newer"]);
|
|
408
573
|
/** The installer: pick agents, integrations and features, confirm, install, start the listener, sign in. */
|
|
409
574
|
async function cmdInstall(ctx) {
|
|
410
575
|
const base = installerBase(ctx);
|
|
@@ -414,77 +579,113 @@ async function cmdInstall(ctx) {
|
|
|
414
579
|
}
|
|
415
580
|
const { prompts } = ctx;
|
|
416
581
|
brandIntro(base.heedFile.heedvane.url, ctx.org, prompts);
|
|
417
|
-
|
|
582
|
+
// `--agent` ticks exactly the agents it names.
|
|
583
|
+
const current = ctx.agents.length > 0 ? { ...base.heedFile, agents: ctx.agents } : base.heedFile;
|
|
584
|
+
const choices = await askChoices(agentOptions(ctx), current, prompts);
|
|
418
585
|
if (choices === null)
|
|
419
586
|
return installCancelled(prompts);
|
|
420
587
|
const text = withInstallChoices(base.text, choices);
|
|
421
588
|
const spec = { heedFile: loadHeedFile(text), agents: choices.agents === "auto" ? [] : choices.agents, existed: base.existed, text };
|
|
422
589
|
const names = new Map(detectionRows(ctx.home, ctx.env).map((row) => [row.client.id, row.client.displayName]));
|
|
423
590
|
const planned = agentLines(planFor({ home: ctx.home, env: ctx.env, ...spec }), names);
|
|
424
|
-
if (!(await confirmInstall(planned, prompts)))
|
|
591
|
+
if (!(await confirmInstall(planned, installDetails(spec.heedFile), prompts)))
|
|
592
|
+
return installCancelled(prompts);
|
|
593
|
+
if (!(await signedInToOrg({ ...ctx, out: promptLine(prompts) }, spec.heedFile.heedvane))) {
|
|
594
|
+
// The sign-in it tried is saved even when it named another organization: the listener runs with it.
|
|
595
|
+
sayListenerFully(await ctx.ensureListener(ctx.home), promptLine(prompts));
|
|
425
596
|
return installCancelled(prompts);
|
|
597
|
+
}
|
|
426
598
|
const notes = [];
|
|
427
599
|
const { actions, applied } = await installWithSpinner(planned.length, () => installPlan({ ...ctx, out: (line) => notes.push(line) }, spec), prompts);
|
|
428
600
|
notes.forEach(promptLine(prompts));
|
|
429
601
|
showInstalled(agentLines(actions, names, applied), prompts);
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
602
|
+
const signedIn = await signInIfNeeded({ ...ctx, out: promptLine(prompts) }, spec.heedFile.heedvane);
|
|
603
|
+
// Last, after the sign-in, so the listener runs with it.
|
|
604
|
+
const ensuring = ctx.ensureListener(ctx.home);
|
|
605
|
+
const listening = await listenerStep(() => ensuring.then(listenerStepResult), prompts);
|
|
606
|
+
const outcome = await ensuring.catch(() => ({ kind: "did not start", log: listenerLogPath(join(ctx.home, ".heed")) }));
|
|
607
|
+
const { line, stillRunning } = listenerReport(outcome);
|
|
608
|
+
if (line !== undefined && outcome.kind === "newer")
|
|
609
|
+
promptLine(prompts)(line);
|
|
610
|
+
installFinished(nextSteps(listening, spec.heedFile, stillRunning), signedIn, prompts);
|
|
611
|
+
return applied.some(appliedFailed) || !signedIn || stillRunning !== undefined ? 1 : 0;
|
|
612
|
+
}
|
|
613
|
+
/** The installer's listener step: true once a listener runs, else what went wrong. */
|
|
614
|
+
function listenerStepResult(outcome) {
|
|
615
|
+
if (LISTENING.has(outcome.kind))
|
|
616
|
+
return true;
|
|
617
|
+
if (outcome.kind === "did not stop")
|
|
618
|
+
return `A Heedvane listener on 127.0.0.1:${outcome.port} did not stop when asked`;
|
|
619
|
+
return `Heedvane did not start; ${outcome.kind === "did not start" ? (outcome.why ?? `see ${outcome.log}`) : "see ~/.heed/listener.log"}`;
|
|
434
620
|
}
|
|
435
|
-
/** heed.yaml's current text (or the template) with `--server` applied, parsed; a message when
|
|
436
|
-
function installerBase(
|
|
437
|
-
const loaded = loadOrTemplate(home);
|
|
621
|
+
/** heed.yaml's current text (or the template) with `--server`, `--org` and `--mode` applied, parsed; a message when it cannot be used. */
|
|
622
|
+
function installerBase(ctx) {
|
|
623
|
+
const loaded = loadOrTemplate(ctx.home);
|
|
438
624
|
if (loaded.error)
|
|
439
625
|
return `~/.heed/heed.yaml does not parse: ${loaded.error}`;
|
|
440
626
|
try {
|
|
441
|
-
|
|
442
|
-
const text = server === undefined ? current : withServerUrl(current, server);
|
|
443
|
-
return { heedFile: loadHeedFile(text), existed: loaded.existed, text };
|
|
627
|
+
return withFlags(loaded, ctx) ?? { heedFile: loaded.heedFile, existed: loaded.existed, text: heedText(ctx.home, loaded.existed) };
|
|
444
628
|
}
|
|
445
629
|
catch (err) {
|
|
446
|
-
return
|
|
630
|
+
return errorMessage(err);
|
|
447
631
|
}
|
|
448
632
|
}
|
|
449
633
|
function agentOptions({ home, env }) {
|
|
450
634
|
return detectionRows(home, env).map(({ client, detected }) => ({ id: client.id, name: client.displayName, detected, capability: capabilitySummary(client) }));
|
|
451
635
|
}
|
|
452
|
-
function nextSteps(listening,
|
|
636
|
+
function nextSteps(listening, heedFile, stillRunning) {
|
|
637
|
+
const server = heedFile.heedvane.url;
|
|
453
638
|
return [
|
|
454
|
-
"Open a new agent session: Heedvane joins it at session start.",
|
|
639
|
+
...(stillRunning === undefined ? ["Open a new agent session: Heedvane joins it at session start."] : [stillRunning, "Once it is stopped, open a new agent session: Heedvane joins it at session start."]),
|
|
640
|
+
...[connectionNote(heedFile)].filter((note) => note !== undefined),
|
|
455
641
|
`Watch your sessions: ${server}`,
|
|
456
|
-
...(listening ? [] : ["Heedvane starts by itself with your next agent session. To see why it did not start now: npx heedvane listener start"]),
|
|
642
|
+
...(listening || stillRunning !== undefined ? [] : ["Heedvane starts by itself with your next agent session. To see why it did not start now: npx heedvane listener start"]),
|
|
457
643
|
"Check the install: npx heedvane doctor",
|
|
458
644
|
"Change your choices: npx heedvane",
|
|
459
645
|
];
|
|
460
646
|
}
|
|
461
|
-
/**
|
|
462
|
-
async function
|
|
463
|
-
const path =
|
|
464
|
-
const status = await
|
|
465
|
-
if (status === "skipped")
|
|
466
|
-
return { clientId: client.id, kind: "hooks", status: "skipped", path, reason:
|
|
467
|
-
}
|
|
647
|
+
/** Hooks written as whole files (Hermes' plugin, Pi's extension): never over files Heedvane did not write. */
|
|
648
|
+
async function applyHookFiles(client, hookFiles, ctx) {
|
|
649
|
+
const path = hookFiles.path(client.markerPath);
|
|
650
|
+
const status = await installHookFiles(hookFiles, path, ctx.dispatcher.gating);
|
|
651
|
+
if (status === "skipped")
|
|
652
|
+
return { clientId: client.id, kind: "hooks", status: "skipped", path, reason: `${path} is not Heedvane's — left untouched` };
|
|
468
653
|
return { clientId: client.id, kind: "hooks", status: status === "installed" ? "create" : "unchanged", path };
|
|
469
654
|
}
|
|
655
|
+
/** Folds removals into an apply result: a failed removal becomes the result, any other makes an unchanged one an update. */
|
|
656
|
+
function withRemovals(items, result) {
|
|
657
|
+
let worst = result;
|
|
658
|
+
for (const item of items) {
|
|
659
|
+
if (item.status === "failed")
|
|
660
|
+
return { status: "failed", path: item.path, reason: item.detail };
|
|
661
|
+
if (worst.status === "unchanged")
|
|
662
|
+
worst = { status: "update", path: item.path };
|
|
663
|
+
}
|
|
664
|
+
return worst;
|
|
665
|
+
}
|
|
470
666
|
/** Drop our groups under event keys this client no longer installs; a failed removal becomes the result. */
|
|
471
667
|
async function withoutStaleHookGroups(client, writes, result) {
|
|
472
668
|
let worst = result;
|
|
473
669
|
for (const { target, containerKey } of writes) {
|
|
474
670
|
if (containerKey !== "hooks")
|
|
475
671
|
continue;
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
if (worst.status === "unchanged")
|
|
480
|
-
worst = { status: "update", path: item.path };
|
|
481
|
-
}
|
|
672
|
+
worst = withRemovals(await removeStaleHookGroups(client, target), worst);
|
|
673
|
+
if (worst.status === "failed")
|
|
674
|
+
return worst;
|
|
482
675
|
}
|
|
483
676
|
return worst;
|
|
484
677
|
}
|
|
678
|
+
/** After the writes: our hook groups under events no longer installed, and the MCP entries plan.ts `mcpLeftovers` names. */
|
|
679
|
+
async function withoutLeftovers(kind, client, done) {
|
|
680
|
+
if (kind === "hooks")
|
|
681
|
+
return withoutStaleHookGroups(client, done.writes, done.result);
|
|
682
|
+
if (kind !== "mcp")
|
|
683
|
+
return done.result;
|
|
684
|
+
return withRemovals(await removeLegacyMcpEntries(client, done.mcpLeftovers), done.result);
|
|
685
|
+
}
|
|
485
686
|
/** Patch every desired entry into the client's config files, then refresh the dispatcher script. */
|
|
486
687
|
async function applyConfigEntries(action, client, ctx) {
|
|
487
|
-
const desireds = desiredEntriesFor(client, action.kind === "hooks" ? "hooks" : "mcp", { home: ctx.home, env: ctx.env });
|
|
688
|
+
const desireds = desiredEntriesFor(client, action.kind === "hooks" ? "hooks" : "mcp", { home: ctx.home, env: ctx.env, connector: ctx.connector, nodeDir: bridgeNodeDir(platform()) });
|
|
488
689
|
// One write per file and container: every hook event lands (and is backed up) together.
|
|
489
690
|
const writes = new Map();
|
|
490
691
|
for (const d of desireds) {
|
|
@@ -503,11 +704,11 @@ async function applyConfigEntries(action, client, ctx) {
|
|
|
503
704
|
if (res.status !== "unchanged")
|
|
504
705
|
worst = res;
|
|
505
706
|
}
|
|
506
|
-
if (
|
|
507
|
-
worst = await
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
707
|
+
if (worst.status !== "failed" && worst.status !== "invalid")
|
|
708
|
+
worst = await withoutLeftovers(action.kind, client, { writes: writes.values(), result: worst, mcpLeftovers: ctx.mcpLeftovers });
|
|
709
|
+
// The dispatcher script the hook command delegates to; an MCP connection runs none.
|
|
710
|
+
if (action.kind === "hooks")
|
|
711
|
+
await writeDispatcherScript(ctx.home, client.id, ctx.dispatcher);
|
|
511
712
|
return {
|
|
512
713
|
clientId: action.clientId,
|
|
513
714
|
kind: action.kind,
|
|
@@ -524,9 +725,11 @@ async function applyAction(action, client, ctx) {
|
|
|
524
725
|
const status = await installSkill(join(ctx.home, ".agents", "skills"));
|
|
525
726
|
return { clientId: action.clientId, kind: "skill", status, path: action.path };
|
|
526
727
|
}
|
|
527
|
-
if (
|
|
528
|
-
return
|
|
529
|
-
|
|
728
|
+
if (action.kind === "hooks" && client.hookFiles !== undefined)
|
|
729
|
+
return applyHookFiles(client, client.hookFiles, ctx);
|
|
730
|
+
// A connection plan.ts found current is left as it is: apply would rewrite what drift checks leave out (mcp.ts comparableEntry).
|
|
731
|
+
if (action.kind === "mcp" && action.operation === "unchanged")
|
|
732
|
+
return { clientId: action.clientId, kind: "mcp", status: "unchanged", path: action.path };
|
|
530
733
|
return applyConfigEntries(action, client, ctx);
|
|
531
734
|
}
|
|
532
735
|
async function recordDeferredHookRemovals(client, results) {
|
|
@@ -608,25 +811,60 @@ async function cmdUninstall(ctx) {
|
|
|
608
811
|
}
|
|
609
812
|
async function cmdListener(ctx) {
|
|
610
813
|
const sub = ctx.rest[0] ?? "";
|
|
814
|
+
if (sub === "restart")
|
|
815
|
+
return cmdListenerRestart(ctx);
|
|
611
816
|
if (sub !== "start") {
|
|
612
|
-
ctx.out(" usage: npx heedvane listener start");
|
|
613
|
-
ctx.out("
|
|
817
|
+
ctx.out(" usage: npx heedvane listener start (in the foreground; Ctrl-C stops it)");
|
|
818
|
+
ctx.out(" npx heedvane listener restart (in the background, with heed.yaml and the sign-in as they are now)");
|
|
614
819
|
return sub === "" ? 2 : 1;
|
|
615
820
|
}
|
|
616
|
-
|
|
821
|
+
let started;
|
|
822
|
+
try {
|
|
823
|
+
started = await serveHooks(ctx.home, ctx.out);
|
|
824
|
+
}
|
|
825
|
+
catch (err) {
|
|
826
|
+
return startRefusal(ctx, err);
|
|
827
|
+
}
|
|
617
828
|
ctx.out(` ✓ listener on 127.0.0.1:${started.port}, ${started.replayed} spooled event(s) replayed (Ctrl-C to stop)`);
|
|
618
|
-
// Ends on Ctrl-C, or when
|
|
829
|
+
// Ends on Ctrl-C, or when a command asks it to stop (POST /shutdown: a listener taking over, uninstall).
|
|
619
830
|
await new Promise((resolve) => {
|
|
620
831
|
started.server.on("close", resolve);
|
|
621
|
-
process
|
|
832
|
+
// Once: hooks in flight finish after the first Ctrl-C; a second one ends the process at once.
|
|
833
|
+
process.once("SIGINT", () => {
|
|
834
|
+
ctx.out(" · [heedvane] listener stopping: Ctrl-C");
|
|
835
|
+
started.server.close();
|
|
836
|
+
started.server.closeIdleConnections();
|
|
837
|
+
});
|
|
622
838
|
});
|
|
623
839
|
return 0;
|
|
624
840
|
}
|
|
841
|
+
/** Why `listener start` did not start: 0 when a listener it keeps already runs, PORT_TAKEN_EXIT when the one there did not stop; else rethrows. */
|
|
842
|
+
function startRefusal(ctx, err) {
|
|
843
|
+
if (err instanceof ListenerAlreadyRuns) {
|
|
844
|
+
ctx.out(` · ${err.message}; nothing to start`);
|
|
845
|
+
return 0;
|
|
846
|
+
}
|
|
847
|
+
if (err.code !== "EADDRINUSE")
|
|
848
|
+
throw err;
|
|
849
|
+
const port = err.port ?? 0;
|
|
850
|
+
ctx.out(` ✗ 127.0.0.1:${port} stays taken: the Heedvane listener there did not stop when asked. Stop it: ${stopCommand(port)}`);
|
|
851
|
+
return PORT_TAKEN_EXIT;
|
|
852
|
+
}
|
|
853
|
+
/** `listener restart`: a background listener with heed.yaml and the sign-in as they are now (after a hand edit). */
|
|
854
|
+
async function cmdListenerRestart(ctx) {
|
|
855
|
+
await ensureEndpoint(join(ctx.home, ".heed"));
|
|
856
|
+
const outcome = await ctx.ensureListener(ctx.home);
|
|
857
|
+
const stillRunning = sayListenerFully(outcome, (line) => ctx.out(` ${line}`));
|
|
858
|
+
if (outcome.kind === "running")
|
|
859
|
+
ctx.out(" ✓ the listener runs with heed.yaml and the sign-in as they are now");
|
|
860
|
+
return stillRunning !== undefined || outcome.kind === "did not start" ? 1 : 0;
|
|
861
|
+
}
|
|
625
862
|
/**
|
|
626
|
-
* `run -- <agent command>`:
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
* Returns the agent's exit code (128 + signal number when a signal ended it)
|
|
863
|
+
* `run -- <agent command>`: for CI, containers and software factories. The run makes sure the machine's listener runs
|
|
864
|
+
* with the current settings ({@link ensureListener}), as every command does, and runs the agent with it; parallel runs
|
|
865
|
+
* share it. The agent keeps stdin, stdout and stderr; Heedvane's own lines go to stderr, so an agent's JSON output stays
|
|
866
|
+
* parseable. Returns the agent's exit code (128 + signal number when a signal ended it); Heedvane never keeps the agent
|
|
867
|
+
* from running.
|
|
630
868
|
*/
|
|
631
869
|
async function cmdRun(ctx) {
|
|
632
870
|
const say = (line) => void process.stderr.write(`heedvane: ${line}\n`);
|
|
@@ -635,23 +873,27 @@ async function cmdRun(ctx) {
|
|
|
635
873
|
return 2;
|
|
636
874
|
}
|
|
637
875
|
const root = join(ctx.home, ".heed");
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
876
|
+
// Heedvane never keeps the agent from running: a heed folder it cannot write runs the agent unchecked, and says so.
|
|
877
|
+
await prepareRun(ctx, root, say).catch((err) => {
|
|
878
|
+
say(`could not make sure the listener runs (${errorMessage(err)}), so this run's calls may not be checked`);
|
|
879
|
+
return undefined; // the agent runs all the same
|
|
880
|
+
});
|
|
881
|
+
// shortcut: the listener outlives the run, so in a container that ends with the agent a delivery report still waiting for its retry is lost; flush before returning if factories miss reports.
|
|
882
|
+
// HEED_AGENT_HOOK_ROOT: the hooks keep the spool in this heed folder, also when HEED_HOME is not $HOME.
|
|
883
|
+
return runAgent({ command: ctx.command, env: { ...ctx.env, HEED_HOME: ctx.home, HEED_AGENT_HOOK_ROOT: root }, say });
|
|
884
|
+
}
|
|
885
|
+
/** Before `run`'s agent starts: the heed folder, a hint when nothing is signed in, and the listener with the current settings. */
|
|
886
|
+
async function prepareRun(ctx, root, say) {
|
|
887
|
+
await ensureEndpoint(root); // a fresh container has no ~/.heed yet
|
|
888
|
+
const settings = await settingsToRun(root).catch((err) => void say(`~/.heed/heed.yaml does not parse (${errorMessage(err)}), so guardrails are off`));
|
|
889
|
+
if (settings !== undefined && (await backendAt(root, settings.config.url)) === null)
|
|
890
|
+
say("not signed in, so guardrails are off: printf '%s' \"$HEEDVANE_ORG_KEY\" | npx -y heedvane auth org");
|
|
891
|
+
const stillRunning = sayListener(await ctx.ensureListener(ctx.home), (line) => say(line.replace(/^[✓·] /, "")));
|
|
892
|
+
if (stillRunning !== undefined)
|
|
893
|
+
say(stillRunning);
|
|
652
894
|
}
|
|
653
895
|
/** Runs the agent with this process's terminal, passes Ctrl-C and `docker stop` on to it, and resolves with its exit code. */
|
|
654
|
-
function runAgent([program, ...args], env, say) {
|
|
896
|
+
function runAgent({ command: [program, ...args], env, say }) {
|
|
655
897
|
return new Promise((resolve) => {
|
|
656
898
|
const child = spawn(program, args, { stdio: "inherit", env });
|
|
657
899
|
const forward = (signal) => void child.kill(signal);
|
|
@@ -676,12 +918,15 @@ function backendCommandContext(ctx) {
|
|
|
676
918
|
* The command that opens `url` in the default browser. Windows goes through rundll32, not `cmd /c start`:
|
|
677
919
|
* cmd would cut the sign-in URL at its first `&`.
|
|
678
920
|
*/
|
|
679
|
-
|
|
921
|
+
/** 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. */
|
|
922
|
+
export function browserOpener(platform, url, browser) {
|
|
923
|
+
if (browser)
|
|
924
|
+
return [browser, [url]];
|
|
680
925
|
const openers = { darwin: ["open", [url]], win32: ["rundll32", ["url.dll,FileProtocolHandler", url]] };
|
|
681
926
|
return openers[platform] ?? ["xdg-open", [url]];
|
|
682
927
|
}
|
|
683
|
-
function openSystemBrowser(url) {
|
|
684
|
-
const [command, args] = browserOpener(process.platform, url);
|
|
928
|
+
function openSystemBrowser(url, browser) {
|
|
929
|
+
const [command, args] = browserOpener(process.platform, url, browser);
|
|
685
930
|
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
686
931
|
child.on("error", () => undefined); // no browser opener: the printed URL is the way in
|
|
687
932
|
child.unref();
|