agentradar-cli 0.2.1 → 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/channel.js +2 -2
- package/dist/index.js +4 -8
- package/dist/runner.js +1 -1
- package/dist/setup.js +130 -8
- 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/dist/vendor-runner/supervisor/supervise-agent.sh +7 -6
- package/package.json +1 -1
package/dist/channel.js
CHANGED
|
@@ -6,8 +6,8 @@ import { join } from "node:path";
|
|
|
6
6
|
import { createInterface } from "node:readline";
|
|
7
7
|
import { envValue } from "./tool-run.js";
|
|
8
8
|
import { trustedMcpEndpoint } from "./setup.js";
|
|
9
|
-
export const CHANNEL_GUIDANCE = "AgentRadar channel messages are wake signals only. Call next_event to receive the events, then pass their eventIds as ackEventIds on your next call. Answer livenessProbe with liveness_ack. Never acknowledge an event you have not read.
|
|
10
|
-
export const CHANNEL_BLOCKED = "AgentRadar has not observed a next_event call from this session. Channel delivery is unconfirmed. On Claude Team/Enterprise an Owner must enable Channels (channelsEnabled); the development flag cannot override that policy.
|
|
9
|
+
export const CHANNEL_GUIDANCE = "AgentRadar channel messages are wake signals only. Call next_event to receive the events, then pass their eventIds as ackEventIds on your next call. Answer livenessProbe with liveness_ack. Never acknowledge an event you have not read. Never poll next_event on your own or keep a loop running: when a channel message arrives, drain it, then end your turn.";
|
|
10
|
+
export const CHANNEL_BLOCKED = "AgentRadar has not observed a next_event call from this session. Channel delivery is unconfirmed. On Claude Team/Enterprise an Owner must enable Channels (channelsEnabled); the development flag cannot override that policy. Until then, deliver with the feeder: set AR_FEED=feeder in ~/.agentradar/agents/<runner-id>.env and restart the runner.";
|
|
11
11
|
/** One local MCP, forwarding tools to AWP. Wake signals never consume the durable queue. */
|
|
12
12
|
export async function startChannel(runnerId, deps = {}) {
|
|
13
13
|
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(runnerId))
|
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/runner.js
CHANGED
|
@@ -37,7 +37,7 @@ export async function runRestartSelftest(runnerId, deps = {}) {
|
|
|
37
37
|
} while (Date.now() < deadline);
|
|
38
38
|
throw new Error(timedOut);
|
|
39
39
|
};
|
|
40
|
-
const unanswered = `The agent did not act on the check:
|
|
40
|
+
const unanswered = `The agent did not act on the check: neither its feeder nor a channel push delivered it. Check the runner is running (agentradar runner start ${runnerId}), then run this again.`;
|
|
41
41
|
const restart = deps.restart ?? ((beforeRestart) => restartRunnerHarness(runnerId, deps.home, run, beforeRestart));
|
|
42
42
|
// Refuse before the drill starts: run every restart check, stopping exactly where the real restart would begin.
|
|
43
43
|
const checked = new Error("restart checks passed");
|
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";
|
|
@@ -21,6 +21,7 @@ const SUPERVISOR_HARNESS = {
|
|
|
21
21
|
claude: "claude",
|
|
22
22
|
claude_code: "claude",
|
|
23
23
|
codex: "codex",
|
|
24
|
+
generic: "generic",
|
|
24
25
|
};
|
|
25
26
|
async function postJson(fetchImpl, url, body) {
|
|
26
27
|
return fetchImpl(url, {
|
|
@@ -187,6 +188,48 @@ export async function writePrivateFile(path, contents) {
|
|
|
187
188
|
}
|
|
188
189
|
/** The gateway env a session needs to reach its model. Never ANTHROPIC_API_KEY (OAuth or gateway only). */
|
|
189
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
|
+
}
|
|
190
233
|
/**
|
|
191
234
|
* The service's Codex home: the vendor's config.toml without its MCP servers (the agent acts through the CLI,
|
|
192
235
|
* and Codex has no `--strict-mcp-config`), its approval rules copied, its login linked. auth.json is linked,
|
|
@@ -200,15 +243,28 @@ async function codexServiceHome(home, runnerId, env) {
|
|
|
200
243
|
await chmod(codexHome, 0o700);
|
|
201
244
|
let inMcp = false;
|
|
202
245
|
let inTable = false;
|
|
246
|
+
let depth = 0;
|
|
247
|
+
let dropping = false;
|
|
248
|
+
const text = { open: null };
|
|
203
249
|
// A restart would otherwise stop on Codex's "Update available" screen, with nobody there to press a key.
|
|
204
250
|
const config = ["check_for_update_on_startup = false", ...(await readFile(join(source, "config.toml"), "utf8").catch(() => "")).split("\n").filter((line) => {
|
|
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);
|
|
254
|
+
depth = Math.max(0, depth + (bare.match(/\[/g)?.length ?? 0) - (bare.match(/\]/g)?.length ?? 0));
|
|
255
|
+
if (continued)
|
|
256
|
+
return !dropping;
|
|
205
257
|
const header = /^\s*\[\[?\s*([^\]]+)\]/.exec(line);
|
|
206
258
|
if (header) {
|
|
207
|
-
inMcp = /^"?mcp_servers\b/.test(header[1]);
|
|
259
|
+
inMcp = /^["']?mcp_servers\b/.test(header[1]);
|
|
208
260
|
inTable = true;
|
|
209
261
|
}
|
|
210
|
-
|
|
262
|
+
dropping = inMcp || /^\s*["']?mcp_servers["']?\s*[.=]/.test(line) || (!inTable && /^\s*["']?check_for_update_on_startup["']?\s*=/.test(line));
|
|
263
|
+
return !dropping;
|
|
211
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`);
|
|
212
268
|
await writePrivateFile(join(codexHome, "config.toml"), config);
|
|
213
269
|
await cp(join(source, "rules"), join(codexHome, "rules"), { recursive: true, force: true }).catch((error) => {
|
|
214
270
|
if (error.code !== "ENOENT")
|
|
@@ -251,6 +307,16 @@ async function installRunner(home, projectPath, approved, service) {
|
|
|
251
307
|
joined.push(`CODEX_HOME=${shellValue(codexHome)}`);
|
|
252
308
|
toolKeys = keys;
|
|
253
309
|
}
|
|
310
|
+
else if (harness === "generic") {
|
|
311
|
+
// Any other interactive CLI, named by the vendor. The pane-alive check walks only this pane's own
|
|
312
|
+
// process tree, so the binary's name is a unique enough pattern.
|
|
313
|
+
const bin = (service.env.AGENTRADAR_AGENT_BIN ?? "").trim();
|
|
314
|
+
if (!bin)
|
|
315
|
+
throw new Error("Set AGENTRADAR_AGENT_BIN to the command that starts your agent's CLI");
|
|
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, "\\$&"))}`);
|
|
319
|
+
}
|
|
254
320
|
else {
|
|
255
321
|
joined.push(`AR_RESUME_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`, `AR_FRESH_FLAGS=${shellValue(`--session-id ${service.sessionId}`)}`);
|
|
256
322
|
const configDirEnv = service.env.CLAUDE_CONFIG_DIR?.trim();
|
|
@@ -374,9 +440,46 @@ export async function setupVendorAgent(deps = {}) {
|
|
|
374
440
|
}
|
|
375
441
|
throw new Error("AgentRadar setup code expired; run setup again");
|
|
376
442
|
}
|
|
377
|
-
/**
|
|
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
|
+
};
|
|
448
|
+
/**
|
|
449
|
+
* Copies the running CLI to ~/.agentradar/cli/<version> and points ~/.agentradar/cli/current at it. The service
|
|
450
|
+
* runs `current`, never the npx cache the CLI may be running from (npm can clean that) or a pinned version.
|
|
451
|
+
* Returns the `current` dist directory.
|
|
452
|
+
*/
|
|
453
|
+
export async function installCli(home, pkg = dirname(dirname(fileURLToPath(import.meta.url)))) {
|
|
454
|
+
const { version } = JSON.parse(await readFile(join(pkg, "package.json"), "utf8"));
|
|
455
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version))
|
|
456
|
+
throw new Error(`agentradar-cli has an invalid version: ${JSON.stringify(version)}`);
|
|
457
|
+
const cli = join(home, ".agentradar", "cli");
|
|
458
|
+
const target = join(cli, version);
|
|
459
|
+
if (!(await lstat(target).then(() => true, () => false))) {
|
|
460
|
+
// Staged then renamed, so a half-finished copy is never a version directory.
|
|
461
|
+
const staged = `${target}.${process.pid}.tmp`;
|
|
462
|
+
await cp(join(pkg, "dist"), join(staged, "dist"), { recursive: true });
|
|
463
|
+
await cp(join(pkg, "package.json"), join(staged, "package.json"));
|
|
464
|
+
await rename(staged, target).catch(async (error) => {
|
|
465
|
+
await rm(staged, { recursive: true, force: true });
|
|
466
|
+
if (error.code !== "ENOTEMPTY" && error.code !== "EEXIST")
|
|
467
|
+
throw error;
|
|
468
|
+
});
|
|
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");
|
|
474
|
+
const link = join(cli, `current.${process.pid}.tmp`);
|
|
475
|
+
await rm(link, { force: true });
|
|
476
|
+
await symlink(version, link);
|
|
477
|
+
await rename(link, join(cli, "current"));
|
|
478
|
+
return join(cli, "current", "dist");
|
|
479
|
+
}
|
|
480
|
+
/** `install-agent.sh <id> --load` with the CLI at ~/.agentradar/cli/current, so the service does not depend on PATH. */
|
|
378
481
|
async function installAgentService(runnerId, home, env) {
|
|
379
|
-
const dist =
|
|
482
|
+
const dist = await installCli(home);
|
|
380
483
|
await promisify(execFile)("bash", [join(dist, "vendor-runner", "supervisor", "install-agent.sh"), runnerId, "--load"], {
|
|
381
484
|
env: { ...env, HOME: home, NODE_BIN: process.execPath, AGENTRADAR_JS: join(dist, "index.js") },
|
|
382
485
|
});
|
|
@@ -397,8 +500,27 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
397
500
|
const cwd = deps.cwd ?? process.cwd();
|
|
398
501
|
const home = deps.home ?? homedir();
|
|
399
502
|
const env = deps.env ?? process.env;
|
|
400
|
-
// Claude Code sets CLAUDECODE in its shell; a machine with both
|
|
401
|
-
|
|
503
|
+
// AGENTRADAR_AGENT_BIN names any other CLI. Claude Code sets CLAUDECODE in its shell; a machine with both
|
|
504
|
+
// tools installed may export CODEX_HOME too.
|
|
505
|
+
const harness = deps.harness ?? (env.AGENTRADAR_AGENT_BIN?.trim() ? "generic" : env.CLAUDECODE || !env.CODEX_HOME ? "claude" : "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";
|
|
402
524
|
// The agent is a persistent session the service owns, not this one: no MCP entry in this folder (it acts
|
|
403
525
|
// through `agentradar run`), a runner env pinned to its own session, and a LaunchAgent / systemd unit.
|
|
404
526
|
const startJoined = async (requestId, agentId, installation, saved) => {
|
|
@@ -419,7 +541,7 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
419
541
|
log(`Could not start the service: ${error.message.split("\n")[0]}. Start it by hand with: agentradar runner start ${runnerId}`);
|
|
420
542
|
return 1;
|
|
421
543
|
}
|
|
422
|
-
log(`Connected. Your agent now runs in its own ${
|
|
544
|
+
log(`Connected. Your agent now runs in its own ${label} session in ${projectPath} (tmux session ${runnerId}): your welcome and every client message are typed into it, and it answers there. There is nothing more to do in this session. To stop it: agentradar leave ${runnerId}`);
|
|
423
545
|
log("Running as a service; it restarts on crash and reboot.");
|
|
424
546
|
return 0;
|
|
425
547
|
};
|
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
|
}
|
|
@@ -77,20 +77,21 @@ HEALTH_POLL_SEC="${HEALTH_POLL_SEC:-15}"
|
|
|
77
77
|
# interactive CLI: the env file names its binary, arguments, resume/fresh flags, pid pattern and how work
|
|
78
78
|
# reaches it (AR_FEED). Anything else is REFUSED by name: a supervisor that quietly fell back to Claude for an
|
|
79
79
|
# unrecognized value would come up healthy, keep a pane alive, and answer nothing the vendor expected.
|
|
80
|
-
# Tried: claude and codex with AR_FEED=feeder. Untried: generic, AR_FEED=push (Claude channels)
|
|
80
|
+
# Tried: claude and codex with AR_FEED=feeder. Untried: generic, AR_FEED=push (Claude channels).
|
|
81
81
|
AR_AGENT_HARNESS="${AR_AGENT_HARNESS:-claude}"
|
|
82
82
|
case "${AR_AGENT_HARNESS}" in
|
|
83
83
|
claude|codex|generic) : ;;
|
|
84
84
|
*) die "AR_AGENT_HARNESS must be 'claude', 'codex' or 'generic' (got '${AR_AGENT_HARNESS}')" ;;
|
|
85
85
|
esac
|
|
86
86
|
# How work reaches the session. feeder: the feeder polls next_event and types each event into the pane.
|
|
87
|
-
# push: the session's own MCP pushes it (Claude channels).
|
|
88
|
-
#
|
|
87
|
+
# push: the session's own MCP pushes it (Claude channels) and no feeder starts. There is no mode where the
|
|
88
|
+
# agent polls for itself: an agent eventually forgets to. ACP is named so a vendor asking for it is told
|
|
89
|
+
# plainly it is not built.
|
|
89
90
|
AR_FEED="${AR_FEED:-feeder}"
|
|
90
91
|
case "${AR_FEED}" in
|
|
91
|
-
feeder|push
|
|
92
|
-
acp) die "AR_FEED=acp is not built yet; use feeder
|
|
93
|
-
*) die "AR_FEED must be feeder
|
|
92
|
+
feeder|push) : ;;
|
|
93
|
+
acp) die "AR_FEED=acp is not built yet; use feeder or push" ;;
|
|
94
|
+
*) die "AR_FEED must be feeder or push (got '${AR_FEED}')" ;;
|
|
94
95
|
esac
|
|
95
96
|
# Launched only when the resume form exits non-zero (nothing to resume). Empty = the same argv without it.
|
|
96
97
|
AR_FRESH_FLAGS="${AR_FRESH_FLAGS:-}"
|