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.
Files changed (65) hide show
  1. package/README.md +60 -13
  2. package/dist/backend/credentials.d.ts +2 -0
  3. package/dist/backend/credentials.js +13 -0
  4. package/dist/backend/repo-remote.js +2 -1
  5. package/dist/backend/sign-in-change.js +3 -2
  6. package/dist/cli.d.ts +4 -2
  7. package/dist/cli.js +389 -144
  8. package/dist/clients/definitions.d.ts +8 -4
  9. package/dist/clients/definitions.js +48 -13
  10. package/dist/clients/hermes-plugin.d.ts +5 -20
  11. package/dist/clients/hermes-plugin.js +97 -71
  12. package/dist/clients/hook-files.d.ts +22 -0
  13. package/dist/clients/hook-files.js +39 -0
  14. package/dist/clients/pi-extension.d.ts +9 -0
  15. package/dist/clients/pi-extension.js +41 -0
  16. package/dist/commands/heedvane.js +18 -3
  17. package/dist/commands/mcp-stub.d.ts +1 -0
  18. package/dist/commands/mcp-stub.js +44 -0
  19. package/dist/commands/memory.js +6 -1
  20. package/dist/dispatchers/posix.d.ts +16 -19
  21. package/dist/dispatchers/posix.js +71 -91
  22. package/dist/doctor.d.ts +2 -0
  23. package/dist/doctor.js +113 -29
  24. package/dist/guard.d.ts +73 -0
  25. package/dist/guard.js +302 -0
  26. package/dist/heed-file.d.ts +19 -3
  27. package/dist/heed-file.js +46 -23
  28. package/dist/hook-fallback.d.ts +73 -0
  29. package/dist/hook-fallback.js +61 -0
  30. package/dist/installer.d.ts +13 -6
  31. package/dist/installer.js +40 -13
  32. package/dist/listener/background.d.ts +48 -4
  33. package/dist/listener/background.js +153 -19
  34. package/dist/listener/endpoint.d.ts +26 -3
  35. package/dist/listener/endpoint.js +55 -10
  36. package/dist/listener/forward.d.ts +7 -1
  37. package/dist/listener/forward.js +40 -12
  38. package/dist/listener/serve.d.ts +37 -0
  39. package/dist/listener/serve.js +196 -32
  40. package/dist/listener/server.d.ts +9 -0
  41. package/dist/listener/server.js +59 -22
  42. package/dist/listener/spool.d.ts +2 -17
  43. package/dist/listener/spool.js +6 -3
  44. package/dist/mcp.d.ts +38 -48
  45. package/dist/mcp.js +196 -63
  46. package/dist/plan.d.ts +47 -8
  47. package/dist/plan.js +164 -52
  48. package/dist/render.js +8 -8
  49. package/dist/reply/harness-output.d.ts +9 -0
  50. package/dist/reply/harness-output.js +13 -0
  51. package/dist/reply/reply.js +2 -1
  52. package/dist/skill/content.d.ts +2 -2
  53. package/dist/skill/content.js +11 -8
  54. package/dist/status.d.ts +1 -0
  55. package/dist/status.js +29 -0
  56. package/dist/steering/rules.js +4 -2
  57. package/dist/uninstall.d.ts +7 -0
  58. package/dist/uninstall.js +72 -71
  59. package/package.json +10 -3
  60. package/skills/heedvane-hooks/SKILL.md +6 -6
  61. package/skills/heedvane-hooks/references/events.md +5 -2
  62. package/dist/dispatchers/windows.d.ts +0 -15
  63. package/dist/dispatchers/windows.js +0 -50
  64. package/dist/mcp-server.d.ts +0 -9
  65. 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-hook — command routing: init | setup | doctor | uninstall |
4
- * listener | status | mcp. Plain argv parsing (no commander); all state
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 { createWriteStream, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
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 { dirname, join } from "node:path";
10
+ import { join } from "node:path";
11
11
  import { supportedClients } from "./clients/definitions.js";
12
- import { installHermesPlugin } from "./clients/hermes-plugin.js";
12
+ import { installHookFiles } from "./clients/hook-files.js";
13
13
  import { applyEntry } from "./apply.js";
14
- import { DEFAULT_HEED_YAML_TEMPLATE, heedvaneConfigAt, loadHeedFile, loadHeedFileAt, withInstallChoices, withServerUrl } from "./heed-file.js";
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, runMcpStdio } from "./mcp-server.js";
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, startListenerInBackground } from "./listener/background.js";
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
- /** The first argument that looks like a flag but is not one of ours (values of value flags skipped). */
114
- function unknownFlag(args) {
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
- if (VALUE_FLAGS.includes(args[i]))
120
+ const arg = args[i];
121
+ if (VALUE_FLAGS.includes(arg)) {
117
122
  i += 1;
118
- else if (args[i].startsWith("-") && !SWITCH_FLAGS.includes(args[i]))
119
- return args[i];
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 unknown = unknownFlag(own);
148
- if (unknown !== undefined) {
149
- ctx.out(` ✗ unknown flag: ${unknown} (nothing was changed)`);
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
- startListener: opts.startListener ?? ((home) => startListenerInBackground(home, fileURLToPath(import.meta.url), env)),
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({ home: ctx.home, out: ctx.out, rest: ctx.args.slice(ctx.args.indexOf("listener") + 1) });
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
- return cmdLogin(backendCommandContext(ctx));
211
- case "auth":
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 (command === "auth" ? cmdAuth : cmdSkills)(backendCommandContext(ctx));
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 = ctx.server === undefined ? null : withServer(home, loaded, ctx.server);
274
+ serverEdit = withFlags(loaded, ctx);
264
275
  }
265
276
  catch (err) {
266
277
  out("");
267
- out(` ✗ --server ${ctx.server} cannot be used: ${errorMessage(err)}`);
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 (or the template) with `--server` as `heedvane.url`; null when it already names that server. Throws on an unusable URL. */
293
- function withServer(home, loaded, server) {
294
- const text = withServerUrl(heedText(home, loaded.existed), server);
295
- const heedFile = loadHeedFile(text);
296
- if (loaded.existed && heedFile.heedvane.url === loaded.heedFile.heedvane.url)
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
- if (!flags.dryRun)
306
- await signInIfNeeded(ctx, spec.heedFile.heedvane.url);
307
- out("");
308
- out(" Next: open a new agent session and run /heedvane-status.");
309
- out(" Re-run `npx heedvane doctor` after any heed.yaml edit.");
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
- /** Writes heed.yaml when it is missing, or when `--server` changed its URL. */
316
- function writeControlFile({ home, out }, { heedFile, existed, text }) {
317
- if (existed && text === undefined)
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
- writeFileSync(join(home, ".heed", "heed.yaml"), text ?? DEFAULT_HEED_YAML_TEMPLATE, { mode: 0o600 });
321
- out(existed ? ` ✓ Set heedvane.url in ~/.heed/heed.yaml to ${heedFile.heedvane.url}` : ` ✓ Wrote ~/.heed/heed.yaml (your control file — edit any time)`);
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
- writeControlFile(ctx, spec);
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 dispatcher = {
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
- async function signInIfNeeded(ctx, url) {
351
- const root = join(ctx.home, ".heed");
352
- if ((await backendAt(root, url)) !== null && (await onRequestedOrg(root, ctx.org)))
353
- return;
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
- /** True without `--org`; else whether the saved login was granted for that organization. */
364
- async function onRequestedOrg(root, org) {
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 true;
367
- const saved = await readLogin(root).catch(() => null);
368
- return saved?.organizationId === org;
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
- const choices = await askChoices(agentOptions(ctx), base.heedFile, prompts);
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 listening = await listenerStep(() => ctx.startListener(ctx.home), listenerLogPath(join(ctx.home, ".heed")), prompts);
431
- await signInIfNeeded({ ...ctx, out: promptLine(prompts) }, spec.heedFile.heedvane.url);
432
- installFinished(nextSteps(listening, spec.heedFile.heedvane.url), prompts);
433
- return applied.some(appliedFailed) ? 1 : 0;
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 either cannot be used. */
436
- function installerBase({ home, server }) {
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
- const current = heedText(home, loaded.existed);
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 `--server ${server} cannot be used: ${errorMessage(err)}`;
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, server) {
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
- /** Hermes' hooks capability rides its native plugin; installHermesPlugin owns creation and ownership checks. */
462
- async function applyHermesPlugin(client, ctx) {
463
- const path = join(ctx.home, ".hermes", "plugins", "heedvane-hooks", "plugin.yaml");
464
- const status = await installHermesPlugin(ctx.home, ctx.dispatcher.gating);
465
- if (status === "skipped") {
466
- return { clientId: client.id, kind: "hooks", status: "skipped", path, reason: "another plugin owns ~/.hermes/plugins/heedvane-hooks — left untouched" };
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
- for (const item of await removeStaleHookGroups(client, target)) {
477
- if (item.status === "failed")
478
- return { status: "failed", path: item.path, reason: item.detail };
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 (action.kind === "hooks" && worst.status !== "failed" && worst.status !== "invalid") {
507
- worst = await withoutStaleHookGroups(client, writes.values(), worst);
508
- }
509
- // The dispatcher script the snippet delegates to.
510
- await writeDispatcherScript(ctx.home, client.id, ctx.dispatcher);
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 (client.id === "hermes" && action.kind === "hooks") {
528
- return applyHermesPlugin(client, ctx);
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(" v1 runs the listener in the foreground; service install (launchd/systemd) is opt-in later.");
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
- const started = await serveHooks(ctx.home, ctx.out);
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 uninstall asks it to stop (POST /shutdown).
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.on("SIGINT", () => started.server.close());
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>`: a listener for exactly one agent run, for CI, containers and software
627
- * factories where nothing stays running. The agent keeps stdin, stdout and stderr; the listener writes to
628
- * ~/.heed/listener.log and Heedvane's own lines go to stderr, so an agent's JSON output stays parseable.
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
- mkdirSync(root, { recursive: true, mode: 0o700 }); // a fresh container has no ~/.heed yet
639
- const log = createWriteStream(listenerLogPath(root), { flags: "a", mode: 0o600 });
640
- const started = await serveHooks(ctx.home, (line) => void log.write(`${line}\n`));
641
- if ((await backendAt(root, heedvaneConfigAt(root).url)) === null)
642
- say("not signed in, so guardrails are off: printf '%s' \"$HEEDVANE_ORG_KEY\" | npx heedvane auth org");
643
- say(`listening on 127.0.0.1:${started.port}; log: ${listenerLogPath(root)}`);
644
- try {
645
- // The agent's hooks find this listener's endpoint through HEED_AGENT_HOOK_ROOT, also when HEED_HOME is not $HOME.
646
- return await runAgent(ctx.command, { ...ctx.env, HEED_HOME: ctx.home, HEED_AGENT_HOOK_ROOT: root }, say);
647
- }
648
- finally {
649
- await new Promise((resolve) => started.server.close(() => resolve()));
650
- await new Promise((resolve) => log.end(resolve));
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
- export function browserOpener(platform, url) {
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();