agentradar-cli 0.3.6 → 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/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 +38 -14
- package/dist/index.js +60 -104
- package/dist/setup.js +47 -34
- package/dist/skill-install.js +6 -4
- package/dist/tool-run.js +163 -147
- 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/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,55 +124,70 @@ 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();
|
|
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
|
-
//
|
|
139
|
-
//
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
(deps.logError ?? console.error)(
|
|
155
|
-
return
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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(
|
|
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(`${
|
|
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
|
-
|
|
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
|
|
225
|
+
logError(`--${flag} must be a ${type}`);
|
|
200
226
|
return null;
|
|
201
227
|
}
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
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;
|
|
@@ -344,7 +351,15 @@ export async function awpRequest(runnerId, method, params, deps) {
|
|
|
344
351
|
const target = await awpEndpoint(runnerId, deps);
|
|
345
352
|
if (!target)
|
|
346
353
|
return null;
|
|
347
|
-
|
|
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;
|
|
348
363
|
}
|
|
349
364
|
/** The runner's MCP endpoint and bearer: its runner file, else a hosted run's own environment. */
|
|
350
365
|
export async function awpEndpoint(runnerId, deps) {
|
|
@@ -378,7 +393,7 @@ export async function awpEndpoint(runnerId, deps) {
|
|
|
378
393
|
}
|
|
379
394
|
url = `${origin}/api/mcp`;
|
|
380
395
|
}
|
|
381
|
-
return { url, bearer };
|
|
396
|
+
return { url, bearer, hosted: contents === null };
|
|
382
397
|
}
|
|
383
398
|
async function postAwp(url, bearer, method, params, deps) {
|
|
384
399
|
const logError = deps.logError ?? console.error;
|
|
@@ -402,7 +417,8 @@ async function postAwp(url, bearer, method, params, deps) {
|
|
|
402
417
|
if (!response.ok) {
|
|
403
418
|
// 426: this CLI is below the server's minimum; its sentence says how to upgrade.
|
|
404
419
|
const said = response.status === 426 ? await response.json().then((body) => body.error?.message).catch(() => null) : null;
|
|
405
|
-
|
|
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}).`));
|
|
406
422
|
return null;
|
|
407
423
|
}
|
|
408
424
|
const body = await response.json();
|
|
@@ -516,20 +516,53 @@ export async function waitForPaneReceipt(eventId, sample = paneHasEvent, sleepFn
|
|
|
516
516
|
}
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
+
// Vadym 14045 / #1856: Rev ran one 1h12m turn while the SAME eventId sat in its input queue ~40 times. An event typed
|
|
520
|
+
// into the pane but not yet seen in its history (Claude queues a prompt typed mid-turn) stays unacked, so the server
|
|
521
|
+
// re-serves it on every poll and the feeder typed it again each time. Once typed, an event is not typed again until
|
|
522
|
+
// its window has passed (EVENT_REINJECT_MS, doubled each retry, capped at EVENT_REINJECT_MAX_MS), and it is acked as
|
|
523
|
+
// soon as it shows in the pane. The DB holds one row for it (served_at set, never acknowledged): the copies are made here.
|
|
524
|
+
const EVENT_REINJECT_MS = Number(process.env.EVENT_REINJECT_MS ?? "600000");
|
|
525
|
+
const EVENT_REINJECT_MAX_MS = Number(process.env.EVENT_REINJECT_MAX_MS ?? "3600000");
|
|
526
|
+
// While every served event is held, the server answers the next poll at once with the same batch; poll this often
|
|
527
|
+
// instead (a busy pane used to hold the loop ~INJECT_BUSY_WAIT_MS in injectEvent; 1 s was 3600 polls per held hour).
|
|
528
|
+
const HELD_POLL_MS = Number(process.env.HELD_POLL_MS ?? "30000");
|
|
529
|
+
const _typed = new Map();
|
|
530
|
+
export function noteTyped(eventId, now = Date.now()) {
|
|
531
|
+
const wait = Math.min((_typed.get(eventId)?.wait ?? EVENT_REINJECT_MS / 2) * 2, EVENT_REINJECT_MAX_MS);
|
|
532
|
+
_typed.set(eventId, { at: now, wait });
|
|
533
|
+
}
|
|
534
|
+
/** True while an event already typed into the pane should be left alone. */
|
|
535
|
+
export function typedAlready(eventId, now = Date.now()) {
|
|
536
|
+
const typed = _typed.get(eventId);
|
|
537
|
+
return Boolean(typed && now - typed.at < typed.wait);
|
|
538
|
+
}
|
|
539
|
+
export const forgetTyped = (eventId) => { _typed.delete(eventId); };
|
|
540
|
+
const pruneTyped = (now = Date.now()) => { for (const [id, t] of _typed) if (now - t.at > 24 * 3_600_000) _typed.delete(id); };
|
|
541
|
+
|
|
542
|
+
// The hosted bridge leaves this when the last workspace was stopped or crashed mid-work (TRC-RESTART-REASON).
|
|
543
|
+
const RESTART_NOTICE = join(homedir(), ".agentradar", "run", "restart-notice.json");
|
|
544
|
+
const readNotice = (file) => { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } };
|
|
545
|
+
|
|
519
546
|
/** Inject one client event into the live pane. Returns true only after Claude's pane shows the submitted turn. */
|
|
520
|
-
async function injectEvent(event) {
|
|
521
|
-
if (!(await
|
|
547
|
+
export async function injectEvent(event, { idle = waitForIdlePane, inject = injectFrame, receipt = waitForPaneReceipt, noticeFile = RESTART_NOTICE } = {}) {
|
|
548
|
+
if (!(await idle())) {
|
|
522
549
|
console.warn(
|
|
523
550
|
`[feeder] pane ${TMUX_SESSION} still mid-turn after ${INJECT_BUSY_WAIT_MS}ms — NOT injecting eventId=${event.eventId}; leaving it UNACKED for re-delivery`,
|
|
524
551
|
);
|
|
525
552
|
return false;
|
|
526
553
|
}
|
|
527
|
-
const
|
|
528
|
-
|
|
554
|
+
const restartNotice = readNotice(noticeFile);
|
|
555
|
+
const injected = await inject(
|
|
556
|
+
formatInjectionFrame(restartNotice ? { ...event, restartNotice } : event, AR_CLI_RUNNER_ID),
|
|
529
557
|
`eventId=${event.eventId} (source=${event.source}, conversationId=${event.conversationId ?? "-"})`,
|
|
530
558
|
);
|
|
531
559
|
if (!injected) return false;
|
|
532
|
-
|
|
560
|
+
noteTyped(event.eventId);
|
|
561
|
+
if (await receipt(event.eventId)) {
|
|
562
|
+
forgetTyped(event.eventId);
|
|
563
|
+
if (restartNotice) fs.rmSync(noticeFile, { force: true }); // told once
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
533
566
|
console.warn(
|
|
534
567
|
`[feeder] pane ${TMUX_SESSION} did not show eventId=${event.eventId} after injection — leaving it UNACKED for re-delivery`,
|
|
535
568
|
);
|
|
@@ -636,6 +669,15 @@ export function paneInputReady(text, harness = AR_AGENT_HARNESS, readyPattern =
|
|
|
636
669
|
: /bypass permissions on\b|^\s*❯/im.test(tail);
|
|
637
670
|
}
|
|
638
671
|
|
|
672
|
+
/**
|
|
673
|
+
* A turn, tool or dialog owns the pane, so nothing may be typed into it (Vadym 15775, Rev's phantom STOP). Measured
|
|
674
|
+
* on CLI 2.1.282 during a foreground Bash: the footer's "esc to interrupt" shows at 80 and 120 columns but is cut
|
|
675
|
+
* at 50, where the ❯ prompt and the bypass footer still show; the spinner line "✽ Sautéing… (24s · ↓ 105 tokens)"
|
|
676
|
+
* shows at every width and turns into "✻ Worked for 13s" when the turn ends.
|
|
677
|
+
*/
|
|
678
|
+
export const paneBusy = (text) => /\besc to (?:interrupt|cancel)\b/i.test(text ?? "")
|
|
679
|
+
|| /^\s*[^\w\s]\s+\S+…\s*\(\s*(?:\d+h\s*)?(?:\d+m\s*)?\d+s\b/m.test(text ?? "");
|
|
680
|
+
|
|
639
681
|
/**
|
|
640
682
|
* Vadym 12490: the permission mode Claude Code's footer names ("⏸ manual mode on"), or null when no footer shows.
|
|
641
683
|
* A hosted session must read "bypass permissions"; anything else is reported so the server can alert.
|
|
@@ -680,7 +722,7 @@ function capturePane() {
|
|
|
680
722
|
// "• Working (1s • esc to interrupt)", measured on codex-cli 0.149.1, so this matcher needs no harness
|
|
681
723
|
// branch. This is only a HINT that triggers a probe on stagnation — it never decides liveness, so a
|
|
682
724
|
// missed/extra hint is harmless.
|
|
683
|
-
const inProgress =
|
|
725
|
+
const inProgress = paneBusy(text);
|
|
684
726
|
// The CLI's idle input prompt is the durable readiness signal, and it is the one pane signal that differs
|
|
685
727
|
// per harness (see paneInputReady). The optional bypass-permissions footer disappeared from a live Claude
|
|
686
728
|
// build after several turns, which falsely demoted a healthy pane to `starting` and stopped next_event
|
|
@@ -1067,7 +1109,13 @@ let _lastRunnerState = null;
|
|
|
1067
1109
|
|
|
1068
1110
|
/** One receive→inject→ack-select cycle (extracted from loop() + exported so the #173b _pendingAck
|
|
1069
1111
|
* fence is directly testable). Sets `_pendingAck` = the eventIds to ack on the NEXT poll. */
|
|
1070
|
-
|
|
1112
|
+
/**
|
|
1113
|
+
* Vadym 15469: Claude Code takes work only through the AgentRadar channel. Keys typed into its pane interrupt a running tool
|
|
1114
|
+
* and can stay stuck in the input box, so a Claude pane fed here is told apart, never typed into: its pollOnce arguments
|
|
1115
|
+
* report channel_unconfirmed ("Not receiving messages"), which drains nothing, and never type a /model switch either.
|
|
1116
|
+
*/
|
|
1117
|
+
export const channelOnlyPoll = (harness = AR_AGENT_HARNESS) => harness === "claude" ? [paneIsLive, () => "channel_unconfirmed", injectEvent, async () => {}] : [];
|
|
1118
|
+
export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, deliverEvent = injectEvent, drive = driveModelSwitch, wait = waitForReady, paneHas = paneHasEvent) {
|
|
1071
1119
|
// next_event refreshes the portal's runner heartbeat. Never call it for a detached/orphan feeder: a
|
|
1072
1120
|
// process without its owned tmux session cannot receive work, so polling would paint a false Live state
|
|
1073
1121
|
// while every event stays unacknowledged. The supervisor recreates the session; until then the durable
|
|
@@ -1113,7 +1161,14 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
|
|
|
1113
1161
|
}
|
|
1114
1162
|
console.log(`[feeder] received ${events.length} event(s) → injecting into live session`);
|
|
1115
1163
|
const delivered = [];
|
|
1164
|
+
let held = 0;
|
|
1165
|
+
pruneTyped();
|
|
1116
1166
|
for (const e of events) {
|
|
1167
|
+
// Already typed into the pane: ack it once it shows there, else leave it alone until its window passes.
|
|
1168
|
+
if (_typed.has(e.eventId)) {
|
|
1169
|
+
if (paneHas(e.eventId)) { forgetTyped(e.eventId); delivered.push(e.eventId); continue; }
|
|
1170
|
+
if (typedAlready(e.eventId)) { held += 1; continue; }
|
|
1171
|
+
}
|
|
1117
1172
|
// Inject in order; ack ONLY the ones that reached a live pane (no-phantom).
|
|
1118
1173
|
// eslint-disable-next-line no-await-in-loop
|
|
1119
1174
|
if (await deliverEvent(firstStep ? { ...e, firstStep } : e)) delivered.push(e.eventId);
|
|
@@ -1124,6 +1179,8 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
|
|
|
1124
1179
|
}
|
|
1125
1180
|
_pendingAck = delivered;
|
|
1126
1181
|
if (delivered.length) console.log(`[feeder] delivered ${delivered.length}/${events.length} → will ack`);
|
|
1182
|
+
// The server re-serves what is still unacked at once, so a batch held back whole must not spin on it.
|
|
1183
|
+
if (held && held === events.length) await sleep(HELD_POLL_MS);
|
|
1127
1184
|
}
|
|
1128
1185
|
|
|
1129
1186
|
async function loop() {
|
|
@@ -1138,9 +1195,11 @@ async function loop() {
|
|
|
1138
1195
|
void syncSkills();
|
|
1139
1196
|
setInterval(() => { void syncSkills(); }, SKILLS_SYNC_INTERVAL_MS).unref?.();
|
|
1140
1197
|
}
|
|
1198
|
+
const channelOnly = channelOnlyPoll();
|
|
1199
|
+
if (channelOnly.length) console.warn("[feeder] Claude Code receives work only through the AgentRadar channel: run `agentradar join` again; reporting channel_unconfirmed, typing nothing");
|
|
1141
1200
|
while (!_stopping) {
|
|
1142
1201
|
try {
|
|
1143
|
-
await pollOnce();
|
|
1202
|
+
await pollOnce(...channelOnly);
|
|
1144
1203
|
} catch (err) {
|
|
1145
1204
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1146
1205
|
console.error(`[feeder] iteration error: ${msg} — re-establishing MCP session`);
|