heedvane 0.0.0-stage → 0.2.0

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 (98) hide show
  1. package/README.md +62 -2
  2. package/dist/apply.d.ts +104 -0
  3. package/dist/apply.js +388 -0
  4. package/dist/backend/client.d.ts +40 -0
  5. package/dist/backend/client.js +32 -0
  6. package/dist/backend/credentials.d.ts +15 -0
  7. package/dist/backend/credentials.js +76 -0
  8. package/dist/backend/login.d.ts +33 -0
  9. package/dist/backend/login.js +265 -0
  10. package/dist/backend/repo-remote.d.ts +15 -0
  11. package/dist/backend/repo-remote.js +61 -0
  12. package/dist/backend/sign-in-change.d.ts +12 -0
  13. package/dist/backend/sign-in-change.js +72 -0
  14. package/dist/best-effort.d.ts +4 -0
  15. package/dist/best-effort.js +25 -0
  16. package/dist/cli.d.ts +29 -0
  17. package/dist/cli.js +666 -0
  18. package/dist/clients/definitions.d.ts +113 -0
  19. package/dist/clients/definitions.js +427 -0
  20. package/dist/clients/hermes-plugin.d.ts +33 -0
  21. package/dist/clients/hermes-plugin.js +247 -0
  22. package/dist/commands/heedvane.d.ts +15 -0
  23. package/dist/commands/heedvane.js +74 -0
  24. package/dist/commands/memory.d.ts +10 -0
  25. package/dist/commands/memory.js +106 -0
  26. package/dist/config-doc.d.ts +97 -0
  27. package/dist/config-doc.js +465 -0
  28. package/dist/dispatchers/posix.d.ts +41 -0
  29. package/dist/dispatchers/posix.js +208 -0
  30. package/dist/dispatchers/snippets.d.ts +6 -0
  31. package/dist/dispatchers/snippets.js +39 -0
  32. package/dist/dispatchers/windows.d.ts +15 -0
  33. package/dist/dispatchers/windows.js +50 -0
  34. package/dist/doctor.d.ts +37 -0
  35. package/dist/doctor.js +303 -0
  36. package/dist/events/contract.d.ts +54 -0
  37. package/dist/events/contract.js +87 -0
  38. package/dist/events/normalize.d.ts +36 -0
  39. package/dist/events/normalize.js +143 -0
  40. package/dist/guards.d.ts +18 -0
  41. package/dist/guards.js +28 -0
  42. package/dist/heed-file.d.ts +78 -0
  43. package/dist/heed-file.js +316 -0
  44. package/dist/installer.d.ts +47 -0
  45. package/dist/installer.js +174 -0
  46. package/dist/listener/background.d.ts +8 -0
  47. package/dist/listener/background.js +36 -0
  48. package/dist/listener/endpoint.d.ts +26 -0
  49. package/dist/listener/endpoint.js +125 -0
  50. package/dist/listener/forward.d.ts +36 -0
  51. package/dist/listener/forward.js +257 -0
  52. package/dist/listener/serve.d.ts +2 -0
  53. package/dist/listener/serve.js +155 -0
  54. package/dist/listener/server.d.ts +48 -0
  55. package/dist/listener/server.js +251 -0
  56. package/dist/listener/spool.d.ts +28 -0
  57. package/dist/listener/spool.js +60 -0
  58. package/dist/mcp-server.d.ts +9 -0
  59. package/dist/mcp-server.js +187 -0
  60. package/dist/mcp.d.ts +57 -0
  61. package/dist/mcp.js +83 -0
  62. package/dist/memory/private-memory.d.ts +12 -0
  63. package/dist/memory/private-memory.js +54 -0
  64. package/dist/memory/table-of-contents.d.ts +10 -0
  65. package/dist/memory/table-of-contents.js +80 -0
  66. package/dist/memory/team-memory.d.ts +60 -0
  67. package/dist/memory/team-memory.js +54 -0
  68. package/dist/plan.d.ts +77 -0
  69. package/dist/plan.js +288 -0
  70. package/dist/render.d.ts +32 -0
  71. package/dist/render.js +132 -0
  72. package/dist/reply/harness-output.d.ts +57 -0
  73. package/dist/reply/harness-output.js +88 -0
  74. package/dist/reply/reply.d.ts +65 -0
  75. package/dist/reply/reply.js +169 -0
  76. package/dist/reply/verdict.d.ts +28 -0
  77. package/dist/reply/verdict.js +17 -0
  78. package/dist/skill/bundle.d.ts +23 -0
  79. package/dist/skill/bundle.js +102 -0
  80. package/dist/skill/content.d.ts +16 -0
  81. package/dist/skill/content.js +182 -0
  82. package/dist/skills/approved.d.ts +31 -0
  83. package/dist/skills/approved.js +123 -0
  84. package/dist/skills/check.d.ts +21 -0
  85. package/dist/skills/check.js +83 -0
  86. package/dist/state.d.ts +51 -0
  87. package/dist/state.js +118 -0
  88. package/dist/steering/rules.d.ts +49 -0
  89. package/dist/steering/rules.js +165 -0
  90. package/dist/steering/steer.d.ts +29 -0
  91. package/dist/steering/steer.js +64 -0
  92. package/dist/steering/violations.d.ts +27 -0
  93. package/dist/steering/violations.js +24 -0
  94. package/dist/uninstall.d.ts +23 -0
  95. package/dist/uninstall.js +159 -0
  96. package/package.json +35 -4
  97. package/skills/heedvane-hooks/SKILL.md +87 -0
  98. package/skills/heedvane-hooks/references/events.md +69 -0
@@ -0,0 +1,316 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import writeFileAtomic from "write-file-atomic";
4
+ import { isScalar, parse, parseDocument } from "yaml";
5
+ import { errorMessage, isRecord } from "./guards.js";
6
+ /** Error raised for any invalid heed.yaml. The message is user-facing: keep it precise. */
7
+ class HeedFileError extends Error {
8
+ constructor(message) {
9
+ super(`invalid heed.yaml: ${message}`);
10
+ this.name = "HeedFileError";
11
+ }
12
+ }
13
+ const TOP_LEVEL_KEYS = [
14
+ "schemaVersion",
15
+ "root",
16
+ "listener",
17
+ "agents",
18
+ "install",
19
+ "gating",
20
+ "heedvane",
21
+ ];
22
+ /**
23
+ * The commented control file written by `npx heedvane init`.
24
+ * Kept loadable: loadHeedFile(DEFAULT_HEED_YAML_TEMPLATE) yields every default.
25
+ */
26
+ export const DEFAULT_HEED_YAML_TEMPLATE = `# ~/.heed/heed.yaml — Heedvane agent hooks control file (v1)
27
+ schemaVersion: v1
28
+
29
+ # Where managed scripts and spool live. Default below; one install per machine.
30
+ root: ~/.heed
31
+
32
+ # The hook listener (Orca's pattern): loopback HTTP, token-authed, per-agent routes.
33
+ listener:
34
+ port: 0 # 0 = pick a free port at first start, persist to endpoint.env
35
+ spool: true # spool to disk when listener is unreachable (5 MB cap, 7-day TTL)
36
+
37
+ # Which agents to manage. \`auto\` = every detected agent on the machine.
38
+ agents: auto # or an explicit list: [claude, codex, warp]
39
+
40
+ # Which integrations to install per agent where supported.
41
+ install:
42
+ hooks: true # native hook config injection (skipped where the agent has none)
43
+ skill: true # heedvane-hooks skill bundle -> ~/.agents/skills/heedvane-hooks/
44
+ mcp: true # heedvane MCP server registration (universal fallback, incl. Warp)
45
+
46
+ # Session gating (Orca's env-gated dispatcher pattern).
47
+ gating:
48
+ mode: always # always = every session; pane = only sessions launched with HEED_SESSION env
49
+
50
+ # The Heedvane backend (credentials: npx heedvane auth, stored in ~/.heed/credentials.json).
51
+ heedvane:
52
+ url: https://app.heedvane.com
53
+ steering: true # ask the organization's steering rules before tool calls, prompts and stops
54
+ memory: true # add your private memories (~/.heed/memory, local only) at session start
55
+ skills:
56
+ enforce: true # deny skills that are not on the organization's approved list
57
+ strict: false # also require the local SKILL.md to match the approved revision
58
+ whenUnknown: allow # no synced list (run npx heedvane skills sync) or listener down: allow or deny
59
+ `;
60
+ function describeType(value) {
61
+ if (value === null)
62
+ return "null";
63
+ if (Array.isArray(value))
64
+ return "an array";
65
+ return typeof value;
66
+ }
67
+ function expectMapping(value, key) {
68
+ if (!isRecord(value)) {
69
+ throw new HeedFileError(`"${key}" must be a mapping, got ${describeType(value)}`);
70
+ }
71
+ return value;
72
+ }
73
+ function rejectUnknownKeys(value, allowed, where) {
74
+ const unknown = Object.keys(value).filter((k) => !allowed.includes(k));
75
+ if (unknown.length > 0) {
76
+ throw new HeedFileError(`"${where}" has unknown key(s): ${unknown.map((k) => JSON.stringify(k)).join(", ")} (allowed: ${allowed.join(", ")})`);
77
+ }
78
+ }
79
+ function expectBool(value, key) {
80
+ if (typeof value !== "boolean") {
81
+ throw new HeedFileError(`"${key}" must be a boolean, got ${describeType(value)}`);
82
+ }
83
+ return value;
84
+ }
85
+ function validateListener(raw) {
86
+ const defaults = { port: 0, spool: true };
87
+ if (raw === undefined)
88
+ return defaults;
89
+ const map = expectMapping(raw, "listener");
90
+ rejectUnknownKeys(map, ["port", "spool"], "listener");
91
+ const out = defaults;
92
+ if (map["port"] !== undefined) {
93
+ const port = map["port"];
94
+ if (typeof port !== "number" || !Number.isInteger(port) || port < 0) {
95
+ throw new HeedFileError(`"listener.port" must be an integer >= 0, got ${describeType(port)}`);
96
+ }
97
+ out.port = port;
98
+ }
99
+ if (map["spool"] !== undefined)
100
+ out.spool = expectBool(map["spool"], "listener.spool");
101
+ return out;
102
+ }
103
+ function validateAgents(raw) {
104
+ if (raw === undefined)
105
+ return "auto";
106
+ if (raw === "auto")
107
+ return "auto";
108
+ if (!Array.isArray(raw)) {
109
+ throw new HeedFileError(`"agents" must be "auto" or a list of agent ids, got ${describeType(raw)}`);
110
+ }
111
+ for (const entry of raw) {
112
+ if (typeof entry !== "string" || entry.trim().length === 0) {
113
+ throw new HeedFileError(`"agents" entries must be non-empty strings, got ${JSON.stringify(entry) ?? describeType(entry)}`);
114
+ }
115
+ }
116
+ return raw.map((entry) => entry.trim());
117
+ }
118
+ function validateInstall(raw) {
119
+ const defaults = { hooks: true, skill: true, mcp: true };
120
+ if (raw === undefined)
121
+ return defaults;
122
+ const map = expectMapping(raw, "install");
123
+ rejectUnknownKeys(map, ["hooks", "skill", "mcp"], "install");
124
+ const out = defaults;
125
+ if (map["hooks"] !== undefined)
126
+ out.hooks = expectBool(map["hooks"], "install.hooks");
127
+ if (map["skill"] !== undefined)
128
+ out.skill = expectBool(map["skill"], "install.skill");
129
+ if (map["mcp"] !== undefined)
130
+ out.mcp = expectBool(map["mcp"], "install.mcp");
131
+ return out;
132
+ }
133
+ function validateGating(raw) {
134
+ const defaults = { mode: "always" };
135
+ if (raw === undefined)
136
+ return defaults;
137
+ const map = expectMapping(raw, "gating");
138
+ rejectUnknownKeys(map, ["mode"], "gating");
139
+ const out = defaults;
140
+ if (map["mode"] !== undefined) {
141
+ const mode = map["mode"];
142
+ if (mode !== "pane" && mode !== "always") {
143
+ throw new HeedFileError(`"gating.mode" must be "pane" or "always", got ${JSON.stringify(mode)}`);
144
+ }
145
+ out.mode = mode;
146
+ }
147
+ return out;
148
+ }
149
+ function validateSkills(raw) {
150
+ const out = { enforce: true, strict: false, whenUnknown: "allow" };
151
+ if (raw === undefined)
152
+ return out;
153
+ const map = expectMapping(raw, "heedvane.skills");
154
+ rejectUnknownKeys(map, ["enforce", "strict", "whenUnknown"], "heedvane.skills");
155
+ if (map["enforce"] !== undefined)
156
+ out.enforce = expectBool(map["enforce"], "heedvane.skills.enforce");
157
+ if (map["strict"] !== undefined)
158
+ out.strict = expectBool(map["strict"], "heedvane.skills.strict");
159
+ const whenUnknown = map["whenUnknown"];
160
+ if (whenUnknown !== undefined) {
161
+ if (whenUnknown !== "allow" && whenUnknown !== "deny") {
162
+ throw new HeedFileError(`"heedvane.skills.whenUnknown" must be "allow" or "deny", got ${JSON.stringify(whenUnknown)}`);
163
+ }
164
+ out.whenUnknown = whenUnknown;
165
+ }
166
+ return out;
167
+ }
168
+ function validateHeedvane(raw) {
169
+ const out = { url: "https://app.heedvane.com", steering: true, memory: true, skills: validateSkills(undefined) };
170
+ if (raw === undefined)
171
+ return out;
172
+ const map = expectMapping(raw, "heedvane");
173
+ rejectUnknownKeys(map, ["url", "steering", "memory", "skills"], "heedvane");
174
+ const url = map["url"];
175
+ if (url !== undefined) {
176
+ if (typeof url !== "string" || !URL.canParse(url) || !/^https?:$/.test(new URL(url).protocol)) {
177
+ throw new HeedFileError(`"heedvane.url" must be an http(s) URL, got ${JSON.stringify(url)}`);
178
+ }
179
+ out.url = url.replace(/\/+$/, "");
180
+ }
181
+ if (map["steering"] !== undefined)
182
+ out.steering = expectBool(map["steering"], "heedvane.steering");
183
+ if (map["memory"] !== undefined)
184
+ out.memory = expectBool(map["memory"], "heedvane.memory");
185
+ out.skills = validateSkills(map["skills"]);
186
+ return out;
187
+ }
188
+ /**
189
+ * Parse and validate a heed.yaml document, returning the fully normalized file.
190
+ * Throws {@link HeedFileError} (message prefixed "invalid heed.yaml:") on any problem:
191
+ * unparseable YAML, non-mapping root, missing/unknown schemaVersion, unknown keys
192
+ * (top-level or nested), or wrong-typed values.
193
+ */
194
+ export function loadHeedFile(text) {
195
+ let raw;
196
+ try {
197
+ raw = parse(text);
198
+ }
199
+ catch (err) {
200
+ const reason = errorMessage(err);
201
+ throw new HeedFileError(`could not parse YAML — ${reason}`);
202
+ }
203
+ if (!isRecord(raw)) {
204
+ throw new HeedFileError(`expected a top-level mapping, got ${describeType(raw)}`);
205
+ }
206
+ const schemaVersion = raw["schemaVersion"];
207
+ if (schemaVersion === undefined) {
208
+ throw new HeedFileError(`missing required key "schemaVersion" (expected "v1")`);
209
+ }
210
+ if (schemaVersion !== "v1") {
211
+ throw new HeedFileError(`unsupported schemaVersion ${JSON.stringify(schemaVersion)} — this CLI supports "v1" only (key: schemaVersion)`);
212
+ }
213
+ rejectUnknownKeys(raw, TOP_LEVEL_KEYS, "top level");
214
+ const root = raw["root"];
215
+ if (root !== undefined && (typeof root !== "string" || root.trim().length === 0)) {
216
+ throw new HeedFileError(`"root" must be a non-empty string, got ${describeType(root)}`);
217
+ }
218
+ return {
219
+ schemaVersion: "v1",
220
+ root: root === undefined ? "~/.heed" : root,
221
+ listener: validateListener(raw["listener"]),
222
+ agents: validateAgents(raw["agents"]),
223
+ install: validateInstall(raw["install"]),
224
+ gating: validateGating(raw["gating"]),
225
+ heedvane: validateHeedvane(raw["heedvane"]),
226
+ };
227
+ }
228
+ /** `text` with `heedvane.url` set to `url` (`connect --server`); the file's comments stay. Validate with {@link loadHeedFile}. */
229
+ export function withServerUrl(text, url) {
230
+ const document = parseDocument(text);
231
+ const current = document.getIn(["heedvane", "url"], true);
232
+ if (isScalar(current))
233
+ current.value = url;
234
+ else
235
+ document.setIn(["heedvane", "url"], url);
236
+ return String(document);
237
+ }
238
+ /** `text` with the installer's choices written into the fields that already hold them; the file's comments stay. */
239
+ export function withInstallChoices(text, choices) {
240
+ const document = parseDocument(text);
241
+ const fields = [
242
+ [["agents"], choices.agents],
243
+ [["install", "hooks"], choices.install.hooks],
244
+ [["install", "skill"], choices.install.skill],
245
+ [["install", "mcp"], choices.install.mcp],
246
+ [["heedvane", "steering"], choices.steering],
247
+ [["heedvane", "memory"], choices.memory],
248
+ [["heedvane", "skills", "enforce"], choices.skillChecks],
249
+ ];
250
+ for (const [path, value] of fields) {
251
+ const current = document.getIn(path, true);
252
+ if (isScalar(current) && typeof value !== "object")
253
+ current.value = value;
254
+ else
255
+ document.setIn(path, value);
256
+ }
257
+ return String(document);
258
+ }
259
+ /** Keys earlier versions wrote into heed.yaml that nothing reads any more (policies are assigned on the server). */
260
+ const RETIRED_KEYS = [["heedvane", "steeringTimeoutMs"], ["policy"]];
261
+ const toStderr = (line) => void process.stderr.write(`${line}\n`);
262
+ /**
263
+ * Load the heed.yaml at `path`. A retired key an earlier version wrote (`init` put `steeringTimeoutMs`
264
+ * in every heed.yaml) is removed from the file in place, its value never read, and one line says so; if
265
+ * the file cannot be rewritten the key is ignored in memory and the line says that instead.
266
+ */
267
+ export function loadHeedFileAt(path, log = toStderr) {
268
+ const text = readFileSync(path, "utf8");
269
+ const document = parseDocument(text);
270
+ const retired = document.errors.length > 0 ? [] : RETIRED_KEYS.filter((path) => document.hasIn(path));
271
+ if (retired.length === 0)
272
+ return loadHeedFile(text);
273
+ const names = retired.map((path) => path.join(".")).join(", ");
274
+ for (const path of retired)
275
+ document.deleteIn(path);
276
+ const refusal = rewriteInPlace(path, document);
277
+ if (refusal === null) {
278
+ log(`[heedvane] removed ${names} from ${path}: no hook waits on Heedvane any more`);
279
+ return loadHeedFile(String(document));
280
+ }
281
+ // The file stays as it was (an alias to the removed key cannot be written back, or the disk refused): load it without the key.
282
+ log(`[heedvane] could not remove ${names} from ${path} (${errorMessage(refusal)}); it is ignored`);
283
+ return loadHeedFile(JSON.stringify(withoutKeys(parse(text), retired)));
284
+ }
285
+ /** Writes the document over `path`; the error that stopped it, or null. */
286
+ function rewriteInPlace(path, document) {
287
+ try {
288
+ writeFileAtomic.sync(path, String(document), { mode: 0o600 });
289
+ return null;
290
+ }
291
+ catch (err) {
292
+ return err ?? new Error("write refused");
293
+ }
294
+ }
295
+ /** A parsed heed.yaml less the retired keys. */
296
+ function withoutKeys(value, retired) {
297
+ if (!isRecord(value))
298
+ return value;
299
+ const kept = Object.fromEntries(Object.entries(value).filter(([key]) => !retired.some((path) => path.length === 1 && path[0] === key)));
300
+ const heedvane = kept["heedvane"];
301
+ if (!isRecord(heedvane))
302
+ return kept;
303
+ const nested = retired.filter((path) => path.length === 2 && path[0] === "heedvane").map((path) => path[1]);
304
+ return { ...kept, heedvane: Object.fromEntries(Object.entries(heedvane).filter(([key]) => !nested.includes(key))) };
305
+ }
306
+ /** heed.yaml's `heedvane` section under `root`; the template's defaults before init has written one. */
307
+ export function heedvaneConfigAt(root) {
308
+ try {
309
+ return loadHeedFileAt(join(root, "heed.yaml")).heedvane;
310
+ }
311
+ catch (err) {
312
+ if (err.code !== "ENOENT")
313
+ throw err;
314
+ return loadHeedFile(DEFAULT_HEED_YAML_TEMPLATE).heedvane;
315
+ }
316
+ }
@@ -0,0 +1,47 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ import type { HeedFile, InstallChoices } from "./heed-file.js";
3
+ import type { PlannedAction } from "./plan.js";
4
+ import { type AppliedResult } from "./render.js";
5
+ /** Where the questions are read and drawn: the process's terminal unless a test injects streams. */
6
+ export interface PromptStreams {
7
+ input?: Readable;
8
+ output?: Writable;
9
+ }
10
+ /** One coding agent the installer can offer. */
11
+ export interface AgentOption {
12
+ id: string;
13
+ name: string;
14
+ detected: boolean;
15
+ /** What Heedvane installs into it, e.g. "hooks + MCP + skill". */
16
+ capability: string;
17
+ }
18
+ /** One agent's line in the summary or the result: what is, or was, installed into it. */
19
+ export interface AgentLine {
20
+ name: string;
21
+ parts: string[];
22
+ /** What failed, each with its reason; empty when everything worked. */
23
+ failed: string[];
24
+ }
25
+ /** The header: the name, what Heedvane does, and which server and organization this install connects to. */
26
+ export declare function brandIntro(server: string, org: string | undefined, streams: PromptStreams): void;
27
+ /** The person's choices, or null when they cancelled (Ctrl-C) at any question. */
28
+ export declare function askChoices(agents: AgentOption[], current: HeedFile, streams: PromptStreams): Promise<InstallChoices | null>;
29
+ /** Shows what the install will do and asks to go ahead; false when the person declines or cancels. */
30
+ export declare function confirmInstall(agents: AgentLine[], streams: PromptStreams): Promise<boolean>;
31
+ /** Runs the install behind a spinner; the spinner ends with the outcome either way. */
32
+ export declare function installWithSpinner<T>(agentCount: number, work: () => Promise<T>, streams: PromptStreams): Promise<T>;
33
+ /** One line per agent: a green tick and what it got, or what failed and why. */
34
+ export declare function showInstalled(agents: AgentLine[], streams: PromptStreams): void;
35
+ /** A line the install prints along the way (the sign-in link, for one), shown inside the installer's frame. */
36
+ export declare function promptLine(streams: PromptStreams): (line: string) => void;
37
+ /** Starts the listener in the background behind a spinner; true when it answers. */
38
+ export declare function listenerStep(start: () => Promise<boolean>, logPath: string, streams: PromptStreams): Promise<boolean>;
39
+ /**
40
+ * One line per agent from the plan, in plan order: what it gets ("hooks · MCP · skill"), "already set up"
41
+ * when nothing changes, or why it gets nothing. `applied` adds what failed when the install ran.
42
+ */
43
+ export declare function agentLines(actions: PlannedAction[], names: ReadonlyMap<string, string>, applied?: AppliedResult[]): AgentLine[];
44
+ /** The closing line after a cancel or a "no": nothing was written. Returns the exit code, 1. */
45
+ export declare function installCancelled(streams: PromptStreams): number;
46
+ /** The closing lines after an install. */
47
+ export declare function installFinished(lines: string[], streams: PromptStreams): void;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The interactive installer's questions (`npx heedvane`, or `connect` at a terminal): which coding agents,
3
+ * what to install into them, which Heedvane features to turn on, then one confirmation. It only asks:
4
+ * the answers become `InstallChoices`, which cli.ts writes into heed.yaml and applies through the same
5
+ * plan as every other install, so a second run starts from the last answers.
6
+ */
7
+ import { cancel, confirm, intro, isCancel, log, multiselect, note, outro, spinner } from "@clack/prompts";
8
+ import { styleText } from "node:util";
9
+ import { appliedFailed } from "./render.js";
10
+ const INTEGRATION_LABEL = { hooks: "hooks", mcp: "MCP server", skill: "skill", skip: "" };
11
+ /** The header: the name, what Heedvane does, and which server and organization this install connects to. */
12
+ export function brandIntro(server, org, streams) {
13
+ intro(brandBadge(" Heedvane ", streams.output ?? process.stdout), streams);
14
+ const target = org === undefined ? server : `${server} · organization ${org}`;
15
+ log.message([styleText("bold", "Guardrails, skill checks and team memory for your coding agents."), styleText("dim", `Connecting to ${target}`)], streams);
16
+ }
17
+ /** The person's choices, or null when they cancelled (Ctrl-C) at any question. */
18
+ export async function askChoices(agents, current, streams) {
19
+ const picked = await pickAgents(agents, current, streams);
20
+ if (picked === null)
21
+ return null;
22
+ const install = await pickIntegrations(current.install, streams);
23
+ if (install === null)
24
+ return null;
25
+ const features = await pickFeatures(current, streams);
26
+ if (features === null)
27
+ return null;
28
+ return {
29
+ agents: sameAsDetected(picked, agents) ? "auto" : picked,
30
+ install,
31
+ steering: features.includes("steering"),
32
+ skillChecks: features.includes("skillChecks"),
33
+ memory: features.includes("memory"),
34
+ };
35
+ }
36
+ /** Shows what the install will do and asks to go ahead; false when the person declines or cancels. */
37
+ export async function confirmInstall(agents, streams) {
38
+ const footer = styleText("dim", "Choices are saved in ~/.heed/heed.yaml. Each config file is backed up before it changes.");
39
+ note([...agentRows(agents), "", footer].join("\n"), "Ready to install", streams);
40
+ const answer = await confirm({ message: "Install now?", initialValue: true, ...streams });
41
+ return !isCancel(answer) && answer;
42
+ }
43
+ /** Runs the install behind a spinner; the spinner ends with the outcome either way. */
44
+ export async function installWithSpinner(agentCount, work, streams) {
45
+ const progress = spinner({ output: streams.output });
46
+ progress.start(`Installing into ${agentCount} ${agentCount === 1 ? "agent" : "agents"}`);
47
+ try {
48
+ const result = await work();
49
+ progress.stop("Installed");
50
+ return result;
51
+ }
52
+ catch (err) {
53
+ progress.error("The install stopped");
54
+ throw err;
55
+ }
56
+ }
57
+ /** One line per agent: a green tick and what it got, or what failed and why. */
58
+ export function showInstalled(agents, streams) {
59
+ const width = Math.max(...agents.map((agent) => agent.name.length));
60
+ for (const agent of agents) {
61
+ const name = styleText("bold", agent.name.padEnd(width));
62
+ if (agent.failed.length > 0)
63
+ log.error(`${name} ${agent.failed.join("; ")}`, streams);
64
+ else
65
+ log.success(`${name} ${agent.parts.join(styleText("dim", " · "))}`, streams);
66
+ }
67
+ }
68
+ /** A line the install prints along the way (the sign-in link, for one), shown inside the installer's frame. */
69
+ export function promptLine(streams) {
70
+ return (line) => {
71
+ if (line.trim() !== "")
72
+ log.message(line.trim(), streams);
73
+ };
74
+ }
75
+ /** Starts the listener in the background behind a spinner; true when it answers. */
76
+ export async function listenerStep(start, logPath, streams) {
77
+ const progress = spinner({ output: streams.output });
78
+ progress.start("Starting Heedvane in the background");
79
+ const started = await start().catch(() => false);
80
+ if (started)
81
+ progress.stop("Heedvane is running in the background");
82
+ else
83
+ progress.error(`Heedvane did not start; see ${logPath}`);
84
+ return started;
85
+ }
86
+ /**
87
+ * One line per agent from the plan, in plan order: what it gets ("hooks · MCP · skill"), "already set up"
88
+ * when nothing changes, or why it gets nothing. `applied` adds what failed when the install ran.
89
+ */
90
+ export function agentLines(actions, names, applied = []) {
91
+ const ids = [...new Set(actions.map((action) => action.clientId))];
92
+ return ids.map((id) => {
93
+ const parts = agentParts(id, actions.filter((action) => action.clientId === id));
94
+ const failed = applied.filter((result) => result.clientId === id && appliedFailed(result))
95
+ .map((result) => `${result.kind}: ${result.reason ?? result.status}`);
96
+ return { name: names.get(id) ?? id, parts, failed };
97
+ });
98
+ }
99
+ /** The closing line after a cancel or a "no": nothing was written. Returns the exit code, 1. */
100
+ export function installCancelled(streams) {
101
+ cancel("Nothing was installed. Run npx heedvane again any time.", streams);
102
+ return 1;
103
+ }
104
+ /** The closing lines after an install. */
105
+ export function installFinished(lines, streams) {
106
+ note(lines.join("\n"), "Next", streams);
107
+ outro("Heedvane is set up.", streams);
108
+ }
109
+ async function pickAgents(agents, current, streams) {
110
+ const ordered = [...agents.filter((agent) => agent.detected), ...agents.filter((agent) => !agent.detected)];
111
+ const chosenBefore = current.agents === "auto" ? agents.filter((agent) => agent.detected).map((agent) => agent.id) : current.agents;
112
+ const answer = await multiselect({
113
+ message: "Which coding agents should Heedvane connect to?",
114
+ options: ordered.map((agent) => ({ value: agent.id, label: agent.name, hint: agent.detected ? agent.capability : "not found on this machine" })),
115
+ initialValues: chosenBefore.filter((id) => agents.some((agent) => agent.id === id)),
116
+ required: true,
117
+ ...streams,
118
+ });
119
+ return isCancel(answer) ? null : answer;
120
+ }
121
+ async function pickIntegrations(install, streams) {
122
+ const answer = await multiselect({
123
+ message: "What should Heedvane add to each agent?",
124
+ options: [
125
+ { value: "hooks", label: "Hooks", hint: "guardrails, skill checks and memory in every session" },
126
+ { value: "mcp", label: "MCP server", hint: "Heedvane's tools inside the agent" },
127
+ { value: "skill", label: "Skill", hint: "teaches the agent how to use Heedvane" },
128
+ ],
129
+ initialValues: ["hooks", "mcp", "skill"].filter((kind) => install[kind]),
130
+ required: true,
131
+ ...streams,
132
+ });
133
+ if (isCancel(answer))
134
+ return null;
135
+ return { hooks: answer.includes("hooks"), mcp: answer.includes("mcp"), skill: answer.includes("skill") };
136
+ }
137
+ async function pickFeatures(current, streams) {
138
+ const on = { steering: current.heedvane.steering, skillChecks: current.heedvane.skills.enforce, memory: current.heedvane.memory };
139
+ const answer = await multiselect({
140
+ message: "What should Heedvane do in each session?",
141
+ options: [
142
+ { value: "steering", label: "Guardrails", hint: "your organization's rules stop risky tool calls" },
143
+ { value: "skillChecks", label: "Skill checks", hint: "only skills your organization approved run" },
144
+ { value: "memory", label: "Private memory", hint: "your notes in ~/.heed/memory at session start" },
145
+ ],
146
+ initialValues: Object.keys(on).filter((feature) => on[feature]),
147
+ required: false,
148
+ ...streams,
149
+ });
150
+ return isCancel(answer) ? null : answer;
151
+ }
152
+ /** `text` on Heedvane's indigo (the dashboard's --brand) where the terminal shows 24-bit color, else on magenta. */
153
+ function brandBadge(text, output) {
154
+ const truecolor = "hasColors" in output && typeof output.hasColors === "function" && output.hasColors(2 ** 24) === true;
155
+ return truecolor ? `\x1b[48;2;86;71;210m\x1b[1;97m${text}\x1b[0m` : styleText(["bgMagenta", "white", "bold"], text);
156
+ }
157
+ /** What one agent gets: its integrations, "already set up" when nothing changes, or why it gets nothing. */
158
+ function agentParts(id, own) {
159
+ const installs = own.filter((action) => action.kind !== "skip");
160
+ if (installs.length === 0)
161
+ return own.map((action) => styleText("dim", action.reason?.replace(`${id}: `, "") ?? "nothing to add"));
162
+ if (installs.every((action) => action.operation === "unchanged"))
163
+ return [styleText("dim", "already set up")];
164
+ return installs.map((action) => INTEGRATION_LABEL[action.kind]);
165
+ }
166
+ function agentRows(agents) {
167
+ const width = Math.max(...agents.map((agent) => agent.name.length));
168
+ return agents.map((agent) => `${styleText("bold", agent.name.padEnd(width))} ${agent.parts.join(styleText("dim", " · "))}`);
169
+ }
170
+ /** True when exactly the detected agents were chosen: heed.yaml then keeps `auto`, so a newly installed agent joins later. */
171
+ function sameAsDetected(picked, agents) {
172
+ const detected = agents.filter((agent) => agent.detected).map((agent) => agent.id);
173
+ return picked.length === detected.length && detected.every((id) => picked.includes(id));
174
+ }
@@ -0,0 +1,8 @@
1
+ /** Where a background listener writes what it prints. */
2
+ export declare function listenerLogPath(root: string): string;
3
+ /**
4
+ * Starts `listener start` as a process that outlives this one, unless a listener already answers.
5
+ * True once the listener answers its probe; false when it did not within 5 s (its log says why).
6
+ * It stops at logout or restart; the hooks then start it again on the fly (src/dispatchers/posix.ts).
7
+ */
8
+ export declare function startListenerInBackground(home: string, cliPath: string, env: NodeJS.ProcessEnv): Promise<boolean>;
@@ -0,0 +1,36 @@
1
+ import { spawn } from "node:child_process";
2
+ import { closeSync, openSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { ensureEndpoint, pingListener } from "./endpoint.js";
6
+ /** How long a freshly started listener gets to answer before the start counts as failed. */
7
+ const START_WAIT_MS = 5_000;
8
+ const START_POLL_MS = 250;
9
+ /** Where a background listener writes what it prints. */
10
+ export function listenerLogPath(root) {
11
+ return join(root, "listener.log");
12
+ }
13
+ /**
14
+ * Starts `listener start` as a process that outlives this one, unless a listener already answers.
15
+ * True once the listener answers its probe; false when it did not within 5 s (its log says why).
16
+ * It stops at logout or restart; the hooks then start it again on the fly (src/dispatchers/posix.ts).
17
+ */
18
+ export async function startListenerInBackground(home, cliPath, env) {
19
+ const root = join(home, ".heed");
20
+ const endpoint = await ensureEndpoint(root);
21
+ if (await pingListener(endpoint))
22
+ return true;
23
+ const log = openSync(listenerLogPath(root), "a", 0o600);
24
+ try {
25
+ spawn(process.execPath, [cliPath, "listener", "start"], { detached: true, stdio: ["ignore", log, log], env: { ...env, HEED_HOME: home } }).unref();
26
+ }
27
+ finally {
28
+ closeSync(log);
29
+ }
30
+ for (let waited = 0; waited < START_WAIT_MS; waited += START_POLL_MS) {
31
+ await delay(START_POLL_MS);
32
+ if (await pingListener(endpoint))
33
+ return true;
34
+ }
35
+ return false;
36
+ }
@@ -0,0 +1,26 @@
1
+ /** The listener's loopback endpoint, persisted under the heed root. */
2
+ export interface Endpoint {
3
+ port: number;
4
+ token: string;
5
+ }
6
+ /** Path of the endpoint file: `root/endpoint.env`. */
7
+ export declare function endpointPath(root: string): string;
8
+ /**
9
+ * Read `{port, token}` from `root/endpoint.env`.
10
+ * Returns null when the file does not exist. If the file exists but is
11
+ * missing/incoherent, the corresponding field degrades (`port` -> 0,
12
+ * `token` -> "") so callers can produce a precise remediation error.
13
+ */
14
+ export declare function readEndpoint(root: string): Promise<Endpoint | null>;
15
+ /** Atomically write `root/endpoint.env` (mode 0600) with the port and token lines. */
16
+ export declare function writeEndpoint(root: string, endpoint: Endpoint): Promise<void>;
17
+ /**
18
+ * The listener endpoint, issued once: an existing endpoint.env with a port and token is kept
19
+ * (dispatchers already point at it); otherwise a free loopback port and a fresh 256-bit token are written.
20
+ */
21
+ export declare function ensureEndpoint(root: string): Promise<Endpoint>;
22
+ /**
23
+ * Whether a listener answers at `endpoint` within 750 ms. The probe is a GET, which the listener refuses
24
+ * with 405 before it reads a token or records anything, so probing never adds an event.
25
+ */
26
+ export declare function pingListener(endpoint: Endpoint): Promise<boolean>;