heedvane 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -2
- package/dist/apply.d.ts +104 -0
- package/dist/apply.js +388 -0
- package/dist/backend/client.d.ts +40 -0
- package/dist/backend/client.js +32 -0
- package/dist/backend/credentials.d.ts +15 -0
- package/dist/backend/credentials.js +76 -0
- package/dist/backend/login.d.ts +33 -0
- package/dist/backend/login.js +265 -0
- package/dist/backend/repo-remote.d.ts +15 -0
- package/dist/backend/repo-remote.js +61 -0
- package/dist/backend/sign-in-change.d.ts +12 -0
- package/dist/backend/sign-in-change.js +72 -0
- package/dist/best-effort.d.ts +4 -0
- package/dist/best-effort.js +25 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +544 -0
- package/dist/clients/definitions.d.ts +113 -0
- package/dist/clients/definitions.js +427 -0
- package/dist/clients/hermes-plugin.d.ts +33 -0
- package/dist/clients/hermes-plugin.js +247 -0
- package/dist/commands/heedvane.d.ts +15 -0
- package/dist/commands/heedvane.js +74 -0
- package/dist/commands/memory.d.ts +10 -0
- package/dist/commands/memory.js +106 -0
- package/dist/config-doc.d.ts +97 -0
- package/dist/config-doc.js +465 -0
- package/dist/dispatchers/posix.d.ts +32 -0
- package/dist/dispatchers/posix.js +191 -0
- package/dist/dispatchers/snippets.d.ts +6 -0
- package/dist/dispatchers/snippets.js +39 -0
- package/dist/dispatchers/windows.d.ts +15 -0
- package/dist/dispatchers/windows.js +50 -0
- package/dist/doctor.d.ts +37 -0
- package/dist/doctor.js +316 -0
- package/dist/events/contract.d.ts +54 -0
- package/dist/events/contract.js +87 -0
- package/dist/events/normalize.d.ts +36 -0
- package/dist/events/normalize.js +143 -0
- package/dist/guards.d.ts +18 -0
- package/dist/guards.js +28 -0
- package/dist/heed-file.d.ts +67 -0
- package/dist/heed-file.js +295 -0
- package/dist/listener/endpoint.d.ts +21 -0
- package/dist/listener/endpoint.js +112 -0
- package/dist/listener/forward.d.ts +36 -0
- package/dist/listener/forward.js +257 -0
- package/dist/listener/serve.d.ts +2 -0
- package/dist/listener/serve.js +155 -0
- package/dist/listener/server.d.ts +48 -0
- package/dist/listener/server.js +251 -0
- package/dist/listener/spool.d.ts +28 -0
- package/dist/listener/spool.js +60 -0
- package/dist/mcp-server.d.ts +9 -0
- package/dist/mcp-server.js +187 -0
- package/dist/mcp.d.ts +57 -0
- package/dist/mcp.js +83 -0
- package/dist/memory/private-memory.d.ts +12 -0
- package/dist/memory/private-memory.js +54 -0
- package/dist/memory/table-of-contents.d.ts +10 -0
- package/dist/memory/table-of-contents.js +80 -0
- package/dist/memory/team-memory.d.ts +60 -0
- package/dist/memory/team-memory.js +54 -0
- package/dist/plan.d.ts +77 -0
- package/dist/plan.js +288 -0
- package/dist/render.d.ts +30 -0
- package/dist/render.js +131 -0
- package/dist/reply/harness-output.d.ts +57 -0
- package/dist/reply/harness-output.js +88 -0
- package/dist/reply/reply.d.ts +65 -0
- package/dist/reply/reply.js +169 -0
- package/dist/reply/verdict.d.ts +28 -0
- package/dist/reply/verdict.js +17 -0
- package/dist/skill/bundle.d.ts +23 -0
- package/dist/skill/bundle.js +102 -0
- package/dist/skill/content.d.ts +16 -0
- package/dist/skill/content.js +182 -0
- package/dist/skills/approved.d.ts +31 -0
- package/dist/skills/approved.js +123 -0
- package/dist/skills/check.d.ts +21 -0
- package/dist/skills/check.js +83 -0
- package/dist/state.d.ts +51 -0
- package/dist/state.js +118 -0
- package/dist/steering/rules.d.ts +49 -0
- package/dist/steering/rules.js +165 -0
- package/dist/steering/steer.d.ts +29 -0
- package/dist/steering/steer.js +64 -0
- package/dist/steering/violations.d.ts +27 -0
- package/dist/steering/violations.js +24 -0
- package/dist/uninstall.d.ts +23 -0
- package/dist/uninstall.js +159 -0
- package/package.json +34 -4
- package/skills/heedvane-hooks/SKILL.md +87 -0
- package/skills/heedvane-hooks/references/events.md +69 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import writeFileAtomic from "write-file-atomic";
|
|
4
|
+
import { isScalar, parse, parseDocument } from "yaml";
|
|
5
|
+
import { errorMessage, isRecord } from "./guards.js";
|
|
6
|
+
/** Error raised for any invalid heed.yaml. The message is user-facing: keep it precise. */
|
|
7
|
+
class HeedFileError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(`invalid heed.yaml: ${message}`);
|
|
10
|
+
this.name = "HeedFileError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const TOP_LEVEL_KEYS = [
|
|
14
|
+
"schemaVersion",
|
|
15
|
+
"root",
|
|
16
|
+
"listener",
|
|
17
|
+
"agents",
|
|
18
|
+
"install",
|
|
19
|
+
"gating",
|
|
20
|
+
"heedvane",
|
|
21
|
+
];
|
|
22
|
+
/**
|
|
23
|
+
* The commented control file written by `npx heedvane init`.
|
|
24
|
+
* Kept loadable: loadHeedFile(DEFAULT_HEED_YAML_TEMPLATE) yields every default.
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_HEED_YAML_TEMPLATE = `# ~/.heed/heed.yaml — Heedvane agent hooks control file (v1)
|
|
27
|
+
schemaVersion: v1
|
|
28
|
+
|
|
29
|
+
# Where managed scripts and spool live. Default below; one install per machine.
|
|
30
|
+
root: ~/.heed
|
|
31
|
+
|
|
32
|
+
# The hook listener (Orca's pattern): loopback HTTP, token-authed, per-agent routes.
|
|
33
|
+
listener:
|
|
34
|
+
port: 0 # 0 = pick a free port at first start, persist to endpoint.env
|
|
35
|
+
spool: true # spool to disk when listener is unreachable (5 MB cap, 7-day TTL)
|
|
36
|
+
|
|
37
|
+
# Which agents to manage. \`auto\` = every detected agent on the machine.
|
|
38
|
+
agents: auto # or an explicit list: [claude, codex, warp]
|
|
39
|
+
|
|
40
|
+
# Which integrations to install per agent where supported.
|
|
41
|
+
install:
|
|
42
|
+
hooks: true # native hook config injection (skipped where the agent has none)
|
|
43
|
+
skill: true # heedvane-hooks skill bundle -> ~/.agents/skills/heedvane-hooks/
|
|
44
|
+
mcp: true # heedvane MCP server registration (universal fallback, incl. Warp)
|
|
45
|
+
|
|
46
|
+
# Session gating (Orca's env-gated dispatcher pattern).
|
|
47
|
+
gating:
|
|
48
|
+
mode: always # always = every session; pane = only sessions launched with HEED_SESSION env
|
|
49
|
+
|
|
50
|
+
# The Heedvane backend (credentials: npx heedvane auth, stored in ~/.heed/credentials.json).
|
|
51
|
+
heedvane:
|
|
52
|
+
url: https://app.heedvane.com
|
|
53
|
+
steering: true # ask the organization's steering rules before tool calls, prompts and stops
|
|
54
|
+
memory: true # add your private memories (~/.heed/memory, local only) at session start
|
|
55
|
+
skills:
|
|
56
|
+
enforce: true # deny skills that are not on the organization's approved list
|
|
57
|
+
strict: false # also require the local SKILL.md to match the approved revision
|
|
58
|
+
whenUnknown: allow # no synced list (run npx heedvane skills sync) or listener down: allow or deny
|
|
59
|
+
`;
|
|
60
|
+
function describeType(value) {
|
|
61
|
+
if (value === null)
|
|
62
|
+
return "null";
|
|
63
|
+
if (Array.isArray(value))
|
|
64
|
+
return "an array";
|
|
65
|
+
return typeof value;
|
|
66
|
+
}
|
|
67
|
+
function expectMapping(value, key) {
|
|
68
|
+
if (!isRecord(value)) {
|
|
69
|
+
throw new HeedFileError(`"${key}" must be a mapping, got ${describeType(value)}`);
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
function rejectUnknownKeys(value, allowed, where) {
|
|
74
|
+
const unknown = Object.keys(value).filter((k) => !allowed.includes(k));
|
|
75
|
+
if (unknown.length > 0) {
|
|
76
|
+
throw new HeedFileError(`"${where}" has unknown key(s): ${unknown.map((k) => JSON.stringify(k)).join(", ")} (allowed: ${allowed.join(", ")})`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function expectBool(value, key) {
|
|
80
|
+
if (typeof value !== "boolean") {
|
|
81
|
+
throw new HeedFileError(`"${key}" must be a boolean, got ${describeType(value)}`);
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
function validateListener(raw) {
|
|
86
|
+
const defaults = { port: 0, spool: true };
|
|
87
|
+
if (raw === undefined)
|
|
88
|
+
return defaults;
|
|
89
|
+
const map = expectMapping(raw, "listener");
|
|
90
|
+
rejectUnknownKeys(map, ["port", "spool"], "listener");
|
|
91
|
+
const out = defaults;
|
|
92
|
+
if (map["port"] !== undefined) {
|
|
93
|
+
const port = map["port"];
|
|
94
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < 0) {
|
|
95
|
+
throw new HeedFileError(`"listener.port" must be an integer >= 0, got ${describeType(port)}`);
|
|
96
|
+
}
|
|
97
|
+
out.port = port;
|
|
98
|
+
}
|
|
99
|
+
if (map["spool"] !== undefined)
|
|
100
|
+
out.spool = expectBool(map["spool"], "listener.spool");
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
function validateAgents(raw) {
|
|
104
|
+
if (raw === undefined)
|
|
105
|
+
return "auto";
|
|
106
|
+
if (raw === "auto")
|
|
107
|
+
return "auto";
|
|
108
|
+
if (!Array.isArray(raw)) {
|
|
109
|
+
throw new HeedFileError(`"agents" must be "auto" or a list of agent ids, got ${describeType(raw)}`);
|
|
110
|
+
}
|
|
111
|
+
for (const entry of raw) {
|
|
112
|
+
if (typeof entry !== "string" || entry.trim().length === 0) {
|
|
113
|
+
throw new HeedFileError(`"agents" entries must be non-empty strings, got ${JSON.stringify(entry) ?? describeType(entry)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return raw.map((entry) => entry.trim());
|
|
117
|
+
}
|
|
118
|
+
function validateInstall(raw) {
|
|
119
|
+
const defaults = { hooks: true, skill: true, mcp: true };
|
|
120
|
+
if (raw === undefined)
|
|
121
|
+
return defaults;
|
|
122
|
+
const map = expectMapping(raw, "install");
|
|
123
|
+
rejectUnknownKeys(map, ["hooks", "skill", "mcp"], "install");
|
|
124
|
+
const out = defaults;
|
|
125
|
+
if (map["hooks"] !== undefined)
|
|
126
|
+
out.hooks = expectBool(map["hooks"], "install.hooks");
|
|
127
|
+
if (map["skill"] !== undefined)
|
|
128
|
+
out.skill = expectBool(map["skill"], "install.skill");
|
|
129
|
+
if (map["mcp"] !== undefined)
|
|
130
|
+
out.mcp = expectBool(map["mcp"], "install.mcp");
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
function validateGating(raw) {
|
|
134
|
+
const defaults = { mode: "always" };
|
|
135
|
+
if (raw === undefined)
|
|
136
|
+
return defaults;
|
|
137
|
+
const map = expectMapping(raw, "gating");
|
|
138
|
+
rejectUnknownKeys(map, ["mode"], "gating");
|
|
139
|
+
const out = defaults;
|
|
140
|
+
if (map["mode"] !== undefined) {
|
|
141
|
+
const mode = map["mode"];
|
|
142
|
+
if (mode !== "pane" && mode !== "always") {
|
|
143
|
+
throw new HeedFileError(`"gating.mode" must be "pane" or "always", got ${JSON.stringify(mode)}`);
|
|
144
|
+
}
|
|
145
|
+
out.mode = mode;
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function validateSkills(raw) {
|
|
150
|
+
const out = { enforce: true, strict: false, whenUnknown: "allow" };
|
|
151
|
+
if (raw === undefined)
|
|
152
|
+
return out;
|
|
153
|
+
const map = expectMapping(raw, "heedvane.skills");
|
|
154
|
+
rejectUnknownKeys(map, ["enforce", "strict", "whenUnknown"], "heedvane.skills");
|
|
155
|
+
if (map["enforce"] !== undefined)
|
|
156
|
+
out.enforce = expectBool(map["enforce"], "heedvane.skills.enforce");
|
|
157
|
+
if (map["strict"] !== undefined)
|
|
158
|
+
out.strict = expectBool(map["strict"], "heedvane.skills.strict");
|
|
159
|
+
const whenUnknown = map["whenUnknown"];
|
|
160
|
+
if (whenUnknown !== undefined) {
|
|
161
|
+
if (whenUnknown !== "allow" && whenUnknown !== "deny") {
|
|
162
|
+
throw new HeedFileError(`"heedvane.skills.whenUnknown" must be "allow" or "deny", got ${JSON.stringify(whenUnknown)}`);
|
|
163
|
+
}
|
|
164
|
+
out.whenUnknown = whenUnknown;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
function validateHeedvane(raw) {
|
|
169
|
+
const out = { url: "https://app.heedvane.com", steering: true, memory: true, skills: validateSkills(undefined) };
|
|
170
|
+
if (raw === undefined)
|
|
171
|
+
return out;
|
|
172
|
+
const map = expectMapping(raw, "heedvane");
|
|
173
|
+
rejectUnknownKeys(map, ["url", "steering", "memory", "skills"], "heedvane");
|
|
174
|
+
const url = map["url"];
|
|
175
|
+
if (url !== undefined) {
|
|
176
|
+
if (typeof url !== "string" || !URL.canParse(url) || !/^https?:$/.test(new URL(url).protocol)) {
|
|
177
|
+
throw new HeedFileError(`"heedvane.url" must be an http(s) URL, got ${JSON.stringify(url)}`);
|
|
178
|
+
}
|
|
179
|
+
out.url = url.replace(/\/+$/, "");
|
|
180
|
+
}
|
|
181
|
+
if (map["steering"] !== undefined)
|
|
182
|
+
out.steering = expectBool(map["steering"], "heedvane.steering");
|
|
183
|
+
if (map["memory"] !== undefined)
|
|
184
|
+
out.memory = expectBool(map["memory"], "heedvane.memory");
|
|
185
|
+
out.skills = validateSkills(map["skills"]);
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Parse and validate a heed.yaml document, returning the fully normalized file.
|
|
190
|
+
* Throws {@link HeedFileError} (message prefixed "invalid heed.yaml:") on any problem:
|
|
191
|
+
* unparseable YAML, non-mapping root, missing/unknown schemaVersion, unknown keys
|
|
192
|
+
* (top-level or nested), or wrong-typed values.
|
|
193
|
+
*/
|
|
194
|
+
export function loadHeedFile(text) {
|
|
195
|
+
let raw;
|
|
196
|
+
try {
|
|
197
|
+
raw = parse(text);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
const reason = errorMessage(err);
|
|
201
|
+
throw new HeedFileError(`could not parse YAML — ${reason}`);
|
|
202
|
+
}
|
|
203
|
+
if (!isRecord(raw)) {
|
|
204
|
+
throw new HeedFileError(`expected a top-level mapping, got ${describeType(raw)}`);
|
|
205
|
+
}
|
|
206
|
+
const schemaVersion = raw["schemaVersion"];
|
|
207
|
+
if (schemaVersion === undefined) {
|
|
208
|
+
throw new HeedFileError(`missing required key "schemaVersion" (expected "v1")`);
|
|
209
|
+
}
|
|
210
|
+
if (schemaVersion !== "v1") {
|
|
211
|
+
throw new HeedFileError(`unsupported schemaVersion ${JSON.stringify(schemaVersion)} — this CLI supports "v1" only (key: schemaVersion)`);
|
|
212
|
+
}
|
|
213
|
+
rejectUnknownKeys(raw, TOP_LEVEL_KEYS, "top level");
|
|
214
|
+
const root = raw["root"];
|
|
215
|
+
if (root !== undefined && (typeof root !== "string" || root.trim().length === 0)) {
|
|
216
|
+
throw new HeedFileError(`"root" must be a non-empty string, got ${describeType(root)}`);
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
schemaVersion: "v1",
|
|
220
|
+
root: root === undefined ? "~/.heed" : root,
|
|
221
|
+
listener: validateListener(raw["listener"]),
|
|
222
|
+
agents: validateAgents(raw["agents"]),
|
|
223
|
+
install: validateInstall(raw["install"]),
|
|
224
|
+
gating: validateGating(raw["gating"]),
|
|
225
|
+
heedvane: validateHeedvane(raw["heedvane"]),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** `text` with `heedvane.url` set to `url` (`connect --server`); the file's comments stay. Validate with {@link loadHeedFile}. */
|
|
229
|
+
export function withServerUrl(text, url) {
|
|
230
|
+
const document = parseDocument(text);
|
|
231
|
+
const current = document.getIn(["heedvane", "url"], true);
|
|
232
|
+
if (isScalar(current))
|
|
233
|
+
current.value = url;
|
|
234
|
+
else
|
|
235
|
+
document.setIn(["heedvane", "url"], url);
|
|
236
|
+
return String(document);
|
|
237
|
+
}
|
|
238
|
+
/** Keys earlier versions wrote into heed.yaml that nothing reads any more (policies are assigned on the server). */
|
|
239
|
+
const RETIRED_KEYS = [["heedvane", "steeringTimeoutMs"], ["policy"]];
|
|
240
|
+
const toStderr = (line) => void process.stderr.write(`${line}\n`);
|
|
241
|
+
/**
|
|
242
|
+
* Load the heed.yaml at `path`. A retired key an earlier version wrote (`init` put `steeringTimeoutMs`
|
|
243
|
+
* in every heed.yaml) is removed from the file in place, its value never read, and one line says so; if
|
|
244
|
+
* the file cannot be rewritten the key is ignored in memory and the line says that instead.
|
|
245
|
+
*/
|
|
246
|
+
export function loadHeedFileAt(path, log = toStderr) {
|
|
247
|
+
const text = readFileSync(path, "utf8");
|
|
248
|
+
const document = parseDocument(text);
|
|
249
|
+
const retired = document.errors.length > 0 ? [] : RETIRED_KEYS.filter((path) => document.hasIn(path));
|
|
250
|
+
if (retired.length === 0)
|
|
251
|
+
return loadHeedFile(text);
|
|
252
|
+
const names = retired.map((path) => path.join(".")).join(", ");
|
|
253
|
+
for (const path of retired)
|
|
254
|
+
document.deleteIn(path);
|
|
255
|
+
const refusal = rewriteInPlace(path, document);
|
|
256
|
+
if (refusal === null) {
|
|
257
|
+
log(`[heedvane] removed ${names} from ${path}: no hook waits on Heedvane any more`);
|
|
258
|
+
return loadHeedFile(String(document));
|
|
259
|
+
}
|
|
260
|
+
// The file stays as it was (an alias to the removed key cannot be written back, or the disk refused): load it without the key.
|
|
261
|
+
log(`[heedvane] could not remove ${names} from ${path} (${errorMessage(refusal)}); it is ignored`);
|
|
262
|
+
return loadHeedFile(JSON.stringify(withoutKeys(parse(text), retired)));
|
|
263
|
+
}
|
|
264
|
+
/** Writes the document over `path`; the error that stopped it, or null. */
|
|
265
|
+
function rewriteInPlace(path, document) {
|
|
266
|
+
try {
|
|
267
|
+
writeFileAtomic.sync(path, String(document), { mode: 0o600 });
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
return err ?? new Error("write refused");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** A parsed heed.yaml less the retired keys. */
|
|
275
|
+
function withoutKeys(value, retired) {
|
|
276
|
+
if (!isRecord(value))
|
|
277
|
+
return value;
|
|
278
|
+
const kept = Object.fromEntries(Object.entries(value).filter(([key]) => !retired.some((path) => path.length === 1 && path[0] === key)));
|
|
279
|
+
const heedvane = kept["heedvane"];
|
|
280
|
+
if (!isRecord(heedvane))
|
|
281
|
+
return kept;
|
|
282
|
+
const nested = retired.filter((path) => path.length === 2 && path[0] === "heedvane").map((path) => path[1]);
|
|
283
|
+
return { ...kept, heedvane: Object.fromEntries(Object.entries(heedvane).filter(([key]) => !nested.includes(key))) };
|
|
284
|
+
}
|
|
285
|
+
/** heed.yaml's `heedvane` section under `root`; the template's defaults before init has written one. */
|
|
286
|
+
export function heedvaneConfigAt(root) {
|
|
287
|
+
try {
|
|
288
|
+
return loadHeedFileAt(join(root, "heed.yaml")).heedvane;
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
if (err.code !== "ENOENT")
|
|
292
|
+
throw err;
|
|
293
|
+
return loadHeedFile(DEFAULT_HEED_YAML_TEMPLATE).heedvane;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The listener's loopback endpoint, persisted under the heed root. */
|
|
2
|
+
export interface Endpoint {
|
|
3
|
+
port: number;
|
|
4
|
+
token: string;
|
|
5
|
+
}
|
|
6
|
+
/** Path of the endpoint file: `root/endpoint.env`. */
|
|
7
|
+
export declare function endpointPath(root: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Read `{port, token}` from `root/endpoint.env`.
|
|
10
|
+
* Returns null when the file does not exist. If the file exists but is
|
|
11
|
+
* missing/incoherent, the corresponding field degrades (`port` -> 0,
|
|
12
|
+
* `token` -> "") so callers can produce a precise remediation error.
|
|
13
|
+
*/
|
|
14
|
+
export declare function readEndpoint(root: string): Promise<Endpoint | null>;
|
|
15
|
+
/** Atomically write `root/endpoint.env` (mode 0600) with the port and token lines. */
|
|
16
|
+
export declare function writeEndpoint(root: string, endpoint: Endpoint): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* The listener endpoint, issued once: an existing endpoint.env with a port and token is kept
|
|
19
|
+
* (dispatchers already point at it); otherwise a free loopback port and a fresh 256-bit token are written.
|
|
20
|
+
*/
|
|
21
|
+
export declare function ensureEndpoint(root: string): Promise<Endpoint>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, open, readFile, rename } from "node:fs/promises";
|
|
3
|
+
import { createServer } from "node:net";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { unlinkOptional } from "../best-effort.js";
|
|
6
|
+
/** The variable names the dispatcher scripts and the Hermes plugin read, sourced straight from endpoint.env. */
|
|
7
|
+
const PORT_KEY = "HEED_AGENT_HOOK_PORT";
|
|
8
|
+
const TOKEN_KEY = "HEED_AGENT_HOOK_TOKEN";
|
|
9
|
+
/** Path of the endpoint file: `root/endpoint.env`. */
|
|
10
|
+
export function endpointPath(root) {
|
|
11
|
+
return join(root, "endpoint.env");
|
|
12
|
+
}
|
|
13
|
+
/** Parse KEY=VALUE lines (ignores blanks, `#` comments, and matching quotes). */
|
|
14
|
+
function parseEnvFile(content) {
|
|
15
|
+
const values = {};
|
|
16
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
17
|
+
const line = rawLine.trim();
|
|
18
|
+
if (!line || line.startsWith("#"))
|
|
19
|
+
continue;
|
|
20
|
+
const eq = line.indexOf("=");
|
|
21
|
+
if (eq <= 0)
|
|
22
|
+
continue;
|
|
23
|
+
const key = line.slice(0, eq).trim();
|
|
24
|
+
let value = line.slice(eq + 1).trim();
|
|
25
|
+
if (value.length >= 2 &&
|
|
26
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
27
|
+
(value.startsWith("'") && value.endsWith("'")))) {
|
|
28
|
+
value = value.slice(1, -1);
|
|
29
|
+
}
|
|
30
|
+
values[key] = value;
|
|
31
|
+
}
|
|
32
|
+
return values;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Read `{port, token}` from `root/endpoint.env`.
|
|
36
|
+
* Returns null when the file does not exist. If the file exists but is
|
|
37
|
+
* missing/incoherent, the corresponding field degrades (`port` -> 0,
|
|
38
|
+
* `token` -> "") so callers can produce a precise remediation error.
|
|
39
|
+
*/
|
|
40
|
+
export async function readEndpoint(root) {
|
|
41
|
+
let content;
|
|
42
|
+
try {
|
|
43
|
+
content = await readFile(endpointPath(root), "utf8");
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (err.code === "ENOENT")
|
|
47
|
+
return null;
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
const values = parseEnvFile(content);
|
|
51
|
+
const port = Number.parseInt(values[PORT_KEY] ?? "", 10);
|
|
52
|
+
return {
|
|
53
|
+
port: Number.isInteger(port) && port >= 0 && port <= 65535 ? port : 0,
|
|
54
|
+
token: values[TOKEN_KEY] ?? "",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Atomic write: temp file in the same directory (created 0600, fsync'd,
|
|
59
|
+
* chmod'd to defeat umask), then rename over the target. Same contract as
|
|
60
|
+
* write-file-atomic, kept stdlib-only so the listener has zero deps.
|
|
61
|
+
*/
|
|
62
|
+
async function atomicWriteFile(filePath, data, mode) {
|
|
63
|
+
const dir = dirname(filePath);
|
|
64
|
+
await mkdir(dir, { recursive: true });
|
|
65
|
+
const tmp = join(dir, `.${basename(filePath)}.${process.pid}.${Date.now().toString(36)}.tmp`);
|
|
66
|
+
const handle = await open(tmp, "w", mode);
|
|
67
|
+
try {
|
|
68
|
+
await handle.writeFile(data, "utf8");
|
|
69
|
+
await handle.sync();
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await handle.close();
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
await chmod(tmp, mode);
|
|
76
|
+
await rename(tmp, filePath);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
await unlinkOptional(tmp);
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Atomically write `root/endpoint.env` (mode 0600) with the port and token lines. */
|
|
84
|
+
export async function writeEndpoint(root, endpoint) {
|
|
85
|
+
const content = `${PORT_KEY}=${endpoint.port}\n${TOKEN_KEY}=${endpoint.token}\n`;
|
|
86
|
+
await atomicWriteFile(endpointPath(root), content, 0o600);
|
|
87
|
+
}
|
|
88
|
+
/** A loopback port free right now (the OS picks it). */
|
|
89
|
+
async function freeLoopbackPort() {
|
|
90
|
+
const server = createServer();
|
|
91
|
+
await new Promise((resolve, reject) => {
|
|
92
|
+
server.once("error", reject);
|
|
93
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
94
|
+
});
|
|
95
|
+
const address = server.address();
|
|
96
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
97
|
+
if (address === null || typeof address !== "object")
|
|
98
|
+
throw new Error("could not reserve a loopback port");
|
|
99
|
+
return address.port;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The listener endpoint, issued once: an existing endpoint.env with a port and token is kept
|
|
103
|
+
* (dispatchers already point at it); otherwise a free loopback port and a fresh 256-bit token are written.
|
|
104
|
+
*/
|
|
105
|
+
export async function ensureEndpoint(root) {
|
|
106
|
+
const existing = await readEndpoint(root);
|
|
107
|
+
if (existing !== null && existing.port > 0 && existing.token)
|
|
108
|
+
return existing;
|
|
109
|
+
const endpoint = { port: await freeLoopbackPort(), token: randomBytes(32).toString("hex") };
|
|
110
|
+
await writeEndpoint(root, endpoint);
|
|
111
|
+
return endpoint;
|
|
112
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Backend } from "../backend/client.js";
|
|
2
|
+
/** Held while Heedvane is unreachable; past this, the oldest {@link SHED_FRACTION} moves to the dead-letter file in one rewrite. */
|
|
3
|
+
export declare const QUEUE_MAX = 5000;
|
|
4
|
+
/** Held while the route refuses this machine outright (403) or does not exist (404): nothing will change soon. */
|
|
5
|
+
export declare const REFUSED_QUEUE_MAX = 500;
|
|
6
|
+
/** 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
|
+
export declare const DEAD_LETTER_MAX_BYTES: number;
|
|
8
|
+
/** How often an item refused on its own is retried before it is dead-lettered. */
|
|
9
|
+
export declare const MAX_REFUSALS = 3;
|
|
10
|
+
export interface Outbox<T> {
|
|
11
|
+
/** Queue an item; it is on disk when this resolves. It is stamped with the current sign-in, or with `identity` when the caller read it earlier (a replayed event belongs to the sign-in the replay began under). */
|
|
12
|
+
add: (item: T, identity?: string | null) => Promise<void>;
|
|
13
|
+
/** Send everything queued; what could not be sent stays queued for the next flush. One at a time. */
|
|
14
|
+
flush: () => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export interface OutboxRoute {
|
|
17
|
+
/** The outbox's file name stem under `<root>/spool`. */
|
|
18
|
+
name: string;
|
|
19
|
+
path: string;
|
|
20
|
+
/** The body's array key, `{ [key]: items }`, also the first element of a 400's issue paths. */
|
|
21
|
+
key: string;
|
|
22
|
+
maxItems: number;
|
|
23
|
+
maxBytes: number;
|
|
24
|
+
}
|
|
25
|
+
/** Steering's delivery reports: at most 100 per post and 256 KiB per body (a little headroom for the wrapper). */
|
|
26
|
+
export declare const DELIVERIES_ROUTE: OutboxRoute;
|
|
27
|
+
export interface OutboxContext {
|
|
28
|
+
/** The heed root; the outbox lives in its spool directory. */
|
|
29
|
+
root: string;
|
|
30
|
+
backend: Backend | null;
|
|
31
|
+
log: (message: string) => void;
|
|
32
|
+
/** The clock the backoff reads (tests move it). */
|
|
33
|
+
now?: () => number;
|
|
34
|
+
}
|
|
35
|
+
/** 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>>;
|