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
|
@@ -1,36 +1,170 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { closeSync, openSync } from "node:fs";
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
import { errorMessage } from "../guards.js";
|
|
6
|
+
import { START_LOCK, START_LOCK_HOLDER, START_LOCK_MINUTES } from "../hook-fallback.js";
|
|
7
|
+
import { listenerVerdict, pingListener, probeListener, readEndpoint } from "./endpoint.js";
|
|
8
|
+
import { SEND_TIMEOUT_MS } from "./forward.js";
|
|
9
|
+
import { askToStop, isAlive, settingsToRun } from "./serve.js";
|
|
10
|
+
/** How long a listener started in the background gets to answer with the current settings. */
|
|
11
|
+
const START_WAIT_MS = 10_000;
|
|
12
|
+
const START_POLL_MS = 100;
|
|
13
|
+
/** How long a listener asked to stop gets to exit: its last flush may be sending reports. */
|
|
14
|
+
const STOP_WAIT_MS = SEND_TIMEOUT_MS + 2_000;
|
|
15
|
+
const STOP_POLL_MS = 100;
|
|
16
|
+
/** `listener start`'s exit code when the port stays taken: the Heedvane listener there did not stop when asked. */
|
|
17
|
+
export const PORT_TAKEN_EXIT = 3;
|
|
9
18
|
/** Where a background listener writes what it prints. */
|
|
10
19
|
export function listenerLogPath(root) {
|
|
11
20
|
return join(root, "listener.log");
|
|
12
21
|
}
|
|
13
22
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
23
|
+
* The one owner of which listener runs: it compares what the running listener reports about itself (its version and
|
|
24
|
+
* the hash of its settings) with the settings a listener would run now, and acts on {@link listenerVerdict}. A listener
|
|
25
|
+
* to start, replace or restart is started in the background with this package's own CLI (`listener start`, which takes
|
|
26
|
+
* the port over from the old one), and this waits until a listener it can keep answers, whatever version won the port.
|
|
27
|
+
* Every command that changes heed.yaml or the sign-in, `listener restart` and `run` call it last.
|
|
17
28
|
*/
|
|
18
|
-
export async function
|
|
29
|
+
export async function ensureListener(home, cliPath, env) {
|
|
19
30
|
const root = join(home, ".heed");
|
|
20
|
-
const endpoint = await
|
|
21
|
-
if (
|
|
31
|
+
const endpoint = await readEndpoint(root).catch(() => null);
|
|
32
|
+
if (endpoint === null)
|
|
33
|
+
return { kind: "not set up" };
|
|
34
|
+
let unusable;
|
|
35
|
+
const current = await settingsToRun(root).then((settings) => settings.hash, (err) => {
|
|
36
|
+
unusable = errorMessage(err);
|
|
37
|
+
return null;
|
|
38
|
+
});
|
|
39
|
+
const found = await probeListener(endpoint);
|
|
40
|
+
const verdict = listenerVerdict(found, current);
|
|
41
|
+
if (verdict === "newer")
|
|
42
|
+
return newer(found, current);
|
|
43
|
+
if (verdict === "keep")
|
|
44
|
+
return { kind: "running" };
|
|
45
|
+
// No heed.yaml parses and no copy of the last good one: a listener started now would end at once (its log says why).
|
|
46
|
+
if (current === null)
|
|
47
|
+
return { kind: "did not start", log: listenerLogPath(root), why: unusable };
|
|
48
|
+
// The start's exit code once it ended (null when it could not be spawned or was killed); unset while another start
|
|
49
|
+
// (the hooks' or another command's) holds the start lock: that one brings up the listener this waits for.
|
|
50
|
+
const start = {};
|
|
51
|
+
const began = Date.now();
|
|
52
|
+
const spawnOnceLockIsTaken = () => {
|
|
53
|
+
if (start.spawned !== undefined || !tookStartLock(root, Date.now() - began > LOCK_WAIT_MS))
|
|
54
|
+
return;
|
|
55
|
+
start.spawned = true;
|
|
56
|
+
const child = spawnBackgroundListener(home, cliPath, env).on("error", () => (start.ended = null)).on("exit", (code) => (start.ended = code));
|
|
57
|
+
writeFileSync(join(root, START_LOCK, START_LOCK_HOLDER), String(child.pid ?? ""));
|
|
58
|
+
};
|
|
59
|
+
spawnOnceLockIsTaken();
|
|
60
|
+
// Until a deadline, not a count of pauses: a probe of a port that never answers takes its whole timeout.
|
|
61
|
+
for (let until = Date.now() + START_WAIT_MS, ended = false; !ended && Date.now() < until; await delay(START_POLL_MS)) {
|
|
62
|
+
ended = start.ended !== undefined; // read before the probe: one made after the start ended has the last word
|
|
63
|
+
// endpoint.env as the start leaves it: one that finds another program on the port moves to a free one.
|
|
64
|
+
const running = await probeListener((await readEndpoint(root).catch(() => null)) ?? endpoint);
|
|
65
|
+
const now = listenerVerdict(running, current);
|
|
66
|
+
if (now === "newer")
|
|
67
|
+
return newer(running, current);
|
|
68
|
+
if (now === "keep")
|
|
69
|
+
return { kind: STARTED[verdict] };
|
|
70
|
+
spawnOnceLockIsTaken(); // after the probe: a lock freed by a start that listens is not taken for a second one
|
|
71
|
+
}
|
|
72
|
+
// A start that ended without a listener to keep leaves the lock to the next one (one that listens removes it).
|
|
73
|
+
if (start.ended !== undefined)
|
|
74
|
+
rmSync(join(root, START_LOCK), { recursive: true, force: true });
|
|
75
|
+
// Only the start knows whether the port stayed taken or it ended before taking it (its log says why).
|
|
76
|
+
if (start.ended === PORT_TAKEN_EXIT)
|
|
77
|
+
return { kind: "did not stop", port: endpoint.port };
|
|
78
|
+
return { kind: "did not start", log: listenerLogPath(root) };
|
|
79
|
+
}
|
|
80
|
+
/** The pid of the start that holds the lock: NaN until it is written, and once the lock is gone. */
|
|
81
|
+
function startLockHolder(lock) {
|
|
82
|
+
try {
|
|
83
|
+
return Number.parseInt(readFileSync(join(lock, START_LOCK_HOLDER), "utf8"), 10);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
if (err.code !== "ENOENT")
|
|
87
|
+
throw err;
|
|
88
|
+
return Number.NaN;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* How long a command waits for a start that holds the lock before it starts one itself: a hook's `npx` that cannot reach
|
|
93
|
+
* the registry holds it for a minute and more. A second start is safe: it re-reads endpoint.env, and a listener
|
|
94
|
+
* endpoint.env does not name steps down.
|
|
95
|
+
*/
|
|
96
|
+
const LOCK_WAIT_MS = 2_500;
|
|
97
|
+
/**
|
|
98
|
+
* Takes the start lock (src/hook-fallback.ts START_LOCK), so only one start runs at a time: false while another holds
|
|
99
|
+
* it. It is taken over when the start named in it has ended, when it is older than a start may take, or `waitedLong`.
|
|
100
|
+
*/
|
|
101
|
+
function tookStartLock(root, waitedLong) {
|
|
102
|
+
const lock = join(root, START_LOCK);
|
|
103
|
+
const holder = startLockHolder(lock);
|
|
104
|
+
const stale = Date.now() - (statSync(lock, { throwIfNoEntry: false })?.mtimeMs ?? Date.now()) > START_LOCK_MINUTES * 60_000;
|
|
105
|
+
if (stale || waitedLong || (Number.isInteger(holder) && !isAlive(holder)))
|
|
106
|
+
rmSync(lock, { recursive: true, force: true });
|
|
107
|
+
try {
|
|
108
|
+
mkdirSync(lock);
|
|
22
109
|
return true;
|
|
23
|
-
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (err.code !== "EEXIST")
|
|
113
|
+
throw err;
|
|
114
|
+
return false; // another start holds it
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const STARTED = { start: "started", replace: "replaced", restart: "restarted" };
|
|
118
|
+
/** The newer version that runs, kept, on the settings a listener would run now (`current`) or on earlier ones. */
|
|
119
|
+
function newer(running, current) {
|
|
120
|
+
// shortcut: a newer listener's settings hash is compared as this version computes it; if a later version hashes other bytes, give the probe a hash version.
|
|
121
|
+
return { kind: "newer", version: running?.version ?? "", earlierSettings: current !== null && running?.settings !== current };
|
|
122
|
+
}
|
|
123
|
+
/** What stops a listener that does not stop when asked, ready to paste. */
|
|
124
|
+
export function stopCommand(port) {
|
|
125
|
+
return `kill $(lsof -t -iTCP:${port} -sTCP:LISTEN)`;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Spawns `listener start` as a process that outlives this one, its output in the listener's log, without waiting for
|
|
129
|
+
* it to answer. A spawn that fails (no node, no permission) is an `error` event on the returned process, never an
|
|
130
|
+
* error that ends the caller.
|
|
131
|
+
*/
|
|
132
|
+
function spawnBackgroundListener(home, cliPath, env) {
|
|
133
|
+
const log = openLog(listenerLogPath(join(home, ".heed")));
|
|
24
134
|
try {
|
|
25
|
-
|
|
135
|
+
// This process's own Node flags (none under npx), so a listener started from source runs as this process does.
|
|
136
|
+
const child = spawn(process.execPath, [...process.execArgv, cliPath, "listener", "start"], { detached: true, windowsHide: true, stdio: ["ignore", log, log], env: { ...env, HEED_HOME: home } });
|
|
137
|
+
child.on("error", () => undefined); // reported by the listener not answering (ensureListener)
|
|
138
|
+
child.unref();
|
|
139
|
+
return child;
|
|
26
140
|
}
|
|
27
141
|
finally {
|
|
28
|
-
|
|
142
|
+
if (log !== "ignore")
|
|
143
|
+
closeSync(log);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** The listener's log, opened to append; "ignore" when it cannot be (a folder in its place, a full disk): the listener runs without it. */
|
|
147
|
+
function openLog(path) {
|
|
148
|
+
try {
|
|
149
|
+
return openSync(path, "a", 0o600);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return "ignore";
|
|
29
153
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Asks the listener at `endpoint` to stop (POST /shutdown) and waits until its process has exited (until it no longer
|
|
157
|
+
* answers, for one older than 0.3.4, which does not name its pid), as long as its last reports may take to send.
|
|
158
|
+
* "no stop route" for a listener from 0.2.0 or earlier, which runs until logout or restart.
|
|
159
|
+
*/
|
|
160
|
+
export async function stopRunningListener(endpoint, reason = "asked to stop") {
|
|
161
|
+
const { status, pid } = await askToStop(endpoint, reason);
|
|
162
|
+
if (status === 404)
|
|
163
|
+
return "no stop route";
|
|
164
|
+
const gone = pid !== null ? async () => !isAlive(pid) : async () => !(await pingListener(endpoint));
|
|
165
|
+
for (const until = Date.now() + STOP_WAIT_MS; Date.now() < until; await delay(STOP_POLL_MS)) {
|
|
166
|
+
if (await gone())
|
|
167
|
+
return "stopped";
|
|
34
168
|
}
|
|
35
|
-
return
|
|
169
|
+
return "still running";
|
|
36
170
|
}
|
|
@@ -12,15 +12,38 @@ export declare function endpointPath(root: string): string;
|
|
|
12
12
|
* `token` -> "") so callers can produce a precise remediation error.
|
|
13
13
|
*/
|
|
14
14
|
export declare function readEndpoint(root: string): Promise<Endpoint | null>;
|
|
15
|
-
/** Atomically write `root/endpoint.env` (mode 0600) with the port and
|
|
15
|
+
/** Atomically write `root/endpoint.env` (mode 0600) with the port, token and proof lines. */
|
|
16
16
|
export declare function writeEndpoint(root: string, endpoint: Endpoint): Promise<void>;
|
|
17
|
+
/** A loopback port free right now (the OS picks it). */
|
|
18
|
+
export declare function freeLoopbackPort(): Promise<number>;
|
|
17
19
|
/**
|
|
18
20
|
* The listener endpoint, issued once: an existing endpoint.env with a port and token is kept
|
|
19
21
|
* (dispatchers already point at it); otherwise a free loopback port and a fresh 256-bit token are written.
|
|
20
22
|
*/
|
|
21
23
|
export declare function ensureEndpoint(root: string): Promise<Endpoint>;
|
|
24
|
+
/** This package's version, which the listener names in its probe answer so an older one is found and replaced. */
|
|
25
|
+
export declare const HEEDVANE_VERSION: string;
|
|
22
26
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
27
|
+
* The version of Heedvane's listener at `endpoint` ("" for one from before 0.3.4, which names none), or null when it
|
|
28
|
+
* does not answer within 750 ms. The probe is a GET, which the listener refuses with 405 and {@link PROBE_REFUSAL}
|
|
29
|
+
* before it reads a token or records anything, so probing never adds an event, and another program on the port,
|
|
30
|
+
* which answers otherwise, is never taken for it (nor sent its token).
|
|
25
31
|
*/
|
|
32
|
+
export declare function listenerVersion(endpoint: Endpoint): Promise<string | null>;
|
|
33
|
+
/** What a running listener says about itself in its probe answer: its version, and the hash of the settings it runs (0.3.4 on). */
|
|
34
|
+
export interface RunningListener {
|
|
35
|
+
version: string;
|
|
36
|
+
settings?: string;
|
|
37
|
+
}
|
|
38
|
+
/** What to do about the listener at the port, given what it reports and the settings a listener would run now. */
|
|
39
|
+
export type ListenerVerdict = "start" | "replace" | "restart" | "keep" | "newer";
|
|
40
|
+
/**
|
|
41
|
+
* The one decision of which listener should run. Nothing answers: start one. An older version: replace it. A newer
|
|
42
|
+
* one: keep it (this CLI is the older). The same version on other settings: restart it. Else keep it. `current` is
|
|
43
|
+
* null when no settings parse at all: a running listener is then kept, so a bad heed.yaml never stops the checks.
|
|
44
|
+
*/
|
|
45
|
+
export declare function listenerVerdict(running: RunningListener | null, current: string | null): ListenerVerdict;
|
|
46
|
+
/** The listener's probe answer: its version (see {@link listenerVersion}) and the settings it runs (0.3.4 on). */
|
|
47
|
+
export declare function probeListener(endpoint: Endpoint): Promise<RunningListener | null>;
|
|
48
|
+
/** Whether Heedvane's listener, of any version, answers at `endpoint` (see {@link listenerVersion}). */
|
|
26
49
|
export declare function pingListener(endpoint: Endpoint): Promise<boolean>;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { listenerProof, PROBE_REFUSAL, PROOF_HEADER, PROOF_KEY } from "../hook-fallback.js";
|
|
2
4
|
import { chmod, mkdir, open, readFile, rename } from "node:fs/promises";
|
|
3
5
|
import { createServer } from "node:net";
|
|
4
6
|
import { basename, dirname, join } from "node:path";
|
|
@@ -80,13 +82,13 @@ async function atomicWriteFile(filePath, data, mode) {
|
|
|
80
82
|
throw err;
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
|
-
/** Atomically write `root/endpoint.env` (mode 0600) with the port and
|
|
85
|
+
/** Atomically write `root/endpoint.env` (mode 0600) with the port, token and proof lines. */
|
|
84
86
|
export async function writeEndpoint(root, endpoint) {
|
|
85
|
-
const content = `${PORT_KEY}=${endpoint.port}\n${TOKEN_KEY}=${endpoint.token}\n`;
|
|
87
|
+
const content = `${PORT_KEY}=${endpoint.port}\n${TOKEN_KEY}=${endpoint.token}\n${PROOF_KEY}=${await listenerProof(endpoint.token)}\n`;
|
|
86
88
|
await atomicWriteFile(endpointPath(root), content, 0o600);
|
|
87
89
|
}
|
|
88
90
|
/** A loopback port free right now (the OS picks it). */
|
|
89
|
-
async function freeLoopbackPort() {
|
|
91
|
+
export async function freeLoopbackPort() {
|
|
90
92
|
const server = createServer();
|
|
91
93
|
await new Promise((resolve, reject) => {
|
|
92
94
|
server.once("error", reject);
|
|
@@ -104,22 +106,65 @@ async function freeLoopbackPort() {
|
|
|
104
106
|
*/
|
|
105
107
|
export async function ensureEndpoint(root) {
|
|
106
108
|
const existing = await readEndpoint(root);
|
|
107
|
-
if (existing !== null && existing.port > 0 && existing.token)
|
|
109
|
+
if (existing !== null && existing.port > 0 && existing.token) {
|
|
110
|
+
// One written before the proof (0.3.3 and earlier) gets its line, which the hooks read.
|
|
111
|
+
if (!(await readFile(endpointPath(root), "utf8")).includes(`${PROOF_KEY}=`))
|
|
112
|
+
await writeEndpoint(root, existing);
|
|
108
113
|
return existing;
|
|
114
|
+
}
|
|
109
115
|
const endpoint = { port: await freeLoopbackPort(), token: randomBytes(32).toString("hex") };
|
|
110
116
|
await writeEndpoint(root, endpoint);
|
|
111
117
|
return endpoint;
|
|
112
118
|
}
|
|
119
|
+
/** This package's version, which the listener names in its probe answer so an older one is found and replaced. */
|
|
120
|
+
export const HEEDVANE_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
113
121
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
122
|
+
* The version of Heedvane's listener at `endpoint` ("" for one from before 0.3.4, which names none), or null when it
|
|
123
|
+
* does not answer within 750 ms. The probe is a GET, which the listener refuses with 405 and {@link PROBE_REFUSAL}
|
|
124
|
+
* before it reads a token or records anything, so probing never adds an event, and another program on the port,
|
|
125
|
+
* which answers otherwise, is never taken for it (nor sent its token).
|
|
116
126
|
*/
|
|
117
|
-
export async function
|
|
127
|
+
export async function listenerVersion(endpoint) {
|
|
128
|
+
return (await probeListener(endpoint))?.version ?? null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The one decision of which listener should run. Nothing answers: start one. An older version: replace it. A newer
|
|
132
|
+
* one: keep it (this CLI is the older). The same version on other settings: restart it. Else keep it. `current` is
|
|
133
|
+
* null when no settings parse at all: a running listener is then kept, so a bad heed.yaml never stops the checks.
|
|
134
|
+
*/
|
|
135
|
+
export function listenerVerdict(running, current) {
|
|
136
|
+
if (running === null)
|
|
137
|
+
return "start";
|
|
138
|
+
// Whatever its version: a listener started now would have nothing to run.
|
|
139
|
+
if (current === null)
|
|
140
|
+
return "keep";
|
|
141
|
+
// shortcut: digit-wise compare, so a prerelease (0.4.0-beta.1) counts as newer than its release; use semver once prereleases ship.
|
|
142
|
+
const order = running.version.localeCompare(HEEDVANE_VERSION, "en", { numeric: true });
|
|
143
|
+
if (order < 0)
|
|
144
|
+
return "replace";
|
|
145
|
+
if (order > 0)
|
|
146
|
+
return "newer";
|
|
147
|
+
return current !== null && running.settings !== current ? "restart" : "keep";
|
|
148
|
+
}
|
|
149
|
+
/** The listener's probe answer: its version (see {@link listenerVersion}) and the settings it runs (0.3.4 on). */
|
|
150
|
+
export async function probeListener(endpoint) {
|
|
118
151
|
try {
|
|
119
|
-
await fetch(`http://127.0.0.1:${endpoint.port}/hook/claude`, { signal: AbortSignal.timeout(750) });
|
|
120
|
-
|
|
152
|
+
const answer = await fetch(`http://127.0.0.1:${endpoint.port}/hook/claude`, { signal: AbortSignal.timeout(750) });
|
|
153
|
+
const body = (answer.status === 405 ? await answer.json() : null);
|
|
154
|
+
if (body?.error !== PROBE_REFUSAL)
|
|
155
|
+
return null;
|
|
156
|
+
const settings = typeof body.settings === "string" ? body.settings : undefined;
|
|
157
|
+
// One that names its settings (0.3.4 on) proves it is this machine's listener; an older one cannot, and is replaced
|
|
158
|
+
// (the hooks never take its answers: src/guard.ts).
|
|
159
|
+
if (settings !== undefined && answer.headers.get(PROOF_HEADER) !== (await listenerProof(endpoint.token)))
|
|
160
|
+
return null;
|
|
161
|
+
return { version: typeof body.version === "string" ? body.version : "", settings };
|
|
121
162
|
}
|
|
122
163
|
catch {
|
|
123
|
-
return
|
|
164
|
+
return null; // nothing answers, or not in the listener's JSON: not the listener
|
|
124
165
|
}
|
|
125
166
|
}
|
|
167
|
+
/** Whether Heedvane's listener, of any version, answers at `endpoint` (see {@link listenerVersion}). */
|
|
168
|
+
export async function pingListener(endpoint) {
|
|
169
|
+
return (await listenerVersion(endpoint)) !== null;
|
|
170
|
+
}
|
|
@@ -3,6 +3,7 @@ import { type Backend } from "../backend/client.js";
|
|
|
3
3
|
export declare const QUEUE_MAX = 5000;
|
|
4
4
|
/** Held while the route refuses this machine outright (403) or does not exist (404): nothing will change soon. */
|
|
5
5
|
export declare const REFUSED_QUEUE_MAX = 500;
|
|
6
|
+
export declare const SEND_TIMEOUT_MS = 10000;
|
|
6
7
|
/** The dead-letter file turns over at this size (the old one is kept once as `.1`), so a permanent refusal cannot fill the disk. */
|
|
7
8
|
export declare const DEAD_LETTER_MAX_BYTES: number;
|
|
8
9
|
/** How often an item refused on its own is retried before it is dead-lettered. */
|
|
@@ -31,6 +32,11 @@ export interface OutboxContext {
|
|
|
31
32
|
log: (message: string) => void;
|
|
32
33
|
/** The clock the backoff reads (tests move it). */
|
|
33
34
|
now?: () => number;
|
|
35
|
+
/**
|
|
36
|
+
* Resolves once the outbox file is this outbox's alone: the listener it replaced has exited. Until then items are
|
|
37
|
+
* kept in memory and sent at once, and the file is read and written after (the default: free now).
|
|
38
|
+
*/
|
|
39
|
+
fileFree?: Promise<void>;
|
|
34
40
|
}
|
|
35
41
|
/** An outbox for one route, loaded with whatever an earlier listener left unsent. Without a backend it only queues. */
|
|
36
|
-
export declare function outbox<T>(route: OutboxRoute, { root, backend, log, now }: OutboxContext): Promise<Outbox<T>>;
|
|
42
|
+
export declare function outbox<T>(route: OutboxRoute, { root, backend, log, now, fileFree }: OutboxContext): Promise<Outbox<T>>;
|
package/dist/listener/forward.js
CHANGED
|
@@ -34,7 +34,7 @@ const SHED_FRACTION = 0.1;
|
|
|
34
34
|
/** Kept batches wait this long before the next try, doubling per kept flush up to {@link BACKOFF_MAX_MS}. */
|
|
35
35
|
const BACKOFF_BASE_MS = 5_000;
|
|
36
36
|
const BACKOFF_MAX_MS = 5 * 60_000;
|
|
37
|
-
const SEND_TIMEOUT_MS = 10_000;
|
|
37
|
+
export const SEND_TIMEOUT_MS = 10_000;
|
|
38
38
|
/** The dead-letter file turns over at this size (the old one is kept once as `.1`), so a permanent refusal cannot fill the disk. */
|
|
39
39
|
export const DEAD_LETTER_MAX_BYTES = 50 * 1024 * 1024;
|
|
40
40
|
/** How often an item refused on its own is retried before it is dead-lettered. */
|
|
@@ -44,34 +44,61 @@ const KEEP_STATUSES = new Set([401, 403, 404, 408, 429]);
|
|
|
44
44
|
/** Steering's delivery reports: at most 100 per post and 256 KiB per body (a little headroom for the wrapper). */
|
|
45
45
|
export const DELIVERIES_ROUTE = { name: "deliveries", path: "/api/agent/steering/deliveries", key: "deliveries", maxItems: 100, maxBytes: 250 * 1024 };
|
|
46
46
|
/** An outbox for one route, loaded with whatever an earlier listener left unsent. Without a backend it only queues. */
|
|
47
|
-
export async function outbox(route, { root, backend, log, now = Date.now }) {
|
|
47
|
+
export async function outbox(route, { root, backend, log, now = Date.now, fileFree }) {
|
|
48
48
|
const dir = spoolDir(root);
|
|
49
49
|
const file = join(dir, `${route.name}.outbox.jsonl`);
|
|
50
50
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
51
|
-
const
|
|
51
|
+
const queue = [];
|
|
52
|
+
const loaded = async () => {
|
|
53
|
+
const { entries, endsCleanly } = await readOutbox(file);
|
|
54
|
+
// A crash mid-append left a line with no newline: the next append would glue onto it and be lost with it.
|
|
55
|
+
if (!endsCleanly)
|
|
56
|
+
await writeFileAtomic(file, entries.map((entry) => `${JSON.stringify(entry)}\n`).join(""), { mode: 0o600 });
|
|
57
|
+
queue.unshift(...entries); // older than anything added meanwhile
|
|
58
|
+
};
|
|
52
59
|
const refusals = new Map();
|
|
53
|
-
// File writes in order, so a rewrite never races an append.
|
|
54
|
-
|
|
60
|
+
// File writes in order, so a rewrite never races an append; the first is the load.
|
|
61
|
+
const load = fileFree === undefined ? loaded() : fileFree.then(loaded);
|
|
62
|
+
if (fileFree === undefined)
|
|
63
|
+
await load;
|
|
64
|
+
let fileIsFree = fileFree === undefined;
|
|
65
|
+
// A file the load after a handover could not read is never written: what the replaced listener left waits for the next start.
|
|
66
|
+
let unreadable = false;
|
|
67
|
+
let writes = load.then(() => void (fileIsFree = true), (err) => {
|
|
68
|
+
unreadable = true;
|
|
69
|
+
log(`[heedvane] could not open the steering delivery outbox after the handover (${errorMessage(err)}); reports are sent from memory, and the file is left for the next start`);
|
|
70
|
+
});
|
|
55
71
|
// One failed write (a full disk, a permission) fails its own caller only; the next write still runs.
|
|
56
72
|
const persist = (write) => {
|
|
57
|
-
const next = writes.then(write);
|
|
73
|
+
const next = writes.then(() => (unreadable ? undefined : write()));
|
|
58
74
|
writes = next.catch(() => undefined);
|
|
59
75
|
return next;
|
|
60
76
|
};
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
// Until the file is free its writes wait behind the load, and nothing waits on them: a send goes out at once, and a failed write is logged.
|
|
78
|
+
const unlessWaiting = (written) => {
|
|
79
|
+
if (fileIsFree)
|
|
80
|
+
return written;
|
|
81
|
+
void written.catch((err) => {
|
|
82
|
+
log(`[heedvane] ${route.path}: could not update the outbox: ${errorMessage(err)}`);
|
|
83
|
+
return undefined; // logged: the items were sent from memory, or wait there for the next flush
|
|
84
|
+
});
|
|
85
|
+
return Promise.resolve();
|
|
86
|
+
};
|
|
87
|
+
const rewrite = () => unlessWaiting(persist(() => writeFileAtomic(file, queue.map((entry) => `${JSON.stringify(entry)}\n`).join(""), { mode: 0o600 })));
|
|
88
|
+
// The dead-letter file is not the outbox: its writes never wait behind the outbox's load, and the caller waits for them.
|
|
89
|
+
let deadLetters = Promise.resolve();
|
|
65
90
|
const deadLetter = (entries, status, json) => {
|
|
66
91
|
log(`[heedvane] dead-lettered ${entries.length} item(s) ${route.path} (${status ?? "no answer"}): ${JSON.stringify(json).slice(0, 500)}`);
|
|
67
92
|
const lines = entries.map((entry) => `${JSON.stringify({ deadAt: new Date().toISOString(), status, answer: json, ...entry })}\n`).join("");
|
|
68
93
|
const deadFile = join(dir, `${route.name}.dead-letter.jsonl`);
|
|
69
|
-
|
|
94
|
+
const next = deadLetters.then(async () => {
|
|
70
95
|
const size = await stat(deadFile).then((info) => info.size, () => 0);
|
|
71
96
|
if (size >= DEAD_LETTER_MAX_BYTES)
|
|
72
97
|
await rename(deadFile, `${deadFile}.1`);
|
|
73
98
|
await appendFile(deadFile, lines, { mode: 0o600 });
|
|
74
99
|
});
|
|
100
|
+
deadLetters = next.catch(() => undefined);
|
|
101
|
+
return next;
|
|
75
102
|
};
|
|
76
103
|
// A streak of kept flushes: back off, say so once, and hold fewer items while the route refuses outright.
|
|
77
104
|
const kept = { streak: 0, until: 0, status: null, shed: 0 };
|
|
@@ -94,7 +121,8 @@ export async function outbox(route, { root, backend, log, now = Date.now }) {
|
|
|
94
121
|
await (shedding ??= shedOldest().finally(() => (shedding = null)));
|
|
95
122
|
const entry = { id: isRecord(item) && typeof item["id"] === "string" ? item["id"] : randomUUID(), item, identity };
|
|
96
123
|
queue.push(entry);
|
|
97
|
-
|
|
124
|
+
// Before the file is free the item is sent from memory at once; its line is written once the file is.
|
|
125
|
+
return unlessWaiting(persist(() => appendFile(file, `${JSON.stringify(entry)}\n`, { mode: 0o600 })));
|
|
98
126
|
};
|
|
99
127
|
const keptFlush = (status) => {
|
|
100
128
|
kept.streak += 1;
|
package/dist/listener/serve.d.ts
CHANGED
|
@@ -1,2 +1,39 @@
|
|
|
1
|
+
import { type HeedvaneConfig } from "../heed-file.js";
|
|
2
|
+
import { type Endpoint } from "./endpoint.js";
|
|
1
3
|
import { type StartedListener } from "./server.js";
|
|
4
|
+
/** A listener of this version already runs at the port with the settings this one would run (or a newer one runs): nothing to start. */
|
|
5
|
+
export declare class ListenerAlreadyRuns extends Error {
|
|
6
|
+
readonly version: string;
|
|
7
|
+
constructor(version: string, newer: boolean);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The listener for `home`, and the one place a listener starts (`listener start`, which `ensureListener` and the hooks
|
|
11
|
+
* run). It reads heed.yaml once, prepares everything a hook's answer needs, and only then takes the port: a listener
|
|
12
|
+
* there that {@link listenerVerdict} says to keep makes it throw {@link ListenerAlreadyRuns}; any other is asked to stop,
|
|
13
|
+
* and this one binds as soon as the port is free. Rejects when the port stays taken.
|
|
14
|
+
*/
|
|
2
15
|
export declare function serveHooks(home: string, out: (line: string) => void): Promise<StartedListener>;
|
|
16
|
+
/** Asks the listener at `endpoint` to stop: its answer's status (null without one) and the pid it names (0.3.4 on). */
|
|
17
|
+
export declare function askToStop(endpoint: Endpoint, reason: string): Promise<{
|
|
18
|
+
status: number | null;
|
|
19
|
+
pid: number | null;
|
|
20
|
+
}>;
|
|
21
|
+
/** Whether process `pid` still runs. */
|
|
22
|
+
export declare function isAlive(pid: number): boolean;
|
|
23
|
+
/** The settings a listener started now runs, read once: heed.yaml's text, its `heedvane` section and their hash. */
|
|
24
|
+
export interface ListenerSettings {
|
|
25
|
+
config: HeedvaneConfig;
|
|
26
|
+
text: string;
|
|
27
|
+
/** Hash of the text and the sign-in, which the listener names in its probe answer. */
|
|
28
|
+
hash: string;
|
|
29
|
+
/** Why heed.yaml cannot be used (it cannot be read, or does not parse), when these are the last good settings instead. */
|
|
30
|
+
unparseable?: string;
|
|
31
|
+
}
|
|
32
|
+
/** Where a listener keeps the heed.yaml it last started with, which it runs while heed.yaml does not parse. */
|
|
33
|
+
export declare function lastGoodPath(root: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* The settings a listener started now would run: heed.yaml (the template's when there is none), else, when it does not
|
|
36
|
+
* parse, the copy a listener last started with. Throws when neither parses. The hash covers the same text that was
|
|
37
|
+
* parsed and the sign-in, so a running listener's report compares with it exactly.
|
|
38
|
+
*/
|
|
39
|
+
export declare function settingsToRun(root: string): Promise<ListenerSettings>;
|