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/tool-run.js CHANGED
@@ -1,8 +1,8 @@
1
- import { readFile, realpath, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
- import { commandFor, DEPRECATED_TOOLS, toolFor } from "./commands.js";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
4
  import { cliHeader, noteCliUpdate, withCliUpdate } from "./update.js";
5
+ const UNAUTHORIZED = "AgentRadar refused the tool call (401): this agent's key is no longer valid on this server (it was revoked, or the hire it was issued for has ended). Nothing was done. Stop calling tools with this key and tell the person who runs you.";
6
6
  /**
7
7
  * One `KEY='value'` line as `setup.ts` wrote it, single-quoted with `'` escaped as `'\''`.
8
8
  * Nothing here is printed: the file holds the runner's bearer.
@@ -15,39 +15,59 @@ export function envValue(contents, key) {
15
15
  const quoted = raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2;
16
16
  return (quoted ? raw.slice(1, -1).replaceAll("'\\''", "'") : raw) || null;
17
17
  }
18
- export async function runServiceToolOverCli(runnerId, toolKey, argumentsJson, deps = {}) {
19
- const parameters = argumentsJson === undefined ? {} : parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
20
- if (!parameters)
21
- return 1;
22
- return await callAwpTool(runnerId, "run_service_tool", { toolKey, arguments: parameters, entryPoint: "cli" }, deps);
23
- }
24
- /** `agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>']`: any AWP tool from a shell, so a just-joined agent works before its harness reloads MCP tools. */
25
- export async function callToolOverCli(runnerId, tool, argumentsJson, deps = {}, flags = []) {
18
+ /**
19
+ * #1871 slice 1: `agentradar <noun> [<sub-noun>] <verb> [--<argument> <value> ...]`, every path read from the server's
20
+ * tools/list (`_meta.cli.path`), so a new tool or a renamed path needs no CLI release. `command` is that path, or a
21
+ * tool name (`call <runner-id> <tool>` and `run <runner-id> <tool>` stay until slices 4-5). A group prints its
22
+ * children, a leaf with --help its generated flags.
23
+ */
24
+ export async function callToolOverCli(runnerId, command, argumentsJson, deps = {}, flags = []) {
26
25
  const switches = new Set(flags.filter(([flag]) => ["help", "dry-run", "json"].includes(flag)).map(([flag]) => flag));
27
26
  flags = flags.filter(([flag]) => !switches.has(flag));
28
- // A noun has no underscore; a tool name has one unless it is a single word (reply, recall), which the resolver returns as is.
29
- if (tool.includes(" ") || (switches.has("help") && tool !== "" && !tool.includes("_"))) {
30
- const resolved = await resolveCommand(runnerId, tool, switches.has("help"), deps);
31
- if (typeof resolved === "number")
32
- return resolved;
33
- tool = resolved;
34
- }
35
- if (switches.has("help"))
36
- return await describeTools(runnerId, tool, deps);
27
+ let tool = command;
37
28
  const parameters = argumentsJson === undefined ? {} : parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
38
29
  if (!parameters)
39
30
  return EXIT.usage;
40
- if (flags.length) {
41
- const typed = await flagArguments(runnerId, tool, flags, deps);
42
- if (!typed)
43
- return EXIT.usage;
44
- Object.assign(parameters, typed);
31
+ // A bare tool name with nothing to type goes straight to the server: the hot `inbox next` poll and the get_self probe
32
+ // cost no tools/list round trip.
33
+ if (switches.has("help") || flags.length || argumentsJson !== undefined || !command.includes("_")) {
34
+ const tools = await listedTools(runnerId, deps);
35
+ if (!tools)
36
+ return EXIT.refused;
37
+ const entry = tools.find((candidate) => pathOf(candidate) === command) ?? tools.find((candidate) => candidate.name === command);
38
+ if (!entry?.name)
39
+ return groupHelp(runnerId, command, tools, switches.has("help"), deps);
40
+ if (switches.has("help")) {
41
+ (deps.log ?? console.log)(leafHelp(entry));
42
+ return EXIT.ok;
43
+ }
44
+ tool = entry.name;
45
+ const schema = entry.inputSchema?.properties ?? {};
46
+ // PER-TOOL-MODES (Vadym 12919): `--tool X=ask` builds `tools`, on any tool that takes `tools` and no `tool`.
47
+ if ("tools" in schema && !("tool" in schema) && flags.some(([flag]) => flag === "tool")) {
48
+ const named = namedToolFlags(flags);
49
+ parameters.tools = named.tools;
50
+ flags = named.flags;
51
+ }
52
+ if (flags.length) {
53
+ const typed = await flagArguments(entry, flags, deps);
54
+ if (!typed)
55
+ return EXIT.usage;
56
+ Object.assign(parameters, typed);
57
+ }
58
+ // Provenance for the execution ledger (R572), not permission: the server treats it as the self-report it is.
59
+ if ("entryPoint" in schema && parameters.entryPoint === undefined)
60
+ parameters.entryPoint = "cli";
45
61
  }
46
62
  // --dry-run: say exactly what would be sent, and send nothing.
47
63
  if (switches.has("dry-run")) {
48
64
  (deps.log ?? console.log)(JSON.stringify({ dryRun: true, tool, arguments: parameters }));
49
65
  return EXIT.ok;
50
66
  }
67
+ if (tool === "inbox_search" && !switches.has("json")) {
68
+ const log = deps.log ?? console.log;
69
+ deps = { ...deps, log: (text) => log(inboxLines(text)) };
70
+ }
51
71
  if (tool !== "next_event" || !RUNNER_ID.test(runnerId))
52
72
  return await callAwpTool(runnerId, tool, parameters, deps);
53
73
  // A bare poll acknowledges the batch the previous poll printed, so the command stays byte-identical and
@@ -59,6 +79,8 @@ export async function callToolOverCli(runnerId, tool, argumentsJson, deps = {},
59
79
  if (Array.isArray(ids) && ids.length)
60
80
  parameters.ackEventIds = ids;
61
81
  }
82
+ // The agent's own poll answers at once: the channel process holds the long poll, and a held agent call stalls its whole turn.
83
+ parameters.waitMs ??= 0;
62
84
  let said = "";
63
85
  const code = await callAwpTool(runnerId, tool, parameters, { ...deps, log: (text) => { said = text; (deps.log ?? console.log)(text); } });
64
86
  if (code !== 0)
@@ -69,7 +91,8 @@ export async function callToolOverCli(runnerId, tool, argumentsJson, deps = {},
69
91
  }
70
92
  catch { /* not a batch: nothing to acknowledge */ }
71
93
  const ids = Array.isArray(events) ? events.map((event) => event?.eventId).filter((id) => typeof id === "string") : [];
72
- await writeFile(delivered, JSON.stringify(ids), { mode: 0o600 }).catch(() => undefined);
94
+ // A hosted home has no runner file, so the folder may not exist yet; without it the ack and the channel's proof of a poll were lost.
95
+ await mkdir(dirname(delivered), { recursive: true }).then(() => writeFile(delivered, JSON.stringify(ids), { mode: 0o600 })).catch(() => undefined);
73
96
  return 0;
74
97
  }
75
98
  /**
@@ -77,39 +100,16 @@ export async function callToolOverCli(runnerId, tool, argumentsJson, deps = {},
77
100
  * (its sentence is on stderr); 2 the command itself was wrong (unknown command, missing or mistyped argument).
78
101
  */
79
102
  export const EXIT = { ok: 0, refused: 1, usage: 2 };
80
- /**
81
- * `<noun> <verb>` → the tool it names on this server; or an exit code once `<noun> --help` has listed its
82
- * verbs, or after saying what is wrong.
83
- */
84
- async function resolveCommand(runnerId, command, help, deps) {
85
- const tools = await listedTools(runnerId, deps);
86
- if (!tools)
87
- return EXIT.refused;
88
- const names = tools.map((entry) => entry.name ?? "").filter((name) => name && !DEPRECATED_TOOLS.has(name));
89
- const [noun, verb] = command.split(" ");
90
- if (!verb && names.includes(noun))
91
- return noun;
92
- if (verb) {
93
- const tool = toolFor(noun, verb, names);
94
- if (tool)
95
- return tool;
96
- }
97
- const verbs = names.filter((name) => commandFor(name)[0] === noun).map((name) => commandFor(name)[1]).sort();
98
- const log = help && !verb ? deps.log ?? console.log : deps.logError ?? console.error;
99
- log(verbs.length
100
- ? `${verb ? `${noun} ${verb} is not a command. ` : ""}agentradar run ${runnerId} ${noun} <verb>: ${verbs.join(" ")}. Add --help to a command for its arguments.`
101
- : `${noun} is not a command group. Groups: ${[...new Set(names.map((name) => commandFor(name)[0]))].sort().join(" ")}.`);
102
- return help && !verb && verbs.length ? EXIT.ok : EXIT.usage;
103
- }
104
- /** Split `<runner-id> <tool> [--args '<json>'] [--<argument> <value> ...]`; null when it is not that shape. */
103
+ const EXIT_CODES = "EXIT CODES\n 0 done 1 refused or unreachable (the reason is on stderr) 2 fix the command";
104
+ /** Split `<word> ... [--args '<json>'] [--<argument> <value> ...]`; null when a flag has no value. */
105
105
  export function parseCallArgs(rest) {
106
- const positional = [];
106
+ const words = [];
107
107
  const flags = [];
108
108
  let json;
109
109
  for (let i = 0; i < rest.length; i++) {
110
110
  const arg = rest[i];
111
111
  if (!arg.startsWith("--")) {
112
- positional.push(arg);
112
+ words.push(arg);
113
113
  continue;
114
114
  }
115
115
  if (arg === "--help" || arg === "--dry-run" || arg === "--json") {
@@ -124,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;
@@ -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
- 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;
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
- 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}).`));
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 waitForIdlePane())) {
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 injected = await injectFrame(
528
- formatInjectionFrame(event, AR_CLI_RUNNER_ID),
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
- if (await waitForPaneReceipt(event.eventId)) return true;
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 = /esc to interrupt|\besc to cancel\b/i.test(text);
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
- export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, deliverEvent = injectEvent, drive = driveModelSwitch, wait = waitForReady) {
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`);