agentradar-cli 0.2.2 → 0.2.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.
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "agentradar",
3
+ "description": "AgentRadar plugins for agents working on the rail.",
4
+ "owner": {
5
+ "name": "AgentRadar"
6
+ },
7
+ "plugins": [
8
+ {
9
+ "name": "agent-guard",
10
+ "source": "./",
11
+ "description": "AgentRadar work discipline for agents on the rail."
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "agent-guard",
3
+ "version": "0.1.0",
4
+ "description": "AgentRadar work discipline: no going idle while a message or task is owed, and a periodic reminder of the house rules.",
5
+ "author": { "name": "AgentRadar" },
6
+ "license": "MIT"
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "description": "Blocks going idle while AgentRadar work is owed; reminds of the house rules every AR_DISCIPLINE_EVERY turns.",
3
+ "hooks": {
4
+ "Stop": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" stop", "timeout": 30 }] }],
5
+ "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" prompt", "timeout": 10 }] }]
6
+ }
7
+ }
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ // AgentRadar agent-guard: `guard.mjs stop` (Stop hook) and `guard.mjs prompt` (UserPromptSubmit hook).
3
+ // Claude Code and Codex share the hook I/O used here: JSON on stdin, JSON decision on stdout.
4
+ //
5
+ // stop: asks the server (`agentradar call <runner> agent_obligations`) what the agent still owes and
6
+ // blocks the stop while a message or task is unacknowledged, at most MAX_BLOCKS times in a row.
7
+ // Tasks still in progress get one reminder. Anything unexpected (no runner, server down, bad
8
+ // output) lets the agent stop: a guard that wedges the session is worse than no guard.
9
+ // prompt: every AR_DISCIPLINE_EVERY turns (default 20, 0 = off) re-adds the house rules, the must-nots
10
+ // and the installed skills to the context.
11
+ import { spawnSync } from "node:child_process";
12
+ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
13
+ import { homedir, tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const MAX_BLOCKS = 3;
18
+ const runner = process.env.AR_CLI_RUNNER_ID ?? "";
19
+ const cli = process.env.AR_AGENTRADAR_BIN || "agentradar";
20
+
21
+ let input = {};
22
+ try { input = JSON.parse(readFileSync(0, "utf8") || "{}"); } catch {}
23
+ // ponytail: no lock; one session's hooks run one at a time.
24
+ const stateDir = process.env.CLAUDE_PLUGIN_DATA || tmpdir();
25
+ const stateFile = join(stateDir, `agent-guard-${String(input.session_id ?? "none").replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
26
+ let state = { turns: 0, blocks: 0 };
27
+ try { state = { ...state, ...JSON.parse(readFileSync(stateFile, "utf8")) }; } catch {}
28
+ const save = () => { try { mkdirSync(stateDir, { recursive: true }); writeFileSync(stateFile, JSON.stringify(state)); return true; } catch { return false; } };
29
+ const call = (tool) => `agentradar call ${runner} ${tool}`;
30
+
31
+ const RULES = [
32
+ "Answer every message in the channel it came from, and a human first: someone is waiting.",
33
+ `Acknowledge what you handled: ${call("acknowledge_messages")} --args '{"eventIds":[...]}' for messages, ${call("acknowledge_task")} --issueId <id> for a task you take on.`,
34
+ "Talk is not a task: reply conversationally, and create a task only for trackable, billable work.",
35
+ `Before you go idle, run ${call("agent_obligations")}: "owed" must be 0, and every in-progress task must be moving or waiting on someone who knows it.`,
36
+ ];
37
+ const MUST_NOTS = [
38
+ "Never ask for, print or pass on a credential. AgentRadar holds the keys.",
39
+ `Never call decide_outcome or declare_outcome on your own task: only a human accepts your work. Record the final state with ${call("append_task_log")} and reply that it is ready for them.`,
40
+ "Never contact the platform team (submit_feedback) unless the human asks you to.",
41
+ ];
42
+
43
+ // Skill folders the agent can load: this plugin's, the project's, and the user's (Claude Code and Codex).
44
+ function skills() {
45
+ const dirs = [
46
+ fileURLToPath(new URL("../skills", import.meta.url)),
47
+ input.cwd && join(input.cwd, ".claude", "skills"),
48
+ join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "skills"),
49
+ process.env.CODEX_HOME && join(process.env.CODEX_HOME, "skills"),
50
+ ];
51
+ const names = new Set();
52
+ for (const dir of dirs) {
53
+ if (!dir) continue;
54
+ // statSync follows a symlinked skill folder; a broken link or a plain file is not a skill.
55
+ try { for (const name of readdirSync(dir)) if (!name.startsWith(".") && statSync(join(dir, name), { throwIfNoEntry: false })?.isDirectory()) names.add(name); } catch {}
56
+ }
57
+ return [...names].sort();
58
+ }
59
+
60
+ if (process.argv[2] === "prompt") {
61
+ const every = Number.parseInt(process.env.AR_DISCIPLINE_EVERY ?? "20", 10);
62
+ // No reset of `blocks` here: Codex may run this hook for a Stop continuation, which would lift the cap.
63
+ state.turns += 1;
64
+ save();
65
+ if (runner && every > 0 && state.turns % every === 0) {
66
+ process.stdout.write(JSON.stringify({
67
+ hookSpecificOutput: {
68
+ hookEventName: "UserPromptSubmit",
69
+ additionalContext: [
70
+ `AgentRadar house rules (reminder every ${every} turns):`,
71
+ ...RULES.map((rule, i) => `${i + 1}. ${rule}`),
72
+ "You must not:",
73
+ ...MUST_NOTS.map((rule) => `- ${rule}`),
74
+ `Your skills (load the one that fits before acting): ${skills().join(", ") || "none installed"}.`,
75
+ ].join("\n"),
76
+ },
77
+ }));
78
+ }
79
+ } else if (process.argv[2] === "stop" && runner) {
80
+ const reason = stopReason();
81
+ state.blocks += 1;
82
+ // A block that cannot be counted is never sent: an unwritable state dir must not lift the cap.
83
+ if (reason && state.blocks <= MAX_BLOCKS && save()) {
84
+ process.stdout.write(JSON.stringify({ decision: "block", reason }));
85
+ } else {
86
+ if (reason) process.stderr.write(`agent-guard: ${state.blocks > MAX_BLOCKS ? `still owed after ${MAX_BLOCKS} reminders` : "cannot record reminders"}; letting the agent stop\n`);
87
+ state.blocks = 0;
88
+ save();
89
+ }
90
+ }
91
+
92
+ function stopReason() {
93
+ const run = spawnSync(cli, ["call", runner, "agent_obligations"], { encoding: "utf8", timeout: 20_000 });
94
+ if (run.status !== 0) return "";
95
+ let o;
96
+ try { o = JSON.parse(run.stdout); } catch { return ""; }
97
+ const messages = Array.isArray(o.unacknowledgedMessages) ? o.unacknowledgedMessages : [];
98
+ const tasks = Array.isArray(o.unacknowledgedTasks) ? o.unacknowledgedTasks : [];
99
+ const working = Array.isArray(o.inProgressTasks) ? o.inProgressTasks : [];
100
+ const asks = Array.isArray(o.overdueAsks) ? o.overdueAsks : [];
101
+ const name = (t) => `${t.identifier ?? t.issueId} "${t.title ?? ""}" (issueId ${t.issueId})`;
102
+ if (messages.length || tasks.length || asks.length) {
103
+ return [
104
+ `AgentRadar: you still owe ${messages.length + tasks.length + asks.length} item(s). Do not go idle yet.`,
105
+ ...(messages.length ? [
106
+ `Messages received but not acknowledged: ${messages.join(", ")}. Each reached you earlier in this session as an event carrying that eventId (${call("read_history")} --conversationId <its conversationId> shows the thread). Reply to each in the channel it came from (a human first), then ${call("acknowledge_messages")} --args '${JSON.stringify({ eventIds: messages })}'.`,
107
+ ] : []),
108
+ ...tasks.map((t) => `Task assigned to you, not acknowledged: ${name(t)}. Take it on or say why not, then ${call("acknowledge_task")} --issueId ${t.issueId}.`),
109
+ ...asks.map((a) => `Nobody answered your ask in thread ${a.threadId} (due ${a.dueAt}${a.note ? `; ${a.note}` : ""}). Chase them in that thread, or if it no longer matters ${call("expect_reply")} --args '${JSON.stringify({ conversationId: a.threadId, by: null })}'.`),
110
+ `Check again with ${call("agent_obligations")}, then stop.`,
111
+ ].join("\n");
112
+ }
113
+ // Only once per stop: when this stop is itself the result of a block, the agent has seen it.
114
+ if (working.length && !input.stop_hook_active) {
115
+ return [
116
+ `AgentRadar: ${working.length} task(s) still in progress: ${working.map(name).join("; ")}.`,
117
+ "Keep working on them, or, if one is waiting on someone, make sure its thread says so, then stop.",
118
+ ].join("\n");
119
+ }
120
+ return "";
121
+ }
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: agentradar-discipline
3
+ description: >
4
+ House rules for an agent working through AgentRadar. Use before going idle, after receiving a
5
+ channel message or an assigned task, and whenever unsure whether something is still owed.
6
+ ---
7
+
8
+ # AgentRadar discipline
9
+
10
+ You act through `agentradar call <runner-id> <tool>`. Your runner id is in `$AR_CLI_RUNNER_ID`.
11
+
12
+ ## Before you go idle
13
+
14
+ 1. Run `agentradar call $AR_CLI_RUNNER_ID agent_obligations`.
15
+ 2. `owed` must be 0:
16
+ - each id in `unacknowledgedMessages` is a message you received and have not handled. Reply in the channel it came from, then `agentradar call $AR_CLI_RUNNER_ID acknowledge_messages --args '{"eventIds":["<id>"]}'`;
17
+ - each entry in `unacknowledgedTasks` is a task assigned to you. Read it, take it on or say why not, then `agentradar call $AR_CLI_RUNNER_ID acknowledge_task --issueId <issueId>`.
18
+ 3. Each entry in `inProgressTasks` is either still moving, or its thread says who it is waiting on.
19
+ 4. Each entry in `overdueAsks` is a question nobody answered in time. Chase them in that thread with `reply`.
20
+
21
+ The Stop hook runs the same check and will not let you stop while something is owed.
22
+
23
+ ## Always
24
+
25
+ - Answer every message in the channel it came from. A human who wrote is waiting for you; answer them first.
26
+ - Talk is not a task. Reply conversationally by default; create a task only for trackable, billable work.
27
+ - Never ask for, print or pass on a credential. AgentRadar holds the keys.
28
+ - When you ask another agent or a human something, call `expect_reply` on that thread with the time you need the answer by. If nobody answers, `next_event` wakes you with kind `followup`: chase them in the thread. If they stay silent, you may escalate to the next human up the chain from the person you wait on; that is your call, never required.
29
+ - Only a human accepts your work. Asked to "mark it done": record the final state with `append_task_log`, then reply that it is finished and ready for them to accept. Never call `decide_outcome` or `declare_outcome` on your own task.
30
+ - Never contact the platform team (`submit_feedback`) unless the human asks you to. "Just handle it" means do the work in your own tools, not report it to AgentRadar.
package/dist/index.js CHANGED
@@ -4,18 +4,18 @@ import { installVendorSkill } from "./skill-install.js";
4
4
  import { startChannel } from "./channel.js";
5
5
  import { runRestartSelftest, startVendorRunner } from "./runner.js";
6
6
  import { joinOrganisation, leaveOrganisation, setupVendorAgent } from "./setup.js";
7
- import { callAwpTool, callToolOverCli, createTaskOverCli, listenOverCli, listServiceToolsOverCli, parseCallArgs, runServiceToolOverCli } from "./tool-run.js";
7
+ import { callAwpTool, callToolOverCli, createTaskOverCli, listServiceToolsOverCli, parseCallArgs, runServiceToolOverCli } from "./tool-run.js";
8
8
  import { installSyncTimer, syncAgentDir } from "./sync.js";
9
9
  // `run` is the one prefix every agent-facing command shares, so one harness "always allow agentradar run"
10
10
  // covers a whole session: `run <link>` (or `run join <link>`) joins, `run <runner-id> <tool>` calls a tool.
11
11
  export const runArgs = (raw) => raw[0] !== "run" ? raw
12
12
  : raw[1] === "join" ? raw.slice(1)
13
13
  : /^https?:\/\//.test(raw[1] ?? "") ? ["join", ...raw.slice(1)]
14
- : raw.length === 3 && raw[2] === "listen" ? ["listen", raw[1]] : ["call", ...raw.slice(1)];
14
+ : ["call", ...raw.slice(1)];
15
15
  async function main() {
16
16
  const args = runArgs(process.argv.slice(2));
17
17
  if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
18
- console.log("Usage: agentradar run <enrolment-url> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar run <runner-id> listen | agentradar setup [--trust] [--harness claude|codex] | agentradar join <enrolment-url> [--name <n>] [--model <m>] [--trust] | agentradar leave <runner-id> | agentradar channel <runner-id> | agentradar selftest <runner-id> [--status|--restart] | agentradar runner start <runner-id> | agentradar tool list <runner-id> | agentradar tool run <runner-id> <tool-key> [--args '<json>'] | agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar task create|repeat <runner-id> --args '<json>' | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
18
+ console.log("Usage: agentradar run <enrolment-url> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] [--harness claude|codex] | agentradar join <enrolment-url> [--name <n>] [--model <m>] [--trust] | agentradar leave <runner-id> | agentradar channel <runner-id> | agentradar selftest <runner-id> [--status|--restart] | agentradar runner start <runner-id> | agentradar tool list <runner-id> | agentradar tool run <runner-id> <tool-key> [--args '<json>'] | agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar task create|repeat <runner-id> --args '<json>' | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
19
19
  return;
20
20
  }
21
21
  if (args[0] === "leave" && args.length === 2) {
@@ -98,10 +98,6 @@ async function main() {
98
98
  : await runServiceToolOverCli(parsed.runnerId, parsed.tool, parsed.json);
99
99
  return;
100
100
  }
101
- if (args[0] === "listen" && args.length === 2) {
102
- process.exitCode = await listenOverCli(args[1]);
103
- return;
104
- }
105
101
  if (args[0] === "task" && (args[1] === "create" || args[1] === "repeat")) {
106
102
  if (args.length !== 5 || args[3] !== "--args") {
107
103
  console.error("Usage: agentradar task create|repeat <runner-id> --args '<json>'");
@@ -133,7 +129,7 @@ async function main() {
133
129
  return;
134
130
  }
135
131
  const help = args.length === 1 && (args[0] === "--help" || args[0] === "-h");
136
- (help ? console.log : console.error)("Usage: agentradar run <enrolment-url> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar run <runner-id> listen | agentradar setup [--trust] | agentradar join <enrolment-url> [--name <n>] [--model <m>] [--trust] | agentradar leave <runner-id> | agentradar channel <runner-id> | agentradar selftest <runner-id> [--status|--restart] | agentradar runner start <runner-id> | agentradar tool list <runner-id> | agentradar tool run <runner-id> <tool-key> [--args '<json>'] | agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar task create|repeat <runner-id> --args '<json>' | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
132
+ (help ? console.log : console.error)("Usage: agentradar run <enrolment-url> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] | agentradar join <enrolment-url> [--name <n>] [--model <m>] [--trust] | agentradar leave <runner-id> | agentradar channel <runner-id> | agentradar selftest <runner-id> [--status|--restart] | agentradar runner start <runner-id> | agentradar tool list <runner-id> | agentradar tool run <runner-id> <tool-key> [--args '<json>'] | agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar task create|repeat <runner-id> --args '<json>' | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
137
133
  if (!help)
138
134
  process.exitCode = 1;
139
135
  }
package/dist/runner.js CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { promisify } from "node:util";
7
7
  import { shellValue, trustedMcpEndpoint, writePrivateFile } from "./setup.js";
8
8
  import { callAwpTool, envValue } from "./tool-run.js";
9
+ import { installedVersion, runningVersion, selfUpdate } from "./update.js";
9
10
  const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
10
11
  const ENGAGEMENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
11
12
  // Per-hire bearers are ordinary agent keys (`pcp_…`); the runner's own is an AWP key (`ar_…`).
@@ -14,6 +15,8 @@ const HIRE_BEARER = /^(ar|pcp)_[A-Za-z0-9_-]+$/;
14
15
  // A hire always runs Claude, whose MCP config it owns; a Codex home would be the catalog agent's.
15
16
  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
17
  const CODEX_HARNESS = /^(export\s+)?AR_AGENT_HARNESS=\s*["']?codex\b/m;
18
+ // While a pane shows this, its agent is mid-turn (the feeder's in-progress hint, same for Claude and Codex).
19
+ const MID_TURN = /esc to interrupt|\besc to cancel\b/i;
17
20
  const run = promisify(execFile);
18
21
  /** Run from outside the target harness: the command deliberately replaces that harness process. */
19
22
  export async function runRestartSelftest(runnerId, deps = {}) {
@@ -227,7 +230,22 @@ export async function startVendorRunner(runnerId, home = homedir(), deps = {}) {
227
230
  const spawnChild = deps.spawn ?? ((command, args) => spawn(command, args, { stdio: "inherit", env: process.env }));
228
231
  const hires = new Map();
229
232
  let timer;
233
+ let updateTimer;
230
234
  let stopped = false;
235
+ let updating = false;
236
+ const tmux = deps.tmux ?? (async (args) => (await run("bash", [
237
+ "-c", 'PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"; exec "${TMUX_BIN:-tmux}" "$@"', "tmux", ...args,
238
+ ])).stdout);
239
+ const installed = deps.installed ?? (() => installedVersion());
240
+ // Unknown (package.json unreadable): auto-update stays off rather than restart on every check.
241
+ const running = await (deps.installed ? installed() : runningVersion()).catch(() => null);
242
+ // Only our own LaunchAgent / systemd unit sets AGENTRADAR_SERVICE=1, and only they restart the runner.
243
+ // Not the parent pid: a nohup/setsid start is reparented to launchd or a subreaper too, and would be
244
+ // killed for an update with nothing to start it again. Every other runner installs updates, never exits.
245
+ const serviced = deps.serviced ?? process.env.AGENTRADAR_SERVICE === "1";
246
+ // Read once, then dropped: the supervisor's tmux server would otherwise pass it to every runner a vendor
247
+ // later starts by hand in that tmux, and those would exit for an update with nothing to restart them.
248
+ delete process.env.AGENTRADAR_SERVICE;
231
249
  const runHire = (hireEnv) => {
232
250
  if (stopped || hires.has(hireEnv))
233
251
  return;
@@ -399,12 +417,87 @@ export async function startVendorRunner(runnerId, home = homedir(), deps = {}) {
399
417
  const stop = () => {
400
418
  stopped = true;
401
419
  clearTimeout(timer);
420
+ clearTimeout(updateTimer);
402
421
  for (const child of hires.values())
403
422
  child.kill("SIGTERM");
404
423
  };
424
+ // Every tmux session this runner owns: the shared agent's and each hire's.
425
+ let sessions = [];
426
+ const sessionNames = async () => (await Promise.all([envPath, ...hires.keys()].map(async (file) => {
427
+ const contents = await readFile(file, "utf8").catch(() => "");
428
+ return envValue(contents, "TMUX_SESSION") ?? envValue(contents, "AGENT_NAME");
429
+ }))).filter((name) => Boolean(name));
430
+ const midTurn = async () => {
431
+ for (const name of await sessionNames()) {
432
+ if (MID_TURN.test(await tmux(["capture-pane", "-p", "-t", `=${name}:`]).catch(() => "")))
433
+ return true;
434
+ }
435
+ return false;
436
+ };
405
437
  const shared = spawnChild("bash", [supervisor, envPath]);
406
438
  shared.once("error", (error) => { stop(); reject(error); });
407
- shared.once("exit", (code, signal) => { stop(); resolvePromise(code ?? (signal ? 1 : 0)); });
439
+ shared.once("exit", (code, signal) => {
440
+ stop();
441
+ if (!updating)
442
+ return resolvePromise(code ?? (signal ? 1 : 0));
443
+ // The supervisors are gone, so nothing relaunches the old panes: end them, and exit for launchd or
444
+ // systemd to start this runner on the new code, whose supervisors resume each session (AR_RESUME_FLAGS)
445
+ // with the new agent-guard plugin, which loads in place from this package.
446
+ void Promise.all(sessions.map((name) => tmux(["kill-session", "-t", `=${name}`]).catch(() => "")))
447
+ .then(() => resolvePromise(0));
448
+ });
449
+ // The package is shared by every agent on this machine, so AGENTRADAR_NO_AUTO_UPDATE=1 in the service's
450
+ // environment or in any agent's env file turns updates off for all of them.
451
+ const optedOut = async () => {
452
+ const values = [process.env.AGENTRADAR_NO_AUTO_UPDATE];
453
+ for (const file of await readdir(agentDir).catch(() => [])) {
454
+ if (file.endsWith(".env"))
455
+ values.push(envValue(await readFile(join(agentDir, file), "utf8").catch(() => ""), "AGENTRADAR_NO_AUTO_UPDATE") ?? undefined);
456
+ }
457
+ return values.some((value) => value && value !== "0");
458
+ };
459
+ // At start and then daily: install npm's latest release. Every tick: restart onto whatever is on disk if
460
+ // it is not what this process runs (another runner on this machine may have installed it). A restart
461
+ // waits for every pane to finish its turn, for at most an hour.
462
+ let nextCheck = 0;
463
+ let told = "";
464
+ const autoUpdate = async () => {
465
+ try {
466
+ if (running && !(await optedOut())) {
467
+ if (Date.now() >= nextCheck) {
468
+ nextCheck = Date.now() + (deps.updateEveryMs ?? 86_400_000);
469
+ const fresh = await (deps.update ?? selfUpdate)();
470
+ if (fresh)
471
+ log(`AgentRadar CLI ${fresh} installed (was ${running})`);
472
+ }
473
+ const onDisk = await installed();
474
+ if (onDisk !== running && !serviced) {
475
+ if (told !== onDisk)
476
+ log(`AgentRadar CLI ${onDisk} is installed; restart this runner to use it`);
477
+ told = onDisk;
478
+ }
479
+ else if (onDisk !== running) {
480
+ for (let i = 0; i < 240 && !stopped && await midTurn(); i += 1) {
481
+ await new Promise((resolveWait) => setTimeout(resolveWait, deps.idlePollMs ?? 15_000));
482
+ }
483
+ if (stopped)
484
+ return;
485
+ log(`Restarting on AgentRadar CLI ${onDisk}; each session resumes where it was`);
486
+ sessions = await sessionNames();
487
+ updating = true;
488
+ stop();
489
+ shared.kill("SIGTERM");
490
+ return;
491
+ }
492
+ }
493
+ }
494
+ catch (error) {
495
+ log(`Auto-update failed, staying on ${running}: ${error.message}`);
496
+ }
497
+ if (!stopped)
498
+ updateTimer = setTimeout(autoUpdate, deps.tickMs ?? 60_000);
499
+ };
500
+ void autoUpdate();
408
501
  hireTick()
409
502
  .then((tick) => {
410
503
  // The next tick is scheduled only after this one finishes, so a slow clone never overlaps another.
package/dist/setup.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { createHash } from "node:crypto";
2
- import { execFile } from "node:child_process";
3
- import { chmod, cp, lstat, mkdir, readFile, realpath, rename, rm, symlink, writeFile } from "node:fs/promises";
2
+ import { execFile, spawn } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import { chmod, cp, lstat, mkdir, readFile, readlink, realpath, rename, rm, symlink, writeFile } from "node:fs/promises";
4
5
  import { basename, dirname, join, resolve } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { homedir } from "node:os";
7
8
  import * as tls from "node:tls";
8
9
  import { promisify } from "node:util";
9
10
  const DEFAULT_API_URL = "https://vendor.agentradar.com/api";
11
+ /** The service's CLI build (the CLI itself and the agent-guard plugin): `installCli` keeps it current, never the npx cache. */
12
+ const cliDist = (home) => join(home, ".agentradar", "cli", "current", "dist");
10
13
  /** `ar_` is a vendor runner bearer; `pcp_` is the key an organisation's own agent claims through `join`. */
11
14
  const BEARER_SHAPE = /^(?:ar|pcp)_[A-Za-z0-9_-]+$/;
12
15
  /**
@@ -188,6 +191,48 @@ export async function writePrivateFile(path, contents) {
188
191
  }
189
192
  /** The gateway env a session needs to reach its model. Never ANTHROPIC_API_KEY (OAuth or gateway only). */
190
193
  const TOOL_ENV = /^(?:ANTHROPIC_(?!API_KEY$)[A-Z0-9_]+|CLAUDE_SECURESTORAGE_CONFIG_DIR)$/;
194
+ /**
195
+ * One TOML line with its strings emptied and its comment cut, so only real brackets are left to count.
196
+ * `text.open` carries a multi-line string (`"""` or `'''`) from one line to the next.
197
+ */
198
+ function tomlCode(line, text) {
199
+ let out = "";
200
+ let i = 0;
201
+ while (i < line.length) {
202
+ if (text.open) {
203
+ let end = line.indexOf(text.open, i);
204
+ // An odd run of backslashes escapes the quote; an even run is escaped backslashes.
205
+ while (end > 0 && text.open === '"""' && (line.slice(0, end).match(/\\*$/)[0].length % 2))
206
+ end = line.indexOf(text.open, end + 1);
207
+ if (end < 0)
208
+ return out;
209
+ i = end + 3;
210
+ text.open = null;
211
+ out += '""';
212
+ continue;
213
+ }
214
+ const three = line.slice(i, i + 3);
215
+ if (three === '"""' || three === "'''") {
216
+ text.open = three;
217
+ i += 3;
218
+ continue;
219
+ }
220
+ const c = line[i];
221
+ if (c === "#")
222
+ break;
223
+ if (c === '"' || c === "'") {
224
+ let j = i + 1;
225
+ while (j < line.length && line[j] !== c)
226
+ j += c === '"' && line[j] === "\\" ? 2 : 1;
227
+ i = j + 1;
228
+ out += '""';
229
+ continue;
230
+ }
231
+ out += c;
232
+ i++;
233
+ }
234
+ return out;
235
+ }
191
236
  /**
192
237
  * The service's Codex home: the vendor's config.toml without its MCP servers (the agent acts through the CLI,
193
238
  * and Codex has no `--strict-mcp-config`), its approval rules copied, its login linked. auth.json is linked,
@@ -199,27 +244,45 @@ async function codexServiceHome(home, runnerId, env) {
199
244
  const codexHome = join(home, ".agentradar", "codex", runnerId);
200
245
  await mkdir(codexHome, { recursive: true, mode: 0o700 });
201
246
  await chmod(codexHome, 0o700);
202
- let inMcp = false;
247
+ let dropTable = false;
203
248
  let inTable = false;
204
249
  let depth = 0;
205
250
  let dropping = false;
251
+ const text = { open: null };
206
252
  // A restart would otherwise stop on Codex's "Update available" screen, with nobody there to press a key.
253
+ // The vendor's own hooks stay out too: the service runs AgentRadar's guard and nothing it did not review.
207
254
  const config = ["check_for_update_on_startup = false", ...(await readFile(join(source, "config.toml"), "utf8").catch(() => "")).split("\n").filter((line) => {
208
- // Brackets inside strings and comments don't count; a line inside a multi-line array goes with its key.
209
- const bare = line.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""').replace(/#.*/, "");
210
- const continued = depth > 0;
255
+ // Brackets inside strings and comments don't count; a line inside a multi-line array or string goes with its key.
256
+ const continued = depth > 0 || text.open !== null;
257
+ const bare = tomlCode(line, text);
211
258
  depth = Math.max(0, depth + (bare.match(/\[/g)?.length ?? 0) - (bare.match(/\]/g)?.length ?? 0));
212
259
  if (continued)
213
260
  return !dropping;
261
+ // An escape in a quoted key ("\u0068ooks") could name mcp_servers or hooks past both checks.
262
+ if (/^\s*(?:\[\[?\s*)?(?:[\w-]+\s*\.\s*)*"[^"\n]*\\/.test(line))
263
+ throw new Error(`${join(source, "config.toml")} has a quoted key with an escape; spell it plainly and run join again`);
214
264
  const header = /^\s*\[\[?\s*([^\]]+)\]/.exec(line);
215
265
  if (header) {
216
- inMcp = /^["']?mcp_servers\b/.test(header[1]);
266
+ dropTable = /^["']?(?:mcp_servers|hooks)\b/.test(header[1]);
217
267
  inTable = true;
218
268
  }
219
- dropping = inMcp || /^\s*["']?mcp_servers["']?\s*[.=]/.test(line) || (!inTable && /^\s*["']?check_for_update_on_startup["']?\s*=/.test(line));
269
+ dropping = dropTable || /^\s*["']?(?:mcp_servers|hooks)["']?\s*[.=]/.test(line) || (!inTable && /^\s*["']?check_for_update_on_startup["']?\s*=/.test(line));
220
270
  return !dropping;
221
271
  })].join("\n");
272
+ // Fail closed: another client's MCP server, or a hook we did not review, must never reach this client's session.
273
+ if (/^\s*(?:\[\[?\s*)?["']?(?:mcp_servers|hooks)\b/m.test(config))
274
+ throw new Error(`Could not strip the MCP servers and hooks from ${join(source, "config.toml")}; simplify it and run join again`);
222
275
  await writePrivateFile(join(codexHome, "config.toml"), config);
276
+ const guard = `node ${JSON.stringify(join(cliDist(home), "agent-guard", "scripts", "guard.mjs"))}`;
277
+ await writePrivateFile(join(codexHome, "hooks.json"), `${JSON.stringify({ hooks: {
278
+ Stop: [{ hooks: [{ type: "command", command: `${guard} stop`, timeout: 30 }] }],
279
+ UserPromptSubmit: [{ hooks: [{ type: "command", command: `${guard} prompt`, timeout: 10 }] }],
280
+ } }, null, 2)}\n`);
281
+ const trusted = await codexHookHashes(codexHome, env).catch(() => []);
282
+ if (trusted.length) {
283
+ await writePrivateFile(join(codexHome, "config.toml"), `${config.replace(/\n*$/, "\n")}${trusted
284
+ .map(({ key, hash }) => `\n[hooks.state.${JSON.stringify(key)}]\ntrusted_hash = ${JSON.stringify(hash)}\n`).join("")}`);
285
+ }
223
286
  await cp(join(source, "rules"), join(codexHome, "rules"), { recursive: true, force: true }).catch((error) => {
224
287
  if (error.code !== "ENOENT")
225
288
  throw error;
@@ -232,6 +295,46 @@ async function codexServiceHome(home, runnerId, env) {
232
295
  }
233
296
  return { codexHome, keys: [...config.matchAll(/^\s*env_key\s*=\s*"([A-Za-z_][A-Za-z0-9_]*)"/gm)].map((match) => match[1]) };
234
297
  }
298
+ /**
299
+ * Codex runs a user hook only once its hash is recorded as trusted (`[hooks.state."<key>"] trusted_hash`),
300
+ * which is what its /hooks review writes. Asks Codex itself for the keys and hashes of the hooks in
301
+ * `<codexHome>/hooks.json` (app-server `hooks/list`), so the hash is never recomputed here. Any failure
302
+ * leaves them untrusted: Codex then skips them, and the agent runs without the guard rather than not at all.
303
+ */
304
+ async function codexHookHashes(codexHome, env) {
305
+ const child = spawn(env.CODEX_BIN || "codex", ["app-server"], { cwd: codexHome, env: { ...env, CODEX_HOME: codexHome }, stdio: ["pipe", "pipe", "ignore"] });
306
+ const lines = createInterface({ input: child.stdout });
307
+ // A child that never spawned (no codex on PATH) emits no end on stdout; closing the reader ends the loop.
308
+ const stop = () => { child.kill(); lines.close(); };
309
+ const timer = setTimeout(stop, 20_000);
310
+ try {
311
+ child.on("error", stop);
312
+ child.stdin.on("error", () => undefined);
313
+ const send = (message) => child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
314
+ send({ id: 1, method: "initialize", params: { clientInfo: { name: "agentradar", version: "0" } } });
315
+ send({ method: "initialized" });
316
+ send({ id: 2, method: "hooks/list", params: { cwds: [codexHome] } });
317
+ for await (const line of lines) {
318
+ let message;
319
+ try {
320
+ message = JSON.parse(line);
321
+ }
322
+ catch {
323
+ continue;
324
+ }
325
+ if (message.id !== 2)
326
+ continue;
327
+ return (message.result?.data ?? []).flatMap((entry) => entry.hooks ?? [])
328
+ .filter((hook) => hook.source === "user" && basename(hook.sourcePath) === "hooks.json")
329
+ .map((hook) => ({ key: hook.key, hash: hook.currentHash }));
330
+ }
331
+ return [];
332
+ }
333
+ finally {
334
+ clearTimeout(timer);
335
+ child.kill();
336
+ }
337
+ }
235
338
  async function installRunner(home, projectPath, approved, service) {
236
339
  if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(approved.runnerId)) {
237
340
  throw new Error("AgentRadar returned an invalid runner ID");
@@ -254,7 +357,7 @@ async function installRunner(home, projectPath, approved, service) {
254
357
  const envPath = join(agentDir, `${approved.runnerId}.env`);
255
358
  const joined = [];
256
359
  if (service) {
257
- joined.push(`AR_CLI_RUNNER_ID=${shellValue(approved.runnerId)}`);
360
+ joined.push(`AR_CLI_RUNNER_ID=${shellValue(approved.runnerId)}`, `AR_AGENTRADAR_BIN=${shellValue(join(cliDist(home), "index.js"))}`);
258
361
  let toolKeys = [];
259
362
  if (harness === "codex") {
260
363
  const { codexHome, keys } = await codexServiceHome(home, approved.runnerId, service.env);
@@ -267,13 +370,23 @@ async function installRunner(home, projectPath, approved, service) {
267
370
  const bin = (service.env.AGENTRADAR_AGENT_BIN ?? "").trim();
268
371
  if (!bin)
269
372
  throw new Error("Set AGENTRADAR_AGENT_BIN to the command that starts your agent's CLI");
270
- joined.push(`AR_AGENT_BIN=${shellValue(bin)}`, `AR_AGENT_ARGS=${shellValue(service.env.AGENTRADAR_AGENT_ARGS ?? "")}`, `AR_RESUME_FLAGS=${shellValue(service.env.AGENTRADAR_RESUME_ARGS ?? "")}`, `AGENT_PID_PATTERN=${shellValue(basename(bin).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))}`);
373
+ joined.push(`AR_AGENT_BIN=${shellValue(bin)}`, `AR_AGENT_ARGS=${shellValue(service.env.AGENTRADAR_AGENT_ARGS ?? "")}`, `AR_RESUME_FLAGS=${shellValue(service.env.AGENTRADAR_RESUME_ARGS ?? "")}`,
374
+ // The feeder types only when this matches the pane's last lines; without it a live pane counts as idle.
375
+ ...(service.env.AGENTRADAR_INPUT_READY_PATTERN ? [`AR_INPUT_READY_PATTERN=${shellValue(service.env.AGENTRADAR_INPUT_READY_PATTERN)}`] : []), `AGENT_PID_PATTERN=${shellValue(basename(bin).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))}`);
271
376
  }
272
377
  else {
273
378
  joined.push(`AR_RESUME_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`, `AR_FRESH_FLAGS=${shellValue(`--session-id ${service.sessionId}`)}`);
274
379
  const configDirEnv = service.env.CLAUDE_CONFIG_DIR?.trim();
275
380
  if (configDirEnv)
276
381
  joined.push(`CLAUDE_CONFIG_DIR=${shellValue(resolve(configDirEnv))}`);
382
+ // agent-guard, enabled for this session only (`--settings`), loaded from the copy inside this package, with
383
+ // its plugin state under the runner's config so the vendor's own Claude plugins are never touched.
384
+ const settings = join(configDir, "agentradar-settings.json");
385
+ await writePrivateFile(settings, `${JSON.stringify({
386
+ extraKnownMarketplaces: { agentradar: { source: { source: "directory", path: join(cliDist(home), "agent-guard") } } },
387
+ enabledPlugins: { "agent-guard@agentradar": true },
388
+ }, null, 2)}\n`);
389
+ joined.push(`CLAUDE_SETTINGS=${shellValue(settings)}`, `CLAUDE_CODE_PLUGIN_CACHE_DIR=${shellValue(join(configDir, "plugins"))}`);
277
390
  }
278
391
  const toolEnv = Object.entries(service.env)
279
392
  .filter(([key, value]) => (TOOL_ENV.test(key) || toolKeys.includes(key)) && key !== "ANTHROPIC_API_KEY" && value && !value.includes("\n"))
@@ -392,6 +505,11 @@ export async function setupVendorAgent(deps = {}) {
392
505
  }
393
506
  throw new Error("AgentRadar setup code expired; run setup again");
394
507
  }
508
+ /** A sortable key for `x.y.z[-pre]`; a release sorts above its prereleases. ponytail: prereleases of one version tie. */
509
+ const versionRank = (version) => {
510
+ const [core = "", pre] = version.split(/-(.*)/);
511
+ return [...core.split("."), pre === undefined ? "1" : "0"].map((part) => part.padStart(9, "0")).join(".");
512
+ };
395
513
  /**
396
514
  * Copies the running CLI to ~/.agentradar/cli/<version> and points ~/.agentradar/cli/current at it. The service
397
515
  * runs `current`, never the npx cache the CLI may be running from (npm can clean that) or a pinned version.
@@ -406,19 +524,28 @@ export async function installCli(home, pkg = dirname(dirname(fileURLToPath(impor
406
524
  if (!(await lstat(target).then(() => true, () => false))) {
407
525
  // Staged then renamed, so a half-finished copy is never a version directory.
408
526
  const staged = `${target}.${process.pid}.tmp`;
409
- await cp(join(pkg, "dist"), join(staged, "dist"), { recursive: true });
410
- await cp(join(pkg, "package.json"), join(staged, "package.json"));
411
- await rename(staged, target).catch(async (error) => {
527
+ try {
528
+ await cp(join(pkg, "dist"), join(staged, "dist"), { recursive: true });
529
+ await cp(join(pkg, "package.json"), join(staged, "package.json"));
530
+ await rename(staged, target);
531
+ }
532
+ catch (error) {
533
+ // A copy that fails partway leaves nothing behind; losing the rename to another runner is fine.
412
534
  await rm(staged, { recursive: true, force: true });
413
- if (error.code !== "ENOTEMPTY" && error.code !== "EEXIST")
535
+ const code = error.code;
536
+ if (code !== "ENOTEMPTY" && code !== "EEXIST")
414
537
  throw error;
415
- });
538
+ }
416
539
  }
540
+ // Forward only: an older CLI run later (a stale npx cache) never downgrades the service.
541
+ const current = await readlink(join(cli, "current")).catch(() => "");
542
+ if (current && versionRank(current) > versionRank(version))
543
+ return cliDist(home);
417
544
  const link = join(cli, `current.${process.pid}.tmp`);
418
545
  await rm(link, { force: true });
419
546
  await symlink(version, link);
420
547
  await rename(link, join(cli, "current"));
421
- return join(cli, "current", "dist");
548
+ return cliDist(home);
422
549
  }
423
550
  /** `install-agent.sh <id> --load` with the CLI at ~/.agentradar/cli/current, so the service does not depend on PATH. */
424
551
  async function installAgentService(runnerId, home, env) {
@@ -446,7 +573,24 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
446
573
  // AGENTRADAR_AGENT_BIN names any other CLI. Claude Code sets CLAUDECODE in its shell; a machine with both
447
574
  // tools installed may export CODEX_HOME too.
448
575
  const harness = deps.harness ?? (env.AGENTRADAR_AGENT_BIN?.trim() ? "generic" : env.CLAUDECODE || !env.CODEX_HOME ? "claude" : "codex");
449
- const label = harness === "generic" ? basename(env.AGENTRADAR_AGENT_BIN.trim()) : harness === "claude" ? "Claude Code" : "Codex";
576
+ // Checked before any request: the supervisor sources these as one-line shell values, the binary as one word.
577
+ if (harness === "generic") {
578
+ const bin = (env.AGENTRADAR_AGENT_BIN ?? "").trim();
579
+ if (!bin)
580
+ throw new Error("Set AGENTRADAR_AGENT_BIN to the command that starts your agent's CLI");
581
+ if (/\s/.test(bin))
582
+ throw new Error("AGENTRADAR_AGENT_BIN is one command with no spaces; put its flags in AGENTRADAR_AGENT_ARGS");
583
+ if (/[\r\n]/.test(`${env.AGENTRADAR_AGENT_ARGS ?? ""}${env.AGENTRADAR_RESUME_ARGS ?? ""}${env.AGENTRADAR_INPUT_READY_PATTERN ?? ""}`)) {
584
+ throw new Error("AGENTRADAR_AGENT_ARGS, AGENTRADAR_RESUME_ARGS and AGENTRADAR_INPUT_READY_PATTERN must be one line each");
585
+ }
586
+ try {
587
+ new RegExp(env.AGENTRADAR_INPUT_READY_PATTERN ?? "");
588
+ }
589
+ catch {
590
+ throw new Error("AGENTRADAR_INPUT_READY_PATTERN is not a valid regular expression");
591
+ }
592
+ }
593
+ const label = harness === "generic" ? basename((env.AGENTRADAR_AGENT_BIN ?? "").trim()) : harness === "claude" ? "Claude Code" : "Codex";
450
594
  // The agent is a persistent session the service owns, not this one: no MCP entry in this folder (it acts
451
595
  // through `agentradar run`), a runner env pinned to its own session, and a LaunchAgent / systemd unit.
452
596
  const startJoined = async (requestId, agentId, installation, saved) => {
package/dist/tool-run.js CHANGED
@@ -54,43 +54,6 @@ export async function callToolOverCli(runnerId, tool, argumentsJson, deps = {},
54
54
  await writeFile(delivered, JSON.stringify(ids), { mode: 0o600 }).catch(() => undefined);
55
55
  return 0;
56
56
  }
57
- /**
58
- * `agentradar run <runner-id> listen`: bare `next_event` polls (each held by the server, each acking the
59
- * batch before it) until work, a liveness probe or the welcome arrives, or until the next hold could run
60
- * past `budgetMs`. The agent runs it as the last step of every turn, so the harness keeps waiting. Nine
61
- * minutes stays under Claude Code's 10-minute Bash cap; Codex waits on it in 5-minute chunks.
62
- */
63
- export async function listenOverCli(runnerId, deps = {}, budgetMs = 540_000) {
64
- const now = deps.now ?? Date.now;
65
- const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
66
- const start = now();
67
- let longest = 0;
68
- for (;;) {
69
- const began = now();
70
- let said = "";
71
- const code = await callToolOverCli(runnerId, "next_event", undefined, { ...deps, log: (text) => { said = text; } });
72
- if (code !== 0)
73
- return code;
74
- let batch = null;
75
- try {
76
- batch = JSON.parse(said);
77
- }
78
- catch { /* not a batch: keep waiting */ }
79
- // Wake on work, a liveness probe or the welcome; anything else (events: null, an unknown key) waits.
80
- if ((Array.isArray(batch?.events) && batch.events.length) || batch?.livenessProbe || batch?.welcome) {
81
- (deps.log ?? console.log)(said);
82
- return 0;
83
- }
84
- // A caller the server does not hold (not yet hired, a run JWT) answers at once; do not spin on it.
85
- if (now() - began < 10_000)
86
- await sleep(10_000 - (now() - began));
87
- longest = Math.max(longest, now() - began);
88
- if (now() - start + longest > budgetMs) {
89
- (deps.log ?? console.log)(`No client message in ${Math.round((now() - start) / 60_000)} minutes. Run agentradar run ${runnerId} listen again.`);
90
- return 0;
91
- }
92
- }
93
- }
94
57
  /** Split `<runner-id> <tool> [--args '<json>'] [--<argument> <value> ...]`; null when it is not that shape. */
95
58
  export function parseCallArgs(rest) {
96
59
  const positional = [];
package/dist/update.js ADDED
@@ -0,0 +1,94 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+ import { installCli } from "./setup.js";
8
+ const LATEST = "https://registry.npmjs.org/agentradar-cli/latest";
9
+ const TARBALL = /^https:\/\/registry\.npmjs\.org\/agentradar-cli\/-\/agentradar-cli-\d+\.\d+\.\d+\.tgz$/;
10
+ const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
11
+ /** This package's folder: the CLI, the runner scripts and the agent-guard plugin all live in it. */
12
+ export const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
13
+ /** A service's copy, made by `agentradar join` at ~/.agentradar/cli/<version>; the service runs ~/.agentradar/cli/current. */
14
+ const serviceCopy = (root) => basename(dirname(root)) === "cli" && basename(dirname(dirname(root))) === ".agentradar";
15
+ /** The version this process runs, whatever `current` points at now. */
16
+ export const runningVersion = async () => JSON.parse(await readFile(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
17
+ /** The version on disk: for a service's copy, the one `current` points at, which another runner may have moved. */
18
+ export const installedVersion = async (root = PACKAGE_ROOT) => JSON.parse(await readFile(join(serviceCopy(root) ? join(dirname(root), "current") : root, "package.json"), "utf8")).version;
19
+ const newer = (a, b) => {
20
+ const [x, y] = [VERSION.exec(a), VERSION.exec(b)];
21
+ if (!x || !y)
22
+ return false;
23
+ for (let i = 1; i <= 3; i++)
24
+ if (Number(x[i]) !== Number(y[i]))
25
+ return Number(x[i]) > Number(y[i]);
26
+ return false;
27
+ };
28
+ /**
29
+ * Replaces this package in place with npm's latest release, when that is newer. Returns the version it
30
+ * installed, or null when nothing changed. Only a copy npm installed (inside `node_modules`) is touched,
31
+ * never a source checkout. The tarball must match the registry's sha512 before anything is unpacked, and
32
+ * the running folder is swapped by rename, so the service's paths stay valid and a failed update leaves
33
+ * the old copy in place.
34
+ */
35
+ export async function selfUpdate(deps = {}) {
36
+ const root = deps.root ?? PACKAGE_ROOT;
37
+ if (!serviceCopy(root) && basename(dirname(root)) !== "node_modules")
38
+ return null;
39
+ const current = await installedVersion(root);
40
+ const get = (url) => (deps.fetch ?? fetch)(url, { signal: AbortSignal.timeout(60_000) });
41
+ const response = await get(LATEST);
42
+ if (!response.ok)
43
+ throw new Error(`npm registry answered ${response.status}`);
44
+ const latest = await response.json();
45
+ const version = latest.version ?? "";
46
+ if (!newer(version, current))
47
+ return null;
48
+ const tarballUrl = latest.dist?.tarball ?? "";
49
+ const integrity = latest.dist?.integrity ?? "";
50
+ if (!TARBALL.test(tarballUrl) || !tarballUrl.endsWith(`-${version}.tgz`) || !integrity.startsWith("sha512-")) {
51
+ throw new Error(`npm registry returned an unexpected release for ${version}`);
52
+ }
53
+ const download = await get(tarballUrl);
54
+ if (!download.ok)
55
+ throw new Error(`download of ${version} answered ${download.status}`);
56
+ const bytes = Buffer.from(await download.arrayBuffer());
57
+ if (`sha512-${createHash("sha512").update(bytes).digest("base64")}` !== integrity) {
58
+ throw new Error(`download of ${version} does not match the registry's checksum`);
59
+ }
60
+ // Unpacked beside the package, so the swap is a rename on one filesystem.
61
+ const work = await mkdtemp(join(dirname(root), ".agentradar-cli-update-"));
62
+ try {
63
+ const tarball = join(work, "package.tgz");
64
+ const next = join(work, "package");
65
+ await writeFile(tarball, bytes);
66
+ await mkdir(next);
67
+ await (deps.untar ?? ((file, into) => promisify(execFile)("tar", ["-xzf", file, "-C", into, "--strip-components=1"])))(tarball, next);
68
+ const manifest = JSON.parse(await readFile(join(next, "package.json"), "utf8"));
69
+ if (manifest.name !== "agentradar-cli" || manifest.version !== version)
70
+ throw new Error(`unpacked package is not agentradar-cli ${version}`);
71
+ // Unpacking installs no dependencies; a release that needs some has to go through npm.
72
+ if (Object.keys(manifest.dependencies ?? {}).length)
73
+ throw new Error(`agentradar-cli ${version} has dependencies; update it with npm`);
74
+ // A service's copy gets a new version folder and `current` moves to it; the running one stays untouched.
75
+ if (serviceCopy(root)) {
76
+ await installCli(dirname(dirname(dirname(root))), next);
77
+ return version;
78
+ }
79
+ const old = join(work, "old");
80
+ await rename(root, old);
81
+ try {
82
+ await rename(next, root);
83
+ }
84
+ catch (error) {
85
+ await rename(old, root);
86
+ throw error;
87
+ }
88
+ }
89
+ finally {
90
+ await rm(work, { recursive: true, force: true });
91
+ }
92
+ return version;
93
+ }
94
+ //# sourceMappingURL=update.js.map
@@ -314,7 +314,23 @@ export function runnerState(auth = agentAuthState, sample = capturePane) {
314
314
  const authState = auth();
315
315
  if (authState === "unauthenticated") return "auth_required";
316
316
  if (authState !== "authenticated") return "starting";
317
- return sample()?.inputReady ? "ready" : "starting";
317
+ const pane = sample();
318
+ if (pane?.rateLimited) return "rate_limited";
319
+ return pane?.inputReady ? "ready" : "starting";
320
+ }
321
+
322
+ /**
323
+ * M23 — the CLI's own usage-limit message near the bottom of the pane. Measured in the binaries, not
324
+ * guessed: Claude Code 2.1.281 prints "Usage limit reached · …", "You've hit your monthly spend limit",
325
+ * "you have reached your weekly usage limit"; codex-cli 0.149.1 prints "You've hit your usage limit." and
326
+ * "Usage limit reached. You've reached your usage limit.".
327
+ *
328
+ * ponytail: the transcript tail is scanned too, so an agent quoting the phrase in its last lines reads as
329
+ * limited until it scrolls away. Anchor on the status line only if that is observed on a real runner.
330
+ */
331
+ export function paneRateLimited(text) {
332
+ const tail = String(text ?? "").split("\n").slice(-12).join("\n");
333
+ return /usage limit reached|you['’]ve hit your (?:\w+ )?(?:usage|spend) limit|you['’]?(?:ve| have) reached your (?:\w+ )?usage limit/i.test(tail);
318
334
  }
319
335
 
320
336
  // --------------------------------------------------------------------------
@@ -358,8 +374,11 @@ async function injectFrame(frame, label) {
358
374
  console.warn(`[feeder] pane ${TMUX_SESSION} not live — NOT injecting ${label} (will re-deliver / probe times out)`);
359
375
  return false;
360
376
  }
361
- const [literalCmd, enterCmd] = sendKeysCommands(TMUX_SESSION, frame);
362
- const okText = await tmux(literalCmd);
377
+ const [setCmd, pasteCmd, enterCmd] = sendKeysCommands(TMUX_SESSION, frame);
378
+ const okSet = await tmux(setCmd);
379
+ const okText = okSet && (await tmux(pasteCmd));
380
+ // paste-buffer -d frees the buffer only on success; never leave client text sitting in tmux.
381
+ if (okSet && !okText) await tmux(["delete-buffer", "-b", setCmd[2]]);
363
382
  await sleep(KEY_SETTLE_MS);
364
383
  const okEnter = okText && (await tmux(enterCmd));
365
384
  if (okEnter) console.log(`[feeder] injected ${label}`);
@@ -580,7 +599,7 @@ function capturePane() {
580
599
  // build after several turns, which falsely demoted a healthy pane to `starting` and stopped next_event
581
600
  // from draining until its heartbeat aged stale.
582
601
  const inputReady = paneInputReady(text);
583
- return { hash, inProgress, inputReady, channelGate: classifyChannelBanner(text) };
602
+ return { hash, inProgress, inputReady, rateLimited: paneRateLimited(text), channelGate: classifyChannelBanner(text) };
584
603
  }
585
604
  /**
586
605
  * #173b (disc obs) — format-drift detector. If the STRIPPED fingerprint keeps CHANGING while the pane
@@ -141,13 +141,18 @@ export function formatWelcomeFrame(welcome, probe) {
141
141
  }
142
142
 
143
143
  /**
144
- * The two tmux invocations that inject `text` as one submitted turn into `session`'s
145
- * pane: send the literal text (‑l so it is NOT parsed as key names), then a separate
146
- * Enter to submit. Returns argv arrays (no shell) — caller spawns `tmux` with each.
144
+ * The tmux invocations that inject `text` as one submitted turn into `session`'s pane: load it
145
+ * into a buffer, paste it as a bracketed paste, then a separate Enter to submit. Returns argv
146
+ * arrays (no shell) — caller spawns `tmux` with each, the last one after a short settle.
147
+ * Typed with `send-keys -l`, a frame over ~1 KB reached Claude Code 2.1.281 as an unbracketed
148
+ * burst and it kept only the last chunk (measured 2026-09-28: the welcome lost its first 1,022
149
+ * bytes, liveness command included). `-p` brackets only when the CLI asked for it.
147
150
  */
148
151
  export function sendKeysCommands(session, text) {
152
+ const buffer = `agentradar-${session}`;
149
153
  return [
150
- ["send-keys", "-t", session, "-l", text],
154
+ ["set-buffer", "-b", buffer, "--", text],
155
+ ["paste-buffer", "-p", "-d", "-b", buffer, "-t", session],
151
156
  ["send-keys", "-t", session, "Enter"],
152
157
  ];
153
158
  }
@@ -21,6 +21,8 @@ Type=simple
21
21
  # The runner reads ~/.agentradar/agents/%i.env.
22
22
  # systemd --user starts with a bare PATH; the agent CLI must resolve as it did for the installing user.
23
23
  Environment="PATH=__PATH__"
24
+ # Tells the runner systemd restarts it, so it may exit to pick up a CLI update.
25
+ Environment="AGENTRADAR_SERVICE=1"
24
26
  ExecStart=__RUNNER__ %i
25
27
  Restart=always
26
28
  RestartSec=15
@@ -45,7 +45,10 @@
45
45
  <key>HOME</key>
46
46
  <string>__HOME__</string>
47
47
  <key>PATH</key>
48
- <string>__PATH__</string>__EXTRA_ENV__
48
+ <string>__PATH__</string>
49
+ <!-- Tells the runner launchd restarts it, so it may exit to pick up a CLI update. -->
50
+ <key>AGENTRADAR_SERVICE</key>
51
+ <string>1</string>__EXTRA_ENV__
49
52
  </dict>
50
53
 
51
54
  <key>WorkingDirectory</key>
@@ -138,6 +138,8 @@ else
138
138
  # SC2086: CLAUDE_EXTRA_FLAGS is intentionally UNQUOTED so "--chrome" (etc.) word-splits into flags.
139
139
  # shellcheck disable=SC2086
140
140
  _launch_flags=( ${CLAUDE_EXTRA_FLAGS} --strict-mcp-config --mcp-config="${MCP_CONFIG}" )
141
+ # `agentradar join` enables the agent-guard plugin for this session only, through a settings file.
142
+ [ -n "${CLAUDE_SETTINGS:-}" ] && _launch_flags+=( --settings "${CLAUDE_SETTINGS}" )
141
143
  # Pane-alive predicate — MUST be unique per agent. Default to the FULL mcp-config path (each agent's
142
144
  # differs by directory), NOT the basename: on a multi-agent box every agent's config is named
143
145
  # `awp-only.json`, so a basename match would find ANOTHER agent's live claude and mask THIS agent's
@@ -169,7 +171,7 @@ if [ -n "${AGENT_TOOL_ENV_FILE}" ]; then
169
171
  # identity). Reject at parse time (visible warning) AND — belt-and-suspenders — the launch lists
170
172
  # the reserved allowlist AFTER TOOL_ENV_ARGS so `env` last-wins keeps reserved vars authoritative.
171
173
  case "${_tline%%=*}" in
172
- HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC|CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF|CODEX_HOME|AR_RESUME_FLAGS|AR_FRESH_FLAGS|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN)
174
+ HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC|CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF|CODEX_HOME|AR_CLI_RUNNER_ID|AR_AGENTRADAR_BIN|AR_DISCIPLINE_EVERY|CLAUDE_CODE_PLUGIN_CACHE_DIR|AR_RESUME_FLAGS|AR_FRESH_FLAGS|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN)
173
175
  log "WARN ignoring reserved key '${_tline%%=*}' in ${AGENT_TOOL_ENV_FILE} (cannot override supervisor/agent env)"; continue ;;
174
176
  esac
175
177
  TOOL_ENV_ARGS+=("${_tline}")
@@ -186,6 +188,10 @@ fi
186
188
  unset CLAUDE_CODE_OAUTH_TOKEN
187
189
  _claude_cfg=()
188
190
  [ -n "${CLAUDE_CONFIG_DIR:-}" ] && _claude_cfg=( CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR}" )
191
+ # agent-guard (templates/agent-guard): its hooks ask the server what this runner still owes through the CLI.
192
+ for _k in AR_CLI_RUNNER_ID AR_AGENTRADAR_BIN AR_DISCIPLINE_EVERY CLAUDE_CODE_PLUGIN_CACHE_DIR; do
193
+ [ -n "${!_k:-}" ] && _claude_cfg+=( "${_k}=${!_k}" )
194
+ done
189
195
 
190
196
  # HARD RULE — OAuth only, never a pay-as-you-go API key. Scoped to the Claude harness because that is where
191
197
  # the variable decides anything: `claude` prefers ANTHROPIC_API_KEY over the subscription session, so its
@@ -345,7 +351,7 @@ ensure_agent() {
345
351
  CODEX_HOME="${CODEX_HOME:-}" AR_RESUME_FLAGS="${AR_RESUME_FLAGS}" AR_FRESH_FLAGS="${AR_FRESH_FLAGS}" \
346
352
  DISABLE_AUTOUPDATER=1 CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF=1 \
347
353
  ${_claude_cfg[@]+"${_claude_cfg[@]}"} ${_tool_env[@]+"${_tool_env[@]}"} \
348
- /bin/sh -c 'if [ -n "${AR_TOOL_ENV_FILE:-}" ]; then while IFS= read -r _l || [ -n "$_l" ]; do case "$_l" in ""|\#*) continue ;; esac; _k=${_l%%=*}; _v=${_l#*=}; case "$_k" in HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC|CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF|CODEX_HOME|AR_RESUME_FLAGS|AR_FRESH_FLAGS|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN) continue ;; esac; export "$_k=$_v"; done < "$AR_TOOL_ENV_FILE"; unset AR_TOOL_ENV_FILE _l _k _v; fi; RF="${AR_RESUME_FLAGS:-}"; FF="${AR_FRESH_FLAGS:-}"; unset AR_RESUME_FLAGS AR_FRESH_FLAGS; CB="$1"; shift; S=$(date +%s); "$CB" $RF "$@" || [ $(( $(date +%s) - S )) -ge 30 ] || exec "$CB" $FF "$@"' sh "${AGENT_BIN}" "${_launch_flags[@]}" || {
354
+ /bin/sh -c 'if [ -n "${AR_TOOL_ENV_FILE:-}" ]; then while IFS= read -r _l || [ -n "$_l" ]; do case "$_l" in ""|\#*) continue ;; esac; _k=${_l%%=*}; _v=${_l#*=}; case "$_k" in HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC|CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF|CODEX_HOME|AR_CLI_RUNNER_ID|AR_AGENTRADAR_BIN|AR_DISCIPLINE_EVERY|CLAUDE_CODE_PLUGIN_CACHE_DIR|AR_RESUME_FLAGS|AR_FRESH_FLAGS|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN) continue ;; esac; export "$_k=$_v"; done < "$AR_TOOL_ENV_FILE"; unset AR_TOOL_ENV_FILE _l _k _v; fi; RF="${AR_RESUME_FLAGS:-}"; FF="${AR_FRESH_FLAGS:-}"; unset AR_RESUME_FLAGS AR_FRESH_FLAGS; CB="$1"; shift; S=$(date +%s); "$CB" $RF "$@" || [ $(( $(date +%s) - S )) -ge 30 ] || exec "$CB" $FF "$@"' sh "${AGENT_BIN}" "${_launch_flags[@]}" || {
349
355
  log "tmux could not start agent session '${TMUX_SESSION}'"
350
356
  return 1
351
357
  }
package/package.json CHANGED
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "name": "agentradar-cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
+ "engines": {
5
+ "node": ">=20"
6
+ },
4
7
  "description": "Secure AgentRadar vendor setup and skill installer",
5
8
  "type": "module",
6
9
  "bin": {
@@ -14,8 +17,10 @@
14
17
  "dist/skill-install.js",
15
18
  "dist/tool-run.js",
16
19
  "dist/sync.js",
20
+ "dist/update.js",
17
21
  "dist/vendor-runner/**",
18
22
  "dist/agentradar-vendor/SKILL.md",
23
+ "dist/agent-guard",
19
24
  "README.md"
20
25
  ],
21
26
  "license": "MIT",
@@ -23,7 +28,7 @@
23
28
  "access": "public"
24
29
  },
25
30
  "scripts": {
26
- "build": "tsc && mkdir -p dist/agentradar-vendor dist/vendor-runner/supervisor && cp ../../skills/agentradar-vendor/SKILL.md dist/agentradar-vendor/SKILL.md && cp ../../templates/vendor-runner/feeder.mjs ../../templates/vendor-runner/pane-live.sh ../../templates/vendor-runner/session-frame.mjs ../../templates/vendor-runner/terminal-bridge.mjs dist/vendor-runner/ && cp ../../templates/vendor-runner/supervisor/supervise-agent.sh ../../templates/vendor-runner/supervisor/install-agent.sh ../../templates/vendor-runner/supervisor/com.agentradar.agent.plist.template ../../templates/vendor-runner/supervisor/agentradar-agent@.service.template dist/vendor-runner/supervisor/ && chmod +x dist/index.js dist/vendor-runner/pane-live.sh dist/vendor-runner/supervisor/supervise-agent.sh dist/vendor-runner/supervisor/install-agent.sh",
31
+ "build": "tsc && mkdir -p dist/agentradar-vendor dist/vendor-runner/supervisor && cp ../../skills/agentradar-vendor/SKILL.md dist/agentradar-vendor/SKILL.md && cp ../../templates/vendor-runner/feeder.mjs ../../templates/vendor-runner/pane-live.sh ../../templates/vendor-runner/session-frame.mjs ../../templates/vendor-runner/terminal-bridge.mjs dist/vendor-runner/ && cp ../../templates/vendor-runner/supervisor/supervise-agent.sh ../../templates/vendor-runner/supervisor/install-agent.sh ../../templates/vendor-runner/supervisor/com.agentradar.agent.plist.template ../../templates/vendor-runner/supervisor/agentradar-agent@.service.template dist/vendor-runner/supervisor/ && rm -rf dist/agent-guard && cp -R ../../templates/agent-guard dist/agent-guard && rm dist/agent-guard/scripts/guard.test.mjs && chmod +x dist/index.js dist/vendor-runner/pane-live.sh dist/vendor-runner/supervisor/supervise-agent.sh dist/vendor-runner/supervisor/install-agent.sh",
27
32
  "clean": "rm -rf dist",
28
33
  "prepack": "pnpm run build",
29
34
  "typecheck": "tsc --noEmit",