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.
- package/dist/agent-guard/hooks/hooks.json +7 -7
- package/dist/agent-guard/skills/agentradar-discipline/SKILL.md +6 -0
- package/dist/agentradar-vendor/SKILL.md +1 -1
- package/dist/channel.js +39 -15
- package/dist/index.js +60 -104
- package/dist/setup.js +47 -34
- package/dist/skill-install.js +6 -4
- package/dist/tool-run.js +196 -157
- package/dist/vendor-runner/feeder.mjs +67 -8
- package/dist/vendor-runner/session-frame.mjs +86 -2
- package/dist/vendor-runner/supervisor/supervise-agent.sh +81 -15
- package/dist/vendor-runner/terminal-bridge.mjs +5 -0
- package/package.json +1 -2
- package/dist/commands.js +0 -74
package/dist/skill-install.js
CHANGED
|
@@ -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
|
|
86
|
-
|
|
87
|
-
const
|
|
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 ?
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
139
|
-
//
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
(deps.logError ?? console.error)(
|
|
155
|
-
return
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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(
|
|
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
|
|
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(`${
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
404
|
-
const said =
|
|
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 :
|
|
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;
|