agentradar-cli 0.3.8 → 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) {
@@ -1,8 +1,10 @@
1
1
  {
2
- "description": "Marks the session as guarded (the runner reports ready only then); blocks going idle while AgentRadar work is owed; reminds of the house rules every AR_DISCIPLINE_EVERY turns; reports compactions to AgentRadar; guards AgentRadar compact-then-switch model changes (a crashed PreModelSwitch hook exits 2 and refuses).",
2
+ "description": "Marks the session as guarded (the runner reports ready only then); blocks going idle while AgentRadar work is owed; reminds of the house rules every AR_DISCIPLINE_EVERY turns; reports compactions to AgentRadar; syncs the skills folder right after write_skill/link_skill; marks a sub-agent's commands (AR_CALLER=teammate) so it cannot act as the agent; guards AgentRadar compact-then-switch model changes (a crashed PreModelSwitch hook exits 2 and refuses); refuses an rm on a variable or $(...) path, which would otherwise raise an approval prompt nobody answers.",
3
3
  "hooks": {
4
4
  "SessionStart": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" start", "timeout": 10 }] }],
5
5
  "Stop": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" stop", "timeout": 30 }] }],
6
+ "PostToolUse": [{ "matcher": "Bash|mcp__.*__(write_skill|link_skill)", "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" skills", "timeout": 20 }] }],
7
+ "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" bash", "timeout": 10 }, { "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/rm-guard.mjs\"", "timeout": 10 }] }],
6
8
  "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" prompt", "timeout": 10 }] }],
7
9
  "PreCompact": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PreCompact", "timeout": 30 }, { "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/compaction-report.mjs\" PreCompact", "timeout": 30 }] }],
8
10
  "PostCompact": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PostCompact", "timeout": 30 }, { "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/compaction-report.mjs\" PostCompact", "timeout": 30 }] }],
@@ -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) {
@@ -8,6 +8,11 @@
8
8
  // output) lets the agent stop: a guard that wedges the session is worse than no guard.
9
9
  // start: writes AR_GUARD_MARKER, the runner's proof that this session loaded the guard: its feeder and
10
10
  // channel report "starting", never "ready", until the file exists (AGENT-GUARD-ACTIVE, Vadym 10699).
11
+ // It also adds the house rules to the context and says to load agentradar-discipline, at the start and
12
+ // after every compaction or resume (Vadym 15633: the built-in skill is loaded for every agent at start).
13
+ // skills: after write_skill/link_skill, has the runner's feeder sync the skills folder at once.
14
+ // bash: (PreToolUse on Bash) a sub-agent's command runs with AR_CALLER=teammate, so the CLI marks its calls and the
15
+ // server refuses it any reply or act as the agent (Vadym 16706). Claude Code names a sub-agent's call by agent_id.
11
16
  // prompt: every AR_DISCIPLINE_EVERY turns (default 20, 0 = off) re-adds the house rules, the must-nots
12
17
  // and the installed skills to the context.
13
18
  import { spawnSync } from "node:child_process";
@@ -17,6 +22,7 @@ import { join } from "node:path";
17
22
  import { fileURLToPath } from "node:url";
18
23
 
19
24
  const MAX_BLOCKS = 3;
25
+ const SKILL_SYNC_WAIT_MS = 15_000;
20
26
  const runner = process.env.AR_CLI_RUNNER_ID ?? "";
21
27
  const cli = process.env.AR_AGENTRADAR_BIN || "agentradar";
22
28
 
@@ -32,7 +38,8 @@ const call = (tool) => `agentradar call ${runner} ${tool}`;
32
38
 
33
39
  const RULES = [
34
40
  "Answer every message in the channel it came from, and a human first: someone is waiting.",
35
- `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
+ "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.",
36
43
  "Talk is not a task: reply conversationally, and create a task only for trackable, billable work.",
37
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.",
38
45
  `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.`,
@@ -40,7 +47,7 @@ const RULES = [
40
47
  const MUST_NOTS = [
41
48
  "Never ask for, print or pass on a credential. AgentRadar holds the keys.",
42
49
  `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.`,
43
- "Never contact the platform team (submit_feedback) unless the human asks you to.",
50
+ "Never contact the platform team (submit_feedback) unless the human asks you to, or a tool answer tells you to report a platform fault.",
44
51
  ];
45
52
 
46
53
  // Skill folders the agent can load: this plugin's, the project's, and the user's (Claude Code and Codex).
@@ -60,26 +67,47 @@ function skills() {
60
67
  return [...names].sort();
61
68
  }
62
69
 
70
+ const houseRules = (event, heading) => JSON.stringify({
71
+ hookSpecificOutput: {
72
+ hookEventName: event,
73
+ additionalContext: [
74
+ heading,
75
+ ...RULES.map((rule, i) => `${i + 1}. ${rule}`),
76
+ "You must not:",
77
+ ...MUST_NOTS.map((rule) => `- ${rule}`),
78
+ `Your skills (load the one that fits before acting): ${skills().join(", ") || "none installed"}.`,
79
+ ].join("\n"),
80
+ },
81
+ });
82
+
63
83
  if (process.argv[2] === "start") {
64
84
  if (process.env.AR_GUARD_MARKER) writeFileSync(process.env.AR_GUARD_MARKER, `${input.session_id ?? ""}\n`);
85
+ if (runner) process.stdout.write(houseRules("SessionStart", "AgentRadar house rules. Load your agentradar-discipline skill now and follow it for this whole session:"));
86
+ } else if (process.argv[2] === "skills" && process.env.AR_GUARD_MARKER
87
+ // The MCP tool, or its CLI mirror (`agentradar call <runner> write_skill`) run through Bash.
88
+ && (input.tool_name !== "Bash" || /\b(write_skill|link_skill)\b/.test(String(input.tool_input?.command ?? "")))) {
89
+ // PostToolUse on write_skill/link_skill (Vadym 12397): ask the feeder to sync skills now, and hold the tool's
90
+ // answer until it has, so the skill is on disk before the agent's next step. A feeder that does not answer in
91
+ // SKILL_SYNC_WAIT_MS leaves it to its next periodic sync.
92
+ const nonce = `${process.pid}-${Date.now()}`;
93
+ writeFileSync(`${process.env.AR_GUARD_MARKER}.skills`, nonce);
94
+ const until = Date.now() + SKILL_SYNC_WAIT_MS;
95
+ const nap = new Int32Array(new SharedArrayBuffer(4));
96
+ while (Date.now() < until) {
97
+ try { if (readFileSync(`${process.env.AR_GUARD_MARKER}.skills-done`, "utf8") === nonce) break; } catch {}
98
+ Atomics.wait(nap, 0, 0, 200);
99
+ }
100
+ } else if (process.argv[2] === "bash") {
101
+ if (input.agent_id && typeof input.tool_input?.command === "string") {
102
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: { ...input.tool_input, command: `export AR_CALLER=teammate; ${input.tool_input.command}` } } }));
103
+ }
65
104
  } else if (process.argv[2] === "prompt") {
66
105
  const every = Number.parseInt(process.env.AR_DISCIPLINE_EVERY ?? "20", 10);
67
106
  // No reset of `blocks` here: Codex may run this hook for a Stop continuation, which would lift the cap.
68
107
  state.turns += 1;
69
108
  save();
70
109
  if (runner && every > 0 && state.turns % every === 0) {
71
- process.stdout.write(JSON.stringify({
72
- hookSpecificOutput: {
73
- hookEventName: "UserPromptSubmit",
74
- additionalContext: [
75
- `AgentRadar house rules (reminder every ${every} turns):`,
76
- ...RULES.map((rule, i) => `${i + 1}. ${rule}`),
77
- "You must not:",
78
- ...MUST_NOTS.map((rule) => `- ${rule}`),
79
- `Your skills (load the one that fits before acting): ${skills().join(", ") || "none installed"}.`,
80
- ].join("\n"),
81
- },
82
- }));
110
+ process.stdout.write(houseRules("UserPromptSubmit", `AgentRadar house rules (reminder every ${every} turns):`));
83
111
  }
84
112
  } else if (process.argv[2] === "stop" && runner) {
85
113
  const reason = stopReason();
@@ -103,15 +131,19 @@ function stopReason() {
103
131
  const tasks = Array.isArray(o.unacknowledgedTasks) ? o.unacknowledgedTasks : [];
104
132
  const working = Array.isArray(o.inProgressTasks) ? o.inProgressTasks : [];
105
133
  const asks = Array.isArray(o.overdueAsks) ? o.overdueAsks : [];
134
+ const unread = Array.isArray(o.unreadTaskComments) ? o.unreadTaskComments : [];
135
+ const late = Array.isArray(o.lateNextUps) ? o.lateNextUps : [];
106
136
  const name = (t) => `${t.identifier ?? t.issueId} "${t.title ?? ""}" (issueId ${t.issueId})`;
107
- if (messages.length || tasks.length || asks.length) {
137
+ if (messages.length || tasks.length || asks.length || unread.length || late.length) {
108
138
  return [
109
- `AgentRadar: you still owe ${messages.length + tasks.length + asks.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.`,
110
140
  ...(messages.length ? [
111
- `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 })}'.`,
112
142
  ] : []),
113
- ...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}.`),
114
- ...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 })}'.`),
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>.`),
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>'.`),
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 })}'.`),
115
147
  `Check again with ${call("agent_obligations")}, then stop.`,
116
148
  ].join("\n");
117
149
  }
@@ -119,7 +151,7 @@ function stopReason() {
119
151
  if (working.length && !input.stop_hook_active) {
120
152
  return [
121
153
  `AgentRadar: ${working.length} task(s) still in progress: ${working.map(name).join("; ")}.`,
122
- "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.`,
123
155
  ].join("\n");
124
156
  }
125
157
  return "";
@@ -141,7 +141,8 @@ async function handleHook(state, input, authorize, now) {
141
141
  || !MODEL.test(input.from_model ?? "") || !MODEL.test(input.to_model ?? "")) return deny();
142
142
  // adapterConfig.model may be null or an alias. The hook names the actual runtime models; the server
143
143
  // must re-authorize this resolved target against both the original request and current route/plan.
144
- const resolved = { ...command, requestedModel: command.model, fromModel: input.from_model, model: input.to_model };
144
+ // Only the four fields the server's strict authorize_model_switch input takes; sessionId/expiresAt would refuse every switch.
145
+ const resolved = { id: command.id, requestedModel: command.model, fromModel: input.from_model, model: input.to_model };
145
146
  let allowed = false;
146
147
  try { allowed = await authorize(resolved) === true; } catch { /* deny below, durably if storage works */ }
147
148
  if (state.read("model-started")) return same(state.read("model-started")) ? ALLOW : DENY;
@@ -200,7 +201,7 @@ function authorizeOnServer(resolved) {
200
201
  const runner = process.env.AR_CLI_RUNNER_ID ?? "";
201
202
  if (!runner) return false;
202
203
  const run = spawnSync(process.env.AR_AGENTRADAR_BIN || "agentradar",
203
- ["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 });
204
205
  try { return run.status === 0 && JSON.parse(run.stdout).allowed === true; } catch { return false; }
205
206
  }
206
207
 
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // Refuses an rm on a variable or $(...) path before it runs (HOSTED-PERMISSION-MODAL-HANG, Vadym 17065). Claude
3
+ // Code stops such an rm with a "Dangerous rm operation" prompt even under bypassPermissions; nobody is at a hosted
4
+ // or runner pane to answer it, so the session sat frozen. Registered as PreToolUse(Bash); JSON on stdin. A deny is
5
+ // returned before Claude Code's own check, so no prompt opens and the agent rewrites the command.
6
+ import { readFileSync, realpathSync } from "node:fs";
7
+ import { pathToFileURL } from "node:url";
8
+
9
+ export const DENY_REASON = "AgentRadar refused this command before it ran: it removes a path built from a variable or $(...), "
10
+ + "which could be empty, and Claude Code would stop to ask a person to approve it. Nobody is at your session to answer, so it "
11
+ + "would hang. Nothing was deleted. Run it again with the variable guarded, e.g. rm -f \"${D:?}\"/*.png, or with a literal path. "
12
+ + "For a $(...) target, run the substitution on its own first, then rm the literal paths it prints.";
13
+ export const SCRIPT_REASON = "AgentRadar refused this command before it ran: it has an rm inside a heredoc, sh -c or eval script, "
14
+ + "which Claude Code cannot check, so it would stop to ask a person to approve it. Nobody is at your session to answer, so it "
15
+ + "would hang. Nothing ran. Run the cleanup as a separate one-line rm on a literal path, or skip it, then run the rest.";
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
+ + "$(...) 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
+ + "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.";
25
+
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
+ }
89
+
90
+ /** The deny reason for a command Claude Code would stop on with an rm approval prompt, or null. Also matches rm text
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. */
93
+ export function rmRefusal(command) {
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;
97
+ for (const seg of text.split(/[;&|\n]/)) {
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;
102
+ if (m && /\$(?:\w|\{|\()|`/.test(m[1].replace(/\$\{\w+:\?[^}]*\}/g, ""))) {
103
+ // ponytail: quote parity before the rm, escaped quotes ignored; a wrong pick only swaps the hint, both deny.
104
+ const before = text.slice(0, at + m.index);
105
+ const open = (q) => before.split(q).length % 2 === 0;
106
+ return open("'") || open('"') ? QUOTED_REASON : DENY_REASON;
107
+ }
108
+ at += seg.length + 1;
109
+ }
110
+ return null;
111
+ }
112
+
113
+ // realpath: on macOS /tmp is a symlink to /private/tmp, and import.meta.url is the resolved path.
114
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
115
+ try {
116
+ const input = JSON.parse(readFileSync(0, "utf8"));
117
+ const reason = input.tool_name === "Bash" ? rmRefusal(input.tool_input?.command ?? "") : null;
118
+ if (reason) {
119
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } }));
120
+ }
121
+ } catch { /* a broken payload never blocks a command */ }
122
+ }
@@ -7,16 +7,18 @@ description: >
7
7
 
8
8
  # AgentRadar discipline
9
9
 
10
- You act through `agentradar call <runner-id> <tool>`. Your runner id is in `$AR_CLI_RUNNER_ID`.
10
+ Every tool named in this skill runs as `agentradar run $AR_CLI_RUNNER_ID <tool>`; add `--help` for its arguments. AgentRadar is CLI-only: there is no AgentRadar MCP to connect to. Your runner id is in `$AR_CLI_RUNNER_ID`.
11
11
 
12
12
  ## Before you go idle
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,14 +26,17 @@ 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.
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
 
31
33
  - Answer every message in the channel it came from. A human who wrote is waiting for you; answer them first.
34
+ - Keep your main session free for messages. Any long job (a script, a report, a rebuild, many API calls) goes to a teammate or sub-agent (in Claude Code: the Agent tool, with a `name`) or to a background job, even when it looks quick; first reply in the thread with one short line saying what you are doing, so the sender is not left waiting. When a human tells you how to work ("delegate that"), it holds for every later job, not just this one. Never say a rule is saved unless you wrote it with `remember`.
32
35
  - Talk is not a task. Reply conversationally by default; create a task only for trackable, billable work.
33
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.
34
37
  - Never ask for, print or pass on a credential. AgentRadar holds the keys.
35
- - 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.
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.
36
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.
37
- - 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.
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.
@@ -20,13 +20,13 @@ You work on the vendor's computer and act on AgentRadar through the `agentradar`
20
20
 
21
21
  ## Start of every session
22
22
 
23
- Run `get_my_context` 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.
23
+ Run `get_my_context` FIRST, before any other tool. It returns your client company, 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.
24
24
 
25
25
  Keep each human's page in company memory current, every fact cited, as the Humans section of the `agentradar-memory-and-skills` skill says. A page is knowledge, never permission.
26
26
 
27
27
  In your first session, call `read_onboarding_obligations` once (reading them acknowledges the safety rules), then `read_readiness`.
28
28
 
29
- Your primary objective is to reach your quotas and drive the organisation forward in your role. Report each metric you own, and your subordinates' when needed, with `report_metric`: only true numbers, in good faith, each backed by `evidence` of what you counted it from, or by `fromEventId` when a human told you the number. 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 anyone below you on the org chart — only for real trackable work, never for chat. You also manage your subordinates' tasks and repeated tasks (`update_task`, `update_recurring_task`, `stop_recurring_task`, `start_recurring_task`, `archive_recurring_task`, `restore_recurring_task`) and their metrics: edit a definition (`edit_metric`) or remove a wrong value with a reason (`void_metric_record`).
29
+ Your primary objective is to reach your quotas and drive the company forward in your role. Report each metric you own, and your subordinates' when needed, with `report_metric`: only true numbers, in good faith, each backed by `evidence` of what you counted it from (the source the metric's How to measure names, shown in your brief and in full in `get_my_context`), or by `fromEventId` when a human told you the number. 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 anyone below you on the org chart — only for real trackable work, never for chat. You also manage your subordinates' tasks and repeated tasks (`update_task`, `update_recurring_task`, `stop_recurring_task`, `start_recurring_task`, `archive_recurring_task`, `restore_recurring_task`) and their metrics: edit a definition (`edit_metric`) or remove a wrong value with a reason (`void_metric_record`).
30
30
 
31
31
  ## Talking to humans
32
32
 
@@ -35,6 +35,7 @@ Write to humans so a 13-year-old would understand: a few short plain lines, then
35
35
  ## Work loop
36
36
 
37
37
  1. Run `agentradar run $AR_CLI_RUNNER_ID inbox next` to receive the next event or assigned work.
38
+ It answers at once, empty or not. To wait for work in a loop, add `--waitMs 30000` to hold up to 30 s; never loop it without a wait.
38
39
  2. Read the supplied engagement context before acting.
39
40
  3. Do the work in this project folder on the vendor's compute.
40
41
  4. Reply with `agentradar run $AR_CLI_RUNNER_ID reply` so AgentRadar can enforce the client boundary and audit the action.
@@ -0,0 +1,34 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { awpEndpoint } from "./tool-run.js";
4
+ export const ASSET_USAGE = "Usage: agentradar asset get <runner-id> <asset-id> [file]";
5
+ const ASSET_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+ /**
7
+ * ASSET-401 (10-22): `agentradar asset get <runner-id> <asset-id> [file]` saves a picture the agent made (a
8
+ * browser_screenshot) to a file it can open, with the runner's own key: the answer's assetId alone left it
9
+ * curling a REST path with no credential.
10
+ */
11
+ export async function getAssetOverCli(runnerId, assetId, file, deps = {}) {
12
+ const log = deps.log ?? console.log;
13
+ const logError = deps.logError ?? console.error;
14
+ if (!ASSET_ID.test(assetId)) {
15
+ logError(`${JSON.stringify(assetId)} is not an asset id. Use the assetId from the tool's answer.`);
16
+ return 2;
17
+ }
18
+ const endpoint = await awpEndpoint(runnerId, deps);
19
+ if (!endpoint)
20
+ return 1;
21
+ const res = await (deps.fetch ?? fetch)(new URL(`/api/assets/${assetId}/content`, endpoint.url), {
22
+ headers: { authorization: `Bearer ${endpoint.bearer}` },
23
+ });
24
+ if (!res.ok) {
25
+ logError(`AgentRadar refused the download (${res.status}). Only a picture you made in the browser you use now can be fetched.`);
26
+ return 1;
27
+ }
28
+ const type = res.headers.get("content-type") ?? "";
29
+ const target = resolve(file ?? `asset-${assetId.slice(0, 8)}.${type.includes("png") ? "png" : type.includes("jpeg") ? "jpg" : "bin"}`);
30
+ await writeFile(target, Buffer.from(await res.arrayBuffer()));
31
+ log(`Saved ${assetId} to ${target}.`);
32
+ return 0;
33
+ }
34
+ //# sourceMappingURL=asset-get.js.map