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 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
- if (args[0] !== "broadcast" || modes.length !== 1 || modes[0] === "?" || named.target !== "all" || !named.text || !named.cta) {
18
- logError(ADMIN_BROADCAST_USAGE);
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 res = await (deps.fetch ?? fetch)(`${base}/admin/agent-broadcast`, {
28
- method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
29
- body: JSON.stringify({ target: "all", text: named.text, cta: named.cta, dryRun: modes[0] === "--dry-run" }),
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
- /** Detached follow-up: waits up to `waitMs` for the boundary this compaction wrote, then reports its token counts. */
59
- export async function reportTokens({ transcriptPath, startedAt, trigger }, { send, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), waitMs = 60_000, pollMs = 500 }) {
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", "--args", JSON.stringify(report)], { encoding: "utf8", timeout: 20_000 });
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")} --args '{"eventIds":[...]}' for messages, ${call("acknowledge_task")} --issueId <id> for a task you take on.`,
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")} --args '${JSON.stringify({ eventIds: 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
- ...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 })}'.`),
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
- "Keep working on them, or, if one is waiting on someone, make sure its thread says so, then stop.",
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", "--args", JSON.stringify(resolved)], { encoding: "utf8", timeout: 20_000 });
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 (/<<|\b(?:ba|z|da)?sh\s+-c\b|\beval\s/.test(text) && new RegExp(RM).test(text)) return SCRIPT_REASON;
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 --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`.
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"}` and let it shut down. 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 saying so.
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 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 human you wait on; that is your call, never required.
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 are wake signals only. Receive the events with `agentradar run $AR_CLI_RUNNER_ID inbox next`; the next call acknowledges the batch you read. 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.";
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, only wake signals. The agent acts through the CLI; wake signals never consume the durable queue. */
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, count, payload) {
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 = process.ppid) {
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`, so a newer mtime is the session having polled.
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 polled = () => agentPolled ||= (() => { try {
77
- return statSync(delivered).mtimeMs >= startedAt;
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, deps.retryMs ?? 3_000);
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
- const response = await next(unconfirmed ? {} : { runnerState: guarded ? "ready" : "starting" });
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
- if ((events.length || payload.livenessProbe || payload.onboarding) && (wake !== lastWake || Date.now() - lastSent >= 30_000)) {
156
- const restart = events.length ? (await runnerFrame()).restartLine(await readFile(notice, "utf8").then(JSON.parse, () => null), runnerId) : "";
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} ` : ""}${wakeText(runnerId, events.length, payload)}`,
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
- // Never ack a count-only wake: only the agent's tool call confirms receipt of event content.
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
- void sync();
202
- skillSync = setInterval(() => { void sync(); }, deps.skillsSyncMs ?? 600_000);
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
- teammateNotices = setInterval(() => void (async () => {
207
- for (const file of readdirSync(run).filter((name) => /^teammate-stopped-.+\.txt$/.test(name))) {
208
- await send({ jsonrpc: "2.0", method: "notifications/claude/channel", params: { content: await readFile(join(run, file), "utf8"), meta: { count: "0" } } });
209
- await rm(join(run, file), { force: true });
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
  }