agentradar-cli 0.2.3 → 0.2.5
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/agent-guard/.claude-plugin/marketplace.json +14 -0
- package/dist/agent-guard/.claude-plugin/plugin.json +7 -0
- package/dist/agent-guard/hooks/hooks.json +7 -0
- package/dist/agent-guard/scripts/guard.mjs +122 -0
- package/dist/agent-guard/skills/agentradar-discipline/SKILL.md +31 -0
- package/dist/agentradar-vendor/SKILL.md +6 -0
- package/dist/runner.js +94 -1
- package/dist/setup.js +90 -15
- package/dist/skill-install.js +70 -3
- package/dist/tool-run.js +1 -1
- package/dist/update.js +94 -0
- package/dist/vendor-runner/feeder.mjs +22 -3
- package/dist/vendor-runner/supervisor/agentradar-agent@.service.template +2 -0
- package/dist/vendor-runner/supervisor/com.agentradar.agent.plist.template +4 -1
- package/dist/vendor-runner/supervisor/supervise-agent.sh +8 -2
- package/dist/vendor-runner/terminal-bridge.mjs +87 -71
- package/package.json +7 -2
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agentradar",
|
|
3
|
+
"description": "AgentRadar plugins for agents working on the rail.",
|
|
4
|
+
"owner": {
|
|
5
|
+
"name": "AgentRadar"
|
|
6
|
+
},
|
|
7
|
+
"plugins": [
|
|
8
|
+
{
|
|
9
|
+
"name": "agent-guard",
|
|
10
|
+
"source": "./",
|
|
11
|
+
"description": "AgentRadar work discipline for agents on the rail."
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Blocks going idle while AgentRadar work is owed; reminds of the house rules every AR_DISCIPLINE_EVERY turns.",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"Stop": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" stop", "timeout": 30 }] }],
|
|
5
|
+
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" prompt", "timeout": 10 }] }]
|
|
6
|
+
}
|
|
7
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// AgentRadar agent-guard: `guard.mjs stop` (Stop hook) and `guard.mjs prompt` (UserPromptSubmit hook).
|
|
3
|
+
// Claude Code and Codex share the hook I/O used here: JSON on stdin, JSON decision on stdout.
|
|
4
|
+
//
|
|
5
|
+
// stop: asks the server (`agentradar call <runner> agent_obligations`) what the agent still owes and
|
|
6
|
+
// blocks the stop while a message or task is unacknowledged, at most MAX_BLOCKS times in a row.
|
|
7
|
+
// Tasks still in progress get one reminder. Anything unexpected (no runner, server down, bad
|
|
8
|
+
// output) lets the agent stop: a guard that wedges the session is worse than no guard.
|
|
9
|
+
// prompt: every AR_DISCIPLINE_EVERY turns (default 20, 0 = off) re-adds the house rules, the must-nots
|
|
10
|
+
// and the installed skills to the context.
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { homedir, tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const MAX_BLOCKS = 3;
|
|
18
|
+
const runner = process.env.AR_CLI_RUNNER_ID ?? "";
|
|
19
|
+
const cli = process.env.AR_AGENTRADAR_BIN || "agentradar";
|
|
20
|
+
|
|
21
|
+
let input = {};
|
|
22
|
+
try { input = JSON.parse(readFileSync(0, "utf8") || "{}"); } catch {}
|
|
23
|
+
// ponytail: no lock; one session's hooks run one at a time.
|
|
24
|
+
const stateDir = process.env.CLAUDE_PLUGIN_DATA || tmpdir();
|
|
25
|
+
const stateFile = join(stateDir, `agent-guard-${String(input.session_id ?? "none").replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
|
|
26
|
+
let state = { turns: 0, blocks: 0 };
|
|
27
|
+
try { state = { ...state, ...JSON.parse(readFileSync(stateFile, "utf8")) }; } catch {}
|
|
28
|
+
const save = () => { try { mkdirSync(stateDir, { recursive: true }); writeFileSync(stateFile, JSON.stringify(state)); return true; } catch { return false; } };
|
|
29
|
+
const call = (tool) => `agentradar call ${runner} ${tool}`;
|
|
30
|
+
|
|
31
|
+
const RULES = [
|
|
32
|
+
"Answer every message in the channel it came from, and a human first: someone is waiting.",
|
|
33
|
+
`Acknowledge what you handled: ${call("acknowledge_messages")} --args '{"eventIds":[...]}' for messages, ${call("acknowledge_task")} --issueId <id> for a task you take on.`,
|
|
34
|
+
"Talk is not a task: reply conversationally, and create a task only for trackable, billable work.",
|
|
35
|
+
"Write so a 13-year-old would understand: short, plain words, one next step, no ids or tool names unless asked. Say it once.",
|
|
36
|
+
`Before you go idle, run ${call("agent_obligations")}: "owed" must be 0, and every in-progress task must be moving or waiting on someone who knows it.`,
|
|
37
|
+
];
|
|
38
|
+
const MUST_NOTS = [
|
|
39
|
+
"Never ask for, print or pass on a credential. AgentRadar holds the keys.",
|
|
40
|
+
`Never call decide_outcome or declare_outcome on your own task: only a human accepts your work. Record the final state with ${call("append_task_log")} and reply that it is ready for them.`,
|
|
41
|
+
"Never contact the platform team (submit_feedback) unless the human asks you to.",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Skill folders the agent can load: this plugin's, the project's, and the user's (Claude Code and Codex).
|
|
45
|
+
function skills() {
|
|
46
|
+
const dirs = [
|
|
47
|
+
fileURLToPath(new URL("../skills", import.meta.url)),
|
|
48
|
+
input.cwd && join(input.cwd, ".claude", "skills"),
|
|
49
|
+
join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "skills"),
|
|
50
|
+
process.env.CODEX_HOME && join(process.env.CODEX_HOME, "skills"),
|
|
51
|
+
];
|
|
52
|
+
const names = new Set();
|
|
53
|
+
for (const dir of dirs) {
|
|
54
|
+
if (!dir) continue;
|
|
55
|
+
// statSync follows a symlinked skill folder; a broken link or a plain file is not a skill.
|
|
56
|
+
try { for (const name of readdirSync(dir)) if (!name.startsWith(".") && statSync(join(dir, name), { throwIfNoEntry: false })?.isDirectory()) names.add(name); } catch {}
|
|
57
|
+
}
|
|
58
|
+
return [...names].sort();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (process.argv[2] === "prompt") {
|
|
62
|
+
const every = Number.parseInt(process.env.AR_DISCIPLINE_EVERY ?? "20", 10);
|
|
63
|
+
// No reset of `blocks` here: Codex may run this hook for a Stop continuation, which would lift the cap.
|
|
64
|
+
state.turns += 1;
|
|
65
|
+
save();
|
|
66
|
+
if (runner && every > 0 && state.turns % every === 0) {
|
|
67
|
+
process.stdout.write(JSON.stringify({
|
|
68
|
+
hookSpecificOutput: {
|
|
69
|
+
hookEventName: "UserPromptSubmit",
|
|
70
|
+
additionalContext: [
|
|
71
|
+
`AgentRadar house rules (reminder every ${every} turns):`,
|
|
72
|
+
...RULES.map((rule, i) => `${i + 1}. ${rule}`),
|
|
73
|
+
"You must not:",
|
|
74
|
+
...MUST_NOTS.map((rule) => `- ${rule}`),
|
|
75
|
+
`Your skills (load the one that fits before acting): ${skills().join(", ") || "none installed"}.`,
|
|
76
|
+
].join("\n"),
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
} else if (process.argv[2] === "stop" && runner) {
|
|
81
|
+
const reason = stopReason();
|
|
82
|
+
state.blocks += 1;
|
|
83
|
+
// A block that cannot be counted is never sent: an unwritable state dir must not lift the cap.
|
|
84
|
+
if (reason && state.blocks <= MAX_BLOCKS && save()) {
|
|
85
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason }));
|
|
86
|
+
} else {
|
|
87
|
+
if (reason) process.stderr.write(`agent-guard: ${state.blocks > MAX_BLOCKS ? `still owed after ${MAX_BLOCKS} reminders` : "cannot record reminders"}; letting the agent stop\n`);
|
|
88
|
+
state.blocks = 0;
|
|
89
|
+
save();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function stopReason() {
|
|
94
|
+
const run = spawnSync(cli, ["call", runner, "agent_obligations"], { encoding: "utf8", timeout: 20_000 });
|
|
95
|
+
if (run.status !== 0) return "";
|
|
96
|
+
let o;
|
|
97
|
+
try { o = JSON.parse(run.stdout); } catch { return ""; }
|
|
98
|
+
const messages = Array.isArray(o.unacknowledgedMessages) ? o.unacknowledgedMessages : [];
|
|
99
|
+
const tasks = Array.isArray(o.unacknowledgedTasks) ? o.unacknowledgedTasks : [];
|
|
100
|
+
const working = Array.isArray(o.inProgressTasks) ? o.inProgressTasks : [];
|
|
101
|
+
const asks = Array.isArray(o.overdueAsks) ? o.overdueAsks : [];
|
|
102
|
+
const name = (t) => `${t.identifier ?? t.issueId} "${t.title ?? ""}" (issueId ${t.issueId})`;
|
|
103
|
+
if (messages.length || tasks.length || asks.length) {
|
|
104
|
+
return [
|
|
105
|
+
`AgentRadar: you still owe ${messages.length + tasks.length + asks.length} item(s). Do not go idle yet.`,
|
|
106
|
+
...(messages.length ? [
|
|
107
|
+
`Messages received but not acknowledged: ${messages.join(", ")}. Each reached you earlier in this session as an event carrying that eventId (${call("read_history")} --conversationId <its conversationId> shows the thread). Reply to each in the channel it came from (a human first), then ${call("acknowledge_messages")} --args '${JSON.stringify({ eventIds: messages })}'.`,
|
|
108
|
+
] : []),
|
|
109
|
+
...tasks.map((t) => `Task assigned to you, not acknowledged: ${name(t)}. Take it on or say why not, then ${call("acknowledge_task")} --issueId ${t.issueId}.`),
|
|
110
|
+
...asks.map((a) => `Nobody answered your ask in thread ${a.threadId} (due ${a.dueAt}${a.note ? `; ${a.note}` : ""}). Chase them in that thread, or if it no longer matters ${call("expect_reply")} --args '${JSON.stringify({ conversationId: a.threadId, by: null })}'.`),
|
|
111
|
+
`Check again with ${call("agent_obligations")}, then stop.`,
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
|
114
|
+
// Only once per stop: when this stop is itself the result of a block, the agent has seen it.
|
|
115
|
+
if (working.length && !input.stop_hook_active) {
|
|
116
|
+
return [
|
|
117
|
+
`AgentRadar: ${working.length} task(s) still in progress: ${working.map(name).join("; ")}.`,
|
|
118
|
+
"Keep working on them, or, if one is waiting on someone, make sure its thread says so, then stop.",
|
|
119
|
+
].join("\n");
|
|
120
|
+
}
|
|
121
|
+
return "";
|
|
122
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: agentradar-discipline
|
|
3
|
+
description: >
|
|
4
|
+
House rules for an agent working through AgentRadar. Use before going idle, after receiving a
|
|
5
|
+
channel message or an assigned task, and whenever unsure whether something is still owed.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# AgentRadar discipline
|
|
9
|
+
|
|
10
|
+
You act through `agentradar call <runner-id> <tool>`. Your runner id is in `$AR_CLI_RUNNER_ID`.
|
|
11
|
+
|
|
12
|
+
## Before you go idle
|
|
13
|
+
|
|
14
|
+
1. Run `agentradar call $AR_CLI_RUNNER_ID agent_obligations`.
|
|
15
|
+
2. `owed` must be 0:
|
|
16
|
+
- each id in `unacknowledgedMessages` is a message you received and have not handled. Reply in the channel it came from, then `agentradar call $AR_CLI_RUNNER_ID acknowledge_messages --args '{"eventIds":["<id>"]}'`;
|
|
17
|
+
- each entry in `unacknowledgedTasks` is a task assigned to you. Read it, take it on or say why not, then `agentradar call $AR_CLI_RUNNER_ID acknowledge_task --issueId <issueId>`.
|
|
18
|
+
3. Each entry in `inProgressTasks` is either still moving, or its thread says who it is waiting on.
|
|
19
|
+
4. Each entry in `overdueAsks` is a question nobody answered in time. Chase them in that thread with `reply`.
|
|
20
|
+
|
|
21
|
+
The Stop hook runs the same check and will not let you stop while something is owed.
|
|
22
|
+
|
|
23
|
+
## Always
|
|
24
|
+
|
|
25
|
+
- Answer every message in the channel it came from. A human who wrote is waiting for you; answer them first.
|
|
26
|
+
- Talk is not a task. Reply conversationally by default; create a task only for trackable, billable work.
|
|
27
|
+
- Write to people so a 13-year-old would understand: a few short lines in plain words, ending with one next step (not a menu of options), no ids, tool names or internal terms unless asked. Say a thing once. Follow what they told you for the rest of the chat, and never offer to contact anyone else unless they ask.
|
|
28
|
+
- Never ask for, print or pass on a credential. AgentRadar holds the keys.
|
|
29
|
+
- When you ask another agent or a human something, call `expect_reply` on that thread with the time you need the answer by. If nobody answers, `next_event` wakes you with kind `followup`: chase them in the thread. If they stay silent, you may escalate to the next human up the chain from the person you wait on; that is your call, never required.
|
|
30
|
+
- Only a human accepts your work. Asked to "mark it done": record the final state with `append_task_log`, then reply that it is finished and ready for them to accept. Never call `decide_outcome` or `declare_outcome` on your own task.
|
|
31
|
+
- Never contact the platform team (`submit_feedback`) unless the human asks you to. "Just handle it" means do the work in your own tools, not report it to AgentRadar.
|
|
@@ -20,8 +20,14 @@ You work on the vendor's computer and connect to AgentRadar through the `agentra
|
|
|
20
20
|
|
|
21
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
|
+
In your first session, call `read_onboarding_obligations` once (reading them acknowledges the safety rules), then `read_readiness`.
|
|
24
|
+
|
|
23
25
|
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
26
|
|
|
27
|
+
## Talking to people
|
|
28
|
+
|
|
29
|
+
Write to people so a 13-year-old would understand: a few short plain lines, then one next step, not a menu. No ids, tool names or internal terms unless asked (say "the owner", "your team", "company memory"; name a plan with its monthly price). Answer only what was asked, and say it once. Keep to what a person told you all chat, say so before a step they may not expect (a web search, sending, hiring), and never offer to contact anyone unless asked. Never talk about how messages reach you. Do the obvious thing for "undo that"; if unclear, offer short choices. Check your tools before saying what is scheduled or done; never answer from memory.
|
|
30
|
+
|
|
25
31
|
## Work loop
|
|
26
32
|
|
|
27
33
|
1. Use the AgentRadar MCP to receive the next event or assigned work.
|
package/dist/runner.js
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
import { shellValue, trustedMcpEndpoint, writePrivateFile } from "./setup.js";
|
|
8
8
|
import { callAwpTool, envValue } from "./tool-run.js";
|
|
9
|
+
import { installedVersion, runningVersion, selfUpdate } from "./update.js";
|
|
9
10
|
const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
10
11
|
const ENGAGEMENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
11
12
|
// Per-hire bearers are ordinary agent keys (`pcp_…`); the runner's own is an AWP key (`ar_…`).
|
|
@@ -14,6 +15,8 @@ const HIRE_BEARER = /^(ar|pcp)_[A-Za-z0-9_-]+$/;
|
|
|
14
15
|
// A hire always runs Claude, whose MCP config it owns; a Codex home would be the catalog agent's.
|
|
15
16
|
const HIRE_OWN_KEYS = /^(export\s+)?(AGENT_NAME|AR_BASE|AWP_MCP_URL|AWP_BEARER|AWP_BEARER_FILE|MCP_CONFIG|TMUX_SESSION|AGENT_PID_PATTERN|AR_AGENT_HARNESS|CODEX_HOME)=/;
|
|
16
17
|
const CODEX_HARNESS = /^(export\s+)?AR_AGENT_HARNESS=\s*["']?codex\b/m;
|
|
18
|
+
// While a pane shows this, its agent is mid-turn (the feeder's in-progress hint, same for Claude and Codex).
|
|
19
|
+
const MID_TURN = /esc to interrupt|\besc to cancel\b/i;
|
|
17
20
|
const run = promisify(execFile);
|
|
18
21
|
/** Run from outside the target harness: the command deliberately replaces that harness process. */
|
|
19
22
|
export async function runRestartSelftest(runnerId, deps = {}) {
|
|
@@ -227,7 +230,22 @@ export async function startVendorRunner(runnerId, home = homedir(), deps = {}) {
|
|
|
227
230
|
const spawnChild = deps.spawn ?? ((command, args) => spawn(command, args, { stdio: "inherit", env: process.env }));
|
|
228
231
|
const hires = new Map();
|
|
229
232
|
let timer;
|
|
233
|
+
let updateTimer;
|
|
230
234
|
let stopped = false;
|
|
235
|
+
let updating = false;
|
|
236
|
+
const tmux = deps.tmux ?? (async (args) => (await run("bash", [
|
|
237
|
+
"-c", 'PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"; exec "${TMUX_BIN:-tmux}" "$@"', "tmux", ...args,
|
|
238
|
+
])).stdout);
|
|
239
|
+
const installed = deps.installed ?? (() => installedVersion());
|
|
240
|
+
// Unknown (package.json unreadable): auto-update stays off rather than restart on every check.
|
|
241
|
+
const running = await (deps.installed ? installed() : runningVersion()).catch(() => null);
|
|
242
|
+
// Only our own LaunchAgent / systemd unit sets AGENTRADAR_SERVICE=1, and only they restart the runner.
|
|
243
|
+
// Not the parent pid: a nohup/setsid start is reparented to launchd or a subreaper too, and would be
|
|
244
|
+
// killed for an update with nothing to start it again. Every other runner installs updates, never exits.
|
|
245
|
+
const serviced = deps.serviced ?? process.env.AGENTRADAR_SERVICE === "1";
|
|
246
|
+
// Read once, then dropped: the supervisor's tmux server would otherwise pass it to every runner a vendor
|
|
247
|
+
// later starts by hand in that tmux, and those would exit for an update with nothing to restart them.
|
|
248
|
+
delete process.env.AGENTRADAR_SERVICE;
|
|
231
249
|
const runHire = (hireEnv) => {
|
|
232
250
|
if (stopped || hires.has(hireEnv))
|
|
233
251
|
return;
|
|
@@ -399,12 +417,87 @@ export async function startVendorRunner(runnerId, home = homedir(), deps = {}) {
|
|
|
399
417
|
const stop = () => {
|
|
400
418
|
stopped = true;
|
|
401
419
|
clearTimeout(timer);
|
|
420
|
+
clearTimeout(updateTimer);
|
|
402
421
|
for (const child of hires.values())
|
|
403
422
|
child.kill("SIGTERM");
|
|
404
423
|
};
|
|
424
|
+
// Every tmux session this runner owns: the shared agent's and each hire's.
|
|
425
|
+
let sessions = [];
|
|
426
|
+
const sessionNames = async () => (await Promise.all([envPath, ...hires.keys()].map(async (file) => {
|
|
427
|
+
const contents = await readFile(file, "utf8").catch(() => "");
|
|
428
|
+
return envValue(contents, "TMUX_SESSION") ?? envValue(contents, "AGENT_NAME");
|
|
429
|
+
}))).filter((name) => Boolean(name));
|
|
430
|
+
const midTurn = async () => {
|
|
431
|
+
for (const name of await sessionNames()) {
|
|
432
|
+
if (MID_TURN.test(await tmux(["capture-pane", "-p", "-t", `=${name}:`]).catch(() => "")))
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
return false;
|
|
436
|
+
};
|
|
405
437
|
const shared = spawnChild("bash", [supervisor, envPath]);
|
|
406
438
|
shared.once("error", (error) => { stop(); reject(error); });
|
|
407
|
-
shared.once("exit", (code, signal) => {
|
|
439
|
+
shared.once("exit", (code, signal) => {
|
|
440
|
+
stop();
|
|
441
|
+
if (!updating)
|
|
442
|
+
return resolvePromise(code ?? (signal ? 1 : 0));
|
|
443
|
+
// The supervisors are gone, so nothing relaunches the old panes: end them, and exit for launchd or
|
|
444
|
+
// systemd to start this runner on the new code, whose supervisors resume each session (AR_RESUME_FLAGS)
|
|
445
|
+
// with the new agent-guard plugin, which loads in place from this package.
|
|
446
|
+
void Promise.all(sessions.map((name) => tmux(["kill-session", "-t", `=${name}`]).catch(() => "")))
|
|
447
|
+
.then(() => resolvePromise(0));
|
|
448
|
+
});
|
|
449
|
+
// The package is shared by every agent on this machine, so AGENTRADAR_NO_AUTO_UPDATE=1 in the service's
|
|
450
|
+
// environment or in any agent's env file turns updates off for all of them.
|
|
451
|
+
const optedOut = async () => {
|
|
452
|
+
const values = [process.env.AGENTRADAR_NO_AUTO_UPDATE];
|
|
453
|
+
for (const file of await readdir(agentDir).catch(() => [])) {
|
|
454
|
+
if (file.endsWith(".env"))
|
|
455
|
+
values.push(envValue(await readFile(join(agentDir, file), "utf8").catch(() => ""), "AGENTRADAR_NO_AUTO_UPDATE") ?? undefined);
|
|
456
|
+
}
|
|
457
|
+
return values.some((value) => value && value !== "0");
|
|
458
|
+
};
|
|
459
|
+
// At start and then daily: install npm's latest release. Every tick: restart onto whatever is on disk if
|
|
460
|
+
// it is not what this process runs (another runner on this machine may have installed it). A restart
|
|
461
|
+
// waits for every pane to finish its turn, for at most an hour.
|
|
462
|
+
let nextCheck = 0;
|
|
463
|
+
let told = "";
|
|
464
|
+
const autoUpdate = async () => {
|
|
465
|
+
try {
|
|
466
|
+
if (running && !(await optedOut())) {
|
|
467
|
+
if (Date.now() >= nextCheck) {
|
|
468
|
+
nextCheck = Date.now() + (deps.updateEveryMs ?? 86_400_000);
|
|
469
|
+
const fresh = await (deps.update ?? selfUpdate)();
|
|
470
|
+
if (fresh)
|
|
471
|
+
log(`AgentRadar CLI ${fresh} installed (was ${running})`);
|
|
472
|
+
}
|
|
473
|
+
const onDisk = await installed();
|
|
474
|
+
if (onDisk !== running && !serviced) {
|
|
475
|
+
if (told !== onDisk)
|
|
476
|
+
log(`AgentRadar CLI ${onDisk} is installed; restart this runner to use it`);
|
|
477
|
+
told = onDisk;
|
|
478
|
+
}
|
|
479
|
+
else if (onDisk !== running) {
|
|
480
|
+
for (let i = 0; i < 240 && !stopped && await midTurn(); i += 1) {
|
|
481
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, deps.idlePollMs ?? 15_000));
|
|
482
|
+
}
|
|
483
|
+
if (stopped)
|
|
484
|
+
return;
|
|
485
|
+
log(`Restarting on AgentRadar CLI ${onDisk}; each session resumes where it was`);
|
|
486
|
+
sessions = await sessionNames();
|
|
487
|
+
updating = true;
|
|
488
|
+
stop();
|
|
489
|
+
shared.kill("SIGTERM");
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
catch (error) {
|
|
495
|
+
log(`Auto-update failed, staying on ${running}: ${error.message}`);
|
|
496
|
+
}
|
|
497
|
+
if (!stopped)
|
|
498
|
+
updateTimer = setTimeout(autoUpdate, deps.tickMs ?? 60_000);
|
|
499
|
+
};
|
|
500
|
+
void autoUpdate();
|
|
408
501
|
hireTick()
|
|
409
502
|
.then((tick) => {
|
|
410
503
|
// The next tick is scheduled only after this one finishes, so a slow clone never overlaps another.
|
package/dist/setup.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { execFile } from "node:child_process";
|
|
2
|
+
import { execFile, spawn } from "node:child_process";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
3
4
|
import { chmod, cp, lstat, mkdir, readFile, readlink, realpath, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
4
5
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
8
|
import * as tls from "node:tls";
|
|
8
9
|
import { promisify } from "node:util";
|
|
10
|
+
import { syncJoinedSkills } from "./skill-install.js";
|
|
9
11
|
const DEFAULT_API_URL = "https://vendor.agentradar.com/api";
|
|
12
|
+
/** The service's CLI build (the CLI itself and the agent-guard plugin): `installCli` keeps it current, never the npx cache. */
|
|
13
|
+
const cliDist = (home) => join(home, ".agentradar", "cli", "current", "dist");
|
|
10
14
|
/** `ar_` is a vendor runner bearer; `pcp_` is the key an organisation's own agent claims through `join`. */
|
|
11
15
|
const BEARER_SHAPE = /^(?:ar|pcp)_[A-Za-z0-9_-]+$/;
|
|
12
16
|
/**
|
|
@@ -241,12 +245,13 @@ async function codexServiceHome(home, runnerId, env) {
|
|
|
241
245
|
const codexHome = join(home, ".agentradar", "codex", runnerId);
|
|
242
246
|
await mkdir(codexHome, { recursive: true, mode: 0o700 });
|
|
243
247
|
await chmod(codexHome, 0o700);
|
|
244
|
-
let
|
|
248
|
+
let dropTable = false;
|
|
245
249
|
let inTable = false;
|
|
246
250
|
let depth = 0;
|
|
247
251
|
let dropping = false;
|
|
248
252
|
const text = { open: null };
|
|
249
253
|
// A restart would otherwise stop on Codex's "Update available" screen, with nobody there to press a key.
|
|
254
|
+
// The vendor's own hooks stay out too: the service runs AgentRadar's guard and nothing it did not review.
|
|
250
255
|
const config = ["check_for_update_on_startup = false", ...(await readFile(join(source, "config.toml"), "utf8").catch(() => "")).split("\n").filter((line) => {
|
|
251
256
|
// Brackets inside strings and comments don't count; a line inside a multi-line array or string goes with its key.
|
|
252
257
|
const continued = depth > 0 || text.open !== null;
|
|
@@ -254,18 +259,31 @@ async function codexServiceHome(home, runnerId, env) {
|
|
|
254
259
|
depth = Math.max(0, depth + (bare.match(/\[/g)?.length ?? 0) - (bare.match(/\]/g)?.length ?? 0));
|
|
255
260
|
if (continued)
|
|
256
261
|
return !dropping;
|
|
262
|
+
// An escape in a quoted key ("\u0068ooks") could name mcp_servers or hooks past both checks.
|
|
263
|
+
if (/^\s*(?:\[\[?\s*)?(?:[\w-]+\s*\.\s*)*"[^"\n]*\\/.test(line))
|
|
264
|
+
throw new Error(`${join(source, "config.toml")} has a quoted key with an escape; spell it plainly and run join again`);
|
|
257
265
|
const header = /^\s*\[\[?\s*([^\]]+)\]/.exec(line);
|
|
258
266
|
if (header) {
|
|
259
|
-
|
|
267
|
+
dropTable = /^["']?(?:mcp_servers|hooks)\b/.test(header[1]);
|
|
260
268
|
inTable = true;
|
|
261
269
|
}
|
|
262
|
-
dropping =
|
|
270
|
+
dropping = dropTable || /^\s*["']?(?:mcp_servers|hooks)["']?\s*[.=]/.test(line) || (!inTable && /^\s*["']?check_for_update_on_startup["']?\s*=/.test(line));
|
|
263
271
|
return !dropping;
|
|
264
272
|
})].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`);
|
|
273
|
+
// Fail closed: another client's MCP server, or a hook we did not review, must never reach this client's session.
|
|
274
|
+
if (/^\s*(?:\[\[?\s*)?["']?(?:mcp_servers|hooks)\b/m.test(config))
|
|
275
|
+
throw new Error(`Could not strip the MCP servers and hooks from ${join(source, "config.toml")}; simplify it and run join again`);
|
|
268
276
|
await writePrivateFile(join(codexHome, "config.toml"), config);
|
|
277
|
+
const guard = `node ${JSON.stringify(join(cliDist(home), "agent-guard", "scripts", "guard.mjs"))}`;
|
|
278
|
+
await writePrivateFile(join(codexHome, "hooks.json"), `${JSON.stringify({ hooks: {
|
|
279
|
+
Stop: [{ hooks: [{ type: "command", command: `${guard} stop`, timeout: 30 }] }],
|
|
280
|
+
UserPromptSubmit: [{ hooks: [{ type: "command", command: `${guard} prompt`, timeout: 10 }] }],
|
|
281
|
+
} }, null, 2)}\n`);
|
|
282
|
+
const trusted = await codexHookHashes(codexHome, env).catch(() => []);
|
|
283
|
+
if (trusted.length) {
|
|
284
|
+
await writePrivateFile(join(codexHome, "config.toml"), `${config.replace(/\n*$/, "\n")}${trusted
|
|
285
|
+
.map(({ key, hash }) => `\n[hooks.state.${JSON.stringify(key)}]\ntrusted_hash = ${JSON.stringify(hash)}\n`).join("")}`);
|
|
286
|
+
}
|
|
269
287
|
await cp(join(source, "rules"), join(codexHome, "rules"), { recursive: true, force: true }).catch((error) => {
|
|
270
288
|
if (error.code !== "ENOENT")
|
|
271
289
|
throw error;
|
|
@@ -278,6 +296,46 @@ async function codexServiceHome(home, runnerId, env) {
|
|
|
278
296
|
}
|
|
279
297
|
return { codexHome, keys: [...config.matchAll(/^\s*env_key\s*=\s*"([A-Za-z_][A-Za-z0-9_]*)"/gm)].map((match) => match[1]) };
|
|
280
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Codex runs a user hook only once its hash is recorded as trusted (`[hooks.state."<key>"] trusted_hash`),
|
|
301
|
+
* which is what its /hooks review writes. Asks Codex itself for the keys and hashes of the hooks in
|
|
302
|
+
* `<codexHome>/hooks.json` (app-server `hooks/list`), so the hash is never recomputed here. Any failure
|
|
303
|
+
* leaves them untrusted: Codex then skips them, and the agent runs without the guard rather than not at all.
|
|
304
|
+
*/
|
|
305
|
+
async function codexHookHashes(codexHome, env) {
|
|
306
|
+
const child = spawn(env.CODEX_BIN || "codex", ["app-server"], { cwd: codexHome, env: { ...env, CODEX_HOME: codexHome }, stdio: ["pipe", "pipe", "ignore"] });
|
|
307
|
+
const lines = createInterface({ input: child.stdout });
|
|
308
|
+
// A child that never spawned (no codex on PATH) emits no end on stdout; closing the reader ends the loop.
|
|
309
|
+
const stop = () => { child.kill(); lines.close(); };
|
|
310
|
+
const timer = setTimeout(stop, 20_000);
|
|
311
|
+
try {
|
|
312
|
+
child.on("error", stop);
|
|
313
|
+
child.stdin.on("error", () => undefined);
|
|
314
|
+
const send = (message) => child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
|
|
315
|
+
send({ id: 1, method: "initialize", params: { clientInfo: { name: "agentradar", version: "0" } } });
|
|
316
|
+
send({ method: "initialized" });
|
|
317
|
+
send({ id: 2, method: "hooks/list", params: { cwds: [codexHome] } });
|
|
318
|
+
for await (const line of lines) {
|
|
319
|
+
let message;
|
|
320
|
+
try {
|
|
321
|
+
message = JSON.parse(line);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (message.id !== 2)
|
|
327
|
+
continue;
|
|
328
|
+
return (message.result?.data ?? []).flatMap((entry) => entry.hooks ?? [])
|
|
329
|
+
.filter((hook) => hook.source === "user" && basename(hook.sourcePath) === "hooks.json")
|
|
330
|
+
.map((hook) => ({ key: hook.key, hash: hook.currentHash }));
|
|
331
|
+
}
|
|
332
|
+
return [];
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
child.kill();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
281
339
|
async function installRunner(home, projectPath, approved, service) {
|
|
282
340
|
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(approved.runnerId)) {
|
|
283
341
|
throw new Error("AgentRadar returned an invalid runner ID");
|
|
@@ -300,7 +358,7 @@ async function installRunner(home, projectPath, approved, service) {
|
|
|
300
358
|
const envPath = join(agentDir, `${approved.runnerId}.env`);
|
|
301
359
|
const joined = [];
|
|
302
360
|
if (service) {
|
|
303
|
-
joined.push(`AR_CLI_RUNNER_ID=${shellValue(approved.runnerId)}`);
|
|
361
|
+
joined.push(`AR_CLI_RUNNER_ID=${shellValue(approved.runnerId)}`, `AR_AGENTRADAR_BIN=${shellValue(join(cliDist(home), "index.js"))}`);
|
|
304
362
|
let toolKeys = [];
|
|
305
363
|
if (harness === "codex") {
|
|
306
364
|
const { codexHome, keys } = await codexServiceHome(home, approved.runnerId, service.env);
|
|
@@ -322,6 +380,14 @@ async function installRunner(home, projectPath, approved, service) {
|
|
|
322
380
|
const configDirEnv = service.env.CLAUDE_CONFIG_DIR?.trim();
|
|
323
381
|
if (configDirEnv)
|
|
324
382
|
joined.push(`CLAUDE_CONFIG_DIR=${shellValue(resolve(configDirEnv))}`);
|
|
383
|
+
// agent-guard, enabled for this session only (`--settings`), loaded from the copy inside this package, with
|
|
384
|
+
// its plugin state under the runner's config so the vendor's own Claude plugins are never touched.
|
|
385
|
+
const settings = join(configDir, "agentradar-settings.json");
|
|
386
|
+
await writePrivateFile(settings, `${JSON.stringify({
|
|
387
|
+
extraKnownMarketplaces: { agentradar: { source: { source: "directory", path: join(cliDist(home), "agent-guard") } } },
|
|
388
|
+
enabledPlugins: { "agent-guard@agentradar": true },
|
|
389
|
+
}, null, 2)}\n`);
|
|
390
|
+
joined.push(`CLAUDE_SETTINGS=${shellValue(settings)}`, `CLAUDE_CODE_PLUGIN_CACHE_DIR=${shellValue(join(configDir, "plugins"))}`);
|
|
325
391
|
}
|
|
326
392
|
const toolEnv = Object.entries(service.env)
|
|
327
393
|
.filter(([key, value]) => (TOOL_ENV.test(key) || toolKeys.includes(key)) && key !== "ANTHROPIC_API_KEY" && value && !value.includes("\n"))
|
|
@@ -459,23 +525,28 @@ export async function installCli(home, pkg = dirname(dirname(fileURLToPath(impor
|
|
|
459
525
|
if (!(await lstat(target).then(() => true, () => false))) {
|
|
460
526
|
// Staged then renamed, so a half-finished copy is never a version directory.
|
|
461
527
|
const staged = `${target}.${process.pid}.tmp`;
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
528
|
+
try {
|
|
529
|
+
await cp(join(pkg, "dist"), join(staged, "dist"), { recursive: true });
|
|
530
|
+
await cp(join(pkg, "package.json"), join(staged, "package.json"));
|
|
531
|
+
await rename(staged, target);
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
// A copy that fails partway leaves nothing behind; losing the rename to another runner is fine.
|
|
465
535
|
await rm(staged, { recursive: true, force: true });
|
|
466
|
-
|
|
536
|
+
const code = error.code;
|
|
537
|
+
if (code !== "ENOTEMPTY" && code !== "EEXIST")
|
|
467
538
|
throw error;
|
|
468
|
-
}
|
|
539
|
+
}
|
|
469
540
|
}
|
|
470
541
|
// Forward only: an older CLI run later (a stale npx cache) never downgrades the service.
|
|
471
542
|
const current = await readlink(join(cli, "current")).catch(() => "");
|
|
472
543
|
if (current && versionRank(current) > versionRank(version))
|
|
473
|
-
return
|
|
544
|
+
return cliDist(home);
|
|
474
545
|
const link = join(cli, `current.${process.pid}.tmp`);
|
|
475
546
|
await rm(link, { force: true });
|
|
476
547
|
await symlink(version, link);
|
|
477
548
|
await rename(link, join(cli, "current"));
|
|
478
|
-
return
|
|
549
|
+
return cliDist(home);
|
|
479
550
|
}
|
|
480
551
|
/** `install-agent.sh <id> --load` with the CLI at ~/.agentradar/cli/current, so the service does not depend on PATH. */
|
|
481
552
|
async function installAgentService(runnerId, home, env) {
|
|
@@ -534,6 +605,10 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
534
605
|
const runnerEnv = await installRunner(home, projectPath, { status: "approved", agentId, runnerId, installation, harness }, { sessionId, env });
|
|
535
606
|
await saved?.();
|
|
536
607
|
log(`Runner installed → ${runnerEnv}`);
|
|
608
|
+
// Best effort: a failed first sync never stops the join; the service's feeder syncs them again.
|
|
609
|
+
await syncJoinedSkills(runnerId, { home, fetch: fetchImpl, log }).catch((error) => {
|
|
610
|
+
log(`Your AgentRadar skills are not installed yet (${String(error?.message ?? error).split("\n")[0]}); the runner installs them once it is running.`);
|
|
611
|
+
});
|
|
537
612
|
try {
|
|
538
613
|
await (deps.installService ?? ((id) => installAgentService(id, home, env)))(runnerId);
|
|
539
614
|
}
|
package/dist/skill-install.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { chmod, lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
1
|
+
import { chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, isAbsolute, join, normalize, sep } from "node:path";
|
|
4
|
+
import { awpRequest, envValue } from "./tool-run.js";
|
|
5
|
+
const MANAGED_MARK = ".agentradar-managed";
|
|
6
|
+
const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,99}$/;
|
|
4
7
|
async function ensureDirectory(path) {
|
|
5
8
|
try {
|
|
6
9
|
const info = await lstat(path);
|
|
@@ -38,7 +41,71 @@ export async function installVendorSkill(deps = {}) {
|
|
|
38
41
|
});
|
|
39
42
|
}
|
|
40
43
|
// The runner keeps this skill current from AgentRadar; the marker says the folder is AgentRadar's to replace.
|
|
41
|
-
await writeFile(join(skillDir,
|
|
44
|
+
await writeFile(join(skillDir, MANAGED_MARK), "Written by AgentRadar; replaced on every sync.\n");
|
|
42
45
|
(deps.log ?? console.log)("AgentRadar vendor skill installed for this project.");
|
|
43
46
|
}
|
|
47
|
+
const isManaged = async (dir) => (await lstat(dir).then((info) => info.isDirectory(), () => false)) && (await lstat(join(dir, MANAGED_MARK)).then(() => true, () => false));
|
|
48
|
+
/**
|
|
49
|
+
* The feeder's `writeManagedSkills` (templates/vendor-runner/feeder.mjs), for the CLI: the server's answer is
|
|
50
|
+
* untrusted input to this box, so names and paths are checked, each skill is staged then renamed into place,
|
|
51
|
+
* a folder without the marker (the vendor's own) is never touched, and a managed skill no longer listed goes.
|
|
52
|
+
*/
|
|
53
|
+
export async function writeManagedSkills(skillsDir, skills) {
|
|
54
|
+
await mkdir(skillsDir, { recursive: true });
|
|
55
|
+
const written = new Set();
|
|
56
|
+
for (const skill of skills) {
|
|
57
|
+
const name = String(skill?.name ?? "");
|
|
58
|
+
const target = join(skillsDir, name);
|
|
59
|
+
if (!SKILL_NAME.test(name))
|
|
60
|
+
continue;
|
|
61
|
+
if (await lstat(target).then(() => true, () => false) && !(await isManaged(target)))
|
|
62
|
+
continue;
|
|
63
|
+
const files = (Array.isArray(skill.files) ? skill.files : []);
|
|
64
|
+
if (files.some((f) => typeof f?.path !== "string" || typeof f?.content !== "string" || isAbsolute(f.path)
|
|
65
|
+
|| normalize(f.path).split(sep).includes("..") || [".", MANAGED_MARK].includes(normalize(f.path))))
|
|
66
|
+
continue;
|
|
67
|
+
const staging = join(skillsDir, `.${name}.sync-${process.pid}`);
|
|
68
|
+
try {
|
|
69
|
+
await rm(staging, { recursive: true, force: true });
|
|
70
|
+
for (const f of files) {
|
|
71
|
+
await mkdir(dirname(join(staging, f.path)), { recursive: true });
|
|
72
|
+
await writeFile(join(staging, f.path), f.content);
|
|
73
|
+
}
|
|
74
|
+
await writeFile(join(staging, MANAGED_MARK), "Written by the AgentRadar runner; replaced on every sync.\n");
|
|
75
|
+
await rm(target, { recursive: true, force: true });
|
|
76
|
+
await rename(staging, target);
|
|
77
|
+
written.add(name);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
await rm(staging, { recursive: true, force: true });
|
|
81
|
+
if (await isManaged(target))
|
|
82
|
+
written.add(name);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const entry of await readdir(skillsDir)) {
|
|
86
|
+
if (!written.has(entry) && await isManaged(join(skillsDir, entry)))
|
|
87
|
+
await rm(join(skillsDir, entry), { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
return [...written];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* R650 — a joined agent's AgentRadar skills (`my_skills`: the bundled set plus the ones linked to it), written
|
|
93
|
+
* at join into the directory its CLI reads, the feeder's `skillsDirFor`: Claude `$AR_BASE/.claude/skills`,
|
|
94
|
+
* Codex `$CODEX_HOME/skills`. Done here because a session that acts only through `agentradar run` has no
|
|
95
|
+
* feeder; where the service runs, its feeder keeps them current.
|
|
96
|
+
*/
|
|
97
|
+
export async function syncJoinedSkills(runnerId, deps) {
|
|
98
|
+
const env = await readFile(join(deps.home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
|
|
99
|
+
const root = envValue(env, "AR_AGENT_HARNESS") === "codex" ? envValue(env, "CODEX_HOME") : envValue(env, "AR_BASE");
|
|
100
|
+
if (!root)
|
|
101
|
+
return;
|
|
102
|
+
const skillsDir = envValue(env, "AR_AGENT_HARNESS") === "codex" ? join(root, "skills") : join(root, ".claude", "skills");
|
|
103
|
+
const body = await awpRequest(runnerId, "tools/call", { name: "my_skills", arguments: {} }, { home: deps.home, fetch: deps.fetch, logError: deps.log });
|
|
104
|
+
const text = body?.result?.content?.find((c) => c.type === "text")?.text;
|
|
105
|
+
const answer = (text ? JSON.parse(text) : {});
|
|
106
|
+
if (!answer.ok || !Array.isArray(answer.skills))
|
|
107
|
+
throw new Error("AgentRadar did not return your skills");
|
|
108
|
+
const written = await writeManagedSkills(skillsDir, answer.skills);
|
|
109
|
+
deps.log(`Installed ${written.length} AgentRadar skill(s) in ${skillsDir}.`);
|
|
110
|
+
}
|
|
44
111
|
//# sourceMappingURL=skill-install.js.map
|
package/dist/tool-run.js
CHANGED
|
@@ -203,7 +203,7 @@ export async function callAwpTool(runnerId, name, toolArguments, deps) {
|
|
|
203
203
|
}
|
|
204
204
|
const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
205
205
|
/** One JSON-RPC request on the runner's AWP MCP endpoint with the runner's own key; null after logging why it failed. */
|
|
206
|
-
async function awpRequest(runnerId, method, params, deps) {
|
|
206
|
+
export async function awpRequest(runnerId, method, params, deps) {
|
|
207
207
|
const logError = deps.logError ?? console.error;
|
|
208
208
|
if (!RUNNER_ID.test(runnerId)) {
|
|
209
209
|
logError("Invalid runner ID");
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { installCli } from "./setup.js";
|
|
8
|
+
const LATEST = "https://registry.npmjs.org/agentradar-cli/latest";
|
|
9
|
+
const TARBALL = /^https:\/\/registry\.npmjs\.org\/agentradar-cli\/-\/agentradar-cli-\d+\.\d+\.\d+\.tgz$/;
|
|
10
|
+
const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
11
|
+
/** This package's folder: the CLI, the runner scripts and the agent-guard plugin all live in it. */
|
|
12
|
+
export const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
13
|
+
/** A service's copy, made by `agentradar join` at ~/.agentradar/cli/<version>; the service runs ~/.agentradar/cli/current. */
|
|
14
|
+
const serviceCopy = (root) => basename(dirname(root)) === "cli" && basename(dirname(dirname(root))) === ".agentradar";
|
|
15
|
+
/** The version this process runs, whatever `current` points at now. */
|
|
16
|
+
export const runningVersion = async () => JSON.parse(await readFile(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
|
|
17
|
+
/** The version on disk: for a service's copy, the one `current` points at, which another runner may have moved. */
|
|
18
|
+
export const installedVersion = async (root = PACKAGE_ROOT) => JSON.parse(await readFile(join(serviceCopy(root) ? join(dirname(root), "current") : root, "package.json"), "utf8")).version;
|
|
19
|
+
const newer = (a, b) => {
|
|
20
|
+
const [x, y] = [VERSION.exec(a), VERSION.exec(b)];
|
|
21
|
+
if (!x || !y)
|
|
22
|
+
return false;
|
|
23
|
+
for (let i = 1; i <= 3; i++)
|
|
24
|
+
if (Number(x[i]) !== Number(y[i]))
|
|
25
|
+
return Number(x[i]) > Number(y[i]);
|
|
26
|
+
return false;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Replaces this package in place with npm's latest release, when that is newer. Returns the version it
|
|
30
|
+
* installed, or null when nothing changed. Only a copy npm installed (inside `node_modules`) is touched,
|
|
31
|
+
* never a source checkout. The tarball must match the registry's sha512 before anything is unpacked, and
|
|
32
|
+
* the running folder is swapped by rename, so the service's paths stay valid and a failed update leaves
|
|
33
|
+
* the old copy in place.
|
|
34
|
+
*/
|
|
35
|
+
export async function selfUpdate(deps = {}) {
|
|
36
|
+
const root = deps.root ?? PACKAGE_ROOT;
|
|
37
|
+
if (!serviceCopy(root) && basename(dirname(root)) !== "node_modules")
|
|
38
|
+
return null;
|
|
39
|
+
const current = await installedVersion(root);
|
|
40
|
+
const get = (url) => (deps.fetch ?? fetch)(url, { signal: AbortSignal.timeout(60_000) });
|
|
41
|
+
const response = await get(LATEST);
|
|
42
|
+
if (!response.ok)
|
|
43
|
+
throw new Error(`npm registry answered ${response.status}`);
|
|
44
|
+
const latest = await response.json();
|
|
45
|
+
const version = latest.version ?? "";
|
|
46
|
+
if (!newer(version, current))
|
|
47
|
+
return null;
|
|
48
|
+
const tarballUrl = latest.dist?.tarball ?? "";
|
|
49
|
+
const integrity = latest.dist?.integrity ?? "";
|
|
50
|
+
if (!TARBALL.test(tarballUrl) || !tarballUrl.endsWith(`-${version}.tgz`) || !integrity.startsWith("sha512-")) {
|
|
51
|
+
throw new Error(`npm registry returned an unexpected release for ${version}`);
|
|
52
|
+
}
|
|
53
|
+
const download = await get(tarballUrl);
|
|
54
|
+
if (!download.ok)
|
|
55
|
+
throw new Error(`download of ${version} answered ${download.status}`);
|
|
56
|
+
const bytes = Buffer.from(await download.arrayBuffer());
|
|
57
|
+
if (`sha512-${createHash("sha512").update(bytes).digest("base64")}` !== integrity) {
|
|
58
|
+
throw new Error(`download of ${version} does not match the registry's checksum`);
|
|
59
|
+
}
|
|
60
|
+
// Unpacked beside the package, so the swap is a rename on one filesystem.
|
|
61
|
+
const work = await mkdtemp(join(dirname(root), ".agentradar-cli-update-"));
|
|
62
|
+
try {
|
|
63
|
+
const tarball = join(work, "package.tgz");
|
|
64
|
+
const next = join(work, "package");
|
|
65
|
+
await writeFile(tarball, bytes);
|
|
66
|
+
await mkdir(next);
|
|
67
|
+
await (deps.untar ?? ((file, into) => promisify(execFile)("tar", ["-xzf", file, "-C", into, "--strip-components=1"])))(tarball, next);
|
|
68
|
+
const manifest = JSON.parse(await readFile(join(next, "package.json"), "utf8"));
|
|
69
|
+
if (manifest.name !== "agentradar-cli" || manifest.version !== version)
|
|
70
|
+
throw new Error(`unpacked package is not agentradar-cli ${version}`);
|
|
71
|
+
// Unpacking installs no dependencies; a release that needs some has to go through npm.
|
|
72
|
+
if (Object.keys(manifest.dependencies ?? {}).length)
|
|
73
|
+
throw new Error(`agentradar-cli ${version} has dependencies; update it with npm`);
|
|
74
|
+
// A service's copy gets a new version folder and `current` moves to it; the running one stays untouched.
|
|
75
|
+
if (serviceCopy(root)) {
|
|
76
|
+
await installCli(dirname(dirname(dirname(root))), next);
|
|
77
|
+
return version;
|
|
78
|
+
}
|
|
79
|
+
const old = join(work, "old");
|
|
80
|
+
await rename(root, old);
|
|
81
|
+
try {
|
|
82
|
+
await rename(next, root);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
await rename(old, root);
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await rm(work, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
return version;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=update.js.map
|
|
@@ -314,7 +314,23 @@ export function runnerState(auth = agentAuthState, sample = capturePane) {
|
|
|
314
314
|
const authState = auth();
|
|
315
315
|
if (authState === "unauthenticated") return "auth_required";
|
|
316
316
|
if (authState !== "authenticated") return "starting";
|
|
317
|
-
|
|
317
|
+
const pane = sample();
|
|
318
|
+
if (pane?.rateLimited) return "rate_limited";
|
|
319
|
+
return pane?.inputReady ? "ready" : "starting";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* M23 — the CLI's own usage-limit message near the bottom of the pane. Measured in the binaries, not
|
|
324
|
+
* guessed: Claude Code 2.1.281 prints "Usage limit reached · …", "You've hit your monthly spend limit",
|
|
325
|
+
* "you have reached your weekly usage limit"; codex-cli 0.149.1 prints "You've hit your usage limit." and
|
|
326
|
+
* "Usage limit reached. You've reached your usage limit.".
|
|
327
|
+
*
|
|
328
|
+
* ponytail: the transcript tail is scanned too, so an agent quoting the phrase in its last lines reads as
|
|
329
|
+
* limited until it scrolls away. Anchor on the status line only if that is observed on a real runner.
|
|
330
|
+
*/
|
|
331
|
+
export function paneRateLimited(text) {
|
|
332
|
+
const tail = String(text ?? "").split("\n").slice(-12).join("\n");
|
|
333
|
+
return /usage limit reached|you['’]ve hit your (?:\w+ )?(?:usage|spend) limit|you['’]?(?:ve| have) reached your (?:\w+ )?usage limit/i.test(tail);
|
|
318
334
|
}
|
|
319
335
|
|
|
320
336
|
// --------------------------------------------------------------------------
|
|
@@ -359,7 +375,10 @@ async function injectFrame(frame, label) {
|
|
|
359
375
|
return false;
|
|
360
376
|
}
|
|
361
377
|
const [setCmd, pasteCmd, enterCmd] = sendKeysCommands(TMUX_SESSION, frame);
|
|
362
|
-
const
|
|
378
|
+
const okSet = await tmux(setCmd);
|
|
379
|
+
const okText = okSet && (await tmux(pasteCmd));
|
|
380
|
+
// paste-buffer -d frees the buffer only on success; never leave client text sitting in tmux.
|
|
381
|
+
if (okSet && !okText) await tmux(["delete-buffer", "-b", setCmd[2]]);
|
|
363
382
|
await sleep(KEY_SETTLE_MS);
|
|
364
383
|
const okEnter = okText && (await tmux(enterCmd));
|
|
365
384
|
if (okEnter) console.log(`[feeder] injected ${label}`);
|
|
@@ -580,7 +599,7 @@ function capturePane() {
|
|
|
580
599
|
// build after several turns, which falsely demoted a healthy pane to `starting` and stopped next_event
|
|
581
600
|
// from draining until its heartbeat aged stale.
|
|
582
601
|
const inputReady = paneInputReady(text);
|
|
583
|
-
return { hash, inProgress, inputReady, channelGate: classifyChannelBanner(text) };
|
|
602
|
+
return { hash, inProgress, inputReady, rateLimited: paneRateLimited(text), channelGate: classifyChannelBanner(text) };
|
|
584
603
|
}
|
|
585
604
|
/**
|
|
586
605
|
* #173b (disc obs) — format-drift detector. If the STRIPPED fingerprint keeps CHANGING while the pane
|
|
@@ -21,6 +21,8 @@ Type=simple
|
|
|
21
21
|
# The runner reads ~/.agentradar/agents/%i.env.
|
|
22
22
|
# systemd --user starts with a bare PATH; the agent CLI must resolve as it did for the installing user.
|
|
23
23
|
Environment="PATH=__PATH__"
|
|
24
|
+
# Tells the runner systemd restarts it, so it may exit to pick up a CLI update.
|
|
25
|
+
Environment="AGENTRADAR_SERVICE=1"
|
|
24
26
|
ExecStart=__RUNNER__ %i
|
|
25
27
|
Restart=always
|
|
26
28
|
RestartSec=15
|
|
@@ -45,7 +45,10 @@
|
|
|
45
45
|
<key>HOME</key>
|
|
46
46
|
<string>__HOME__</string>
|
|
47
47
|
<key>PATH</key>
|
|
48
|
-
<string>__PATH__</string>
|
|
48
|
+
<string>__PATH__</string>
|
|
49
|
+
<!-- Tells the runner launchd restarts it, so it may exit to pick up a CLI update. -->
|
|
50
|
+
<key>AGENTRADAR_SERVICE</key>
|
|
51
|
+
<string>1</string>__EXTRA_ENV__
|
|
49
52
|
</dict>
|
|
50
53
|
|
|
51
54
|
<key>WorkingDirectory</key>
|
|
@@ -138,6 +138,8 @@ else
|
|
|
138
138
|
# SC2086: CLAUDE_EXTRA_FLAGS is intentionally UNQUOTED so "--chrome" (etc.) word-splits into flags.
|
|
139
139
|
# shellcheck disable=SC2086
|
|
140
140
|
_launch_flags=( ${CLAUDE_EXTRA_FLAGS} --strict-mcp-config --mcp-config="${MCP_CONFIG}" )
|
|
141
|
+
# `agentradar join` enables the agent-guard plugin for this session only, through a settings file.
|
|
142
|
+
[ -n "${CLAUDE_SETTINGS:-}" ] && _launch_flags+=( --settings "${CLAUDE_SETTINGS}" )
|
|
141
143
|
# Pane-alive predicate — MUST be unique per agent. Default to the FULL mcp-config path (each agent's
|
|
142
144
|
# differs by directory), NOT the basename: on a multi-agent box every agent's config is named
|
|
143
145
|
# `awp-only.json`, so a basename match would find ANOTHER agent's live claude and mask THIS agent's
|
|
@@ -169,7 +171,7 @@ if [ -n "${AGENT_TOOL_ENV_FILE}" ]; then
|
|
|
169
171
|
# identity). Reject at parse time (visible warning) AND — belt-and-suspenders — the launch lists
|
|
170
172
|
# the reserved allowlist AFTER TOOL_ENV_ARGS so `env` last-wins keeps reserved vars authoritative.
|
|
171
173
|
case "${_tline%%=*}" in
|
|
172
|
-
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)
|
|
174
|
+
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_CLI_RUNNER_ID|AR_AGENTRADAR_BIN|AR_DISCIPLINE_EVERY|CLAUDE_CODE_PLUGIN_CACHE_DIR|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)
|
|
173
175
|
log "WARN ignoring reserved key '${_tline%%=*}' in ${AGENT_TOOL_ENV_FILE} (cannot override supervisor/agent env)"; continue ;;
|
|
174
176
|
esac
|
|
175
177
|
TOOL_ENV_ARGS+=("${_tline}")
|
|
@@ -186,6 +188,10 @@ fi
|
|
|
186
188
|
unset CLAUDE_CODE_OAUTH_TOKEN
|
|
187
189
|
_claude_cfg=()
|
|
188
190
|
[ -n "${CLAUDE_CONFIG_DIR:-}" ] && _claude_cfg=( CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR}" )
|
|
191
|
+
# agent-guard (templates/agent-guard): its hooks ask the server what this runner still owes through the CLI.
|
|
192
|
+
for _k in AR_CLI_RUNNER_ID AR_AGENTRADAR_BIN AR_DISCIPLINE_EVERY CLAUDE_CODE_PLUGIN_CACHE_DIR; do
|
|
193
|
+
[ -n "${!_k:-}" ] && _claude_cfg+=( "${_k}=${!_k}" )
|
|
194
|
+
done
|
|
189
195
|
|
|
190
196
|
# HARD RULE — OAuth only, never a pay-as-you-go API key. Scoped to the Claude harness because that is where
|
|
191
197
|
# the variable decides anything: `claude` prefers ANTHROPIC_API_KEY over the subscription session, so its
|
|
@@ -345,7 +351,7 @@ ensure_agent() {
|
|
|
345
351
|
CODEX_HOME="${CODEX_HOME:-}" AR_RESUME_FLAGS="${AR_RESUME_FLAGS}" AR_FRESH_FLAGS="${AR_FRESH_FLAGS}" \
|
|
346
352
|
DISABLE_AUTOUPDATER=1 CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF=1 \
|
|
347
353
|
${_claude_cfg[@]+"${_claude_cfg[@]}"} ${_tool_env[@]+"${_tool_env[@]}"} \
|
|
348
|
-
/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[@]}" || {
|
|
354
|
+
/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_CLI_RUNNER_ID|AR_AGENTRADAR_BIN|AR_DISCIPLINE_EVERY|CLAUDE_CODE_PLUGIN_CACHE_DIR|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[@]}" || {
|
|
349
355
|
log "tmux could not start agent session '${TMUX_SESSION}'"
|
|
350
356
|
return 1
|
|
351
357
|
}
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Opens a persistent OUTBOUND WebSocket to the AgentRadar server (the mini INITIATES; the server never
|
|
4
4
|
// connects inbound — phone-home preserved) and bridges the agent's tmux pane:
|
|
5
|
-
// - server {attach} →
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// -
|
|
5
|
+
// - server {attach} → attach a read-only tmux CONTROL-MODE client (`tmux -C attach -r`) → stream the pane's
|
|
6
|
+
// `%output` as {output} frames, after a scrollback snapshot (`capture-pane -S -2000`) so a
|
|
7
|
+
// late viewer can scroll back and lands on the current screen.
|
|
8
|
+
// - server {detach} → kill the control client (idle agents cost nothing).
|
|
9
|
+
// - pane GEOMETRY → {hello cols,rows} on connect + {geometry cols,rows} on each `%layout-change` (#9).
|
|
9
10
|
// The server has no other way to know the pane size, and without it the client
|
|
10
11
|
// watch-terminal renders every agent at a hardcoded 120×30.
|
|
11
12
|
// - server {input} → `tmux send-keys -l` (VENDOR keystrokes; the server already dropped client input).
|
|
@@ -26,22 +27,20 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
26
27
|
import fs from "node:fs";
|
|
27
28
|
import http from "node:http";
|
|
28
29
|
import https from "node:https";
|
|
29
|
-
import
|
|
30
|
-
import path from "node:path";
|
|
30
|
+
import { StringDecoder } from "node:string_decoder";
|
|
31
31
|
|
|
32
32
|
const AWP_MCP_ENDPOINT = process.env.AWP_MCP_URL ?? "https://staging.agentradar.com/api/mcp";
|
|
33
|
-
|
|
33
|
+
// Re-read on every connect: a hosted supervisor rewrites the file when it refreshes the run token.
|
|
34
|
+
export function readBearer() {
|
|
34
35
|
const f = process.env.AWP_BEARER_FILE;
|
|
35
|
-
if (f)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
36
|
+
if (!f) return process.env.AWP_BEARER ?? "";
|
|
37
|
+
try {
|
|
38
|
+
return fs.readFileSync(f, "utf8").trim();
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.error(`[bridge] AWP_BEARER_FILE unreadable (${f}): ${e.message}`);
|
|
41
|
+
return "";
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
})();
|
|
43
|
+
}
|
|
45
44
|
const TMUX_SESSION = process.env.TMUX_SESSION ?? "agent";
|
|
46
45
|
// Resolve the tmux binary robustly: a bare "tmux" only works if it's on PATH, but the bridge is often
|
|
47
46
|
// launched from a supervisor / LaunchAgent whose PATH excludes e.g. /opt/homebrew/bin — then every tmux
|
|
@@ -67,11 +66,7 @@ const RECONNECT_MAX_MS = Number(process.env.BRIDGE_RECONNECT_MAX_MS ?? "15000");
|
|
|
67
66
|
// force-closed it — i.e. a ~5 min live-terminal blackout on EVERY deploy. Watch for ping silence ourselves so
|
|
68
67
|
// recovery is ours, not the load balancer's.
|
|
69
68
|
const SERVER_PING_TIMEOUT_MS = Number(process.env.BRIDGE_PING_TIMEOUT_MS ?? "90000"); // 3× the 30s server ping
|
|
70
|
-
|
|
71
|
-
// only way to notice a pane change is to ask. The poll runs only between attach and detach, so an unwatched
|
|
72
|
-
// agent still costs nothing, and a frame is sent only when the size actually CHANGED — an unchanged read is
|
|
73
|
-
// one cheap tmux call and no traffic.
|
|
74
|
-
const GEOMETRY_POLL_MS = Number(process.env.BRIDGE_GEOMETRY_POLL_MS ?? "5000");
|
|
69
|
+
const SNAPSHOT_LINES = 2000;
|
|
75
70
|
|
|
76
71
|
/**
|
|
77
72
|
* #18: run `fn` at most once, however many times the returned function is called.
|
|
@@ -206,12 +201,37 @@ export function parsePaneGeometry(stdout) {
|
|
|
206
201
|
return { cols, rows };
|
|
207
202
|
}
|
|
208
203
|
|
|
204
|
+
/**
|
|
205
|
+
* One line of tmux control-mode output → `{ type: "output", pane, data: Buffer }`, `{ type: "layout" }`,
|
|
206
|
+
* `{ type: "exit" }`, or null for everything else. `%output` data has bytes below 0x20 and `\` octal-escaped
|
|
207
|
+
* (`\015`); other bytes, UTF-8 included, pass through raw, so the line is handled as bytes (latin1), never text.
|
|
208
|
+
*/
|
|
209
|
+
export function parseControlLine(line) {
|
|
210
|
+
const s = Buffer.isBuffer(line) ? line.toString("latin1") : line;
|
|
211
|
+
if (s.startsWith("%output ")) {
|
|
212
|
+
const sp = s.indexOf(" ", 8);
|
|
213
|
+
if (sp < 0) return null;
|
|
214
|
+
const data = s.slice(sp + 1).replace(/\\([0-7]{3})/g, (_, o) => String.fromCharCode(parseInt(o, 8)));
|
|
215
|
+
return { type: "output", pane: s.slice(8, sp), data: Buffer.from(data, "latin1") };
|
|
216
|
+
}
|
|
217
|
+
if (s.startsWith("%layout-change ")) return { type: "layout" };
|
|
218
|
+
if (s.startsWith("%exit")) return { type: "exit" };
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The scrollback snapshot as terminal text: history + visible screen, then the cursor put back where the pane
|
|
224
|
+
* has it, so live output that follows (relative cursor moves) lands in the right place.
|
|
225
|
+
*/
|
|
226
|
+
export function snapshotText(captured, cursor) {
|
|
227
|
+
const body = captured.replace(/\n$/, "");
|
|
228
|
+
return cursor ? `${body}\x1b[${cursor.y + 1};${cursor.x + 1}H` : body;
|
|
229
|
+
}
|
|
230
|
+
|
|
209
231
|
class TerminalBridge {
|
|
210
232
|
constructor() {
|
|
211
233
|
this.sock = null;
|
|
212
|
-
this.
|
|
213
|
-
this.fifoPath = null;
|
|
214
|
-
this.fifoStream = null;
|
|
234
|
+
this.control = null; // the read-only tmux control-mode client while a viewer is attached
|
|
215
235
|
this.recvBuf = Buffer.alloc(0);
|
|
216
236
|
this.backoff = RECONNECT_MIN_MS;
|
|
217
237
|
this.pingTimer = null;
|
|
@@ -219,7 +239,6 @@ class TerminalBridge {
|
|
|
219
239
|
// Reset to null on every (re)connect — a reconnect may land on a different server task with no memory
|
|
220
240
|
// of this session, so the new peer must be told even if the pane never moved.
|
|
221
241
|
this.geometry = null;
|
|
222
|
-
this.geometryTimer = null;
|
|
223
242
|
}
|
|
224
243
|
|
|
225
244
|
/**
|
|
@@ -275,57 +294,52 @@ class TerminalBridge {
|
|
|
275
294
|
this.send({ type: "geometry", cols: g.cols, rows: g.rows });
|
|
276
295
|
}
|
|
277
296
|
|
|
278
|
-
startGeometryPoll() {
|
|
279
|
-
if (this.geometryTimer) return;
|
|
280
|
-
this.geometryTimer = setInterval(() => this.reportGeometry(), GEOMETRY_POLL_MS);
|
|
281
|
-
if (typeof this.geometryTimer.unref === "function") this.geometryTimer.unref();
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
stopGeometryPoll() {
|
|
285
|
-
if (!this.geometryTimer) return;
|
|
286
|
-
clearInterval(this.geometryTimer);
|
|
287
|
-
this.geometryTimer = null;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
297
|
startPipe() {
|
|
291
|
-
if (this.
|
|
292
|
-
this.piping = true;
|
|
298
|
+
if (this.control) return;
|
|
293
299
|
// #9: a viewer just attached — publish the real pane size before the first bytes, so the client sizes
|
|
294
300
|
// its grid correctly rather than painting the replay into a fallback grid and re-flowing a moment later.
|
|
295
301
|
this.reportGeometry(true);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const cap = tmux(["capture-pane", "-
|
|
299
|
-
if (cap.status === 0 && cap.stdout)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
302
|
+
const pane = tmux(["display-message", "-p", "-t", TMUX_SESSION, "#{pane_id} #{cursor_x} #{cursor_y}"]);
|
|
303
|
+
const [paneId, cx, cy] = pane.status === 0 ? pane.stdout.trim().split(" ") : [];
|
|
304
|
+
const cap = tmux(["capture-pane", "-p", "-e", "-J", "-S", `-${SNAPSHOT_LINES}`, "-t", TMUX_SESSION]);
|
|
305
|
+
if (cap.status === 0 && cap.stdout) {
|
|
306
|
+
const cursor = cx !== undefined && cy !== undefined ? { x: Number(cx), y: Number(cy) } : null;
|
|
307
|
+
this.send({ type: "output", data: snapshotText(cap.stdout, cursor) });
|
|
308
|
+
}
|
|
309
|
+
// Read-only + ignore-size: the bridge can neither type into nor shrink the vendor's pane.
|
|
310
|
+
const child = spawn(TMUX_BIN, ["-C", "attach-session", "-r", "-t", TMUX_SESSION], {
|
|
311
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
312
|
+
});
|
|
313
|
+
this.control = child;
|
|
314
|
+
const decoder = new StringDecoder("utf8"); // a UTF-8 character can straddle two %output lines
|
|
315
|
+
let buf = Buffer.alloc(0);
|
|
316
|
+
child.stdout.on("data", (chunk) => {
|
|
317
|
+
buf = Buffer.concat([buf, chunk]);
|
|
318
|
+
let nl;
|
|
319
|
+
while ((nl = buf.indexOf(0x0a)) >= 0) {
|
|
320
|
+
const msg = parseControlLine(buf.subarray(0, nl));
|
|
321
|
+
buf = buf.subarray(nl + 1);
|
|
322
|
+
if (msg?.type === "output" && (!paneId || msg.pane === paneId)) {
|
|
323
|
+
const text = decoder.write(msg.data);
|
|
324
|
+
if (text) this.send({ type: "output", data: text });
|
|
325
|
+
} else if (msg?.type === "layout") this.reportGeometry();
|
|
326
|
+
else if (msg?.type === "exit") this.stopPipe();
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
child.on("error", () => {});
|
|
330
|
+
child.on("exit", () => {
|
|
331
|
+
if (this.control === child) this.control = null;
|
|
332
|
+
});
|
|
307
333
|
}
|
|
308
334
|
|
|
309
335
|
stopPipe() {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
this.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
} catch {
|
|
318
|
-
/* ignore */
|
|
319
|
-
}
|
|
320
|
-
this.fifoStream = null;
|
|
321
|
-
}
|
|
322
|
-
if (this.fifoPath) {
|
|
323
|
-
try {
|
|
324
|
-
fs.unlinkSync(this.fifoPath);
|
|
325
|
-
} catch {
|
|
326
|
-
/* ignore */
|
|
327
|
-
}
|
|
328
|
-
this.fifoPath = null;
|
|
336
|
+
const child = this.control;
|
|
337
|
+
if (!child) return;
|
|
338
|
+
this.control = null;
|
|
339
|
+
try {
|
|
340
|
+
child.kill();
|
|
341
|
+
} catch {
|
|
342
|
+
/* already gone */
|
|
329
343
|
}
|
|
330
344
|
}
|
|
331
345
|
|
|
@@ -355,6 +369,8 @@ class TerminalBridge {
|
|
|
355
369
|
}
|
|
356
370
|
|
|
357
371
|
connect() {
|
|
372
|
+
const bearer = readBearer();
|
|
373
|
+
if (!bearer) return this.scheduleReconnect();
|
|
358
374
|
const url = feederWsUrl();
|
|
359
375
|
const key = randomBytes(16).toString("base64");
|
|
360
376
|
const lib = url.protocol === "wss:" ? https : http;
|
|
@@ -369,7 +385,7 @@ class TerminalBridge {
|
|
|
369
385
|
"Sec-WebSocket-Version": "13",
|
|
370
386
|
"Sec-WebSocket-Key": key,
|
|
371
387
|
// Auth rides the subprotocol (never a query param); NEVER logged.
|
|
372
|
-
"Sec-WebSocket-Protocol": `agentradar-feeder, ${
|
|
388
|
+
"Sec-WebSocket-Protocol": `agentradar-feeder, ${bearer}`,
|
|
373
389
|
},
|
|
374
390
|
});
|
|
375
391
|
req.on("upgrade", (res, socket) => {
|
|
@@ -459,7 +475,7 @@ class TerminalBridge {
|
|
|
459
475
|
|
|
460
476
|
// Only auto-run when executed directly (not when imported by the unit test).
|
|
461
477
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
462
|
-
if (!
|
|
478
|
+
if (!readBearer()) {
|
|
463
479
|
console.error("[bridge] FATAL: no AWP bearer (set AWP_BEARER_FILE or AWP_BEARER)");
|
|
464
480
|
process.exit(1);
|
|
465
481
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentradar-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"engines": {
|
|
5
|
+
"node": ">=20"
|
|
6
|
+
},
|
|
4
7
|
"description": "Secure AgentRadar vendor setup and skill installer",
|
|
5
8
|
"type": "module",
|
|
6
9
|
"bin": {
|
|
@@ -14,8 +17,10 @@
|
|
|
14
17
|
"dist/skill-install.js",
|
|
15
18
|
"dist/tool-run.js",
|
|
16
19
|
"dist/sync.js",
|
|
20
|
+
"dist/update.js",
|
|
17
21
|
"dist/vendor-runner/**",
|
|
18
22
|
"dist/agentradar-vendor/SKILL.md",
|
|
23
|
+
"dist/agent-guard",
|
|
19
24
|
"README.md"
|
|
20
25
|
],
|
|
21
26
|
"license": "MIT",
|
|
@@ -23,7 +28,7 @@
|
|
|
23
28
|
"access": "public"
|
|
24
29
|
},
|
|
25
30
|
"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 ../../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",
|
|
31
|
+
"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/ && rm -rf dist/agent-guard && cp -R ../../templates/agent-guard dist/agent-guard && rm dist/agent-guard/scripts/guard.test.mjs && 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
32
|
"clean": "rm -rf dist",
|
|
28
33
|
"prepack": "pnpm run build",
|
|
29
34
|
"typecheck": "tsc --noEmit",
|