agentradar-cli 0.3.9 → 0.3.10
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/admin.js +27 -7
- package/dist/agent-guard/scripts/compaction-report.mjs +13 -7
- package/dist/agent-guard/scripts/guard.mjs +9 -7
- package/dist/agent-guard/scripts/model-switch.mjs +1 -1
- package/dist/agent-guard/scripts/rm-guard.mjs +76 -4
- package/dist/agent-guard/skills/agentradar-discipline/SKILL.md +10 -6
- package/dist/channel.js +94 -28
- package/dist/github.js +209 -0
- package/dist/index.js +31 -11
- package/dist/setup.js +26 -0
- package/dist/skill-install.js +13 -23
- package/dist/tool-run.js +193 -54
- package/dist/vendor-runner/feeder.mjs +53 -12
- package/dist/vendor-runner/session-frame.mjs +72 -37
- package/dist/vendor-runner/supervisor/supervise-agent.sh +6 -1
- package/package.json +3 -2
package/dist/admin.js
CHANGED
|
@@ -1,21 +1,29 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
/**
|
|
2
|
-
* ADMIN-BROADCAST (Vadym 12310/12828): `agentradar admin broadcast` for an instance admin. Authenticates with a board
|
|
3
|
+
* ADMIN-BROADCAST (Vadym 12310/12828): `agentradar admin broadcast` for an instance admin; REPORTER-NOTICE-ROUTE: `admin notice` to one agent. Authenticates with a board
|
|
3
4
|
* API key in AGENTRADAR_ADMIN_TOKEN, never a runner key; the server refuses anyone who is not an instance admin.
|
|
4
5
|
*/
|
|
5
6
|
export const ADMIN_BROADCAST_USAGE = "Usage: agentradar admin broadcast --dry-run|--send --target all --text <text> --cta <next step>"
|
|
6
7
|
+ " (env AGENTRADAR_ADMIN_TOKEN = an instance admin's board API key; AGENTRADAR_API_URL = the API base)";
|
|
8
|
+
export const ADMIN_NOTICE_USAGE = "Usage: agentradar admin notice --dry-run|--send --agent <id> --ref <key> --file <text file> --cta <next step, naming the tool; a retry must say to check first> [--wake]"
|
|
9
|
+
+ " (a notice is delivered once per --ref; --wake also wakes the agent; same env as broadcast)";
|
|
7
10
|
export async function adminOverCli(args, deps = {}) {
|
|
8
11
|
const env = deps.env ?? process.env, log = deps.log ?? console.log, logError = deps.logError ?? console.error;
|
|
12
|
+
const sub = args[0], notice = sub === "notice";
|
|
9
13
|
const named = {};
|
|
10
14
|
const modes = args.slice(1).filter((arg) => arg === "--dry-run" || arg === "--send");
|
|
15
|
+
let wake = false;
|
|
11
16
|
for (let i = 1; i < args.length; i++) {
|
|
12
|
-
if (["--target", "--text", "--cta"].includes(args[i]))
|
|
17
|
+
if ((notice ? ["--agent", "--ref", "--file", "--cta"] : ["--target", "--text", "--cta"]).includes(args[i]))
|
|
13
18
|
named[args[i].slice(2)] = args[++i] ?? "";
|
|
19
|
+
else if (notice && args[i] === "--wake")
|
|
20
|
+
wake = true;
|
|
14
21
|
else if (!modes.includes(args[i]))
|
|
15
22
|
modes.push("?");
|
|
16
23
|
}
|
|
17
|
-
|
|
18
|
-
|
|
24
|
+
const complete = notice ? named.agent && named.ref && named.file && named.cta : named.target === "all" && named.text && named.cta;
|
|
25
|
+
if ((sub !== "broadcast" && !notice) || modes.length !== 1 || modes[0] === "?" || !complete) {
|
|
26
|
+
logError(notice ? ADMIN_NOTICE_USAGE : ADMIN_BROADCAST_USAGE);
|
|
19
27
|
return 2;
|
|
20
28
|
}
|
|
21
29
|
const token = env.AGENTRADAR_ADMIN_TOKEN?.trim();
|
|
@@ -24,9 +32,21 @@ export async function adminOverCli(args, deps = {}) {
|
|
|
24
32
|
return 2;
|
|
25
33
|
}
|
|
26
34
|
const base = (env.AGENTRADAR_API_URL ?? "https://vendor.agentradar.com/api").replace(/\/+$/, "");
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
const dryRun = modes[0] === "--dry-run";
|
|
36
|
+
let body;
|
|
37
|
+
if (notice) {
|
|
38
|
+
try {
|
|
39
|
+
body = { agentId: named.agent, ref: named.ref, text: (deps.readFile ?? ((f) => readFileSync(f, "utf8")))(named.file), cta: named.cta, wake, dryRun };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
logError(`Cannot read ${named.file}`);
|
|
43
|
+
return 2;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else
|
|
47
|
+
body = { target: "all", text: named.text, cta: named.cta, dryRun };
|
|
48
|
+
const res = await (deps.fetch ?? fetch)(`${base}/admin/${notice ? "agent-notice" : "agent-broadcast"}`, {
|
|
49
|
+
method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body),
|
|
30
50
|
});
|
|
31
51
|
const text = await res.text();
|
|
32
52
|
if (!res.ok) {
|
|
@@ -41,9 +41,11 @@ export async function reportCompaction(event, input, { send, followUp, stateDir
|
|
|
41
41
|
if (input?.hook_event_name !== event || !ID.test(input.session_id ?? "")) return;
|
|
42
42
|
const marker = join(stateDir, `agentradar-compaction-${input.session_id}.json`);
|
|
43
43
|
const trigger = typeof input.trigger === "string" ? input.trigger.slice(0, 40) : null;
|
|
44
|
+
// Every report names its session: several sessions of one agent compacting at once read as one loop without it.
|
|
45
|
+
const sessionId = input.session_id;
|
|
44
46
|
if (event === "PreCompact") {
|
|
45
47
|
writeFileSync(marker, JSON.stringify({ at: now() }), { mode: 0o600 });
|
|
46
|
-
return send({ phase: "start", trigger });
|
|
48
|
+
return send({ phase: "start", sessionId, trigger });
|
|
47
49
|
}
|
|
48
50
|
if (event !== "PostCompact") return;
|
|
49
51
|
let startedAt = now();
|
|
@@ -51,21 +53,25 @@ export async function reportCompaction(event, input, { send, followUp, stateDir
|
|
|
51
53
|
rmSync(marker, { force: true });
|
|
52
54
|
// The compact_boundary line (the only source of token counts) is written after this hook returns, so it is
|
|
53
55
|
// reported separately, by a detached follow-up; this report must not wait for it.
|
|
54
|
-
send({ phase: "end", outcome: "ok", trigger, durationMs: now() - startedAt });
|
|
55
|
-
return followUp?.({ transcriptPath: input.transcript_path, startedAt, trigger });
|
|
56
|
+
send({ phase: "end", outcome: "ok", sessionId, trigger, durationMs: now() - startedAt });
|
|
57
|
+
return followUp?.({ transcriptPath: input.transcript_path, startedAt, sessionId, trigger });
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Detached follow-up: waits up to `waitMs` for the boundary this compaction wrote, then reports its token counts. No
|
|
62
|
+
* boundary in time is reported too (boundary "not_found"), so a missing tokens row means the follow-up never ran.
|
|
63
|
+
*/
|
|
64
|
+
export async function reportTokens({ transcriptPath, startedAt, sessionId, trigger }, { send, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), waitMs = 60_000, pollMs = 500 }) {
|
|
60
65
|
for (let waited = 0; waited <= waitMs; waited += pollMs) {
|
|
61
66
|
const boundary = lastBoundary(transcriptPath, startedAt - 1_000);
|
|
62
|
-
if (boundary) return send({ phase: "tokens", trigger, ...boundary });
|
|
67
|
+
if (boundary) return send({ phase: "tokens", sessionId, trigger, ...boundary });
|
|
63
68
|
await sleep(pollMs);
|
|
64
69
|
}
|
|
70
|
+
return send({ phase: "tokens", sessionId, trigger, boundary: "not_found" });
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
const sendToServer = (report) => spawnSync(process.env.AR_AGENTRADAR_BIN || "agentradar",
|
|
68
|
-
["call", process.env.AR_CLI_RUNNER_ID || "hosted", "report_compaction", "--
|
|
74
|
+
["call", process.env.AR_CLI_RUNNER_ID || "hosted", "report_compaction", "--input", JSON.stringify(report)], { encoding: "utf8", timeout: 20_000 });
|
|
69
75
|
const spawnFollowUp = (job) => spawn(process.execPath, [fileURLToPath(import.meta.url), "tokens", JSON.stringify(job)], { detached: true, stdio: "ignore" }).unref();
|
|
70
76
|
|
|
71
77
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
@@ -38,7 +38,7 @@ const call = (tool) => `agentradar call ${runner} ${tool}`;
|
|
|
38
38
|
|
|
39
39
|
const RULES = [
|
|
40
40
|
"Answer every message in the channel it came from, and a human first: someone is waiting.",
|
|
41
|
-
`Acknowledge what you handled: ${call("acknowledge_messages")} --
|
|
41
|
+
`Acknowledge what you handled: ${call("acknowledge_messages")} --input '{"eventIds":[...]}' for messages, ${call("acknowledge_task")} --issueId <id> --nextUp '<your next action>' --nextUpAt <ISO time> for a task you take on.`,
|
|
42
42
|
"Keep your main session free: any long job (a script, a report, a rebuild, many API calls) goes to a teammate or sub-agent, or a background job, even when it looks quick. A human's \"how to work\" order holds for every later job.",
|
|
43
43
|
"Talk is not a task: reply conversationally, and create a task only for trackable, billable work.",
|
|
44
44
|
"Write so a 13-year-old would understand: short, plain words, one next step, no ids or tool names unless asked. Say it once.",
|
|
@@ -132,16 +132,18 @@ function stopReason() {
|
|
|
132
132
|
const working = Array.isArray(o.inProgressTasks) ? o.inProgressTasks : [];
|
|
133
133
|
const asks = Array.isArray(o.overdueAsks) ? o.overdueAsks : [];
|
|
134
134
|
const unread = Array.isArray(o.unreadTaskComments) ? o.unreadTaskComments : [];
|
|
135
|
+
const late = Array.isArray(o.lateNextUps) ? o.lateNextUps : [];
|
|
135
136
|
const name = (t) => `${t.identifier ?? t.issueId} "${t.title ?? ""}" (issueId ${t.issueId})`;
|
|
136
|
-
if (messages.length || tasks.length || asks.length || unread.length) {
|
|
137
|
+
if (messages.length || tasks.length || asks.length || unread.length || late.length) {
|
|
137
138
|
return [
|
|
138
|
-
`AgentRadar: you still owe ${messages.length + tasks.length + asks.length + unread.length} item(s). Do not go idle yet.`,
|
|
139
|
+
`AgentRadar: you still owe ${messages.length + tasks.length + asks.length + unread.length + late.length} item(s). Do not go idle yet.`,
|
|
139
140
|
...(messages.length ? [
|
|
140
|
-
`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")} --
|
|
141
|
+
`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")} --input '${JSON.stringify({ eventIds: messages })}'.`,
|
|
141
142
|
] : []),
|
|
142
|
-
...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}
|
|
143
|
+
...tasks.map((t) => `Task assigned to you, not acknowledged: ${name(t)}. Read it first with ${call("get_task")} --issueId ${t.issueId} and do what its description asks (its title alone is not the work). Take it on or say why not, then ${call("acknowledge_task")} --issueId ${t.issueId} --nextUp '<your next action>' --nextUpAt <ISO time>.`),
|
|
143
144
|
...unread.map((t) => `Comments on your task ${name(t)} you have not read: ${(t.commentIds ?? []).join(", ")}. Read them with ${call("get_task")} --issueId ${t.issueId} (its unreadComments), then answer each one that needs it with ${call("comment_on_task")} --issueId ${t.issueId} --replyToCommentId <its id> --body '<your answer>'.`),
|
|
144
|
-
...
|
|
145
|
+
...late.map((t) => `Late next up on your task ${name(t)}: ${JSON.stringify(t.nextUp ?? "")} was due ${t.nextUpAt}. Do it, then ${call("comment_on_task")} --issueId ${t.issueId} --body '<what you did>' --nextUp '<your next action>' --nextUpAt <ISO time>.`),
|
|
146
|
+
...asks.map((a) => `Nobody answered your ask in thread ${a.threadId} (due ${a.dueAt}${a.note ? `; ${a.note}` : ""}). Chase them in that thread and run ${call("expect_reply")} again with by set to when you will check back, or if it no longer matters ${call("expect_reply")} --input '${JSON.stringify({ conversationId: a.threadId, by: null })}'.`),
|
|
145
147
|
`Check again with ${call("agent_obligations")}, then stop.`,
|
|
146
148
|
].join("\n");
|
|
147
149
|
}
|
|
@@ -149,7 +151,7 @@ function stopReason() {
|
|
|
149
151
|
if (working.length && !input.stop_hook_active) {
|
|
150
152
|
return [
|
|
151
153
|
`AgentRadar: ${working.length} task(s) still in progress: ${working.map(name).join("; ")}.`,
|
|
152
|
-
|
|
154
|
+
`Keep working on them (${call("get_task")} --issueId <its issueId> shows what each asks), or, if one is waiting on someone, make sure its thread says so, then stop.`,
|
|
153
155
|
].join("\n");
|
|
154
156
|
}
|
|
155
157
|
return "";
|
|
@@ -201,7 +201,7 @@ function authorizeOnServer(resolved) {
|
|
|
201
201
|
const runner = process.env.AR_CLI_RUNNER_ID ?? "";
|
|
202
202
|
if (!runner) return false;
|
|
203
203
|
const run = spawnSync(process.env.AR_AGENTRADAR_BIN || "agentradar",
|
|
204
|
-
["call", runner, "authorize_model_switch", "--
|
|
204
|
+
["call", runner, "authorize_model_switch", "--input", JSON.stringify(resolved)], { encoding: "utf8", timeout: 20_000 });
|
|
205
205
|
try { return run.status === 0 && JSON.parse(run.stdout).allowed === true; } catch { return false; }
|
|
206
206
|
}
|
|
207
207
|
|
|
@@ -16,17 +16,89 @@ export const SCRIPT_REASON = "AgentRadar refused this command before it ran: it
|
|
|
16
16
|
export const QUOTED_REASON = "AgentRadar refused this command before it ran: a quoted string in it carries rm text on a variable or "
|
|
17
17
|
+ "$(...) path, and Claude Code stops to ask a person to approve even quoted rm text. Nobody is at your session to answer, so it "
|
|
18
18
|
+ "would hang. Nothing ran. Put fixtures in a file with your file-writing tool and pass them with `< case.json`, or leave the rm text out.";
|
|
19
|
+
// REV-PHANTOM-STOP-15775: Rev's subagent hung 10-27 on `cd <dir> ; rm -f sub/dir/* ...` (Claude Code 2.1.29x prompts:
|
|
20
|
+
// "changes directories before the removal, so the relative glob target cannot be statically resolved").
|
|
21
|
+
export const CD_GLOB_REASON = "AgentRadar refused this command before it ran: it changes directory and then removes a relative "
|
|
22
|
+
+ "wildcard path (like cd /x && rm -f sub/*), which Claude Code cannot resolve, so it would stop to ask a person to approve it. "
|
|
23
|
+
+ "Nobody is at your session to answer, so it would hang. Nothing ran. Run the rm on its own with the absolute path, "
|
|
24
|
+
+ "e.g. rm -f /x/sub/*, then run the rest.";
|
|
19
25
|
|
|
20
26
|
const RM = String.raw`(?<!git\s+)(?<![-\w])rm\s`;
|
|
27
|
+
// Shell words are looked for at ANY unquoted position (timeout 30 bash -c, sudo -u root bash -c, perl -e '...' sh -c), not
|
|
28
|
+
// only at command position: a miss leaves an rm for Claude Code to stop on, a hit only costs a rewrite.
|
|
29
|
+
const SH = String.raw`(?:^|[\s;&|(])(?:\S*\/)?(?:ba|z|da|k)?sh(?=\s|$)`;
|
|
30
|
+
const isScript = (un) => new RegExp(`${SH}[^\\n]*?\\s-[a-z]*c(?:\\s|$)`).test(un) || /(?:^|[\s;&|(])eval(?=\s)/.test(un)
|
|
31
|
+
|| (new RegExp(SH).test(un) && un.includes("<<<"));
|
|
32
|
+
// A heredoc is fed to a shell when its line holds an unquoted shell word, ssh, docker/kubectl exec, or source/. .
|
|
33
|
+
const feedsShell = (un) => new RegExp(SH).test(un) || /(?:^|[\s;&|(])ssh(?=\s)/.test(un) || /\b(?:docker|podman|kubectl)\b[^;&|]*\sexec\b/.test(un)
|
|
34
|
+
|| /(?:^|[\s;&|(])(?:source|\.)\s/.test(un);
|
|
35
|
+
|
|
36
|
+
/** One quote-aware pass, like the shell reads it: the command segments (split on ; & | newline outside quotes) with
|
|
37
|
+
* their unquoted text, and the text with every heredoc body removed, except a body fed to a shell, which is a script and
|
|
38
|
+
* stays. Not an opener: << inside $((..)), after a # comment start, or with no terminator line (bash would not run it). */
|
|
39
|
+
function scan(text) {
|
|
40
|
+
const segs = [];
|
|
41
|
+
let out = "", seg = "", un = "", line = [], pending = [], fed = false, q = null, dq = 0, arith = 0, i = 0;
|
|
42
|
+
const end = () => { if (seg.trim()) { segs.push({ un }); line.push(un); } seg = un = ""; };
|
|
43
|
+
const put = (x, quoted) => { out += x; seg += x; if (!quoted) un += x; };
|
|
44
|
+
while (i < text.length) {
|
|
45
|
+
const c = text[i];
|
|
46
|
+
if (q) {
|
|
47
|
+
put(c, true);
|
|
48
|
+
if (c === q) {
|
|
49
|
+
// "$(bash -c ...)" and `...` inside double quotes still run: keep such a span visible to the shell-word test.
|
|
50
|
+
if (q === '"') un += (out.slice(dq).match(/\$\(.*\)|`[^`]*`/gs) ?? []).join(" ");
|
|
51
|
+
q = null;
|
|
52
|
+
} else if (c === "\\" && q === '"') put(text[++i] ?? "", true);
|
|
53
|
+
i++; continue;
|
|
54
|
+
}
|
|
55
|
+
if (c === "\\") { put(c + (text[i + 1] ?? "")); i += 2; continue; }
|
|
56
|
+
if (c === "'" || c === '"') { q = c; dq = out.length; put(c, true); i++; continue; }
|
|
57
|
+
if (c === "#" && !arith && (!out || /[\s;&|(]/.test(out.at(-1)))) {
|
|
58
|
+
const nl = text.indexOf("\n", i), e = nl < 0 ? text.length : nl;
|
|
59
|
+
out += text.slice(i, e); seg += text.slice(i, e); i = e; continue;
|
|
60
|
+
}
|
|
61
|
+
if (text.startsWith("((", i)) { arith++; put("(("); i += 2; continue; }
|
|
62
|
+
if (arith && text.startsWith("))", i)) { arith--; put("))"); i += 2; continue; }
|
|
63
|
+
if (text.startsWith("<<<", i)) { put("<<<"); i += 3; continue; }
|
|
64
|
+
const m = c === "<" && !arith && text.slice(i).match(/^<<(-?)\s*(['"]?)([\w.-]+)\2/);
|
|
65
|
+
if (m) { pending.push({ dash: !!m[1], delim: m[3] }); put(m[0]); i += m[0].length; continue; }
|
|
66
|
+
if (c === "\n" || c === ";" || c === "&" || c === "|") {
|
|
67
|
+
end(); out += c; i++;
|
|
68
|
+
if (c === "\n") {
|
|
69
|
+
const shellFed = feedsShell(line.join(";"));
|
|
70
|
+
for (const h of pending) {
|
|
71
|
+
const from = i;
|
|
72
|
+
let found = false;
|
|
73
|
+
while (i < text.length && !found) {
|
|
74
|
+
const nl = text.indexOf("\n", i), ln = text.slice(i, nl < 0 ? text.length : nl);
|
|
75
|
+
i = nl < 0 ? text.length : nl + 1;
|
|
76
|
+
found = (h.dash ? ln.replace(/^\t+/, "") : ln) === h.delim;
|
|
77
|
+
}
|
|
78
|
+
if (shellFed || !found) { out += text.slice(from, i); if (shellFed) fed = true; }
|
|
79
|
+
}
|
|
80
|
+
pending = []; line = [];
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
put(c); i++;
|
|
85
|
+
}
|
|
86
|
+
end();
|
|
87
|
+
return { segs, text: out, fed };
|
|
88
|
+
}
|
|
21
89
|
|
|
22
90
|
/** The deny reason for a command Claude Code would stop on with an rm approval prompt, or null. Also matches rm text
|
|
23
|
-
* inside quotes (echo 'rm -f $D'), since Claude Code's own check flags that too.
|
|
91
|
+
* inside quotes (echo 'rm -f $D'), since Claude Code's own check flags that too. Heredoc bodies that only get written
|
|
92
|
+
* or piped to a program (python3 <<EOF, cat > f <<EOF) are not matched; an sh -c / eval is matched at command position. */
|
|
24
93
|
export function rmRefusal(command) {
|
|
25
|
-
const text = String(command);
|
|
26
|
-
if (
|
|
27
|
-
let at = 0;
|
|
94
|
+
const { segs, text, fed } = scan(String(command));
|
|
95
|
+
if ((fed || segs.some((x) => isScript(x.un))) && new RegExp(RM).test(text)) return SCRIPT_REASON;
|
|
96
|
+
let at = 0, cd = false;
|
|
28
97
|
for (const seg of text.split(/[;&|\n]/)) {
|
|
29
98
|
const m = seg.match(new RegExp(`${RM}+(.*)$`));
|
|
99
|
+
if (cd && m && m[1].split(/\s+/).some((w) => /^[^-/~$`]/.test(w.replace(/^['"]/, "")) && /[*?[]/.test(w))) return CD_GLOB_REASON;
|
|
100
|
+
// cd as a word anywhere: { cd, if cd, builtin cd, $(cd. An "echo cd x" over-denies, which is harmless.
|
|
101
|
+
if (/(?:^|[\s({])(?:cd|pushd)(?:\s|$)/.test(seg)) cd = true;
|
|
30
102
|
if (m && /\$(?:\w|\{|\()|`/.test(m[1].replace(/\$\{\w+:\?[^}]*\}/g, ""))) {
|
|
31
103
|
// ponytail: quote parity before the rm, escaped quotes ignored; a wrong pick only swaps the hint, both deny.
|
|
32
104
|
const before = text.slice(0, at + m.index);
|
|
@@ -13,10 +13,12 @@ Every tool named in this skill runs as `agentradar run $AR_CLI_RUNNER_ID <tool>`
|
|
|
13
13
|
|
|
14
14
|
1. Run `agentradar call $AR_CLI_RUNNER_ID agent_obligations`.
|
|
15
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 --
|
|
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
|
-
|
|
19
|
-
|
|
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 --input '{"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> --nextUp '<your next action>' --nextUpAt <ISO time>` (your next action on it, and when);
|
|
18
|
+
- each entry in `lateNextUps` is a task whose next up is past its time. Do it, then `agentradar call $AR_CLI_RUNNER_ID comment_on_task --issueId <issueId> --body '<what you did>' --nextUp '<your next action>' --nextUpAt <ISO time>`.
|
|
19
|
+
3. Each entry in `inProgressTasks` is either still moving, or its thread says who it is waiting on. `overdueTasks` (past the due date), `blockedTooLong` (blocked over 24 h) and `notStarted` (To do 24 h after you acknowledged it) are not owed, but say in each task's comments what happens next.
|
|
20
|
+
4. Each entry in `overdueAsks` is a follow-up that woke you with no answer. Decide: chase them in that thread with `reply` and arm the next with `expect_reply`, or drop it with `expect_reply` by=null.
|
|
21
|
+
5. Every handover you made this session has a follow-up: a pending `expect_reply` on its thread (`read_thread_notes` shows it), or the next up on the task you own for it, moved to when you will check back. Otherwise it is answered or closed. Arm any that is missing now.
|
|
20
22
|
|
|
21
23
|
The Stop hook runs the same check and will not let you stop while something is owed.
|
|
22
24
|
|
|
@@ -24,7 +26,7 @@ The Stop hook runs the same check and will not let you stop while something is o
|
|
|
24
26
|
|
|
25
27
|
There is no runtime cap. Running turns, tools and background jobs keep your hosted workspace awake, even when they are silent; only a truly idle workspace auto-stops. Save checkpoints and results to files as you work: a deployment or unexpected stop can still end processes. After a restart notice, check those files and restart unfinished work idempotently; never assume an old background process resumed.
|
|
26
28
|
|
|
27
|
-
In a hosted workspace, every teammate you start keeps its own process in the workspace memory, even while it waits. At most 2 can run at once; a 3rd start is refused. As soon as a teammate is idle or its work is done, stop it: `SendMessage` it `{"type":"shutdown_request"}
|
|
29
|
+
In a hosted workspace, every teammate you start keeps its own process in the workspace memory, even while it waits. At most 2 can run at once; a 3rd start is refused. As soon as a teammate is idle or its work is done, stop it: `SendMessage` it `{"type":"shutdown_request"}`, then carry on with your work; don't wait for its approval. Start a new one when new work needs it. A teammate left idle for 10 minutes is stopped by the workspace, and you get a message whenever a teammate stops or ends.
|
|
28
30
|
|
|
29
31
|
## Always
|
|
30
32
|
|
|
@@ -33,6 +35,8 @@ In a hosted workspace, every teammate you start keeps its own process in the wor
|
|
|
33
35
|
- Talk is not a task. Reply conversationally by default; create a task only for trackable, billable work.
|
|
34
36
|
- Write to humans so a 13-year-old would understand: a few short lines in plain words, ending with one next step (not a list of ideas), no ids, paths, tool names or internal terms unless asked. Say money in dollars, never cents; give every link with what it opens. 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.
|
|
35
37
|
- Never ask for, print or pass on a credential. AgentRadar holds the keys.
|
|
36
|
-
- When you ask
|
|
38
|
+
- Every handover gets a follow-up. When you ask someone something, hand them work or a decision, or send work for their review, say it in a thread and, in the same turn, either call `expect_reply` on that thread with `by` set to when you will check back, or update the next up on the task you own for it (`update_task` with `nextUp` and `nextUpAt`) to when you will check back. Restating the same next-up time does not count. Either one wakes you once. When it wakes you and they have not answered, decide: chase them and arm the next follow-up yourself the same way, or drop it with `expect_reply` by=null. Nothing re-arms on its own. When the handover closes, drop it.
|
|
39
|
+
- When to check back is your call: an agent sooner (within hours), a human less often and only in their daytime (the company's time zone when you don't know theirs). Escalating to the next human up the chain is your call too, never required.
|
|
40
|
+
- Approvals are excluded: the Moderator already chases those.
|
|
37
41
|
- 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.
|
|
38
42
|
- Never contact the platform team (`submit_feedback`) unless the human asks you to, or a tool answer tells you to report a platform fault (then do it once). "Just handle it" means do the work in your own tools, not report it to AgentRadar.
|
package/dist/channel.js
CHANGED
|
@@ -10,36 +10,60 @@ import { awpEndpoint } from "./tool-run.js";
|
|
|
10
10
|
import { noteCliUpdate } from "./update.js";
|
|
11
11
|
import { trustedMcpEndpoint } from "./setup.js";
|
|
12
12
|
import { syncJoinedSkills } from "./skill-install.js";
|
|
13
|
-
export const CHANNEL_GUIDANCE = "AgentRadar channel messages
|
|
13
|
+
export const CHANNEL_GUIDANCE = "AgentRadar channel messages carry your new events in full, in order, one per line, each with its own next step: act on each line as it says. Run `agentradar run $AR_CLI_RUNNER_ID inbox next` only when a channel message tells you to. Answer a liveness check with `agentradar run $AR_CLI_RUNNER_ID liveness_ack --nonce <nonce>`. Never acknowledge an event you have not read. Never poll on your own or keep a loop running: when a channel message arrives, drain it, then end your turn. Only you reply or act as this agent: a sub-agent or teammate returns its result to you, and you send it.";
|
|
14
14
|
// Vadym 16706: what a teammate (sub-agent) reads from the same channel, which never wakes it.
|
|
15
15
|
export const TEAMMATE_GUIDANCE = "You are a sub-agent of this AgentRadar agent. AgentRadar delivers the agent's messages to its main session, not to you, and refuses any reply, send, approval or task change you make as the agent. Finish your task and return your result to the main session; it replies and acts.";
|
|
16
16
|
export const CHANNEL_BLOCKED = "AgentRadar has not seen this session run `agentradar run $AR_CLI_RUNNER_ID inbox next`. Channel delivery is unconfirmed. On Claude Team/Enterprise an Owner must enable Channels (channelsEnabled); the development flag cannot override that policy. Your company sees this agent as not receiving messages until the channel works.";
|
|
17
17
|
// The runner's frame text (templates/vendor-runner, shipped beside this file as dist/vendor-runner), so both say a restart one way.
|
|
18
18
|
const runnerFrame = () => import(new URL(import.meta.url.endsWith(".ts")
|
|
19
19
|
? "../../../templates/vendor-runner/session-frame.mjs" : "./vendor-runner/session-frame.mjs", import.meta.url).href);
|
|
20
|
-
/** The channel: a local stdio MCP with no tools
|
|
21
|
-
/** The wake line. Zero events never says "run inbox next": that call has nothing to return. Each reason names its own tool. */
|
|
22
|
-
export function wakeText(runnerId,
|
|
20
|
+
/** The channel: a local stdio MCP with no tools. It types each event into the wake as the feeder did; the agent acts through the CLI. */
|
|
21
|
+
/** The wake line with no events. Zero events never says "run inbox next": that call has nothing to return. Each reason names its own tool. */
|
|
22
|
+
export function wakeText(runnerId, payload) {
|
|
23
23
|
const run = `agentradar run ${runnerId}`;
|
|
24
|
-
if (count)
|
|
25
|
-
return `${payload.firstStep ? `${payload.firstStep} ` : ""}${count} new AgentRadar events; run \`${run} inbox next\`${payload.livenessProbe ? " to receive your liveness check" : ""}.`;
|
|
26
24
|
return [
|
|
27
25
|
payload.onboarding && `AgentRadar session started, no messages waiting. Run \`${run} get_my_context\` now for your identity, peers and rules.`,
|
|
28
26
|
payload.livenessProbe && `AgentRadar liveness check, no messages waiting. Run \`${run} liveness_ack --nonce ${payload.livenessProbe.nonce}\` now.`,
|
|
29
27
|
].filter(Boolean).join(" ");
|
|
30
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* ACK-CHANNEL-EVENT-INLINE: the wake lists every event in order, each in the feeder's own frame (fenced sender text, the read
|
|
31
|
+
* step for a preview, the reply command), so the agent answers without first running `inbox next` (#2749 cost a turn).
|
|
32
|
+
* Claude Code wraps the content in a <channel> tag, so a sender cannot write one to close it early.
|
|
33
|
+
*/
|
|
34
|
+
export function eventsText(frame, runnerId, events, payload) {
|
|
35
|
+
const lines = events.map((event, i) => frame.formatInjectionFrame(i === 0 && payload.firstStep ? { ...event, firstStep: payload.firstStep } : event, runnerId));
|
|
36
|
+
if (payload.livenessProbe)
|
|
37
|
+
lines.push(frame.formatProbeFrame(payload.livenessProbe, runnerId));
|
|
38
|
+
// Each frame ends "Then stop." (it was written for one event per wake): only the last line may stop the agent.
|
|
39
|
+
return lines.map((line, i) => i < lines.length - 1 ? line.replace(/ Then stop\.$/, " Then handle the next line.") : line).join("\n").replace(/<(\/?)channel/gi, "‹$1channel");
|
|
40
|
+
}
|
|
41
|
+
let ownHostIsTeammate;
|
|
42
|
+
let psFailed = false;
|
|
31
43
|
/**
|
|
32
44
|
* True when this channel runs under a Claude Code teammate (started with --agent-id). Claude Code gives every teammate
|
|
33
45
|
* pane the session's --mcp-config, so each teammate ran its own channel under a new sessionId: Hormozi's 2 teammates and
|
|
34
46
|
* his main session flipped the runner session on every poll and starved his queue for hours (10-23).
|
|
35
47
|
*/
|
|
36
|
-
export function hostIsTeammate(pid
|
|
48
|
+
export function hostIsTeammate(pid) {
|
|
49
|
+
// This process's own host never changes and the walk is up to 8 blocking ps spawns: asked once, not on every request.
|
|
50
|
+
// A walk whose ps failed (a timeout under load) is not remembered, or a false would stick for the process's life.
|
|
51
|
+
if (pid === undefined) {
|
|
52
|
+
if (ownHostIsTeammate !== undefined)
|
|
53
|
+
return ownHostIsTeammate;
|
|
54
|
+
psFailed = false;
|
|
55
|
+
const own = hostIsTeammate(process.ppid);
|
|
56
|
+
if (!psFailed)
|
|
57
|
+
ownHostIsTeammate = own;
|
|
58
|
+
return own;
|
|
59
|
+
}
|
|
37
60
|
for (let hops = 0; pid > 1 && hops < 8; hops++) {
|
|
38
61
|
let row;
|
|
39
62
|
try {
|
|
40
63
|
row = execFileSync("ps", ["-o", "ppid=,args=", "-p", String(pid)], { encoding: "utf8", timeout: 5_000 }).trim();
|
|
41
64
|
}
|
|
42
65
|
catch {
|
|
66
|
+
psFailed = true;
|
|
43
67
|
return false;
|
|
44
68
|
}
|
|
45
69
|
const [, parent, args] = row.match(/^(\d+)\s+(.*)$/) ?? [];
|
|
@@ -71,20 +95,26 @@ export async function startChannel(runnerId, deps = {}) {
|
|
|
71
95
|
let polling = false;
|
|
72
96
|
let agentPolled = false;
|
|
73
97
|
const startedAt = Date.now();
|
|
74
|
-
// The CLI rewrites <runner>.delivered.json after every `inbox next
|
|
98
|
+
// The CLI rewrites <runner>.delivered.json after every `inbox next` and touches it after every other call, so a newer
|
|
99
|
+
// mtime is the session having acted.
|
|
75
100
|
const delivered = join(home, ".agentradar", "agents", `${runnerId}.delivered.json`);
|
|
76
|
-
const
|
|
77
|
-
return statSync(delivered).mtimeMs >=
|
|
101
|
+
const calledSince = (t) => { try {
|
|
102
|
+
return statSync(delivered).mtimeMs >= t;
|
|
78
103
|
}
|
|
79
104
|
catch {
|
|
80
105
|
return false;
|
|
81
|
-
} }
|
|
106
|
+
} };
|
|
107
|
+
const polled = () => agentPolled ||= calledSince(startedAt);
|
|
82
108
|
// Vadym 10342: an unconfirmed channel is reported to AgentRadar (runnerState channel_unconfirmed), so the owner sees
|
|
83
109
|
// the agent is not receiving messages; "ready" again once the session does run `inbox next`.
|
|
84
110
|
let unconfirmed = null;
|
|
85
111
|
let warning;
|
|
86
112
|
let skillSync;
|
|
87
113
|
let teammateNotices;
|
|
114
|
+
// Detached sync and notice passes: the channel drains them on stop, so none still writes into the home dir after it returns.
|
|
115
|
+
const pending = new Set();
|
|
116
|
+
const syncAbort = new AbortController(); // aborts a sync request still hung when the drain bound passes
|
|
117
|
+
const track = (work) => { pending.add(work); void work.finally(() => pending.delete(work)); };
|
|
88
118
|
const send = (value) => new Promise((resolve, reject) => {
|
|
89
119
|
output.write(`${JSON.stringify(value)}\n`, (error) => error ? reject(error) : resolve());
|
|
90
120
|
});
|
|
@@ -110,9 +140,9 @@ export async function startChannel(runnerId, deps = {}) {
|
|
|
110
140
|
const next = (args = {}) => remote("tools/call", { name: "next_event", arguments: { ...args, sessionId } });
|
|
111
141
|
// AGENT-GUARD-ACTIVE: the supervisor's marker, written by agent-guard's SessionStart hook in this session.
|
|
112
142
|
const guardLoaded = () => !process.env.AR_GUARD_MARKER || existsSync(process.env.AR_GUARD_MARKER);
|
|
113
|
-
const pause = (until) => new Promise((resolve) => {
|
|
143
|
+
const pause = (ms, until) => new Promise((resolve) => {
|
|
114
144
|
const done = () => { clearTimeout(timer); clearInterval(watch); stop.signal.removeEventListener("abort", done); resolve(); };
|
|
115
|
-
const timer = setTimeout(done,
|
|
145
|
+
const timer = setTimeout(done, ms);
|
|
116
146
|
// COLD-START-WARMUP: a booting session's work is held until it polls ready, so poll the moment the marker lands.
|
|
117
147
|
const watch = until && setInterval(() => { if (until())
|
|
118
148
|
done(); }, 100);
|
|
@@ -125,6 +155,18 @@ export async function startChannel(runnerId, deps = {}) {
|
|
|
125
155
|
let lastSent = 0;
|
|
126
156
|
let selftestStarted = false;
|
|
127
157
|
let guarded = true;
|
|
158
|
+
// Inlined events, acked once the session has made a call after the wake that carried them (the feeder acked on pane receipt).
|
|
159
|
+
let unacked = [];
|
|
160
|
+
// NEXT-EVENT-POLL-BACKOFF: a session that never acts got the same wake re-pushed every 30 s. Each re-push of the SAME
|
|
161
|
+
// unchanged set doubles that gap up to 5 min; a new event, a probe or a session call resets it. The 3 s poll stays, so a
|
|
162
|
+
// new event is pushed at the next poll; the server already parks served-unacked messages (redelivery.ts).
|
|
163
|
+
let resends = 0;
|
|
164
|
+
const resendMs = deps.resendMs ?? 30_000;
|
|
165
|
+
// NEXT-EVENT-POLL-BACKOFF: a failing poll (server down, key refused) retried every 3 s forever. Each consecutive failure
|
|
166
|
+
// doubles the pause up to 20x (60 s), jittered to 50-100% so runners cut off together do not return in step; any poll
|
|
167
|
+
// that gets through resets it.
|
|
168
|
+
const retryMs = deps.retryMs ?? 3_000;
|
|
169
|
+
let failures = 0;
|
|
128
170
|
while (!stop.signal.aborted) {
|
|
129
171
|
try {
|
|
130
172
|
guarded = guardLoaded(); // first, so a call that throws after the marker lands still gets the full pause
|
|
@@ -145,33 +187,45 @@ export async function startChannel(runnerId, deps = {}) {
|
|
|
145
187
|
if (unconfirmed === "reported" && polled())
|
|
146
188
|
unconfirmed = null;
|
|
147
189
|
// While reported, no runnerState: the stored state stands, and the poll still drains for wake signals.
|
|
148
|
-
|
|
190
|
+
// Receipt only: a preview still needs read_message before any reply (server gate). A "starting" poll cannot ack.
|
|
191
|
+
const ack = guarded && unacked.length && calledSince(lastSent) ? unacked : null;
|
|
192
|
+
const response = await next({ ...(unconfirmed ? {} : { runnerState: guarded ? "ready" : "starting" }), ...(ack ? { ackEventIds: ack } : {}) });
|
|
149
193
|
if (response.error || response.result?.isError)
|
|
150
194
|
throw new Error("AgentRadar refused next_event; check the runner key and its poll permission");
|
|
151
195
|
const content = response.result?.content;
|
|
152
196
|
const payload = JSON.parse(content?.find((item) => item.type === "text")?.text ?? "{}");
|
|
197
|
+
if (ack)
|
|
198
|
+
unacked = [];
|
|
153
199
|
const events = payload.events ?? [];
|
|
154
200
|
const wake = JSON.stringify([events.map((event) => event.eventId), payload.livenessProbe?.nonce, Boolean(payload.onboarding)]);
|
|
155
|
-
|
|
156
|
-
|
|
201
|
+
const same = wake === lastWake;
|
|
202
|
+
if (!same || calledSince(lastSent))
|
|
203
|
+
resends = 0;
|
|
204
|
+
if ((events.length || payload.livenessProbe || payload.onboarding) && (!same || Date.now() - lastSent >= Math.min(resendMs * 2 ** resends, resendMs * 10))) {
|
|
205
|
+
const frame = await runnerFrame();
|
|
206
|
+
const restart = events.length ? frame.restartLine(await readFile(notice, "utf8").then(JSON.parse, () => null), runnerId) : "";
|
|
157
207
|
await send({ jsonrpc: "2.0", method: "notifications/claude/channel", params: {
|
|
158
|
-
content: `${restart ? `${restart}
|
|
208
|
+
content: `${restart ? `${restart}\n` : ""}${events.length ? eventsText(frame, runnerId, events, payload) : wakeText(runnerId, payload)}`,
|
|
159
209
|
meta: { count: String(events.length) },
|
|
160
210
|
} });
|
|
161
211
|
if (restart)
|
|
162
212
|
await rm(notice, { force: true });
|
|
213
|
+
if (same)
|
|
214
|
+
resends += 1;
|
|
163
215
|
lastWake = wake;
|
|
164
216
|
lastSent = Date.now();
|
|
217
|
+
unacked = events.flatMap((event) => event.eventId ? [event.eventId] : []);
|
|
165
218
|
}
|
|
166
|
-
|
|
219
|
+
failures = 0;
|
|
167
220
|
}
|
|
168
221
|
catch (error) {
|
|
222
|
+
failures += 1;
|
|
169
223
|
const said = error.message;
|
|
170
224
|
// Below the server's minimum CLI the retry cannot succeed: say how to upgrade instead.
|
|
171
225
|
if (!stop.signal.aborted)
|
|
172
226
|
logError(/^upgrade required|below the minimum/.test(said) ? said : "AgentRadar channel poll failed; retrying. Check connectivity and the runner key.");
|
|
173
227
|
}
|
|
174
|
-
await pause(guarded ? undefined : guardLoaded);
|
|
228
|
+
await pause(failures ? Math.min(retryMs * 2 ** failures, retryMs * 20) * (0.5 + Math.random() / 2) : retryMs, guarded ? undefined : guardLoaded);
|
|
175
229
|
}
|
|
176
230
|
};
|
|
177
231
|
let teammate;
|
|
@@ -196,19 +250,28 @@ export async function startChannel(runnerId, deps = {}) {
|
|
|
196
250
|
void poll();
|
|
197
251
|
// Vadym 11038: a push-mode session runs no feeder, so the channel keeps its skills and brief current as the feeder
|
|
198
252
|
// does: at connect and every 10 minutes, dropping skills no longer linked. stdout is the MCP stream; logs go to stderr.
|
|
199
|
-
const sync = () => syncJoinedSkills(runnerId, { home, fetch: deps.fetch, log: logError })
|
|
253
|
+
const sync = () => syncJoinedSkills(runnerId, { home, fetch: deps.fetch, log: logError, signal: syncAbort.signal })
|
|
200
254
|
.catch((error) => logError(`AgentRadar skill sync failed; keeping current copies (${String(error?.message ?? error).split("\n")[0]}).`));
|
|
201
|
-
|
|
202
|
-
skillSync = setInterval(() => {
|
|
255
|
+
track(sync());
|
|
256
|
+
skillSync = setInterval(() => { track(sync()); }, deps.skillsSyncMs ?? 600_000);
|
|
203
257
|
skillSync.unref?.();
|
|
204
|
-
// TEAMMATE-IDLE-STOP: the hosted bridge leaves teammate-stopped-<name>.txt for each idle teammate it stopped; each is said once.
|
|
258
|
+
// TEAMMATE-IDLE-STOP: the hosted bridge leaves teammate-stopped-<name>.txt for each idle teammate it stopped or teammate that ended; each is said once.
|
|
205
259
|
const run = join(home, ".agentradar", "run");
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
260
|
+
let noticing = false; // a slow pass is not started again before it has removed its file, or the notice is said twice
|
|
261
|
+
teammateNotices = setInterval(() => track((async () => {
|
|
262
|
+
if (noticing)
|
|
263
|
+
return;
|
|
264
|
+
noticing = true;
|
|
265
|
+
try {
|
|
266
|
+
for (const file of readdirSync(run).filter((name) => /^teammate-stopped-.+\.txt$/.test(name))) {
|
|
267
|
+
await send({ jsonrpc: "2.0", method: "notifications/claude/channel", params: { content: await readFile(join(run, file), "utf8"), meta: { count: "0" } } });
|
|
268
|
+
await rm(join(run, file), { force: true });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
noticing = false;
|
|
210
273
|
}
|
|
211
|
-
})().catch(() => { }), deps.teammateNoticeMs ?? 5_000);
|
|
274
|
+
})().catch(() => { })), deps.teammateNoticeMs ?? 5_000);
|
|
212
275
|
teammateNotices.unref?.();
|
|
213
276
|
}
|
|
214
277
|
}
|
|
@@ -239,6 +302,9 @@ export async function startChannel(runnerId, deps = {}) {
|
|
|
239
302
|
clearTimeout(warning);
|
|
240
303
|
clearInterval(skillSync);
|
|
241
304
|
clearInterval(teammateNotices);
|
|
305
|
+
// Bounded wait, so a hung fetch cannot hold the exit; then its request is aborted so it cannot write after we return.
|
|
306
|
+
await Promise.race([Promise.allSettled([...pending]), new Promise((resolve) => setTimeout(resolve, deps.drainMs ?? 2_000).unref())]);
|
|
307
|
+
syncAbort.abort();
|
|
242
308
|
stop.abort();
|
|
243
309
|
}
|
|
244
310
|
}
|