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.
Files changed (60) hide show
  1. package/README.md +41 -9
  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 +2 -1
  7. package/dist/cli.js +325 -118
  8. package/dist/clients/definitions.d.ts +7 -3
  9. package/dist/clients/definitions.js +27 -7
  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/dispatchers/posix.d.ts +16 -19
  20. package/dist/dispatchers/posix.js +71 -91
  21. package/dist/doctor.d.ts +2 -0
  22. package/dist/doctor.js +96 -28
  23. package/dist/guard.d.ts +73 -0
  24. package/dist/guard.js +302 -0
  25. package/dist/heed-file.d.ts +5 -2
  26. package/dist/heed-file.js +7 -7
  27. package/dist/hook-fallback.d.ts +73 -0
  28. package/dist/hook-fallback.js +61 -0
  29. package/dist/installer.d.ts +5 -5
  30. package/dist/installer.js +13 -12
  31. package/dist/listener/background.d.ts +48 -4
  32. package/dist/listener/background.js +153 -19
  33. package/dist/listener/endpoint.d.ts +26 -3
  34. package/dist/listener/endpoint.js +55 -10
  35. package/dist/listener/forward.d.ts +7 -1
  36. package/dist/listener/forward.js +40 -12
  37. package/dist/listener/serve.d.ts +37 -0
  38. package/dist/listener/serve.js +196 -32
  39. package/dist/listener/server.d.ts +9 -0
  40. package/dist/listener/server.js +59 -22
  41. package/dist/listener/spool.d.ts +2 -17
  42. package/dist/listener/spool.js +6 -3
  43. package/dist/mcp.d.ts +23 -4
  44. package/dist/mcp.js +124 -22
  45. package/dist/plan.d.ts +35 -6
  46. package/dist/plan.js +125 -40
  47. package/dist/render.js +2 -1
  48. package/dist/reply/harness-output.d.ts +9 -0
  49. package/dist/reply/harness-output.js +13 -0
  50. package/dist/skill/content.d.ts +2 -2
  51. package/dist/skill/content.js +6 -3
  52. package/dist/status.js +1 -1
  53. package/dist/steering/rules.js +4 -2
  54. package/dist/uninstall.d.ts +7 -2
  55. package/dist/uninstall.js +49 -44
  56. package/package.json +10 -3
  57. package/skills/heedvane-hooks/SKILL.md +3 -3
  58. package/skills/heedvane-hooks/references/events.md +3 -0
  59. package/dist/dispatchers/windows.d.ts +0 -15
  60. 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-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
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 { DATA_MODES, DEFAULT_HEED_YAML_TEMPLATE, heedvaneConfigAt, loadHeedFile, loadHeedFileAt, withInstallChoices, withSettings } from "./heed-file.js";
15
- import { planActions, desiredEntriesFor, heedvaneConnector, installingNodeDir, isClientDetected } from "./plan.js";
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, startListenerInBackground } from "./listener/background.js";
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
- /** The first argument that looks like a flag but is not one of ours (values of value flags skipped). */
116
- 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) {
117
119
  for (let i = 0; i < args.length; i++) {
118
- if (VALUE_FLAGS.includes(args[i]))
120
+ const arg = args[i];
121
+ if (VALUE_FLAGS.includes(arg)) {
119
122
  i += 1;
120
- else if (args[i].startsWith("-") && !SWITCH_FLAGS.includes(args[i]))
121
- 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}`;
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 unknown = unknownFlag(own);
150
- if (unknown !== undefined) {
151
- ctx.out(` ✗ unknown flag: ${unknown} (nothing was changed)`);
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
- startListener: opts.startListener ?? ((home) => startListenerInBackground(home, fileURLToPath(import.meta.url), env)),
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({ 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) });
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
- return cmdLogin(backendCommandContext(ctx));
209
- 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
+ }
210
221
  case "skills":
211
- return (command === "auth" ? cmdAuth : cmdSkills)(backendCommandContext(ctx));
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(home, loaded, ctx);
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
- /** heed.yaml (or the template) with the flags' settings; null when they change nothing. Throws on a value it cannot use. */
298
- function withFlags(home, loaded, flags) {
299
- const settings = flagSettings(flags);
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
- if (!flags.dryRun)
320
- await signInIfNeeded(ctx, spec.heedFile.heedvane.url);
321
- out("");
322
- out(" Next: open a new agent session; Heedvane joins it at session start.");
323
- if (spec.heedFile.install.mcp && heedvaneConnector(spec.heedFile) === undefined)
324
- out(` ${MCP_NEEDS_ORG}`);
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
- /** Writes heed.yaml when it is missing, or when `--server` changed its URL. */
332
- function writeControlFile({ home, out }, { heedFile, existed, text }) {
333
- 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")))
334
373
  return;
335
374
  mkdirSync(join(home, ".heed"), { recursive: true, mode: 0o700 });
336
- writeFileSync(join(home, ".heed", "heed.yaml"), text ?? DEFAULT_HEED_YAML_TEMPLATE, { mode: 0o600 });
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
- writeControlFile(ctx, spec);
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 dispatcher = {
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
- async function signInIfNeeded(ctx, url) {
373
- const root = join(ctx.home, ".heed");
374
- if ((await backendAt(root, url)) !== null && (await onRequestedOrg(root, ctx.org)))
375
- 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;
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
- /** True without `--org`; else whether the saved login was granted for that organization. */
386
- 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 }) {
387
523
  if (org === undefined)
388
- return true;
389
- const saved = await readLogin(root).catch(() => null);
390
- 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;
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
- 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);
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, heedvaneConnector(spec.heedFile)), 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));
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 listening = await listenerStep(() => ctx.startListener(ctx.home), listenerLogPath(join(ctx.home, ".heed")), prompts);
453
- await signInIfNeeded({ ...ctx, out: promptLine(prompts) }, spec.heedFile.heedvane.url);
454
- installFinished(nextSteps(listening, spec.heedFile), prompts);
455
- 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"}`;
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(ctx.home, loaded, ctx) ?? { heedFile: loaded.heedFile, existed: loaded.existed, text: heedText(ctx.home, loaded.existed) };
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.install.mcp && heedvaneConnector(heedFile) === undefined ? [MCP_NEEDS_ORG] : []),
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
- /** Hermes' hooks capability rides its native plugin; installHermesPlugin owns creation and ownership checks. */
484
- async function applyHermesPlugin(client, ctx) {
485
- const path = join(ctx.home, ".hermes", "plugins", "heedvane-hooks", "plugin.yaml");
486
- const status = await installHermesPlugin(ctx.home, ctx.dispatcher.gating);
487
- if (status === "skipped") {
488
- return { clientId: client.id, kind: "hooks", status: "skipped", path, reason: "another plugin owns ~/.hermes/plugins/heedvane-hooks — left untouched" };
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 what an earlier version registered as MCP (the local helper, the connection's earlier name), with or without a connection to write. */
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
- return kind === "mcp" ? withRemovals(await removeLegacyMcpEntries(client), done.result) : done.result;
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 snippet delegates to.
545
- await writeDispatcherScript(ctx.home, client.id, ctx.dispatcher);
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 (client.id === "hermes" && action.kind === "hooks") {
563
- return applyHermesPlugin(client, ctx);
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(" 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)");
649
819
  return sub === "" ? 2 : 1;
650
820
  }
651
- 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
+ }
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 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).
654
830
  await new Promise((resolve) => {
655
831
  started.server.on("close", resolve);
656
- 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
+ });
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>`: a listener for exactly one agent run, for CI, containers and software
662
- * factories where nothing stays running. The agent keeps stdin, stdout and stderr; the listener writes to
663
- * ~/.heed/listener.log and Heedvane's own lines go to stderr, so an agent's JSON output stays parseable.
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
- mkdirSync(root, { recursive: true, mode: 0o700 }); // a fresh container has no ~/.heed yet
674
- const log = createWriteStream(listenerLogPath(root), { flags: "a", mode: 0o600 });
675
- const started = await serveHooks(ctx.home, (line) => void log.write(`${line}\n`));
676
- if ((await backendAt(root, heedvaneConfigAt(root).url)) === null)
677
- say("not signed in, so guardrails are off: printf '%s' \"$HEEDVANE_ORG_KEY\" | npx heedvane auth org");
678
- say(`listening on 127.0.0.1:${started.port}; log: ${listenerLogPath(root)}`);
679
- try {
680
- // The agent's hooks find this listener's endpoint through HEED_AGENT_HOOK_ROOT, also when HEED_HOME is not $HOME.
681
- return await runAgent(ctx.command, { ...ctx.env, HEED_HOME: ctx.home, HEED_AGENT_HOOK_ROOT: root }, say);
682
- }
683
- finally {
684
- await new Promise((resolve) => started.server.close(() => resolve()));
685
- await new Promise((resolve) => log.end(resolve));
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);