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 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 and Flue agents cannot be connected yet.
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, ["--agent", "--client", "--folder", "--offset", "--url", "--server", "--org", "--token"]),
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 || command === "help" || command === "--help") {
155
+ if (!command) {
110
156
  ctx.out(USAGE);
111
- return command ? 0 : 2;
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`) or for help. */
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 && !ctx.args.includes("--help");
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
- process.on("SIGINT", () => {
574
- started.server.close(() => resolve());
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
@@ -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.0",
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",