agentradar-cli 0.2.2 → 0.2.3
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/index.js +4 -8
- package/dist/setup.js +80 -6
- package/dist/tool-run.js +0 -37
- package/dist/vendor-runner/feeder.mjs +2 -2
- package/dist/vendor-runner/session-frame.mjs +9 -4
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -4,18 +4,18 @@ import { installVendorSkill } from "./skill-install.js";
|
|
|
4
4
|
import { startChannel } from "./channel.js";
|
|
5
5
|
import { runRestartSelftest, startVendorRunner } from "./runner.js";
|
|
6
6
|
import { joinOrganisation, leaveOrganisation, setupVendorAgent } from "./setup.js";
|
|
7
|
-
import { callAwpTool, callToolOverCli, createTaskOverCli,
|
|
7
|
+
import { callAwpTool, callToolOverCli, createTaskOverCli, listServiceToolsOverCli, parseCallArgs, runServiceToolOverCli } from "./tool-run.js";
|
|
8
8
|
import { installSyncTimer, syncAgentDir } from "./sync.js";
|
|
9
9
|
// `run` is the one prefix every agent-facing command shares, so one harness "always allow agentradar run"
|
|
10
10
|
// covers a whole session: `run <link>` (or `run join <link>`) joins, `run <runner-id> <tool>` calls a tool.
|
|
11
11
|
export const runArgs = (raw) => raw[0] !== "run" ? raw
|
|
12
12
|
: raw[1] === "join" ? raw.slice(1)
|
|
13
13
|
: /^https?:\/\//.test(raw[1] ?? "") ? ["join", ...raw.slice(1)]
|
|
14
|
-
:
|
|
14
|
+
: ["call", ...raw.slice(1)];
|
|
15
15
|
async function main() {
|
|
16
16
|
const args = runArgs(process.argv.slice(2));
|
|
17
17
|
if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
|
|
18
|
-
console.log("Usage: agentradar run <enrolment-url> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar
|
|
18
|
+
console.log("Usage: agentradar run <enrolment-url> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] [--harness claude|codex] | agentradar join <enrolment-url> [--name <n>] [--model <m>] [--trust] | agentradar leave <runner-id> | agentradar channel <runner-id> | agentradar selftest <runner-id> [--status|--restart] | agentradar runner start <runner-id> | agentradar tool list <runner-id> | agentradar tool run <runner-id> <tool-key> [--args '<json>'] | agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar task create|repeat <runner-id> --args '<json>' | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
|
|
19
19
|
return;
|
|
20
20
|
}
|
|
21
21
|
if (args[0] === "leave" && args.length === 2) {
|
|
@@ -98,10 +98,6 @@ async function main() {
|
|
|
98
98
|
: await runServiceToolOverCli(parsed.runnerId, parsed.tool, parsed.json);
|
|
99
99
|
return;
|
|
100
100
|
}
|
|
101
|
-
if (args[0] === "listen" && args.length === 2) {
|
|
102
|
-
process.exitCode = await listenOverCli(args[1]);
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
101
|
if (args[0] === "task" && (args[1] === "create" || args[1] === "repeat")) {
|
|
106
102
|
if (args.length !== 5 || args[3] !== "--args") {
|
|
107
103
|
console.error("Usage: agentradar task create|repeat <runner-id> --args '<json>'");
|
|
@@ -133,7 +129,7 @@ async function main() {
|
|
|
133
129
|
return;
|
|
134
130
|
}
|
|
135
131
|
const help = args.length === 1 && (args[0] === "--help" || args[0] === "-h");
|
|
136
|
-
(help ? console.log : console.error)("Usage: agentradar run <enrolment-url> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar
|
|
132
|
+
(help ? console.log : console.error)("Usage: agentradar run <enrolment-url> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] | agentradar join <enrolment-url> [--name <n>] [--model <m>] [--trust] | agentradar leave <runner-id> | agentradar channel <runner-id> | agentradar selftest <runner-id> [--status|--restart] | agentradar runner start <runner-id> | agentradar tool list <runner-id> | agentradar tool run <runner-id> <tool-key> [--args '<json>'] | agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar task create|repeat <runner-id> --args '<json>' | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
|
|
137
133
|
if (!help)
|
|
138
134
|
process.exitCode = 1;
|
|
139
135
|
}
|
package/dist/setup.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
|
-
import { chmod, cp, lstat, mkdir, readFile, realpath, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { chmod, cp, lstat, mkdir, readFile, readlink, realpath, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { homedir } from "node:os";
|
|
@@ -188,6 +188,48 @@ export async function writePrivateFile(path, contents) {
|
|
|
188
188
|
}
|
|
189
189
|
/** The gateway env a session needs to reach its model. Never ANTHROPIC_API_KEY (OAuth or gateway only). */
|
|
190
190
|
const TOOL_ENV = /^(?:ANTHROPIC_(?!API_KEY$)[A-Z0-9_]+|CLAUDE_SECURESTORAGE_CONFIG_DIR)$/;
|
|
191
|
+
/**
|
|
192
|
+
* One TOML line with its strings emptied and its comment cut, so only real brackets are left to count.
|
|
193
|
+
* `text.open` carries a multi-line string (`"""` or `'''`) from one line to the next.
|
|
194
|
+
*/
|
|
195
|
+
function tomlCode(line, text) {
|
|
196
|
+
let out = "";
|
|
197
|
+
let i = 0;
|
|
198
|
+
while (i < line.length) {
|
|
199
|
+
if (text.open) {
|
|
200
|
+
let end = line.indexOf(text.open, i);
|
|
201
|
+
// An odd run of backslashes escapes the quote; an even run is escaped backslashes.
|
|
202
|
+
while (end > 0 && text.open === '"""' && (line.slice(0, end).match(/\\*$/)[0].length % 2))
|
|
203
|
+
end = line.indexOf(text.open, end + 1);
|
|
204
|
+
if (end < 0)
|
|
205
|
+
return out;
|
|
206
|
+
i = end + 3;
|
|
207
|
+
text.open = null;
|
|
208
|
+
out += '""';
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const three = line.slice(i, i + 3);
|
|
212
|
+
if (three === '"""' || three === "'''") {
|
|
213
|
+
text.open = three;
|
|
214
|
+
i += 3;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const c = line[i];
|
|
218
|
+
if (c === "#")
|
|
219
|
+
break;
|
|
220
|
+
if (c === '"' || c === "'") {
|
|
221
|
+
let j = i + 1;
|
|
222
|
+
while (j < line.length && line[j] !== c)
|
|
223
|
+
j += c === '"' && line[j] === "\\" ? 2 : 1;
|
|
224
|
+
i = j + 1;
|
|
225
|
+
out += '""';
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
out += c;
|
|
229
|
+
i++;
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
191
233
|
/**
|
|
192
234
|
* The service's Codex home: the vendor's config.toml without its MCP servers (the agent acts through the CLI,
|
|
193
235
|
* and Codex has no `--strict-mcp-config`), its approval rules copied, its login linked. auth.json is linked,
|
|
@@ -203,11 +245,12 @@ async function codexServiceHome(home, runnerId, env) {
|
|
|
203
245
|
let inTable = false;
|
|
204
246
|
let depth = 0;
|
|
205
247
|
let dropping = false;
|
|
248
|
+
const text = { open: null };
|
|
206
249
|
// A restart would otherwise stop on Codex's "Update available" screen, with nobody there to press a key.
|
|
207
250
|
const config = ["check_for_update_on_startup = false", ...(await readFile(join(source, "config.toml"), "utf8").catch(() => "")).split("\n").filter((line) => {
|
|
208
|
-
// Brackets inside strings and comments don't count; a line inside a multi-line array goes with its key.
|
|
209
|
-
const
|
|
210
|
-
const
|
|
251
|
+
// Brackets inside strings and comments don't count; a line inside a multi-line array or string goes with its key.
|
|
252
|
+
const continued = depth > 0 || text.open !== null;
|
|
253
|
+
const bare = tomlCode(line, text);
|
|
211
254
|
depth = Math.max(0, depth + (bare.match(/\[/g)?.length ?? 0) - (bare.match(/\]/g)?.length ?? 0));
|
|
212
255
|
if (continued)
|
|
213
256
|
return !dropping;
|
|
@@ -219,6 +262,9 @@ async function codexServiceHome(home, runnerId, env) {
|
|
|
219
262
|
dropping = inMcp || /^\s*["']?mcp_servers["']?\s*[.=]/.test(line) || (!inTable && /^\s*["']?check_for_update_on_startup["']?\s*=/.test(line));
|
|
220
263
|
return !dropping;
|
|
221
264
|
})].join("\n");
|
|
265
|
+
// Fail closed: another client's MCP server must never reach this client's session.
|
|
266
|
+
if (/^\s*(?:\[\[?\s*)?["']?mcp_servers\b/m.test(config))
|
|
267
|
+
throw new Error(`Could not strip the MCP servers from ${join(source, "config.toml")}; simplify it and run join again`);
|
|
222
268
|
await writePrivateFile(join(codexHome, "config.toml"), config);
|
|
223
269
|
await cp(join(source, "rules"), join(codexHome, "rules"), { recursive: true, force: true }).catch((error) => {
|
|
224
270
|
if (error.code !== "ENOENT")
|
|
@@ -267,7 +313,9 @@ async function installRunner(home, projectPath, approved, service) {
|
|
|
267
313
|
const bin = (service.env.AGENTRADAR_AGENT_BIN ?? "").trim();
|
|
268
314
|
if (!bin)
|
|
269
315
|
throw new Error("Set AGENTRADAR_AGENT_BIN to the command that starts your agent's CLI");
|
|
270
|
-
joined.push(`AR_AGENT_BIN=${shellValue(bin)}`, `AR_AGENT_ARGS=${shellValue(service.env.AGENTRADAR_AGENT_ARGS ?? "")}`, `AR_RESUME_FLAGS=${shellValue(service.env.AGENTRADAR_RESUME_ARGS ?? "")}`,
|
|
316
|
+
joined.push(`AR_AGENT_BIN=${shellValue(bin)}`, `AR_AGENT_ARGS=${shellValue(service.env.AGENTRADAR_AGENT_ARGS ?? "")}`, `AR_RESUME_FLAGS=${shellValue(service.env.AGENTRADAR_RESUME_ARGS ?? "")}`,
|
|
317
|
+
// The feeder types only when this matches the pane's last lines; without it a live pane counts as idle.
|
|
318
|
+
...(service.env.AGENTRADAR_INPUT_READY_PATTERN ? [`AR_INPUT_READY_PATTERN=${shellValue(service.env.AGENTRADAR_INPUT_READY_PATTERN)}`] : []), `AGENT_PID_PATTERN=${shellValue(basename(bin).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))}`);
|
|
271
319
|
}
|
|
272
320
|
else {
|
|
273
321
|
joined.push(`AR_RESUME_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`, `AR_FRESH_FLAGS=${shellValue(`--session-id ${service.sessionId}`)}`);
|
|
@@ -392,6 +440,11 @@ export async function setupVendorAgent(deps = {}) {
|
|
|
392
440
|
}
|
|
393
441
|
throw new Error("AgentRadar setup code expired; run setup again");
|
|
394
442
|
}
|
|
443
|
+
/** A sortable key for `x.y.z[-pre]`; a release sorts above its prereleases. ponytail: prereleases of one version tie. */
|
|
444
|
+
const versionRank = (version) => {
|
|
445
|
+
const [core = "", pre] = version.split(/-(.*)/);
|
|
446
|
+
return [...core.split("."), pre === undefined ? "1" : "0"].map((part) => part.padStart(9, "0")).join(".");
|
|
447
|
+
};
|
|
395
448
|
/**
|
|
396
449
|
* Copies the running CLI to ~/.agentradar/cli/<version> and points ~/.agentradar/cli/current at it. The service
|
|
397
450
|
* runs `current`, never the npx cache the CLI may be running from (npm can clean that) or a pinned version.
|
|
@@ -414,6 +467,10 @@ export async function installCli(home, pkg = dirname(dirname(fileURLToPath(impor
|
|
|
414
467
|
throw error;
|
|
415
468
|
});
|
|
416
469
|
}
|
|
470
|
+
// Forward only: an older CLI run later (a stale npx cache) never downgrades the service.
|
|
471
|
+
const current = await readlink(join(cli, "current")).catch(() => "");
|
|
472
|
+
if (current && versionRank(current) > versionRank(version))
|
|
473
|
+
return join(cli, "current", "dist");
|
|
417
474
|
const link = join(cli, `current.${process.pid}.tmp`);
|
|
418
475
|
await rm(link, { force: true });
|
|
419
476
|
await symlink(version, link);
|
|
@@ -446,7 +503,24 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
446
503
|
// AGENTRADAR_AGENT_BIN names any other CLI. Claude Code sets CLAUDECODE in its shell; a machine with both
|
|
447
504
|
// tools installed may export CODEX_HOME too.
|
|
448
505
|
const harness = deps.harness ?? (env.AGENTRADAR_AGENT_BIN?.trim() ? "generic" : env.CLAUDECODE || !env.CODEX_HOME ? "claude" : "codex");
|
|
449
|
-
|
|
506
|
+
// Checked before any request: the supervisor sources these as one-line shell values, the binary as one word.
|
|
507
|
+
if (harness === "generic") {
|
|
508
|
+
const bin = (env.AGENTRADAR_AGENT_BIN ?? "").trim();
|
|
509
|
+
if (!bin)
|
|
510
|
+
throw new Error("Set AGENTRADAR_AGENT_BIN to the command that starts your agent's CLI");
|
|
511
|
+
if (/\s/.test(bin))
|
|
512
|
+
throw new Error("AGENTRADAR_AGENT_BIN is one command with no spaces; put its flags in AGENTRADAR_AGENT_ARGS");
|
|
513
|
+
if (/[\r\n]/.test(`${env.AGENTRADAR_AGENT_ARGS ?? ""}${env.AGENTRADAR_RESUME_ARGS ?? ""}${env.AGENTRADAR_INPUT_READY_PATTERN ?? ""}`)) {
|
|
514
|
+
throw new Error("AGENTRADAR_AGENT_ARGS, AGENTRADAR_RESUME_ARGS and AGENTRADAR_INPUT_READY_PATTERN must be one line each");
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
new RegExp(env.AGENTRADAR_INPUT_READY_PATTERN ?? "");
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
throw new Error("AGENTRADAR_INPUT_READY_PATTERN is not a valid regular expression");
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const label = harness === "generic" ? basename((env.AGENTRADAR_AGENT_BIN ?? "").trim()) : harness === "claude" ? "Claude Code" : "Codex";
|
|
450
524
|
// The agent is a persistent session the service owns, not this one: no MCP entry in this folder (it acts
|
|
451
525
|
// through `agentradar run`), a runner env pinned to its own session, and a LaunchAgent / systemd unit.
|
|
452
526
|
const startJoined = async (requestId, agentId, installation, saved) => {
|
package/dist/tool-run.js
CHANGED
|
@@ -54,43 +54,6 @@ export async function callToolOverCli(runnerId, tool, argumentsJson, deps = {},
|
|
|
54
54
|
await writeFile(delivered, JSON.stringify(ids), { mode: 0o600 }).catch(() => undefined);
|
|
55
55
|
return 0;
|
|
56
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
57
|
/** Split `<runner-id> <tool> [--args '<json>'] [--<argument> <value> ...]`; null when it is not that shape. */
|
|
95
58
|
export function parseCallArgs(rest) {
|
|
96
59
|
const positional = [];
|
|
@@ -358,8 +358,8 @@ async function injectFrame(frame, label) {
|
|
|
358
358
|
console.warn(`[feeder] pane ${TMUX_SESSION} not live — NOT injecting ${label} (will re-deliver / probe times out)`);
|
|
359
359
|
return false;
|
|
360
360
|
}
|
|
361
|
-
const [
|
|
362
|
-
const okText = await tmux(
|
|
361
|
+
const [setCmd, pasteCmd, enterCmd] = sendKeysCommands(TMUX_SESSION, frame);
|
|
362
|
+
const okText = (await tmux(setCmd)) && (await tmux(pasteCmd));
|
|
363
363
|
await sleep(KEY_SETTLE_MS);
|
|
364
364
|
const okEnter = okText && (await tmux(enterCmd));
|
|
365
365
|
if (okEnter) console.log(`[feeder] injected ${label}`);
|
|
@@ -141,13 +141,18 @@ export function formatWelcomeFrame(welcome, probe) {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
|
-
* The
|
|
145
|
-
*
|
|
146
|
-
*
|
|
144
|
+
* The tmux invocations that inject `text` as one submitted turn into `session`'s pane: load it
|
|
145
|
+
* into a buffer, paste it as a bracketed paste, then a separate Enter to submit. Returns argv
|
|
146
|
+
* arrays (no shell) — caller spawns `tmux` with each, the last one after a short settle.
|
|
147
|
+
* Typed with `send-keys -l`, a frame over ~1 KB reached Claude Code 2.1.281 as an unbracketed
|
|
148
|
+
* burst and it kept only the last chunk (measured 2026-09-28: the welcome lost its first 1,022
|
|
149
|
+
* bytes, liveness command included). `-p` brackets only when the CLI asked for it.
|
|
147
150
|
*/
|
|
148
151
|
export function sendKeysCommands(session, text) {
|
|
152
|
+
const buffer = `agentradar-${session}`;
|
|
149
153
|
return [
|
|
150
|
-
["
|
|
154
|
+
["set-buffer", "-b", buffer, "--", text],
|
|
155
|
+
["paste-buffer", "-p", "-d", "-b", buffer, "-t", session],
|
|
151
156
|
["send-keys", "-t", session, "Enter"],
|
|
152
157
|
];
|
|
153
158
|
}
|