heedvane 0.2.1 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -13
- 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 +4 -2
- package/dist/cli.js +389 -144
- package/dist/clients/definitions.d.ts +8 -4
- package/dist/clients/definitions.js +48 -13
- 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/commands/memory.js +6 -1
- 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 +113 -29
- package/dist/guard.d.ts +73 -0
- package/dist/guard.js +302 -0
- package/dist/heed-file.d.ts +19 -3
- package/dist/heed-file.js +46 -23
- package/dist/hook-fallback.d.ts +73 -0
- package/dist/hook-fallback.js +61 -0
- package/dist/installer.d.ts +13 -6
- package/dist/installer.js +40 -13
- 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 +38 -48
- package/dist/mcp.js +196 -63
- package/dist/plan.d.ts +47 -8
- package/dist/plan.js +164 -52
- package/dist/render.js +8 -8
- package/dist/reply/harness-output.d.ts +9 -0
- package/dist/reply/harness-output.js +13 -0
- package/dist/reply/reply.js +2 -1
- package/dist/skill/content.d.ts +2 -2
- package/dist/skill/content.js +11 -8
- package/dist/status.d.ts +1 -0
- package/dist/status.js +29 -0
- package/dist/steering/rules.js +4 -2
- package/dist/uninstall.d.ts +7 -0
- package/dist/uninstall.js +72 -71
- package/package.json +10 -3
- package/skills/heedvane-hooks/SKILL.md +6 -6
- package/skills/heedvane-hooks/references/events.md +5 -2
- package/dist/dispatchers/windows.d.ts +0 -15
- package/dist/dispatchers/windows.js +0 -50
- package/dist/mcp-server.d.ts +0 -9
- package/dist/mcp-server.js +0 -187
package/dist/doctor.js
CHANGED
|
@@ -3,17 +3,20 @@
|
|
|
3
3
|
* remediation}. The desired-entry lookup is injected (defaulting to the
|
|
4
4
|
* pure planner's desiredEntriesFor) so this module never imports apply.ts.
|
|
5
5
|
*/
|
|
6
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { accessSync, constants as fsConstants, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { supportedClients } from "./clients/definitions.js";
|
|
9
9
|
import { entryValue, parseDoc, readEntry, valuesEqual } from "./config-doc.js";
|
|
10
10
|
import { loadHeedFileAt } from "./heed-file.js";
|
|
11
|
-
import { desiredEntriesFor, hooksDeferredTo, isClientDetected } from "./plan.js";
|
|
11
|
+
import { bridgeNodeDir, connectionOff, desiredEntriesFor, heedvaneConnector, heedvaneMcpEntries, hooksDeferredTo, isClientDetected, legacyMcpEntries, supportsCapability } from "./plan.js";
|
|
12
|
+
import { MCP_NEEDS_ORG, MCP_OFF_METRICS_ONLY, ORG_KEY_ENV, ORG_REMEDIATION, comparableEntry, connectorEntry, keyConnectorEntry } from "./mcp.js";
|
|
12
13
|
import { readInstalledSkillDigest } from "./skill/bundle.js";
|
|
13
14
|
import { HeedStateCorruptError, readState } from "./state.js";
|
|
14
15
|
import { readApprovedSkills } from "./skills/approved.js";
|
|
15
|
-
import {
|
|
16
|
+
import { HEEDVANE_VERSION, listenerVerdict, probeListener, readEndpoint } from "./listener/endpoint.js";
|
|
17
|
+
import { settingsToRun } from "./listener/serve.js";
|
|
16
18
|
import { errorMessage } from "./guards.js";
|
|
19
|
+
import { HOOK_NODE_FILE } from "./hook-fallback.js";
|
|
17
20
|
const INIT_REMEDIATION = "run npx heedvane init";
|
|
18
21
|
const SETUP_REMEDIATION = "run npx heedvane setup";
|
|
19
22
|
/** Inspect one desired entry on disk; every failure path produces a detail line, never a throw. */
|
|
@@ -22,6 +25,11 @@ function entryVerdict(d, home) {
|
|
|
22
25
|
if (!existsSync(path)) {
|
|
23
26
|
return { aligned: false, present: false, detail: `${d.entryName}: missing (${d.path})` };
|
|
24
27
|
}
|
|
28
|
+
if (d.containerKey === "") {
|
|
29
|
+
// Whole-file desired (Hermes' plugin files, Pi's guard extension): compared byte for byte, never parsed as a config.
|
|
30
|
+
const ok = readFileSync(path, "utf8") === d.entry;
|
|
31
|
+
return { aligned: ok, present: true, detail: `${d.entryName}: ${ok ? "aligned" : "stale"}` };
|
|
32
|
+
}
|
|
25
33
|
let doc;
|
|
26
34
|
try {
|
|
27
35
|
doc = parseDoc(readFileSync(path, "utf8"), d.format);
|
|
@@ -29,16 +37,11 @@ function entryVerdict(d, home) {
|
|
|
29
37
|
catch {
|
|
30
38
|
return { aligned: false, present: true, detail: `${d.entryName}: config does not parse` };
|
|
31
39
|
}
|
|
32
|
-
if (d.containerKey === "") {
|
|
33
|
-
// Whole-file desired (hermes plugin.yaml).
|
|
34
|
-
const ok = readFileSync(path, "utf8") === d.entry;
|
|
35
|
-
return { aligned: ok, present: true, detail: `${d.entryName}: ${ok ? "aligned" : "stale"}` };
|
|
36
|
-
}
|
|
37
40
|
const current = readEntry(doc, d.containerKey, d.entryName);
|
|
38
41
|
if (current === undefined) {
|
|
39
42
|
return { aligned: false, present: true, detail: `${d.entryName}: missing in ${d.path}` };
|
|
40
43
|
}
|
|
41
|
-
if (!valuesEqual(current, entryValue(d.containerKey, current, d.entry))) {
|
|
44
|
+
if (!valuesEqual(comparableEntry(current), comparableEntry(entryValue(d.containerKey, current, d.entry)))) {
|
|
42
45
|
return { aligned: false, present: true, detail: `${d.entryName}: stale in ${d.path}` };
|
|
43
46
|
}
|
|
44
47
|
return { aligned: true, present: true, detail: `${d.entryName}: aligned` };
|
|
@@ -61,14 +64,17 @@ function registrationState(desireds, home) {
|
|
|
61
64
|
return { state: "stale", detail: parts.join("; ") };
|
|
62
65
|
return { state: "aligned", detail: parts.join("; ") };
|
|
63
66
|
}
|
|
67
|
+
/** The oldest Node the CLI runs on, as package.json's `engines` states it: 22.5 has path.matchesGlob, which steering rules with file patterns run on. */
|
|
68
|
+
export const NODE_FLOOR = "22.5";
|
|
64
69
|
function checkNode() {
|
|
65
|
-
const major =
|
|
66
|
-
const
|
|
70
|
+
const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
|
|
71
|
+
const [floorMajor = 0, floorMinor = 0] = NODE_FLOOR.split(".").map(Number);
|
|
72
|
+
const ok = major > floorMajor || (major === floorMajor && minor >= floorMinor);
|
|
67
73
|
return {
|
|
68
74
|
ok,
|
|
69
75
|
name: "node",
|
|
70
|
-
detail: `node ${process.versions.node} (${ok ?
|
|
71
|
-
remediation: ok ? undefined :
|
|
76
|
+
detail: `node ${process.versions.node} (${ok ? `>= ${NODE_FLOOR}` : `< ${NODE_FLOOR} — heedvane requires Node ${NODE_FLOOR} or later`})`,
|
|
77
|
+
remediation: ok ? undefined : `upgrade Node to ${NODE_FLOOR} or later (nvm install ${floorMajor})`,
|
|
72
78
|
};
|
|
73
79
|
}
|
|
74
80
|
function checkHeedFile(home) {
|
|
@@ -109,13 +115,14 @@ void checkState;
|
|
|
109
115
|
function scopeSet(clients) {
|
|
110
116
|
return clients && clients.length > 0 ? new Set(clients) : null;
|
|
111
117
|
}
|
|
112
|
-
/** The pure-planner desired-entry lookup, resolving each client id against home/env. */
|
|
113
|
-
function defaultDesiredLookup(home, env) {
|
|
118
|
+
/** The pure-planner desired-entry lookup, resolving each client id against home/env and heed.yaml's organization. */
|
|
119
|
+
function defaultDesiredLookup(home, env, heedFile) {
|
|
120
|
+
const connector = heedFile === undefined ? undefined : heedvaneConnector(heedFile);
|
|
114
121
|
return (clientId, capability) => {
|
|
115
122
|
const client = supportedClients({ home, platform: process.platform, env }).find((c) => c.id === clientId);
|
|
116
123
|
if (!client)
|
|
117
124
|
return undefined;
|
|
118
|
-
return desiredEntriesFor(client, capability, { home, env }).map((d) => ({
|
|
125
|
+
return desiredEntriesFor(client, capability, { home, env, connector, nodeDir: bridgeNodeDir(process.platform), gating: heedFile?.gating.mode }).map((d) => ({
|
|
119
126
|
path: d.path,
|
|
120
127
|
format: d.format,
|
|
121
128
|
containerKey: d.containerKey,
|
|
@@ -157,16 +164,44 @@ function heedFileWants(client, capability, ctx) {
|
|
|
157
164
|
function collectCapabilityStates(client, ctx) {
|
|
158
165
|
const states = [];
|
|
159
166
|
for (const capability of ["hooks", "mcp"]) {
|
|
160
|
-
const supported = client
|
|
167
|
+
const supported = supportsCapability(client, capability);
|
|
161
168
|
if (!supported || !heedFileWants(client, capability, ctx))
|
|
162
169
|
continue;
|
|
163
|
-
const desireds = ctx.desired(client.id, capability);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
170
|
+
const desireds = ctx.desired(client.id, capability) ?? [];
|
|
171
|
+
const result = capability === "mcp" ? mcpConnectionState(client, desireds, ctx) : hooksState(desireds, ctx.home);
|
|
172
|
+
if (result !== undefined)
|
|
173
|
+
states.push({ capability, result });
|
|
167
174
|
}
|
|
168
175
|
return states;
|
|
169
176
|
}
|
|
177
|
+
/** The hooks' registration verdict, when this client has hooks to register. */
|
|
178
|
+
function hooksState(desireds, home) {
|
|
179
|
+
return desireds.length > 0 ? registrationState(desireds, home) : undefined;
|
|
180
|
+
}
|
|
181
|
+
/** Heedvane's MCP connection: stale while an earlier version's entry is left, missing without an organization, else the registration verdict. */
|
|
182
|
+
function mcpConnectionState(client, desireds, { home, env, heedFile }) {
|
|
183
|
+
if (heedFile !== undefined && connectionOff(heedFile) === "metrics-only") {
|
|
184
|
+
const left = heedvaneMcpEntries(client);
|
|
185
|
+
return left === undefined ? undefined : { state: "stale", detail: `${left.names.join(", ")} in ${left.path}: ${MCP_OFF_METRICS_ONLY}; npx -y heedvane connect removes it` };
|
|
186
|
+
}
|
|
187
|
+
const legacy = legacyMcpEntries(client);
|
|
188
|
+
if (legacy !== undefined && desireds.length === 0)
|
|
189
|
+
return { state: "stale", detail: `${legacy.names.join(", ")} in ${legacy.path} is an earlier version's entry; ${MCP_NEEDS_ORG}`, remediation: ORG_REMEDIATION };
|
|
190
|
+
if (legacy !== undefined)
|
|
191
|
+
return { state: "stale", detail: `${legacy.names.join(", ")} in ${legacy.path} is an earlier version's entry; npx -y heedvane connect replaces it` };
|
|
192
|
+
const connector = heedFile === undefined ? undefined : heedvaneConnector(heedFile);
|
|
193
|
+
const keyForm = connector === undefined ? undefined : keyConnectorEntry(client.id, connector);
|
|
194
|
+
if (desireds.length > 0)
|
|
195
|
+
return withKeyNote(registrationState(desireds, home), desireds.some((desired) => valuesEqual(desired.entry, keyForm)), env);
|
|
196
|
+
// Agents the connection is not registered in have nothing to check.
|
|
197
|
+
return connectorEntry(client.id, "") === undefined ? undefined : { state: "missing", detail: MCP_NEEDS_ORG, remediation: ORG_REMEDIATION };
|
|
198
|
+
}
|
|
199
|
+
/** A software factory's connection sends HEEDVANE_ORG_KEY from the agent's environment, so doctor says when its own lacks it. */
|
|
200
|
+
function withKeyNote(result, sendsKey, env) {
|
|
201
|
+
if (!sendsKey || env[ORG_KEY_ENV]?.trim())
|
|
202
|
+
return result;
|
|
203
|
+
return { ...result, detail: `${result.detail}; it sends ${ORG_KEY_ENV}, which is not set here: set it where the agent runs` };
|
|
204
|
+
}
|
|
170
205
|
/** One detected client's registration check (aligned / stale / missing with per-entry detail). */
|
|
171
206
|
function pushRegistrationChecks(checks, client, ctx) {
|
|
172
207
|
const detected = isClientDetected(client);
|
|
@@ -189,11 +224,12 @@ function pushRegistrationChecks(checks, client, ctx) {
|
|
|
189
224
|
return;
|
|
190
225
|
const allAligned = states.every((s) => s.result.state === "aligned");
|
|
191
226
|
const detail = states.map((s) => `${s.capability}: ${s.result.state} (${s.result.detail})`).join("; ");
|
|
227
|
+
const remedies = new Set(states.filter((s) => s.result.state !== "aligned").map((s) => s.result.remediation ?? SETUP_REMEDIATION));
|
|
192
228
|
checks.push({
|
|
193
229
|
ok: allAligned,
|
|
194
230
|
name: `${client.id} registration`,
|
|
195
231
|
detail,
|
|
196
|
-
remediation: allAligned ? undefined :
|
|
232
|
+
remediation: allAligned ? undefined : [...remedies].join("; "),
|
|
197
233
|
});
|
|
198
234
|
}
|
|
199
235
|
/** The skill-digest verdict against state.json (aligned / stale / missing / unrecorded). */
|
|
@@ -233,7 +269,7 @@ export async function runDoctor(input) {
|
|
|
233
269
|
const root = join(home, ".heed");
|
|
234
270
|
const state = await readStateOrReport(root, checks);
|
|
235
271
|
// 4. Desired-entry lookup: injected, falling back to the pure planner.
|
|
236
|
-
const desired = input.desired ?? defaultDesiredLookup(home, env);
|
|
272
|
+
const desired = input.desired ?? defaultDesiredLookup(home, env, heedFile);
|
|
237
273
|
// 5. Per detected client: registration state aligned | stale | missing.
|
|
238
274
|
const scope = scopeSet(input.clients);
|
|
239
275
|
const allClients = supportedClients({ home, platform: process.platform, env });
|
|
@@ -250,6 +286,7 @@ export async function runDoctor(input) {
|
|
|
250
286
|
}
|
|
251
287
|
// 7. Listener reachable — best-effort POST, warn-only, never fails the report.
|
|
252
288
|
checks.push(await checkListener(root));
|
|
289
|
+
checks.push(checkHookNode(root));
|
|
253
290
|
checks.push(await checkApprovedSkills(root, heedFile));
|
|
254
291
|
const healthy = checks.every((c) => c.ok || c.warnOnly);
|
|
255
292
|
return { healthy, checks, scopedTo: scope ? [...scope] : undefined };
|
|
@@ -276,6 +313,42 @@ async function checkApprovedSkills(root, heedFile) {
|
|
|
276
313
|
return { ...base, ok: false, detail: `${list.skills.length} approved, synced ${list.syncedAt}: revocations since then are not enforced`, remediation: sync };
|
|
277
314
|
return { ...base, detail: `${list.skills.length} approved, synced ${list.syncedAt}` };
|
|
278
315
|
}
|
|
316
|
+
/** The Node every hook's fallback runs with (agent-hooks/node), which `brew upgrade` or `nvm uninstall` can remove. */
|
|
317
|
+
function checkHookNode(root) {
|
|
318
|
+
const dir = join(root, "agent-hooks");
|
|
319
|
+
const nodes = existsSync(join(dir, HOOK_NODE_FILE)) ? readFileSync(join(dir, HOOK_NODE_FILE), "utf8").split("\n").filter(Boolean) : [];
|
|
320
|
+
const base = { name: "hooks' Node", warnOnly: true };
|
|
321
|
+
const node = nodes.find(isExecutable);
|
|
322
|
+
if (node !== undefined)
|
|
323
|
+
return { ...base, ok: true, detail: node };
|
|
324
|
+
// Hooks installed before the file was written (0.3.3 and earlier) have none.
|
|
325
|
+
if (nodes.length === 0 && !(existsSync(dir) && readdirSync(dir).some((name) => name.endsWith("-hook.sh"))))
|
|
326
|
+
return { ...base, ok: true, detail: "no hooks installed" };
|
|
327
|
+
const missing = nodes.length === 0 ? "not recorded" : `${nodes.join(" and ")} ${nodes.length === 1 ? "is" : "are"} gone`;
|
|
328
|
+
return { ...base, ok: false, detail: `${missing}, so hooks that cannot reach the listener fall back to node on PATH, if there is one`, remediation: "npx heedvane connect" };
|
|
329
|
+
}
|
|
330
|
+
/** A listener the verdict keeps. One before 0.3.4, kept because no settings parse, cannot prove itself: the hooks keep their events instead. */
|
|
331
|
+
function keptListener(running, at) {
|
|
332
|
+
if (running.settings !== undefined)
|
|
333
|
+
return { detail: `reachable on ${at}` };
|
|
334
|
+
return { ok: false, detail: `an older listener (${running.version || "before 0.3.4"}) runs on ${at}, which the hooks do not use: events spool until heed.yaml parses and a listener of this version starts` };
|
|
335
|
+
}
|
|
336
|
+
/** A newer listener: this npx heedvane is older; the newer one applies the settings when it runs earlier ones. */
|
|
337
|
+
function newerListener(running, current, at) {
|
|
338
|
+
const older = `this npx heedvane (${HEEDVANE_VERSION}) is older`;
|
|
339
|
+
if (current !== null && running.settings !== current)
|
|
340
|
+
return { detail: `a newer listener (${running.version}) runs on ${at} with the earlier settings; ${older}`, remediation: "npx -y heedvane@latest listener restart" };
|
|
341
|
+
return { detail: `a newer listener (${running.version}) runs on ${at}; ${older}`, remediation: "npx -y heedvane@latest doctor" };
|
|
342
|
+
}
|
|
343
|
+
function isExecutable(path) {
|
|
344
|
+
try {
|
|
345
|
+
accessSync(path, fsConstants.X_OK);
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return false; // removed, or not a program
|
|
350
|
+
}
|
|
351
|
+
}
|
|
279
352
|
async function checkListener(root) {
|
|
280
353
|
const base = { ok: true, name: "listener", detail: "", warnOnly: true };
|
|
281
354
|
let endpoint;
|
|
@@ -294,10 +367,21 @@ async function checkListener(root) {
|
|
|
294
367
|
if (!endpoint.port) {
|
|
295
368
|
return { ...base, detail: "endpoint.env incomplete (no port) — re-run npx heedvane setup" };
|
|
296
369
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
370
|
+
const running = await probeListener(endpoint);
|
|
371
|
+
const at = `127.0.0.1:${endpoint.port}`;
|
|
372
|
+
// The verdict every command acts on (src/listener/background.ts ensureListener): doctor only reports it.
|
|
373
|
+
const current = await settingsToRun(root).then((settings) => settings.hash, () => null);
|
|
374
|
+
switch (listenerVerdict(running, current)) {
|
|
375
|
+
case "keep":
|
|
376
|
+
return { ...base, ...keptListener(running, at) };
|
|
377
|
+
case "start":
|
|
378
|
+
return { ...base, detail: `not reachable on ${at} — ${ENFORCEMENT_OFF}; events will spool (warn-only; never fails the report)` };
|
|
379
|
+
case "newer":
|
|
380
|
+
return { ...base, ok: false, ...newerListener(running, current, at) };
|
|
381
|
+
case "replace":
|
|
382
|
+
return { ...base, ok: false, detail: `an older listener (${running?.version || "before 0.3.4"}) runs on ${at}`, remediation: "npx heedvane listener restart" };
|
|
383
|
+
case "restart":
|
|
384
|
+
// A hand edit of heed.yaml, or a sign-in no command saw, reaches a running listener only through a restart.
|
|
385
|
+
return { ...base, ok: false, detail: `runs on ${at} with the settings it started with; heed.yaml or the sign-in changed since`, remediation: "npx heedvane listener restart" };
|
|
386
|
+
}
|
|
303
387
|
}
|
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export interface ToolCallQuestion {
|
|
2
|
+
tool: string;
|
|
3
|
+
input: unknown;
|
|
4
|
+
id?: string;
|
|
5
|
+
session?: string;
|
|
6
|
+
cwd?: string;
|
|
7
|
+
/** Shows the person a notice, where the agent can (Pi's `ctx.ui.notify`); stderr when not given. */
|
|
8
|
+
tell?: (notice: string) => void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Heedvane's reason to refuse this tool call, or undefined to let it run. It never throws: when the listener cannot
|
|
12
|
+
* answer (none runs, another program holds its port, or it answers with an error) the event is spooled for it to
|
|
13
|
+
* replay and the tool runs, as with every agent's hooks.
|
|
14
|
+
*/
|
|
15
|
+
export declare function heedvaneRefusal(call: ToolCallQuestion): Promise<string | undefined>;
|
|
16
|
+
/** An event no listener answered: the agent's id, the hook's JSON text, its event id and its session. */
|
|
17
|
+
interface Unanswered {
|
|
18
|
+
source: string;
|
|
19
|
+
payload: string;
|
|
20
|
+
id: string;
|
|
21
|
+
session: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Every agent's fallback when the listener gives no answer: keeps the event in the spool for the listener to replay,
|
|
25
|
+
* starts a listener (on any failure: on Windows a refused loopback connection outlasts the timeout), and says whether
|
|
26
|
+
* the session is yet to be told that its calls are not checked; its marker is written now. Never throws.
|
|
27
|
+
*/
|
|
28
|
+
export declare function keepUnanswered(root: string, unanswered: Unanswered): boolean;
|
|
29
|
+
/** Pi's core: `new Agent({ beforeToolCall })`, or `agent.beforeToolCall = beforeToolCall`. */
|
|
30
|
+
export declare function beforeToolCall(context: {
|
|
31
|
+
toolCall: {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
};
|
|
35
|
+
args: unknown;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
block: true;
|
|
38
|
+
reason: string;
|
|
39
|
+
} | undefined>;
|
|
40
|
+
/** Pi Durable: `defineExtension({ name: "heedvane", hooks: [hook(ToolTask, { beforeTool })] })`. Its conversation is the session. */
|
|
41
|
+
export declare function beforeTool(call: {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
arguments: unknown;
|
|
45
|
+
}, api?: {
|
|
46
|
+
conversationId?: string | number;
|
|
47
|
+
}): Promise<{
|
|
48
|
+
block: string;
|
|
49
|
+
} | undefined>;
|
|
50
|
+
interface PiToolCallEvent {
|
|
51
|
+
toolName: string;
|
|
52
|
+
toolCallId: string;
|
|
53
|
+
input: unknown;
|
|
54
|
+
}
|
|
55
|
+
interface PiContext {
|
|
56
|
+
cwd?: string;
|
|
57
|
+
sessionManager?: {
|
|
58
|
+
getSessionId?: () => string;
|
|
59
|
+
};
|
|
60
|
+
/** False in print and JSON modes, which have no UI. */
|
|
61
|
+
hasUI?: boolean;
|
|
62
|
+
ui?: {
|
|
63
|
+
notify?: (message: string, type?: "info" | "warning" | "error") => void;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** The Pi coding agent's extension entry point. */
|
|
67
|
+
export default function heedvane(pi: {
|
|
68
|
+
on(event: "tool_call", handler: (event: PiToolCallEvent, ctx: PiContext) => Promise<{
|
|
69
|
+
block: true;
|
|
70
|
+
reason: string;
|
|
71
|
+
} | undefined>): unknown;
|
|
72
|
+
}): void;
|
|
73
|
+
export {};
|
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));
|