heedvane 0.3.3 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -9
- package/dist/backend/credentials.d.ts +2 -0
- package/dist/backend/credentials.js +13 -0
- package/dist/backend/repo-remote.js +2 -1
- package/dist/backend/sign-in-change.js +3 -2
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +325 -118
- package/dist/clients/definitions.d.ts +7 -3
- package/dist/clients/definitions.js +27 -7
- package/dist/clients/hermes-plugin.d.ts +5 -20
- package/dist/clients/hermes-plugin.js +97 -71
- package/dist/clients/hook-files.d.ts +22 -0
- package/dist/clients/hook-files.js +39 -0
- package/dist/clients/pi-extension.d.ts +9 -0
- package/dist/clients/pi-extension.js +41 -0
- package/dist/commands/heedvane.js +18 -3
- package/dist/commands/mcp-stub.d.ts +1 -0
- package/dist/commands/mcp-stub.js +44 -0
- package/dist/dispatchers/posix.d.ts +16 -19
- package/dist/dispatchers/posix.js +71 -91
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +96 -28
- package/dist/guard.d.ts +73 -0
- package/dist/guard.js +302 -0
- package/dist/heed-file.d.ts +5 -2
- package/dist/heed-file.js +7 -7
- package/dist/hook-fallback.d.ts +73 -0
- package/dist/hook-fallback.js +61 -0
- package/dist/installer.d.ts +5 -5
- package/dist/installer.js +13 -12
- package/dist/listener/background.d.ts +48 -4
- package/dist/listener/background.js +153 -19
- package/dist/listener/endpoint.d.ts +26 -3
- package/dist/listener/endpoint.js +55 -10
- package/dist/listener/forward.d.ts +7 -1
- package/dist/listener/forward.js +40 -12
- package/dist/listener/serve.d.ts +37 -0
- package/dist/listener/serve.js +196 -32
- package/dist/listener/server.d.ts +9 -0
- package/dist/listener/server.js +59 -22
- package/dist/listener/spool.d.ts +2 -17
- package/dist/listener/spool.js +6 -3
- package/dist/mcp.d.ts +23 -4
- package/dist/mcp.js +124 -22
- package/dist/plan.d.ts +35 -6
- package/dist/plan.js +125 -40
- package/dist/render.js +2 -1
- package/dist/reply/harness-output.d.ts +9 -0
- package/dist/reply/harness-output.js +13 -0
- package/dist/skill/content.d.ts +2 -2
- package/dist/skill/content.js +6 -3
- package/dist/status.js +1 -1
- package/dist/steering/rules.js +4 -2
- package/dist/uninstall.d.ts +7 -2
- package/dist/uninstall.js +49 -44
- package/package.json +10 -3
- package/skills/heedvane-hooks/SKILL.md +3 -3
- package/skills/heedvane-hooks/references/events.md +3 -0
- package/dist/dispatchers/windows.d.ts +0 -15
- package/dist/dispatchers/windows.js +0 -50
package/dist/guard.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// heedvane: Heedvane's guard for Pi. It asks the local Heedvane listener before each tool runs, and lets the tool run when it cannot ask.
|
|
2
|
+
//
|
|
3
|
+
// One file serves every Pi:
|
|
4
|
+
// - The Pi coding agent loads it as an extension: `npx -y heedvane connect --agent pi` copies it to
|
|
5
|
+
// `<Pi's agent folder>/extensions/heedvane.ts`, and `npx -y heedvane uninstall` removes it.
|
|
6
|
+
// - Code built on Pi's core (`@earendil-works/pi-agent-core`): `new Agent({ ..., beforeToolCall })`.
|
|
7
|
+
// - Pi Durable (`@earendil-works/pi-durable`), which does not run on Pi's core: `hook(ToolTask, { beforeTool })`.
|
|
8
|
+
// It imports only Node's own modules and src/hook-fallback.ts, which the copy carries inline, so it runs on its own.
|
|
9
|
+
// It is also every agent's fallback when the listener cannot answer ({@link keepUnanswered}): the dispatchers and
|
|
10
|
+
// Hermes run its copy `fallback.mjs` with UNANSWERED_FLAG. It spools the event for the listener to replay, starts a
|
|
11
|
+
// listener, tells the person once per session that calls are not checked, and lets the tool run.
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { appendFileSync, closeSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { basename, delimiter, dirname, join, sep } from "node:path";
|
|
17
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
18
|
+
import { parseEnv } from "node:util";
|
|
19
|
+
import { HOOK_TIMEOUT_MS, LISTENER_START, OTHER_SIGN_IN_DIR, PANE_KEY_ENV, listenerProof, PROBE_REFUSAL, PROOF_HEADER, SESSION_START_TIMEOUT_MS, SPOOL_CAP_BYTES, SPOOL_DIR, SPOOL_ENV, SPOOL_FILE_PREFIX, SPOOL_FILE_SUFFIX, SPOOL_TTL_DAYS, START_LOCK, START_LOCK_HOLDER, START_LOCK_MINUTES, RETRY_AFTER_MS, UNANSWERED_FLAG, UNCHECKED_ERROR_NOTICE, UNCHECKED_MARK_PREFIX, UNCHECKED_NOTICE } from "./hook-fallback.js";
|
|
20
|
+
/** heed.yaml `gating.mode`, set in the copy `connect` installs: `pane` asks only in sessions started with HEED_SESSION. Code that imports the guard always asks. */
|
|
21
|
+
const GATING = "always";
|
|
22
|
+
/** Pi's built-in tools under Claude Code's names, the names Heedvane's rules list. */
|
|
23
|
+
const RULE_TOOL_NAMES = { bash: "Bash", read: "Read", edit: "Edit", write: "Write", grep: "Grep", find: "Glob", ls: "LS" };
|
|
24
|
+
const FALLBACK_REASON = "Heedvane's rules refuse this tool call.";
|
|
25
|
+
/**
|
|
26
|
+
* Heedvane's reason to refuse this tool call, or undefined to let it run. It never throws: when the listener cannot
|
|
27
|
+
* answer (none runs, another program holds its port, or it answers with an error) the event is spooled for it to
|
|
28
|
+
* replay and the tool runs, as with every agent's hooks.
|
|
29
|
+
*/
|
|
30
|
+
export async function heedvaneRefusal(call) {
|
|
31
|
+
let asked;
|
|
32
|
+
try {
|
|
33
|
+
if (GATING !== "always" && !process.env["HEED_SESSION"])
|
|
34
|
+
return undefined;
|
|
35
|
+
asked = { place: listenerPlace(), event: { event: hookEvent(call), id: randomUUID() } };
|
|
36
|
+
const question = { agent: "pi", body: JSON.stringify(asked.event.event), id: asked.event.id };
|
|
37
|
+
const answer = await listenerAnswer(asked.place, question).catch((err) => {
|
|
38
|
+
return askAgain(asked.place, question, err); // its second answer; a refusal of the listener's own is rethrown there
|
|
39
|
+
});
|
|
40
|
+
return refusalIn(answer);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (refusedWith(err) === undefined)
|
|
44
|
+
keepForTheListener(call, asked, err);
|
|
45
|
+
else if (noteRefused(asked.place.root, err, { source: "pi", payload: JSON.stringify(asked.event.event), id: asked.event.id, session: asked.event.event.session_id }))
|
|
46
|
+
tell(call, UNCHECKED_ERROR_NOTICE);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** The process's folder; the home folder when it cannot say (its folder was removed). */
|
|
51
|
+
function cwdOrHome() {
|
|
52
|
+
try {
|
|
53
|
+
return process.cwd();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return homedir();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** The call as Claude Code's PreToolUse, with Claude Code's tool names. */
|
|
60
|
+
function hookEvent(call) {
|
|
61
|
+
return {
|
|
62
|
+
hook_event_name: "PreToolUse",
|
|
63
|
+
session_id: call.session ?? process.env["HEED_SESSION"] ?? `pi-${process.pid}`,
|
|
64
|
+
cwd: call.cwd ?? cwdOrHome(),
|
|
65
|
+
tool_name: RULE_TOOL_NAMES[call.tool] ?? call.tool,
|
|
66
|
+
tool_input: call.input,
|
|
67
|
+
tool_use_id: call.id,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The port and token from HEED_AGENT_HOOK_PORT and HEED_AGENT_HOOK_TOKEN when both are set, else from the endpoint file:
|
|
72
|
+
* HEED_AGENT_HOOK_ENDPOINT, else endpoint.env in HEED_AGENT_HOOK_ROOT (which `heedvane run` sets), else in ~/.heed.
|
|
73
|
+
* An empty variable counts as unset, as in the dispatchers.
|
|
74
|
+
*/
|
|
75
|
+
function listenerPlace() {
|
|
76
|
+
const endpointFile = process.env["HEED_AGENT_HOOK_ENDPOINT"] || join(process.env["HEED_AGENT_HOOK_ROOT"] || join(homedir(), ".heed"), "endpoint.env");
|
|
77
|
+
const root = dirname(endpointFile);
|
|
78
|
+
const fromEnv = { port: process.env["HEED_AGENT_HOOK_PORT"], token: process.env["HEED_AGENT_HOOK_TOKEN"] };
|
|
79
|
+
if (fromEnv.port && fromEnv.token)
|
|
80
|
+
return { root, ...fromEnv };
|
|
81
|
+
try {
|
|
82
|
+
const values = parseEnv(readFileSync(endpointFile, "utf8"));
|
|
83
|
+
return { root, port: values["HEED_AGENT_HOOK_PORT"], token: values["HEED_AGENT_HOOK_TOKEN"] };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { root }; // no endpoint file: nothing to ask, the event is spooled
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The listener's answer; throws when there is none: no endpoint, nothing listening, another program, or an error
|
|
91
|
+
* answer. An error answer from a listener that names its settings (0.3.4 on), other than 503 (it is stopping), is its
|
|
92
|
+
* own answer to this event: the error carries its status as `refused`, and starting another listener would change nothing.
|
|
93
|
+
*/
|
|
94
|
+
async function listenerAnswer(place, { agent, body, id }) {
|
|
95
|
+
if (!place.port || !place.token)
|
|
96
|
+
throw new Error("no listener endpoint");
|
|
97
|
+
const route = `http://127.0.0.1:${place.port}/hook/${agent}`;
|
|
98
|
+
// Every call, since the port can change hands between calls; a probe on loopback costs well under a millisecond.
|
|
99
|
+
const probe = await fetch(route, { signal: AbortSignal.timeout(HOOK_TIMEOUT_MS) });
|
|
100
|
+
const about = (probe.status === 405 ? await probe.json() : {});
|
|
101
|
+
// Only this machine's listener names the proof of its token; a listener before 0.3.4 names none, and is never asked.
|
|
102
|
+
// shortcut: proof is static and readable by GET; another OS user can replay it — challenge-response (nonce + HMAC, no token on the wire) right after this PR
|
|
103
|
+
const proof = await listenerProof(place.token);
|
|
104
|
+
const proven = (answer) => answer.headers.get(PROOF_HEADER) === proof;
|
|
105
|
+
if (about.error !== PROBE_REFUSAL || !proven(probe))
|
|
106
|
+
throw new Error(`another program answers on 127.0.0.1:${place.port}`);
|
|
107
|
+
const response = await fetch(route, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "Content-Type": "application/json", "X-Heed-Agent-Hook-Token": place.token, "X-Heed-Hook-Version": "1", "X-Heed-Event-Id": id },
|
|
110
|
+
body,
|
|
111
|
+
// As long as the dispatcher's own ask: a session start waits for Heedvane's team memory.
|
|
112
|
+
signal: AbortSignal.timeout(/"hook_event_name": ?"SessionStart"/.test(body) ? SESSION_START_TIMEOUT_MS : HOOK_TIMEOUT_MS),
|
|
113
|
+
});
|
|
114
|
+
if (!proven(response))
|
|
115
|
+
throw new Error(`another program answers on 127.0.0.1:${place.port}`);
|
|
116
|
+
if (!response.ok)
|
|
117
|
+
throw Object.assign(new Error(`the listener answered ${response.status}`), { refused: typeof about.settings === "string" && response.status !== 503 ? response.status : undefined });
|
|
118
|
+
return response.json();
|
|
119
|
+
}
|
|
120
|
+
/** The listener's answer after one pause, unless `failed` was its own error answer: a listener taking over from another binds within milliseconds. */
|
|
121
|
+
async function askAgain(place, question, failed) {
|
|
122
|
+
if (refusedWith(failed) !== undefined)
|
|
123
|
+
throw failed;
|
|
124
|
+
// Nothing to ask without a port and a token (no endpoint.env: heedvane/guard without Heedvane installed), so no pause.
|
|
125
|
+
if (!place.port || !place.token)
|
|
126
|
+
throw failed ?? new Error("no listener endpoint");
|
|
127
|
+
await delay(RETRY_AFTER_MS);
|
|
128
|
+
return listenerAnswer(place, question);
|
|
129
|
+
}
|
|
130
|
+
/** The status of the listener's own error answer to this event (see {@link listenerAnswer}), or undefined. */
|
|
131
|
+
function refusedWith(err) {
|
|
132
|
+
const status = err?.refused;
|
|
133
|
+
return typeof status === "number" ? status : undefined;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* A current listener answered this event with an error: the call runs, the listener's log says so, and this says
|
|
137
|
+
* whether the session is yet to be told (once, by its own marker). A server error (5xx) may pass, so the event is kept
|
|
138
|
+
* for the listener to replay; a refused request (4xx) would be refused again.
|
|
139
|
+
*/
|
|
140
|
+
function noteRefused(root, err, unanswered) {
|
|
141
|
+
// shortcut: the kept event waits for the next listener start to replay it, upgrade if a running listener should drain its spool.
|
|
142
|
+
if ((refusedWith(err) ?? 0) >= 500)
|
|
143
|
+
attempt(() => spoolEvent(root, unanswered));
|
|
144
|
+
attempt(() => appendFileSync(join(root, "listener.log"), `[heedvane] ${String(err)} to a hook; the call ran unchecked\n`, { mode: 0o600 }));
|
|
145
|
+
return attempt(() => markTold(root, `error-${unanswered.session}`)) === true;
|
|
146
|
+
}
|
|
147
|
+
/** The reason in the listener's `{block: true, reason}`, or undefined when it lets the call run. */
|
|
148
|
+
function refusalIn(answer) {
|
|
149
|
+
if (typeof answer !== "object" || answer === null || answer.block !== true)
|
|
150
|
+
return undefined;
|
|
151
|
+
const reason = answer.reason;
|
|
152
|
+
return typeof reason === "string" && reason !== "" ? reason : FALLBACK_REASON;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Pi's call the listener did not answer, kept for it ({@link keepUnanswered}), the session told once. `asked` is
|
|
156
|
+
* undefined when the guard failed before it could ask: the call is then built again here and the failure logged.
|
|
157
|
+
* It never throws into Pi.
|
|
158
|
+
*/
|
|
159
|
+
function keepForTheListener(call, asked, err) {
|
|
160
|
+
const { place, event } = asked ?? { place: { root: join(homedir(), ".heed") }, event: { event: hookEvent(call), id: randomUUID() } };
|
|
161
|
+
const unanswered = { source: "pi", payload: JSON.stringify(event.event), id: event.id, session: event.event.session_id };
|
|
162
|
+
if (keepUnanswered(place.root, unanswered))
|
|
163
|
+
tell(call, UNCHECKED_NOTICE);
|
|
164
|
+
if (asked === undefined)
|
|
165
|
+
attempt(() => appendFileSync(join(place.root, "listener.log"), `[heedvane] Pi's guard could not ask the listener (${String(err)}); the call ran unchecked and is spooled\n`, { mode: 0o600 }));
|
|
166
|
+
}
|
|
167
|
+
/** Shows the person `notice` where Pi can (its notice, or stderr); never throws into Pi. */
|
|
168
|
+
function tell(call, notice) {
|
|
169
|
+
attempt(() => (call.tell ?? ((text) => tellPerson(undefined, text)))(notice));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Every agent's fallback when the listener gives no answer: keeps the event in the spool for the listener to replay,
|
|
173
|
+
* starts a listener (on any failure: on Windows a refused loopback connection outlasts the timeout), and says whether
|
|
174
|
+
* the session is yet to be told that its calls are not checked; its marker is written now. Never throws.
|
|
175
|
+
*/
|
|
176
|
+
export function keepUnanswered(root, unanswered) {
|
|
177
|
+
attempt(() => spoolEvent(root, unanswered));
|
|
178
|
+
attempt(() => startListener(root));
|
|
179
|
+
return attempt(() => markTold(root, unanswered.session)) === true;
|
|
180
|
+
}
|
|
181
|
+
/** `step`'s result, or undefined when it throws (a full disk, a read-only home, a start already under way): the tool still runs. */
|
|
182
|
+
function attempt(step) {
|
|
183
|
+
try {
|
|
184
|
+
return step();
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return undefined; // the fallback's own failure never reaches the agent
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** The spool record, in the pane's file, which the listener replays when it starts; spool files past their TTL go first. */
|
|
191
|
+
function spoolEvent(root, { source, payload, id }) {
|
|
192
|
+
const pane = PANE_KEY_ENV.map((variable) => process.env[variable]).find(Boolean) ?? "";
|
|
193
|
+
const dir = join(root, SPOOL_DIR);
|
|
194
|
+
const file = join(dir, `${SPOOL_FILE_PREFIX}${pane || "default"}${SPOOL_FILE_SUFFIX}`);
|
|
195
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
196
|
+
attempt(() => pruneSpool(dir)); // a prune that fails (an older Node in Pi, a file another hook just removed) never costs the event
|
|
197
|
+
if ((statSync(file, { throwIfNoEntry: false })?.size ?? 0) >= SPOOL_CAP_BYTES)
|
|
198
|
+
return;
|
|
199
|
+
const fromEnv = Object.fromEntries(Object.entries(SPOOL_ENV).map(([field, variable]) => [field, process.env[variable] ?? ""]));
|
|
200
|
+
const record = { paneKey: pane, ...fromEnv, source, receivedAt: new Date().toISOString(), id, payload };
|
|
201
|
+
appendFileSync(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
202
|
+
}
|
|
203
|
+
/** Removes spool files, told markers and the events set aside after a change of sign-in that are older than SPOOL_TTL_DAYS. */
|
|
204
|
+
function pruneSpool(dir) {
|
|
205
|
+
const cutoff = Date.now() - SPOOL_TTL_DAYS * 86_400_000;
|
|
206
|
+
for (const entry of readdirSync(dir, { recursive: true, withFileTypes: true })) {
|
|
207
|
+
const path = join(entry.parentPath, entry.name);
|
|
208
|
+
const pruned = entry.name.startsWith(SPOOL_FILE_PREFIX) || entry.name.startsWith(UNCHECKED_MARK_PREFIX) || path.includes(`${sep}${OTHER_SIGN_IN_DIR}${sep}`);
|
|
209
|
+
if (entry.isFile() && pruned && statSync(path).mtimeMs < cutoff)
|
|
210
|
+
rmSync(path, { force: true });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Writes the session's told marker in the spool folder; false when it was there: the session has been told. */
|
|
214
|
+
function markTold(root, session) {
|
|
215
|
+
const marker = join(root, SPOOL_DIR, `${UNCHECKED_MARK_PREFIX}${session.replace(/[^A-Za-z0-9_.-]/g, "") || "default"}`);
|
|
216
|
+
mkdirSync(dirname(marker), { recursive: true, mode: 0o700 });
|
|
217
|
+
closeSync(openSync(marker, "wx", 0o600)); // throws when the marker is there
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
/** LISTENER_START from the heed folder, in the background, at most once a minute; `listener start` decides whether one must start. */
|
|
221
|
+
function startListener(root) {
|
|
222
|
+
if (process.env["HEED_LISTENER_AUTOSTART"] === "0" || basename(root) !== ".heed")
|
|
223
|
+
return;
|
|
224
|
+
const lock = join(root, START_LOCK);
|
|
225
|
+
const lockAge = Date.now() - (statSync(lock, { throwIfNoEntry: false })?.mtimeMs ?? Date.now());
|
|
226
|
+
if (lockAge > START_LOCK_MINUTES * 60_000)
|
|
227
|
+
rmSync(lock, { recursive: true, force: true });
|
|
228
|
+
mkdirSync(lock); // throws when another start holds the lock
|
|
229
|
+
const log = openSync(join(root, "listener.log"), "a", 0o600);
|
|
230
|
+
try {
|
|
231
|
+
// From the heed folder: in the agent's project, npx could run a `heedvane` the project's package.json names.
|
|
232
|
+
// The folder of the Node that runs this is the last place to find npx: an agent started from a desktop app may have none on PATH.
|
|
233
|
+
const path = `${process.env["PATH"] ?? ""}${delimiter}${dirname(process.execPath)}`;
|
|
234
|
+
const [command, ...args] = LISTENER_START;
|
|
235
|
+
// Windows runs npx as npx.cmd, which Node starts only through a shell. Not run on Windows in this package's tests.
|
|
236
|
+
const child = spawn(command, args, { cwd: root, detached: true, windowsHide: true, shell: process.platform === "win32", stdio: ["ignore", log, log], env: { ...process.env, NODE_OPTIONS: undefined, PATH: path, HEED_HOME: dirname(root) } }); // Node drops undefined variables; the agent's NODE_OPTIONS are its project's
|
|
237
|
+
child.on("error", () => undefined); // no npx: the next start is a minute away
|
|
238
|
+
child.unref();
|
|
239
|
+
writeFileSync(join(lock, START_LOCK_HOLDER), String(child.pid ?? "")); // a command takes the lock over once this start has ended
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
closeSync(log);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** Pi's core: `new Agent({ beforeToolCall })`, or `agent.beforeToolCall = beforeToolCall`. */
|
|
246
|
+
export async function beforeToolCall(context) {
|
|
247
|
+
const reason = await heedvaneRefusal({ tool: context.toolCall.name, input: context.args, id: context.toolCall.id });
|
|
248
|
+
return reason === undefined ? undefined : { block: true, reason };
|
|
249
|
+
}
|
|
250
|
+
/** Pi Durable: `defineExtension({ name: "heedvane", hooks: [hook(ToolTask, { beforeTool })] })`. Its conversation is the session. */
|
|
251
|
+
export async function beforeTool(call, api) {
|
|
252
|
+
const session = api?.conversationId === undefined ? undefined : `pi-durable-${api.conversationId}`;
|
|
253
|
+
const reason = await heedvaneRefusal({ tool: call.name, input: call.arguments, id: call.id, session });
|
|
254
|
+
return reason === undefined ? undefined : { block: reason };
|
|
255
|
+
}
|
|
256
|
+
/** The Pi coding agent's extension entry point. */
|
|
257
|
+
export default function heedvane(pi) {
|
|
258
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
259
|
+
const reason = await heedvaneRefusal({ tool: event.toolName, input: event.input, id: event.toolCallId, cwd: ctx?.cwd, session: sessionOf(ctx), tell: (notice) => tellPerson(ctx, notice) });
|
|
260
|
+
return reason === undefined ? undefined : { block: true, reason };
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/** Pi's warning notice where it has a UI; on stderr in print and JSON modes, where a pipeline's log shows it. */
|
|
264
|
+
function tellPerson(ctx, notice) {
|
|
265
|
+
if (ctx?.hasUI === true && ctx.ui?.notify !== undefined)
|
|
266
|
+
ctx.ui.notify(`Heedvane: ${notice}`, "warning");
|
|
267
|
+
else
|
|
268
|
+
process.stderr.write(`heedvane: ${notice}\n`);
|
|
269
|
+
}
|
|
270
|
+
function sessionOf(ctx) {
|
|
271
|
+
try {
|
|
272
|
+
return ctx?.sessionManager?.getSessionId?.();
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return undefined; // a session manager that cannot say: the process's session stands in
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* `node fallback.mjs --heedvane-unanswered <agent> [notice field]`: the dispatchers' and Hermes' fallback, with the
|
|
280
|
+
* hook's event on stdin and the answer the hook gives without the listener in HEED_HOOK_FALLBACK_ANSWER. Keeps the
|
|
281
|
+
* event, and prints that answer with the notice in the agent's notice field the first time in a session.
|
|
282
|
+
*/
|
|
283
|
+
async function answerUnanswered([agent = "", noticeField]) {
|
|
284
|
+
const fallback = JSON.parse(process.env["HEED_HOOK_FALLBACK_ANSWER"] || "{}");
|
|
285
|
+
const payload = readFileSync(0, "utf8");
|
|
286
|
+
const place = listenerPlace();
|
|
287
|
+
const question = { agent, body: payload, id: process.env["HEED_HOOK_EVENT_ID"] || randomUUID() };
|
|
288
|
+
// The hook asked once already; a listener taking the port over answers the second time.
|
|
289
|
+
const answer = await askAgain(place, question, undefined).catch((err) => {
|
|
290
|
+
const unanswered = { source: agent, payload, id: question.id, session: sessionIn(payload) };
|
|
291
|
+
const [told, notice] = refusedWith(err) === undefined ? [keepUnanswered(place.root, unanswered), UNCHECKED_NOTICE] : [noteRefused(place.root, err, unanswered), UNCHECKED_ERROR_NOTICE];
|
|
292
|
+
return told && noticeField ? { [noticeField]: notice, ...fallback } : fallback;
|
|
293
|
+
});
|
|
294
|
+
process.stdout.write(`${JSON.stringify(answer)}\n`);
|
|
295
|
+
}
|
|
296
|
+
/** The hook event's session id, or "" when it names none. */
|
|
297
|
+
function sessionIn(payload) {
|
|
298
|
+
const session = attempt(() => JSON.parse(payload).session_id);
|
|
299
|
+
return typeof session === "string" ? session : "";
|
|
300
|
+
}
|
|
301
|
+
if (process.argv[2] === UNANSWERED_FLAG)
|
|
302
|
+
void answerUnanswered(process.argv.slice(3));
|
package/dist/heed-file.d.ts
CHANGED
|
@@ -67,8 +67,6 @@ export declare const DEFAULT_HEED_YAML_TEMPLATE = "# ~/.heed/heed.yaml \u2014 He
|
|
|
67
67
|
export declare function loadHeedFile(text: string): HeedFile;
|
|
68
68
|
/** `text` with each `[path, value]` written in place; the file's comments stay. Validate with {@link loadHeedFile}. */
|
|
69
69
|
export declare function withSettings(text: string, settings: ReadonlyArray<readonly [string[], unknown]>): string;
|
|
70
|
-
/** `text` with `heedvane.url` set to `url` (`connect --server`); the file's comments stay. Validate with {@link loadHeedFile}. */
|
|
71
|
-
export declare function withServerUrl(text: string, url: string): string;
|
|
72
70
|
/** What the interactive installer asks: which agents, which integrations, and which Heedvane features. */
|
|
73
71
|
export interface InstallChoices {
|
|
74
72
|
/** Chosen agent ids, or "auto" for every detected agent. */
|
|
@@ -87,5 +85,10 @@ export declare function withInstallChoices(text: string, choices: InstallChoices
|
|
|
87
85
|
* the file cannot be rewritten the key is ignored in memory and the line says that instead.
|
|
88
86
|
*/
|
|
89
87
|
export declare function loadHeedFileAt(path: string, log?: (line: string) => void): HeedFile;
|
|
88
|
+
/** {@link loadHeedFileAt}, with the file's text as it is on disk afterwards: what the settings it holds hash from. */
|
|
89
|
+
export declare function readHeedFileAt(path: string, log?: (line: string) => void): {
|
|
90
|
+
heedFile: HeedFile;
|
|
91
|
+
text: string;
|
|
92
|
+
};
|
|
90
93
|
/** heed.yaml's `heedvane` section under `root`; the template's defaults before init has written one. */
|
|
91
94
|
export declare function heedvaneConfigAt(root: string): HeedvaneConfig;
|
package/dist/heed-file.js
CHANGED
|
@@ -262,10 +262,6 @@ export function withSettings(text, settings) {
|
|
|
262
262
|
}
|
|
263
263
|
return String(document);
|
|
264
264
|
}
|
|
265
|
-
/** `text` with `heedvane.url` set to `url` (`connect --server`); the file's comments stay. Validate with {@link loadHeedFile}. */
|
|
266
|
-
export function withServerUrl(text, url) {
|
|
267
|
-
return withSettings(text, [[["heedvane", "url"], url]]);
|
|
268
|
-
}
|
|
269
265
|
/** `text` with the installer's choices written into the fields that already hold them; the file's comments stay. */
|
|
270
266
|
export function withInstallChoices(text, choices) {
|
|
271
267
|
return withSettings(text, [
|
|
@@ -288,22 +284,26 @@ const toStderr = (line) => void process.stderr.write(`${line}\n`);
|
|
|
288
284
|
* the file cannot be rewritten the key is ignored in memory and the line says that instead.
|
|
289
285
|
*/
|
|
290
286
|
export function loadHeedFileAt(path, log = toStderr) {
|
|
287
|
+
return readHeedFileAt(path, log).heedFile;
|
|
288
|
+
}
|
|
289
|
+
/** {@link loadHeedFileAt}, with the file's text as it is on disk afterwards: what the settings it holds hash from. */
|
|
290
|
+
export function readHeedFileAt(path, log = toStderr) {
|
|
291
291
|
const text = readFileSync(path, "utf8");
|
|
292
292
|
const document = parseDocument(text);
|
|
293
293
|
const retired = document.errors.length > 0 ? [] : RETIRED_KEYS.filter((path) => document.hasIn(path));
|
|
294
294
|
if (retired.length === 0)
|
|
295
|
-
return loadHeedFile(text);
|
|
295
|
+
return { heedFile: loadHeedFile(text), text };
|
|
296
296
|
const names = retired.map((path) => path.join(".")).join(", ");
|
|
297
297
|
for (const path of retired)
|
|
298
298
|
document.deleteIn(path);
|
|
299
299
|
const refusal = rewriteInPlace(path, document);
|
|
300
300
|
if (refusal === null) {
|
|
301
301
|
log(`[heedvane] removed ${names} from ${path}: no hook waits on Heedvane any more`);
|
|
302
|
-
return loadHeedFile(String(document));
|
|
302
|
+
return { heedFile: loadHeedFile(String(document)), text: String(document) };
|
|
303
303
|
}
|
|
304
304
|
// 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.
|
|
305
305
|
log(`[heedvane] could not remove ${names} from ${path} (${errorMessage(refusal)}); it is ignored`);
|
|
306
|
-
return loadHeedFile(JSON.stringify(withoutKeys(parse(text), retired)));
|
|
306
|
+
return { heedFile: loadHeedFile(JSON.stringify(withoutKeys(parse(text), retired))), text };
|
|
307
307
|
}
|
|
308
308
|
/** Writes the document over `path`; the error that stopped it, or null. */
|
|
309
309
|
function rewriteInPlace(path, document) {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an agent's hook does when Heedvane's listener cannot answer, owned here once. Pi's guard (src/guard.ts) is the
|
|
3
|
+
* one fallback that acts on these values: Pi runs it in process, and the POSIX dispatchers (src/dispatchers/posix.ts)
|
|
4
|
+
* and Hermes' plugin (src/clients/hermes-plugin.ts) run its installed copy, {@link FALLBACK_FILE}. The listener reads
|
|
5
|
+
* the spool they describe (src/listener/spool.ts). It imports nothing, so each copy of the guard carries it inline
|
|
6
|
+
* (src/clients/pi-extension.ts).
|
|
7
|
+
*/
|
|
8
|
+
/** The listener's refusal of a GET on a hook route: the answer that tells it apart from another program on its port. */
|
|
9
|
+
export declare const PROBE_REFUSAL = "method not allowed; POST only";
|
|
10
|
+
/**
|
|
11
|
+
* The header every answer of this machine's listener (0.3.4 on) carries, and the endpoint.env line the dispatchers read it
|
|
12
|
+
* from: {@link listenerProof} of the token, so it proves the answer is that listener's without showing the token.
|
|
13
|
+
*/
|
|
14
|
+
export declare const PROOF_HEADER = "X-Heed-Listener";
|
|
15
|
+
export declare const PROOF_KEY = "HEED_AGENT_HOOK_PROOF";
|
|
16
|
+
/** What the proof signs; Hermes' plugin signs the same text in Python. */
|
|
17
|
+
export declare const PROOF_MESSAGE = "heedvane listener";
|
|
18
|
+
/** The proof of the listener with `token`: HMAC-SHA-256 of {@link PROOF_MESSAGE} under it, 32 hex digits. WebCrypto, so this file imports nothing. */
|
|
19
|
+
export declare function listenerProof(token: string): Promise<string>;
|
|
20
|
+
/** Events no listener answered: `<heed root>/spool/pane-<pane key, else session, else "default">.jsonl`, one JSON record a line. */
|
|
21
|
+
export declare const SPOOL_DIR = "spool";
|
|
22
|
+
export declare const SPOOL_FILE_PREFIX = "pane-";
|
|
23
|
+
export declare const SPOOL_FILE_SUFFIX = ".jsonl";
|
|
24
|
+
/** A pane's spool file stops growing at 5 MB. */
|
|
25
|
+
export declare const SPOOL_CAP_BYTES = 5242880;
|
|
26
|
+
/** Spool files, and the events set aside after a change of sign-in, older than this are removed. */
|
|
27
|
+
export declare const SPOOL_TTL_DAYS = 7;
|
|
28
|
+
export declare const OTHER_SIGN_IN_DIR = "other-sign-in";
|
|
29
|
+
/** One spooled event. `payload` is the hook's JSON text, `source` the agent id. */
|
|
30
|
+
export interface SpoolRecord {
|
|
31
|
+
paneKey: string;
|
|
32
|
+
tabId: string;
|
|
33
|
+
worktreeId: string;
|
|
34
|
+
launchToken: string;
|
|
35
|
+
source: string;
|
|
36
|
+
receivedAt: string;
|
|
37
|
+
payload: string;
|
|
38
|
+
/** The event's unique id (absent in lines from scripts older than ids). */
|
|
39
|
+
id?: string;
|
|
40
|
+
}
|
|
41
|
+
/** The record fields copied from the agent's environment, and the pane key's variables in order. */
|
|
42
|
+
export declare const SPOOL_ENV: {
|
|
43
|
+
readonly tabId: "HEED_TAB_ID";
|
|
44
|
+
readonly worktreeId: "HEED_WORKTREE_ID";
|
|
45
|
+
readonly launchToken: "HEED_LAUNCH_TOKEN";
|
|
46
|
+
};
|
|
47
|
+
export declare const PANE_KEY_ENV: readonly ["HEED_PANE_KEY", "HEED_SESSION"];
|
|
48
|
+
/** Told once per session, where the agent can show it, when the hook cannot ask the listener; the agent still continues. */
|
|
49
|
+
export declare const UNCHECKED_NOTICE = "Heedvane is not reachable; actions are not being checked.";
|
|
50
|
+
/** Told once per session when the listener answers a call with an error of its own; that call ran unchecked. */
|
|
51
|
+
export declare const UNCHECKED_ERROR_NOTICE = "Heedvane's listener answered with an error, so an action was not checked; ~/.heed/listener.log says why.";
|
|
52
|
+
/** The marker, one per session in the spool folder, that the session has been told. */
|
|
53
|
+
export declare const UNCHECKED_MARK_PREFIX = "unchecked-";
|
|
54
|
+
/** How long a hook waits for the listener: to connect, to answer, and at a session start (its one policy fetch). */
|
|
55
|
+
export declare const HOOK_CONNECT_TIMEOUT_MS = 500;
|
|
56
|
+
export declare const HOOK_TIMEOUT_MS = 800;
|
|
57
|
+
export declare const SESSION_START_TIMEOUT_MS = 2000;
|
|
58
|
+
/** The guard's copy beside the dispatchers' scripts and in Hermes' plugin, which they run with this flag when the listener did not answer. */
|
|
59
|
+
export declare const FALLBACK_FILE = "fallback.mjs";
|
|
60
|
+
export declare const UNANSWERED_FLAG = "--heedvane-unanswered";
|
|
61
|
+
/** How long the fallback waits before it asks the listener once more: one taking the port over binds within milliseconds. */
|
|
62
|
+
export declare const RETRY_AFTER_MS = 250;
|
|
63
|
+
/** The longest Hermes waits for the fallback: a second for Node's start and keeping the event, the pause, the probe and the ask. */
|
|
64
|
+
export declare const FALLBACK_TIMEOUT_MS: number;
|
|
65
|
+
/** The file beside the dispatchers' scripts that names the Node the fallback runs with: the one that last installed the hooks. */
|
|
66
|
+
export declare const HOOK_NODE_FILE = "node";
|
|
67
|
+
/** With nothing listening, the hook starts the listener with this command from the heed folder, at most once a minute. */
|
|
68
|
+
export declare const LISTENER_START: readonly ["npx", "-y", "heedvane", "listener", "start"];
|
|
69
|
+
/** The lock folder in the heed folder that keeps it to one start a minute; the listener removes it once it listens. */
|
|
70
|
+
export declare const START_LOCK = "listener.starting";
|
|
71
|
+
export declare const START_LOCK_MINUTES = 1;
|
|
72
|
+
/** The file in the start lock naming the pid of the start that holds it. */
|
|
73
|
+
export declare const START_LOCK_HOLDER = "pid";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an agent's hook does when Heedvane's listener cannot answer, owned here once. Pi's guard (src/guard.ts) is the
|
|
3
|
+
* one fallback that acts on these values: Pi runs it in process, and the POSIX dispatchers (src/dispatchers/posix.ts)
|
|
4
|
+
* and Hermes' plugin (src/clients/hermes-plugin.ts) run its installed copy, {@link FALLBACK_FILE}. The listener reads
|
|
5
|
+
* the spool they describe (src/listener/spool.ts). It imports nothing, so each copy of the guard carries it inline
|
|
6
|
+
* (src/clients/pi-extension.ts).
|
|
7
|
+
*/
|
|
8
|
+
/** The listener's refusal of a GET on a hook route: the answer that tells it apart from another program on its port. */
|
|
9
|
+
export const PROBE_REFUSAL = "method not allowed; POST only";
|
|
10
|
+
/**
|
|
11
|
+
* The header every answer of this machine's listener (0.3.4 on) carries, and the endpoint.env line the dispatchers read it
|
|
12
|
+
* from: {@link listenerProof} of the token, so it proves the answer is that listener's without showing the token.
|
|
13
|
+
*/
|
|
14
|
+
export const PROOF_HEADER = "X-Heed-Listener";
|
|
15
|
+
export const PROOF_KEY = "HEED_AGENT_HOOK_PROOF";
|
|
16
|
+
/** What the proof signs; Hermes' plugin signs the same text in Python. */
|
|
17
|
+
export const PROOF_MESSAGE = "heedvane listener";
|
|
18
|
+
/** The proof of the listener with `token`: HMAC-SHA-256 of {@link PROOF_MESSAGE} under it, 32 hex digits. WebCrypto, so this file imports nothing. */
|
|
19
|
+
export async function listenerProof(token) {
|
|
20
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(token), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
21
|
+
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(PROOF_MESSAGE)));
|
|
22
|
+
return Array.from(mac.subarray(0, 16), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
23
|
+
}
|
|
24
|
+
/** Events no listener answered: `<heed root>/spool/pane-<pane key, else session, else "default">.jsonl`, one JSON record a line. */
|
|
25
|
+
export const SPOOL_DIR = "spool";
|
|
26
|
+
export const SPOOL_FILE_PREFIX = "pane-";
|
|
27
|
+
export const SPOOL_FILE_SUFFIX = ".jsonl";
|
|
28
|
+
/** A pane's spool file stops growing at 5 MB. */
|
|
29
|
+
export const SPOOL_CAP_BYTES = 5_242_880;
|
|
30
|
+
/** Spool files, and the events set aside after a change of sign-in, older than this are removed. */
|
|
31
|
+
export const SPOOL_TTL_DAYS = 7;
|
|
32
|
+
export const OTHER_SIGN_IN_DIR = "other-sign-in";
|
|
33
|
+
/** The record fields copied from the agent's environment, and the pane key's variables in order. */
|
|
34
|
+
export const SPOOL_ENV = { tabId: "HEED_TAB_ID", worktreeId: "HEED_WORKTREE_ID", launchToken: "HEED_LAUNCH_TOKEN" };
|
|
35
|
+
export const PANE_KEY_ENV = ["HEED_PANE_KEY", "HEED_SESSION"];
|
|
36
|
+
/** Told once per session, where the agent can show it, when the hook cannot ask the listener; the agent still continues. */
|
|
37
|
+
export const UNCHECKED_NOTICE = "Heedvane is not reachable; actions are not being checked.";
|
|
38
|
+
/** Told once per session when the listener answers a call with an error of its own; that call ran unchecked. */
|
|
39
|
+
export const UNCHECKED_ERROR_NOTICE = "Heedvane's listener answered with an error, so an action was not checked; ~/.heed/listener.log says why.";
|
|
40
|
+
/** The marker, one per session in the spool folder, that the session has been told. */
|
|
41
|
+
export const UNCHECKED_MARK_PREFIX = "unchecked-";
|
|
42
|
+
/** How long a hook waits for the listener: to connect, to answer, and at a session start (its one policy fetch). */
|
|
43
|
+
export const HOOK_CONNECT_TIMEOUT_MS = 500;
|
|
44
|
+
export const HOOK_TIMEOUT_MS = 800;
|
|
45
|
+
export const SESSION_START_TIMEOUT_MS = 2_000;
|
|
46
|
+
/** The guard's copy beside the dispatchers' scripts and in Hermes' plugin, which they run with this flag when the listener did not answer. */
|
|
47
|
+
export const FALLBACK_FILE = "fallback.mjs";
|
|
48
|
+
export const UNANSWERED_FLAG = "--heedvane-unanswered";
|
|
49
|
+
/** How long the fallback waits before it asks the listener once more: one taking the port over binds within milliseconds. */
|
|
50
|
+
export const RETRY_AFTER_MS = 250;
|
|
51
|
+
/** The longest Hermes waits for the fallback: a second for Node's start and keeping the event, the pause, the probe and the ask. */
|
|
52
|
+
export const FALLBACK_TIMEOUT_MS = 1_000 + RETRY_AFTER_MS + 2 * HOOK_TIMEOUT_MS;
|
|
53
|
+
/** The file beside the dispatchers' scripts that names the Node the fallback runs with: the one that last installed the hooks. */
|
|
54
|
+
export const HOOK_NODE_FILE = "node";
|
|
55
|
+
/** With nothing listening, the hook starts the listener with this command from the heed folder, at most once a minute. */
|
|
56
|
+
export const LISTENER_START = ["npx", "-y", "heedvane", "listener", "start"];
|
|
57
|
+
/** The lock folder in the heed folder that keeps it to one start a minute; the listener removes it once it listens. */
|
|
58
|
+
export const START_LOCK = "listener.starting";
|
|
59
|
+
export const START_LOCK_MINUTES = 1;
|
|
60
|
+
/** The file in the start lock naming the pid of the start that holds it. */
|
|
61
|
+
export const START_LOCK_HOLDER = "pid";
|
package/dist/installer.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Readable, Writable } from "node:stream";
|
|
2
2
|
import type { DataMode, HeedFile, InstallChoices } from "./heed-file.js";
|
|
3
|
-
import type { PlannedAction } from "./plan.js";
|
|
4
3
|
import { type AppliedResult } from "./render.js";
|
|
4
|
+
import { type PlannedAction } from "./plan.js";
|
|
5
5
|
/** Where the questions are read and drawn: the process's terminal unless a test injects streams. */
|
|
6
6
|
export interface PromptStreams {
|
|
7
7
|
input?: Readable;
|
|
@@ -29,7 +29,7 @@ export declare function askChoices(agents: AgentOption[], current: HeedFile, str
|
|
|
29
29
|
/** Shows what the install will do and asks to go ahead; false when the person declines or cancels. */
|
|
30
30
|
export declare function confirmInstall(agents: AgentLine[], details: string[], streams: PromptStreams): Promise<boolean>;
|
|
31
31
|
/** The summary's lines about the whole install: what leaves the machine, and where the MCP connection points. */
|
|
32
|
-
export declare function installDetails(heedFile: HeedFile
|
|
32
|
+
export declare function installDetails(heedFile: HeedFile): string[];
|
|
33
33
|
/** Runs the install behind a spinner; the spinner ends with the outcome either way. */
|
|
34
34
|
export declare function installWithSpinner<T>(agentCount: number, work: () => Promise<T>, streams: PromptStreams): Promise<T>;
|
|
35
35
|
/** One line per agent: a green tick and what it got, or what failed and why. */
|
|
@@ -37,7 +37,7 @@ export declare function showInstalled(agents: AgentLine[], streams: PromptStream
|
|
|
37
37
|
/** A line the install prints along the way (the sign-in link, for one), shown inside the installer's frame. */
|
|
38
38
|
export declare function promptLine(streams: PromptStreams): (line: string) => void;
|
|
39
39
|
/** Starts the listener in the background behind a spinner; true when it answers. */
|
|
40
|
-
export declare function listenerStep(start: () => Promise<
|
|
40
|
+
export declare function listenerStep(start: () => Promise<true | string>, streams: PromptStreams): Promise<boolean>;
|
|
41
41
|
/**
|
|
42
42
|
* One line per agent from the plan, in plan order: what it gets ("hooks · MCP · skill"), "already set up"
|
|
43
43
|
* when nothing changes, or why it gets nothing. `applied` adds what failed when the install ran.
|
|
@@ -45,8 +45,8 @@ export declare function listenerStep(start: () => Promise<boolean>, logPath: str
|
|
|
45
45
|
export declare function agentLines(actions: PlannedAction[], names: ReadonlyMap<string, string>, applied?: AppliedResult[]): AgentLine[];
|
|
46
46
|
/** The closing line after a cancel or a "no": nothing was written. Returns the exit code, 1. */
|
|
47
47
|
export declare function installCancelled(streams: PromptStreams): number;
|
|
48
|
-
/** The closing lines after an install. */
|
|
49
|
-
export declare function installFinished(lines: string[], streams: PromptStreams): void;
|
|
48
|
+
/** The closing lines after an install; without a sign-in it is installed but not yet set up. */
|
|
49
|
+
export declare function installFinished(lines: string[], signedIn: boolean, streams: PromptStreams): void;
|
|
50
50
|
/** What each data mode lets leave this machine, as the installer says it. */
|
|
51
51
|
export declare const DATA_MODE_LABEL: Record<DataMode, {
|
|
52
52
|
label: string;
|
package/dist/installer.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { cancel, confirm, intro, isCancel, log, multiselect, note, outro, select, spinner } from "@clack/prompts";
|
|
8
8
|
import { styleText } from "node:util";
|
|
9
9
|
import { appliedFailed } from "./render.js";
|
|
10
|
-
import {
|
|
10
|
+
import { connectionNote, heedvaneConnector } from "./plan.js";
|
|
11
11
|
const INTEGRATION_LABEL = { hooks: "hooks", mcp: "MCP server", skill: "skill", skip: "" };
|
|
12
12
|
/** The header: the name, what Heedvane does, and which server and organization this install connects to. */
|
|
13
13
|
export function brandIntro(server, org, streams) {
|
|
@@ -46,11 +46,12 @@ export async function confirmInstall(agents, details, streams) {
|
|
|
46
46
|
return !isCancel(answer) && answer;
|
|
47
47
|
}
|
|
48
48
|
/** The summary's lines about the whole install: what leaves the machine, and where the MCP connection points. */
|
|
49
|
-
export function installDetails(heedFile
|
|
49
|
+
export function installDetails(heedFile) {
|
|
50
50
|
const lines = [`What leaves this machine: ${DATA_MODE_LABEL[heedFile.heedvane.dataMode].label}`];
|
|
51
51
|
if (!heedFile.install.mcp)
|
|
52
52
|
return lines;
|
|
53
|
-
|
|
53
|
+
const connector = heedvaneConnector(heedFile);
|
|
54
|
+
return [...lines, connector === undefined ? connectionNote(heedFile) : `Heedvane's MCP connection: ${connector}`];
|
|
54
55
|
}
|
|
55
56
|
/** Runs the install behind a spinner; the spinner ends with the outcome either way. */
|
|
56
57
|
export async function installWithSpinner(agentCount, work, streams) {
|
|
@@ -85,15 +86,15 @@ export function promptLine(streams) {
|
|
|
85
86
|
};
|
|
86
87
|
}
|
|
87
88
|
/** Starts the listener in the background behind a spinner; true when it answers. */
|
|
88
|
-
export async function listenerStep(start,
|
|
89
|
+
export async function listenerStep(start, streams) {
|
|
89
90
|
const progress = spinner({ output: streams.output });
|
|
90
91
|
progress.start("Starting Heedvane in the background");
|
|
91
|
-
const started = await start().catch(() =>
|
|
92
|
-
if (started)
|
|
92
|
+
const started = await start().catch(() => "Heedvane did not start");
|
|
93
|
+
if (started === true)
|
|
93
94
|
progress.stop("Heedvane is running in the background");
|
|
94
95
|
else
|
|
95
|
-
progress.error(
|
|
96
|
-
return started;
|
|
96
|
+
progress.error(started);
|
|
97
|
+
return started === true;
|
|
97
98
|
}
|
|
98
99
|
/**
|
|
99
100
|
* One line per agent from the plan, in plan order: what it gets ("hooks · MCP · skill"), "already set up"
|
|
@@ -113,10 +114,10 @@ export function installCancelled(streams) {
|
|
|
113
114
|
cancel("Nothing was installed. Run npx heedvane again any time.", streams);
|
|
114
115
|
return 1;
|
|
115
116
|
}
|
|
116
|
-
/** The closing lines after an install. */
|
|
117
|
-
export function installFinished(lines, streams) {
|
|
117
|
+
/** The closing lines after an install; without a sign-in it is installed but not yet set up. */
|
|
118
|
+
export function installFinished(lines, signedIn, streams) {
|
|
118
119
|
note(lines.join("\n"), "Next", streams);
|
|
119
|
-
outro("Heedvane is set up.", streams);
|
|
120
|
+
outro(signedIn ? "Heedvane is set up." : "Installed, but not signed in yet: run npx -y heedvane login", streams);
|
|
120
121
|
}
|
|
121
122
|
async function pickAgents(agents, current, streams) {
|
|
122
123
|
const ordered = [...agents.filter((agent) => agent.detected), ...agents.filter((agent) => !agent.detected)];
|
|
@@ -182,7 +183,7 @@ function brandBadge(text, output) {
|
|
|
182
183
|
}
|
|
183
184
|
/** What one agent gets: its integrations, "already set up" when nothing changes, or why it gets nothing. */
|
|
184
185
|
function agentParts(id, own) {
|
|
185
|
-
const installs = own.filter((action) => action.kind !== "skip");
|
|
186
|
+
const installs = own.filter((action) => action.kind !== "skip" && action.operation !== "remove");
|
|
186
187
|
if (installs.length === 0)
|
|
187
188
|
return own.map((action) => styleText("dim", action.reason?.replace(`${id}: `, "") ?? "nothing to add"));
|
|
188
189
|
if (installs.every((action) => action.operation === "unchanged"))
|