heedvane 0.3.3 → 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 +41 -9
- 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 +2 -1
- package/dist/cli.js +325 -118
- package/dist/clients/definitions.d.ts +7 -3
- package/dist/clients/definitions.js +27 -7
- 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/dispatchers/posix.d.ts +16 -19
- package/dist/dispatchers/posix.js +71 -91
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +96 -28
- package/dist/guard.d.ts +73 -0
- package/dist/guard.js +302 -0
- package/dist/heed-file.d.ts +5 -2
- package/dist/heed-file.js +7 -7
- package/dist/hook-fallback.d.ts +73 -0
- package/dist/hook-fallback.js +61 -0
- package/dist/installer.d.ts +5 -5
- package/dist/installer.js +13 -12
- 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 +23 -4
- package/dist/mcp.js +124 -22
- package/dist/plan.d.ts +35 -6
- package/dist/plan.js +125 -40
- package/dist/render.js +2 -1
- package/dist/reply/harness-output.d.ts +9 -0
- package/dist/reply/harness-output.js +13 -0
- package/dist/skill/content.d.ts +2 -2
- package/dist/skill/content.js +6 -3
- package/dist/status.js +1 -1
- package/dist/steering/rules.js +4 -2
- package/dist/uninstall.d.ts +7 -2
- package/dist/uninstall.js +49 -44
- package/package.json +10 -3
- package/skills/heedvane-hooks/SKILL.md +3 -3
- package/skills/heedvane-hooks/references/events.md +3 -0
- package/dist/dispatchers/windows.d.ts +0 -15
- package/dist/dispatchers/windows.js +0 -50
package/dist/cli.js
CHANGED
|
@@ -1,32 +1,34 @@
|
|
|
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
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 { DATA_MODES, DEFAULT_HEED_YAML_TEMPLATE,
|
|
15
|
-
import { planActions, desiredEntriesFor,
|
|
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";
|
|
21
|
+
import { writeDispatcherScript, writeHookNode } from "./dispatchers/posix.js";
|
|
22
22
|
import { removeDeferredHookGroups, removeLegacyMcpEntries, removeStaleHookGroups, uninstall } from "./uninstall.js";
|
|
23
23
|
import { statusText } from "./status.js";
|
|
24
|
-
import { MCP_NEEDS_ORG } from "./mcp.js";
|
|
25
24
|
import { errorMessage } from "./guards.js";
|
|
26
|
-
import { ensureEndpoint } from "./listener/endpoint.js";
|
|
27
|
-
import { listenerLogPath,
|
|
28
|
-
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";
|
|
29
29
|
import { cmdAuth, cmdLogin, cmdSkills } from "./commands/heedvane.js";
|
|
30
|
+
import { cmdMcpStub } from "./commands/mcp-stub.js";
|
|
31
|
+
import writeFileAtomic from "write-file-atomic";
|
|
30
32
|
import { cmdMemory } from "./commands/memory.js";
|
|
31
33
|
import { backendAt } from "./backend/credentials.js";
|
|
32
34
|
import { readLogin } from "./backend/login.js";
|
|
@@ -43,7 +45,7 @@ const USAGE = ` heedvane-hook — cross-agent steering, guardrails & analysis
|
|
|
43
45
|
setup plan + apply from an existing heed.yaml
|
|
44
46
|
doctor scoped, remediation-carrying health check
|
|
45
47
|
uninstall marker-verified removal (never touches heed.yaml)
|
|
46
|
-
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
|
|
47
49
|
login sign in to Heedvane in the browser (~/.heed/login.json, 0600; refreshed automatically)
|
|
48
50
|
auth org CI/headless: store an organization API key read from stdin (~/.heed/credentials.json, 0600)
|
|
49
51
|
skills sync install the organization's approved skills and cache the list hooks enforce
|
|
@@ -112,13 +114,17 @@ function ownArgs(args) {
|
|
|
112
114
|
const separator = args.indexOf("--");
|
|
113
115
|
return separator < 0 ? args : args.slice(0, separator);
|
|
114
116
|
}
|
|
115
|
-
/**
|
|
116
|
-
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) {
|
|
117
119
|
for (let i = 0; i < args.length; i++) {
|
|
118
|
-
|
|
120
|
+
const arg = args[i];
|
|
121
|
+
if (VALUE_FLAGS.includes(arg)) {
|
|
119
122
|
i += 1;
|
|
120
|
-
|
|
121
|
-
|
|
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}`;
|
|
122
128
|
}
|
|
123
129
|
return undefined;
|
|
124
130
|
}
|
|
@@ -146,9 +152,9 @@ async function main(argv, opts = {}) {
|
|
|
146
152
|
ctx.out(helpFor(helpTopic(ctx.positional)));
|
|
147
153
|
return 0;
|
|
148
154
|
}
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
151
|
-
ctx.out(` ✗
|
|
155
|
+
const problem = flagProblem(own);
|
|
156
|
+
if (problem !== undefined) {
|
|
157
|
+
ctx.out(` ✗ ${problem} (nothing was changed)`);
|
|
152
158
|
ctx.out(helpFor(command));
|
|
153
159
|
return 2;
|
|
154
160
|
}
|
|
@@ -174,7 +180,7 @@ function commandContext(argv, opts) {
|
|
|
174
180
|
interactive: opts.interactive ?? (process.stdin.isTTY === true && process.stdout.isTTY === true),
|
|
175
181
|
cwd: opts.cwd ?? process.cwd(),
|
|
176
182
|
prompts: opts.prompts ?? {},
|
|
177
|
-
|
|
183
|
+
ensureListener: opts.ensureListener ?? ((home) => ensureListener(home, fileURLToPath(import.meta.url), env)),
|
|
178
184
|
};
|
|
179
185
|
}
|
|
180
186
|
async function routeCommand(command, ctx) {
|
|
@@ -200,15 +206,22 @@ async function routeServiceCommand(command, ctx) {
|
|
|
200
206
|
return 0;
|
|
201
207
|
}
|
|
202
208
|
case "listener": {
|
|
203
|
-
return cmdListener({
|
|
209
|
+
return cmdListener({ ...ctx, rest: ctx.args.slice(ctx.args.indexOf("listener") + 1) });
|
|
204
210
|
}
|
|
205
211
|
case "run":
|
|
206
|
-
return cmdRun({ home: ctx.home, env: ctx.env, command: ctx.args.slice(ctx.args.indexOf("--") + 1), separated: ctx.args.includes("--") });
|
|
207
|
-
case "login":
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
}
|
|
210
221
|
case "skills":
|
|
211
|
-
return
|
|
222
|
+
return cmdSkills(backendCommandContext(ctx));
|
|
223
|
+
case "mcp":
|
|
224
|
+
return cmdMcpStub();
|
|
212
225
|
case "memory":
|
|
213
226
|
return cmdMemory({ home: ctx.home, out: ctx.out, words: ctx.positional.slice(1), flag: (name) => flagValue(ctx.args, name), cwd: ctx.cwd });
|
|
214
227
|
default:
|
|
@@ -258,7 +271,7 @@ async function cmdInit(ctx) {
|
|
|
258
271
|
}
|
|
259
272
|
let serverEdit;
|
|
260
273
|
try {
|
|
261
|
-
serverEdit = withFlags(
|
|
274
|
+
serverEdit = withFlags(loaded, ctx);
|
|
262
275
|
}
|
|
263
276
|
catch (err) {
|
|
264
277
|
out("");
|
|
@@ -294,9 +307,16 @@ function flagSettings({ server, org, mode }) {
|
|
|
294
307
|
const settings = [[["heedvane", "url"], server], [["heedvane", "org"], org], [["heedvane", "dataMode"], mode]];
|
|
295
308
|
return settings.filter((setting) => setting[1] !== undefined);
|
|
296
309
|
}
|
|
297
|
-
/**
|
|
298
|
-
|
|
299
|
-
|
|
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 });
|
|
300
320
|
if (settings.length === 0)
|
|
301
321
|
return null;
|
|
302
322
|
const text = withSettings(heedText(home, loaded.existed), settings);
|
|
@@ -311,53 +331,69 @@ function withFlags(home, loaded, flags) {
|
|
|
311
331
|
return null;
|
|
312
332
|
return { heedFile, existed: loaded.existed, text };
|
|
313
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
|
+
}
|
|
314
342
|
async function applyAndReport(ctx, spec) {
|
|
315
343
|
const { out, flags } = ctx;
|
|
344
|
+
if (!(await signedInToOrg(ctx, spec.heedFile.heedvane)))
|
|
345
|
+
return signInRefused(ctx);
|
|
316
346
|
const { actions, applied } = await installPlan(ctx, spec);
|
|
317
347
|
for (const line of renderApplied(applied))
|
|
318
348
|
out(line);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
out(
|
|
322
|
-
out
|
|
323
|
-
|
|
324
|
-
out(` ${MCP_NEEDS_ORG}`);
|
|
325
|
-
out(" Re-run `npx heedvane doctor` after any heed.yaml edit.");
|
|
326
|
-
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;
|
|
327
354
|
if (flags.json)
|
|
328
|
-
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 }) });
|
|
329
356
|
return failed ? 1 : 0;
|
|
330
357
|
}
|
|
331
|
-
/**
|
|
332
|
-
function
|
|
333
|
-
|
|
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")))
|
|
334
373
|
return;
|
|
335
374
|
mkdirSync(join(home, ".heed"), { recursive: true, mode: 0o700 });
|
|
336
|
-
|
|
375
|
+
writeFileAtomic.sync(path, text ?? DEFAULT_HEED_YAML_TEMPLATE, { mode: 0o600 });
|
|
337
376
|
if (!existed)
|
|
338
377
|
return out(` ✓ Wrote ~/.heed/heed.yaml (your control file — edit any time)`);
|
|
339
378
|
const { url, org, dataMode } = heedFile.heedvane;
|
|
340
379
|
for (const [name, value] of [["url", url], ["org", org], ["dataMode", dataMode]]) {
|
|
341
|
-
if (value !== undefined)
|
|
380
|
+
if (value !== undefined && value !== before?.[name])
|
|
342
381
|
out(` ✓ Set heedvane.${name} in ~/.heed/heed.yaml to ${value}`);
|
|
343
382
|
}
|
|
344
383
|
}
|
|
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. */
|
|
346
384
|
/** Writes heed.yaml, applies the plan, issues the listener endpoint and records the machine state. Prints only what applying prints. */
|
|
347
385
|
async function installPlan(ctx, spec) {
|
|
348
386
|
const { home, env, out } = ctx;
|
|
349
387
|
const { heedFile, agents } = spec;
|
|
350
388
|
// The control file comes first (mock order): "✓ Wrote ~/.heed/heed.yaml".
|
|
351
|
-
|
|
389
|
+
const before = spec.existed ? loadOrTemplate(home).heedFile.heedvane : undefined;
|
|
390
|
+
writeControlFile(ctx, spec, before);
|
|
352
391
|
const actions = planFor({ home, env, heedFile, agents });
|
|
353
|
-
const
|
|
354
|
-
denySkillsWhenUnchecked: heedFile.heedvane.skills.enforce && heedFile.heedvane.skills.whenUnknown === "deny",
|
|
355
|
-
gating: heedFile.gating.mode,
|
|
356
|
-
nodeDir: installingNodeDir(),
|
|
357
|
-
};
|
|
358
|
-
const applied = await applyPlan(actions, { home, env, out, dispatcher, connector: heedvaneConnector(heedFile) });
|
|
392
|
+
const applied = await applyPlan(actions, applyContext({ home, env, out }, heedFile));
|
|
359
393
|
// The listener port and token every dispatcher reads; kept once issued.
|
|
360
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"]);
|
|
361
397
|
// Machine state: CLI-owned; preserve an existing endpoint.
|
|
362
398
|
const root = join(home, ".heed");
|
|
363
399
|
const existing = await readState(root);
|
|
@@ -369,25 +405,130 @@ async function installPlan(ctx, spec) {
|
|
|
369
405
|
await writeStateAtomic(root, state);
|
|
370
406
|
return { actions, applied };
|
|
371
407
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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;
|
|
376
499
|
if (!ctx.interactive) {
|
|
377
500
|
ctx.out(" Next: sign in with `npx heedvane login` (CI and headless machines: npx heedvane auth org < key-file).");
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
await cmdLogin({ home: ctx.home, out: ctx.out, words: [], project: false, readStdin: async () => "", openBrowser: ctx.openBrowser });
|
|
381
|
-
if (!(await onRequestedOrg(root, ctx.org))) {
|
|
382
|
-
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;
|
|
383
502
|
}
|
|
503
|
+
return (await signIn(ctx)) && isOrgSignIn(ctx, target);
|
|
384
504
|
}
|
|
385
|
-
/**
|
|
386
|
-
|
|
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 }) {
|
|
387
523
|
if (org === undefined)
|
|
388
|
-
return
|
|
389
|
-
const saved = await readLogin(
|
|
390
|
-
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;
|
|
391
532
|
}
|
|
392
533
|
async function cmdSetup(ctx) {
|
|
393
534
|
const { home, out } = ctx;
|
|
@@ -427,6 +568,8 @@ function takesInstaller(ctx) {
|
|
|
427
568
|
function heedText(home, existed) {
|
|
428
569
|
return existed ? readFileSync(join(home, ".heed", "heed.yaml"), "utf8") : DEFAULT_HEED_YAML_TEMPLATE;
|
|
429
570
|
}
|
|
571
|
+
/** The outcomes after which a listener answers the hooks. */
|
|
572
|
+
const LISTENING = new Set(["running", "started", "replaced", "restarted", "newer"]);
|
|
430
573
|
/** The installer: pick agents, integrations and features, confirm, install, start the listener, sign in. */
|
|
431
574
|
async function cmdInstall(ctx) {
|
|
432
575
|
const base = installerBase(ctx);
|
|
@@ -436,23 +579,44 @@ async function cmdInstall(ctx) {
|
|
|
436
579
|
}
|
|
437
580
|
const { prompts } = ctx;
|
|
438
581
|
brandIntro(base.heedFile.heedvane.url, ctx.org, prompts);
|
|
439
|
-
|
|
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);
|
|
440
585
|
if (choices === null)
|
|
441
586
|
return installCancelled(prompts);
|
|
442
587
|
const text = withInstallChoices(base.text, choices);
|
|
443
588
|
const spec = { heedFile: loadHeedFile(text), agents: choices.agents === "auto" ? [] : choices.agents, existed: base.existed, text };
|
|
444
589
|
const names = new Map(detectionRows(ctx.home, ctx.env).map((row) => [row.client.id, row.client.displayName]));
|
|
445
590
|
const planned = agentLines(planFor({ home: ctx.home, env: ctx.env, ...spec }), names);
|
|
446
|
-
if (!(await confirmInstall(planned, installDetails(spec.heedFile
|
|
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));
|
|
447
596
|
return installCancelled(prompts);
|
|
597
|
+
}
|
|
448
598
|
const notes = [];
|
|
449
599
|
const { actions, applied } = await installWithSpinner(planned.length, () => installPlan({ ...ctx, out: (line) => notes.push(line) }, spec), prompts);
|
|
450
600
|
notes.forEach(promptLine(prompts));
|
|
451
601
|
showInstalled(agentLines(actions, names, applied), prompts);
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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"}`;
|
|
456
620
|
}
|
|
457
621
|
/** heed.yaml's current text (or the template) with `--server`, `--org` and `--mode` applied, parsed; a message when it cannot be used. */
|
|
458
622
|
function installerBase(ctx) {
|
|
@@ -460,7 +624,7 @@ function installerBase(ctx) {
|
|
|
460
624
|
if (loaded.error)
|
|
461
625
|
return `~/.heed/heed.yaml does not parse: ${loaded.error}`;
|
|
462
626
|
try {
|
|
463
|
-
return withFlags(
|
|
627
|
+
return withFlags(loaded, ctx) ?? { heedFile: loaded.heedFile, existed: loaded.existed, text: heedText(ctx.home, loaded.existed) };
|
|
464
628
|
}
|
|
465
629
|
catch (err) {
|
|
466
630
|
return errorMessage(err);
|
|
@@ -469,24 +633,23 @@ function installerBase(ctx) {
|
|
|
469
633
|
function agentOptions({ home, env }) {
|
|
470
634
|
return detectionRows(home, env).map(({ client, detected }) => ({ id: client.id, name: client.displayName, detected, capability: capabilitySummary(client) }));
|
|
471
635
|
}
|
|
472
|
-
function nextSteps(listening, heedFile) {
|
|
636
|
+
function nextSteps(listening, heedFile, stillRunning) {
|
|
473
637
|
const server = heedFile.heedvane.url;
|
|
474
638
|
return [
|
|
475
|
-
"Open a new agent session: Heedvane joins it at session start.",
|
|
476
|
-
...(heedFile.
|
|
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),
|
|
477
641
|
`Watch your sessions: ${server}`,
|
|
478
|
-
...(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"]),
|
|
479
643
|
"Check the install: npx heedvane doctor",
|
|
480
644
|
"Change your choices: npx heedvane",
|
|
481
645
|
];
|
|
482
646
|
}
|
|
483
|
-
/**
|
|
484
|
-
async function
|
|
485
|
-
const path =
|
|
486
|
-
const status = await
|
|
487
|
-
if (status === "skipped")
|
|
488
|
-
return { clientId: client.id, kind: "hooks", status: "skipped", path, reason:
|
|
489
|
-
}
|
|
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` };
|
|
490
653
|
return { clientId: client.id, kind: "hooks", status: status === "installed" ? "create" : "unchanged", path };
|
|
491
654
|
}
|
|
492
655
|
/** Folds removals into an apply result: a failed removal becomes the result, any other makes an unchanged one an update. */
|
|
@@ -512,15 +675,17 @@ async function withoutStaleHookGroups(client, writes, result) {
|
|
|
512
675
|
}
|
|
513
676
|
return worst;
|
|
514
677
|
}
|
|
515
|
-
/** After the writes: our hook groups under events no longer installed, and
|
|
678
|
+
/** After the writes: our hook groups under events no longer installed, and the MCP entries plan.ts `mcpLeftovers` names. */
|
|
516
679
|
async function withoutLeftovers(kind, client, done) {
|
|
517
680
|
if (kind === "hooks")
|
|
518
681
|
return withoutStaleHookGroups(client, done.writes, done.result);
|
|
519
|
-
|
|
682
|
+
if (kind !== "mcp")
|
|
683
|
+
return done.result;
|
|
684
|
+
return withRemovals(await removeLegacyMcpEntries(client, done.mcpLeftovers), done.result);
|
|
520
685
|
}
|
|
521
686
|
/** Patch every desired entry into the client's config files, then refresh the dispatcher script. */
|
|
522
687
|
async function applyConfigEntries(action, client, ctx) {
|
|
523
|
-
const desireds = desiredEntriesFor(client, action.kind === "hooks" ? "hooks" : "mcp", { home: ctx.home, env: ctx.env, connector: ctx.connector });
|
|
688
|
+
const desireds = desiredEntriesFor(client, action.kind === "hooks" ? "hooks" : "mcp", { home: ctx.home, env: ctx.env, connector: ctx.connector, nodeDir: bridgeNodeDir(platform()) });
|
|
524
689
|
// One write per file and container: every hook event lands (and is backed up) together.
|
|
525
690
|
const writes = new Map();
|
|
526
691
|
for (const d of desireds) {
|
|
@@ -540,9 +705,10 @@ async function applyConfigEntries(action, client, ctx) {
|
|
|
540
705
|
worst = res;
|
|
541
706
|
}
|
|
542
707
|
if (worst.status !== "failed" && worst.status !== "invalid")
|
|
543
|
-
worst = await withoutLeftovers(action.kind, client, { writes: writes.values(), result: worst });
|
|
544
|
-
// The dispatcher script the
|
|
545
|
-
|
|
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);
|
|
546
712
|
return {
|
|
547
713
|
clientId: action.clientId,
|
|
548
714
|
kind: action.kind,
|
|
@@ -559,9 +725,11 @@ async function applyAction(action, client, ctx) {
|
|
|
559
725
|
const status = await installSkill(join(ctx.home, ".agents", "skills"));
|
|
560
726
|
return { clientId: action.clientId, kind: "skill", status, path: action.path };
|
|
561
727
|
}
|
|
562
|
-
if (
|
|
563
|
-
return
|
|
564
|
-
|
|
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 };
|
|
565
733
|
return applyConfigEntries(action, client, ctx);
|
|
566
734
|
}
|
|
567
735
|
async function recordDeferredHookRemovals(client, results) {
|
|
@@ -643,25 +811,60 @@ async function cmdUninstall(ctx) {
|
|
|
643
811
|
}
|
|
644
812
|
async function cmdListener(ctx) {
|
|
645
813
|
const sub = ctx.rest[0] ?? "";
|
|
814
|
+
if (sub === "restart")
|
|
815
|
+
return cmdListenerRestart(ctx);
|
|
646
816
|
if (sub !== "start") {
|
|
647
|
-
ctx.out(" usage: npx heedvane listener start");
|
|
648
|
-
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)");
|
|
649
819
|
return sub === "" ? 2 : 1;
|
|
650
820
|
}
|
|
651
|
-
|
|
821
|
+
let started;
|
|
822
|
+
try {
|
|
823
|
+
started = await serveHooks(ctx.home, ctx.out);
|
|
824
|
+
}
|
|
825
|
+
catch (err) {
|
|
826
|
+
return startRefusal(ctx, err);
|
|
827
|
+
}
|
|
652
828
|
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
|
|
829
|
+
// Ends on Ctrl-C, or when a command asks it to stop (POST /shutdown: a listener taking over, uninstall).
|
|
654
830
|
await new Promise((resolve) => {
|
|
655
831
|
started.server.on("close", resolve);
|
|
656
|
-
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
|
+
});
|
|
657
838
|
});
|
|
658
839
|
return 0;
|
|
659
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
|
+
}
|
|
660
862
|
/**
|
|
661
|
-
* `run -- <agent command>`:
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
* 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.
|
|
665
868
|
*/
|
|
666
869
|
async function cmdRun(ctx) {
|
|
667
870
|
const say = (line) => void process.stderr.write(`heedvane: ${line}\n`);
|
|
@@ -670,23 +873,27 @@ async function cmdRun(ctx) {
|
|
|
670
873
|
return 2;
|
|
671
874
|
}
|
|
672
875
|
const root = join(ctx.home, ".heed");
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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);
|
|
687
894
|
}
|
|
688
895
|
/** Runs the agent with this process's terminal, passes Ctrl-C and `docker stop` on to it, and resolves with its exit code. */
|
|
689
|
-
function runAgent([program, ...args], env, say) {
|
|
896
|
+
function runAgent({ command: [program, ...args], env, say }) {
|
|
690
897
|
return new Promise((resolve) => {
|
|
691
898
|
const child = spawn(program, args, { stdio: "inherit", env });
|
|
692
899
|
const forward = (signal) => void child.kill(signal);
|