agentradar-cli 0.3.6 → 0.3.8

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.
@@ -82,13 +82,15 @@ export function skillsDirFor(harness, env) {
82
82
  return dir.includes("\0") ? null : join(dir);
83
83
  }
84
84
  export async function syncJoinedSkills(runnerId, deps) {
85
- const env = await readFile(join(deps.home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
86
- const harness = envValue(env, "AR_AGENT_HARNESS");
87
- const skillsDir = skillsDirFor(harness, (name) => name === "HOME" ? deps.home : envValue(env, name));
85
+ const file = await readFile(join(deps.home, ".agentradar", "agents", `${runnerId}.env`), "utf8").catch(() => null);
86
+ // A hosted session has no runner file: it is Claude Code, and its channel's env carries AR_BASE (hosted-session-start.mjs).
87
+ const value = (name) => file === null ? process.env[name] ?? null : envValue(file, name);
88
+ const harness = file === null ? "claude" : value("AR_AGENT_HARNESS");
89
+ const skillsDir = skillsDirFor(harness, (name) => name === "HOME" ? deps.home : value(name));
88
90
  if (!skillsDir)
89
91
  return;
90
92
  const codex = harness === "codex";
91
- const root = (codex ? envValue(env, "CODEX_HOME") : envValue(env, "AR_BASE")) ?? "";
93
+ const root = (codex ? value("CODEX_HOME") : value("AR_BASE")) ?? "";
92
94
  // Vadym 9988: the server's start instructions with this agent's brief, before its first session starts.
93
95
  // Same file and mark as the runner's feeder, which keeps it fresh from then on.
94
96
  const init = await awpRequest(runnerId, "initialize", {
package/dist/tool-run.js CHANGED
@@ -1,8 +1,8 @@
1
- import { readFile, realpath, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
- import { commandFor, DEPRECATED_TOOLS, toolFor } from "./commands.js";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
4
  import { cliHeader, noteCliUpdate, withCliUpdate } from "./update.js";
5
+ const UNAUTHORIZED = "AgentRadar refused the tool call (401): this agent's key is no longer valid on this server (it was revoked, or the hire it was issued for has ended). Nothing was done. Stop calling tools with this key and tell the person who runs you.";
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.
@@ -15,39 +15,59 @@ export function envValue(contents, key) {
15
15
  const quoted = raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2;
16
16
  return (quoted ? raw.slice(1, -1).replaceAll("'\\''", "'") : raw) || null;
17
17
  }
18
- export async function runServiceToolOverCli(runnerId, toolKey, argumentsJson, deps = {}) {
19
- const parameters = argumentsJson === undefined ? {} : parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
20
- if (!parameters)
21
- return 1;
22
- return await callAwpTool(runnerId, "run_service_tool", { toolKey, arguments: parameters, entryPoint: "cli" }, deps);
23
- }
24
- /** `agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>']`: any AWP tool from a shell, so a just-joined agent works before its harness reloads MCP tools. */
25
- export async function callToolOverCli(runnerId, tool, argumentsJson, deps = {}, flags = []) {
18
+ /**
19
+ * #1871 slice 1: `agentradar <noun> [<sub-noun>] <verb> [--<argument> <value> ...]`, every path read from the server's
20
+ * tools/list (`_meta.cli.path`), so a new tool or a renamed path needs no CLI release. `command` is that path, or a
21
+ * tool name (`call <runner-id> <tool>` and `run <runner-id> <tool>` stay until slices 4-5). A group prints its
22
+ * children, a leaf with --help its generated flags.
23
+ */
24
+ export async function callToolOverCli(runnerId, command, argumentsJson, deps = {}, flags = []) {
26
25
  const switches = new Set(flags.filter(([flag]) => ["help", "dry-run", "json"].includes(flag)).map(([flag]) => flag));
27
26
  flags = flags.filter(([flag]) => !switches.has(flag));
28
- // A noun has no underscore; a tool name has one unless it is a single word (reply, recall), which the resolver returns as is.
29
- if (tool.includes(" ") || (switches.has("help") && tool !== "" && !tool.includes("_"))) {
30
- const resolved = await resolveCommand(runnerId, tool, switches.has("help"), deps);
31
- if (typeof resolved === "number")
32
- return resolved;
33
- tool = resolved;
34
- }
35
- if (switches.has("help"))
36
- return await describeTools(runnerId, tool, deps);
27
+ let tool = command;
37
28
  const parameters = argumentsJson === undefined ? {} : parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
38
29
  if (!parameters)
39
30
  return EXIT.usage;
40
- if (flags.length) {
41
- const typed = await flagArguments(runnerId, tool, flags, deps);
42
- if (!typed)
43
- return EXIT.usage;
44
- Object.assign(parameters, typed);
31
+ // A bare tool name with nothing to type goes straight to the server: the hot `inbox next` poll and the get_self probe
32
+ // cost no tools/list round trip.
33
+ if (switches.has("help") || flags.length || argumentsJson !== undefined || !command.includes("_")) {
34
+ const tools = await listedTools(runnerId, deps);
35
+ if (!tools)
36
+ return EXIT.refused;
37
+ const entry = tools.find((candidate) => pathOf(candidate) === command) ?? tools.find((candidate) => candidate.name === command);
38
+ if (!entry?.name)
39
+ return groupHelp(runnerId, command, tools, switches.has("help"), deps);
40
+ if (switches.has("help")) {
41
+ (deps.log ?? console.log)(leafHelp(entry));
42
+ return EXIT.ok;
43
+ }
44
+ tool = entry.name;
45
+ const schema = entry.inputSchema?.properties ?? {};
46
+ // PER-TOOL-MODES (Vadym 12919): `--tool X=ask` builds `tools`, on any tool that takes `tools` and no `tool`.
47
+ if ("tools" in schema && !("tool" in schema) && flags.some(([flag]) => flag === "tool")) {
48
+ const named = namedToolFlags(flags);
49
+ parameters.tools = named.tools;
50
+ flags = named.flags;
51
+ }
52
+ if (flags.length) {
53
+ const typed = await flagArguments(entry, flags, deps);
54
+ if (!typed)
55
+ return EXIT.usage;
56
+ Object.assign(parameters, typed);
57
+ }
58
+ // Provenance for the execution ledger (R572), not permission: the server treats it as the self-report it is.
59
+ if ("entryPoint" in schema && parameters.entryPoint === undefined)
60
+ parameters.entryPoint = "cli";
45
61
  }
46
62
  // --dry-run: say exactly what would be sent, and send nothing.
47
63
  if (switches.has("dry-run")) {
48
64
  (deps.log ?? console.log)(JSON.stringify({ dryRun: true, tool, arguments: parameters }));
49
65
  return EXIT.ok;
50
66
  }
67
+ if (tool === "inbox_search" && !switches.has("json")) {
68
+ const log = deps.log ?? console.log;
69
+ deps = { ...deps, log: (text) => log(inboxLines(text)) };
70
+ }
51
71
  if (tool !== "next_event" || !RUNNER_ID.test(runnerId))
52
72
  return await callAwpTool(runnerId, tool, parameters, deps);
53
73
  // A bare poll acknowledges the batch the previous poll printed, so the command stays byte-identical and
@@ -59,6 +79,8 @@ export async function callToolOverCli(runnerId, tool, argumentsJson, deps = {},
59
79
  if (Array.isArray(ids) && ids.length)
60
80
  parameters.ackEventIds = ids;
61
81
  }
82
+ // The agent's own poll answers at once: the channel process holds the long poll, and a held agent call stalls its whole turn.
83
+ parameters.waitMs ??= 0;
62
84
  let said = "";
63
85
  const code = await callAwpTool(runnerId, tool, parameters, { ...deps, log: (text) => { said = text; (deps.log ?? console.log)(text); } });
64
86
  if (code !== 0)
@@ -69,7 +91,8 @@ export async function callToolOverCli(runnerId, tool, argumentsJson, deps = {},
69
91
  }
70
92
  catch { /* not a batch: nothing to acknowledge */ }
71
93
  const ids = Array.isArray(events) ? events.map((event) => event?.eventId).filter((id) => typeof id === "string") : [];
72
- await writeFile(delivered, JSON.stringify(ids), { mode: 0o600 }).catch(() => undefined);
94
+ // A hosted home has no runner file, so the folder may not exist yet; without it the ack and the channel's proof of a poll were lost.
95
+ await mkdir(dirname(delivered), { recursive: true }).then(() => writeFile(delivered, JSON.stringify(ids), { mode: 0o600 })).catch(() => undefined);
73
96
  return 0;
74
97
  }
75
98
  /**
@@ -77,39 +100,16 @@ export async function callToolOverCli(runnerId, tool, argumentsJson, deps = {},
77
100
  * (its sentence is on stderr); 2 the command itself was wrong (unknown command, missing or mistyped argument).
78
101
  */
79
102
  export const EXIT = { ok: 0, refused: 1, usage: 2 };
80
- /**
81
- * `<noun> <verb>` → the tool it names on this server; or an exit code once `<noun> --help` has listed its
82
- * verbs, or after saying what is wrong.
83
- */
84
- async function resolveCommand(runnerId, command, help, deps) {
85
- const tools = await listedTools(runnerId, deps);
86
- if (!tools)
87
- return EXIT.refused;
88
- const names = tools.map((entry) => entry.name ?? "").filter((name) => name && !DEPRECATED_TOOLS.has(name));
89
- const [noun, verb] = command.split(" ");
90
- if (!verb && names.includes(noun))
91
- return noun;
92
- if (verb) {
93
- const tool = toolFor(noun, verb, names);
94
- if (tool)
95
- return tool;
96
- }
97
- const verbs = names.filter((name) => commandFor(name)[0] === noun).map((name) => commandFor(name)[1]).sort();
98
- const log = help && !verb ? deps.log ?? console.log : deps.logError ?? console.error;
99
- log(verbs.length
100
- ? `${verb ? `${noun} ${verb} is not a command. ` : ""}agentradar run ${runnerId} ${noun} <verb>: ${verbs.join(" ")}. Add --help to a command for its arguments.`
101
- : `${noun} is not a command group. Groups: ${[...new Set(names.map((name) => commandFor(name)[0]))].sort().join(" ")}.`);
102
- return help && !verb && verbs.length ? EXIT.ok : EXIT.usage;
103
- }
104
- /** Split `<runner-id> <tool> [--args '<json>'] [--<argument> <value> ...]`; null when it is not that shape. */
103
+ const EXIT_CODES = "EXIT CODES\n 0 done 1 refused or unreachable (the reason is on stderr) 2 fix the command";
104
+ /** Split `<word> ... [--args '<json>'] [--<argument> <value> ...]`; null when a flag has no value. */
105
105
  export function parseCallArgs(rest) {
106
- const positional = [];
106
+ const words = [];
107
107
  const flags = [];
108
108
  let json;
109
109
  for (let i = 0; i < rest.length; i++) {
110
110
  const arg = rest[i];
111
111
  if (!arg.startsWith("--")) {
112
- positional.push(arg);
112
+ words.push(arg);
113
113
  continue;
114
114
  }
115
115
  if (arg === "--help" || arg === "--dry-run" || arg === "--json") {
@@ -124,63 +124,99 @@ export function parseCallArgs(rest) {
124
124
  else
125
125
  flags.push([arg.slice(2), value]);
126
126
  }
127
- const help = flags.some(([flag]) => flag === "help");
128
- // `<runner-id> <noun> <verb>` (Vadym 9970) names the same tool as `<runner-id> <tool>`; resolved against the server's list.
129
- if (positional.length === 3)
130
- return { runnerId: positional[0], tool: `${positional[1]} ${positional[2]}`, json, flags };
131
- return positional.length === 2 || (help && positional.length === 1)
132
- ? { runnerId: positional[0], tool: positional[1] ?? "", json, flags } : null;
127
+ return { words, json, flags };
133
128
  }
129
+ const pathOf = (tool) => typeof tool._meta?.cli?.path === "string" ? tool._meta.cli.path : undefined;
130
+ const kebab = (name) => name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
131
+ const firstSentence = (text = "") => text.split(/(?<=[.!?])\s/)[0].trim();
132
+ // Vadym 14075: a hosted agent tried ~10 key sets for approvalDetails on request_egress and never found the real ones,
133
+ // so --help spells out a JSON argument's fields and these commands carry one complete example each.
134
+ // The approvals and browser skills quote this output word for word (tool-run.test.ts checks it).
135
+ const APPROVAL_EXAMPLES = {
136
+ request_egress: `--host supplier.example.com --reason 'The price list task needs this supplier website.' --approval-details '{"context":"The price list task needs prices from a supplier website.","reason":"No connector has these prices, and the website is the only source.","ifDeclined":"The price list stays unfinished.","decisionDetail":"Let my browser reach supplier.example.com."}'`,
137
+ request_spend_limit: `--monthly-limit-cents 5000 --reason 'The weekly reports need more AI time.' --approval-details '{"context":"I used most of my monthly AI limit by the 20th.","reason":"The weekly reports added this month need more AI time.","ifDeclined":"I pause the weekly reports until next month.","decisionDetail":"Raise my monthly AI limit to $50."}'`,
138
+ request_hire: `--title 'Support agent' --approval-details '{"context":"Customer emails wait two days for an answer.","reason":"Nobody on the team has time to answer them.","ifDeclined":"Emails keep waiting and some customers may leave.","decisionDetail":"Hire one support agent to answer customer emails."}'`,
139
+ request_permission: `--area projects --op write --approval-details '{"context":"I was asked to create a project for the spring launch.","reason":"My role can read projects but not create them.","ifDeclined":"Someone else has to create the project.","decisionDetail":"Let my role create and edit projects."}'`,
140
+ };
141
+ /** ` context (required, 1-600 characters): What happened: ...` for each field of a JSON argument. */
142
+ const fieldLines = (name, schema, root) => [`--${kebab(name)} is JSON with these fields:`,
143
+ ...Object.entries(schema.properties ?? {}).map(([field, own]) => {
144
+ // A field reusing another's rule arrives as {"$ref": "#/properties/approvalDetails/properties/context"} plus its own description.
145
+ const f = { ...own.$ref?.split("/").slice(1).reduce((at, key) => at?.[key], root), ...own };
146
+ const size = f.minLength !== undefined && f.maxLength !== undefined ? `, ${f.minLength}-${f.maxLength} characters` : "";
147
+ return ` ${field} (${schema.required?.includes(field) ? "required" : "optional"}${size})${f.description ? `: ${f.description}` : ""}`;
148
+ })];
134
149
  async function listedTools(runnerId, deps) {
135
150
  const listed = await awpRequest(runnerId, "tools/list", {}, deps);
136
151
  return listed ? listed.result?.tools ?? [] : null;
137
152
  }
138
- // An agent that guessed a tool name from memory needs the real list, or it goes looking with commands
139
- // its harness has not allowed and the session stalls on a permission prompt.
140
- const unknownTool = (runnerId, tool, tools) => `${tool} is not a tool on this server. Its tools: ${tools.map((entry) => entry.name).join(" ")}. `
141
- + `\`agentradar run ${runnerId} <tool> --help\` shows a tool's arguments.`;
142
- /** `agentradar run <runner-id> [<tool>] --help`: the server's own tool list or one tool's arguments. */
143
- async function describeTools(runnerId, tool, deps) {
144
- const tools = await listedTools(runnerId, deps);
145
- if (!tools)
146
- return 1;
147
- const log = deps.log ?? console.log;
148
- if (!tool) {
149
- log(`Tools: ${tools.map((entry) => entry.name).join(" ")}. \`agentradar run ${runnerId} <tool> --help\` shows a tool's arguments.`);
150
- return 0;
153
+ // A connected service's tool (ask_question on a company's MCP) is never in the tool list, so an agent told to use one by
154
+ // name said "not available" instead of running it (behaviour evals tool-mode, approval-third-person; #2010).
155
+ const serviceTools = (runnerId, tools) => {
156
+ const command = (name) => { const tool = tools.find((entry) => entry.name === name); return tool && (pathOf(tool) ?? `run ${runnerId} ${name}`); };
157
+ return command("run_service_tool")
158
+ ? ` A tool of a service your company connected is not in this list: \`agentradar ${command("list_service_tools")}\` shows them,`
159
+ + ` and \`agentradar ${command("run_service_tool")} --tool-key <tool> --arguments '<its arguments as JSON>'\` runs one.`
160
+ : "";
161
+ };
162
+ /** `agentradar <group> --help`: one line per child; anything else that names no command is a usage error that lists the groups. */
163
+ function groupHelp(runnerId, command, tools, help, deps) {
164
+ const prefix = command ? `${command} ` : "";
165
+ const children = new Map();
166
+ for (const tool of tools) {
167
+ const path = pathOf(tool);
168
+ if (!path?.startsWith(prefix))
169
+ continue;
170
+ const word = path.slice(prefix.length).split(" ")[0];
171
+ children.set(word, [...children.get(word) ?? [], tool]);
151
172
  }
152
- const entry = tools.find((candidate) => candidate.name === tool);
153
- if (!entry) {
154
- (deps.logError ?? console.error)(unknownTool(runnerId, tool, tools));
155
- return 1;
173
+ if (!children.size) {
174
+ const groups = [...new Set(tools.map(pathOf).filter((path) => path).map((path) => path.split(" ")[0]))].sort();
175
+ (deps.logError ?? console.error)(`${command} is not a command. Groups: ${groups.join(" ")}. \`agentradar <group> --help\` lists its commands.${serviceTools(runnerId, tools)}`);
176
+ return EXIT.usage;
156
177
  }
157
- const required = entry.inputSchema?.required ?? [];
158
- const names = Object.keys(entry.inputSchema?.properties ?? {}).map((name) => `--${name}${required.includes(name) ? " (required)" : ""}`);
159
- log(`${tool}: ${entry.description ?? ""}\nArguments: ${names.join(" ") || "none"}`);
160
- return 0;
178
+ const lines = [...children].sort(([a], [b]) => a.localeCompare(b)).map(([word, leaves]) => ` ${word.padEnd(16)} ${leaves.length === 1 && pathOf(leaves[0]) === `${prefix}${word}` ? firstSentence(leaves[0].description) : `(${leaves.length} commands)`}`);
179
+ (help ? deps.log ?? console.log : deps.logError ?? console.error)([
180
+ `agentradar ${prefix}<${command ? "command" : "group"}>`, ...lines,
181
+ `Add --help to any of them for its flags.${command ? "" : serviceTools(runnerId, tools)}`,
182
+ ].join("\n"));
183
+ return help ? EXIT.ok : EXIT.usage;
184
+ }
185
+ /** `agentradar <path> --help`: the description, usage, required and optional flags, all generated from the inputSchema. */
186
+ function leafHelp(tool) {
187
+ const properties = tool.inputSchema?.properties ?? {};
188
+ const required = tool.inputSchema?.required ?? [];
189
+ const typeOf = (schema) => schema.enum ? schema.enum.join("|")
190
+ : [schema.type].flat()[0] === "array" ? `${typeOf(schema.items ?? {})} (a list: repeat the flag, or pass one JSON array)` : `<${[schema.type].flat()[0] ?? "value"}>`;
191
+ const flag = (name) => ` --${kebab(name)} ${typeOf(properties[name])}${properties[name].description ? ` ${firstSentence(properties[name].description)}` : ""}`;
192
+ const optional = Object.keys(properties).filter((name) => !required.includes(name));
193
+ const path = pathOf(tool) ?? `call <runner-id> ${tool.name}`;
194
+ return [
195
+ tool.description ?? "", "",
196
+ "USAGE", ` agentradar ${path}${required.map((name) => ` --${kebab(name)} ${typeOf(properties[name] ?? {})}`).join("")}${optional.length ? " [flags]" : ""}`,
197
+ ...(required.length ? ["REQUIRED", ...required.filter((name) => name in properties).map(flag)] : []),
198
+ "FLAGS", ...optional.map(flag), " --dry-run print the exact call and send nothing --json raw JSON --runner <id> the runner (default $AR_CLI_RUNNER_ID)",
199
+ ...Object.entries(properties).flatMap(([name, schema]) => schema.properties ? fieldLines(name, schema, tool.inputSchema) : []),
200
+ ...(tool.name && APPROVAL_EXAMPLES[tool.name] ? [`Example: agentradar ${path} ${APPROVAL_EXAMPLES[tool.name]}`] : []),
201
+ EXIT_CODES,
202
+ ].join("\n");
161
203
  }
162
204
  /**
163
205
  * `--text "hi" --thread-id X` as the tool's own arguments, typed by the inputSchema the server
164
206
  * advertises, so an agent never types JSON (braces and quotes) into a shell command.
165
207
  */
166
- async function flagArguments(runnerId, tool, flags, deps) {
208
+ async function flagArguments(entry, flags, deps) {
167
209
  const logError = deps.logError ?? console.error;
168
- const tools = await listedTools(runnerId, deps);
169
- if (!tools)
170
- return null;
171
- const entry = tools.find((candidate) => candidate.name === tool);
172
- if (!entry) {
173
- logError(unknownTool(runnerId, tool, tools));
174
- return null;
175
- }
176
210
  const schema = entry.inputSchema?.properties ?? {};
177
211
  const out = {};
178
212
  const camel = (flag) => flag.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
179
213
  for (let [flag, value] of flags) {
180
214
  // --body-file page.md: the argument read from a file, so a multi-line page never sits in a quoted
181
215
  // shell argument (Claude Code prompts on a newline followed by # inside one).
216
+ // --content-base64-file page.html: a *Base64 argument gets the file's bytes as base64, any type (ANY-FILE-SEND).
182
217
  if (flag.endsWith("-file") && !(flag in schema) && !(camel(flag) in schema)) {
183
- const text = await readArgumentFile(value, deps.cwd ?? process.cwd(), deps.home ?? homedir()).catch((error) => error);
218
+ const binary = camel(flag.slice(0, -"-file".length)).endsWith("Base64");
219
+ const text = await readArgumentFile(value, deps.cwd ?? process.cwd(), deps.home ?? homedir(), binary).catch((error) => error);
184
220
  if (text instanceof Error) {
185
221
  logError(`--${flag}: ${text.message}`);
186
222
  return null;
@@ -190,37 +226,73 @@ async function flagArguments(runnerId, tool, flags, deps) {
190
226
  // --text is what agents reach for on append_task_log, whose argument is --entry.
191
227
  const key = flag in schema ? flag : flag === "text" && "entry" in schema ? "entry" : camel(flag);
192
228
  if (!(key in schema)) {
193
- logError(`${tool} has no argument --${flag}. Its arguments: ${Object.keys(schema).map((name) => `--${name}`).join(" ") || "none"}`);
229
+ logError(`${pathOf(entry) ?? entry.name} has no flag --${flag}. Its flags: ${Object.keys(schema).map((name) => `--${kebab(name)}`).join(" ") || "none"}`);
194
230
  return null;
195
231
  }
196
232
  const type = [schema[key].type].flat()[0];
197
- const typed = coerce(type === "array" ? [schema[key].items?.type].flat()[0] : type, value);
233
+ if (type === "array") {
234
+ // A list is one JSON array or a repeated flag (#1752): --message-ids a --message-ids b, or --tools '[{...},{...}]'.
235
+ const itemType = [schema[key].items?.type].flat()[0];
236
+ const items = parseList(value, itemType);
237
+ if (!items) {
238
+ logError(listFlagError(flag, itemType, schema[key]));
239
+ return null;
240
+ }
241
+ out[key] = [...(out[key] ?? []), ...items];
242
+ continue;
243
+ }
244
+ const typed = coerce(type, value);
198
245
  if (typed === undefined) {
199
- logError(`--${flag} must be ${type === "array" ? "a list of " + schema[key].items?.type : "a " + type}`);
246
+ logError(schema[key].properties
247
+ ? `--${flag} must be JSON with the fields ${(schema[key].required ?? Object.keys(schema[key].properties)).join(", ")}; \`agentradar ${pathOf(entry) ?? `call <runner-id> ${entry.name}`} --help\` shows each one.`
248
+ : `--${flag} must be a ${type}`);
200
249
  return null;
201
250
  }
202
- // A repeated flag builds a list: --message-ids a --message-ids b.
203
- out[key] = type === "array" ? [...(out[key] ?? []), typed] : typed;
251
+ out[key] = typed;
204
252
  }
205
253
  return out;
206
254
  }
255
+ /** One flag's items: a JSON array spreads (a nested-list schema keeps it whole), anything else is a single item; null when an item does not fit the type. */
256
+ function parseList(value, itemType) {
257
+ let parsed;
258
+ if (itemType !== "array" && value.trimStart().startsWith("["))
259
+ try {
260
+ parsed = JSON.parse(value);
261
+ }
262
+ catch { /* a single item that starts with "[" */ }
263
+ const items = Array.isArray(parsed) ? parsed : [coerce(itemType, value)];
264
+ const fits = (item) => itemType === "object" ? !!item && typeof item === "object" && !Array.isArray(item)
265
+ : itemType === "array" ? Array.isArray(item)
266
+ : itemType === "integer" ? Number.isInteger(item)
267
+ : itemType === "number" || itemType === "string" || itemType === "boolean" ? typeof item === itemType
268
+ : item !== undefined;
269
+ return items.every(fits) ? items : null;
270
+ }
271
+ /** What an agent reads when a list argument is mistyped: both accepted forms, with an example built from the tool's own item fields. */
272
+ function listFlagError(flag, itemType, schema) {
273
+ const fields = Object.keys(schema.items?.properties ?? {});
274
+ const object = `{${(fields.length ? fields : ["key"]).map((name) => `"${name}":"..."`).join(",")}}`;
275
+ const [one, two] = itemType === "object" ? [object, object] : itemType === "number" || itemType === "integer" ? ["1", "2"] : itemType === "boolean" ? ["true", "false"] : ['"a"', '"b"'];
276
+ const bare = (item) => (itemType === "object" ? `'${item}'` : item.replaceAll('"', ""));
277
+ return `--${flag} takes a list of ${itemType ?? "values"}: either one JSON array, --${flag} '[${one},${two}]', or the flag repeated, --${flag} ${bare(one)} --${flag} ${bare(two)}.`;
278
+ }
207
279
  /**
208
280
  * `agentradar run` is always-allowed, so a file flag must not read and upload any file the agent names:
209
- * only a regular file inside the working folder (after symlinks), and at most 256 KB — and never one in
281
+ * only a regular file inside the working folder (after symlinks), and at most 256 KB (7 MB as base64) — and never one in
210
282
  * ~/.agentradar, which holds the runner's own key even when the working folder is $HOME.
211
283
  * ponytail: a hard link to the key is not caught (same inode, different path); making one is the agent's own act.
212
284
  */
213
- async function readArgumentFile(path, cwd, home) {
285
+ async function readArgumentFile(path, cwd, home, binary = false) {
214
286
  const real = await realpath(resolve(cwd, path)).catch(() => { throw new Error(`cannot read ${path}`); });
215
287
  const within = (dir) => { const inside = relative(dir, real); return inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside); };
216
288
  const own = await realpath(join(home, ".agentradar")).catch(() => null);
217
289
  if (own && within(own))
218
290
  throw new Error(`${path} is refused: a file flag never reads AgentRadar's own folder (~/.agentradar), which holds this agent's key`);
219
291
  const info = await stat(real);
220
- if (!info.isFile() || !within(await realpath(cwd)) || info.size > 256 * 1024) {
221
- throw new Error(`${path} is refused: a file flag reads only a regular file inside the current folder, up to 256 KB`);
292
+ if (!info.isFile() || !within(await realpath(cwd)) || info.size > (binary ? 7 * 1024 * 1024 : 256 * 1024)) {
293
+ throw new Error(`${path} is refused: a file flag reads only a regular file inside the current folder, up to ${binary ? "7 MB" : "256 KB"}`);
222
294
  }
223
- return await readFile(real, "utf8");
295
+ return binary ? (await readFile(real)).toString("base64") : await readFile(real, "utf8");
224
296
  }
225
297
  function coerce(type, value) {
226
298
  if (type === "string")
@@ -238,22 +310,7 @@ function coerce(type, value) {
238
310
  return type === undefined ? value : undefined;
239
311
  }
240
312
  }
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>";
245
- // The parity test (server listing-description-realpg) fails when set_listing_description gains an argument this line does not name.
246
- export const LISTING_DESCRIBE_USAGE = "Usage: agentradar listing describe <runner-id> --capabilities <text>";
247
- // The parity test (server inbox-search-realpg) fails when inbox_search gains an argument this line does not name.
248
- export const INBOX_SEARCH_USAGE = "Usage: agentradar inbox search <runner-id> [--query <text>] [--channel <c>] [--thread-id <id>] [--sender <s>] [--from <iso>] [--to <iso>] [--has-attachment true] [--unread true] [--unacked true] [--limit <n>] [--before <cursor>] [--after <cursor>] [--around <message-id>] [--json]";
249
- /**
250
- * INBOX-SEARCH (Vadym 9974) — `agentradar inbox search <runner-id> [--query <text>] [--<filter> <value> ...] [--json]`:
251
- * the MCP `inbox_search` on the same endpoint with the same key. One block per message, or the tool's JSON with --json.
252
- */
253
- export async function inboxSearchOverCli(runnerId, flags, json, deps = {}) {
254
- const log = deps.log ?? console.log;
255
- return await callToolOverCli(runnerId, "inbox_search", undefined, json ? deps : { ...deps, log: (text) => log(inboxLines(text)) }, flags);
256
- }
313
+ /** INBOX-SEARCH (Vadym 9974): one block per message; `--json` keeps the tool's own JSON. */
257
314
  function inboxLines(text) {
258
315
  let body;
259
316
  try {
@@ -270,32 +327,13 @@ function inboxLines(text) {
270
327
  const pages = [body.older && `Older: --before ${body.older}`, body.newer && `Newer: --after ${body.newer}`].filter(Boolean);
271
328
  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");
272
329
  }
273
- /**
274
- * Vadym 10719 — `agentradar permissions mine|roles|set|request|grant-tool <runner-id> [--<argument> <value> ...]`: the MCP permission
275
- * tools on the same endpoint with the same key. The parity test (server permission-tools-realpg) fails when one drifts.
276
- */
277
- export const PERMISSION_VERB_TOOLS = {
278
- mine: "my_permissions",
279
- roles: "read_role_permissions",
280
- set: "set_role_permission",
281
- request: "request_permission",
282
- "grant-tool": "propose_tool_assignment",
283
- };
284
- /**
285
- * Ruling 49 — `agentradar task create|repeat|make-recurring <runner-id> --args '<json>'`: the agent's own
286
- * `create_task`, `create_recurring_task` or `make_task_recurring` (Vadym 10317), on the same AWP endpoint
287
- * with the same key, so who it may assign to is the server's decision exactly as it is over MCP.
288
- */
289
- export const TASK_VERB_TOOLS = {
290
- create: "create_task",
291
- repeat: "create_recurring_task",
292
- "make-recurring": "make_task_recurring",
293
- };
294
- export async function createTaskOverCli(runnerId, verb, argumentsJson, deps = {}) {
295
- const parameters = parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
296
- if (!parameters)
297
- return 1;
298
- return await callAwpTool(runnerId, TASK_VERB_TOOLS[verb], parameters, deps);
330
+ /** PER-TOOL-MODES (Vadym 12919): `--tool X=ask --tool Y=blocked` is `tools: [{toolKey: X, mode: ask}, ...]`; a bare `--tool X` takes `--mode`. */
331
+ export function namedToolFlags(flags) {
332
+ const tools = flags.filter(([flag]) => flag === "tool").map(([, value]) => {
333
+ const at = value.lastIndexOf("=");
334
+ return at < 0 ? { toolKey: value } : { toolKey: value.slice(0, at), mode: value.slice(at + 1) };
335
+ });
336
+ return { tools, flags: flags.filter(([flag]) => flag !== "tool") };
299
337
  }
300
338
  function parseArgumentsObject(argumentsJson, logError) {
301
339
  let parsed;
@@ -312,14 +350,6 @@ function parseArgumentsObject(argumentsJson, logError) {
312
350
  }
313
351
  return parsed;
314
352
  }
315
- /**
316
- * R251 — `agentradar tool list <runner-id>`: the server's own `list_service_tools`, printed as it
317
- * answered. The listing is the policy's preview for this agent, so the CLI computes nothing — a second
318
- * copy of the decision here could only drift from the one the MCP tool and every real call make.
319
- */
320
- export async function listServiceToolsOverCli(runnerId, deps = {}) {
321
- return await callAwpTool(runnerId, "list_service_tools", {}, deps);
322
- }
323
353
  /** One `tools/call` on the runner's AWP MCP endpoint with the runner's own key; the answer is printed verbatim. */
324
354
  export async function callAwpTool(runnerId, name, toolArguments, deps) {
325
355
  const log = deps.log ?? console.log;
@@ -332,7 +362,8 @@ export async function callAwpTool(runnerId, name, toolArguments, deps) {
332
362
  // A refusal is the server's sentence and belongs on stderr with a non-zero exit, or a script that
333
363
  // pipes stdout reads "the tool ran" from a body that says it was refused.
334
364
  if (body.result?.isError) {
335
- logError(said);
365
+ // The server's JSON, then its plain "AgentRadar refused <tool>: ... Now: ..." line when it sends one.
366
+ logError([said, ...(body.result.content ?? []).slice(1).map((c) => c.text).filter((t) => typeof t === "string")].join("\n"));
336
367
  return 1;
337
368
  }
338
369
  log(withCliUpdate(said));
@@ -344,7 +375,15 @@ export async function awpRequest(runnerId, method, params, deps) {
344
375
  const target = await awpEndpoint(runnerId, deps);
345
376
  if (!target)
346
377
  return null;
347
- return postAwp(target.url, target.bearer, method, params, deps);
378
+ const result = await postAwp(target.url, target.bearer, method, params, deps);
379
+ // A hosted workspace stops after 15 idle minutes; the bridge reads this file's mtime, so a background job that
380
+ // keeps calling AgentRadar keeps it awake. Only a call that succeeded counts: a loop of failing calls must not hold
381
+ // the box up. Platform hooks (AR_HOOK) and skill sync are not the agent's work.
382
+ if (result !== null && target.hosted && (deps.env ?? process.env).AR_HOOK !== "1" && deps.surface !== "skill-sync") {
383
+ const run = join(deps.home ?? homedir(), ".agentradar", "run");
384
+ await mkdir(run, { recursive: true }).then(() => writeFile(join(run, "activity"), "")).catch(() => { });
385
+ }
386
+ return result;
348
387
  }
349
388
  /** The runner's MCP endpoint and bearer: its runner file, else a hosted run's own environment. */
350
389
  export async function awpEndpoint(runnerId, deps) {
@@ -378,7 +417,7 @@ export async function awpEndpoint(runnerId, deps) {
378
417
  }
379
418
  url = `${origin}/api/mcp`;
380
419
  }
381
- return { url, bearer };
420
+ return { url, bearer, hosted: contents === null };
382
421
  }
383
422
  async function postAwp(url, bearer, method, params, deps) {
384
423
  const logError = deps.logError ?? console.error;
@@ -400,14 +439,14 @@ async function postAwp(url, bearer, method, params, deps) {
400
439
  });
401
440
  await noteCliUpdate(response.headers, logError);
402
441
  if (!response.ok) {
403
- // 426: this CLI is below the server's minimum; its sentence says how to upgrade.
404
- const said = response.status === 426 ? await response.json().then((body) => body.error?.message).catch(() => null) : null;
405
- logError(said ?? `AgentRadar refused the tool call (${response.status}).`);
442
+ // 426 says how to upgrade; 401 says why the key was refused and how to re-join.
443
+ const said = await response.json().then((body) => body.error?.message).catch(() => null);
444
+ logError(said ?? (response.status === 401 ? UNAUTHORIZED : `AgentRadar refused the tool call (${response.status}).`));
406
445
  return null;
407
446
  }
408
447
  const body = await response.json();
409
448
  if (body.error) {
410
- logError(typeof body.error.message === "string" ? body.error.message : "AgentRadar returned an error");
449
+ logError(typeof body.error.message === "string" ? body.error.message : `AgentRadar returned an error to ${method === "tools/call" ? String(params.name) : method}. Now: run \`agentradar upgrade\`, then try again.`);
411
450
  return null;
412
451
  }
413
452
  return body;