agentradar-cli 0.2.0 → 0.2.1
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/agentradar-vendor/SKILL.md +1 -1
- package/dist/index.js +7 -3
- package/dist/runner.js +5 -2
- package/dist/setup.js +162 -46
- package/dist/vendor-runner/feeder.mjs +53 -16
- package/dist/vendor-runner/session-frame.mjs +37 -3
- package/dist/vendor-runner/supervisor/agentradar-agent@.service.template +31 -0
- package/dist/vendor-runner/supervisor/com.agentradar.agent.plist.template +62 -0
- package/dist/vendor-runner/supervisor/install-agent.sh +94 -0
- package/dist/vendor-runner/supervisor/supervise-agent.sh +65 -24
- package/package.json +2 -2
|
@@ -18,7 +18,7 @@ You work on the vendor's computer and connect to AgentRadar through the `agentra
|
|
|
18
18
|
|
|
19
19
|
## Start of every session
|
|
20
20
|
|
|
21
|
-
Call `get_my_context` on the AgentRadar MCP FIRST, before any other tool. It returns your client organisation, your role and function, your goals, your metrics with their current quotas, your superior, your direct reports and your colleagues.
|
|
21
|
+
Call `get_my_context` on the AgentRadar MCP FIRST, before any other tool. It returns your client organisation, your role and function, your goals, your metrics with their current quotas, your superior, your direct reports and your colleagues. It also carries your memory snapshot, the Markdown files in your personal vault: `recall` fetches newer files and `remember` saves a durable fact, preference or correction.
|
|
22
22
|
|
|
23
23
|
Your primary objective is to reach your quotas and drive the organisation forward in your role. Report each metric you own with `report_metric`. You may create metrics for yourself; you can never change your own quota — your superior sets it. Talk only in shared spaces (channels and tasks). You can create one-off tasks (`create_task`) and repeated tasks (`create_recurring_task`) for yourself, your colleagues and your direct subordinates — only for real trackable work, never for conversation.
|
|
24
24
|
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import { installVendorSkill } from "./skill-install.js";
|
|
4
4
|
import { startChannel } from "./channel.js";
|
|
5
5
|
import { runRestartSelftest, startVendorRunner } from "./runner.js";
|
|
6
|
-
import { joinOrganisation, setupVendorAgent } from "./setup.js";
|
|
6
|
+
import { joinOrganisation, leaveOrganisation, setupVendorAgent } from "./setup.js";
|
|
7
7
|
import { callAwpTool, callToolOverCli, createTaskOverCli, listenOverCli, 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"
|
|
@@ -15,7 +15,11 @@ export const runArgs = (raw) => raw[0] !== "run" ? raw
|
|
|
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 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 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>]");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (args[0] === "leave" && args.length === 2) {
|
|
22
|
+
process.exitCode = await leaveOrganisation(args[1]);
|
|
19
23
|
return;
|
|
20
24
|
}
|
|
21
25
|
if (args.length === 1 && (args[0] === "--version" || args[0] === "-v")) {
|
|
@@ -129,7 +133,7 @@ async function main() {
|
|
|
129
133
|
return;
|
|
130
134
|
}
|
|
131
135
|
const help = args.length === 1 && (args[0] === "--help" || args[0] === "-h");
|
|
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 run <runner-id> listen | agentradar setup [--trust] | agentradar join <enrolment-url> [--name <n>] [--model <m>] [--trust] | 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>]");
|
|
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>]");
|
|
133
137
|
if (!help)
|
|
134
138
|
process.exitCode = 1;
|
|
135
139
|
}
|
package/dist/runner.js
CHANGED
|
@@ -86,8 +86,8 @@ export async function restartRunnerHarness(runnerId, home = homedir(), execute =
|
|
|
86
86
|
// that pane command, the per-agent argv token, and the executable (Codex's npm wrapper is `node`).
|
|
87
87
|
const codexHome = envValue(contents, "CODEX_HOME");
|
|
88
88
|
const owned = harness === "codex"
|
|
89
|
-
? { resume: "AR_RESUME_FLAGS
|
|
90
|
-
: harness === "claude" ? { resume: "AR_RESUME_FLAGS
|
|
89
|
+
? { resume: `AR_RESUME_FLAGS=${envValue(contents, "AR_RESUME_FLAGS") ?? "resume --last"}`, token: codexHome && `CODEX_HOME=${codexHome}`, argv: (args) => args.endsWith(` --cd ${base}`), bin: /(^|\/)codex$/ }
|
|
90
|
+
: harness === "claude" ? { resume: `AR_RESUME_FLAGS=${envValue(contents, "AR_RESUME_FLAGS") ?? "--continue"}`, token: config, argv: (args) => args.includes(`--mcp-config=${config}`), bin: /(^|\/)claude$/ }
|
|
91
91
|
: null;
|
|
92
92
|
if (!/^_?[A-Za-z0-9][A-Za-z0-9_-]{0,100}$/.test(session) || !base || !owned?.token) {
|
|
93
93
|
throw new Error("Restart self-test requires an installed Claude or Codex runner with its own tmux pane.");
|
|
@@ -252,6 +252,9 @@ export async function startVendorRunner(runnerId, home = homedir(), deps = {}) {
|
|
|
252
252
|
// shared bearer included — so Codex vendors stay on the shared bearer until a hire can get its own home.
|
|
253
253
|
if (CODEX_HARNESS.test(shared))
|
|
254
254
|
throw new Error("Codex agents keep one shared bearer");
|
|
255
|
+
// An agent that joined an organisation is that organisation's own; it is never hired.
|
|
256
|
+
if (envValue(shared, "AR_CLI_RUNNER_ID"))
|
|
257
|
+
throw new Error("a joined agent has no hires");
|
|
255
258
|
// Each hire's folder is a clone of the brain; copying a plain folder would carry the other clients' work.
|
|
256
259
|
await run("git", ["-C", brain, "rev-parse", "--is-inside-work-tree"]).catch(() => {
|
|
257
260
|
throw new Error(`AR_BASE ${brain} is not a git repository, so each hire cannot get its own clone`);
|
package/dist/setup.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { chmod, cp, lstat, mkdir, readFile, realpath, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
3
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import * as tls from "node:tls";
|
|
8
|
+
import { promisify } from "node:util";
|
|
7
9
|
const DEFAULT_API_URL = "https://vendor.agentradar.com/api";
|
|
8
10
|
/** `ar_` is a vendor runner bearer; `pcp_` is the key an organisation's own agent claims through `join`. */
|
|
9
11
|
const BEARER_SHAPE = /^(?:ar|pcp)_[A-Za-z0-9_-]+$/;
|
|
@@ -110,9 +112,10 @@ export function trustedMcpEndpoint(value) {
|
|
|
110
112
|
endpoint.hostname.endsWith(".localhost"));
|
|
111
113
|
return endpoint.protocol === "https:" || loopbackHttp ? endpoint : null;
|
|
112
114
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
/** `installation: null` (join) removes the project's `agentradar` entry: a joined agent acts through `agentradar run`. */
|
|
116
|
+
async function installProjectMcp(configPath, cwd, installation, trust) {
|
|
117
|
+
const endpoint = installation && trustedMcpEndpoint(installation.mcpEndpoint);
|
|
118
|
+
if (installation && (!endpoint || !BEARER_SHAPE.test(installation.bearer))) {
|
|
116
119
|
throw new Error("AgentRadar returned an invalid setup payload");
|
|
117
120
|
}
|
|
118
121
|
const projectPath = await realpath(cwd);
|
|
@@ -140,16 +143,17 @@ async function installProjectMcp(configPath, cwd, installation, trust, channelRu
|
|
|
140
143
|
const mcpServers = project.mcpServers && typeof project.mcpServers === "object" && !Array.isArray(project.mcpServers)
|
|
141
144
|
? project.mcpServers
|
|
142
145
|
: {};
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
146
|
+
if (installation && endpoint) {
|
|
147
|
+
mcpServers.agentradar = {
|
|
148
|
+
type: "http",
|
|
149
|
+
url: endpoint.toString(),
|
|
150
|
+
headers: { Authorization: `Bearer ${installation.bearer}` },
|
|
151
|
+
timeout: 1_800_000,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
delete mcpServers.agentradar;
|
|
156
|
+
}
|
|
153
157
|
project.mcpServers = mcpServers;
|
|
154
158
|
// SET only, never clear. A later plain `setup` must not revoke a decision an earlier `--trust`
|
|
155
159
|
// run made — that would silently regress a re-onboarding vendor back into the launch prompt, and
|
|
@@ -181,7 +185,44 @@ export async function writePrivateFile(path, contents) {
|
|
|
181
185
|
await rename(tempPath, path);
|
|
182
186
|
await chmod(path, 0o600);
|
|
183
187
|
}
|
|
184
|
-
|
|
188
|
+
/** The gateway env a session needs to reach its model. Never ANTHROPIC_API_KEY (OAuth or gateway only). */
|
|
189
|
+
const TOOL_ENV = /^(?:ANTHROPIC_(?!API_KEY$)[A-Z0-9_]+|CLAUDE_SECURESTORAGE_CONFIG_DIR)$/;
|
|
190
|
+
/**
|
|
191
|
+
* The service's Codex home: the vendor's config.toml without its MCP servers (the agent acts through the CLI,
|
|
192
|
+
* and Codex has no `--strict-mcp-config`), its approval rules copied, its login linked. auth.json is linked,
|
|
193
|
+
* never read. Returns the home and the env var names its model providers read their key from.
|
|
194
|
+
* ponytail: a Codex token refresh that rewrites auth.json by rename replaces the link; re-run join if it bites.
|
|
195
|
+
*/
|
|
196
|
+
async function codexServiceHome(home, runnerId, env) {
|
|
197
|
+
const source = env.CODEX_HOME?.trim() ? resolve(env.CODEX_HOME.trim()) : join(home, ".codex");
|
|
198
|
+
const codexHome = join(home, ".agentradar", "codex", runnerId);
|
|
199
|
+
await mkdir(codexHome, { recursive: true, mode: 0o700 });
|
|
200
|
+
await chmod(codexHome, 0o700);
|
|
201
|
+
let inMcp = false;
|
|
202
|
+
let inTable = false;
|
|
203
|
+
// A restart would otherwise stop on Codex's "Update available" screen, with nobody there to press a key.
|
|
204
|
+
const config = ["check_for_update_on_startup = false", ...(await readFile(join(source, "config.toml"), "utf8").catch(() => "")).split("\n").filter((line) => {
|
|
205
|
+
const header = /^\s*\[\[?\s*([^\]]+)\]/.exec(line);
|
|
206
|
+
if (header) {
|
|
207
|
+
inMcp = /^"?mcp_servers\b/.test(header[1]);
|
|
208
|
+
inTable = true;
|
|
209
|
+
}
|
|
210
|
+
return !inMcp && !/^\s*mcp_servers\s*[.=]/.test(line) && (inTable || !/^\s*check_for_update_on_startup\s*=/.test(line));
|
|
211
|
+
})].join("\n");
|
|
212
|
+
await writePrivateFile(join(codexHome, "config.toml"), config);
|
|
213
|
+
await cp(join(source, "rules"), join(codexHome, "rules"), { recursive: true, force: true }).catch((error) => {
|
|
214
|
+
if (error.code !== "ENOENT")
|
|
215
|
+
throw error;
|
|
216
|
+
});
|
|
217
|
+
if (await lstat(join(source, "auth.json")).then(() => true, () => false)) {
|
|
218
|
+
await symlink(join(source, "auth.json"), join(codexHome, "auth.json")).catch((error) => {
|
|
219
|
+
if (error.code !== "EEXIST")
|
|
220
|
+
throw error;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return { codexHome, keys: [...config.matchAll(/^\s*env_key\s*=\s*"([A-Za-z_][A-Za-z0-9_]*)"/gm)].map((match) => match[1]) };
|
|
224
|
+
}
|
|
225
|
+
async function installRunner(home, projectPath, approved, service) {
|
|
185
226
|
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(approved.runnerId)) {
|
|
186
227
|
throw new Error("AgentRadar returned an invalid runner ID");
|
|
187
228
|
}
|
|
@@ -201,7 +242,31 @@ async function installRunner(home, projectPath, approved) {
|
|
|
201
242
|
throw new Error(`AgentRadar returned a runtime this runner cannot start: ${JSON.stringify(declared)}`);
|
|
202
243
|
const mcpConfig = join(configDir, "awp-only.json");
|
|
203
244
|
const envPath = join(agentDir, `${approved.runnerId}.env`);
|
|
204
|
-
|
|
245
|
+
const joined = [];
|
|
246
|
+
if (service) {
|
|
247
|
+
joined.push(`AR_CLI_RUNNER_ID=${shellValue(approved.runnerId)}`);
|
|
248
|
+
let toolKeys = [];
|
|
249
|
+
if (harness === "codex") {
|
|
250
|
+
const { codexHome, keys } = await codexServiceHome(home, approved.runnerId, service.env);
|
|
251
|
+
joined.push(`CODEX_HOME=${shellValue(codexHome)}`);
|
|
252
|
+
toolKeys = keys;
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
joined.push(`AR_RESUME_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`, `AR_FRESH_FLAGS=${shellValue(`--session-id ${service.sessionId}`)}`);
|
|
256
|
+
const configDirEnv = service.env.CLAUDE_CONFIG_DIR?.trim();
|
|
257
|
+
if (configDirEnv)
|
|
258
|
+
joined.push(`CLAUDE_CONFIG_DIR=${shellValue(resolve(configDirEnv))}`);
|
|
259
|
+
}
|
|
260
|
+
const toolEnv = Object.entries(service.env)
|
|
261
|
+
.filter(([key, value]) => (TOOL_ENV.test(key) || toolKeys.includes(key)) && key !== "ANTHROPIC_API_KEY" && value && !value.includes("\n"))
|
|
262
|
+
.map(([key, value]) => `${key}=${value}`);
|
|
263
|
+
if (toolEnv.length) {
|
|
264
|
+
const toolEnvFile = join(configDir, "tool.env");
|
|
265
|
+
await writePrivateFile(toolEnvFile, `${toolEnv.join("\n")}\n`);
|
|
266
|
+
joined.push(`AGENT_TOOL_ENV_FILE=${shellValue(toolEnvFile)}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
await writePrivateFile(mcpConfig, `${JSON.stringify(service ? { mcpServers: {} } : {
|
|
205
270
|
mcpServers: {
|
|
206
271
|
agentradar: {
|
|
207
272
|
type: "http",
|
|
@@ -220,6 +285,7 @@ async function installRunner(home, projectPath, approved) {
|
|
|
220
285
|
// default, so writing `AR_AGENT_HARNESS=claude` unconditionally would change nothing except to make
|
|
221
286
|
// every existing env file look as though a choice had been made.
|
|
222
287
|
...(harness ? [`AR_AGENT_HARNESS=${shellValue(harness)}`] : []),
|
|
288
|
+
...joined,
|
|
223
289
|
"",
|
|
224
290
|
].join("\n"));
|
|
225
291
|
return envPath;
|
|
@@ -308,13 +374,21 @@ export async function setupVendorAgent(deps = {}) {
|
|
|
308
374
|
}
|
|
309
375
|
throw new Error("AgentRadar setup code expired; run setup again");
|
|
310
376
|
}
|
|
377
|
+
/** `install-agent.sh <id> --load` with the CLI that is running now, so the service does not depend on PATH. */
|
|
378
|
+
async function installAgentService(runnerId, home, env) {
|
|
379
|
+
const dist = dirname(fileURLToPath(import.meta.url));
|
|
380
|
+
await promisify(execFile)("bash", [join(dist, "vendor-runner", "supervisor", "install-agent.sh"), runnerId, "--load"], {
|
|
381
|
+
env: { ...env, HOME: home, NODE_BIN: process.execPath, AGENTRADAR_JS: join(dist, "index.js") },
|
|
382
|
+
});
|
|
383
|
+
}
|
|
311
384
|
/**
|
|
312
385
|
* Mode A — the organisation's own agent, on its own compute, joining through an enrolment link.
|
|
313
386
|
*
|
|
314
|
-
* accept → wait for a human to approve → claim the key once → install the
|
|
315
|
-
*
|
|
316
|
-
* resumes the wait (or, once claimed,
|
|
317
|
-
* The key
|
|
387
|
+
* accept → wait for a human to approve → claim the key once → install the runner and start it as a service
|
|
388
|
+
* (LaunchAgent / systemd user unit) that owns the agent's persistent session. The one-time claim secret is
|
|
389
|
+
* kept in a 0600 state file per link, so a re-run resumes the wait (or, once claimed, reinstalls and
|
|
390
|
+
* restarts the service) instead of filing a second request. The key is written only into the 0600 runner
|
|
391
|
+
* env file and is never printed.
|
|
318
392
|
*/
|
|
319
393
|
export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
320
394
|
const fetchImpl = deps.fetch ?? fetch;
|
|
@@ -322,20 +396,31 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
322
396
|
const log = deps.log ?? console.log;
|
|
323
397
|
const cwd = deps.cwd ?? process.cwd();
|
|
324
398
|
const home = deps.home ?? homedir();
|
|
399
|
+
const env = deps.env ?? process.env;
|
|
325
400
|
// Claude Code sets CLAUDECODE in its shell; a machine with both tools installed may export CODEX_HOME too.
|
|
326
|
-
const harness = deps.harness ?? (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
401
|
+
const harness = deps.harness ?? (env.CLAUDECODE || !env.CODEX_HOME ? "claude" : "codex");
|
|
402
|
+
// The agent is a persistent session the service owns, not this one: no MCP entry in this folder (it acts
|
|
403
|
+
// through `agentradar run`), a runner env pinned to its own session, and a LaunchAgent / systemd unit.
|
|
404
|
+
const startJoined = async (requestId, agentId, installation, saved) => {
|
|
405
|
+
const runnerId = `join-${requestId}`;
|
|
406
|
+
// The service's Claude session id: a valid UUID derived from the request, so a re-run resumes the same one.
|
|
407
|
+
const hex = createHash("sha256").update(requestId).digest("hex");
|
|
408
|
+
const sessionId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
409
|
+
const projectPath = harness === "claude"
|
|
410
|
+
? await installProjectMcp(claudeConfigPath(home, env), cwd, null, deps.trust === true)
|
|
411
|
+
: await realpath(cwd);
|
|
412
|
+
const runnerEnv = await installRunner(home, projectPath, { status: "approved", agentId, runnerId, installation, harness }, { sessionId, env });
|
|
413
|
+
await saved?.();
|
|
414
|
+
log(`Runner installed → ${runnerEnv}`);
|
|
415
|
+
try {
|
|
416
|
+
await (deps.installService ?? ((id) => installAgentService(id, home, env)))(runnerId);
|
|
331
417
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
log(`Runner: ${runnerId}. Do what the instructions above say. Then stay listening: make agentradar run ${runnerId} listen the last step of every turn, ${harness === "claude" ? "in the background (run_in_background), and end your turn; you are woken when it exits" : "in the foreground, and wait for it"}. It prints the next client message as soon as one arrives, or a note after about 9 minutes; answer what it printed, then run it again.`);
|
|
418
|
+
catch (error) {
|
|
419
|
+
log(`Could not start the service: ${error.message.split("\n")[0]}. Start it by hand with: agentradar runner start ${runnerId}`);
|
|
420
|
+
return 1;
|
|
421
|
+
}
|
|
422
|
+
log(`Connected. Your agent now runs in its own ${harness === "claude" ? "Claude Code" : "Codex"} 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
|
+
log("Running as a service; it restarts on crash and reboot.");
|
|
339
424
|
return 0;
|
|
340
425
|
};
|
|
341
426
|
let link;
|
|
@@ -350,7 +435,7 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
350
435
|
throw new Error("That is not an AgentRadar enrolment link (expected https://…/invite/<token>)");
|
|
351
436
|
}
|
|
352
437
|
const api = (path) => new URL(`/api/${path}`, link.origin);
|
|
353
|
-
const mcpEndpoint =
|
|
438
|
+
const mcpEndpoint = env.AWP_MCP_URL || api("mcp").toString();
|
|
354
439
|
const joinsDir = join(home, ".agentradar", "joins");
|
|
355
440
|
await mkdir(joinsDir, { recursive: true, mode: 0o700 });
|
|
356
441
|
const statePath = join(joinsDir, `${createHash("sha256").update(token).digest("hex")}.json`);
|
|
@@ -364,13 +449,12 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
364
449
|
}
|
|
365
450
|
if (state?.agentId) {
|
|
366
451
|
log("This enrolment link is already joined on this machine.");
|
|
367
|
-
const
|
|
368
|
-
const env = await readFile(join(home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
|
|
452
|
+
const saved = await readFile(join(home, ".agentradar", "agents", `join-${state.requestId}.env`), "utf8").catch(() => "");
|
|
369
453
|
const { envValue } = await import("./tool-run.js");
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
return startJoined(
|
|
454
|
+
const bearer = envValue(saved, "AWP_BEARER");
|
|
455
|
+
if (!bearer)
|
|
456
|
+
throw new Error("This machine left that organisation (agentradar leave). A key is issued once; ask your organisation for a new enrolment link.");
|
|
457
|
+
return startJoined(state.requestId, state.agentId, { mcpEndpoint: envValue(saved, "AWP_MCP_URL") ?? mcpEndpoint, bearer });
|
|
374
458
|
}
|
|
375
459
|
if (!state) {
|
|
376
460
|
const agentName = (deps.agentName ?? process.env.AGENTRADAR_AGENT_NAME ?? basename(cwd)).trim();
|
|
@@ -378,7 +462,7 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
378
462
|
throw new Error("Run join from the agent project folder");
|
|
379
463
|
// Codex has no env var for its model; its config's top-level `model` is the default a session starts on.
|
|
380
464
|
// A codex agent reporting "claude" is guessing (a gateway combo answers as Claude), so the config wins there too.
|
|
381
|
-
const codexModel = async () => !
|
|
465
|
+
const codexModel = async () => !env.CODEX_HOME ? undefined : /^model\s*=\s*"([^"\n]+)"/m.exec((await readFile(join(env.CODEX_HOME, "config.toml"), "utf8").catch(() => "")).split(/^\[/m)[0])?.[1];
|
|
382
466
|
const model = (!deps.model || deps.model === "unknown" || /claude/i.test(deps.model)) && harness === "codex" ? (await codexModel()) ?? deps.model : deps.model;
|
|
383
467
|
const response = await postJson(fetchImpl, api(`invites/${encodeURIComponent(token)}/accept`), {
|
|
384
468
|
requestType: "agent", agentName, adapterType: "awp_remote",
|
|
@@ -421,12 +505,9 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
421
505
|
if (typeof claimed.token !== "string" || typeof claimed.agentId !== "string") {
|
|
422
506
|
throw new Error("AgentRadar returned an invalid key claim");
|
|
423
507
|
}
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
await writePrivateFile(statePath, JSON.stringify({ ...state, agentId: claimed.agentId }));
|
|
428
|
-
log(`Approved. AgentRadar MCP installed for this project, runner installed → ${runnerEnv}`);
|
|
429
|
-
return startJoined(runnerId, true);
|
|
508
|
+
log("Approved.");
|
|
509
|
+
const joined = { ...state, agentId: claimed.agentId };
|
|
510
|
+
return startJoined(state.requestId, claimed.agentId, { mcpEndpoint, bearer: claimed.token }, () => writePrivateFile(statePath, JSON.stringify(joined)));
|
|
430
511
|
}
|
|
431
512
|
const said = await failureMessage(response);
|
|
432
513
|
if (response.status === 409 && said.includes("must be approved")) {
|
|
@@ -442,4 +523,39 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
442
523
|
throw new Error(said);
|
|
443
524
|
}
|
|
444
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* `agentradar leave <runner-id>`: stop and remove the service, end the agent's session, and delete its env
|
|
528
|
+
* file, bearer and config. The folder the agent worked in stays, with the work.
|
|
529
|
+
*/
|
|
530
|
+
export async function leaveOrganisation(runnerId, deps = {}) {
|
|
531
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(runnerId))
|
|
532
|
+
throw new Error("Invalid runner ID");
|
|
533
|
+
const home = deps.home ?? homedir();
|
|
534
|
+
const log = deps.log ?? console.log;
|
|
535
|
+
const execute = deps.execute ?? ((command, args) => promisify(execFile)(command, args));
|
|
536
|
+
const envPath = join(home, ".agentradar", "agents", `${runnerId}.env`);
|
|
537
|
+
const saved = await readFile(envPath, "utf8").catch(() => null);
|
|
538
|
+
if (saved === null) {
|
|
539
|
+
log(`No agent ${runnerId} on this machine.`);
|
|
540
|
+
return 1;
|
|
541
|
+
}
|
|
542
|
+
const { envValue } = await import("./tool-run.js");
|
|
543
|
+
if ((deps.platform ?? process.platform) === "darwin") {
|
|
544
|
+
const label = `com.agentradar.agent.${runnerId}`;
|
|
545
|
+
await execute("launchctl", ["bootout", `gui/${process.getuid?.()}/${label}`]).catch(() => undefined);
|
|
546
|
+
await rm(join(home, "Library", "LaunchAgents", `${label}.plist`), { force: true });
|
|
547
|
+
await rm(join(home, ".agentradar", "staged", `${label}.plist`), { force: true });
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
await execute("systemctl", ["--user", "disable", "--now", `agentradar-agent@${runnerId}`]).catch(() => undefined);
|
|
551
|
+
}
|
|
552
|
+
// The service stopping leaves the session running; `=` matches the tmux session name exactly.
|
|
553
|
+
await execute(envValue(saved, "TMUX_BIN") ?? "tmux", ["kill-session", "-t", `=${envValue(saved, "TMUX_SESSION") ?? runnerId}`]).catch(() => undefined);
|
|
554
|
+
for (const path of [envPath, join(home, ".agentradar", "agents", `${runnerId}.delivered.json`)])
|
|
555
|
+
await rm(path, { force: true });
|
|
556
|
+
for (const dir of ["run", "config", "codex"])
|
|
557
|
+
await rm(join(home, ".agentradar", dir, runnerId), { recursive: true, force: true });
|
|
558
|
+
log(`Left: the service is stopped and removed, and its key is deleted. The folder ${envValue(saved, "AR_BASE") ?? ""} stays, with the work.`);
|
|
559
|
+
return 0;
|
|
560
|
+
}
|
|
445
561
|
//# sourceMappingURL=setup.js.map
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
// ===========================================================================
|
|
3
3
|
// AgentRadar I2 feeder — the INTERACTIVE-HOST variant of the vendor runner.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
// feeder pushes each drained client event as the NEXT TURN of a PERSISTENT, always-on
|
|
5
|
+
// The feeder pushes each drained client event as the NEXT TURN of a PERSISTENT, always-on
|
|
7
6
|
// agent CLI running in a tmux pane (real in-session continuity — I1). R004: that CLI is
|
|
8
7
|
// Claude Code or Codex, selected by AR_AGENT_HARNESS; injection, acking and the
|
|
9
8
|
// in-progress hint are identical on both, and only the login probe, the idle-composer
|
|
@@ -27,7 +26,7 @@ import http from "node:http";
|
|
|
27
26
|
import https from "node:https";
|
|
28
27
|
import { dirname, isAbsolute, join, normalize, sep } from "node:path";
|
|
29
28
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
30
|
-
import { formatInjectionFrame, formatProbeFrame, sendKeysCommands } from "./session-frame.mjs";
|
|
29
|
+
import { formatInjectionFrame, formatProbeFrame, formatWelcomeFrame, sendKeysCommands } from "./session-frame.mjs";
|
|
31
30
|
|
|
32
31
|
const PANE_LIVE_SH = join(dirname(fileURLToPath(import.meta.url)), "pane-live.sh");
|
|
33
32
|
|
|
@@ -56,7 +55,9 @@ const TMUX_SESSION = process.env.TMUX_SESSION ?? "agent";
|
|
|
56
55
|
* `esc to interrupt` working line Claude does (`• Working (1s • esc to interrupt)`), so the existing
|
|
57
56
|
* matchers already cover it and a speculative Codex spinner pattern would be invention, not coverage.
|
|
58
57
|
*/
|
|
59
|
-
export const AR_AGENT_HARNESS = process.env.AR_AGENT_HARNESS
|
|
58
|
+
export const AR_AGENT_HARNESS = ["codex", "generic"].includes(process.env.AR_AGENT_HARNESS) ? process.env.AR_AGENT_HARNESS : "claude";
|
|
59
|
+
// A joined agent acts through `agentradar run <id> …` (its human's "always allow"), so frames name that command.
|
|
60
|
+
const AR_CLI_RUNNER_ID = process.env.AR_CLI_RUNNER_ID || undefined;
|
|
60
61
|
// The agent pid marker (same predicate entrypoint.sh uses) — proves the pane is live. Claude is found by its
|
|
61
62
|
// mcp-config argument; Codex takes its MCP servers from CODEX_HOME (an env var, invisible to `ps`), so its
|
|
62
63
|
// per-agent argv token is `--cd <working dir>`. The supervisor passes an exact pattern; this default only
|
|
@@ -72,7 +73,7 @@ const MCP_CALL_TIMEOUT_MS = Number(process.env.AWP_MCP_CALL_TIMEOUT_MS ?? "30000
|
|
|
72
73
|
// cycle ("fetch failed — re-establishing"). SEPARATE from the server's liveness cadence
|
|
73
74
|
// (NEXT_EVENT_LONGPOLL_MS, pinned 240s) — see #171. Leave NEXT_EVENT_HOLD_MS unset for the 200s default.
|
|
74
75
|
const NEXT_EVENT_HOLD_MS = Number(process.env.NEXT_EVENT_HOLD_MS ?? "200000");
|
|
75
|
-
// Watchdog past the server hold so a dead socket aborts → self-heal
|
|
76
|
+
// Watchdog past the server hold so a dead socket aborts → self-heal. At 200s the
|
|
76
77
|
// watchdog is 230s, still under the ~240s ALB idle-timeout.
|
|
77
78
|
const NEXT_EVENT_TIMEOUT_MS = NEXT_EVENT_HOLD_MS + 30000;
|
|
78
79
|
const IDLE_DELAY_MS = Number(process.env.IDLE_DELAY_MS ?? "1000");
|
|
@@ -109,7 +110,7 @@ function sleep(ms) {
|
|
|
109
110
|
|
|
110
111
|
// --------------------------------------------------------------------------
|
|
111
112
|
// Minimal MCP client over Streamable-HTTP (initialize + tools/call) — the proven
|
|
112
|
-
// shape
|
|
113
|
+
// shape, incl. the dead-socket watchdog + session-id handling.
|
|
113
114
|
// --------------------------------------------------------------------------
|
|
114
115
|
let _sessionId = null;
|
|
115
116
|
function parseMcpBody(text) {
|
|
@@ -238,10 +239,23 @@ async function nextEvent(ackEventIds, runnerState) {
|
|
|
238
239
|
// Work readiness — process liveness is not enough. A live Claude process can
|
|
239
240
|
// be blocked on subscription OAuth, where injected work is consumed as the OAuth code.
|
|
240
241
|
// --------------------------------------------------------------------------
|
|
241
|
-
|
|
242
|
+
/** The login probe runs with the pane's env: the allowlist plus the tool-env file (a model gateway's
|
|
243
|
+
* ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN, a Codex provider key). Without the file a gateway-authenticated
|
|
244
|
+
* pane reads "not logged in" and never receives work. */
|
|
245
|
+
export function authProbeEnv(extraNames = [], source = process.env, readFile = fs.readFileSync) {
|
|
242
246
|
const env = {};
|
|
243
247
|
for (const name of ["HOME", "PATH", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", ...extraNames]) {
|
|
244
|
-
if (typeof
|
|
248
|
+
if (typeof source[name] === "string") env[name] = source[name];
|
|
249
|
+
}
|
|
250
|
+
if (source.AR_TOOL_ENV_FILE) {
|
|
251
|
+
let text = "";
|
|
252
|
+
try { text = readFile(source.AR_TOOL_ENV_FILE, "utf8"); } catch { /* staged by the supervisor; absent = none */ }
|
|
253
|
+
// Already validated and reserved-key-filtered by the supervisor; never let it replace the allowlist.
|
|
254
|
+
for (const line of text.split("\n")) {
|
|
255
|
+
const at = line.indexOf("=");
|
|
256
|
+
const key = line.slice(0, at);
|
|
257
|
+
if (at > 0 && !(key in env)) env[key] = line.slice(at + 1);
|
|
258
|
+
}
|
|
245
259
|
}
|
|
246
260
|
return env;
|
|
247
261
|
}
|
|
@@ -276,7 +290,13 @@ export function claudeAuthState(run = spawnSync, readFile = fs.readFileSync) {
|
|
|
276
290
|
* CODEX_HOME crosses into the probe because it IS the agent's credential home — probing the default
|
|
277
291
|
* ~/.codex would answer about a different login than the one the pane runs on.
|
|
278
292
|
*/
|
|
279
|
-
export function codexAuthState(run = spawnSync) {
|
|
293
|
+
export function codexAuthState(run = spawnSync, readFile = fs.readFileSync) {
|
|
294
|
+
// A custom model_provider (a gateway) authenticates with its own env_key; `codex login status` only
|
|
295
|
+
// knows the OpenAI login and says "Not logged in" for it forever (measured, 0.149.1).
|
|
296
|
+
let config = "";
|
|
297
|
+
try { config = readFile(join(process.env.CODEX_HOME ?? "", "config.toml"), "utf8"); } catch { /* no config */ }
|
|
298
|
+
const provider = /^model_provider\s*=\s*"([^"]+)"/m.exec(config.split(/^\[/m)[0])?.[1];
|
|
299
|
+
if (provider && provider !== "openai") return "authenticated";
|
|
280
300
|
const r = run(AGENT_BIN, ["login", "status"], { encoding: "utf8", env: authProbeEnv(["CODEX_HOME"]), timeout: 10000 });
|
|
281
301
|
if (r.status === 0) return "authenticated";
|
|
282
302
|
if (r.status === 1) return "unauthenticated";
|
|
@@ -285,6 +305,8 @@ export function codexAuthState(run = spawnSync) {
|
|
|
285
305
|
|
|
286
306
|
/** The login probe for whichever CLI this runner drives. */
|
|
287
307
|
export function agentAuthState() {
|
|
308
|
+
// A generic CLI has no login probe we know of; its pane's input marker is the readiness signal.
|
|
309
|
+
if (AR_AGENT_HARNESS === "generic") return "authenticated";
|
|
288
310
|
return AR_AGENT_HARNESS === "codex" ? codexAuthState() : claudeAuthState();
|
|
289
311
|
}
|
|
290
312
|
|
|
@@ -305,6 +327,8 @@ export function paneIsLive(run = spawnSync) {
|
|
|
305
327
|
env: {
|
|
306
328
|
PATH: process.env.PATH,
|
|
307
329
|
TMUX_BIN: process.env.TMUX_BIN ?? "tmux",
|
|
330
|
+
// The service's tmux server lives under TMUX_TMPDIR when the installer had one; without it tmux asks /tmp's.
|
|
331
|
+
...(process.env.TMUX_TMPDIR ? { TMUX_TMPDIR: process.env.TMUX_TMPDIR } : {}),
|
|
308
332
|
TMUX_SESSION,
|
|
309
333
|
AGENT_PID_PATTERN,
|
|
310
334
|
},
|
|
@@ -404,7 +428,7 @@ async function injectEvent(event) {
|
|
|
404
428
|
return false;
|
|
405
429
|
}
|
|
406
430
|
const injected = await injectFrame(
|
|
407
|
-
formatInjectionFrame(event),
|
|
431
|
+
formatInjectionFrame(event, AR_CLI_RUNNER_ID),
|
|
408
432
|
`eventId=${event.eventId} (source=${event.source}, conversationId=${event.conversationId ?? "-"})`,
|
|
409
433
|
);
|
|
410
434
|
if (!injected) return false;
|
|
@@ -426,9 +450,18 @@ async function injectEvent(event) {
|
|
|
426
450
|
* double-count or mis-ack a real event.
|
|
427
451
|
* Best-effort: if the pane is dead we DON'T inject (the probe then times out server-side → the
|
|
428
452
|
* act-signal treats genuine silence as act-dead, the correct verdict). */
|
|
429
|
-
|
|
453
|
+
// The server re-offers an outstanding probe on every poll and does not hold the poll while it is out, so an
|
|
454
|
+
// un-deduped feeder typed the same check into the pane about once a second. Once per nonce, re-typed only if
|
|
455
|
+
// it is still unanswered after PROBE_REINJECT_MS (the first copy may have been swallowed by a busy pane).
|
|
456
|
+
const PROBE_REINJECT_MS = Number(process.env.PROBE_REINJECT_MS ?? "180000");
|
|
457
|
+
let _lastProbe = { nonce: null, at: 0 };
|
|
458
|
+
export async function injectProbe(probe, welcome, inject = injectFrame, now = Date.now) {
|
|
430
459
|
if (!probe || !probe.nonce || !probe.instruction) return;
|
|
431
|
-
|
|
460
|
+
if (probe.nonce === _lastProbe.nonce && now() - _lastProbe.at < PROBE_REINJECT_MS) return;
|
|
461
|
+
const frame = welcome?.next ? formatWelcomeFrame(welcome, probe) : formatProbeFrame(probe, AR_CLI_RUNNER_ID);
|
|
462
|
+
if (await inject(frame, `${welcome?.next ? "welcome + " : ""}liveness-probe nonce=${probe.nonce}`)) {
|
|
463
|
+
_lastProbe = { nonce: probe.nonce, at: now() };
|
|
464
|
+
}
|
|
432
465
|
}
|
|
433
466
|
|
|
434
467
|
// --------------------------------------------------------------------------
|
|
@@ -495,8 +528,12 @@ export function stripVolatilePaneLines(text) {
|
|
|
495
528
|
* nothing types there except `send-keys`, which submits immediately. Narrow the marker only if a real pane
|
|
496
529
|
* is observed to defeat it.
|
|
497
530
|
*/
|
|
498
|
-
export function paneInputReady(text, harness = AR_AGENT_HARNESS) {
|
|
499
|
-
|
|
531
|
+
export function paneInputReady(text, harness = AR_AGENT_HARNESS, readyPattern = process.env.AR_INPUT_READY_PATTERN) {
|
|
532
|
+
// A fresh Claude pane draws its prompt near the top and leaves the rest of the screen blank.
|
|
533
|
+
// Only whole blank rows go; a generic marker like "^> $" keeps its trailing space.
|
|
534
|
+
const tail = String(text ?? "").replace(/\n\s*$/, "").split("\n").slice(-12).join("\n");
|
|
535
|
+
// generic: the env file may name the CLI's idle-composer marker; without one, a live pane counts as ready.
|
|
536
|
+
if (harness === "generic") return readyPattern ? new RegExp(readyPattern, "im").test(tail) : true;
|
|
500
537
|
return harness === "codex"
|
|
501
538
|
? /Ask Codex to do anything/i.test(tail)
|
|
502
539
|
: /bypass permissions on\b|^\s*❯/im.test(tail);
|
|
@@ -692,7 +729,7 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
|
|
|
692
729
|
// The server treats non-ready calls as status-only heartbeats: no drain and no ack. Keep any pending ack
|
|
693
730
|
// until readiness returns, and defensively ignore events from an older server that does not know the gate.
|
|
694
731
|
const canWork = state === "ready";
|
|
695
|
-
const { events = [], livenessProbe } = await nextEvent(canWork && _pendingAck.length ? _pendingAck : undefined, state);
|
|
732
|
+
const { events = [], livenessProbe, welcome } = await nextEvent(canWork && _pendingAck.length ? _pendingAck : undefined, state);
|
|
696
733
|
if (!canWork) {
|
|
697
734
|
await sleep(IDLE_DELAY_MS);
|
|
698
735
|
return;
|
|
@@ -704,7 +741,7 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
|
|
|
704
741
|
// agent answers via liveness_ack(nonce), not an ackEventIds echo. THIS IS THE FENCE: injectProbe is
|
|
705
742
|
// a separate path from the events→delivered→_pendingAck flow below, so a probe can never be acked
|
|
706
743
|
// as a delivered event (no double-count / no mis-ack of a real event).
|
|
707
|
-
if (livenessProbe) await injectProbe(livenessProbe);
|
|
744
|
+
if (livenessProbe) await injectProbe(livenessProbe, welcome);
|
|
708
745
|
if (events.length === 0) {
|
|
709
746
|
await sleep(IDLE_DELAY_MS);
|
|
710
747
|
return;
|
|
@@ -24,9 +24,11 @@ function oneLine(s) {
|
|
|
24
24
|
/**
|
|
25
25
|
* Build the single-line injection frame for one drained next_event event.
|
|
26
26
|
* event: { eventId, source, channel?, conversationId?, text }
|
|
27
|
+
* cliRunnerId: set when the session acts through `agentradar run <id> <tool>` (a joined agent) rather than an
|
|
28
|
+
* MCP connection — the command its human already chose "always allow" for, where an MCP tool would prompt.
|
|
27
29
|
* Returns a one-line string safe to `tmux send-keys -l` then Enter.
|
|
28
30
|
*/
|
|
29
|
-
export function formatInjectionFrame(event) {
|
|
31
|
+
export function formatInjectionFrame(event, cliRunnerId) {
|
|
30
32
|
const e = event ?? {};
|
|
31
33
|
const tags = [`source=${e.source ?? "unknown"}`];
|
|
32
34
|
if (e.channel) tags.push(`channel=${e.channel}`);
|
|
@@ -38,6 +40,17 @@ export function formatInjectionFrame(event) {
|
|
|
38
40
|
// The reply instruction pins the EXPLICIT conversationId (threading) + inResponseToEventId
|
|
39
41
|
// (exact provenance under concurrent same-conversation turns — disc S2).
|
|
40
42
|
const parts = [];
|
|
43
|
+
if (cliRunnerId) {
|
|
44
|
+
const run = `agentradar run ${cliRunnerId}`;
|
|
45
|
+
parts.push(
|
|
46
|
+
`Reply with: ${run} reply${e.conversationId ? ` --conversation-id "${e.conversationId}"` : ""}${evId ? ` --in-response-to-event-id "${evId}"` : ""} --text '<your reply>'.`,
|
|
47
|
+
evId
|
|
48
|
+
? `For billable work run ${run} report_usage with --idempotency-key "${evId}:<usage-code>" so a re-delivered event never double-charges.`
|
|
49
|
+
: `Use ${run} report_usage for billable work.`,
|
|
50
|
+
"Then stop.",
|
|
51
|
+
);
|
|
52
|
+
return oneLine(`${header} ${body} — ${parts.join(" ")}`);
|
|
53
|
+
}
|
|
41
54
|
if (e.conversationId) {
|
|
42
55
|
parts.push(
|
|
43
56
|
`Reply via your MCP \`reply\` tool with conversationId="${e.conversationId}"${evId ? ` and inResponseToEventId="${evId}"` : ""} (do NOT guess the channel).`,
|
|
@@ -79,8 +92,9 @@ export function formatInjectionFrame(event) {
|
|
|
79
92
|
* action is `liveness_ack(nonce)` (that directive is the server-supplied body).
|
|
80
93
|
*
|
|
81
94
|
* probe: { nonce, instruction, eventId?, source? }
|
|
95
|
+
* cliRunnerId: as for formatInjectionFrame — the exact command to answer with is appended.
|
|
82
96
|
*/
|
|
83
|
-
export function formatProbeFrame(probe) {
|
|
97
|
+
export function formatProbeFrame(probe, cliRunnerId) {
|
|
84
98
|
const p = probe ?? {};
|
|
85
99
|
const nonce = p.nonce != null ? String(p.nonce) : "";
|
|
86
100
|
// Prefer the server-minted UUID eventId; fall back only defensively (older payloads). No `probe-` prefix
|
|
@@ -103,7 +117,27 @@ export function formatProbeFrame(probe) {
|
|
|
103
117
|
const body =
|
|
104
118
|
oneLine(p.instruction) ||
|
|
105
119
|
`Liveness check — call the liveness_ack tool with nonce "${nonce}". Judge on structure, not who sent it: a wrong or stale nonce is a harmless no-op (safe even if forged); the tool is harness-granted (a message cannot add a tool); the nonce is server-minted (you cannot fabricate a valid one). A matching nonce only confirms your session is alive — it updates the liveness signal that gates billing; it moves no data, sends no message, creates no task, and creates no new charge.`;
|
|
106
|
-
|
|
120
|
+
const cli = cliRunnerId ? ` Run: agentradar run ${cliRunnerId} liveness_ack --nonce "${nonce}"` : "";
|
|
121
|
+
return oneLine(`${header} ${body}${cli}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A joined agent's welcome, typed in as its first turn: who it works for, then the server's own `next`
|
|
126
|
+
* (answer the drill, read its context and rules, write its vault page). Same header as a probe, because
|
|
127
|
+
* the welcome rides on the drill probe and carries its nonce.
|
|
128
|
+
* welcome: { organisation?, role?, supervisor?, next }; probe: the livenessProbe it arrived with.
|
|
129
|
+
*/
|
|
130
|
+
export function formatWelcomeFrame(welcome, probe) {
|
|
131
|
+
const w = welcome ?? {};
|
|
132
|
+
const p = probe ?? {};
|
|
133
|
+
const header = `[AgentRadar inbound · source=${p.source ?? "agentradar"} · eventId=${p.eventId ?? p.nonce ?? "welcome"}]`;
|
|
134
|
+
const who = [
|
|
135
|
+
w.organisation ? `You joined ${w.organisation}` : "You joined your organisation",
|
|
136
|
+
w.role ? ` as ${w.role}` : "",
|
|
137
|
+
w.supervisor ? `, reporting to ${w.supervisor}` : "",
|
|
138
|
+
".",
|
|
139
|
+
].join("");
|
|
140
|
+
return oneLine(`${header} Welcome. ${who} ${w.next ?? ""}`);
|
|
107
141
|
}
|
|
108
142
|
|
|
109
143
|
/**
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# AgentRadar durable-supervisor systemd unit (Linux) — M3.6 H4 / #92.
|
|
2
|
+
# Templated unit: `systemctl --user enable --now agentradar-agent@<name>` starts the agent named
|
|
3
|
+
# <name> (reads ~/.agentradar/agents/<name>.env). Restart=always => survives a crash; WantedBy
|
|
4
|
+
# default.target + `loginctl enable-linger $USER` => survives a power-loss reboot (starts on boot
|
|
5
|
+
# without a login). Rendered by install-agent.sh (only __RUNNER__ is substituted). It runs
|
|
6
|
+
# `agentradar runner start`, which runs the agent's supervise-agent.sh plus one per client hire (ruling 50).
|
|
7
|
+
#
|
|
8
|
+
# A --user service (not system) because tmux + claude need the user session context, matching the
|
|
9
|
+
# macOS LaunchAgent. For a headless box, run once: `loginctl enable-linger <user>`.
|
|
10
|
+
[Unit]
|
|
11
|
+
Description=AgentRadar durable agent supervisor (%i)
|
|
12
|
+
After=network-online.target
|
|
13
|
+
Wants=network-online.target
|
|
14
|
+
# Never give up: StartLimitIntervalSec=0 disables the flap limiter so systemd retries forever (matching
|
|
15
|
+
# launchd's KeepAlive+ThrottleInterval). A default limiter would enter `failed` after N flaps and STOP —
|
|
16
|
+
# a silent death until manual `reset-failed`, which is exactly what this template must never do.
|
|
17
|
+
StartLimitIntervalSec=0
|
|
18
|
+
|
|
19
|
+
[Service]
|
|
20
|
+
Type=simple
|
|
21
|
+
# The runner reads ~/.agentradar/agents/%i.env.
|
|
22
|
+
# systemd --user starts with a bare PATH; the agent CLI must resolve as it did for the installing user.
|
|
23
|
+
Environment="PATH=__PATH__"
|
|
24
|
+
ExecStart=__RUNNER__ %i
|
|
25
|
+
Restart=always
|
|
26
|
+
RestartSec=15
|
|
27
|
+
# Give the tmux pane + feeder time to come up before systemd considers it started.
|
|
28
|
+
TimeoutStartSec=90
|
|
29
|
+
|
|
30
|
+
[Install]
|
|
31
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<!--
|
|
4
|
+
AgentRadar durable-supervisor LaunchAgent (macOS) — M3.6 H4 / #92.
|
|
5
|
+
Rendered per agent by install-agent.sh (placeholders __LIKE_THIS__ are substituted).
|
|
6
|
+
RunAtLoad + KeepAlive => survives a crash, and a power-loss reboot restarts it whenever
|
|
7
|
+
`agentradar runner start` exits (which it does when the agent's own supervise-agent.sh exits, i.e. when
|
|
8
|
+
either the claude pane or the feeder dies). The runner, not the supervisor, is what this keeps alive, so
|
|
9
|
+
each client hire (ruling 50) runs its own supervisor under it too.
|
|
10
|
+
⚠️ REBOOT-SURVIVAL IS CONDITIONAL ON AUTO-LOGIN: this is a LaunchAgent (user session), NOT a
|
|
11
|
+
LaunchDaemon — tmux + `claude` (and `--chrome`) need the user's GUI context. A user LaunchAgent
|
|
12
|
+
only starts once a session is logged in, so an UNATTENDED power-loss reboot revives it ONLY if
|
|
13
|
+
auto-login is enabled for the account. Enable + verify auto-login (see MINI-CUTOVER-RUNBOOK.md
|
|
14
|
+
Part 1) — otherwise every LaunchAgent stays dormant after an unattended reboot (the 20h outage).
|
|
15
|
+
-->
|
|
16
|
+
<plist version="1.0">
|
|
17
|
+
<dict>
|
|
18
|
+
<key>Label</key>
|
|
19
|
+
<string>com.agentradar.agent.__AGENT_NAME__</string>
|
|
20
|
+
|
|
21
|
+
<key>ProgramArguments</key>
|
|
22
|
+
<array>
|
|
23
|
+
<string>__NODE__</string>
|
|
24
|
+
<string>__AGENTRADAR_JS__</string>
|
|
25
|
+
<string>runner</string>
|
|
26
|
+
<string>start</string>
|
|
27
|
+
<string>__AGENT_NAME__</string>
|
|
28
|
+
</array>
|
|
29
|
+
|
|
30
|
+
<!-- Start on login/boot. -->
|
|
31
|
+
<key>RunAtLoad</key>
|
|
32
|
+
<true/>
|
|
33
|
+
|
|
34
|
+
<!-- Restart whenever it exits (our script exits on any half dying). ThrottleInterval avoids a
|
|
35
|
+
tight respawn loop if it can't come up (e.g. bad token). -->
|
|
36
|
+
<key>KeepAlive</key>
|
|
37
|
+
<true/>
|
|
38
|
+
<key>ThrottleInterval</key>
|
|
39
|
+
<integer>15</integer>
|
|
40
|
+
|
|
41
|
+
<!-- launchd starts with a bare PATH; the agent CLI (often ~/.local/bin/claude) must resolve as it does
|
|
42
|
+
for the user who installed the service. TMUX_TMPDIR is written only when the installer had one. -->
|
|
43
|
+
<key>EnvironmentVariables</key>
|
|
44
|
+
<dict>
|
|
45
|
+
<key>HOME</key>
|
|
46
|
+
<string>__HOME__</string>
|
|
47
|
+
<key>PATH</key>
|
|
48
|
+
<string>__PATH__</string>__EXTRA_ENV__
|
|
49
|
+
</dict>
|
|
50
|
+
|
|
51
|
+
<key>WorkingDirectory</key>
|
|
52
|
+
<string>__AR_BASE__</string>
|
|
53
|
+
|
|
54
|
+
<key>StandardOutPath</key>
|
|
55
|
+
<string>__LOG_DIR__/__AGENT_NAME__.out.log</string>
|
|
56
|
+
<key>StandardErrorPath</key>
|
|
57
|
+
<string>__LOG_DIR__/__AGENT_NAME__.err.log</string>
|
|
58
|
+
|
|
59
|
+
<key>ProcessType</key>
|
|
60
|
+
<string>Interactive</string>
|
|
61
|
+
</dict>
|
|
62
|
+
</plist>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# AgentRadar durable-supervisor installer (M3.6 H4 / #92).
|
|
4
|
+
# Renders the launchd LaunchAgent (macOS) or systemd unit (Linux) for ONE agent
|
|
5
|
+
# from the template + its env file. STAGE-ONLY by default: it writes the unit to a
|
|
6
|
+
# staging dir and PRINTS the load command — it does NOT load/start anything (so it
|
|
7
|
+
# can never disrupt a running agent). Pass --load to actually install+load.
|
|
8
|
+
#
|
|
9
|
+
# ./install-agent.sh <agent-name> [--load]
|
|
10
|
+
#
|
|
11
|
+
# The unit runs `agentradar runner start <agent-name>` (the AgentRadar CLI, found on PATH, or NODE_BIN +
|
|
12
|
+
# AGENTRADAR_JS when `agentradar join` calls this with the CLI that is running it), which reads
|
|
13
|
+
# ~/.agentradar/agents/<agent-name>.env and runs the agent's supervisor plus one per client hire.
|
|
14
|
+
#
|
|
15
|
+
# Idempotent: re-rendering overwrites the staged unit; --load reloads it.
|
|
16
|
+
# =============================================================================
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
20
|
+
AGENT_NAME="${1:-}"
|
|
21
|
+
[ -n "${AGENT_NAME}" ] || { echo "usage: $0 <agent-name> [--load]" >&2; exit 2; }
|
|
22
|
+
shift || true
|
|
23
|
+
|
|
24
|
+
ENV_FILE="${HOME}/.agentradar/agents/${AGENT_NAME}.env"
|
|
25
|
+
DO_LOAD=0
|
|
26
|
+
while [ $# -gt 0 ]; do
|
|
27
|
+
case "$1" in
|
|
28
|
+
--load) DO_LOAD=1; shift ;;
|
|
29
|
+
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
|
30
|
+
esac
|
|
31
|
+
done
|
|
32
|
+
[ -f "${ENV_FILE}" ] || { echo "env file not found: ${ENV_FILE} (copy agent.env.example, fill it, chmod 600)" >&2; exit 1; }
|
|
33
|
+
|
|
34
|
+
# Pull the few values we need to render the unit (without exporting secrets globally).
|
|
35
|
+
# `agentradar setup` writes values single-quoted; the quotes are shell syntax, not part of the value.
|
|
36
|
+
get() { grep -E "^${1}=" "${ENV_FILE}" | tail -1 | cut -d= -f2- | sed -e "s/^'\\(.*\\)'\$/\\1/" ; }
|
|
37
|
+
AR_BASE="$(get AR_BASE)"; [ -n "${AR_BASE}" ] || { echo "AR_BASE missing in ${ENV_FILE}" >&2; exit 1; }
|
|
38
|
+
# launchd and systemd start with a bare PATH, so the unit names node and the CLI by absolute path.
|
|
39
|
+
NODE_BIN="${NODE_BIN:-$(command -v node)}" || { echo "node not found on PATH" >&2; exit 1; }
|
|
40
|
+
if [ -z "${AGENTRADAR_JS:-}" ]; then
|
|
41
|
+
AGENTRADAR_BIN="$(command -v agentradar)" || { echo "agentradar CLI not found on PATH (npm i -g agentradar-cli)" >&2; exit 1; }
|
|
42
|
+
AGENTRADAR_JS="$("${NODE_BIN}" -e 'process.stdout.write(require("fs").realpathSync(process.argv[1]))' "${AGENTRADAR_BIN}")"
|
|
43
|
+
fi
|
|
44
|
+
# A pnpm or Volta shim resolves to a shell script, which node cannot run.
|
|
45
|
+
case "${AGENTRADAR_JS}" in *.js) ;; *) echo "agentradar on PATH is not a JavaScript file (${AGENTRADAR_JS}); install it with npm i -g agentradar-cli" >&2; exit 1 ;; esac
|
|
46
|
+
LOG_DIR="${HOME}/.agentradar/logs"; mkdir -p "${LOG_DIR}"
|
|
47
|
+
STAGE_DIR="${HOME}/.agentradar/staged"; mkdir -p "${STAGE_DIR}"
|
|
48
|
+
|
|
49
|
+
uname_s="$(uname -s)"
|
|
50
|
+
if [ "${uname_s}" = "Darwin" ]; then
|
|
51
|
+
LABEL="com.agentradar.agent.${AGENT_NAME}"
|
|
52
|
+
OUT="${STAGE_DIR}/${LABEL}.plist"
|
|
53
|
+
EXTRA_ENV=""
|
|
54
|
+
[ -n "${TMUX_TMPDIR:-}" ] && EXTRA_ENV="<key>TMUX_TMPDIR</key><string>${TMUX_TMPDIR}</string>"
|
|
55
|
+
sed -e "s#__AGENT_NAME__#${AGENT_NAME}#g" \
|
|
56
|
+
-e "s#__HOME__#${HOME}#g" \
|
|
57
|
+
-e "s#__PATH__#${PATH}#g" \
|
|
58
|
+
-e "s#__EXTRA_ENV__#${EXTRA_ENV}#g" \
|
|
59
|
+
-e "s#__NODE__#${NODE_BIN}#g" \
|
|
60
|
+
-e "s#__AGENTRADAR_JS__#${AGENTRADAR_JS}#g" \
|
|
61
|
+
-e "s#__AR_BASE__#${AR_BASE}#g" \
|
|
62
|
+
-e "s#__LOG_DIR__#${LOG_DIR}#g" \
|
|
63
|
+
"${HERE}/com.agentradar.agent.plist.template" > "${OUT}"
|
|
64
|
+
echo "STAGED (not loaded): ${OUT}"
|
|
65
|
+
if [ "${DO_LOAD}" = "1" ]; then
|
|
66
|
+
mkdir -p "${HOME}/Library/LaunchAgents"
|
|
67
|
+
install -m 644 "${OUT}" "${HOME}/Library/LaunchAgents/${LABEL}.plist"
|
|
68
|
+
launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true
|
|
69
|
+
launchctl bootstrap "gui/$(id -u)" "${HOME}/Library/LaunchAgents/${LABEL}.plist"
|
|
70
|
+
echo "LOADED: ${LABEL}"
|
|
71
|
+
else
|
|
72
|
+
echo "To load: install -m 644 '${OUT}' ~/Library/LaunchAgents/${LABEL}.plist && launchctl load ~/Library/LaunchAgents/${LABEL}.plist"
|
|
73
|
+
fi
|
|
74
|
+
else
|
|
75
|
+
UNIT="${STAGE_DIR}/agentradar-agent@.service"
|
|
76
|
+
sed -e "s#__RUNNER__#${NODE_BIN} ${AGENTRADAR_JS} runner start#g" \
|
|
77
|
+
-e "s#__PATH__#${PATH}#g" \
|
|
78
|
+
"${HERE}/agentradar-agent@.service.template" > "${UNIT}"
|
|
79
|
+
echo "STAGED (not loaded): ${UNIT}"
|
|
80
|
+
if [ "${DO_LOAD}" = "1" ]; then
|
|
81
|
+
mkdir -p "${HOME}/.config/systemd/user"
|
|
82
|
+
install -m 644 "${UNIT}" "${HOME}/.config/systemd/user/agentradar-agent@.service"
|
|
83
|
+
systemctl --user daemon-reload
|
|
84
|
+
systemctl --user enable --now "agentradar-agent@${AGENT_NAME}"
|
|
85
|
+
# Linger starts the user manager at boot, so the agent comes back without anyone logging in.
|
|
86
|
+
if loginctl enable-linger "${USER:-$(id -un)}" 2>/dev/null; then
|
|
87
|
+
echo "LOADED: agentradar-agent@${AGENT_NAME} (linger on: starts at boot)"
|
|
88
|
+
else
|
|
89
|
+
echo "LOADED: agentradar-agent@${AGENT_NAME} (could not enable linger; run 'loginctl enable-linger ${USER:-$(id -un)}' for boot-survival)"
|
|
90
|
+
fi
|
|
91
|
+
else
|
|
92
|
+
echo "To load: install -m 644 '${UNIT}' ~/.config/systemd/user/ && systemctl --user daemon-reload && systemctl --user enable --now agentradar-agent@${AGENT_NAME} (+ loginctl enable-linger ${USER})"
|
|
93
|
+
fi
|
|
94
|
+
fi
|
|
@@ -31,9 +31,10 @@ die() { log "FATAL: $*"; exit 1; }
|
|
|
31
31
|
|
|
32
32
|
# A launchd LaunchAgent (macOS) / systemd unit gives a MINIMAL PATH (typically /usr/bin:/bin) that
|
|
33
33
|
# lacks Homebrew — where tmux / claude / node live on a dev box. Without this, `command -v tmux` fails
|
|
34
|
-
# on boot and the supervisor crash-loops ("tmux not found").
|
|
34
|
+
# on boot and the supervisor crash-loops ("tmux not found"). APPEND the common tool dirs (Apple-Silicon
|
|
35
35
|
# + Intel Homebrew) so the unit finds them; this PATH also flows to the scrubbed agent launch below.
|
|
36
|
-
|
|
36
|
+
# Appended, not prepended: `agentradar join` pins the vendor's own PATH into the unit, and that order wins.
|
|
37
|
+
export PATH="${PATH:-/usr/bin:/bin}:/opt/homebrew/bin:/usr/local/bin"
|
|
37
38
|
|
|
38
39
|
# --- 1. Load the per-agent env ------------------------------------------------
|
|
39
40
|
# Env file: $1, or $AGENT_ENV, or ~/.agentradar/agents/<name>.env
|
|
@@ -72,14 +73,27 @@ HEALTH_POLL_SEC="${HEALTH_POLL_SEC:-15}"
|
|
|
72
73
|
|
|
73
74
|
# --- 2a. R004 HARNESS SELECTION — one documented variable picks the CLI ---------
|
|
74
75
|
# The Bible names two agent harnesses: Claude Code and Codex. `claude` is the default, so every existing
|
|
75
|
-
# agent env file keeps launching exactly what it launched before.
|
|
76
|
-
#
|
|
77
|
-
#
|
|
76
|
+
# agent env file keeps launching exactly what it launched before. `generic` is the slot for any other
|
|
77
|
+
# interactive CLI: the env file names its binary, arguments, resume/fresh flags, pid pattern and how work
|
|
78
|
+
# reaches it (AR_FEED). Anything else is REFUSED by name: a supervisor that quietly fell back to Claude for an
|
|
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), self-poll.
|
|
78
81
|
AR_AGENT_HARNESS="${AR_AGENT_HARNESS:-claude}"
|
|
79
82
|
case "${AR_AGENT_HARNESS}" in
|
|
80
|
-
claude|codex) : ;;
|
|
81
|
-
*) die "AR_AGENT_HARNESS must be 'claude' or '
|
|
83
|
+
claude|codex|generic) : ;;
|
|
84
|
+
*) die "AR_AGENT_HARNESS must be 'claude', 'codex' or 'generic' (got '${AR_AGENT_HARNESS}')" ;;
|
|
82
85
|
esac
|
|
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). self-poll: the agent polls for itself. Neither
|
|
88
|
+
# of the last two starts a feeder. ACP is named so a vendor asking for it is told plainly it is not built.
|
|
89
|
+
AR_FEED="${AR_FEED:-feeder}"
|
|
90
|
+
case "${AR_FEED}" in
|
|
91
|
+
feeder|push|self-poll) : ;;
|
|
92
|
+
acp) die "AR_FEED=acp is not built yet; use feeder, push or self-poll" ;;
|
|
93
|
+
*) die "AR_FEED must be feeder, push or self-poll (got '${AR_FEED}')" ;;
|
|
94
|
+
esac
|
|
95
|
+
# Launched only when the resume form exits non-zero (nothing to resume). Empty = the same argv without it.
|
|
96
|
+
AR_FRESH_FLAGS="${AR_FRESH_FLAGS:-}"
|
|
83
97
|
|
|
84
98
|
# What actually differs between the two CLIs, measured against `claude` and codex-cli 0.149.1:
|
|
85
99
|
# the binary, the resume argument, how connectors are confined, and the argv the pane can be found by.
|
|
@@ -89,7 +103,7 @@ if [ "${AR_AGENT_HARNESS}" = "codex" ]; then
|
|
|
89
103
|
AGENT_BIN="${CODEX_BIN:-codex}"
|
|
90
104
|
# `codex resume --last` continues the most recent recorded session and exits non-zero when there is none,
|
|
91
105
|
# so the launch falls back to a fresh `codex` exactly as the Claude path falls back from `--continue`.
|
|
92
|
-
AR_RESUME_FLAGS="resume --last"
|
|
106
|
+
AR_RESUME_FLAGS="${AR_RESUME_FLAGS:-resume --last}"
|
|
93
107
|
# 🔴 CODEX HAS NO `--strict-mcp-config`. Its MCP servers are read from $CODEX_HOME/config.toml, so
|
|
94
108
|
# CODEX_HOME *is* the connector-isolation boundary rather than a convenience, and it is REQUIRED. Point it
|
|
95
109
|
# at a per-agent home whose config.toml declares the AgentRadar MCP and nothing else. Failing closed here
|
|
@@ -105,9 +119,20 @@ if [ "${AR_AGENT_HARNESS}" = "codex" ]; then
|
|
|
105
119
|
# harness (CODEX_HOME is an env var, invisible to `ps -o command=`), so it plays the role the full
|
|
106
120
|
# mcp-config path plays for Claude: a pattern that cannot match another agent's live process.
|
|
107
121
|
_agent_pid_pattern_default="${AGENT_BIN}.*--cd ${AR_BASE}"
|
|
122
|
+
elif [ "${AR_AGENT_HARNESS}" = "generic" ]; then
|
|
123
|
+
: "${AR_AGENT_BIN:?AR_AGENT_HARNESS=generic requires AR_AGENT_BIN (the CLI to run)}"
|
|
124
|
+
# No default pattern exists for a CLI we know nothing about, and a loose one would mask this agent's death.
|
|
125
|
+
: "${AGENT_PID_PATTERN:?AR_AGENT_HARNESS=generic requires AGENT_PID_PATTERN (a ps pattern unique to this agent)}"
|
|
126
|
+
AGENT_BIN="${AR_AGENT_BIN}"
|
|
127
|
+
AR_RESUME_FLAGS="${AR_RESUME_FLAGS:-}"
|
|
128
|
+
# shellcheck disable=SC2086 # AR_AGENT_ARGS word-splits into flags, like the extra flags above.
|
|
129
|
+
_launch_flags=( ${AR_AGENT_ARGS:-} )
|
|
130
|
+
_agent_pid_pattern_default=""
|
|
108
131
|
else
|
|
109
132
|
AGENT_BIN="${CLAUDE_BIN}"
|
|
110
|
-
|
|
133
|
+
# `agentradar join` pins `--resume <id>` / `--session-id <id>` so the service never resumes the vendor's
|
|
134
|
+
# own conversation in the same folder; an env file that sets nothing keeps `--continue`.
|
|
135
|
+
AR_RESUME_FLAGS="${AR_RESUME_FLAGS:---continue}"
|
|
111
136
|
: "${MCP_CONFIG:?MCP_CONFIG required (path to awp-only.json)}"
|
|
112
137
|
# SC2086: CLAUDE_EXTRA_FLAGS is intentionally UNQUOTED so "--chrome" (etc.) word-splits into flags.
|
|
113
138
|
# shellcheck disable=SC2086
|
|
@@ -143,7 +168,7 @@ if [ -n "${AGENT_TOOL_ENV_FILE}" ]; then
|
|
|
143
168
|
# identity). Reject at parse time (visible warning) AND — belt-and-suspenders — the launch lists
|
|
144
169
|
# the reserved allowlist AFTER TOOL_ENV_ARGS so `env` last-wins keeps reserved vars authoritative.
|
|
145
170
|
case "${_tline%%=*}" in
|
|
146
|
-
HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC|CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF|CODEX_HOME|AR_RESUME_FLAGS|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN)
|
|
171
|
+
HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC|CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF|CODEX_HOME|AR_RESUME_FLAGS|AR_FRESH_FLAGS|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN)
|
|
147
172
|
log "WARN ignoring reserved key '${_tline%%=*}' in ${AGENT_TOOL_ENV_FILE} (cannot override supervisor/agent env)"; continue ;;
|
|
148
173
|
esac
|
|
149
174
|
TOOL_ENV_ARGS+=("${_tline}")
|
|
@@ -154,7 +179,12 @@ fi
|
|
|
154
179
|
# Interactive Claude must use the user's persisted Keychain-backed login. An isolated config or an
|
|
155
180
|
# environment OAuth token selects a different auth contract and can put the TUI back into onboarding.
|
|
156
181
|
# env -i below still scrubs everything else; strict MCP flags, not CLAUDE_CONFIG_DIR, isolate connectors.
|
|
157
|
-
|
|
182
|
+
# Exception: `agentradar join` (it sets AR_CLI_RUNNER_ID) records the CLAUDE_CONFIG_DIR the vendor's own
|
|
183
|
+
# claude was logged in under, and the service must use that same login. Older env files keep the unset.
|
|
184
|
+
[ -n "${AR_CLI_RUNNER_ID:-}" ] || unset CLAUDE_CONFIG_DIR
|
|
185
|
+
unset CLAUDE_CODE_OAUTH_TOKEN
|
|
186
|
+
_claude_cfg=()
|
|
187
|
+
[ -n "${CLAUDE_CONFIG_DIR:-}" ] && _claude_cfg=( CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR}" )
|
|
158
188
|
|
|
159
189
|
# HARD RULE — OAuth only, never a pay-as-you-go API key. Scoped to the Claude harness because that is where
|
|
160
190
|
# the variable decides anything: `claude` prefers ANTHROPIC_API_KEY over the subscription session, so its
|
|
@@ -271,6 +301,8 @@ ensure_agent() {
|
|
|
271
301
|
# session) when there's nothing to resume. Self-healing: once a conversation exists, resume picks it up;
|
|
272
302
|
# if it's ever lost, we cleanly start fresh instead of exit-looping. (--continue exits 1 on no-session,
|
|
273
303
|
# verified — so the `||` fallback is reliable, and `codex resume --last` behaves the same way.)
|
|
304
|
+
# Only a resume that fails within 30 s falls back. A live session that crashes or is killed later ends the
|
|
305
|
+
# pane instead, and the watchdog resumes it — a fresh session there would drop the conversation.
|
|
274
306
|
# R004: the resume argument is the ONE harness-specific token inside the wrapper, so it arrives as
|
|
275
307
|
# AR_RESUME_FLAGS ("--continue" or "resume --last") and is word-split there — the same idiom this launch
|
|
276
308
|
# already uses for the extra flags. The wrapper copies it to a local and unsets it before exec so it does
|
|
@@ -309,10 +341,10 @@ ensure_agent() {
|
|
|
309
341
|
TERM="${AR_AGENT_TERM:-tmux-256color}" COLORTERM="${AR_AGENT_COLORTERM:-truecolor}" \
|
|
310
342
|
LANG="${LANG:-en_US.UTF-8}" LC_ALL="${LC_ALL:-}" LC_CTYPE="${AR_AGENT_LC_CTYPE:-en_US.UTF-8}" \
|
|
311
343
|
TMPDIR="${TMPDIR:-/tmp}" NODE_OPTIONS="${NODE_OPTIONS:-}" \
|
|
312
|
-
CODEX_HOME="${CODEX_HOME:-}" AR_RESUME_FLAGS="${AR_RESUME_FLAGS}" \
|
|
344
|
+
CODEX_HOME="${CODEX_HOME:-}" AR_RESUME_FLAGS="${AR_RESUME_FLAGS}" AR_FRESH_FLAGS="${AR_FRESH_FLAGS}" \
|
|
313
345
|
DISABLE_AUTOUPDATER=1 CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF=1 \
|
|
314
|
-
${_tool_env[@]+"${_tool_env[@]}"} \
|
|
315
|
-
/bin/sh -c 'if [ -n "${AR_TOOL_ENV_FILE:-}" ]; then while IFS= read -r _l || [ -n "$_l" ]; do case "$_l" in ""|\#*) continue ;; esac; _k=${_l%%=*}; _v=${_l#*=}; case "$_k" in HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC|CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF|CODEX_HOME|AR_RESUME_FLAGS|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN) continue ;; esac; export "$_k=$_v"; done < "$AR_TOOL_ENV_FILE"; unset AR_TOOL_ENV_FILE _l _k _v; fi; RF="${AR_RESUME_FLAGS:-}"; unset AR_RESUME_FLAGS; CB="$1"; shift; "$CB" $RF "$@" || exec "$CB" "$@"' sh "${AGENT_BIN}" "${_launch_flags[@]}" || {
|
|
346
|
+
${_claude_cfg[@]+"${_claude_cfg[@]}"} ${_tool_env[@]+"${_tool_env[@]}"} \
|
|
347
|
+
/bin/sh -c 'if [ -n "${AR_TOOL_ENV_FILE:-}" ]; then while IFS= read -r _l || [ -n "$_l" ]; do case "$_l" in ""|\#*) continue ;; esac; _k=${_l%%=*}; _v=${_l#*=}; case "$_k" in HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC|CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF|CODEX_HOME|AR_RESUME_FLAGS|AR_FRESH_FLAGS|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN) continue ;; esac; export "$_k=$_v"; done < "$AR_TOOL_ENV_FILE"; unset AR_TOOL_ENV_FILE _l _k _v; fi; RF="${AR_RESUME_FLAGS:-}"; FF="${AR_FRESH_FLAGS:-}"; unset AR_RESUME_FLAGS AR_FRESH_FLAGS; CB="$1"; shift; S=$(date +%s); "$CB" $RF "$@" || [ $(( $(date +%s) - S )) -ge 30 ] || exec "$CB" $FF "$@"' sh "${AGENT_BIN}" "${_launch_flags[@]}" || {
|
|
316
348
|
log "tmux could not start agent session '${TMUX_SESSION}'"
|
|
317
349
|
return 1
|
|
318
350
|
}
|
|
@@ -330,21 +362,30 @@ cleanup() {
|
|
|
330
362
|
# #152: remove the staged 0600 secret files on exit.
|
|
331
363
|
[ -n "${_SECRETS_DIR:-}" ] && rm -rf "${_SECRETS_DIR}" 2>/dev/null || true
|
|
332
364
|
}
|
|
333
|
-
trap cleanup EXIT
|
|
365
|
+
trap cleanup EXIT
|
|
366
|
+
# A TERM handler that returns lets bash carry on looping: launchd/systemd stop (and `agentradar leave`) would
|
|
367
|
+
# wait out their kill timeout. Exit, and the EXIT trap cleans up.
|
|
368
|
+
trap 'exit 143' INT TERM
|
|
334
369
|
|
|
335
370
|
until ensure_agent; do
|
|
336
371
|
log "agent start failed — retrying in ${HEALTH_POLL_SEC}s"
|
|
337
372
|
sleep "${HEALTH_POLL_SEC}"
|
|
338
373
|
done
|
|
339
374
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
#
|
|
343
|
-
AWP_MCP_URL
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
375
|
+
if [ "${AR_FEED}" = "feeder" ]; then
|
|
376
|
+
log "starting feeder (${FEEDER_JS}) → session '${TMUX_SESSION}'"
|
|
377
|
+
# #152: pass AWP_BEARER_FILE (a 0600 PATH), NEVER the raw AWP_BEARER — the feeder reads the file, so the
|
|
378
|
+
# bearer is not in the feeder process's environment (`ps eww`) or argv. AWP_MCP_URL is not a secret.
|
|
379
|
+
# AR_TOOL_ENV_FILE: the login probe must see the same gateway env the pane runs with.
|
|
380
|
+
AWP_MCP_URL="${AWP_MCP_URL}" AWP_BEARER_FILE="${AWP_BEARER_FILE}" TMUX_SESSION="${TMUX_SESSION}" \
|
|
381
|
+
AGENT_PID_PATTERN="${AGENT_PID_PATTERN}" AR_AGENT_HARNESS="${AR_AGENT_HARNESS}" AGENT_BIN="${AGENT_BIN}" \
|
|
382
|
+
AR_TOOL_ENV_FILE="${_TOOL_ENV_FILE}" \
|
|
383
|
+
node "${FEEDER_JS}" &
|
|
384
|
+
FEEDER_PID=$!
|
|
385
|
+
log "feeder pid ${FEEDER_PID}"
|
|
386
|
+
else
|
|
387
|
+
log "no feeder (AR_FEED=${AR_FEED}): the session receives its own work"
|
|
388
|
+
fi
|
|
348
389
|
|
|
349
390
|
# #133 M4 — start the optional feeder bridge (best-effort, isolated). Same 0600 bearer FILE + session env
|
|
350
391
|
# as the feeder; NEVER the raw bearer. Its failure NEVER affects the feeder/agent (nice-to-have terminal).
|
|
@@ -371,7 +412,7 @@ start_bridge
|
|
|
371
412
|
# next_event heartbeat cannot paint the runner Live during recovery. Restart the bridge after a new pane is
|
|
372
413
|
# created because tmux pipe-pane was bound to the dead pane.
|
|
373
414
|
while true; do
|
|
374
|
-
if ! kill -0 "${FEEDER_PID}" 2>/dev/null; then
|
|
415
|
+
if [ -n "${FEEDER_PID}" ] && ! kill -0 "${FEEDER_PID}" 2>/dev/null; then
|
|
375
416
|
log "feeder exited — exiting so the service manager restarts the whole unit"
|
|
376
417
|
exit 1
|
|
377
418
|
fi
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentradar-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Secure AgentRadar vendor setup and skill installer",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"access": "public"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
|
-
"build": "tsc && mkdir -p dist/agentradar-vendor dist/vendor-runner/supervisor && cp ../../skills/agentradar-vendor/SKILL.md dist/agentradar-vendor/SKILL.md && cp ../../templates/vendor-runner/feeder.mjs ../../templates/vendor-runner/pane-live.sh ../../templates/vendor-runner/session-frame.mjs ../../templates/vendor-runner/terminal-bridge.mjs dist/vendor-runner/ && cp ../../templates/vendor-runner/supervisor/supervise-agent.sh dist/vendor-runner/supervisor/ && chmod +x dist/index.js dist/vendor-runner/pane-live.sh dist/vendor-runner/supervisor/supervise-agent.sh",
|
|
26
|
+
"build": "tsc && mkdir -p dist/agentradar-vendor dist/vendor-runner/supervisor && cp ../../skills/agentradar-vendor/SKILL.md dist/agentradar-vendor/SKILL.md && cp ../../templates/vendor-runner/feeder.mjs ../../templates/vendor-runner/pane-live.sh ../../templates/vendor-runner/session-frame.mjs ../../templates/vendor-runner/terminal-bridge.mjs dist/vendor-runner/ && cp ../../templates/vendor-runner/supervisor/supervise-agent.sh ../../templates/vendor-runner/supervisor/install-agent.sh ../../templates/vendor-runner/supervisor/com.agentradar.agent.plist.template ../../templates/vendor-runner/supervisor/agentradar-agent@.service.template dist/vendor-runner/supervisor/ && chmod +x dist/index.js dist/vendor-runner/pane-live.sh dist/vendor-runner/supervisor/supervise-agent.sh dist/vendor-runner/supervisor/install-agent.sh",
|
|
27
27
|
"clean": "rm -rf dist",
|
|
28
28
|
"prepack": "pnpm run build",
|
|
29
29
|
"typecheck": "tsc --noEmit",
|