agentradar-cli 0.1.9 → 0.2.1
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/agentradar-vendor/SKILL.md +6 -0
- package/dist/channel.js +150 -0
- package/dist/index.js +113 -10
- package/dist/runner.js +434 -8
- package/dist/setup.js +360 -20
- package/dist/skill-install.js +2 -0
- package/dist/tool-run.js +300 -0
- package/dist/vendor-runner/feeder.mjs +237 -30
- package/dist/vendor-runner/session-frame.mjs +37 -3
- package/dist/vendor-runner/supervisor/agentradar-agent@.service.template +31 -0
- package/dist/vendor-runner/supervisor/com.agentradar.agent.plist.template +62 -0
- package/dist/vendor-runner/supervisor/install-agent.sh +94 -0
- package/dist/vendor-runner/supervisor/supervise-agent.sh +138 -37
- package/package.json +4 -2
package/dist/tool-run.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* One `KEY='value'` line as `setup.ts` wrote it, single-quoted with `'` escaped as `'\''`.
|
|
6
|
+
* Nothing here is printed: the file holds the runner's bearer.
|
|
7
|
+
*/
|
|
8
|
+
export function envValue(contents, key) {
|
|
9
|
+
const line = contents.split("\n").find((row) => row.startsWith(`${key}=`));
|
|
10
|
+
if (!line)
|
|
11
|
+
return null;
|
|
12
|
+
const raw = line.slice(key.length + 1).trim();
|
|
13
|
+
const quoted = raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2;
|
|
14
|
+
return (quoted ? raw.slice(1, -1).replaceAll("'\\''", "'") : raw) || null;
|
|
15
|
+
}
|
|
16
|
+
export async function runServiceToolOverCli(runnerId, toolKey, argumentsJson, deps = {}) {
|
|
17
|
+
const parameters = argumentsJson === undefined ? {} : parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
|
|
18
|
+
if (!parameters)
|
|
19
|
+
return 1;
|
|
20
|
+
return await callAwpTool(runnerId, "run_service_tool", { toolKey, arguments: parameters, entryPoint: "cli" }, deps);
|
|
21
|
+
}
|
|
22
|
+
/** `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. */
|
|
23
|
+
export async function callToolOverCli(runnerId, tool, argumentsJson, deps = {}, flags = []) {
|
|
24
|
+
const parameters = argumentsJson === undefined ? {} : parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
|
|
25
|
+
if (!parameters)
|
|
26
|
+
return 1;
|
|
27
|
+
if (flags.length) {
|
|
28
|
+
const typed = await flagArguments(runnerId, tool, flags, deps);
|
|
29
|
+
if (!typed)
|
|
30
|
+
return 1;
|
|
31
|
+
Object.assign(parameters, typed);
|
|
32
|
+
}
|
|
33
|
+
if (tool !== "next_event" || !RUNNER_ID.test(runnerId))
|
|
34
|
+
return await callAwpTool(runnerId, tool, parameters, deps);
|
|
35
|
+
// A bare poll acknowledges the batch the previous poll printed, so the command stays byte-identical and
|
|
36
|
+
// an operator can allow it once. Asking for the next event means the last one was read; a lost or
|
|
37
|
+
// failed poll leaves the file as it was, so delivery stays at-least-once.
|
|
38
|
+
const delivered = join(deps.home ?? homedir(), ".agentradar", "agents", `${runnerId}.delivered.json`);
|
|
39
|
+
if (parameters.ackEventIds === undefined) {
|
|
40
|
+
const ids = await readFile(delivered, "utf8").then((text) => JSON.parse(text), () => []);
|
|
41
|
+
if (Array.isArray(ids) && ids.length)
|
|
42
|
+
parameters.ackEventIds = ids;
|
|
43
|
+
}
|
|
44
|
+
let said = "";
|
|
45
|
+
const code = await callAwpTool(runnerId, tool, parameters, { ...deps, log: (text) => { said = text; (deps.log ?? console.log)(text); } });
|
|
46
|
+
if (code !== 0)
|
|
47
|
+
return code;
|
|
48
|
+
let events = [];
|
|
49
|
+
try {
|
|
50
|
+
events = JSON.parse(said).events;
|
|
51
|
+
}
|
|
52
|
+
catch { /* not a batch: nothing to acknowledge */ }
|
|
53
|
+
const ids = Array.isArray(events) ? events.map((event) => event?.eventId).filter((id) => typeof id === "string") : [];
|
|
54
|
+
await writeFile(delivered, JSON.stringify(ids), { mode: 0o600 }).catch(() => undefined);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* `agentradar run <runner-id> listen`: bare `next_event` polls (each held by the server, each acking the
|
|
59
|
+
* batch before it) until work, a liveness probe or the welcome arrives, or until the next hold could run
|
|
60
|
+
* past `budgetMs`. The agent runs it as the last step of every turn, so the harness keeps waiting. Nine
|
|
61
|
+
* minutes stays under Claude Code's 10-minute Bash cap; Codex waits on it in 5-minute chunks.
|
|
62
|
+
*/
|
|
63
|
+
export async function listenOverCli(runnerId, deps = {}, budgetMs = 540_000) {
|
|
64
|
+
const now = deps.now ?? Date.now;
|
|
65
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
66
|
+
const start = now();
|
|
67
|
+
let longest = 0;
|
|
68
|
+
for (;;) {
|
|
69
|
+
const began = now();
|
|
70
|
+
let said = "";
|
|
71
|
+
const code = await callToolOverCli(runnerId, "next_event", undefined, { ...deps, log: (text) => { said = text; } });
|
|
72
|
+
if (code !== 0)
|
|
73
|
+
return code;
|
|
74
|
+
let batch = null;
|
|
75
|
+
try {
|
|
76
|
+
batch = JSON.parse(said);
|
|
77
|
+
}
|
|
78
|
+
catch { /* not a batch: keep waiting */ }
|
|
79
|
+
// Wake on work, a liveness probe or the welcome; anything else (events: null, an unknown key) waits.
|
|
80
|
+
if ((Array.isArray(batch?.events) && batch.events.length) || batch?.livenessProbe || batch?.welcome) {
|
|
81
|
+
(deps.log ?? console.log)(said);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
// A caller the server does not hold (not yet hired, a run JWT) answers at once; do not spin on it.
|
|
85
|
+
if (now() - began < 10_000)
|
|
86
|
+
await sleep(10_000 - (now() - began));
|
|
87
|
+
longest = Math.max(longest, now() - began);
|
|
88
|
+
if (now() - start + longest > budgetMs) {
|
|
89
|
+
(deps.log ?? console.log)(`No client message in ${Math.round((now() - start) / 60_000)} minutes. Run agentradar run ${runnerId} listen again.`);
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Split `<runner-id> <tool> [--args '<json>'] [--<argument> <value> ...]`; null when it is not that shape. */
|
|
95
|
+
export function parseCallArgs(rest) {
|
|
96
|
+
const positional = [];
|
|
97
|
+
const flags = [];
|
|
98
|
+
let json;
|
|
99
|
+
for (let i = 0; i < rest.length; i++) {
|
|
100
|
+
const arg = rest[i];
|
|
101
|
+
if (!arg.startsWith("--")) {
|
|
102
|
+
positional.push(arg);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const value = rest[++i];
|
|
106
|
+
if (value === undefined)
|
|
107
|
+
return null;
|
|
108
|
+
if (arg === "--args")
|
|
109
|
+
json = value;
|
|
110
|
+
else
|
|
111
|
+
flags.push([arg.slice(2), value]);
|
|
112
|
+
}
|
|
113
|
+
return positional.length === 2 ? { runnerId: positional[0], tool: positional[1], json, flags } : null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* `--text "hi" --conversation-id X` as the tool's own arguments, typed by the inputSchema the server
|
|
117
|
+
* advertises, so an agent never types JSON (braces and quotes) into a shell command.
|
|
118
|
+
*/
|
|
119
|
+
async function flagArguments(runnerId, tool, flags, deps) {
|
|
120
|
+
const logError = deps.logError ?? console.error;
|
|
121
|
+
const listed = await awpRequest(runnerId, "tools/list", {}, deps);
|
|
122
|
+
if (!listed)
|
|
123
|
+
return null;
|
|
124
|
+
const schema = listed.result?.tools?.find((entry) => entry.name === tool)?.inputSchema?.properties ?? {};
|
|
125
|
+
const out = {};
|
|
126
|
+
const camel = (flag) => flag.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
127
|
+
for (let [flag, value] of flags) {
|
|
128
|
+
// --body-file page.md: the argument read from a file, so a multi-line page never sits in a quoted
|
|
129
|
+
// shell argument (Claude Code prompts on a newline followed by # inside one).
|
|
130
|
+
if (flag.endsWith("-file") && !(flag in schema) && !(camel(flag) in schema)) {
|
|
131
|
+
const text = await readArgumentFile(value, deps.cwd ?? process.cwd(), deps.home ?? homedir()).catch((error) => error);
|
|
132
|
+
if (text instanceof Error) {
|
|
133
|
+
logError(`--${flag}: ${text.message}`);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
[flag, value] = [flag.slice(0, -"-file".length), text];
|
|
137
|
+
}
|
|
138
|
+
// --text is what agents reach for on append_task_log, whose argument is --entry.
|
|
139
|
+
const key = flag in schema ? flag : flag === "text" && "entry" in schema ? "entry" : camel(flag);
|
|
140
|
+
if (!(key in schema)) {
|
|
141
|
+
logError(`${tool} has no argument --${flag}. Its arguments: ${Object.keys(schema).map((name) => `--${name}`).join(" ") || "none"}`);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const type = [schema[key].type].flat()[0];
|
|
145
|
+
const typed = coerce(type === "array" ? [schema[key].items?.type].flat()[0] : type, value);
|
|
146
|
+
if (typed === undefined) {
|
|
147
|
+
logError(`--${flag} must be ${type === "array" ? "a list of " + schema[key].items?.type : "a " + type}`);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
// A repeated flag builds a list: --message-ids a --message-ids b.
|
|
151
|
+
out[key] = type === "array" ? [...(out[key] ?? []), typed] : typed;
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* `agentradar run` is always-allowed, so a file flag must not read and upload any file the agent names:
|
|
157
|
+
* only a regular file inside the working folder (after symlinks), and at most 256 KB — and never one in
|
|
158
|
+
* ~/.agentradar, which holds the runner's own key even when the working folder is $HOME.
|
|
159
|
+
* ponytail: a hard link to the key is not caught (same inode, different path); making one is the agent's own act.
|
|
160
|
+
*/
|
|
161
|
+
async function readArgumentFile(path, cwd, home) {
|
|
162
|
+
const real = await realpath(resolve(cwd, path)).catch(() => { throw new Error(`cannot read ${path}`); });
|
|
163
|
+
const within = (dir) => { const inside = relative(dir, real); return inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside); };
|
|
164
|
+
const own = await realpath(join(home, ".agentradar")).catch(() => null);
|
|
165
|
+
if (own && within(own))
|
|
166
|
+
throw new Error(`${path} is refused: a file flag never reads AgentRadar's own folder (~/.agentradar), which holds this agent's key`);
|
|
167
|
+
const info = await stat(real);
|
|
168
|
+
if (!info.isFile() || !within(await realpath(cwd)) || info.size > 256 * 1024) {
|
|
169
|
+
throw new Error(`${path} is refused: a file flag reads only a regular file inside the current folder, up to 256 KB`);
|
|
170
|
+
}
|
|
171
|
+
return await readFile(real, "utf8");
|
|
172
|
+
}
|
|
173
|
+
function coerce(type, value) {
|
|
174
|
+
if (type === "string")
|
|
175
|
+
return value;
|
|
176
|
+
if (type === "number" || type === "integer") {
|
|
177
|
+
const number = value.trim() === "" ? NaN : Number(value);
|
|
178
|
+
return Number.isFinite(number) && (type === "number" || Number.isInteger(number)) ? number : undefined;
|
|
179
|
+
}
|
|
180
|
+
if (type === "boolean")
|
|
181
|
+
return value === "true" ? true : value === "false" ? false : undefined;
|
|
182
|
+
try {
|
|
183
|
+
return JSON.parse(value);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return type === undefined ? value : undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Ruling 49 — `agentradar task create|repeat <runner-id> --args '<json>'`: the agent's own `create_task`
|
|
191
|
+
* or `create_recurring_task`, on the same AWP endpoint with the same key, so who it may assign to is the
|
|
192
|
+
* server's decision exactly as it is over MCP.
|
|
193
|
+
*/
|
|
194
|
+
export async function createTaskOverCli(runnerId, recurring, argumentsJson, deps = {}) {
|
|
195
|
+
const parameters = parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
|
|
196
|
+
if (!parameters)
|
|
197
|
+
return 1;
|
|
198
|
+
return await callAwpTool(runnerId, recurring ? "create_recurring_task" : "create_task", parameters, deps);
|
|
199
|
+
}
|
|
200
|
+
function parseArgumentsObject(argumentsJson, logError) {
|
|
201
|
+
let parsed;
|
|
202
|
+
try {
|
|
203
|
+
parsed = JSON.parse(argumentsJson);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
logError("--args must be a JSON object, e.g. --args '{\"title\":\"a real issue\"}'");
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
210
|
+
logError("--args must be a JSON object");
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
return parsed;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* R251 — `agentradar tool list <runner-id>`: the server's own `list_service_tools`, printed as it
|
|
217
|
+
* answered. The listing is the policy's preview for this agent, so the CLI computes nothing — a second
|
|
218
|
+
* copy of the decision here could only drift from the one the MCP tool and every real call make.
|
|
219
|
+
*/
|
|
220
|
+
export async function listServiceToolsOverCli(runnerId, deps = {}) {
|
|
221
|
+
return await callAwpTool(runnerId, "list_service_tools", {}, deps);
|
|
222
|
+
}
|
|
223
|
+
/** One `tools/call` on the runner's AWP MCP endpoint with the runner's own key; the answer is printed verbatim. */
|
|
224
|
+
export async function callAwpTool(runnerId, name, toolArguments, deps) {
|
|
225
|
+
const log = deps.log ?? console.log;
|
|
226
|
+
const logError = deps.logError ?? console.error;
|
|
227
|
+
const body = await awpRequest(runnerId, "tools/call", { name, arguments: toolArguments }, deps);
|
|
228
|
+
if (!body)
|
|
229
|
+
return 1;
|
|
230
|
+
const text = body.result?.content?.[0]?.text;
|
|
231
|
+
const said = typeof text === "string" ? text : JSON.stringify(body.result ?? {});
|
|
232
|
+
// A refusal is the server's sentence and belongs on stderr with a non-zero exit, or a script that
|
|
233
|
+
// pipes stdout reads "the tool ran" from a body that says it was refused.
|
|
234
|
+
if (body.result?.isError) {
|
|
235
|
+
logError(said);
|
|
236
|
+
return 1;
|
|
237
|
+
}
|
|
238
|
+
log(said);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
242
|
+
/** One JSON-RPC request on the runner's AWP MCP endpoint with the runner's own key; null after logging why it failed. */
|
|
243
|
+
async function awpRequest(runnerId, method, params, deps) {
|
|
244
|
+
const logError = deps.logError ?? console.error;
|
|
245
|
+
if (!RUNNER_ID.test(runnerId)) {
|
|
246
|
+
logError("Invalid runner ID");
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
const envPath = join(deps.home ?? homedir(), ".agentradar", "agents", `${runnerId}.env`);
|
|
250
|
+
let url;
|
|
251
|
+
let bearer;
|
|
252
|
+
const contents = await readFile(envPath, "utf8").catch(() => null);
|
|
253
|
+
if (contents !== null) {
|
|
254
|
+
url = envValue(contents, "AWP_MCP_URL");
|
|
255
|
+
bearer = envValue(contents, "AWP_BEARER");
|
|
256
|
+
if (!url || !bearer) {
|
|
257
|
+
logError(`${envPath} is missing AWP_MCP_URL or AWP_BEARER. Run agentradar setup again.`);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
// Ruling 41 — a hosted agent has no runner file. Its run already carries the server's origin and its
|
|
263
|
+
// per-run JWT (`PAPERCLIP_API_URL`/`PAPERCLIP_API_KEY`), which the AWP MCP admits for a vendorless
|
|
264
|
+
// hosted agent (`server/src/awp/resolve.ts`). The runner file, when present, still wins.
|
|
265
|
+
const env = deps.env ?? process.env;
|
|
266
|
+
const origin = env.PAPERCLIP_API_URL?.trim().replace(/\/+$/, "");
|
|
267
|
+
bearer = env.PAPERCLIP_API_KEY?.trim() || null;
|
|
268
|
+
if (!origin || !bearer) {
|
|
269
|
+
logError(`No runner named ${runnerId}: ${envPath} does not exist. Run agentradar setup first.`);
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
url = `${origin}/api/mcp`;
|
|
273
|
+
}
|
|
274
|
+
const response = await (deps.fetch ?? fetch)(url, {
|
|
275
|
+
method: "POST",
|
|
276
|
+
headers: {
|
|
277
|
+
authorization: `Bearer ${bearer}`,
|
|
278
|
+
"content-type": "application/json",
|
|
279
|
+
// Both, because the endpoint answers JSON only when the client says it takes JSON.
|
|
280
|
+
accept: "application/json, text/event-stream",
|
|
281
|
+
},
|
|
282
|
+
body: JSON.stringify({
|
|
283
|
+
jsonrpc: "2.0",
|
|
284
|
+
id: 1,
|
|
285
|
+
method,
|
|
286
|
+
params,
|
|
287
|
+
}),
|
|
288
|
+
});
|
|
289
|
+
if (!response.ok) {
|
|
290
|
+
logError(`AgentRadar refused the tool call (${response.status}).`);
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
const body = await response.json();
|
|
294
|
+
if (body.error) {
|
|
295
|
+
logError(typeof body.error.message === "string" ? body.error.message : "AgentRadar returned an error");
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
return body;
|
|
299
|
+
}
|
|
300
|
+
//# sourceMappingURL=tool-run.js.map
|