heedvane 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +41 -9
  2. package/dist/backend/credentials.d.ts +2 -0
  3. package/dist/backend/credentials.js +13 -0
  4. package/dist/backend/repo-remote.js +2 -1
  5. package/dist/backend/sign-in-change.js +3 -2
  6. package/dist/cli.d.ts +2 -1
  7. package/dist/cli.js +325 -118
  8. package/dist/clients/definitions.d.ts +7 -3
  9. package/dist/clients/definitions.js +27 -7
  10. package/dist/clients/hermes-plugin.d.ts +5 -20
  11. package/dist/clients/hermes-plugin.js +97 -71
  12. package/dist/clients/hook-files.d.ts +22 -0
  13. package/dist/clients/hook-files.js +39 -0
  14. package/dist/clients/pi-extension.d.ts +9 -0
  15. package/dist/clients/pi-extension.js +41 -0
  16. package/dist/commands/heedvane.js +18 -3
  17. package/dist/commands/mcp-stub.d.ts +1 -0
  18. package/dist/commands/mcp-stub.js +44 -0
  19. package/dist/dispatchers/posix.d.ts +16 -19
  20. package/dist/dispatchers/posix.js +71 -91
  21. package/dist/doctor.d.ts +2 -0
  22. package/dist/doctor.js +96 -28
  23. package/dist/guard.d.ts +73 -0
  24. package/dist/guard.js +302 -0
  25. package/dist/heed-file.d.ts +5 -2
  26. package/dist/heed-file.js +7 -7
  27. package/dist/hook-fallback.d.ts +73 -0
  28. package/dist/hook-fallback.js +61 -0
  29. package/dist/installer.d.ts +5 -5
  30. package/dist/installer.js +13 -12
  31. package/dist/listener/background.d.ts +48 -4
  32. package/dist/listener/background.js +153 -19
  33. package/dist/listener/endpoint.d.ts +26 -3
  34. package/dist/listener/endpoint.js +55 -10
  35. package/dist/listener/forward.d.ts +7 -1
  36. package/dist/listener/forward.js +40 -12
  37. package/dist/listener/serve.d.ts +37 -0
  38. package/dist/listener/serve.js +196 -32
  39. package/dist/listener/server.d.ts +9 -0
  40. package/dist/listener/server.js +59 -22
  41. package/dist/listener/spool.d.ts +2 -17
  42. package/dist/listener/spool.js +6 -3
  43. package/dist/mcp.d.ts +23 -4
  44. package/dist/mcp.js +124 -22
  45. package/dist/plan.d.ts +35 -6
  46. package/dist/plan.js +125 -40
  47. package/dist/render.js +2 -1
  48. package/dist/reply/harness-output.d.ts +9 -0
  49. package/dist/reply/harness-output.js +13 -0
  50. package/dist/skill/content.d.ts +2 -2
  51. package/dist/skill/content.js +6 -3
  52. package/dist/status.js +1 -1
  53. package/dist/steering/rules.js +4 -2
  54. package/dist/uninstall.d.ts +7 -2
  55. package/dist/uninstall.js +49 -44
  56. package/package.json +10 -3
  57. package/skills/heedvane-hooks/SKILL.md +3 -3
  58. package/skills/heedvane-hooks/references/events.md +3 -0
  59. package/dist/dispatchers/windows.d.ts +0 -15
  60. package/dist/dispatchers/windows.js +0 -50
@@ -1,8 +1,52 @@
1
+ import { type Endpoint } from "./endpoint.js";
2
+ /** `listener start`'s exit code when the port stays taken: the Heedvane listener there did not stop when asked. */
3
+ export declare const PORT_TAKEN_EXIT = 3;
1
4
  /** Where a background listener writes what it prints. */
2
5
  export declare function listenerLogPath(root: string): string;
6
+ /** What {@link ensureListener} found and did. */
7
+ export type ListenerOutcome =
8
+ /** No endpoint.env: Heedvane's hooks were never set up on this machine, so no listener is wanted. */
9
+ {
10
+ kind: "not set up";
11
+ }
12
+ /** One of this version already ran with the current settings. */
13
+ | {
14
+ kind: "running";
15
+ }
16
+ /** None ran, an older version ran, or this version on other settings: one with the current settings answers now. */
17
+ | {
18
+ kind: "started" | "replaced" | "restarted";
19
+ }
20
+ /** A newer version runs and was kept, as this CLI is the older; `earlierSettings` when it runs other settings than these. */
21
+ | {
22
+ kind: "newer";
23
+ version: string;
24
+ earlierSettings: boolean;
25
+ }
26
+ /** The one at the port did not stop when asked (0.2.0 and earlier have no way to be asked). */
27
+ | {
28
+ kind: "did not stop";
29
+ port: number;
30
+ }
31
+ /** None answers with the current settings; why, or the listener's log says why. */
32
+ | {
33
+ kind: "did not start";
34
+ log: string;
35
+ why?: string;
36
+ };
3
37
  /**
4
- * Starts `listener start` as a process that outlives this one, unless a listener already answers.
5
- * True once the listener answers its probe; false when it did not within 5 s (its log says why).
6
- * It stops at logout or restart; the hooks then start it again on the fly (src/dispatchers/posix.ts).
38
+ * The one owner of which listener runs: it compares what the running listener reports about itself (its version and
39
+ * the hash of its settings) with the settings a listener would run now, and acts on {@link listenerVerdict}. A listener
40
+ * to start, replace or restart is started in the background with this package's own CLI (`listener start`, which takes
41
+ * the port over from the old one), and this waits until a listener it can keep answers, whatever version won the port.
42
+ * Every command that changes heed.yaml or the sign-in, `listener restart` and `run` call it last.
7
43
  */
8
- export declare function startListenerInBackground(home: string, cliPath: string, env: NodeJS.ProcessEnv): Promise<boolean>;
44
+ export declare function ensureListener(home: string, cliPath: string, env: NodeJS.ProcessEnv): Promise<ListenerOutcome>;
45
+ /** What stops a listener that does not stop when asked, ready to paste. */
46
+ export declare function stopCommand(port: number): string;
47
+ /**
48
+ * Asks the listener at `endpoint` to stop (POST /shutdown) and waits until its process has exited (until it no longer
49
+ * answers, for one older than 0.3.4, which does not name its pid), as long as its last reports may take to send.
50
+ * "no stop route" for a listener from 0.2.0 or earlier, which runs until logout or restart.
51
+ */
52
+ export declare function stopRunningListener(endpoint: Endpoint, reason?: string): Promise<"stopped" | "no stop route" | "still running">;
@@ -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 { ensureEndpoint, pingListener } from "./endpoint.js";
6
- /** How long a freshly started listener gets to answer before the start counts as failed. */
7
- const START_WAIT_MS = 5_000;
8
- const START_POLL_MS = 250;
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
- * Starts `listener start` as a process that outlives this one, unless a listener already answers.
15
- * True once the listener answers its probe; false when it did not within 5 s (its log says why).
16
- * It stops at logout or restart; the hooks then start it again on the fly (src/dispatchers/posix.ts).
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 startListenerInBackground(home, cliPath, env) {
29
+ export async function ensureListener(home, cliPath, env) {
19
30
  const root = join(home, ".heed");
20
- const endpoint = await ensureEndpoint(root);
21
- if (await pingListener(endpoint))
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
- const log = openSync(listenerLogPath(root), "a", 0o600);
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
- spawn(process.execPath, [cliPath, "listener", "start"], { detached: true, stdio: ["ignore", log, log], env: { ...env, HEED_HOME: home } }).unref();
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
- closeSync(log);
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
- for (let waited = 0; waited < START_WAIT_MS; waited += START_POLL_MS) {
31
- await delay(START_POLL_MS);
32
- if (await pingListener(endpoint))
33
- return true;
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 false;
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 token lines. */
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
- * Whether a listener answers at `endpoint` within 750 ms. The probe is a GET, which the listener refuses
24
- * with 405 before it reads a token or records anything, so probing never adds an event.
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 token lines. */
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
- * Whether a listener answers at `endpoint` within 750 ms. The probe is a GET, which the listener refuses
115
- * with 405 before it reads a token or records anything, so probing never adds an event.
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 pingListener(endpoint) {
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
- return true;
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 false;
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>>;
@@ -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 { entries: queue, endsCleanly } = await readOutbox(file);
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
- let writes = Promise.resolve();
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
- const rewrite = () => persist(() => writeFileAtomic(file, queue.map((entry) => `${JSON.stringify(entry)}\n`).join(""), { mode: 0o600 }));
62
- // A crash mid-append left a line with no newline: the next append would glue onto it and be lost with it.
63
- if (!endsCleanly)
64
- await rewrite();
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
- return persist(async () => {
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
- return persist(() => appendFile(file, `${JSON.stringify(entry)}\n`, { mode: 0o600 }));
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;
@@ -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>;