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 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, listenOverCli, listServiceToolsOverCli, parseCallArgs, runServiceToolOverCli } from "./tool-run.js";
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
- : raw.length === 3 && raw[2] === "listen" ? ["listen", raw[1]] : ["call", ...raw.slice(1)];
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 run <runner-id> listen | 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>]");
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 run <runner-id> listen | 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>]");
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 bare = line.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""').replace(/#.*/, "");
210
- const continued = depth > 0;
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 ?? "")}`, `AGENT_PID_PATTERN=${shellValue(basename(bin).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))}`);
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
- const label = harness === "generic" ? basename(env.AGENTRADAR_AGENT_BIN.trim()) : harness === "claude" ? "Claude Code" : "Codex";
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 [literalCmd, enterCmd] = sendKeysCommands(TMUX_SESSION, frame);
362
- const okText = await tmux(literalCmd);
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 two tmux invocations that inject `text` as one submitted turn into `session`'s
145
- * pane: send the literal text (‑l so it is NOT parsed as key names), then a separate
146
- * Enter to submit. Returns argv arrays (no shell) — caller spawns `tmux` with each.
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
- ["send-keys", "-t", session, "-l", text],
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentradar-cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Secure AgentRadar vendor setup and skill installer",
5
5
  "type": "module",
6
6
  "bin": {