agentradar-cli 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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";
5
- import { cliHeader, newer, runningVersion } from "./update.js";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
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,55 +124,70 @@ 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();
134
132
  async function listedTools(runnerId, deps) {
135
133
  const listed = await awpRequest(runnerId, "tools/list", {}, deps);
136
134
  return listed ? listed.result?.tools ?? [] : null;
137
135
  }
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;
136
+ // 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
137
+ // name said "not available" instead of running it (behaviour evals tool-mode, approval-third-person; #2010).
138
+ const serviceTools = (runnerId, tools) => {
139
+ const command = (name) => { const tool = tools.find((entry) => entry.name === name); return tool && (pathOf(tool) ?? `run ${runnerId} ${name}`); };
140
+ return command("run_service_tool")
141
+ ? ` A tool of a service your company connected is not in this list: \`agentradar ${command("list_service_tools")}\` shows them,`
142
+ + ` and \`agentradar ${command("run_service_tool")} --tool-key <tool> --arguments '<its arguments as JSON>'\` runs one.`
143
+ : "";
144
+ };
145
+ /** `agentradar <group> --help`: one line per child; anything else that names no command is a usage error that lists the groups. */
146
+ function groupHelp(runnerId, command, tools, help, deps) {
147
+ const prefix = command ? `${command} ` : "";
148
+ const children = new Map();
149
+ for (const tool of tools) {
150
+ const path = pathOf(tool);
151
+ if (!path?.startsWith(prefix))
152
+ continue;
153
+ const word = path.slice(prefix.length).split(" ")[0];
154
+ children.set(word, [...children.get(word) ?? [], tool]);
151
155
  }
152
- const entry = tools.find((candidate) => candidate.name === tool);
153
- if (!entry) {
154
- (deps.logError ?? console.error)(unknownTool(runnerId, tool, tools));
155
- return 1;
156
+ if (!children.size) {
157
+ const groups = [...new Set(tools.map(pathOf).filter((path) => path).map((path) => path.split(" ")[0]))].sort();
158
+ (deps.logError ?? console.error)(`${command} is not a command. Groups: ${groups.join(" ")}. \`agentradar <group> --help\` lists its commands.${serviceTools(runnerId, tools)}`);
159
+ return EXIT.usage;
156
160
  }
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;
161
+ 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)`}`);
162
+ (help ? deps.log ?? console.log : deps.logError ?? console.error)([
163
+ `agentradar ${prefix}<${command ? "command" : "group"}>`, ...lines,
164
+ `Add --help to any of them for its flags.${command ? "" : serviceTools(runnerId, tools)}`,
165
+ ].join("\n"));
166
+ return help ? EXIT.ok : EXIT.usage;
167
+ }
168
+ /** `agentradar <path> --help`: the description, usage, required and optional flags, all generated from the inputSchema. */
169
+ function leafHelp(tool) {
170
+ const properties = tool.inputSchema?.properties ?? {};
171
+ const required = tool.inputSchema?.required ?? [];
172
+ const typeOf = (schema) => schema.enum ? schema.enum.join("|")
173
+ : [schema.type].flat()[0] === "array" ? `${typeOf(schema.items ?? {})} (a list: repeat the flag, or pass one JSON array)` : `<${[schema.type].flat()[0] ?? "value"}>`;
174
+ const flag = (name) => ` --${kebab(name)} ${typeOf(properties[name])}${properties[name].description ? ` ${firstSentence(properties[name].description)}` : ""}`;
175
+ const optional = Object.keys(properties).filter((name) => !required.includes(name));
176
+ const path = pathOf(tool) ?? `call <runner-id> ${tool.name}`;
177
+ return [
178
+ tool.description ?? "", "",
179
+ "USAGE", ` agentradar ${path}${required.map((name) => ` --${kebab(name)} ${typeOf(properties[name] ?? {})}`).join("")}${optional.length ? " [flags]" : ""}`,
180
+ ...(required.length ? ["REQUIRED", ...required.filter((name) => name in properties).map(flag)] : []),
181
+ "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)",
182
+ EXIT_CODES,
183
+ ].join("\n");
161
184
  }
162
185
  /**
163
186
  * `--text "hi" --thread-id X` as the tool's own arguments, typed by the inputSchema the server
164
187
  * advertises, so an agent never types JSON (braces and quotes) into a shell command.
165
188
  */
166
- async function flagArguments(runnerId, tool, flags, deps) {
189
+ async function flagArguments(entry, flags, deps) {
167
190
  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
191
  const schema = entry.inputSchema?.properties ?? {};
177
192
  const out = {};
178
193
  const camel = (flag) => flag.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
@@ -190,20 +205,54 @@ async function flagArguments(runnerId, tool, flags, deps) {
190
205
  // --text is what agents reach for on append_task_log, whose argument is --entry.
191
206
  const key = flag in schema ? flag : flag === "text" && "entry" in schema ? "entry" : camel(flag);
192
207
  if (!(key in schema)) {
193
- logError(`${tool} has no argument --${flag}. Its arguments: ${Object.keys(schema).map((name) => `--${name}`).join(" ") || "none"}`);
208
+ logError(`${pathOf(entry) ?? entry.name} has no flag --${flag}. Its flags: ${Object.keys(schema).map((name) => `--${kebab(name)}`).join(" ") || "none"}`);
194
209
  return null;
195
210
  }
196
211
  const type = [schema[key].type].flat()[0];
197
- const typed = coerce(type === "array" ? [schema[key].items?.type].flat()[0] : type, value);
212
+ if (type === "array") {
213
+ // A list is one JSON array or a repeated flag (#1752): --message-ids a --message-ids b, or --tools '[{...},{...}]'.
214
+ const itemType = [schema[key].items?.type].flat()[0];
215
+ const items = parseList(value, itemType);
216
+ if (!items) {
217
+ logError(listFlagError(flag, itemType, schema[key]));
218
+ return null;
219
+ }
220
+ out[key] = [...(out[key] ?? []), ...items];
221
+ continue;
222
+ }
223
+ const typed = coerce(type, value);
198
224
  if (typed === undefined) {
199
- logError(`--${flag} must be ${type === "array" ? "a list of " + schema[key].items?.type : "a " + type}`);
225
+ logError(`--${flag} must be a ${type}`);
200
226
  return null;
201
227
  }
202
- // A repeated flag builds a list: --message-ids a --message-ids b.
203
- out[key] = type === "array" ? [...(out[key] ?? []), typed] : typed;
228
+ out[key] = typed;
204
229
  }
205
230
  return out;
206
231
  }
232
+ /** 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. */
233
+ function parseList(value, itemType) {
234
+ let parsed;
235
+ if (itemType !== "array" && value.trimStart().startsWith("["))
236
+ try {
237
+ parsed = JSON.parse(value);
238
+ }
239
+ catch { /* a single item that starts with "[" */ }
240
+ const items = Array.isArray(parsed) ? parsed : [coerce(itemType, value)];
241
+ const fits = (item) => itemType === "object" ? !!item && typeof item === "object" && !Array.isArray(item)
242
+ : itemType === "array" ? Array.isArray(item)
243
+ : itemType === "integer" ? Number.isInteger(item)
244
+ : itemType === "number" || itemType === "string" || itemType === "boolean" ? typeof item === itemType
245
+ : item !== undefined;
246
+ return items.every(fits) ? items : null;
247
+ }
248
+ /** What an agent reads when a list argument is mistyped: both accepted forms, with an example built from the tool's own item fields. */
249
+ function listFlagError(flag, itemType, schema) {
250
+ const fields = Object.keys(schema.items?.properties ?? {});
251
+ const object = `{${(fields.length ? fields : ["key"]).map((name) => `"${name}":"..."`).join(",")}}`;
252
+ const [one, two] = itemType === "object" ? [object, object] : itemType === "number" || itemType === "integer" ? ["1", "2"] : itemType === "boolean" ? ["true", "false"] : ['"a"', '"b"'];
253
+ const bare = (item) => (itemType === "object" ? `'${item}'` : item.replaceAll('"', ""));
254
+ 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)}.`;
255
+ }
207
256
  /**
208
257
  * `agentradar run` is always-allowed, so a file flag must not read and upload any file the agent names:
209
258
  * only a regular file inside the working folder (after symlinks), and at most 256 KB — and never one in
@@ -238,22 +287,7 @@ function coerce(type, value) {
238
287
  return type === undefined ? value : undefined;
239
288
  }
240
289
  }
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
- }
290
+ /** INBOX-SEARCH (Vadym 9974): one block per message; `--json` keeps the tool's own JSON. */
257
291
  function inboxLines(text) {
258
292
  let body;
259
293
  try {
@@ -270,32 +304,13 @@ function inboxLines(text) {
270
304
  const pages = [body.older && `Older: --before ${body.older}`, body.newer && `Newer: --after ${body.newer}`].filter(Boolean);
271
305
  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
306
  }
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);
307
+ /** PER-TOOL-MODES (Vadym 12919): `--tool X=ask --tool Y=blocked` is `tools: [{toolKey: X, mode: ask}, ...]`; a bare `--tool X` takes `--mode`. */
308
+ export function namedToolFlags(flags) {
309
+ const tools = flags.filter(([flag]) => flag === "tool").map(([, value]) => {
310
+ const at = value.lastIndexOf("=");
311
+ return at < 0 ? { toolKey: value } : { toolKey: value.slice(0, at), mode: value.slice(at + 1) };
312
+ });
313
+ return { tools, flags: flags.filter(([flag]) => flag !== "tool") };
299
314
  }
300
315
  function parseArgumentsObject(argumentsJson, logError) {
301
316
  let parsed;
@@ -312,14 +327,6 @@ function parseArgumentsObject(argumentsJson, logError) {
312
327
  }
313
328
  return parsed;
314
329
  }
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
330
  /** One `tools/call` on the runner's AWP MCP endpoint with the runner's own key; the answer is printed verbatim. */
324
331
  export async function callAwpTool(runnerId, name, toolArguments, deps) {
325
332
  const log = deps.log ?? console.log;
@@ -335,38 +342,24 @@ export async function callAwpTool(runnerId, name, toolArguments, deps) {
335
342
  logError(said);
336
343
  return 1;
337
344
  }
338
- log(said);
339
- await cliVersionNotice(said, logError);
345
+ log(withCliUpdate(said));
340
346
  return 0;
341
347
  }
342
- /**
343
- * Vadym 9970: the server names the CLI it expects (`cli: { latest, min }`, on a successful get_my_context).
344
- * When this one is behind, the agent reads it in its own output (stderr, so --json stdout stays parseable).
345
- */
346
- async function cliVersionNotice(said, logError) {
347
- let cli;
348
- try {
349
- cli = JSON.parse(said).cli;
350
- }
351
- catch {
352
- return;
353
- }
354
- if (!cli || typeof cli.latest !== "string")
355
- return;
356
- const mine = await runningVersion().catch(() => null);
357
- if (!mine || !newer(cli.latest, mine))
358
- return;
359
- logError(typeof cli.min === "string" && newer(cli.min, mine)
360
- ? `agentradar ${mine} is below the minimum ${cli.min}: upgrade required. Run \`agentradar upgrade\`.`
361
- : `agentradar ${cli.latest} is available (you run ${mine}). Run \`agentradar upgrade\`.`);
362
- }
363
348
  const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
364
349
  /** One JSON-RPC request on the runner's AWP MCP endpoint with the runner's own key; null after logging why it failed. */
365
350
  export async function awpRequest(runnerId, method, params, deps) {
366
351
  const target = await awpEndpoint(runnerId, deps);
367
352
  if (!target)
368
353
  return null;
369
- return postAwp(target.url, target.bearer, method, params, deps);
354
+ const result = await postAwp(target.url, target.bearer, method, params, deps);
355
+ // A hosted workspace stops after 15 idle minutes; the bridge reads this file's mtime, so a background job that
356
+ // keeps calling AgentRadar keeps it awake. Only a call that succeeded counts: a loop of failing calls must not hold
357
+ // the box up. Platform hooks (AR_HOOK) and skill sync are not the agent's work.
358
+ if (result !== null && target.hosted && (deps.env ?? process.env).AR_HOOK !== "1" && deps.surface !== "skill-sync") {
359
+ const run = join(deps.home ?? homedir(), ".agentradar", "run");
360
+ await mkdir(run, { recursive: true }).then(() => writeFile(join(run, "activity"), "")).catch(() => { });
361
+ }
362
+ return result;
370
363
  }
371
364
  /** The runner's MCP endpoint and bearer: its runner file, else a hosted run's own environment. */
372
365
  export async function awpEndpoint(runnerId, deps) {
@@ -400,14 +393,14 @@ export async function awpEndpoint(runnerId, deps) {
400
393
  }
401
394
  url = `${origin}/api/mcp`;
402
395
  }
403
- return { url, bearer };
396
+ return { url, bearer, hosted: contents === null };
404
397
  }
405
398
  async function postAwp(url, bearer, method, params, deps) {
406
399
  const logError = deps.logError ?? console.error;
407
400
  const response = await (deps.fetch ?? fetch)(url, {
408
401
  method: "POST",
409
402
  headers: {
410
- ...(await cliHeader()),
403
+ ...(await cliHeader(deps.surface)),
411
404
  authorization: `Bearer ${bearer}`,
412
405
  "content-type": "application/json",
413
406
  // Both, because the endpoint answers JSON only when the client says it takes JSON.
@@ -420,10 +413,12 @@ async function postAwp(url, bearer, method, params, deps) {
420
413
  params,
421
414
  }),
422
415
  });
416
+ await noteCliUpdate(response.headers, logError);
423
417
  if (!response.ok) {
424
418
  // 426: this CLI is below the server's minimum; its sentence says how to upgrade.
425
419
  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}).`);
420
+ // 401: the server says only "unauthorized" for every cause, so a revoked key and an ended hire read the same here.
421
+ logError(said ?? (response.status === 401 ? UNAUTHORIZED : `AgentRadar refused the tool call (${response.status}).`));
427
422
  return null;
428
423
  }
429
424
  const body = await response.json();
package/dist/update.js CHANGED
@@ -16,8 +16,33 @@ export const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
16
16
  const serviceCopy = (root) => basename(dirname(root)) === "cli" && basename(dirname(dirname(root))) === ".agentradar";
17
17
  /** The version this process runs, whatever `current` points at now. */
18
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(() => "") });
19
+ /**
20
+ * Sent as `x-agentradar-cli` on every MCP call, so the server can refuse a CLI below its minimum (self-release card 2),
21
+ * and as `x-agentradar-surface` so it can count which client a call came from (MCPR-1).
22
+ */
23
+ export const cliHeader = async (surface = "cli") => ({ "x-agentradar-cli": await runningVersion().catch(() => ""), "x-agentradar-surface": surface });
24
+ let behind = null;
25
+ export const forgetCliUpdate = () => { behind = null; };
26
+ export async function noteCliUpdate(headers, logError = console.error) {
27
+ const latest = headers?.get("x-agentradar-cli-latest");
28
+ const current = latest && !behind ? await runningVersion().catch(() => "") : "";
29
+ if (!latest || !current || !newer(latest, current) || behind)
30
+ return;
31
+ behind = { current, latest, command: "agentradar upgrade" };
32
+ logError(`agentradar ${latest} is available (you run ${current}). Run \`agentradar upgrade\`.`);
33
+ }
34
+ /** A JSON-object output with `cliUpdate` added when this CLI is behind; anything else, or a current CLI, unchanged. */
35
+ export function withCliUpdate(text) {
36
+ if (!behind)
37
+ return text;
38
+ try {
39
+ const body = JSON.parse(text);
40
+ return body && typeof body === "object" && !Array.isArray(body) ? JSON.stringify({ ...body, cliUpdate: behind }, null, text.includes("\n") ? 2 : 0) : text;
41
+ }
42
+ catch {
43
+ return text;
44
+ }
45
+ }
21
46
  /** The version on disk: for a service's copy, the one `current` points at, which another runner may have moved. */
22
47
  export const installedVersion = async (root = PACKAGE_ROOT) => JSON.parse(await readFile(join(serviceCopy(root) ? join(dirname(root), "current") : root, "package.json"), "utf8")).version;
23
48
  export const newer = (a, b) => {