agentradar-cli 0.1.9 → 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/dist/runner.js CHANGED
@@ -1,17 +1,443 @@
1
- import { spawn } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { mkdir, readdir, readFile, realpath, rename, rm, stat } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
- export async function startVendorRunner(runnerId, home = homedir()) {
6
- if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(runnerId)) {
6
+ import { promisify } from "node:util";
7
+ import { shellValue, trustedMcpEndpoint, writePrivateFile } from "./setup.js";
8
+ import { callAwpTool, envValue } from "./tool-run.js";
9
+ const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
10
+ const ENGAGEMENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
11
+ // Per-hire bearers are ordinary agent keys (`pcp_…`); the runner's own is an AWP key (`ar_…`).
12
+ const HIRE_BEARER = /^(ar|pcp)_[A-Za-z0-9_-]+$/;
13
+ // What a hire's env file sets for itself; the catalog agent's values for these never reach it.
14
+ // A hire always runs Claude, whose MCP config it owns; a Codex home would be the catalog agent's.
15
+ const HIRE_OWN_KEYS = /^(export\s+)?(AGENT_NAME|AR_BASE|AWP_MCP_URL|AWP_BEARER|AWP_BEARER_FILE|MCP_CONFIG|TMUX_SESSION|AGENT_PID_PATTERN|AR_AGENT_HARNESS|CODEX_HOME)=/;
16
+ const CODEX_HARNESS = /^(export\s+)?AR_AGENT_HARNESS=\s*["']?codex\b/m;
17
+ const run = promisify(execFile);
18
+ /** Run from outside the target harness: the command deliberately replaces that harness process. */
19
+ export async function runRestartSelftest(runnerId, deps = {}) {
20
+ const call = deps.call ?? (async (args) => {
21
+ let value = {};
22
+ let failure = "Self-test request failed";
23
+ const code = await callAwpTool(runnerId, "run_selftest", args, {
24
+ home: deps.home, log: (text) => { value = JSON.parse(text); }, logError: (text) => { failure = text; },
25
+ });
26
+ if (code || value.ok === false)
27
+ throw new Error(typeof value.error === "string" ? value.error : failure);
28
+ return value;
29
+ });
30
+ const wait = async (args, done, timedOut = "Self-test timed out; persistence is not proven.") => {
31
+ const deadline = Date.now() + (deps.waitMs ?? 120_000);
32
+ do {
33
+ const value = await call(args);
34
+ if (done(value))
35
+ return value;
36
+ await new Promise((resolve) => setTimeout(resolve, 500));
37
+ } while (Date.now() < deadline);
38
+ throw new Error(timedOut);
39
+ };
40
+ const unanswered = `The agent did not act on the check: it is not polling next_event and no channel push reached it. Have it poll (agentradar run ${runnerId} next_event), then run this again.`;
41
+ const restart = deps.restart ?? ((beforeRestart) => restartRunnerHarness(runnerId, deps.home, run, beforeRestart));
42
+ // Refuse before the drill starts: run every restart check, stopping exactly where the real restart would begin.
43
+ const checked = new Error("restart checks passed");
44
+ await restart(async () => { throw checked; }).catch((error) => { if (error !== checked)
45
+ throw error; });
46
+ await call({});
47
+ await wait({ statusOnly: true }, (value) => value.state === "answered", unanswered);
48
+ const prepared = await call({ persistence: "prepare" });
49
+ if (typeof prepared.runId !== "string")
50
+ throw new Error("Self-test returned no run ID.");
51
+ const runId = prepared.runId;
52
+ await wait({ persistence: "status", runId }, (value) => value.markerReceived === true && value.markerStored === true);
53
+ // Persist the recall event before replacement, then release it only after observing the new process.
54
+ // The server does not certify caller-supplied PIDs; the outside CLI owns that local measurement.
55
+ const process = await restart(async () => { await call({ persistence: "recall", runId }); });
56
+ if (process.previousPid === process.currentPid)
57
+ throw new Error("The harness process did not change.");
58
+ const afterRestart = await call({ persistence: "status", runId });
59
+ const restartedAt = Date.parse(String(afterRestart.observedAt));
60
+ if (!Number.isFinite(restartedAt))
61
+ throw new Error("No server timestamp for harness replacement.");
62
+ await call({});
63
+ await wait({ statusOnly: true }, (value) => value.state === "answered", unanswered);
64
+ await call({ persistence: "resume", runId });
65
+ const result = await wait({ persistence: "status", runId }, (value) => value.state === "recalled");
66
+ if (Date.parse(String(result.recallAnsweredAt)) < restartedAt || Date.parse(String(result.recallReceivedAt)) < restartedAt
67
+ || !Number.isFinite(Date.parse(String(result.recallAnsweredAt))) || !Number.isFinite(Date.parse(String(result.recallReceivedAt)))
68
+ || result.selftestEventsReceived !== 2 || JSON.stringify(result.selftestReplyCounts) !== "[1,1]") {
69
+ throw new Error("Self-test did not prove both events survived with one reply each after harness replacement.");
70
+ }
71
+ (deps.log ?? console.log)(JSON.stringify({ ...result, ...process, harnessRestart: "passed", machineRestart: "not measured" }));
72
+ }
73
+ /** Restart only the named runner's owned pane, not its feeder or a user's native channel session. */
74
+ export async function restartRunnerHarness(runnerId, home = homedir(), execute = run, beforeRestart) {
75
+ if (!RUNNER_ID.test(runnerId))
7
76
  throw new Error("Invalid runner ID");
77
+ const contents = await readFile(join(home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
78
+ const session = envValue(contents, "TMUX_SESSION") ?? runnerId;
79
+ const base = envValue(contents, "AR_BASE");
80
+ const config = envValue(contents, "MCP_CONFIG");
81
+ const harness = envValue(contents, "AR_AGENT_HARNESS") ?? "claude";
82
+ // JOIN.md's default install writes no MCP_CONFIG: its Claude hosts `agentradar channel` instead.
83
+ if (!config && harness === "claude")
84
+ return restartChannelSession(runnerId, execute, beforeRestart);
85
+ // What the supervisor launches per harness: the resume flags its pane carries, the per-agent value in
86
+ // that pane command, the per-agent argv token, and the executable (Codex's npm wrapper is `node`).
87
+ const codexHome = envValue(contents, "CODEX_HOME");
88
+ const owned = harness === "codex"
89
+ ? { resume: `AR_RESUME_FLAGS=${envValue(contents, "AR_RESUME_FLAGS") ?? "resume --last"}`, token: codexHome && `CODEX_HOME=${codexHome}`, argv: (args) => args.endsWith(` --cd ${base}`), bin: /(^|\/)codex$/ }
90
+ : harness === "claude" ? { resume: `AR_RESUME_FLAGS=${envValue(contents, "AR_RESUME_FLAGS") ?? "--continue"}`, token: config, argv: (args) => args.includes(`--mcp-config=${config}`), bin: /(^|\/)claude$/ }
91
+ : null;
92
+ if (!/^_?[A-Za-z0-9][A-Za-z0-9_-]{0,100}$/.test(session) || !base || !owned?.token) {
93
+ throw new Error("Restart self-test requires an installed Claude or Codex runner with its own tmux pane.");
94
+ }
95
+ const tmux = envValue(contents, "TMUX_BIN") ?? "tmux";
96
+ const { stdout } = await execute(tmux, ["list-panes", "-s", "-t", `=${session}`, "-F", "#{pane_id}\t#{pane_pid}\t#{pane_start_path}\t#{pane_start_command}"]);
97
+ const panes = stdout.trim().split("\n");
98
+ if (panes.length !== 1)
99
+ throw new Error("Restart refused: the runner must own exactly one pane.");
100
+ const [pane, root, cwd, command] = panes[0].split("\t");
101
+ if (!pane || !/^%\d+$/.test(pane) || !root || !/^\d+$/.test(root)
102
+ || !cwd || await realpath(cwd) !== await realpath(base)
103
+ || !command?.includes(owned.resume) || !command.includes(owned.token)) {
104
+ throw new Error("Restart refused: this pane is not the installed runner's resumable harness.");
105
+ }
106
+ const findHarness = async (rootPid) => {
107
+ const rows = await processRows(execute);
108
+ const descendants = new Set([rootPid]);
109
+ for (let depth = 0; depth < 8; depth++) {
110
+ for (const row of rows)
111
+ if (descendants.has(row.parent))
112
+ descendants.add(row.pid);
113
+ }
114
+ if (descendants.has(process.pid))
115
+ throw new Error("Run --restart from a separate terminal, not from inside the target harness.");
116
+ const matches = [];
117
+ for (const row of rows) {
118
+ if (!descendants.has(row.pid) || row.uid !== process.getuid?.()
119
+ || !owned.argv(row.args))
120
+ continue;
121
+ // macOS truncates comm when another column follows it; read the executable on its own.
122
+ const executable = await execute("ps", ["-p", String(row.pid), "-o", "comm="]).then((result) => result.stdout.trim(), () => "");
123
+ if (owned.bin.test(executable))
124
+ matches.push(row.pid);
125
+ }
126
+ return matches.length === 1 ? matches[0] : null;
127
+ };
128
+ const previousPid = await findHarness(Number(root));
129
+ if (!previousPid)
130
+ throw new Error("Restart refused: no unique live Claude or Codex process belongs to the runner pane.");
131
+ // respawn-pane reuses the supervisor's scrubbed, resuming launch command. No shell reconstruction,
132
+ // no global process pattern, and no neighbouring session is stopped.
133
+ await beforeRestart?.();
134
+ await execute(tmux, ["respawn-pane", "-k", "-t", pane]);
135
+ const deadline = Date.now() + 30_000;
136
+ while (Date.now() < deadline) {
137
+ const { stdout: currentRoot } = await execute(tmux, ["display-message", "-p", "-t", pane, "#{pane_pid}"]);
138
+ const currentPid = await findHarness(Number(currentRoot.trim()));
139
+ const oldAlive = await execute("ps", ["-p", String(previousPid), "-o", "pid="]).then(() => true, () => false);
140
+ if (!oldAlive && currentPid && currentPid !== previousPid)
141
+ return { previousPid, currentPid };
142
+ await new Promise((resolve) => setTimeout(resolve, 250));
143
+ }
144
+ throw new Error("Harness restart not confirmed within 30 seconds; persistence is not proven.");
145
+ }
146
+ const processRows = async (execute) => (await execute("ps", ["-axo", "pid=,ppid=,uid=,args="])).stdout.split("\n").flatMap((line) => {
147
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/);
148
+ return match ? [{ pid: Number(match[1]), parent: Number(match[2]), uid: Number(match[3]), args: match[4] }] : [];
149
+ });
150
+ /**
151
+ * JOIN.md's default: the operator's interactive Claude hosts `agentradar channel <id>` as a stdio MCP.
152
+ * End that one session and resume it (--continue, same folder) in this terminal with this shell's
153
+ * environment, where the operator can accept the channel prompt. No runner, tmux or other session is touched.
154
+ */
155
+ export async function restartChannelSession(runnerId, execute = run, beforeRestart, deps = {}) {
156
+ if (!RUNNER_ID.test(runnerId))
157
+ throw new Error("Invalid runner ID");
158
+ const rows = await processRows(execute);
159
+ const channel = new RegExp(`(^|[\\s/])agentradar channel ${runnerId}$`);
160
+ const hosts = new Set(rows.filter((row) => row.uid === process.getuid?.() && channel.test(row.args)).map((row) => row.parent));
161
+ if (hosts.size !== 1)
162
+ throw new Error("Restart refused: no single live session of yours runs this agent. Start it, then run this again from a separate terminal.");
163
+ const [previousPid] = [...hosts];
164
+ for (let pid = process.pid, depth = 0; pid > 1 && depth < 64; depth++) {
165
+ if (pid === previousPid)
166
+ throw new Error("Run --restart from a separate terminal, not from inside the target harness.");
167
+ pid = rows.find((row) => row.pid === pid)?.parent ?? 0;
168
+ }
169
+ const executable = await execute("ps", ["-p", String(previousPid), "-o", "comm="]).then((result) => result.stdout.trim(), () => "");
170
+ if (!/(^|\/)claude$/.test(executable))
171
+ throw new Error("Restart refused: the channel's host is not Claude Code.");
172
+ const cwd = process.platform === "linux" ? await realpath(`/proc/${previousPid}/cwd`).catch(() => "")
173
+ : (await execute("lsof", ["-a", "-p", String(previousPid), "-d", "cwd", "-Fn"]).catch(() => ({ stdout: "" })))
174
+ .stdout.split("\n").find((line) => line.startsWith("n/"))?.slice(1) ?? "";
175
+ if (!cwd)
176
+ throw new Error("Restart refused: the session's folder could not be read.");
177
+ const signal = deps.signal ?? ((pid, name) => { process.kill(pid, name); });
178
+ await beforeRestart?.();
179
+ signal(previousPid, "SIGTERM");
180
+ const alive = () => { try {
181
+ signal(previousPid, 0);
182
+ return true;
8
183
  }
184
+ catch {
185
+ return false;
186
+ } };
187
+ for (const deadline = Date.now() + 30_000; alive();) {
188
+ if (Date.now() > deadline)
189
+ throw new Error("The old session did not exit within 30 seconds; persistence is not proven.");
190
+ await new Promise((resolve) => setTimeout(resolve, 250));
191
+ }
192
+ const launch = deps.launch ?? ((command, args, folder) => new Promise((resolve, reject) => {
193
+ console.log("Resuming your agent's session in this terminal. Accept the channel prompt; the self-test result prints after you exit it.");
194
+ process.on("SIGINT", () => undefined); // Ctrl-C belongs to the resumed session, not to this CLI.
195
+ const child = spawn(command, args, { cwd: folder, stdio: "inherit" });
196
+ child.once("spawn", () => resolve(child.pid));
197
+ child.once("error", reject);
198
+ }));
199
+ return { previousPid, currentPid: await launch(executable, ["--continue", "--dangerously-load-development-channels", "server:agentradar"], cwd) };
200
+ }
201
+ /**
202
+ * `agentradar runner start` — the catalog agent's shared supervisor, plus one supervisor per hire.
203
+ *
204
+ * Ruling 50: a hire is its own agent row in the client's company with its own bearer, and one agent
205
+ * serving two clients is two separate runtimes. So each claimed hire gets its own env file, its own
206
+ * AWP-only MCP config, its own tmux session and its own folder (a `git clone` of the brain), and runs
207
+ * its own supervisor — the same one the shared bearer runs under, unchanged. The shared supervisor keeps
208
+ * serving every hire made before ruling 50, and it stays the runner's lifetime: when it exits,
209
+ * the hires are stopped and its code is returned, as before. A hire's supervisor exiting touches no other
210
+ * runtime. The shared bearer is never in a hire's env file or environment, so neither its supervisor
211
+ * nor its pane holds the key to the doors that hand out every client's bearer.
212
+ * ponytail: separate, not sandboxed — every hire runs as the vendor's user and can read its siblings'
213
+ * files, the shared env file included; a user or container per hire when a vendor needs that.
214
+ *
215
+ * Every tick claims new bearers, writes them, and acks what it wrote; then retires every hire whose
216
+ * bearer AgentRadar now refuses (the hire ended, or its key was revoked): its supervisor and tmux session
217
+ * stop and its env and bearer files go, while its folder stays, with the work. Then it launches each
218
+ * `<runnerId>--<engagementId>.env` that is not running — after a restart, and after a supervisor exits.
219
+ */
220
+ export async function startVendorRunner(runnerId, home = homedir(), deps = {}) {
221
+ if (!RUNNER_ID.test(runnerId))
222
+ throw new Error("Invalid runner ID");
223
+ const log = deps.log ?? console.log;
9
224
  const envPath = join(home, ".agentradar", "agents", `${runnerId}.env`);
225
+ const agentDir = dirname(envPath);
10
226
  const supervisor = join(dirname(fileURLToPath(import.meta.url)), "vendor-runner", "supervisor", "supervise-agent.sh");
11
- return new Promise((resolve, reject) => {
12
- const child = spawn("bash", [supervisor, envPath], { stdio: "inherit", env: process.env });
13
- child.once("error", reject);
14
- child.once("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
227
+ const spawnChild = deps.spawn ?? ((command, args) => spawn(command, args, { stdio: "inherit", env: process.env }));
228
+ const hires = new Map();
229
+ let timer;
230
+ let stopped = false;
231
+ const runHire = (hireEnv) => {
232
+ if (stopped || hires.has(hireEnv))
233
+ return;
234
+ const child = spawnChild("bash", [supervisor, hireEnv]);
235
+ hires.set(hireEnv, child);
236
+ child.once("error", (error) => log(`Hire supervisor ${hireEnv} failed to start: ${error.message}`));
237
+ child.once("exit", (code) => {
238
+ hires.delete(hireEnv);
239
+ if (!stopped)
240
+ log(`Hire supervisor ${hireEnv} exited (${code ?? "signal"}); the next tick restarts it`);
241
+ });
242
+ };
243
+ // Checks the catalog agent can run hires at all, then returns the tick that launches and claims them.
244
+ const hireTick = async () => {
245
+ const shared = await readFile(envPath, "utf8");
246
+ const mcpUrl = trustedMcpEndpoint(envValue(shared, "AWP_MCP_URL") ?? "");
247
+ const bearer = envValue(shared, "AWP_BEARER");
248
+ const brain = envValue(shared, "AR_BASE");
249
+ if (!mcpUrl || !bearer || !brain)
250
+ throw new Error(`${envPath} lacks AWP_MCP_URL, AWP_BEARER or AR_BASE`);
251
+ // A Codex agent's MCP servers live in its CODEX_HOME, which a hire would share with the catalog agent,
252
+ // shared bearer included — so Codex vendors stay on the shared bearer until a hire can get its own home.
253
+ if (CODEX_HARNESS.test(shared))
254
+ throw new Error("Codex agents keep one shared bearer");
255
+ // An agent that joined an organisation is that organisation's own; it is never hired.
256
+ if (envValue(shared, "AR_CLI_RUNNER_ID"))
257
+ throw new Error("a joined agent has no hires");
258
+ // Each hire's folder is a clone of the brain; copying a plain folder would carry the other clients' work.
259
+ await run("git", ["-C", brain, "rev-parse", "--is-inside-work-tree"]).catch(() => {
260
+ throw new Error(`AR_BASE ${brain} is not a git repository, so each hire cannot get its own clone`);
261
+ });
262
+ const base = resolve(brain);
263
+ const prefix = `${runnerId}--`;
264
+ const folderOf = (engagementId) => `${base}--${engagementId}`;
265
+ // The server's REST API sits beside its MCP endpoint (`…/api/mcp` → `…/api/agent-hires/…`).
266
+ // Under launchd PATH is bare, so tmux is looked up where the supervisor looks for it.
267
+ const killSession = (name) => spawnChild("bash", [
268
+ "-c", 'PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"; exec "${TMUX_BIN:-tmux}" kill-session -t "$1"', "tmux", `=_${name}`,
269
+ ]).once("error", () => { });
270
+ const post = (path, body, token = bearer) => (deps.fetch ?? fetch)(new URL(path, mcpUrl), {
271
+ method: "POST",
272
+ headers: { authorization: `Bearer ${token}`, accept: "application/json", "content-type": "application/json", "cache-control": "no-store" },
273
+ body: JSON.stringify(body ?? {}),
274
+ signal: AbortSignal.timeout(30_000),
275
+ });
276
+ const launchAll = async () => {
277
+ for (const file of await readdir(agentDir)) {
278
+ const engagementId = file.startsWith(prefix) && file.endsWith(".env") ? file.slice(prefix.length, -".env".length) : "";
279
+ const hireEnv = join(agentDir, file);
280
+ if (!ENGAGEMENT_ID.test(engagementId) || hires.has(hireEnv) || stopped)
281
+ continue;
282
+ try {
283
+ const folder = folderOf(engagementId);
284
+ if (!(await stat(folder).catch(() => null))) {
285
+ // Cloned aside and renamed, so a runner killed mid-clone never leaves a half folder that runs.
286
+ // Without `origin` a hire cannot push to or fetch from the brain the other runtimes share.
287
+ const partial = `${folder}.partial`;
288
+ await rm(partial, { recursive: true, force: true });
289
+ await run("git", ["clone", "--quiet", "--", base, partial]);
290
+ await run("git", ["-C", partial, "remote", "remove", "origin"]);
291
+ await rename(partial, folder);
292
+ }
293
+ await copyProjectTrust(join(home, ".claude.json"), base, folder);
294
+ runHire(hireEnv);
295
+ }
296
+ catch (error) {
297
+ log(`Hire ${engagementId} could not start: ${error.message}`);
298
+ }
299
+ }
300
+ };
301
+ const claim = async () => {
302
+ const response = await post("agent-hires/bearers/claim");
303
+ if (!response.ok)
304
+ throw new Error(`claim refused (${response.status})`);
305
+ const { hires: claimed } = await response.json();
306
+ const written = [];
307
+ for (const hire of claimed) {
308
+ if (!ENGAGEMENT_ID.test(hire.engagementId) || !HIRE_BEARER.test(hire.bearer)) {
309
+ log(`AgentRadar returned an invalid hire; skipped`);
310
+ continue;
311
+ }
312
+ const name = `${prefix}${hire.engagementId}`;
313
+ try {
314
+ const configDir = join(home, ".agentradar", "config", name);
315
+ await mkdir(configDir, { recursive: true, mode: 0o700 });
316
+ const mcpConfig = join(configDir, "awp-only.json");
317
+ await writePrivateFile(mcpConfig, `${JSON.stringify({
318
+ mcpServers: { agentradar: { type: "http", url: mcpUrl.toString(), headers: { Authorization: `Bearer ${hire.bearer}` } } },
319
+ }, null, 2)}\n`);
320
+ // Every setting the vendor put in the catalog agent's env (tool env, harness flags) is copied, as
321
+ // read when the runner started, except the ones this hire sets for itself — the shared bearer among
322
+ // them. Copied rather than sourced: sourcing would put the shared bearer in the hire's supervisor.
323
+ // The hire's own values come first, so a copied `$AR_BASE` means the hire's folder, and again last,
324
+ // so nothing copied can move them. With no AGENT_PID_PATTERN the supervisor derives it from MCP_CONFIG.
325
+ // ponytail: line by line, so a multi-line quoted value is copied only in part; parse it when one appears.
326
+ const own = [
327
+ `AGENT_NAME=${shellValue(name)}`,
328
+ `AR_BASE=${shellValue(folderOf(hire.engagementId))}`,
329
+ `AWP_MCP_URL=${shellValue(mcpUrl.toString())}`,
330
+ `AWP_BEARER=${shellValue(hire.bearer)}`,
331
+ `MCP_CONFIG=${shellValue(mcpConfig)}`,
332
+ `AR_AGENT_HARNESS='claude'`,
333
+ // tmux resolves `-t name` by prefix when no session is named exactly that, so the shared
334
+ // session `<runnerId>` must never be a prefix of a hire's. A runner ID cannot start with `_`.
335
+ `TMUX_SESSION=${shellValue(`_${name}`)}`,
336
+ ];
337
+ const copied = shared.split("\n").filter((line) => line.trim() && !HIRE_OWN_KEYS.test(line.trimStart()) && !line.includes(bearer));
338
+ await writePrivateFile(join(agentDir, `${name}.env`), [...own, ...copied, ...own, ""].join("\n"));
339
+ written.push(hire.engagementId);
340
+ log(`Hire ${hire.engagementId} claimed → ${name}`);
341
+ }
342
+ catch (error) {
343
+ log(`Hire ${hire.engagementId} could not be written, claiming it again next tick: ${error.message}`);
344
+ }
345
+ }
346
+ // Acked only once on disk: until then the server hands the bearer out again.
347
+ if (written.length > 0) {
348
+ const ack = await post("agent-hires/bearers/ack", { engagementIds: written });
349
+ if (!ack.ok)
350
+ throw new Error(`ack refused (${ack.status})`);
351
+ }
352
+ };
353
+ // Asks AgentRadar about each hire's bearer, with that bearer, at a door that changes nothing. Only a
354
+ // 401 retires a hire, only from the server that issued it, and only after the shared bearer's claim
355
+ // succeeded this tick, so a runner re-pointed or cut off never deletes a bearer it cannot vouch for.
356
+ const retireEnded = async () => {
357
+ for (const file of await readdir(agentDir)) {
358
+ const engagementId = file.startsWith(prefix) && file.endsWith(".env") ? file.slice(prefix.length, -".env".length) : "";
359
+ if (!ENGAGEMENT_ID.test(engagementId) || stopped)
360
+ continue;
361
+ const name = `${prefix}${engagementId}`;
362
+ const hireEnv = join(agentDir, file);
363
+ const contents = await readFile(hireEnv, "utf8");
364
+ const hireBearer = envValue(contents, "AWP_BEARER");
365
+ if (!hireBearer || envValue(contents, "AWP_MCP_URL") !== mcpUrl.toString())
366
+ continue;
367
+ if ((await post("agent-hires/bearers/claim", undefined, hireBearer)).status !== 401)
368
+ continue;
369
+ await rm(hireEnv, { force: true });
370
+ await rm(join(home, ".agentradar", "config", name), { recursive: true, force: true });
371
+ await rm(join(home, ".agentradar", "run", name), { recursive: true, force: true });
372
+ // The session goes only once its supervisor has exited, or the supervisor would start it again.
373
+ // `=` makes tmux match the session name exactly, never by prefix.
374
+ const child = hires.get(hireEnv);
375
+ if (child) {
376
+ child.once("exit", () => killSession(name));
377
+ child.kill("SIGTERM");
378
+ }
379
+ else {
380
+ killSession(name);
381
+ }
382
+ log(`Hire ${engagementId} ended or its key was revoked: stopped, bearer deleted, folder ${folderOf(engagementId)} kept`);
383
+ }
384
+ };
385
+ // Retiring runs before launching, so a hire that ended while the runner was down never starts;
386
+ // launching runs even when AgentRadar is unreachable, so the hires already written keep working.
387
+ return async () => {
388
+ try {
389
+ await claim();
390
+ await retireEnded();
391
+ }
392
+ catch (error) {
393
+ log(`Per-hire tick failed, retrying: ${error.message}`);
394
+ }
395
+ await launchAll().catch((error) => log(`Per-hire launch failed, retrying: ${error.message}`));
396
+ };
397
+ };
398
+ return new Promise((resolvePromise, reject) => {
399
+ const stop = () => {
400
+ stopped = true;
401
+ clearTimeout(timer);
402
+ for (const child of hires.values())
403
+ child.kill("SIGTERM");
404
+ };
405
+ const shared = spawnChild("bash", [supervisor, envPath]);
406
+ shared.once("error", (error) => { stop(); reject(error); });
407
+ shared.once("exit", (code, signal) => { stop(); resolvePromise(code ?? (signal ? 1 : 0)); });
408
+ hireTick()
409
+ .then((tick) => {
410
+ // The next tick is scheduled only after this one finishes, so a slow clone never overlaps another.
411
+ const loop = async () => {
412
+ await tick();
413
+ if (!stopped)
414
+ timer = setTimeout(loop, deps.tickMs ?? 60_000);
415
+ };
416
+ return loop();
417
+ })
418
+ .catch((error) => log(`Per-hire runtimes are off: ${error.message}`));
15
419
  });
16
420
  }
421
+ /**
422
+ * A hire's folder is new, so Claude Code would stop at its trust dialog with nobody at the pane. It is a
423
+ * clone of the brain the vendor already trusted, so that decision carries over — and only that one: a
424
+ * brain never trusted stays untrusted here too (see `installProjectMcp` in setup.ts on why trust is
425
+ * never inferred). The supervisor unsets CLAUDE_CONFIG_DIR, so Claude reads `~/.claude.json`.
426
+ * ponytail: read-modify-write while the catalog agent's Claude may also write this file; lock if it bites.
427
+ */
428
+ async function copyProjectTrust(configPath, from, to) {
429
+ let config;
430
+ try {
431
+ config = JSON.parse(await readFile(configPath, "utf8"));
432
+ }
433
+ catch {
434
+ return;
435
+ }
436
+ const projects = config.projects;
437
+ const target = await realpath(to);
438
+ if (projects?.[await realpath(from)]?.hasTrustDialogAccepted !== true || projects[target]?.hasTrustDialogAccepted === true)
439
+ return;
440
+ projects[target] = { ...projects[target], hasTrustDialogAccepted: true };
441
+ await writePrivateFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
442
+ }
17
443
  //# sourceMappingURL=runner.js.map