agentradar-cli 0.3.4 → 0.3.6

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/admin.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * ADMIN-BROADCAST (Vadym 12310/12828): `agentradar admin broadcast` for an instance admin. Authenticates with a board
3
+ * API key in AGENTRADAR_ADMIN_TOKEN, never a runner key; the server refuses anyone who is not an instance admin.
4
+ */
5
+ export const ADMIN_BROADCAST_USAGE = "Usage: agentradar admin broadcast --dry-run|--send --target all --text <text> --cta <next step>"
6
+ + " (env AGENTRADAR_ADMIN_TOKEN = an instance admin's board API key; AGENTRADAR_API_URL = the API base)";
7
+ export async function adminOverCli(args, deps = {}) {
8
+ const env = deps.env ?? process.env, log = deps.log ?? console.log, logError = deps.logError ?? console.error;
9
+ const named = {};
10
+ const modes = args.slice(1).filter((arg) => arg === "--dry-run" || arg === "--send");
11
+ for (let i = 1; i < args.length; i++) {
12
+ if (["--target", "--text", "--cta"].includes(args[i]))
13
+ named[args[i].slice(2)] = args[++i] ?? "";
14
+ else if (!modes.includes(args[i]))
15
+ modes.push("?");
16
+ }
17
+ if (args[0] !== "broadcast" || modes.length !== 1 || modes[0] === "?" || named.target !== "all" || !named.text || !named.cta) {
18
+ logError(ADMIN_BROADCAST_USAGE);
19
+ return 2;
20
+ }
21
+ const token = env.AGENTRADAR_ADMIN_TOKEN?.trim();
22
+ if (!token) {
23
+ logError("Set AGENTRADAR_ADMIN_TOKEN to an instance admin's board API key");
24
+ return 2;
25
+ }
26
+ const base = (env.AGENTRADAR_API_URL ?? "https://vendor.agentradar.com/api").replace(/\/+$/, "");
27
+ const res = await (deps.fetch ?? fetch)(`${base}/admin/agent-broadcast`, {
28
+ method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
29
+ body: JSON.stringify({ target: "all", text: named.text, cta: named.cta, dryRun: modes[0] === "--dry-run" }),
30
+ });
31
+ const text = await res.text();
32
+ if (!res.ok) {
33
+ logError(`Refused (${res.status}): ${text}`);
34
+ return 1;
35
+ }
36
+ log(text);
37
+ return 0;
38
+ }
39
+ //# sourceMappingURL=admin.js.map
@@ -1,11 +1,11 @@
1
1
  {
2
- "description": "Marks the session as guarded (the runner reports ready only then); blocks going idle while AgentRadar work is owed; reminds of the house rules every AR_DISCIPLINE_EVERY turns; guards AgentRadar compact-then-switch model changes (a crashed PreModelSwitch hook exits 2 and refuses).",
2
+ "description": "Marks the session as guarded (the runner reports ready only then); blocks going idle while AgentRadar work is owed; reminds of the house rules every AR_DISCIPLINE_EVERY turns; reports compactions to AgentRadar; guards AgentRadar compact-then-switch model changes (a crashed PreModelSwitch hook exits 2 and refuses).",
3
3
  "hooks": {
4
4
  "SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" start", "timeout": 10 }] }],
5
5
  "Stop": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" stop", "timeout": 30 }] }],
6
6
  "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" prompt", "timeout": 10 }] }],
7
- "PreCompact": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PreCompact", "timeout": 30 }] }],
8
- "PostCompact": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PostCompact", "timeout": 30 }] }],
7
+ "PreCompact": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PreCompact", "timeout": 30 }, { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/compaction-report.mjs\" PreCompact", "timeout": 30 }] }],
8
+ "PostCompact": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PostCompact", "timeout": 30 }, { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/compaction-report.mjs\" PostCompact", "timeout": 30 }] }],
9
9
  "PreModelSwitch": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PreModelSwitch || exit 2", "timeout": 30 }] }],
10
10
  "PostModelSwitch": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PostModelSwitch", "timeout": 30 }] }]
11
11
  }
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ // Reports this session's compactions to AgentRadar (Vadym 11657). Registered for PreCompact and PostCompact as
3
+ // `compaction-report.mjs <event>`; JSON on stdin. Observation only: it never blocks (exit 0 whatever happens).
4
+ // PostCompact stdin carries no token counts, so they come from the transcript's compact_boundary line.
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
10
+
11
+ const ID = /^[a-zA-Z0-9_-]{1,100}$/;
12
+ const TAIL_BYTES = 1024 * 1024;
13
+ /** The last lines of a transcript, read from its last 1 MB only (a long session's file is far larger). */
14
+ const tail = (path) => {
15
+ let fd;
16
+ try {
17
+ fd = openSync(path, "r");
18
+ const { size } = fstatSync(fd);
19
+ const buf = Buffer.alloc(Math.min(size, TAIL_BYTES));
20
+ readSync(fd, buf, 0, buf.length, size - buf.length);
21
+ return buf.toString("utf8").split("\n").slice(-200);
22
+ } catch { return []; } finally { if (fd !== undefined) closeSync(fd); }
23
+ };
24
+
25
+ /** The newest compact_boundary written at or after `sinceMs`, as { preTokens, postTokens, durationMs }. */
26
+ export function lastBoundary(transcriptPath, sinceMs) {
27
+ for (const line of tail(transcriptPath).reverse()) {
28
+ if (!line.includes('"compact_boundary"')) continue;
29
+ try {
30
+ const entry = JSON.parse(line);
31
+ if (Date.parse(entry.timestamp) < sinceMs) return null;
32
+ const { preTokens = null, postTokens = null, durationMs = null } = entry.compactMetadata ?? {};
33
+ return { preTokens, postTokens, durationMs };
34
+ } catch { /* partial line: keep looking */ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /** One hook call. `send` posts a report; `stateDir` holds the start time between the two hooks. */
40
+ export async function reportCompaction(event, input, { send, followUp, stateDir = tmpdir(), now = Date.now }) {
41
+ if (input?.hook_event_name !== event || !ID.test(input.session_id ?? "")) return;
42
+ const marker = join(stateDir, `agentradar-compaction-${input.session_id}.json`);
43
+ const trigger = typeof input.trigger === "string" ? input.trigger.slice(0, 40) : null;
44
+ if (event === "PreCompact") {
45
+ writeFileSync(marker, JSON.stringify({ at: now() }), { mode: 0o600 });
46
+ return send({ phase: "start", trigger });
47
+ }
48
+ if (event !== "PostCompact") return;
49
+ let startedAt = now();
50
+ if (existsSync(marker)) { try { startedAt = JSON.parse(readFileSync(marker, "utf8")).at; } catch { /* keep now */ } }
51
+ rmSync(marker, { force: true });
52
+ // The compact_boundary line (the only source of token counts) is written after this hook returns, so it is
53
+ // reported separately, by a detached follow-up; this report must not wait for it.
54
+ send({ phase: "end", outcome: "ok", trigger, durationMs: now() - startedAt });
55
+ return followUp?.({ transcriptPath: input.transcript_path, startedAt, trigger });
56
+ }
57
+
58
+ /** Detached follow-up: waits up to `waitMs` for the boundary this compaction wrote, then reports its token counts. */
59
+ export async function reportTokens({ transcriptPath, startedAt, trigger }, { send, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), waitMs = 60_000, pollMs = 500 }) {
60
+ for (let waited = 0; waited <= waitMs; waited += pollMs) {
61
+ const boundary = lastBoundary(transcriptPath, startedAt - 1_000);
62
+ if (boundary) return send({ phase: "tokens", trigger, ...boundary });
63
+ await sleep(pollMs);
64
+ }
65
+ }
66
+
67
+ const sendToServer = (report) => spawnSync(process.env.AR_AGENTRADAR_BIN || "agentradar",
68
+ ["call", process.env.AR_CLI_RUNNER_ID || "hosted", "report_compaction", "--args", JSON.stringify(report)], { encoding: "utf8", timeout: 20_000 });
69
+ const spawnFollowUp = (job) => spawn(process.execPath, [fileURLToPath(import.meta.url), "tokens", JSON.stringify(job)], { detached: true, stdio: "ignore" }).unref();
70
+
71
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
72
+ try {
73
+ if (process.argv[2] === "tokens") await reportTokens(JSON.parse(process.argv[3]), { send: sendToServer });
74
+ else await reportCompaction(process.argv[2], JSON.parse(readFileSync(0, "utf8")), { send: sendToServer, followUp: spawnFollowUp });
75
+ } catch { /* observation only */ }
76
+ }
@@ -1,24 +1,26 @@
1
1
  ---
2
2
  name: agentradar-vendor
3
3
  description: >
4
- Run an AgentRadar vendor agent on vendor-owned compute through the single
5
- AgentRadar MCP. Use when receiving client work, replying, reporting usage,
4
+ Run an AgentRadar vendor agent on vendor-owned compute through the
5
+ AgentRadar CLI. Use when receiving client work, replying, reporting usage,
6
6
  or checking context for an AgentRadar engagement.
7
7
  ---
8
8
 
9
9
  # AgentRadar vendor agent
10
10
 
11
- You work on the vendor's computer and connect to AgentRadar through the `agentradar` MCP only. AgentRadar holds every client channel credential; never ask for or store a client's Slack, email, Telegram, or AgentRadar token.
11
+ Every tool named in this skill runs as `agentradar run $AR_CLI_RUNNER_ID <tool>`; add `--help` for its arguments. AgentRadar is CLI-only: there is no AgentRadar MCP to connect to.
12
+
13
+ You work on the vendor's computer and act on AgentRadar through the `agentradar` CLI only (`agentradar run $AR_CLI_RUNNER_ID <tool>`). AgentRadar holds every client channel credential; never ask for or store a client's Slack, email, Telegram, or AgentRadar token.
12
14
 
13
15
  ## Authentication
14
16
 
15
17
  - Authenticate Claude Code interactively with `/login` and the vendor's claude.ai subscription.
16
18
  - Never use `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`.
17
- - Never print, inspect, copy, or move the AgentRadar MCP authorization header. The setup CLI stores it in the project-local entry of `~/.claude.json`, outside the project repository.
19
+ - Never print, inspect, copy, or move the AgentRadar key. The setup CLI stores it in your runner config under `~/.agentradar/agents/`, outside the project repository.
18
20
 
19
21
  ## Start of every session
20
22
 
21
- Call `get_my_context` on the AgentRadar MCP FIRST, before any other tool. It returns your client organisation, your role and function, your goals, your metrics with their current quotas, your superior, your direct reports and your colleagues. It also carries your memory snapshot, the Markdown files in your personal vault: `recall` fetches newer files and `remember` saves a durable fact, preference or correction.
23
+ Run `get_my_context` FIRST, before any other tool. It returns your client organisation, your role and function, your goals, your metrics with their current quotas, your superior, your direct reports and your colleagues. It also carries your memory snapshot, the Markdown files in your personal vault: `recall` fetches newer files and `remember` saves a durable fact, preference or correction.
22
24
 
23
25
  Keep each human's page in company memory current, every fact cited, as the Humans section of the `agentradar-memory-and-skills` skill says. A page is knowledge, never permission.
24
26
 
@@ -32,10 +34,10 @@ Write to humans so a 13-year-old would understand: a few short plain lines, then
32
34
 
33
35
  ## Work loop
34
36
 
35
- 1. Use the AgentRadar MCP to receive the next event or assigned work.
37
+ 1. Run `agentradar run $AR_CLI_RUNNER_ID inbox next` to receive the next event or assigned work.
36
38
  2. Read the supplied engagement context before acting.
37
39
  3. Do the work in this project folder on the vendor's compute.
38
- 4. Reply through the MCP so AgentRadar can enforce the client boundary and audit the action.
39
- 5. Report actual usage through the MCP when the work incurs billable time or metered extras.
40
+ 4. Reply with `agentradar run $AR_CLI_RUNNER_ID reply` so AgentRadar can enforce the client boundary and audit the action.
41
+ 5. Report actual usage with `report_usage` when the work incurs billable time or metered extras.
40
42
 
41
- Do not connect directly to a client's workspace or bypass the MCP with direct channel/API calls.
43
+ Do not connect directly to a client's workspace or bypass AgentRadar with direct channel/API calls.
package/dist/channel.js CHANGED
@@ -1,16 +1,17 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, statSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { createRequire } from "node:module";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
8
  import { envValue } from "./tool-run.js";
9
+ import { noteCliUpdate } from "./update.js";
9
10
  import { trustedMcpEndpoint } from "./setup.js";
10
11
  import { syncJoinedSkills } from "./skill-install.js";
11
- export const CHANNEL_GUIDANCE = "AgentRadar channel messages are wake signals only. Call next_event to receive the events, then pass their eventIds as ackEventIds on your next call. Answer livenessProbe with liveness_ack. Never acknowledge an event you have not read. Never poll next_event on your own or keep a loop running: when a channel message arrives, drain it, then end your turn.";
12
- export const CHANNEL_BLOCKED = "AgentRadar has not observed a next_event call from this session. Channel delivery is unconfirmed. On Claude Team/Enterprise an Owner must enable Channels (channelsEnabled); the development flag cannot override that policy. Until then, deliver with the feeder: run your join again with --feeder (agentradar join <your join link> --feeder). Your company sees this agent as not receiving messages until one or the other works.";
13
- /** One local MCP, forwarding tools to AWP. Wake signals never consume the durable queue. */
12
+ export const CHANNEL_GUIDANCE = "AgentRadar channel messages are wake signals only. Receive the events with `agentradar run $AR_CLI_RUNNER_ID inbox next`; the next call acknowledges the batch you read. Answer a liveness check with `agentradar run $AR_CLI_RUNNER_ID liveness_ack --nonce <nonce>`. Never acknowledge an event you have not read. Never poll on your own or keep a loop running: when a channel message arrives, drain it, then end your turn.";
13
+ export const CHANNEL_BLOCKED = "AgentRadar has not seen this session run `agentradar run $AR_CLI_RUNNER_ID inbox next`. Channel delivery is unconfirmed. On Claude Team/Enterprise an Owner must enable Channels (channelsEnabled); the development flag cannot override that policy. Until then, deliver with the feeder: run your join again with --feeder (agentradar join <your join link> --feeder). Your company sees this agent as not receiving messages until one or the other works.";
14
+ /** The channel: a local stdio MCP with no tools, only wake signals. The agent acts through the CLI; wake signals never consume the durable queue. */
14
15
  export async function startChannel(runnerId, deps = {}) {
15
16
  if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(runnerId))
16
17
  throw new Error("Invalid runner ID");
@@ -27,8 +28,17 @@ export async function startChannel(runnerId, deps = {}) {
27
28
  let sequence = 0;
28
29
  let polling = false;
29
30
  let agentPolled = false;
31
+ const startedAt = Date.now();
32
+ // The CLI rewrites <runner>.delivered.json after every `inbox next`, so a newer mtime is the session having polled.
33
+ const delivered = join(home, ".agentradar", "agents", `${runnerId}.delivered.json`);
34
+ const polled = () => agentPolled ||= (() => { try {
35
+ return statSync(delivered).mtimeMs >= startedAt;
36
+ }
37
+ catch {
38
+ return false;
39
+ } })();
30
40
  // Vadym 10342: an unconfirmed channel is reported to AgentRadar (runnerState channel_unconfirmed), so the owner sees
31
- // the agent is not receiving messages; "ready" again once the session does call next_event.
41
+ // the agent is not receiving messages; "ready" again once the session does run `inbox next`.
32
42
  let unconfirmed = null;
33
43
  let warning;
34
44
  let skillSync;
@@ -39,10 +49,11 @@ export async function startChannel(runnerId, deps = {}) {
39
49
  const response = await (deps.fetch ?? fetch)(endpoint, {
40
50
  method: "POST",
41
51
  redirect: "error",
42
- headers: { "x-agentradar-cli": createRequire(import.meta.url)("../package.json").version, authorization: `Bearer ${bearer}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
52
+ headers: { "x-agentradar-cli": createRequire(import.meta.url)("../package.json").version, "x-agentradar-surface": "channel", authorization: `Bearer ${bearer}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
43
53
  body: JSON.stringify({ jsonrpc: "2.0", id: ++sequence, method, params }),
44
54
  signal: AbortSignal.any([stop.signal, AbortSignal.timeout(230_000)]),
45
55
  });
56
+ await noteCliUpdate(response.headers, logError);
46
57
  if (!response.ok) {
47
58
  const said = response.status === 426 ? await response.json().then((body) => body.error?.message).catch(() => null) : null;
48
59
  throw new Error(said ?? `AgentRadar refused the channel call (${response.status})`);
@@ -81,7 +92,7 @@ export async function startChannel(runnerId, deps = {}) {
81
92
  await next({ runnerState: "channel_unconfirmed" });
82
93
  unconfirmed = "reported";
83
94
  }
84
- if (unconfirmed === "reported" && agentPolled)
95
+ if (unconfirmed === "reported" && polled())
85
96
  unconfirmed = null;
86
97
  // While reported, no runnerState: the stored state stands, and the poll still drains for wake signals.
87
98
  // AGENT-GUARD-ACTIVE: the supervisor's marker, written by agent-guard's SessionStart hook in this session.
@@ -95,7 +106,7 @@ export async function startChannel(runnerId, deps = {}) {
95
106
  const wake = JSON.stringify([events.map((event) => event.eventId), payload.livenessProbe?.nonce, Boolean(payload.onboarding)]);
96
107
  if ((events.length || payload.livenessProbe || payload.onboarding) && (wake !== lastWake || Date.now() - lastSent >= 30_000)) {
97
108
  await send({ jsonrpc: "2.0", method: "notifications/claude/channel", params: {
98
- content: `${payload.firstStep ? `${payload.firstStep} ` : ""}${events.length} new AgentRadar events; call next_event${payload.livenessProbe ? " to receive your liveness check" : ""}.`,
109
+ content: `${payload.firstStep ? `${payload.firstStep} ` : ""}${events.length} new AgentRadar events; run \`agentradar run ${runnerId} inbox next\`${payload.livenessProbe ? " to receive your liveness check" : ""}.`,
99
110
  meta: { count: String(events.length) },
100
111
  } });
101
112
  lastWake = wake;
@@ -116,7 +127,7 @@ export async function startChannel(runnerId, deps = {}) {
116
127
  if (message.method === "initialize") {
117
128
  await send({ jsonrpc: "2.0", id: message.id, result: {
118
129
  protocolVersion: "2024-11-05",
119
- capabilities: { tools: {}, experimental: { "claude/channel": {} } },
130
+ capabilities: { experimental: { "claude/channel": {} } },
120
131
  serverInfo: { name: "agentradar", version: createRequire(import.meta.url)("../package.json").version },
121
132
  instructions: CHANNEL_GUIDANCE,
122
133
  } });
@@ -124,7 +135,7 @@ export async function startChannel(runnerId, deps = {}) {
124
135
  else if (message.method === "notifications/initialized") {
125
136
  if (!polling) {
126
137
  polling = true;
127
- warning = setTimeout(() => { if (!agentPolled) {
138
+ warning = setTimeout(() => { if (!polled()) {
128
139
  logError(CHANNEL_BLOCKED);
129
140
  unconfirmed = "pending";
130
141
  } }, deps.confirmMs ?? 30_000);
@@ -142,16 +153,6 @@ export async function startChannel(runnerId, deps = {}) {
142
153
  if (message.method === "ping") {
143
154
  await send({ jsonrpc: "2.0", id: message.id, result: {} });
144
155
  }
145
- else if (message.method === "tools/list" || message.method === "tools/call") {
146
- const params = message.params ?? {};
147
- const isNext = message.method === "tools/call" && params.name === "next_event";
148
- const response = isNext ? await next(params.arguments ?? {}) : await remote(message.method, params);
149
- if (isNext && !response.error && !response.result?.isError) {
150
- agentPolled = true;
151
- clearTimeout(warning);
152
- }
153
- await send({ jsonrpc: "2.0", id: message.id, ...(response.error ? { error: response.error } : { result: response.result }) });
154
- }
155
156
  else {
156
157
  await send({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Method not found" } });
157
158
  }
package/dist/index.js CHANGED
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
+ import { homedir } from "node:os";
3
4
  import { installVendorSkill } from "./skill-install.js";
4
5
  import { startChannel } from "./channel.js";
5
6
  import { runRestartSelftest, startVendorRunner } from "./runner.js";
6
- import { cancelJoin, CODEX_GUARD_REFUSAL, ensureCodexGuard, joinOrganisation, leaveOrganisation, setupVendorAgent } from "./setup.js";
7
+ import { cancelJoin, CODEX_GUARD_REFUSAL, dropStaleMcp, ensureCodexGuard, joinOrganisation, leaveOrganisation, setupVendorAgent } from "./setup.js";
7
8
  import { callAwpTool, callToolOverCli, createTaskOverCli, INBOX_SEARCH_USAGE, METRIC_REPORT_USAGE, PROPOSE_HUMAN_USAGE, inboxSearchOverCli, LISTING_DESCRIBE_USAGE, listServiceToolsOverCli, parseCallArgs, PERMISSION_VERB_TOOLS, runServiceToolOverCli, TASK_VERB_TOOLS } from "./tool-run.js";
8
9
  import { installSyncTimer, syncAgentDir } from "./sync.js";
9
10
  import { importVaultOverCli } from "./vault-import.js";
10
11
  import { cloneVaultOverCli } from "./vault-clone.js";
11
12
  import { secretsOverCli } from "./secrets.js";
13
+ import { adminOverCli } from "./admin.js";
12
14
  // `run` is the one prefix every agent-facing command shares, so one harness "always allow agentradar run"
13
15
  // covers a whole session: `run <link>` (or `run join <link>`) joins, `run <runner-id> <tool>` calls a tool.
14
16
  export const runArgs = (raw) => raw[0] !== "run" ? raw
@@ -18,7 +20,7 @@ export const runArgs = (raw) => raw[0] !== "run" ? raw
18
20
  async function main() {
19
21
  const args = runArgs(process.argv.slice(2));
20
22
  if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
21
- console.log("Usage: agentradar run <join-link> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] [--harness claude|codex] | agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--feeder] | agentradar join cancel <join-link|request-id> | 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|make-recurring <runner-id> --args '<json>' | agentradar inbox search <runner-id> [--query <text>] [--<filter> <value> ...] [--json] | agentradar listing describe <runner-id> --capabilities <text> | agentradar permissions mine|roles|set|request|grant-tool <runner-id> [--<argument> <value> ...] | agentradar vault import <runner-id> <dir> [--vault <vault-id>] [--source <name>] [--write] | agentradar vault clone <runner-id> <vault-id> [dir] | agentradar metric report <runner-id> --metric-id <id> --value <n> --period-start <iso> --period-end <iso> [--evidence <text>] | agentradar secrets pull|renew <runner-id> | agentradar secrets exec <runner-id> -- <command> | agentradar propose-human <runner-id> --name <name> --email <email> --role-id <id> | agentradar secrets upload <runner-id> --env-file <path> | --from-env <NAME,...> | --prompt <NAME> [--replace] | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
23
+ console.log("Usage: agentradar run <join-link> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] [--harness claude|codex] | agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--feeder] | agentradar join cancel <join-link|request-id> | 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|make-recurring <runner-id> --args '<json>' | agentradar inbox search <runner-id> [--query <text>] [--<filter> <value> ...] [--json] | agentradar listing describe <runner-id> --capabilities <text> | agentradar permissions mine|roles|set|request|grant-tool <runner-id> [--<argument> <value> ...] | agentradar vault import <runner-id> <dir> [--vault <vault-id>] [--source <name>] [--write] | agentradar vault clone <runner-id> <vault-id> [dir] | agentradar metric report <runner-id> --metric-id <id> --value <n> --period-start <iso> --period-end <iso> [--evidence <text>] | agentradar secrets pull|renew <runner-id> | agentradar secrets exec <runner-id> -- <command> | agentradar propose-human <runner-id> --name <name> --email <email> --role-id <id> | agentradar secrets upload <runner-id> --env-file <path> | --from-env <NAME,...> | --prompt <NAME> [--replace] | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>] | agentradar admin broadcast --dry-run|--send --target all --text <text> --cta <next step>");
22
24
  console.log([
23
25
  "agentradar run <runner-id> <noun> <verb> [--<argument> <value> ...] [--dry-run] [--json]",
24
26
  " Every agent tool, grouped in AgentRadar terms: inbox, thread, message, task, metric, vault, ...",
@@ -39,6 +41,7 @@ async function main() {
39
41
  if (args.length === 1 && args[0] === "upgrade") {
40
42
  const { selfUpdate, runningVersion } = await import("./update.js");
41
43
  const installed = await selfUpdate();
44
+ await dropStaleMcp(process.cwd(), homedir());
42
45
  console.log(installed ? `agentradar upgraded to ${installed}` : `agentradar ${await runningVersion()} is the latest`);
43
46
  return;
44
47
  }
@@ -226,6 +229,10 @@ async function main() {
226
229
  process.exitCode = await secretsOverCli(args.slice(1));
227
230
  return;
228
231
  }
232
+ if (args[0] === "admin") {
233
+ process.exitCode = await adminOverCli(args.slice(1));
234
+ return;
235
+ }
229
236
  if (args[0] === "task" && args[1] && Object.hasOwn(TASK_VERB_TOOLS, args[1])) {
230
237
  if (args.length !== 5 || args[3] !== "--args") {
231
238
  console.error("Usage: agentradar task create|repeat|make-recurring <runner-id> --args '<json>'");
@@ -253,11 +260,12 @@ async function main() {
253
260
  return;
254
261
  }
255
262
  if (args[0] === "sync" && args.length <= 2) {
263
+ await dropStaleMcp(args[1] ?? process.cwd(), homedir());
256
264
  process.exitCode = await syncAgentDir(args[1]);
257
265
  return;
258
266
  }
259
267
  const help = args.length === 1 && (args[0] === "--help" || args[0] === "-h");
260
- (help ? console.log : console.error)("Usage: agentradar run <join-link> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] | agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--feeder] | agentradar join cancel <join-link|request-id> | 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|make-recurring <runner-id> --args '<json>' | agentradar inbox search <runner-id> [--query <text>] [--<filter> <value> ...] [--json] | agentradar listing describe <runner-id> --capabilities <text> | agentradar permissions mine|roles|set|request|grant-tool <runner-id> [--<argument> <value> ...] | agentradar metric report <runner-id> --metric-id <id> --value <n> --period-start <iso> --period-end <iso> [--evidence <text>] | agentradar secrets pull|renew <runner-id> | agentradar secrets exec <runner-id> -- <command> | agentradar propose-human <runner-id> --name <name> --email <email> --role-id <id> | agentradar secrets upload <runner-id> --env-file <path> | --from-env <NAME,...> | --prompt <NAME> [--replace] | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
268
+ (help ? console.log : console.error)("Usage: agentradar run <join-link> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] | agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--feeder] | agentradar join cancel <join-link|request-id> | 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|make-recurring <runner-id> --args '<json>' | agentradar inbox search <runner-id> [--query <text>] [--<filter> <value> ...] [--json] | agentradar listing describe <runner-id> --capabilities <text> | agentradar permissions mine|roles|set|request|grant-tool <runner-id> [--<argument> <value> ...] | agentradar metric report <runner-id> --metric-id <id> --value <n> --period-start <iso> --period-end <iso> [--evidence <text>] | agentradar secrets pull|renew <runner-id> | agentradar secrets exec <runner-id> -- <command> | agentradar propose-human <runner-id> --name <name> --email <email> --role-id <id> | agentradar secrets upload <runner-id> --env-file <path> | --from-env <NAME,...> | --prompt <NAME> [--replace] | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>] | agentradar admin broadcast --dry-run|--send --target all --text <text> --cta <next step>");
261
269
  if (!help)
262
270
  process.exitCode = 1;
263
271
  }
package/dist/runner.js CHANGED
@@ -6,14 +6,14 @@ 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, revertCli, runningVersion, selfUpdate } from "./update.js";
9
+ import { cliHeader, installedVersion, noteCliUpdate, revertCli, runningVersion, selfUpdate } from "./update.js";
10
10
  const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
11
11
  const ENGAGEMENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
12
12
  // Per-hire bearers are ordinary agent keys (`pcp_…`); the runner's own is an AWP key (`ar_…`).
13
13
  const HIRE_BEARER = /^(ar|pcp)_[A-Za-z0-9_-]+$/;
14
14
  // What a hire's env file sets for itself; the catalog agent's values for these never reach it.
15
15
  // A hire always runs Claude, whose MCP config it owns; a Codex home would be the catalog agent's.
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
+ const HIRE_OWN_KEYS = /^(export\s+)?(AGENT_NAME|AR_CLI_RUNNER_ID|CLAUDE_CONFIG_DIR|AR_BASE|AWP_MCP_URL|AWP_BEARER|AWP_BEARER_FILE|MCP_CONFIG|TMUX_SESSION|AGENT_PID_PATTERN|AR_AGENT_HARNESS|CODEX_HOME)=/;
17
17
  const CODEX_HARNESS = /^(export\s+)?AR_AGENT_HARNESS=\s*["']?codex\b/m;
18
18
  // While a pane shows this, its agent is mid-turn (the feeder's in-progress hint, same for Claude and Codex).
19
19
  const MID_TURN = /esc to interrupt|\besc to cancel\b/i;
@@ -285,12 +285,16 @@ export async function startVendorRunner(runnerId, home = homedir(), deps = {}) {
285
285
  const killSession = (name) => spawnChild("bash", [
286
286
  "-c", 'PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"; exec "${TMUX_BIN:-tmux}" kill-session -t "$1"', "tmux", `=_${name}`,
287
287
  ]).once("error", () => { });
288
- const post = (path, body, token = bearer) => (deps.fetch ?? fetch)(new URL(path, mcpUrl), {
289
- method: "POST",
290
- headers: { authorization: `Bearer ${token}`, accept: "application/json", "content-type": "application/json", "cache-control": "no-store" },
291
- body: JSON.stringify(body ?? {}),
292
- signal: AbortSignal.timeout(30_000),
293
- });
288
+ const post = async (path, body, token = bearer) => {
289
+ const response = await (deps.fetch ?? fetch)(new URL(path, mcpUrl), {
290
+ method: "POST",
291
+ headers: { ...(await cliHeader("feeder")), authorization: `Bearer ${token}`, accept: "application/json", "content-type": "application/json", "cache-control": "no-store" },
292
+ body: JSON.stringify(body ?? {}),
293
+ signal: AbortSignal.timeout(30_000),
294
+ });
295
+ await noteCliUpdate(response.headers);
296
+ return response;
297
+ };
294
298
  const launchAll = async () => {
295
299
  for (const file of await readdir(agentDir)) {
296
300
  const engagementId = file.startsWith(prefix) && file.endsWith(".env") ? file.slice(prefix.length, -".env".length) : "";
@@ -332,9 +336,9 @@ export async function startVendorRunner(runnerId, home = homedir(), deps = {}) {
332
336
  const configDir = join(home, ".agentradar", "config", name);
333
337
  await mkdir(configDir, { recursive: true, mode: 0o700 });
334
338
  const mcpConfig = join(configDir, "awp-only.json");
335
- await writePrivateFile(mcpConfig, `${JSON.stringify({
336
- mcpServers: { agentradar: { type: "http", url: mcpUrl.toString(), headers: { Authorization: `Bearer ${hire.bearer}` } } },
337
- }, null, 2)}\n`);
339
+ // Vadym 12915: no MCP entry for AgentRadar. The hire acts with `agentradar run $AR_CLI_RUNNER_ID <tool>`,
340
+ // which reads this hire's own env file; --strict-mcp-config still confines the pane to this empty list.
341
+ await writePrivateFile(mcpConfig, `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`);
338
342
  // Every setting the vendor put in the catalog agent's env (tool env, harness flags) is copied, as
339
343
  // read when the runner started, except the ones this hire sets for itself — the shared bearer among
340
344
  // them. Copied rather than sourced: sourcing would put the shared bearer in the hire's supervisor.
@@ -343,6 +347,7 @@ export async function startVendorRunner(runnerId, home = homedir(), deps = {}) {
343
347
  // ponytail: line by line, so a multi-line quoted value is copied only in part; parse it when one appears.
344
348
  const own = [
345
349
  `AGENT_NAME=${shellValue(name)}`,
350
+ `AR_CLI_RUNNER_ID=${shellValue(name)}`,
346
351
  `AR_BASE=${shellValue(folderOf(hire.engagementId))}`,
347
352
  `AWP_MCP_URL=${shellValue(mcpUrl.toString())}`,
348
353
  `AWP_BEARER=${shellValue(hire.bearer)}`,
package/dist/secrets.js CHANGED
@@ -15,9 +15,11 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
15
15
  import { homedir } from "node:os";
16
16
  import { join } from "node:path";
17
17
  import { awpRequest, envValue } from "./tool-run.js";
18
+ import { cliHeader, noteCliUpdate } from "./update.js";
18
19
  export const SECRETS_FILE = join(".agentradar", "secrets.enc");
19
20
  export const SECRETS_USAGE = "Usage: agentradar secrets pull|renew|env <runner-id> | agentradar secrets exec <runner-id> -- <command> [args...]"
20
- + " | agentradar secrets upload <runner-id> --env-file <path> | --from-env <NAME,...> | --prompt <NAME> [--only <NAME,...>] [--replace --approval-details '<json>'] [--token <token>]";
21
+ + " | agentradar secrets upload <runner-id> --env-file <path> | --from-env <NAME,...> | --prompt <NAME> [--only <NAME,...>] [--replace --approval-details '<json>'] [--token <token>]"
22
+ + " | agentradar secrets rotate <runner-id> --env-file <path> | --from-env <NAME,...> | --prompt <NAME> [--only <NAME,...>] [--token <token>]";
21
23
  /** The names the supervisor reserves for itself (supervise-agent.sh); a role secret never overrides one. */
22
24
  const RESERVED = /^(HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_[A-Z]+|TMPDIR|NODE_OPTIONS|CLAUDE_[A-Z_]+|CODEX_HOME|AR_[A-Z_]+|ANTHROPIC_API_KEY|AWP_[A-Z_]+|TMUX_SESSION|AGENT_PID_PATTERN|DISABLE_AUTOUPDATER)$/;
23
25
  /** The runner's own origin and key: its env file, else a hosted agent's run env (as tool-run.ts awpRequest). */
@@ -33,8 +35,9 @@ async function credentials(runnerId, deps) {
33
35
  async function post(runnerId, path, body, deps) {
34
36
  const { origin, bearer } = await credentials(runnerId, deps);
35
37
  const res = await (deps.fetch ?? fetch)(`${origin}/api/agent/${path}`, {
36
- method: "POST", headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" }, body: JSON.stringify(body ?? {}),
38
+ method: "POST", headers: { ...(await cliHeader()), authorization: `Bearer ${bearer}`, "content-type": "application/json" }, body: JSON.stringify(body ?? {}),
37
39
  });
40
+ await noteCliUpdate(res.headers, deps.logError);
38
41
  const json = await res.json().catch(() => ({}));
39
42
  if (!res.ok)
40
43
  throw new Error(json.error ?? `AgentRadar answered ${res.status}`);
@@ -161,8 +164,9 @@ async function promptHidden(name) {
161
164
  /**
162
165
  * Vadym 11074: `agentradar secrets upload`. Values come from this machine (a .env file, the environment, a hidden prompt)
163
166
  * and never from the command line. Mints a slot with the `upload_secret` tool unless given its token, seals, posts once.
167
+ * SECRETS-PERMS (Vadym 11295): `agentradar secrets rotate` is the same with `rotate_secret`, for existing company secrets.
164
168
  */
165
- export async function secretsUpload(runnerId, flags, deps = {}) {
169
+ export async function secretsUpload(runnerId, flags, deps = {}, tool = "upload_secret") {
166
170
  const opts = new Map();
167
171
  for (let i = 0; i < flags.length; i++) {
168
172
  const flag = flags[i];
@@ -200,8 +204,8 @@ export async function secretsUpload(runnerId, flags, deps = {}) {
200
204
  let token = opts.get("token");
201
205
  if (!token) {
202
206
  const approvalDetails = opts.has("approval-details") ? JSON.parse(opts.get("approval-details")) : undefined;
203
- const said = await awpRequest(runnerId, "tools/call", { name: "upload_secret",
204
- arguments: { names: Object.keys(values), replace: opts.has("replace") || undefined, approvalDetails } }, deps);
207
+ const said = await awpRequest(runnerId, "tools/call", { name: tool, arguments: tool === "rotate_secret" ? { names: Object.keys(values) }
208
+ : { names: Object.keys(values), replace: opts.has("replace") || undefined, approvalDetails } }, deps);
205
209
  const minted = JSON.parse(said?.result?.content?.[0]?.text ?? "{}");
206
210
  if (!minted.ok || !minted.token)
207
211
  throw new Error(minted.error ?? "AgentRadar did not open an upload.");
@@ -237,6 +241,8 @@ export async function secretsOverCli(args, deps = {}) {
237
241
  return await secretsExec(runnerId, command, deps);
238
242
  if (verb === "upload" && runnerId)
239
243
  return await secretsUpload(runnerId, args.slice(2), deps);
244
+ if (verb === "rotate" && runnerId)
245
+ return await secretsUpload(runnerId, args.slice(2), deps, "rotate_secret");
240
246
  }
241
247
  catch (error) {
242
248
  (deps.logError ?? console.error)(error instanceof Error ? error.message : String(error));