agentradar-cli 0.3.1 → 0.3.3

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.
@@ -24,7 +24,7 @@ The Stop hook runs the same check and will not let you stop while something is o
24
24
 
25
25
  - Answer every message in the channel it came from. A human who wrote is waiting for you; answer them first.
26
26
  - Talk is not a task. Reply conversationally by default; create a task only for trackable, billable work.
27
- - Write to people so a 13-year-old would understand: a few short lines in plain words, ending with one next step (not a list of ideas), no ids, paths, tool names or internal terms unless asked. Say money in dollars, never cents; give every link with what it opens. Say a thing once. Follow what they told you for the rest of the chat, and never offer to contact anyone else unless they ask.
27
+ - Write to humans so a 13-year-old would understand: a few short lines in plain words, ending with one next step (not a list of ideas), no ids, paths, tool names or internal terms unless asked. Say money in dollars, never cents; give every link with what it opens. Say a thing once. Follow what they told you for the rest of the chat, and never offer to contact anyone else unless they ask.
28
28
  - Never ask for, print or pass on a credential. AgentRadar holds the keys.
29
29
  - 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.
30
30
  - 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.
@@ -20,15 +20,15 @@ You work on the vendor's computer and connect to AgentRadar through the `agentra
20
20
 
21
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.
22
22
 
23
- Keep each person's page in company memory current, every fact cited, as the People section of the `agentradar-memory-and-skills` skill says. A page is knowledge, never permission.
23
+ 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
24
 
25
25
  In your first session, call `read_onboarding_obligations` once (reading them acknowledges the safety rules), then `read_readiness`.
26
26
 
27
- Your primary objective is to reach your quotas and drive the organisation forward in your role. Report each metric you own, and your subordinates' when needed, with `report_metric`: only true numbers, in good faith, each backed by `evidence` of what you counted it from, or by `fromEventId` when a person told you the number. You may create metrics for yourself; you can never change your own quota — your superior sets it. Talk only in shared spaces (channels and tasks). You can create one-off tasks (`create_task`) and repeated tasks (`create_recurring_task`) for yourself, your colleagues and anyone below you on the org chart — only for real trackable work, never for chat. You also manage your subordinates' tasks and repeated tasks (`update_task`, `update_recurring_task`, `stop_recurring_task`) and their metrics: edit a definition (`edit_metric`) or remove a wrong value with a reason (`void_metric_record`).
27
+ Your primary objective is to reach your quotas and drive the organisation forward in your role. Report each metric you own, and your subordinates' when needed, with `report_metric`: only true numbers, in good faith, each backed by `evidence` of what you counted it from, or by `fromEventId` when a human told you the number. You may create metrics for yourself; you can never change your own quota — your superior sets it. Talk only in shared spaces (channels and tasks). You can create one-off tasks (`create_task`) and repeated tasks (`create_recurring_task`) for yourself, your colleagues and anyone below you on the org chart — only for real trackable work, never for chat. You also manage your subordinates' tasks and repeated tasks (`update_task`, `update_recurring_task`, `stop_recurring_task`) and their metrics: edit a definition (`edit_metric`) or remove a wrong value with a reason (`void_metric_record`).
28
28
 
29
- ## Talking to people
29
+ ## Talking to humans
30
30
 
31
- Write to people so a 13-year-old would understand: a few short plain lines, then one next step, not a list of ideas. No ids, paths, tool names, task codes or internal terms unless asked (say "your team", "company memory"; name a plan with its monthly price). Say money in dollars, never cents; give every link with what it opens. Answer only what was asked, and say it once. Talk to the event's `sender` as "you", never by name or as "the owner"; no persona from another chat carries over. Keep to what a person told you all chat, say so before a step they may not expect (a web search, sending, hiring), and never offer to contact anyone unless asked. Never talk about how messages reach you. Do the obvious thing for "undo that"; if unclear, offer short choices. Check your tools before saying what is scheduled or done, never from memory.
31
+ Write to humans so a 13-year-old would understand: a few short plain lines, then one next step, not a list of ideas. No ids, paths, tool names, task codes or internal terms unless asked (say "your team", "company memory"; name a plan with its monthly price). Say money in dollars, never cents; give every link with what it opens. Answer only what was asked, and say it once. Talk to the event's `sender` as "you", never by name or as "the owner"; no persona from another chat carries over. Keep to what a human told you all chat, say so before a step they may not expect (a web search, sending, hiring), and never offer to contact anyone unless asked. Never talk about how messages reach you. Do the obvious thing for "undo that"; if unclear, offer short choices. Check your tools before saying what is scheduled or done, never from memory.
32
32
 
33
33
  ## Work loop
34
34
 
package/dist/channel.js CHANGED
@@ -7,13 +7,15 @@ import { join } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
8
  import { envValue } from "./tool-run.js";
9
9
  import { trustedMcpEndpoint } from "./setup.js";
10
+ import { syncJoinedSkills } from "./skill-install.js";
10
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.";
11
- 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 organisation sees this agent as not receiving messages until one or the other works.";
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.";
12
13
  /** One local MCP, forwarding tools to AWP. Wake signals never consume the durable queue. */
13
14
  export async function startChannel(runnerId, deps = {}) {
14
15
  if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(runnerId))
15
16
  throw new Error("Invalid runner ID");
16
- const config = await readFile(join(deps.home ?? homedir(), ".agentradar", "agents", `${runnerId}.env`), "utf8");
17
+ const home = deps.home ?? homedir();
18
+ const config = await readFile(join(home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
17
19
  const endpoint = trustedMcpEndpoint(envValue(config, "AWP_MCP_URL") ?? "");
18
20
  const bearer = envValue(config, "AWP_BEARER");
19
21
  if (!endpoint || !bearer)
@@ -29,6 +31,7 @@ export async function startChannel(runnerId, deps = {}) {
29
31
  // the agent is not receiving messages; "ready" again once the session does call next_event.
30
32
  let unconfirmed = null;
31
33
  let warning;
34
+ let skillSync;
32
35
  const send = (value) => new Promise((resolve, reject) => {
33
36
  output.write(`${JSON.stringify(value)}\n`, (error) => error ? reject(error) : resolve());
34
37
  });
@@ -36,12 +39,14 @@ export async function startChannel(runnerId, deps = {}) {
36
39
  const response = await (deps.fetch ?? fetch)(endpoint, {
37
40
  method: "POST",
38
41
  redirect: "error",
39
- headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
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" },
40
43
  body: JSON.stringify({ jsonrpc: "2.0", id: ++sequence, method, params }),
41
44
  signal: AbortSignal.any([stop.signal, AbortSignal.timeout(230_000)]),
42
45
  });
43
- if (!response.ok)
44
- throw new Error(`AgentRadar refused the channel call (${response.status})`);
46
+ if (!response.ok) {
47
+ const said = response.status === 426 ? await response.json().then((body) => body.error?.message).catch(() => null) : null;
48
+ throw new Error(said ?? `AgentRadar refused the channel call (${response.status})`);
49
+ }
45
50
  const text = await response.text();
46
51
  const json = text.trim().startsWith("{") ? text : text.split("\n").find((line) => line.startsWith("data:"))?.slice(5);
47
52
  if (!json)
@@ -90,7 +95,7 @@ export async function startChannel(runnerId, deps = {}) {
90
95
  const wake = JSON.stringify([events.map((event) => event.eventId), payload.livenessProbe?.nonce, Boolean(payload.onboarding)]);
91
96
  if ((events.length || payload.livenessProbe || payload.onboarding) && (wake !== lastWake || Date.now() - lastSent >= 30_000)) {
92
97
  await send({ jsonrpc: "2.0", method: "notifications/claude/channel", params: {
93
- content: `${events.length} new AgentRadar events; call next_event${payload.livenessProbe ? " to receive your liveness check" : ""}.`,
98
+ content: `${payload.firstStep ? `${payload.firstStep} ` : ""}${events.length} new AgentRadar events; call next_event${payload.livenessProbe ? " to receive your liveness check" : ""}.`,
94
99
  meta: { count: String(events.length) },
95
100
  } });
96
101
  lastWake = wake;
@@ -98,9 +103,11 @@ export async function startChannel(runnerId, deps = {}) {
98
103
  }
99
104
  // Never ack a count-only wake: only the agent's tool call confirms receipt of event content.
100
105
  }
101
- catch {
106
+ catch (error) {
107
+ const said = error.message;
108
+ // Below the server's minimum CLI the retry cannot succeed: say how to upgrade instead.
102
109
  if (!stop.signal.aborted)
103
- logError("AgentRadar channel poll failed; retrying. Check connectivity and the runner key.");
110
+ logError(/below the minimum/.test(said) ? said : "AgentRadar channel poll failed; retrying. Check connectivity and the runner key.");
104
111
  }
105
112
  await pause();
106
113
  }
@@ -122,6 +129,13 @@ export async function startChannel(runnerId, deps = {}) {
122
129
  unconfirmed = "pending";
123
130
  } }, deps.confirmMs ?? 30_000);
124
131
  void poll();
132
+ // Vadym 11038: a push-mode session runs no feeder, so the channel keeps its skills and brief current as the feeder
133
+ // does: at connect and every 10 minutes, dropping skills no longer linked. stdout is the MCP stream; logs go to stderr.
134
+ const sync = () => syncJoinedSkills(runnerId, { home, fetch: deps.fetch, log: logError })
135
+ .catch((error) => logError(`AgentRadar skill sync failed; keeping current copies (${String(error?.message ?? error).split("\n")[0]}).`));
136
+ void sync();
137
+ skillSync = setInterval(() => { void sync(); }, deps.skillsSyncMs ?? 600_000);
138
+ skillSync.unref?.();
125
139
  }
126
140
  }
127
141
  else if (message.id !== undefined) {
@@ -159,6 +173,7 @@ export async function startChannel(runnerId, deps = {}) {
159
173
  }
160
174
  finally {
161
175
  clearTimeout(warning);
176
+ clearInterval(skillSync);
162
177
  stop.abort();
163
178
  }
164
179
  }
package/dist/index.js CHANGED
@@ -3,11 +3,12 @@ import { createRequire } from "node:module";
3
3
  import { installVendorSkill } from "./skill-install.js";
4
4
  import { startChannel } from "./channel.js";
5
5
  import { runRestartSelftest, startVendorRunner } from "./runner.js";
6
- import { cancelJoin, joinOrganisation, leaveOrganisation, setupVendorAgent } from "./setup.js";
7
- import { callAwpTool, callToolOverCli, createTaskOverCli, INBOX_SEARCH_USAGE, inboxSearchOverCli, LISTING_DESCRIBE_USAGE, listServiceToolsOverCli, parseCallArgs, PERMISSION_VERB_TOOLS, runServiceToolOverCli, TASK_VERB_TOOLS } from "./tool-run.js";
6
+ import { cancelJoin, CODEX_GUARD_REFUSAL, ensureCodexGuard, joinOrganisation, leaveOrganisation, setupVendorAgent } from "./setup.js";
7
+ 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
8
  import { installSyncTimer, syncAgentDir } from "./sync.js";
9
9
  import { importVaultOverCli } from "./vault-import.js";
10
10
  import { cloneVaultOverCli } from "./vault-clone.js";
11
+ import { secretsOverCli } from "./secrets.js";
11
12
  // `run` is the one prefix every agent-facing command shares, so one harness "always allow agentradar run"
12
13
  // covers a whole session: `run <link>` (or `run join <link>`) joins, `run <runner-id> <tool>` calls a tool.
13
14
  export const runArgs = (raw) => raw[0] !== "run" ? raw
@@ -17,7 +18,7 @@ export const runArgs = (raw) => raw[0] !== "run" ? raw
17
18
  async function main() {
18
19
  const args = runArgs(process.argv.slice(2));
19
20
  if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
20
- 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 <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 skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
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>]");
21
22
  console.log([
22
23
  "agentradar run <runner-id> <noun> <verb> [--<argument> <value> ...] [--dry-run] [--json]",
23
24
  " Every agent tool, grouped in AgentRadar terms: inbox, thread, message, task, metric, vault, ...",
@@ -41,6 +42,19 @@ async function main() {
41
42
  console.log(installed ? `agentradar upgraded to ${installed}` : `agentradar ${await runningVersion()} is the latest`);
42
43
  return;
43
44
  }
45
+ // The supervisor asks before every Codex launch (CODEX_HOME and CODEX_BIN from its env): exit 1 keeps Codex down.
46
+ if (args.length === 1 && args[0] === "codex-guard") {
47
+ try {
48
+ if (!process.env.CODEX_HOME)
49
+ throw new Error(CODEX_GUARD_REFUSAL);
50
+ await ensureCodexGuard(process.env.CODEX_HOME, process.env);
51
+ }
52
+ catch (error) {
53
+ console.error(error.message);
54
+ process.exitCode = 1;
55
+ }
56
+ return;
57
+ }
44
58
  if (args[0] === "leave" && args.length === 2) {
45
59
  process.exitCode = await leaveOrganisation(args[1]);
46
60
  return;
@@ -164,6 +178,28 @@ async function main() {
164
178
  process.exitCode = await inboxSearchOverCli(parsed.runnerId, parsed.flags, args.includes("--json"));
165
179
  return;
166
180
  }
181
+ if (args[0] === "metric" && args[1] === "report") {
182
+ // report_metric over the same endpoint and key: an agent's own metric, a subordinate's, or a person's below it.
183
+ const parsed = parseCallArgs([...args.slice(2), "report_metric"]);
184
+ if (!parsed || !parsed.flags.some(([flag]) => flag === "metric-id")) {
185
+ console.error(METRIC_REPORT_USAGE);
186
+ process.exitCode = 1;
187
+ return;
188
+ }
189
+ process.exitCode = await callToolOverCli(parsed.runnerId, parsed.tool, undefined, {}, parsed.flags);
190
+ return;
191
+ }
192
+ if (args[0] === "propose-human") {
193
+ // R013: the Chief invites a human it found into a vacant role, the MCP `propose_human` over the same endpoint and key.
194
+ const parsed = parseCallArgs([...args.slice(1), "propose_human"]);
195
+ if (!parsed || !["name", "email", "role-id"].every((k) => parsed.flags.some(([flag]) => flag === k))) {
196
+ console.error(PROPOSE_HUMAN_USAGE);
197
+ process.exitCode = 1;
198
+ return;
199
+ }
200
+ process.exitCode = await callToolOverCli(parsed.runnerId, parsed.tool, undefined, {}, parsed.flags);
201
+ return;
202
+ }
167
203
  if (args[0] === "listing" && args[1] === "describe") {
168
204
  // UAT U1: the agent's public catalog description, the MCP `set_listing_description` over the same endpoint and key.
169
205
  const parsed = parseCallArgs([...args.slice(2), "set_listing_description"]);
@@ -179,13 +215,17 @@ async function main() {
179
215
  const tool = PERMISSION_VERB_TOOLS[args[1]];
180
216
  const parsed = parseCallArgs([...args.slice(2), tool]);
181
217
  if (!parsed || parsed.tool !== tool) {
182
- console.error("Usage: agentradar permissions mine|roles|set|request <runner-id> [--<argument> <value> ...]");
218
+ console.error("Usage: agentradar permissions mine|roles|set|request|grant-tool <runner-id> [--<argument> <value> ...]");
183
219
  process.exitCode = 1;
184
220
  return;
185
221
  }
186
222
  process.exitCode = await callToolOverCli(parsed.runnerId, tool, parsed.json, {}, parsed.flags);
187
223
  return;
188
224
  }
225
+ if (args[0] === "secrets") {
226
+ process.exitCode = await secretsOverCli(args.slice(1));
227
+ return;
228
+ }
189
229
  if (args[0] === "task" && args[1] && Object.hasOwn(TASK_VERB_TOOLS, args[1])) {
190
230
  if (args.length !== 5 || args[3] !== "--args") {
191
231
  console.error("Usage: agentradar task create|repeat|make-recurring <runner-id> --args '<json>'");
@@ -217,7 +257,7 @@ async function main() {
217
257
  return;
218
258
  }
219
259
  const help = args.length === 1 && (args[0] === "--help" || args[0] === "-h");
220
- (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 <runner-id> [--<argument> <value> ...] | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
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>]");
221
261
  if (!help)
222
262
  process.exitCode = 1;
223
263
  }
package/dist/runner.js CHANGED
@@ -6,7 +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
+ import { installedVersion, 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_…`).
@@ -461,14 +461,36 @@ export async function startVendorRunner(runnerId, home = homedir(), deps = {}) {
461
461
  // waits for every pane to finish its turn, for at most an hour.
462
462
  let nextCheck = 0;
463
463
  let told = "";
464
+ let failing = "";
465
+ let failedChecks = 0;
466
+ // Self-release card 4: AgentRadar can hold every runner's updates while a bad release is fixed. Only an explicit
467
+ // hold counts: a runner whose key cannot read get_my_context (a vendor agent nobody hired yet) still updates.
468
+ const held = deps.held ?? (async () => {
469
+ let said = {};
470
+ const code = await callAwpTool(runnerId, "get_my_context", {}, {
471
+ home, log: (text) => { try {
472
+ said = JSON.parse(text);
473
+ }
474
+ catch { /* not JSON: hold */ } }, logError: () => { },
475
+ });
476
+ return code === 0 && said.cli?.hold === true;
477
+ });
478
+ // Self-release card 3: before restarting onto a new version, prove it starts and reaches AgentRadar with this
479
+ // runner's key. One that cannot is put aside (`current` back, marked bad) and this runner stays as it is.
480
+ const probe = deps.probe ?? ((version) => run(process.execPath, [join(home, ".agentradar", "cli", version, "dist", "index.js"), "call", runnerId, "get_self"], { timeout: 60_000 })
481
+ .then(() => true, () => false));
464
482
  const autoUpdate = async () => {
465
483
  try {
466
484
  if (running && !(await optedOut())) {
467
485
  if (Date.now() >= nextCheck) {
468
486
  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})`);
487
+ if (await held())
488
+ log(`AgentRadar is holding CLI updates; staying on ${running}`);
489
+ else {
490
+ const fresh = await (deps.update ?? selfUpdate)();
491
+ if (fresh)
492
+ log(`AgentRadar CLI ${fresh} installed (was ${running})`);
493
+ }
472
494
  }
473
495
  const onDisk = await installed();
474
496
  if (onDisk !== running && !serviced) {
@@ -476,6 +498,25 @@ export async function startVendorRunner(runnerId, home = homedir(), deps = {}) {
476
498
  log(`AgentRadar CLI ${onDisk} is installed; restart this runner to use it`);
477
499
  told = onDisk;
478
500
  }
501
+ else if (onDisk !== running && await held()) {
502
+ // A hold also stops a restart onto a version already on disk (another runner may have installed it).
503
+ if (told !== `held ${onDisk}`)
504
+ log(`AgentRadar is holding CLI updates; not restarting onto ${onDisk}`);
505
+ told = `held ${onDisk}`;
506
+ }
507
+ else if (onDisk !== running && !(await probe(onDisk))) {
508
+ // One failed check can be the network; two in a row, a minute apart, put the version aside.
509
+ failedChecks = failing === onDisk ? failedChecks + 1 : 1;
510
+ failing = onDisk;
511
+ if (failedChecks >= 2) {
512
+ await revertCli(home, running, onDisk);
513
+ // Counted afresh: when the day is up and it is installed again, one blip must not put it aside again.
514
+ failedChecks = 0;
515
+ log(`AgentRadar CLI ${onDisk} failed its check twice (start + get_self); staying on ${running} and skipping ${onDisk} for a day`);
516
+ }
517
+ else
518
+ log(`AgentRadar CLI ${onDisk} failed its check once; checking again before restarting`);
519
+ }
479
520
  else if (onDisk !== running) {
480
521
  for (let i = 0; i < 240 && !stopped && await midTurn(); i += 1) {
481
522
  await new Promise((resolveWait) => setTimeout(resolveWait, deps.idlePollMs ?? 15_000));
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Bible #40 (Vadym 10582), mode 3: `agentradar secrets pull | renew | exec`.
3
+ *
4
+ * pull writes the role's leasable secrets, ENCRYPTED, to `.agentradar/secrets.enc` (0600) in the workspace and
5
+ * prints only their names. The key stays with AgentRadar under a 24 h lease.
6
+ * renew extends the lease while this agent still holds the role; a retired or replaced holder is refused.
7
+ * exec fetches the key while the lease is live, decrypts in this process, and runs a command with the values in
8
+ * its env. Nothing is written in plaintext, and the command's output passes through a value matcher.
9
+ * env prints `export KEY='value'` lines for the supervisor's launch wrapper to eval into the agent's env at start.
10
+ * Nothing is written to disk; with nothing pulled it prints nothing.
11
+ */
12
+ import { spawn } from "node:child_process";
13
+ import { createCipheriv, createDecipheriv, createHash, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from "node:crypto";
14
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { awpRequest, envValue } from "./tool-run.js";
18
+ export const SECRETS_FILE = join(".agentradar", "secrets.enc");
19
+ 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
+ /** The names the supervisor reserves for itself (supervise-agent.sh); a role secret never overrides one. */
22
+ 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
+ /** The runner's own origin and key: its env file, else a hosted agent's run env (as tool-run.ts awpRequest). */
24
+ async function credentials(runnerId, deps) {
25
+ const contents = await readFile(join(deps.home ?? homedir(), ".agentradar", "agents", `${runnerId}.env`), "utf8").catch(() => null);
26
+ const env = deps.env ?? process.env;
27
+ const url = contents !== null ? envValue(contents, "AWP_MCP_URL") : env.PAPERCLIP_API_URL ? `${env.PAPERCLIP_API_URL}/api/mcp` : null;
28
+ const bearer = contents !== null ? envValue(contents, "AWP_BEARER") : env.PAPERCLIP_API_KEY ?? null;
29
+ if (!url || !bearer)
30
+ throw new Error(`No runner named ${runnerId}. Run agentradar setup first.`);
31
+ return { origin: new URL(url).origin, bearer };
32
+ }
33
+ async function post(runnerId, path, body, deps) {
34
+ const { origin, bearer } = await credentials(runnerId, deps);
35
+ 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 ?? {}),
37
+ });
38
+ const json = await res.json().catch(() => ({}));
39
+ if (!res.ok)
40
+ throw new Error(json.error ?? `AgentRadar answered ${res.status}`);
41
+ return json;
42
+ }
43
+ /**
44
+ * Replace every value in a text with [secret], however short (Bible #40: no value in any output). A short value also
45
+ * masks the same characters in ordinary words; that over-redaction is the price of never printing it.
46
+ */
47
+ export function redactValues(text, values) {
48
+ return values.filter((v) => v.length > 0).sort((a, b) => b.length - a.length)
49
+ .reduce((out, value) => out.split(value).join("[secret]"), text);
50
+ }
51
+ export async function secretsPull(runnerId, deps = {}) {
52
+ const pulled = await post(runnerId, "role-secrets/pull", {}, deps);
53
+ const dir = join(deps.cwd ?? process.cwd(), ".agentradar");
54
+ await mkdir(dir, { recursive: true, mode: 0o700 });
55
+ const file = join(deps.cwd ?? process.cwd(), SECRETS_FILE);
56
+ await writeFile(`${file}.tmp`, JSON.stringify({ runnerId, ...pulled }), { mode: 0o600 });
57
+ await rename(`${file}.tmp`, file);
58
+ (deps.log ?? console.log)(`Pulled ${pulled.names.length} secret${pulled.names.length === 1 ? "" : "s"} (${pulled.names.join(", ")}), encrypted, to ${file}. `
59
+ + `Use them with: agentradar secrets exec ${runnerId} -- <command>. The lease ends ${pulled.expiresAt} unless renewed.`);
60
+ return 0;
61
+ }
62
+ async function readPulled(deps) {
63
+ const text = await readFile(join(deps.cwd ?? process.cwd(), SECRETS_FILE), "utf8").catch(() => null);
64
+ if (!text)
65
+ throw new Error("No pulled secrets here. Run agentradar secrets pull <runner-id> first.");
66
+ return JSON.parse(text);
67
+ }
68
+ export async function secretsRenew(runnerId, deps = {}) {
69
+ const { leaseId } = await readPulled(deps);
70
+ const renewed = await post(runnerId, "role-secrets/renew", { leaseId }, deps);
71
+ (deps.log ?? console.log)(`Lease renewed until ${renewed.expiresAt}.`);
72
+ return 0;
73
+ }
74
+ /** The decrypted values, in memory only. */
75
+ export async function decryptPulled(runnerId, deps = {}) {
76
+ const pulled = await readPulled(deps);
77
+ const { key } = await post(runnerId, "role-secrets/unlock", { leaseId: pulled.leaseId }, deps);
78
+ const decipher = createDecipheriv("aes-256-gcm", Buffer.from(key, "base64"), Buffer.from(pulled.iv, "base64"));
79
+ decipher.setAuthTag(Buffer.from(pulled.tag, "base64"));
80
+ return JSON.parse(Buffer.concat([decipher.update(Buffer.from(pulled.ciphertext, "base64")), decipher.final()]).toString("utf8"));
81
+ }
82
+ export async function secretsExec(runnerId, command, deps = {}) {
83
+ if (!command.length)
84
+ throw new Error(SECRETS_USAGE);
85
+ const values = await decryptPulled(runnerId, deps);
86
+ const secrets = Object.values(values);
87
+ const child = spawn(command[0], command.slice(1), { env: { ...(deps.env ?? process.env), ...values }, stdio: ["inherit", "pipe", "pipe"] });
88
+ // Whole lines only, so a value split across two chunks is still matched.
89
+ // ponytail: a value printed inside one very long unterminated line waits for the close to be flushed.
90
+ const pipe = (from, to) => {
91
+ let held = "";
92
+ from.on("data", (chunk) => {
93
+ held += chunk.toString("utf8");
94
+ const cut = held.lastIndexOf("\n") + 1;
95
+ if (cut) {
96
+ to.write(redactValues(held.slice(0, cut), secrets));
97
+ held = held.slice(cut);
98
+ }
99
+ });
100
+ from.on("end", () => { if (held)
101
+ to.write(redactValues(held, secrets)); });
102
+ };
103
+ pipe(child.stdout, process.stdout);
104
+ pipe(child.stderr, process.stderr);
105
+ return new Promise((resolve) => child.on("close", (code) => resolve(code ?? 1)));
106
+ }
107
+ /** `KEY=value` lines of a .env file; comments, blanks and `export ` prefixes skipped, one pair of quotes stripped. */
108
+ export function parseEnvFile(text) {
109
+ const out = {};
110
+ for (const line of text.split(/\r?\n/)) {
111
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*?)\s*$/.exec(line);
112
+ if (!m || line.trimStart().startsWith("#"))
113
+ continue;
114
+ const v = m[2];
115
+ out[m[1]] = /^(['"]).*\1$/.test(v) && v.length >= 2 ? v.slice(1, -1) : v;
116
+ }
117
+ return out;
118
+ }
119
+ /** Seal values to a slot token (`<slot>.<server public key>`): X25519, HKDF-SHA256, AES-256-GCM, AAD = slot hash and names. */
120
+ export function sealForSlot(token, values) {
121
+ const [slot, serverKey] = token.split(".");
122
+ if (!slot || !serverKey)
123
+ throw new Error("That is not an upload token.");
124
+ const slotHash = createHash("sha256").update(slot).digest("hex");
125
+ const mine = generateKeyPairSync("x25519");
126
+ const shared = diffieHellman({ privateKey: mine.privateKey,
127
+ publicKey: createPublicKey({ key: Buffer.from(serverKey, "base64url"), format: "der", type: "spki" }) });
128
+ const key = Buffer.from(hkdfSync("sha256", shared, Buffer.from(slotHash), "agentradar-secret-upload-v1", 32));
129
+ const iv = randomBytes(12);
130
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
131
+ cipher.setAAD(Buffer.from(`${slotHash}\n${Object.keys(values).sort().join(",")}`));
132
+ const ciphertext = Buffer.concat([cipher.update(JSON.stringify(values), "utf8"), cipher.final()]);
133
+ return { slot, body: { ephemeralPublicKey: mine.publicKey.export({ type: "spki", format: "der" }).toString("base64url"),
134
+ iv: iv.toString("base64url"), tag: cipher.getAuthTag().toString("base64url"), ciphertext: ciphertext.toString("base64url") } };
135
+ }
136
+ /** A value typed at a hidden prompt: nothing is echoed, nothing reaches shell history. */
137
+ async function promptHidden(name) {
138
+ const stdin = process.stdin;
139
+ if (!stdin.isTTY)
140
+ throw new Error("--prompt needs a terminal.");
141
+ process.stderr.write(`${name}: `);
142
+ stdin.setRawMode(true);
143
+ stdin.resume();
144
+ return new Promise((resolve, reject) => {
145
+ let value = "";
146
+ const onData = (chunk) => {
147
+ for (const ch of chunk.toString("utf8")) {
148
+ if (ch === "\r" || ch === "\n" || ch === "\u0003") {
149
+ stdin.setRawMode(false);
150
+ stdin.pause();
151
+ stdin.off("data", onData);
152
+ process.stderr.write("\n");
153
+ return ch === "\u0003" ? reject(new Error("Cancelled.")) : resolve(value);
154
+ }
155
+ value = ch === "\u007f" ? value.slice(0, -1) : value + ch;
156
+ }
157
+ };
158
+ stdin.on("data", onData);
159
+ });
160
+ }
161
+ /**
162
+ * Vadym 11074: `agentradar secrets upload`. Values come from this machine (a .env file, the environment, a hidden prompt)
163
+ * and never from the command line. Mints a slot with the `upload_secret` tool unless given its token, seals, posts once.
164
+ */
165
+ export async function secretsUpload(runnerId, flags, deps = {}) {
166
+ const opts = new Map();
167
+ for (let i = 0; i < flags.length; i++) {
168
+ const flag = flags[i];
169
+ if (flag === "--replace") {
170
+ opts.set("replace", "1");
171
+ continue;
172
+ }
173
+ if (!["--env-file", "--from-env", "--prompt", "--only", "--approval-details", "--token"].includes(flag) || flags[i + 1] === undefined) {
174
+ throw new Error(`${flag.startsWith("--") ? `Unknown option ${flag}` : "Values are never taken on the command line"}. ${SECRETS_USAGE}`);
175
+ }
176
+ opts.set(flag.slice(2), flags[++i]);
177
+ }
178
+ const list = (v) => (v ?? "").split(",").map((n) => n.trim()).filter(Boolean);
179
+ const env = deps.env ?? process.env;
180
+ let values = {};
181
+ if (opts.has("env-file"))
182
+ values = parseEnvFile(await readFile(opts.get("env-file"), "utf8"));
183
+ for (const name of list(opts.get("from-env"))) {
184
+ if (!env[name])
185
+ throw new Error(`${name} is not set in this environment.`);
186
+ values[name] = env[name];
187
+ }
188
+ if (opts.has("prompt"))
189
+ values[opts.get("prompt")] = await promptHidden(opts.get("prompt"));
190
+ const only = list(opts.get("only"));
191
+ if (only.length)
192
+ values = Object.fromEntries(only.map((n) => {
193
+ if (!values[n])
194
+ throw new Error(`${n} has no value in what you gave.`);
195
+ return [n, values[n]];
196
+ }));
197
+ values = Object.fromEntries(Object.entries(values).filter(([, v]) => v.length > 0));
198
+ if (!Object.keys(values).length)
199
+ throw new Error(`No values to upload. ${SECRETS_USAGE}`);
200
+ let token = opts.get("token");
201
+ if (!token) {
202
+ 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);
205
+ const minted = JSON.parse(said?.result?.content?.[0]?.text ?? "{}");
206
+ if (!minted.ok || !minted.token)
207
+ throw new Error(minted.error ?? "AgentRadar did not open an upload.");
208
+ token = minted.token;
209
+ }
210
+ const { slot, body } = sealForSlot(token, values);
211
+ const { outcomes } = await post(runnerId, `secret-uploads/${encodeURIComponent(slot)}`, body, deps);
212
+ for (const o of outcomes) {
213
+ (deps.log ?? console.log)(`${o.name}: ${o.status.replace("_", " ")}${o.reason ? ` (${o.reason})` : ""}${o.approvalId ? ` (approval ${o.approvalId})` : ""}`);
214
+ }
215
+ return outcomes.some((o) => o.status === "refused") ? 1 : 0;
216
+ }
217
+ export async function secretsEnv(runnerId, deps = {}) {
218
+ const text = await readFile(join(deps.cwd ?? process.cwd(), SECRETS_FILE), "utf8").catch(() => null);
219
+ if (!text)
220
+ return 0;
221
+ const values = await decryptPulled(runnerId, deps);
222
+ const quote = (v) => `'${v.replace(/'/g, "'\\''")}'`;
223
+ (deps.log ?? console.log)(Object.entries(values).filter(([k]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k) && !RESERVED.test(k))
224
+ .map(([k, v]) => `export ${k}=${quote(v)}`).join("\n"));
225
+ return 0;
226
+ }
227
+ export async function secretsOverCli(args, deps = {}) {
228
+ const [verb, runnerId, sep, ...command] = args;
229
+ try {
230
+ if (verb === "pull" && runnerId && !sep)
231
+ return await secretsPull(runnerId, deps);
232
+ if (verb === "renew" && runnerId && !sep)
233
+ return await secretsRenew(runnerId, deps);
234
+ if (verb === "env" && runnerId && !sep)
235
+ return await secretsEnv(runnerId, deps);
236
+ if (verb === "exec" && runnerId && sep === "--")
237
+ return await secretsExec(runnerId, command, deps);
238
+ if (verb === "upload" && runnerId)
239
+ return await secretsUpload(runnerId, args.slice(2), deps);
240
+ }
241
+ catch (error) {
242
+ (deps.logError ?? console.error)(error instanceof Error ? error.message : String(error));
243
+ return 1;
244
+ }
245
+ (deps.logError ?? console.error)(SECRETS_USAGE);
246
+ return 1;
247
+ }
248
+ //# sourceMappingURL=secrets.js.map
package/dist/setup.js CHANGED
@@ -289,6 +289,8 @@ async function codexServiceHome(home, runnerId, env) {
289
289
  await writePrivateFile(join(codexHome, "config.toml"), `${config.replace(/\n*$/, "\n")}${trusted
290
290
  .map(({ key, hash }) => `\n[hooks.state.${JSON.stringify(key)}]\ntrusted_hash = ${JSON.stringify(hash)}\n`).join("")}`);
291
291
  }
292
+ if (!(await codexGuardTrusted(codexHome, env)))
293
+ throw new Error(CODEX_GUARD_REFUSAL);
292
294
  await cp(join(source, "rules"), join(codexHome, "rules"), { recursive: true, force: true }).catch((error) => {
293
295
  if (error.code !== "ENOENT")
294
296
  throw error;
@@ -301,11 +303,38 @@ async function codexServiceHome(home, runnerId, env) {
301
303
  }
302
304
  return { codexHome, keys: [...config.matchAll(/^\s*env_key\s*=\s*"([A-Za-z_][A-Za-z0-9_]*)"/gm)].map((match) => match[1]) };
303
305
  }
306
+ export const CODEX_GUARD_REFUSAL = "Codex did not accept AgentRadar's guard hooks, and the agent does not start without its guard. Fix: run `codex update` (an older Codex: `npm install -g @openai/codex@latest`). A connected agent then starts by itself within a minute; during join, run join again.";
307
+ /**
308
+ * `agentradar codex-guard`, which the supervisor runs before every Codex launch. Trust Codex dropped (an upgrade
309
+ * can) is recorded again exactly as join records it, from the vendor's ~/.codex, and Codex is asked once more;
310
+ * only a Codex that still refuses keeps the agent down, with CODEX_GUARD_REFUSAL naming the fix.
311
+ * ponytail: a vendor who joined with a custom CODEX_HOME gets ~/.codex's config on repair; re-run join if it bites.
312
+ */
313
+ export async function ensureCodexGuard(codexHome, env, home = homedir()) {
314
+ if (await codexGuardTrusted(codexHome, env))
315
+ return;
316
+ const runnerId = basename(codexHome);
317
+ // Only a service home join made; never write trust into any other Codex home.
318
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(runnerId) || resolve(codexHome) !== join(home, ".agentradar", "codex", runnerId)) {
319
+ throw new Error(CODEX_GUARD_REFUSAL);
320
+ }
321
+ const { CODEX_HOME: _serviceHome, ...vendorEnv } = env;
322
+ await codexServiceHome(home, runnerId, vendorEnv);
323
+ }
324
+ /**
325
+ * Retire-legacy step 1 (Vadym 11054): Codex starts only with AgentRadar's guard. True only when Codex itself lists
326
+ * both guard hooks from `<codexHome>/hooks.json` as trusted. Asked at join and, through `agentradar codex-guard`,
327
+ * by the supervisor before every launch, since a Codex upgrade can drop the trust it recorded.
328
+ */
329
+ export async function codexGuardTrusted(codexHome, env) {
330
+ const hooks = await codexHookHashes(codexHome, env).catch(() => []);
331
+ return hooks.length === 2 && hooks.every((hook) => hook.trusted);
332
+ }
304
333
  /**
305
334
  * Codex runs a user hook only once its hash is recorded as trusted (`[hooks.state."<key>"] trusted_hash`),
306
335
  * which is what its /hooks review writes. Asks Codex itself for the keys and hashes of the hooks in
307
336
  * `<codexHome>/hooks.json` (app-server `hooks/list`), so the hash is never recomputed here. Any failure
308
- * leaves them untrusted: Codex then skips them, and the agent runs without the guard rather than not at all.
337
+ * leaves them untrusted, and `codexGuardTrusted` then refuses to start the agent.
309
338
  */
310
339
  async function codexHookHashes(codexHome, env) {
311
340
  const child = spawn(env.CODEX_BIN || "codex", ["app-server"], { cwd: codexHome, env: { ...env, CODEX_HOME: codexHome }, stdio: ["pipe", "pipe", "ignore"] });
@@ -332,7 +361,7 @@ async function codexHookHashes(codexHome, env) {
332
361
  continue;
333
362
  return (message.result?.data ?? []).flatMap((entry) => entry.hooks ?? [])
334
363
  .filter((hook) => hook.source === "user" && basename(hook.sourcePath) === "hooks.json")
335
- .map((hook) => ({ key: hook.key, hash: hook.currentHash }));
364
+ .map((hook) => ({ key: hook.key, hash: hook.currentHash, trusted: hook.trustStatus === "trusted" }));
336
365
  }
337
366
  return [];
338
367
  }
@@ -702,14 +731,14 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
702
731
  if (attach) {
703
732
  log(`Connected. Your welcome and every client message are typed into this session (tmux ${attach}), and you answer here. Nothing else was started. To stop: agentradar leave ${runnerId}`);
704
733
  if (approvedBypass)
705
- log("Your organisation allowed bypass-permissions mode; this session keeps the permission mode it already runs in.");
734
+ log("Your company allowed bypass-permissions mode; this session keeps the permission mode it already runs in.");
706
735
  log("Running as a service; the delivery restarts on crash and reboot.");
707
736
  return 0;
708
737
  }
709
- const mode = bypass ? ", in bypass-permissions mode as your organisation allowed" : "";
738
+ const mode = bypass ? ", in bypass-permissions mode as your company allowed" : "";
710
739
  if (channel) {
711
740
  log(`Connected through the AgentRadar channel. This conversation now continues as a service in ${projectPath} (tmux session ${runnerId}${mode}): client messages are pushed into it and it answers there, with no restart from you. Exit this session now (/exit) so only the service carries on; watch it with: tmux attach -t ${runnerId}. To stop it: agentradar leave ${runnerId}`);
712
- log(`If your organisation's Claude plan blocks channels, deliver with the feeder instead: agentradar join ${enrolmentUrl} --feeder`);
741
+ log(`If your company's Claude plan blocks channels, deliver with the feeder instead: agentradar join ${enrolmentUrl} --feeder`);
713
742
  log("Running as a service; it restarts on crash and reboot.");
714
743
  return 0;
715
744
  }
@@ -725,7 +754,7 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
725
754
  link = new URL(enrolmentUrl);
726
755
  }
727
756
  catch {
728
- throw new Error("Pass the join link your organisation gave you, e.g. https://…/invite/<token>");
757
+ throw new Error("Pass the join link your company gave you, e.g. https://…/invite/<token>");
729
758
  }
730
759
  const token = /^\/(?:api\/invites|invite)\/([^/]+?)(?:\.md)?(?:\/|$)/.exec(link.pathname)?.[1];
731
760
  if (!token || !trustedMcpEndpoint(link.origin)) {
@@ -751,7 +780,7 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
751
780
  const { envValue } = await import("./tool-run.js");
752
781
  const bearer = envValue(saved, "AWP_BEARER");
753
782
  if (!bearer)
754
- throw new Error("This machine left that organisation (agentradar leave). A key is issued once; ask your organisation for a new join link.");
783
+ throw new Error("This machine left that company (agentradar leave). A key is issued once; ask your company for a new join link.");
755
784
  // `--revoke-bypass` is remembered on this machine, so a later re-run keeps the prompts on.
756
785
  if (deps.revokeBypass && state.bypassConfirmed !== false) {
757
786
  state = { ...state, bypassConfirmed: false };
@@ -776,7 +805,7 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
776
805
  });
777
806
  if (!response.ok) {
778
807
  throw new Error(response.status === 404
779
- ? "This join link has expired, was already used, or was withdrawn. Ask your organisation for a new link."
808
+ ? "This join link has expired, was already used, or was withdrawn. Ask your company for a new link."
780
809
  : `This join link cannot be used. ${await failureMessage(response)}`);
781
810
  }
782
811
  const accepted = await response.json();
@@ -786,14 +815,14 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
786
815
  state = { requestId: accepted.id, claimSecret: accepted.claimSecret, ...(typeof accepted.approvalUrl === "string" ? { approvalUrl: accepted.approvalUrl } : {}) };
787
816
  await writePrivateFile(statePath, JSON.stringify(state));
788
817
  }
789
- log("Join request sent. Waiting for your organisation to approve it…");
818
+ log("Join request sent. Waiting for your company to approve it…");
790
819
  if (state.approvalUrl)
791
820
  log(`Approval link: ${state.approvalUrl}\nSend it to your own human once; they approve after signing in. Never send anything else from this folder.`);
792
821
  const claimUrl = api(`join-requests/${encodeURIComponent(state.requestId)}/claim-api-key`);
793
822
  // Named off the join request, which exists before the key does, so the server can record it at claim.
794
823
  const runnerId = `join-${state.requestId}`;
795
824
  for (let poll = 1;; poll++) {
796
- if (poll > (deps.polls ?? 9)) {
825
+ if (poll > (deps.polls ?? Infinity)) {
797
826
  log(`Still waiting for approval. Run this same command again: agentradar run ${enrolmentUrl}`);
798
827
  return 0;
799
828
  }
@@ -824,13 +853,13 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
824
853
  continue;
825
854
  }
826
855
  if (response.status === 409 && said.includes("rejected")) {
827
- throw new Error("Your organisation declined this join request. Ask it for a new join link.");
856
+ throw new Error("Your company declined this join request. Ask it for a new join link.");
828
857
  }
829
858
  if (response.status === 409 && said.includes("withdrawn")) {
830
- throw new Error("This join request was withdrawn (agentradar join cancel). Ask your organisation for a new join link.");
859
+ throw new Error("This join request was withdrawn (agentradar join cancel). Ask your company for a new join link.");
831
860
  }
832
861
  if (response.status === 409 && /already (?:used|claimed)/.test(said)) {
833
- throw new Error("This join link's key was already claimed. A key is issued once and cannot be claimed again; ask your organisation for a new join link.");
862
+ throw new Error("This join link's key was already claimed. A key is issued once and cannot be claimed again; ask your company for a new join link.");
834
863
  }
835
864
  throw new Error(said);
836
865
  }
@@ -863,7 +892,7 @@ export async function cancelJoin(target, deps = {}) {
863
892
  return 1;
864
893
  }
865
894
  if (state.agentId) {
866
- log(`That join was already approved. To leave the organisation: agentradar leave join-${state.requestId}`);
895
+ log(`That join was already approved. To leave the company: agentradar leave join-${state.requestId}`);
867
896
  return 1;
868
897
  }
869
898
  // The secret goes only back to the AgentRadar host that issued it.
@@ -1,6 +1,6 @@
1
1
  import { chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
- import { dirname, isAbsolute, join, normalize, sep } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, normalize, sep } from "node:path";
4
4
  import { awpRequest, envValue } from "./tool-run.js";
5
5
  const MANAGED_MARK = ".agentradar-managed";
6
6
  const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,99}$/;
@@ -132,20 +132,37 @@ async function writeBrief(file, text) {
132
132
  await writeFile(temporary, `${BRIEF_MARK}\n${text.trim()}\n`);
133
133
  await rename(temporary, file);
134
134
  }
135
+ /** Where each CLI reads skills. The same table as templates/vendor-runner/feeder.mjs; skill-install.test.ts pins them equal. */
136
+ export const SKILL_DIRS = {
137
+ claude: "$AR_BASE/.claude/skills",
138
+ codex: "$CODEX_HOME/skills",
139
+ gemini: "$HOME/.gemini/skills",
140
+ "cursor-agent": "$HOME/.cursor/skills",
141
+ agent: "$HOME/.cursor/skills",
142
+ pi: "$HOME/.pi/agent/skills",
143
+ hermes: "$HOME/.hermes/skills",
144
+ opencode: "$HOME/.claude/skills",
145
+ };
146
+ export function skillsDirFor(harness, env) {
147
+ const name = harness === "generic" ? basename(env("AR_AGENT_BIN") ?? "") : harness ?? "claude";
148
+ const dir = (SKILL_DIRS[name] ?? SKILL_DIRS.claude).replace(/\$(\w+)/, (_, v) => env(v) ?? "\0");
149
+ return dir.includes("\0") ? null : join(dir);
150
+ }
135
151
  export async function syncJoinedSkills(runnerId, deps) {
136
152
  const env = await readFile(join(deps.home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
137
- const root = envValue(env, "AR_AGENT_HARNESS") === "codex" ? envValue(env, "CODEX_HOME") : envValue(env, "AR_BASE");
138
- if (!root)
153
+ const harness = envValue(env, "AR_AGENT_HARNESS");
154
+ const skillsDir = skillsDirFor(harness, (name) => name === "HOME" ? deps.home : envValue(env, name));
155
+ if (!skillsDir)
139
156
  return;
140
- const codex = envValue(env, "AR_AGENT_HARNESS") === "codex";
141
- const skillsDir = codex ? join(root, "skills") : join(root, ".claude", "skills");
157
+ const codex = harness === "codex";
158
+ const root = (codex ? envValue(env, "CODEX_HOME") : envValue(env, "AR_BASE")) ?? "";
142
159
  // Vadym 9988: the server's start instructions with this agent's brief, before its first session starts.
143
160
  // Same file and mark as the runner's feeder, which keeps it fresh from then on.
144
161
  const init = await awpRequest(runnerId, "initialize", {
145
162
  protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "agentradar-cli", version: "1.0.0" },
146
163
  }, { home: deps.home, fetch: deps.fetch, logError: deps.log });
147
164
  const briefFile = codex ? join(root, "AGENTS.md") : join(root, ".claude", "rules", "agentradar-brief.md");
148
- if (init?.result?.instructions)
165
+ if (root && init?.result?.instructions)
149
166
  await writeBrief(briefFile, init.result.instructions);
150
167
  const body = await awpRequest(runnerId, "tools/call", { name: "my_skills", arguments: {} }, { home: deps.home, fetch: deps.fetch, logError: deps.log });
151
168
  const text = body?.result?.content?.find((c) => c.type === "text")?.text;
package/dist/tool-run.js CHANGED
@@ -2,7 +2,7 @@ import { readFile, realpath, stat, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { commandFor, DEPRECATED_TOOLS, toolFor } from "./commands.js";
5
- import { newer, runningVersion } from "./update.js";
5
+ import { cliHeader, newer, runningVersion } from "./update.js";
6
6
  /**
7
7
  * One `KEY='value'` line as `setup.ts` wrote it, single-quoted with `'` escaped as `'\''`.
8
8
  * Nothing here is printed: the file holds the runner's bearer.
@@ -238,6 +238,10 @@ function coerce(type, value) {
238
238
  return type === undefined ? value : undefined;
239
239
  }
240
240
  }
241
+ // The parity test (server human-metrics-ext-realpg) fails when report_metric gains an argument this line does not name.
242
+ export const METRIC_REPORT_USAGE = "Usage: agentradar metric report <runner-id> --metric-id <id> --value <n> --period-start <iso> --period-end <iso> [--evidence <text>] [--from-event-id <id>] [--comment <text>]";
243
+ // The parity test (server r013-propose-human-realpg) fails when propose_human gains an argument this line does not name.
244
+ export const PROPOSE_HUMAN_USAGE = "Usage: agentradar propose-human <runner-id> --name <name> --email <email> --role-id <id>";
241
245
  // The parity test (server listing-description-realpg) fails when set_listing_description gains an argument this line does not name.
242
246
  export const LISTING_DESCRIBE_USAGE = "Usage: agentradar listing describe <runner-id> --capabilities <text>";
243
247
  // The parity test (server inbox-search-realpg) fails when inbox_search gains an argument this line does not name.
@@ -267,7 +271,7 @@ function inboxLines(text) {
267
271
  return [...results.map((r) => `${r.at} ${r.channel} ${r.sender?.name ?? r.from ?? ""}\n thread ${r.threadId} message ${r.messageId}\n ${r.snippet}`), ...pages].join("\n");
268
272
  }
269
273
  /**
270
- * Vadym 10719 — `agentradar permissions mine|roles|set|request <runner-id> [--<argument> <value> ...]`: the MCP permission
274
+ * Vadym 10719 — `agentradar permissions mine|roles|set|request|grant-tool <runner-id> [--<argument> <value> ...]`: the MCP permission
271
275
  * tools on the same endpoint with the same key. The parity test (server permission-tools-realpg) fails when one drifts.
272
276
  */
273
277
  export const PERMISSION_VERB_TOOLS = {
@@ -275,6 +279,7 @@ export const PERMISSION_VERB_TOOLS = {
275
279
  roles: "read_role_permissions",
276
280
  set: "set_role_permission",
277
281
  request: "request_permission",
282
+ "grant-tool": "propose_tool_assignment",
278
283
  };
279
284
  /**
280
285
  * Ruling 49 — `agentradar task create|repeat|make-recurring <runner-id> --args '<json>'`: the agent's own
@@ -402,6 +407,7 @@ async function postAwp(url, bearer, method, params, deps) {
402
407
  const response = await (deps.fetch ?? fetch)(url, {
403
408
  method: "POST",
404
409
  headers: {
410
+ ...(await cliHeader()),
405
411
  authorization: `Bearer ${bearer}`,
406
412
  "content-type": "application/json",
407
413
  // Both, because the endpoint answers JSON only when the client says it takes JSON.
@@ -415,7 +421,9 @@ async function postAwp(url, bearer, method, params, deps) {
415
421
  }),
416
422
  });
417
423
  if (!response.ok) {
418
- logError(`AgentRadar refused the tool call (${response.status}).`);
424
+ // 426: this CLI is below the server's minimum; its sentence says how to upgrade.
425
+ const said = response.status === 426 ? await response.json().then((body) => body.error?.message).catch(() => null) : null;
426
+ logError(said ?? `AgentRadar refused the tool call (${response.status}).`);
419
427
  return null;
420
428
  }
421
429
  const body = await response.json();
package/dist/update.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, readFile, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { promisify } from "node:util";
@@ -8,12 +8,16 @@ import { installCli } from "./setup.js";
8
8
  const LATEST = "https://registry.npmjs.org/agentradar-cli/latest";
9
9
  const TARBALL = /^https:\/\/registry\.npmjs\.org\/agentradar-cli\/-\/agentradar-cli-\d+\.\d+\.\d+\.tgz$/;
10
10
  const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
11
+ /** How long a version that failed its check twice is skipped by the daily install. */
12
+ const BAD_FOR_MS = 24 * 3_600_000;
11
13
  /** This package's folder: the CLI, the runner scripts and the agent-guard plugin all live in it. */
12
14
  export const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
13
15
  /** A service's copy, made by `agentradar join` at ~/.agentradar/cli/<version>; the service runs ~/.agentradar/cli/current. */
14
16
  const serviceCopy = (root) => basename(dirname(root)) === "cli" && basename(dirname(dirname(root))) === ".agentradar";
15
17
  /** The version this process runs, whatever `current` points at now. */
16
18
  export const runningVersion = async () => JSON.parse(await readFile(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
19
+ /** Sent as `x-agentradar-cli` on every MCP call, so the server can refuse a CLI below its minimum (self-release card 2). */
20
+ export const cliHeader = async () => ({ "x-agentradar-cli": await runningVersion().catch(() => "") });
17
21
  /** The version on disk: for a service's copy, the one `current` points at, which another runner may have moved. */
18
22
  export const installedVersion = async (root = PACKAGE_ROOT) => JSON.parse(await readFile(join(serviceCopy(root) ? join(dirname(root), "current") : root, "package.json"), "utf8")).version;
19
23
  export const newer = (a, b) => {
@@ -45,6 +49,12 @@ export async function selfUpdate(deps = {}) {
45
49
  const version = latest.version ?? "";
46
50
  if (!newer(version, current))
47
51
  return null;
52
+ // A version a runner on this machine already reverted (self-release card 3) is not installed again.
53
+ // For a day: the failure may have been ours (the network), so the version gets another chance after that, and
54
+ // its check runs again before any restart.
55
+ const bad = await stat(join(dirname(root), `${version}.bad`)).catch(() => null);
56
+ if (serviceCopy(root) && bad && Date.now() - bad.mtimeMs < BAD_FOR_MS)
57
+ return null;
48
58
  const tarballUrl = latest.dist?.tarball ?? "";
49
59
  const integrity = latest.dist?.integrity ?? "";
50
60
  if (!TARBALL.test(tarballUrl) || !tarballUrl.endsWith(`-${version}.tgz`) || !integrity.startsWith("sha512-")) {
@@ -91,4 +101,16 @@ export async function selfUpdate(deps = {}) {
91
101
  }
92
102
  return version;
93
103
  }
104
+ /**
105
+ * Self-release card 3: put a failed version aside. `current` points back at the version still running (its folder
106
+ * is untouched), and `<version>.bad` stops the daily check installing the failed one again.
107
+ */
108
+ export async function revertCli(home, to, bad) {
109
+ const cli = join(home, ".agentradar", "cli");
110
+ const link = join(cli, `current.${process.pid}.revert`);
111
+ await rm(link, { force: true });
112
+ await symlink(to, link);
113
+ await rename(link, join(cli, "current"));
114
+ await writeFile(join(cli, `${bad}.bad`), `${new Date().toISOString()} failed its check; reverted to ${to}\n`);
115
+ }
94
116
  //# sourceMappingURL=update.js.map
@@ -25,7 +25,7 @@ import fs from "node:fs";
25
25
  import http from "node:http";
26
26
  import https from "node:https";
27
27
  import { homedir } from "node:os";
28
- import { dirname, isAbsolute, join, normalize, sep } from "node:path";
28
+ import { basename, dirname, isAbsolute, join, normalize, sep } from "node:path";
29
29
  import { fileURLToPath, pathToFileURL } from "node:url";
30
30
  import { formatInjectionFrame, formatProbeFrame, formatWelcomeFrame, sendKeysCommands } from "./session-frame.mjs";
31
31
 
@@ -96,10 +96,28 @@ const PANE_MCP_TIMEOUT_MS = Number(process.env.PANE_MCP_TIMEOUT_MS ?? "10000");
96
96
  // R650 — the agent's AgentRadar skills (bundled + linked) are written into its CLI's skills directory on this
97
97
  // cadence. Only directories this feeder created (they carry MANAGED_MARK) are ever replaced or removed; a
98
98
  // skill the vendor put there by hand is left alone. 0 disables.
99
- /** Claude reads `$AR_BASE/.claude/skills`; Codex reads `$CODEX_HOME/skills`, the per-agent home the supervisor requires. */
99
+ /**
100
+ * Where each CLI reads skills, the same paths the heartbeat adapters use. Claude (and Grok, which reads the
101
+ * workspace's `.claude/skills`) is the default; Codex reads `$CODEX_HOME/skills`, the per-agent home the supervisor
102
+ * requires. A `generic` runner is looked up by its binary's name. packages/agentradar-cli/src/skill-install.ts
103
+ * keeps the same table (skill-install.test.ts pins them equal).
104
+ * ponytail: gemini/cursor/pi/opencode/hermes read $HOME, so two joined agents on one box share one directory; add a
105
+ * per-agent HOME when that happens.
106
+ */
107
+ export const SKILL_DIRS = {
108
+ claude: "$AR_BASE/.claude/skills",
109
+ codex: "$CODEX_HOME/skills",
110
+ gemini: "$HOME/.gemini/skills",
111
+ "cursor-agent": "$HOME/.cursor/skills",
112
+ agent: "$HOME/.cursor/skills",
113
+ pi: "$HOME/.pi/agent/skills",
114
+ hermes: "$HOME/.hermes/skills",
115
+ opencode: "$HOME/.claude/skills",
116
+ };
100
117
  export function skillsDirFor(harness, env) {
101
- if (harness === "codex") return env.CODEX_HOME ? join(env.CODEX_HOME, "skills") : null;
102
- return env.AR_BASE ? join(env.AR_BASE, ".claude", "skills") : null;
118
+ const name = harness === "generic" ? basename(env.AR_AGENT_BIN ?? "") : harness;
119
+ const dir = (SKILL_DIRS[name] ?? SKILL_DIRS.claude).replace(/\$(\w+)/, (_, v) => env[v] ?? "\0");
120
+ return dir.includes("\0") ? null : join(dir);
103
121
  }
104
122
  const SKILLS_DIR = skillsDirFor(AR_AGENT_HARNESS, process.env);
105
123
  /**
@@ -974,7 +992,7 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
974
992
  // The server treats non-ready calls as status-only heartbeats: no drain and no ack. Keep any pending ack
975
993
  // until readiness returns, and defensively ignore events from an older server that does not know the gate.
976
994
  const canWork = state === "ready";
977
- const { events = [], livenessProbe, welcome, modelSwitch } = await nextEvent(canWork && _pendingAck.length ? _pendingAck : undefined, state);
995
+ const { events = [], livenessProbe, welcome, modelSwitch, firstStep } = await nextEvent(canWork && _pendingAck.length ? _pendingAck : undefined, state);
978
996
  _nextSwitch = modelSwitch ?? null;
979
997
  if (!canWork) {
980
998
  await sleep(IDLE_DELAY_MS);
@@ -997,7 +1015,7 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
997
1015
  for (const e of events) {
998
1016
  // Inject in order; ack ONLY the ones that reached a live pane (no-phantom).
999
1017
  // eslint-disable-next-line no-await-in-loop
1000
- if (await deliverEvent(e)) delivered.push(e.eventId);
1018
+ if (await deliverEvent(firstStep ? { ...e, firstStep } : e)) delivered.push(e.eventId);
1001
1019
  // eslint-disable-next-line no-await-in-loop
1002
1020
  else break; // pane went away mid-batch → stop; unacked events re-deliver in order.
1003
1021
  // eslint-disable-next-line no-await-in-loop
@@ -23,7 +23,7 @@ function oneLine(s) {
23
23
 
24
24
  /**
25
25
  * Build the single-line injection frame for one drained next_event event.
26
- * event: { eventId, source, channel?, conversationId?, text }
26
+ * event: { eventId, source, channel?, conversationId?, text, firstStep? }
27
27
  * cliRunnerId: set when the session acts through `agentradar run <id> <tool>` (a joined agent) rather than an
28
28
  * MCP connection — the command its human already chose "always allow" for, where an MCP tool would prompt.
29
29
  * Returns a one-line string safe to `tmux send-keys -l` then Enter.
@@ -35,7 +35,8 @@ export function formatInjectionFrame(event, cliRunnerId) {
35
35
  if (e.conversationId) tags.push(`conversationId=${e.conversationId}`);
36
36
  if (e.taskId) tags.push(`taskId=${e.taskId}`); // assignment events: the agent works THIS task, not one dug out of its log
37
37
  if (e.eventId) tags.push(`eventId=${e.eventId}`);
38
- const header = `[AgentRadar inbound · ${tags.join(" · ")}]`;
38
+ // Vadym 11025: the server's `firstStep` (get_my_context first) opens every frame that carries work.
39
+ const header = `[AgentRadar inbound · ${tags.join(" · ")}]${e.firstStep ? ` ${oneLine(e.firstStep)} Message:` : ""}`;
39
40
  const body = oneLine(e.text) || "(no text)";
40
41
  const evId = e.eventId ? String(e.eventId) : null;
41
42
  // The reply instruction pins the EXPLICIT conversationId (threading) + inResponseToEventId
@@ -317,8 +317,25 @@ ensure_agent() {
317
317
  "${TMUX_BIN}" kill-session -t "${TMUX_SESSION}" 2>/dev/null || true
318
318
  fi
319
319
  [ -n "${AR_GUARD_MARKER}" ] && rm -f "${AR_GUARD_MARKER}" # this launch must prove the guard afresh
320
+ # Retire-legacy step 1 (Vadym 11054): Codex starts only with AgentRadar's guard. Its SessionStart hook does not
321
+ # fire at an idle start, so there is no marker to wait for; Codex is asked, before every launch, whether it
322
+ # trusts both guard hooks (a Codex upgrade can drop that trust). No answer, no start.
323
+ # The guard records lost trust again itself; what it still refuses, its message names the fix for.
324
+ if [ "${AR_AGENT_HARNESS}" = "codex" ] && ! _guard_out=$(CODEX_HOME="${CODEX_HOME}" CODEX_BIN="${AGENT_BIN}" node "${AR_AGENTRADAR_BIN:-${HOME}/.agentradar/cli/current/dist/index.js}" codex-guard 2>&1); then
325
+ die "not starting Codex: ${_guard_out:-the AgentRadar guard check gave no answer}"
326
+ fi
320
327
  _resume_now="${AR_RESUME_FLAGS}"
321
- if [ "${_FORCE_FRESH:-0}" = 1 ]; then _resume_now="${AR_FRESH_FLAGS}"; _FORCE_FRESH=0; fi # poisoned history: do not resume it
328
+ if [ "${_FORCE_FRESH:-0}" = 1 ]; then # poisoned history: do not resume it
329
+ _resume_now="${AR_FRESH_FLAGS}"
330
+ # `agentradar join` writes AR_FRESH_FLAGS as the same `--resume <id>` as AR_RESUME_FLAGS, so a pinned session
331
+ # (either flag names an id) is healed onto a NEW id, generated now; later restarts resume that one, never the poisoned id.
332
+ case "${AR_AGENT_HARNESS}:${AR_RESUME_FLAGS} ${AR_FRESH_FLAGS}" in
333
+ claude:*--resume\ *|claude:*--session-id\ *)
334
+ _new_sid="$(node -e 'process.stdout.write(require("crypto").randomUUID())')" || die "could not generate a fresh session id"
335
+ _resume_now="--session-id ${_new_sid}"; AR_RESUME_FLAGS="--resume ${_new_sid}"; AR_FRESH_FLAGS="--session-id ${_new_sid}" ;;
336
+ esac
337
+ _FORCE_FRESH=0
338
+ fi
322
339
  ensure_browser # #150: bring up the browser first for --chrome agents (idempotent, no-op otherwise)
323
340
  if [ -n "${PRE_LAUNCH_HOOK}" ] && [ -x "${PRE_LAUNCH_HOOK}" ]; then
324
341
  log "running pre-launch hook: ${PRE_LAUNCH_HOOK}"
@@ -348,6 +365,8 @@ ensure_agent() {
348
365
  # #166: pass the tool creds as a FILE PATH (AR_TOOL_ENV_FILE), never inline KEY=VALUE on env -i.
349
366
  _tool_env=()
350
367
  [ -n "${_TOOL_ENV_FILE}" ] && _tool_env=( AR_TOOL_ENV_FILE="${_TOOL_ENV_FILE}" )
368
+ # Bible #40 mode 3: role secrets pulled into ${AR_BASE}/.agentradar/secrets.enc are decrypted by
369
+ # `agentradar secrets env` inside the wrapper and exported in memory, never written in plaintext.
351
370
  # #1: do NOT inherit TERM. Under launchd the supervisor's TERM is `dumb` (or absent), and `dumb` is
352
371
  # neither unset nor empty, so the old `${TERM:-xterm-256color}` default never fired and the agent's
353
372
  # claude ran with TERM=dumb. chalk/ink then disable colour ENTIRELY, so the TUI emitted zero SGR —
@@ -382,7 +401,7 @@ ensure_agent() {
382
401
  CODEX_HOME="${CODEX_HOME:-}" AR_RESUME_FLAGS="${_resume_now}" AR_FRESH_FLAGS="${AR_FRESH_FLAGS}" \
383
402
  DISABLE_AUTOUPDATER=1 CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF=1 \
384
403
  ${_claude_cfg[@]+"${_claude_cfg[@]}"} ${_tool_env[@]+"${_tool_env[@]}"} \
385
- /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_GUARD_MARKER|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[@]}" || {
404
+ /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_GUARD_MARKER|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; if [ -n "${AR_CLI_RUNNER_ID:-}" ] && [ -n "${AR_AGENTRADAR_BIN:-}" ] && [ -f .agentradar/secrets.enc ]; then _e=$("$AR_AGENTRADAR_BIN" secrets env "$AR_CLI_RUNNER_ID") && eval "$_e"; unset _e; 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[@]}" || {
386
405
  log "tmux could not start agent session '${TMUX_SESSION}'"
387
406
  return 1
388
407
  }
@@ -465,7 +484,20 @@ start_bridge
465
484
  # launchd/systemd remains the parent-level/reboot safety net. The feeder self-pauses while the owned pane
466
485
  # next_event heartbeat cannot paint the runner Live during recovery. Restart the bridge after a new pane is
467
486
  # created because tmux pipe-pane was bound to the dead pane.
487
+ # Bible #40: a pulled role-secret lease lives 24 h; renew it every 12 h while the agent still holds the role.
488
+ SECRETS_RENEW_SEC="${SECRETS_RENEW_SEC:-43200}"
489
+ SECRETS_RETRY_SEC="${SECRETS_RETRY_SEC:-600}"
490
+ _secrets_renewed_at=0
468
491
  while true; do
492
+ if [ -n "${AR_CLI_RUNNER_ID:-}" ] && [ -n "${AR_AGENTRADAR_BIN:-}" ] && [ -f "${AR_BASE}/.agentradar/secrets.enc" ] \
493
+ && [ $(( $(date +%s) - _secrets_renewed_at )) -ge "${SECRETS_RENEW_SEC}" ]; then
494
+ if (cd "${AR_BASE}" && "${AR_AGENTRADAR_BIN}" secrets renew "${AR_CLI_RUNNER_ID}") >/dev/null 2>&1; then
495
+ _secrets_renewed_at=$(date +%s)
496
+ else
497
+ log "WARN role-secret lease renewal refused or failed; retrying in ${SECRETS_RETRY_SEC}s"
498
+ _secrets_renewed_at=$(( $(date +%s) - SECRETS_RENEW_SEC + SECRETS_RETRY_SEC ))
499
+ fi
500
+ fi
469
501
  if [ -n "${FEEDER_PID}" ] && ! kill -0 "${FEEDER_PID}" 2>/dev/null; then
470
502
  log "feeder exited — exiting so the service manager restarts the whole unit"
471
503
  exit 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentradar-cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "engines": {
5
5
  "node": ">=20"
6
6
  },
@@ -21,6 +21,7 @@
21
21
  "dist/update.js",
22
22
  "dist/commands.js",
23
23
  "dist/vault-clone.js",
24
+ "dist/secrets.js",
24
25
  "dist/vendor-runner/**",
25
26
  "dist/agentradar-vendor/SKILL.md",
26
27
  "dist/agent-guard",