heedvane 0.2.0 → 0.2.1
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 +1 -1
- package/dist/cli.js +54 -8
- package/dist/listener/server.d.ts +2 -0
- package/dist/listener/server.js +23 -3
- package/dist/uninstall.js +27 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ own lines go to stderr and `~/.heed/listener.log`.
|
|
|
49
49
|
|
|
50
50
|
- Claude Code runs the hooks in print mode (`claude -p`), but not with `--bare`.
|
|
51
51
|
- Codex runs hooks only after they are trusted.
|
|
52
|
-
- Pi
|
|
52
|
+
- The installer does not set up Pi or Flue. Connect them with a hook of your own that sends each action to `POST /api/agent/steering` with the organization key; Heedvane answers whether to refuse it.
|
|
53
53
|
|
|
54
54
|
## Everyday commands
|
|
55
55
|
|
package/dist/cli.js
CHANGED
|
@@ -98,17 +98,63 @@ function parseInvocation(args) {
|
|
|
98
98
|
clients: parseRepeatedFlag(args, "--client"),
|
|
99
99
|
server: flagValue(args, "--server") ?? flagValue(args, "--url"),
|
|
100
100
|
org: flagValue(args, "--org"),
|
|
101
|
-
positional: positionalArgs(args,
|
|
101
|
+
positional: positionalArgs(args, VALUE_FLAGS),
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
|
+
/** Flags without a value. */
|
|
105
|
+
const SWITCH_FLAGS = ["--dry-run", "--yes", "--json", "--purge-config", "--project", "--help", "-h"];
|
|
106
|
+
/** Flags followed by a value. */
|
|
107
|
+
const VALUE_FLAGS = ["--agent", "--client", "--folder", "--offset", "--url", "--server", "--org", "--token"];
|
|
108
|
+
/** Heedvane's own arguments: everything before `--`, after which `run` passes the rest to the agent. */
|
|
109
|
+
function ownArgs(args) {
|
|
110
|
+
const separator = args.indexOf("--");
|
|
111
|
+
return separator < 0 ? args : args.slice(0, separator);
|
|
112
|
+
}
|
|
113
|
+
/** The first argument that looks like a flag but is not one of ours (values of value flags skipped). */
|
|
114
|
+
function unknownFlag(args) {
|
|
115
|
+
for (let i = 0; i < args.length; i++) {
|
|
116
|
+
if (VALUE_FLAGS.includes(args[i]))
|
|
117
|
+
i += 1;
|
|
118
|
+
else if (args[i].startsWith("-") && !SWITCH_FLAGS.includes(args[i]))
|
|
119
|
+
return args[i];
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
/** One command's usage line from USAGE plus the flags, or the whole USAGE for no or an unknown command. */
|
|
124
|
+
function helpFor(command) {
|
|
125
|
+
const lines = USAGE.split("\n");
|
|
126
|
+
const own = command === undefined ? [] : lines.filter((line) => line.trim().startsWith(`${command} `));
|
|
127
|
+
if (own.length === 0)
|
|
128
|
+
return USAGE;
|
|
129
|
+
const flags = lines.slice(lines.findIndex((line) => line.trim().startsWith("Flags:")));
|
|
130
|
+
return [` Usage: npx heedvane ${command} [flags]`, "", ...own, "", ...flags].join("\n");
|
|
131
|
+
}
|
|
132
|
+
/** The command help is asked about: `help <command>`, `<command> --help`, or none for `-h` alone. */
|
|
133
|
+
function helpTopic([first, second]) {
|
|
134
|
+
if (first === "help")
|
|
135
|
+
return second;
|
|
136
|
+
return first === "-h" ? undefined : first;
|
|
137
|
+
}
|
|
104
138
|
async function main(argv, opts = {}) {
|
|
105
139
|
const ctx = commandContext(argv, opts);
|
|
106
140
|
const command = ctx.positional[0];
|
|
141
|
+
const own = ownArgs(ctx.args);
|
|
142
|
+
// Help never runs anything: `uninstall --help` must not uninstall.
|
|
143
|
+
if (command === "help" || own.includes("--help") || own.includes("-h")) {
|
|
144
|
+
ctx.out(helpFor(helpTopic(ctx.positional)));
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
const unknown = unknownFlag(own);
|
|
148
|
+
if (unknown !== undefined) {
|
|
149
|
+
ctx.out(` ✗ unknown flag: ${unknown} (nothing was changed)`);
|
|
150
|
+
ctx.out(helpFor(command));
|
|
151
|
+
return 2;
|
|
152
|
+
}
|
|
107
153
|
if (command === undefined && takesInstaller(ctx))
|
|
108
154
|
return cmdInstall(ctx);
|
|
109
|
-
if (!command
|
|
155
|
+
if (!command) {
|
|
110
156
|
ctx.out(USAGE);
|
|
111
|
-
return
|
|
157
|
+
return 2;
|
|
112
158
|
}
|
|
113
159
|
return routeCommand(command, ctx);
|
|
114
160
|
}
|
|
@@ -351,9 +397,9 @@ async function cmdSetup(ctx) {
|
|
|
351
397
|
}
|
|
352
398
|
return applyAndReport(ctx, { heedFile, agents: ctx.agents, existed: true });
|
|
353
399
|
}
|
|
354
|
-
/** A person at a terminal who did not ask for the one-shot path (`--yes`, `--dry-run`, `--json`)
|
|
400
|
+
/** A person at a terminal who did not ask for the one-shot path (`--yes`, `--dry-run`, `--json`). */
|
|
355
401
|
function takesInstaller(ctx) {
|
|
356
|
-
return ctx.interactive && !ctx.flags.yes && !ctx.flags.dryRun && !ctx.flags.json
|
|
402
|
+
return ctx.interactive && !ctx.flags.yes && !ctx.flags.dryRun && !ctx.flags.json;
|
|
357
403
|
}
|
|
358
404
|
/** heed.yaml's text, or the template when there is none yet. */
|
|
359
405
|
function heedText(home, existed) {
|
|
@@ -569,10 +615,10 @@ async function cmdListener(ctx) {
|
|
|
569
615
|
}
|
|
570
616
|
const started = await serveHooks(ctx.home, ctx.out);
|
|
571
617
|
ctx.out(` ✓ listener on 127.0.0.1:${started.port}, ${started.replayed} spooled event(s) replayed (Ctrl-C to stop)`);
|
|
618
|
+
// Ends on Ctrl-C, or when uninstall asks it to stop (POST /shutdown).
|
|
572
619
|
await new Promise((resolve) => {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
});
|
|
620
|
+
started.server.on("close", resolve);
|
|
621
|
+
process.on("SIGINT", () => started.server.close());
|
|
576
622
|
});
|
|
577
623
|
return 0;
|
|
578
624
|
}
|
|
@@ -39,6 +39,8 @@ export interface StartedListener {
|
|
|
39
39
|
* length-leaking early return would defeat the point).
|
|
40
40
|
*/
|
|
41
41
|
export declare function safeTokenEqual(a: string, b: string): boolean;
|
|
42
|
+
/** `POST /shutdown` with the token: uninstall stops the listener it no longer has hooks for. */
|
|
43
|
+
export declare const SHUTDOWN_ROUTE = "/shutdown";
|
|
42
44
|
/**
|
|
43
45
|
* Start the loopback hook listener: binds 127.0.0.1 only (never 0.0.0.0),
|
|
44
46
|
* serves POST /hook/<agentId>, auths via x-heed-agent-hook-token against
|
package/dist/listener/server.js
CHANGED
|
@@ -71,6 +71,20 @@ function sendJson(res, status, body) {
|
|
|
71
71
|
res.end(JSON.stringify(body));
|
|
72
72
|
}
|
|
73
73
|
const ROUTE_PATTERN = /^\/hook\/([A-Za-z0-9_-]+)$/;
|
|
74
|
+
/** `POST /shutdown` with the token: uninstall stops the listener it no longer has hooks for. */
|
|
75
|
+
export const SHUTDOWN_ROUTE = "/shutdown";
|
|
76
|
+
/** True after answering a token-carrying `POST /shutdown`; the listener closes once the answer is sent. */
|
|
77
|
+
function handledShutdown(req, res, deps) {
|
|
78
|
+
if (req.method !== "POST" || new URL(req.url ?? "/", "http://127.0.0.1").pathname !== SHUTDOWN_ROUTE)
|
|
79
|
+
return false;
|
|
80
|
+
if (!hasValidToken(req, deps.token)) {
|
|
81
|
+
sendJson(res, 401, { error: "invalid or missing x-heed-agent-hook-token" });
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
res.on("finish", deps.stop);
|
|
85
|
+
sendJson(res, 200, { stopping: true });
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
74
88
|
/** The route's agent id, or null after answering 404/405. */
|
|
75
89
|
function routeAgent(req, res, agents) {
|
|
76
90
|
const route = ROUTE_PATTERN.exec(new URL(req.url ?? "/", "http://127.0.0.1").pathname);
|
|
@@ -150,6 +164,8 @@ function internalErrorResponder(res) {
|
|
|
150
164
|
};
|
|
151
165
|
}
|
|
152
166
|
async function handleRequest(req, res, deps) {
|
|
167
|
+
if (handledShutdown(req, res, deps))
|
|
168
|
+
return;
|
|
153
169
|
const agent = routeAgent(req, res, deps.agents);
|
|
154
170
|
if (agent === null)
|
|
155
171
|
return;
|
|
@@ -194,16 +210,20 @@ export async function startListener(options) {
|
|
|
194
210
|
}
|
|
195
211
|
const log = options.log ?? ((message) => console.log(message));
|
|
196
212
|
const agents = options.knownAgents ?? KNOWN_AGENTS;
|
|
213
|
+
const server = createServer((req, res) => {
|
|
214
|
+
handleRequest(req, res, deps).catch(internalErrorResponder(res));
|
|
215
|
+
});
|
|
197
216
|
const deps = {
|
|
198
217
|
agents,
|
|
199
218
|
token: endpoint.token,
|
|
200
219
|
onEvent: options.onEvent,
|
|
201
220
|
log,
|
|
202
221
|
warned: new Set(),
|
|
222
|
+
stop: () => {
|
|
223
|
+
server.close();
|
|
224
|
+
server.closeAllConnections();
|
|
225
|
+
},
|
|
203
226
|
};
|
|
204
|
-
const server = createServer((req, res) => {
|
|
205
|
-
handleRequest(req, res, deps).catch(internalErrorResponder(res));
|
|
206
|
-
});
|
|
207
227
|
// Node-level slowloris hardening (covers stalls before the request handler
|
|
208
228
|
// fires); requestTimeout must be larger than headersTimeout.
|
|
209
229
|
server.requestTimeout = REQUEST_TIMEOUT_MS;
|
package/dist/uninstall.js
CHANGED
|
@@ -16,6 +16,9 @@ import { hooksTarget, isClientDetected } from "./plan.js";
|
|
|
16
16
|
import { skillInstallDir } from "./skill/bundle.js";
|
|
17
17
|
import { statePath } from "./state.js";
|
|
18
18
|
import { unlinkOptional } from "./best-effort.js";
|
|
19
|
+
import { pingListener, readEndpoint } from "./listener/endpoint.js";
|
|
20
|
+
import { SHUTDOWN_ROUTE } from "./listener/server.js";
|
|
21
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
19
22
|
/** Hook events carrying one of our groups, with the user's own groups that must survive. */
|
|
20
23
|
function ourHookEvents(doc) {
|
|
21
24
|
const hooks = doc["hooks"];
|
|
@@ -150,10 +153,34 @@ async function removeSharedFiles(home, scoped) {
|
|
|
150
153
|
await rm(skillDir, { recursive: true, force: true });
|
|
151
154
|
await unlinkOptional(statePath(join(home, ".heed")));
|
|
152
155
|
return [
|
|
156
|
+
await stopListener(join(home, ".heed")),
|
|
153
157
|
{ clientId: "*", name: "skill bundle", path: skillDir, status: "removed" },
|
|
154
158
|
{ clientId: "*", name: "state.json", path: statePath(join(home, ".heed")), status: "removed" },
|
|
155
159
|
];
|
|
156
160
|
}
|
|
161
|
+
/** How long uninstall waits for the listener to stop answering after asking it to stop. */
|
|
162
|
+
const STOP_WAIT_MS = 3_000;
|
|
163
|
+
const STOP_POLL_MS = 100;
|
|
164
|
+
/** The background listener answers hooks that are gone now: ask it to stop (POST /shutdown) and wait until it has. */
|
|
165
|
+
async function stopListener(root) {
|
|
166
|
+
const endpoint = await readEndpoint(root).catch(() => null);
|
|
167
|
+
const item = { clientId: "*", name: "listener", path: `127.0.0.1:${endpoint?.port ?? 0}` };
|
|
168
|
+
if (endpoint === null || !(await pingListener(endpoint)))
|
|
169
|
+
return { ...item, status: "absent" };
|
|
170
|
+
const asked = await fetch(`http://127.0.0.1:${endpoint.port}${SHUTDOWN_ROUTE}`, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: { "x-heed-agent-hook-token": endpoint.token },
|
|
173
|
+
signal: AbortSignal.timeout(STOP_WAIT_MS),
|
|
174
|
+
}).catch(() => null);
|
|
175
|
+
for (let waited = 0; waited < STOP_WAIT_MS; waited += STOP_POLL_MS) {
|
|
176
|
+
if (!(await pingListener(endpoint)))
|
|
177
|
+
return { ...item, status: "removed" };
|
|
178
|
+
await delay(STOP_POLL_MS);
|
|
179
|
+
}
|
|
180
|
+
// A listener from 0.2.0 or earlier has no stop route (404); it ends at logout or restart.
|
|
181
|
+
const detail = asked?.status === 404 ? "an older listener is still running; it stops at logout or restart" : "the listener did not stop; it stops at logout or restart";
|
|
182
|
+
return { ...item, status: "failed", detail };
|
|
183
|
+
}
|
|
157
184
|
function existsMarker(client) {
|
|
158
185
|
return isClientDetected(client);
|
|
159
186
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "heedvane",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Heedvane for coding agents: one command installs steering, guardrails, team memory and skill checks into Claude Code, Codex, Cursor, Gemini CLI and more.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|