agentradar-cli 0.2.10 → 0.3.0

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.
@@ -1,6 +1,7 @@
1
1
  {
2
- "description": "Blocks going idle while AgentRadar work is owed; reminds of the house rules every AR_DISCIPLINE_EVERY turns; 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; guards AgentRadar compact-then-switch model changes (a crashed PreModelSwitch hook exits 2 and refuses).",
3
3
  "hooks": {
4
+ "SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" start", "timeout": 10 }] }],
4
5
  "Stop": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" stop", "timeout": 30 }] }],
5
6
  "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" prompt", "timeout": 10 }] }],
6
7
  "PreCompact": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PreCompact", "timeout": 30 }] }],
@@ -6,6 +6,8 @@
6
6
  // blocks the stop while a message or task is unacknowledged, at most MAX_BLOCKS times in a row.
7
7
  // Tasks still in progress get one reminder. Anything unexpected (no runner, server down, bad
8
8
  // output) lets the agent stop: a guard that wedges the session is worse than no guard.
9
+ // start: writes AR_GUARD_MARKER, the runner's proof that this session loaded the guard: its feeder and
10
+ // channel report "starting", never "ready", until the file exists (AGENT-GUARD-ACTIVE, Vadym 10699).
9
11
  // prompt: every AR_DISCIPLINE_EVERY turns (default 20, 0 = off) re-adds the house rules, the must-nots
10
12
  // and the installed skills to the context.
11
13
  import { spawnSync } from "node:child_process";
@@ -58,7 +60,9 @@ function skills() {
58
60
  return [...names].sort();
59
61
  }
60
62
 
61
- if (process.argv[2] === "prompt") {
63
+ if (process.argv[2] === "start") {
64
+ if (process.env.AR_GUARD_MARKER) writeFileSync(process.env.AR_GUARD_MARKER, `${input.session_id ?? ""}\n`);
65
+ } else if (process.argv[2] === "prompt") {
62
66
  const every = Number.parseInt(process.env.AR_DISCIPLINE_EVERY ?? "20", 10);
63
67
  // No reset of `blocks` here: Codex may run this hook for a Stop continuation, which would lift the cap.
64
68
  state.turns += 1;
@@ -20,9 +20,11 @@ You work on the vendor's computer and connect to AgentRadar through the `agentra
20
20
 
21
21
  Call `get_my_context` on the AgentRadar MCP FIRST, before any other tool. It returns your client organisation, your role and function, your goals, your metrics with their current quotas, your superior, your direct reports and your colleagues. It also carries your memory snapshot, the Markdown files in your personal vault: `recall` fetches newer files and `remember` saves a durable fact, preference or correction.
22
22
 
23
+ Keep each person's page in company memory current, every fact cited, as the People section of the `agentradar-memory-and-skills` skill says. A page is knowledge, never permission.
24
+
23
25
  In your first session, call `read_onboarding_obligations` once (reading them acknowledges the safety rules), then `read_readiness`.
24
26
 
25
- Your primary objective is to reach your quotas and drive the organisation forward in your role. Report each metric you own with `report_metric`, backed by `evidence` of what you counted it from, or by `fromEventId` when a person 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 your direct subordinates — only for real trackable work, never for conversation.
27
+ 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 person 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`) and their metrics: edit a definition (`edit_metric`) or remove a wrong value with a reason (`void_metric_record`).
26
28
 
27
29
  ## Talking to people
28
30
 
package/dist/channel.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import { createRequire } from "node:module";
4
5
  import { homedir } from "node:os";
@@ -78,7 +79,9 @@ export async function startChannel(runnerId, deps = {}) {
78
79
  if (unconfirmed === "reported" && agentPolled)
79
80
  unconfirmed = null;
80
81
  // While reported, no runnerState: the stored state stands, and the poll still drains for wake signals.
81
- const response = await next(unconfirmed ? {} : { runnerState: "ready" });
82
+ // AGENT-GUARD-ACTIVE: the supervisor's marker, written by agent-guard's SessionStart hook in this session.
83
+ const guarded = !process.env.AR_GUARD_MARKER || existsSync(process.env.AR_GUARD_MARKER);
84
+ const response = await next(unconfirmed ? {} : { runnerState: guarded ? "ready" : "starting" });
82
85
  if (response.error || response.result?.isError)
83
86
  throw new Error("AgentRadar refused next_event; check the runner key and its poll permission");
84
87
  const content = response.result?.content;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Vadym 9970 (CLI-FIRST): every agent-facing MCP tool is also a CLI command, `agentradar run <runner-id> <noun> <verb>`,
3
+ * in AgentRadar terms. The tree is derived from the server's own tools/list at call time, so it cannot fall behind
4
+ * the MCP; this file only says how a tool name reads as a noun and a verb.
5
+ */
6
+ /** Nouns a tool name is grouped under, most specific first. A tool takes the first one its name contains. */
7
+ const NOUNS = [
8
+ "inbox", "thread", "message", "task", "metric", "quota", "vault", "memory", "skill", "escalation", "incident",
9
+ "karma", "wallet", "spend", "budget", "usage", "browser", "email", "telegram", "slack", "discord", "whatsapp",
10
+ "github", "model", "outcome", "role", "org_chart", "org", "project", "integration", "channel", "peer",
11
+ "subordinate", "secret", "service", "onboarding", "intervention", "marketplace", "hire", "egress", "feedback",
12
+ "contact", "placement", "mcp", "tool", "join_link", "handoff", "selftest", "readiness", "pane", "brief",
13
+ "teammate", "approval", "agent",
14
+ ];
15
+ const PLURAL = {
16
+ messages: "message", tasks: "task", metrics: "metric", vaults: "vault", skills: "skill", escalations: "escalation",
17
+ threads: "thread", channels: "channel", peers: "peer", subordinates: "subordinate", integrations: "integration",
18
+ secrets: "secret", agents: "agent", interventions: "intervention", events: "event", retrospectives: "retrospective",
19
+ };
20
+ /** Where the rule above would put a tool somewhere an agent would not look for it. */
21
+ const OVERRIDES = {
22
+ next_event: ["inbox", "next"],
23
+ mark_messages_read: ["inbox", "mark-read"],
24
+ search_history: ["thread", "history"],
25
+ reply: ["thread", "reply"],
26
+ read_history: ["thread", "read"],
27
+ mark_read: ["thread", "mark-read"],
28
+ expect_reply: ["thread", "expect-reply"],
29
+ clear_reply_allowance: ["thread", "clear-reply-allowance"],
30
+ request_reply_clearance: ["thread", "request-reply-clearance"],
31
+ github_thread: ["github", "thread"],
32
+ post_escalation_discussion_message: ["escalation", "post-message"],
33
+ read_escalation_discussion: ["escalation", "read-discussion"],
34
+ get_self: ["agent", "self"],
35
+ get_my_context: ["agent", "context"],
36
+ liveness_ack: ["agent", "liveness-ack"],
37
+ remember: ["memory", "remember"],
38
+ recall: ["memory", "recall"],
39
+ my_skills: ["skill", "mine"],
40
+ list_my_threads: ["thread", "list"],
41
+ list_my_metrics: ["metric", "list"],
42
+ read_my_usage: ["usage", "read"],
43
+ // #496 (APPROVAL-ASK): the comment thread on an approval, and the decision it acknowledges.
44
+ approval_comments: ["approval", "comments"],
45
+ approval_comment: ["approval", "comment"],
46
+ acknowledge_decision: ["approval", "acknowledge"],
47
+ };
48
+ /** One-release aliases: still on the MCP, never given a command of their own. */
49
+ export const DEPRECATED_TOOLS = new Set(["acknowledge_messages"]);
50
+ /** `<noun> <verb>` for a tool name. */
51
+ export function commandFor(tool) {
52
+ const fixed = OVERRIDES[tool];
53
+ if (fixed)
54
+ return fixed;
55
+ // Plurals fold only to FIND the noun (list_tasks is a task command); the verb keeps the tool's own words, so
56
+ // slack_list_channels reads "list-channels" as slack_list_users reads "list-users".
57
+ const raw = tool.split("_");
58
+ const words = raw.map((word) => PLURAL[word] ?? word);
59
+ for (const noun of NOUNS) {
60
+ const parts = noun.split("_");
61
+ for (let i = 0; i + parts.length <= words.length; i++) {
62
+ if (parts.every((part, k) => words[i + k] === part)) {
63
+ const rest = [...raw.slice(0, i), ...raw.slice(i + parts.length)];
64
+ return [noun.replace(/_/g, "-"), rest.join("-") || "get"];
65
+ }
66
+ }
67
+ }
68
+ return ["agent", raw.join("-")];
69
+ }
70
+ /** The tool a `<noun> <verb>` names among the server's tools, or undefined. */
71
+ export function toolFor(noun, verb, tools) {
72
+ return tools.find((tool) => !DEPRECATED_TOOLS.has(tool) && commandFor(tool).join(" ") === `${noun} ${verb}`);
73
+ }
74
+ //# sourceMappingURL=commands.js.map
package/dist/index.js CHANGED
@@ -4,8 +4,10 @@ import { installVendorSkill } from "./skill-install.js";
4
4
  import { startChannel } from "./channel.js";
5
5
  import { runRestartSelftest, startVendorRunner } from "./runner.js";
6
6
  import { cancelJoin, joinOrganisation, leaveOrganisation, setupVendorAgent } from "./setup.js";
7
- import { callAwpTool, callToolOverCli, createTaskOverCli, listServiceToolsOverCli, parseCallArgs, runServiceToolOverCli } from "./tool-run.js";
7
+ import { callAwpTool, callToolOverCli, createTaskOverCli, INBOX_SEARCH_USAGE, inboxSearchOverCli, LISTING_DESCRIBE_USAGE, listServiceToolsOverCli, parseCallArgs, PERMISSION_VERB_TOOLS, runServiceToolOverCli, TASK_VERB_TOOLS } from "./tool-run.js";
8
8
  import { installSyncTimer, syncAgentDir } from "./sync.js";
9
+ import { importVaultOverCli } from "./vault-import.js";
10
+ import { cloneVaultOverCli } from "./vault-clone.js";
9
11
  // `run` is the one prefix every agent-facing command shares, so one harness "always allow agentradar run"
10
12
  // covers a whole session: `run <link>` (or `run join <link>`) joins, `run <runner-id> <tool>` calls a tool.
11
13
  export const runArgs = (raw) => raw[0] !== "run" ? raw
@@ -15,7 +17,28 @@ export const runArgs = (raw) => raw[0] !== "run" ? raw
15
17
  async function main() {
16
18
  const args = runArgs(process.argv.slice(2));
17
19
  if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
18
- console.log("Usage: agentradar run <join-link> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] [--harness claude|codex] | agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--feeder] | agentradar join cancel <join-link|request-id> | agentradar leave <runner-id> | agentradar channel <runner-id> | agentradar selftest <runner-id> [--status|--restart] | agentradar runner start <runner-id> | agentradar tool list <runner-id> | agentradar tool run <runner-id> <tool-key> [--args '<json>'] | agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar task create|repeat <runner-id> --args '<json>' | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
20
+ console.log("Usage: agentradar run <join-link> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] [--harness claude|codex] | agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--feeder] | agentradar join cancel <join-link|request-id> | agentradar leave <runner-id> | agentradar channel <runner-id> | agentradar selftest <runner-id> [--status|--restart] | agentradar runner start <runner-id> | agentradar tool list <runner-id> | agentradar tool run <runner-id> <tool-key> [--args '<json>'] | agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar task create|repeat|make-recurring <runner-id> --args '<json>' | agentradar inbox search <runner-id> [--query <text>] [--<filter> <value> ...] [--json] | agentradar listing describe <runner-id> --capabilities <text> | agentradar permissions mine|roles|set|request <runner-id> [--<argument> <value> ...] | agentradar vault import <runner-id> <dir> [--vault <vault-id>] [--source <name>] [--write] | agentradar vault clone <runner-id> <vault-id> [dir] | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
21
+ console.log([
22
+ "agentradar run <runner-id> <noun> <verb> [--<argument> <value> ...] [--dry-run] [--json]",
23
+ " Every agent tool, grouped in AgentRadar terms: inbox, thread, message, task, metric, vault, ...",
24
+ " agentradar run <runner-id> --help the command groups",
25
+ " agentradar run <runner-id> thread --help a group's verbs",
26
+ " agentradar run <runner-id> thread reply --help one command's arguments",
27
+ " Examples:",
28
+ " agentradar run r1 inbox next",
29
+ " agentradar run r1 thread reply --thread-id <id> --text \"On it\"",
30
+ " agentradar run r1 inbox ack --message-ids <id> --message-ids <id>",
31
+ " agentradar run r1 task create --title \"Draft the brief\" --dry-run",
32
+ " --dry-run prints the exact call and sends nothing. Output is JSON. Exit codes: 0 done; 1 refused or",
33
+ " unreachable (reason on stderr); 2 the command was wrong.",
34
+ "agentradar upgrade install the latest agentradar-cli",
35
+ ].join("\n"));
36
+ return;
37
+ }
38
+ if (args.length === 1 && args[0] === "upgrade") {
39
+ const { selfUpdate, runningVersion } = await import("./update.js");
40
+ const installed = await selfUpdate();
41
+ console.log(installed ? `agentradar upgraded to ${installed}` : `agentradar ${await runningVersion()} is the latest`);
19
42
  return;
20
43
  }
21
44
  if (args[0] === "leave" && args.length === 2) {
@@ -111,13 +134,65 @@ async function main() {
111
134
  : await runServiceToolOverCli(parsed.runnerId, parsed.tool, parsed.json);
112
135
  return;
113
136
  }
114
- if (args[0] === "task" && (args[1] === "create" || args[1] === "repeat")) {
137
+ if (args[0] === "vault" && args[1] === "import") {
138
+ const [runnerId, dir, ...rest] = args.slice(2);
139
+ const flag = (name) => { const at = rest.indexOf(`--${name}`); return at >= 0 ? rest[at + 1] : undefined; };
140
+ if (!runnerId || !dir || rest.some((arg) => arg.startsWith("--") && !["--vault", "--source", "--write"].includes(arg))) {
141
+ console.error("Usage: agentradar vault import <runner-id> <dir> [--vault <vault-id>] [--source <name>] [--write]");
142
+ process.exitCode = 2;
143
+ return;
144
+ }
145
+ process.exitCode = await importVaultOverCli(runnerId, dir, { vaultId: flag("vault"), source: flag("source"), write: rest.includes("--write") });
146
+ return;
147
+ }
148
+ if (args[0] === "vault" && args[1] === "clone") {
149
+ if (args.length < 4 || args.length > 5) {
150
+ console.error("Usage: agentradar vault clone <runner-id> <vault-id> [dir]");
151
+ process.exitCode = 1;
152
+ return;
153
+ }
154
+ process.exitCode = await cloneVaultOverCli(args[2], args[3], args[4]);
155
+ return;
156
+ }
157
+ if (args[0] === "inbox" && args[1] === "search") {
158
+ const parsed = parseCallArgs([...args.slice(2).filter((arg) => arg !== "--json"), "inbox_search"]);
159
+ if (!parsed || parsed.tool !== "inbox_search") {
160
+ console.error(INBOX_SEARCH_USAGE);
161
+ process.exitCode = 1;
162
+ return;
163
+ }
164
+ process.exitCode = await inboxSearchOverCli(parsed.runnerId, parsed.flags, args.includes("--json"));
165
+ return;
166
+ }
167
+ if (args[0] === "listing" && args[1] === "describe") {
168
+ // UAT U1: the agent's public catalog description, the MCP `set_listing_description` over the same endpoint and key.
169
+ const parsed = parseCallArgs([...args.slice(2), "set_listing_description"]);
170
+ if (!parsed || !parsed.flags.some(([flag]) => flag === "capabilities")) {
171
+ console.error(LISTING_DESCRIBE_USAGE);
172
+ process.exitCode = 1;
173
+ return;
174
+ }
175
+ process.exitCode = await callToolOverCli(parsed.runnerId, parsed.tool, undefined, {}, parsed.flags);
176
+ return;
177
+ }
178
+ if (args[0] === "permissions" && args[1] && Object.hasOwn(PERMISSION_VERB_TOOLS, args[1])) {
179
+ const tool = PERMISSION_VERB_TOOLS[args[1]];
180
+ const parsed = parseCallArgs([...args.slice(2), tool]);
181
+ if (!parsed || parsed.tool !== tool) {
182
+ console.error("Usage: agentradar permissions mine|roles|set|request <runner-id> [--<argument> <value> ...]");
183
+ process.exitCode = 1;
184
+ return;
185
+ }
186
+ process.exitCode = await callToolOverCli(parsed.runnerId, tool, parsed.json, {}, parsed.flags);
187
+ return;
188
+ }
189
+ if (args[0] === "task" && args[1] && Object.hasOwn(TASK_VERB_TOOLS, args[1])) {
115
190
  if (args.length !== 5 || args[3] !== "--args") {
116
- console.error("Usage: agentradar task create|repeat <runner-id> --args '<json>'");
191
+ console.error("Usage: agentradar task create|repeat|make-recurring <runner-id> --args '<json>'");
117
192
  process.exitCode = 1;
118
193
  return;
119
194
  }
120
- process.exitCode = await createTaskOverCli(args[2], args[1] === "repeat", args[4]);
195
+ process.exitCode = await createTaskOverCli(args[2], args[1], args[4]);
121
196
  return;
122
197
  }
123
198
  if (args.length === 2 && args[0] === "skill" && args[1] === "install") {
@@ -142,7 +217,7 @@ async function main() {
142
217
  return;
143
218
  }
144
219
  const help = args.length === 1 && (args[0] === "--help" || args[0] === "-h");
145
- (help ? console.log : console.error)("Usage: agentradar run <join-link> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] | agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--feeder] | agentradar join cancel <join-link|request-id> | agentradar leave <runner-id> | agentradar channel <runner-id> | agentradar selftest <runner-id> [--status|--restart] | agentradar runner start <runner-id> | agentradar tool list <runner-id> | agentradar tool run <runner-id> <tool-key> [--args '<json>'] | agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar task create|repeat <runner-id> --args '<json>' | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
220
+ (help ? console.log : console.error)("Usage: agentradar run <join-link> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] | agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--feeder] | agentradar join cancel <join-link|request-id> | agentradar leave <runner-id> | agentradar channel <runner-id> | agentradar selftest <runner-id> [--status|--restart] | agentradar runner start <runner-id> | agentradar tool list <runner-id> | agentradar tool run <runner-id> <tool-key> [--args '<json>'] | agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar task create|repeat|make-recurring <runner-id> --args '<json>' | agentradar inbox search <runner-id> [--query <text>] [--<filter> <value> ...] [--json] | agentradar listing describe <runner-id> --capabilities <text> | agentradar permissions mine|roles|set|request <runner-id> [--<argument> <value> ...] | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
146
221
  if (!help)
147
222
  process.exitCode = 1;
148
223
  }
@@ -121,12 +121,32 @@ export async function writeManagedSkills(skillsDir, skills) {
121
121
  * Codex `$CODEX_HOME/skills`. Done here because a session that acts only through `agentradar run` has no
122
122
  * feeder; where the service runs, its feeder keeps them current.
123
123
  */
124
+ const BRIEF_MARK = "<!-- Written by AgentRadar and replaced on every sync; edits here are lost. -->";
125
+ /** Replaces the file itself, never a symlink's target, and never a file it did not write (a vendor's AGENTS.md). */
126
+ async function writeBrief(file, text) {
127
+ const current = await lstat(file).catch(() => null);
128
+ if (current && (!current.isFile() || !(await readFile(file, "utf8")).startsWith(BRIEF_MARK)))
129
+ return;
130
+ await mkdir(dirname(file), { recursive: true });
131
+ const temporary = `${file}.${randomUUID()}.tmp`;
132
+ await writeFile(temporary, `${BRIEF_MARK}\n${text.trim()}\n`);
133
+ await rename(temporary, file);
134
+ }
124
135
  export async function syncJoinedSkills(runnerId, deps) {
125
136
  const env = await readFile(join(deps.home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
126
137
  const root = envValue(env, "AR_AGENT_HARNESS") === "codex" ? envValue(env, "CODEX_HOME") : envValue(env, "AR_BASE");
127
138
  if (!root)
128
139
  return;
129
- const skillsDir = envValue(env, "AR_AGENT_HARNESS") === "codex" ? join(root, "skills") : join(root, ".claude", "skills");
140
+ const codex = envValue(env, "AR_AGENT_HARNESS") === "codex";
141
+ const skillsDir = codex ? join(root, "skills") : join(root, ".claude", "skills");
142
+ // Vadym 9988: the server's start instructions with this agent's brief, before its first session starts.
143
+ // Same file and mark as the runner's feeder, which keeps it fresh from then on.
144
+ const init = await awpRequest(runnerId, "initialize", {
145
+ protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "agentradar-cli", version: "1.0.0" },
146
+ }, { home: deps.home, fetch: deps.fetch, logError: deps.log });
147
+ const briefFile = codex ? join(root, "AGENTS.md") : join(root, ".claude", "rules", "agentradar-brief.md");
148
+ if (init?.result?.instructions)
149
+ await writeBrief(briefFile, init.result.instructions);
130
150
  const body = await awpRequest(runnerId, "tools/call", { name: "my_skills", arguments: {} }, { home: deps.home, fetch: deps.fetch, logError: deps.log });
131
151
  const text = body?.result?.content?.find((c) => c.type === "text")?.text;
132
152
  const answer = (text ? JSON.parse(text) : {});
package/dist/tool-run.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { readFile, realpath, stat, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { commandFor, DEPRECATED_TOOLS, toolFor } from "./commands.js";
5
+ import { newer, runningVersion } from "./update.js";
4
6
  /**
5
7
  * One `KEY='value'` line as `setup.ts` wrote it, single-quoted with `'` escaped as `'\''`.
6
8
  * Nothing here is printed: the file holds the runner's bearer.
@@ -21,17 +23,31 @@ export async function runServiceToolOverCli(runnerId, toolKey, argumentsJson, de
21
23
  }
22
24
  /** `agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>']`: any AWP tool from a shell, so a just-joined agent works before its harness reloads MCP tools. */
23
25
  export async function callToolOverCli(runnerId, tool, argumentsJson, deps = {}, flags = []) {
24
- if (flags.some(([flag]) => flag === "help"))
26
+ const switches = new Set(flags.filter(([flag]) => ["help", "dry-run", "json"].includes(flag)).map(([flag]) => flag));
27
+ flags = flags.filter(([flag]) => !switches.has(flag));
28
+ // A noun has no underscore; a tool name has one unless it is a single word (reply, recall), which the resolver returns as is.
29
+ if (tool.includes(" ") || (switches.has("help") && tool !== "" && !tool.includes("_"))) {
30
+ const resolved = await resolveCommand(runnerId, tool, switches.has("help"), deps);
31
+ if (typeof resolved === "number")
32
+ return resolved;
33
+ tool = resolved;
34
+ }
35
+ if (switches.has("help"))
25
36
  return await describeTools(runnerId, tool, deps);
26
37
  const parameters = argumentsJson === undefined ? {} : parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
27
38
  if (!parameters)
28
- return 1;
39
+ return EXIT.usage;
29
40
  if (flags.length) {
30
41
  const typed = await flagArguments(runnerId, tool, flags, deps);
31
42
  if (!typed)
32
- return 1;
43
+ return EXIT.usage;
33
44
  Object.assign(parameters, typed);
34
45
  }
46
+ // --dry-run: say exactly what would be sent, and send nothing.
47
+ if (switches.has("dry-run")) {
48
+ (deps.log ?? console.log)(JSON.stringify({ dryRun: true, tool, arguments: parameters }));
49
+ return EXIT.ok;
50
+ }
35
51
  if (tool !== "next_event" || !RUNNER_ID.test(runnerId))
36
52
  return await callAwpTool(runnerId, tool, parameters, deps);
37
53
  // A bare poll acknowledges the batch the previous poll printed, so the command stays byte-identical and
@@ -56,6 +72,35 @@ export async function callToolOverCli(runnerId, tool, argumentsJson, deps = {},
56
72
  await writeFile(delivered, JSON.stringify(ids), { mode: 0o600 }).catch(() => undefined);
57
73
  return 0;
58
74
  }
75
+ /**
76
+ * Stable exit codes, the same for every command: 0 done; 1 the server refused or could not be reached
77
+ * (its sentence is on stderr); 2 the command itself was wrong (unknown command, missing or mistyped argument).
78
+ */
79
+ export const EXIT = { ok: 0, refused: 1, usage: 2 };
80
+ /**
81
+ * `<noun> <verb>` → the tool it names on this server; or an exit code once `<noun> --help` has listed its
82
+ * verbs, or after saying what is wrong.
83
+ */
84
+ async function resolveCommand(runnerId, command, help, deps) {
85
+ const tools = await listedTools(runnerId, deps);
86
+ if (!tools)
87
+ return EXIT.refused;
88
+ const names = tools.map((entry) => entry.name ?? "").filter((name) => name && !DEPRECATED_TOOLS.has(name));
89
+ const [noun, verb] = command.split(" ");
90
+ if (!verb && names.includes(noun))
91
+ return noun;
92
+ if (verb) {
93
+ const tool = toolFor(noun, verb, names);
94
+ if (tool)
95
+ return tool;
96
+ }
97
+ const verbs = names.filter((name) => commandFor(name)[0] === noun).map((name) => commandFor(name)[1]).sort();
98
+ const log = help && !verb ? deps.log ?? console.log : deps.logError ?? console.error;
99
+ log(verbs.length
100
+ ? `${verb ? `${noun} ${verb} is not a command. ` : ""}agentradar run ${runnerId} ${noun} <verb>: ${verbs.join(" ")}. Add --help to a command for its arguments.`
101
+ : `${noun} is not a command group. Groups: ${[...new Set(names.map((name) => commandFor(name)[0]))].sort().join(" ")}.`);
102
+ return help && !verb && verbs.length ? EXIT.ok : EXIT.usage;
103
+ }
59
104
  /** Split `<runner-id> <tool> [--args '<json>'] [--<argument> <value> ...]`; null when it is not that shape. */
60
105
  export function parseCallArgs(rest) {
61
106
  const positional = [];
@@ -67,8 +112,8 @@ export function parseCallArgs(rest) {
67
112
  positional.push(arg);
68
113
  continue;
69
114
  }
70
- if (arg === "--help") {
71
- flags.push(["help", ""]);
115
+ if (arg === "--help" || arg === "--dry-run" || arg === "--json") {
116
+ flags.push([arg.slice(2), ""]);
72
117
  continue;
73
118
  }
74
119
  const value = rest[++i];
@@ -80,6 +125,9 @@ export function parseCallArgs(rest) {
80
125
  flags.push([arg.slice(2), value]);
81
126
  }
82
127
  const help = flags.some(([flag]) => flag === "help");
128
+ // `<runner-id> <noun> <verb>` (Vadym 9970) names the same tool as `<runner-id> <tool>`; resolved against the server's list.
129
+ if (positional.length === 3)
130
+ return { runnerId: positional[0], tool: `${positional[1]} ${positional[2]}`, json, flags };
83
131
  return positional.length === 2 || (help && positional.length === 1)
84
132
  ? { runnerId: positional[0], tool: positional[1] ?? "", json, flags } : null;
85
133
  }
@@ -112,7 +160,7 @@ async function describeTools(runnerId, tool, deps) {
112
160
  return 0;
113
161
  }
114
162
  /**
115
- * `--text "hi" --conversation-id X` as the tool's own arguments, typed by the inputSchema the server
163
+ * `--text "hi" --thread-id X` as the tool's own arguments, typed by the inputSchema the server
116
164
  * advertises, so an agent never types JSON (braces and quotes) into a shell command.
117
165
  */
118
166
  async function flagArguments(runnerId, tool, flags, deps) {
@@ -190,16 +238,59 @@ function coerce(type, value) {
190
238
  return type === undefined ? value : undefined;
191
239
  }
192
240
  }
241
+ // The parity test (server listing-description-realpg) fails when set_listing_description gains an argument this line does not name.
242
+ export const LISTING_DESCRIBE_USAGE = "Usage: agentradar listing describe <runner-id> --capabilities <text>";
243
+ // The parity test (server inbox-search-realpg) fails when inbox_search gains an argument this line does not name.
244
+ export const INBOX_SEARCH_USAGE = "Usage: agentradar inbox search <runner-id> [--query <text>] [--channel <c>] [--thread-id <id>] [--sender <s>] [--from <iso>] [--to <iso>] [--has-attachment true] [--unread true] [--unacked true] [--limit <n>] [--before <cursor>] [--after <cursor>] [--around <message-id>] [--json]";
193
245
  /**
194
- * Ruling 49 — `agentradar task create|repeat <runner-id> --args '<json>'`: the agent's own `create_task`
195
- * or `create_recurring_task`, on the same AWP endpoint with the same key, so who it may assign to is the
196
- * server's decision exactly as it is over MCP.
246
+ * INBOX-SEARCH (Vadym 9974) — `agentradar inbox search <runner-id> [--query <text>] [--<filter> <value> ...] [--json]`:
247
+ * the MCP `inbox_search` on the same endpoint with the same key. One block per message, or the tool's JSON with --json.
197
248
  */
198
- export async function createTaskOverCli(runnerId, recurring, argumentsJson, deps = {}) {
249
+ export async function inboxSearchOverCli(runnerId, flags, json, deps = {}) {
250
+ const log = deps.log ?? console.log;
251
+ return await callToolOverCli(runnerId, "inbox_search", undefined, json ? deps : { ...deps, log: (text) => log(inboxLines(text)) }, flags);
252
+ }
253
+ function inboxLines(text) {
254
+ let body;
255
+ try {
256
+ body = JSON.parse(text);
257
+ }
258
+ catch {
259
+ return text;
260
+ }
261
+ const results = body.results;
262
+ if (!Array.isArray(results))
263
+ return text;
264
+ if (!results.length)
265
+ return "No messages found.";
266
+ const pages = [body.older && `Older: --before ${body.older}`, body.newer && `Newer: --after ${body.newer}`].filter(Boolean);
267
+ return [...results.map((r) => `${r.at} ${r.channel} ${r.sender?.name ?? r.from ?? ""}\n thread ${r.threadId} message ${r.messageId}\n ${r.snippet}`), ...pages].join("\n");
268
+ }
269
+ /**
270
+ * Vadym 10719 — `agentradar permissions mine|roles|set|request <runner-id> [--<argument> <value> ...]`: the MCP permission
271
+ * tools on the same endpoint with the same key. The parity test (server permission-tools-realpg) fails when one drifts.
272
+ */
273
+ export const PERMISSION_VERB_TOOLS = {
274
+ mine: "my_permissions",
275
+ roles: "read_role_permissions",
276
+ set: "set_role_permission",
277
+ request: "request_permission",
278
+ };
279
+ /**
280
+ * Ruling 49 — `agentradar task create|repeat|make-recurring <runner-id> --args '<json>'`: the agent's own
281
+ * `create_task`, `create_recurring_task` or `make_task_recurring` (Vadym 10317), on the same AWP endpoint
282
+ * with the same key, so who it may assign to is the server's decision exactly as it is over MCP.
283
+ */
284
+ export const TASK_VERB_TOOLS = {
285
+ create: "create_task",
286
+ repeat: "create_recurring_task",
287
+ "make-recurring": "make_task_recurring",
288
+ };
289
+ export async function createTaskOverCli(runnerId, verb, argumentsJson, deps = {}) {
199
290
  const parameters = parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
200
291
  if (!parameters)
201
292
  return 1;
202
- return await callAwpTool(runnerId, recurring ? "create_recurring_task" : "create_task", parameters, deps);
293
+ return await callAwpTool(runnerId, TASK_VERB_TOOLS[verb], parameters, deps);
203
294
  }
204
295
  function parseArgumentsObject(argumentsJson, logError) {
205
296
  let parsed;
@@ -240,11 +331,40 @@ export async function callAwpTool(runnerId, name, toolArguments, deps) {
240
331
  return 1;
241
332
  }
242
333
  log(said);
334
+ await cliVersionNotice(said, logError);
243
335
  return 0;
244
336
  }
337
+ /**
338
+ * Vadym 9970: the server names the CLI it expects (`cli: { latest, min }`, on a successful get_my_context).
339
+ * When this one is behind, the agent reads it in its own output (stderr, so --json stdout stays parseable).
340
+ */
341
+ async function cliVersionNotice(said, logError) {
342
+ let cli;
343
+ try {
344
+ cli = JSON.parse(said).cli;
345
+ }
346
+ catch {
347
+ return;
348
+ }
349
+ if (!cli || typeof cli.latest !== "string")
350
+ return;
351
+ const mine = await runningVersion().catch(() => null);
352
+ if (!mine || !newer(cli.latest, mine))
353
+ return;
354
+ logError(typeof cli.min === "string" && newer(cli.min, mine)
355
+ ? `agentradar ${mine} is below the minimum ${cli.min}: upgrade required. Run \`agentradar upgrade\`.`
356
+ : `agentradar ${cli.latest} is available (you run ${mine}). Run \`agentradar upgrade\`.`);
357
+ }
245
358
  const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
246
359
  /** One JSON-RPC request on the runner's AWP MCP endpoint with the runner's own key; null after logging why it failed. */
247
360
  export async function awpRequest(runnerId, method, params, deps) {
361
+ const target = await awpEndpoint(runnerId, deps);
362
+ if (!target)
363
+ return null;
364
+ return postAwp(target.url, target.bearer, method, params, deps);
365
+ }
366
+ /** The runner's MCP endpoint and bearer: its runner file, else a hosted run's own environment. */
367
+ export async function awpEndpoint(runnerId, deps) {
248
368
  const logError = deps.logError ?? console.error;
249
369
  if (!RUNNER_ID.test(runnerId)) {
250
370
  logError("Invalid runner ID");
@@ -275,6 +395,10 @@ export async function awpRequest(runnerId, method, params, deps) {
275
395
  }
276
396
  url = `${origin}/api/mcp`;
277
397
  }
398
+ return { url, bearer };
399
+ }
400
+ async function postAwp(url, bearer, method, params, deps) {
401
+ const logError = deps.logError ?? console.error;
278
402
  const response = await (deps.fetch ?? fetch)(url, {
279
403
  method: "POST",
280
404
  headers: {
package/dist/update.js CHANGED
@@ -16,7 +16,7 @@ const serviceCopy = (root) => basename(dirname(root)) === "cli" && basename(dirn
16
16
  export const runningVersion = async () => JSON.parse(await readFile(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
17
17
  /** The version on disk: for a service's copy, the one `current` points at, which another runner may have moved. */
18
18
  export const installedVersion = async (root = PACKAGE_ROOT) => JSON.parse(await readFile(join(serviceCopy(root) ? join(dirname(root), "current") : root, "package.json"), "utf8")).version;
19
- const newer = (a, b) => {
19
+ export const newer = (a, b) => {
20
20
  const [x, y] = [VERSION.exec(a), VERSION.exec(b)];
21
21
  if (!x || !y)
22
22
  return false;
@@ -0,0 +1,59 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { awpEndpoint, awpRequest } from "./tool-run.js";
7
+ /**
8
+ * VAULT-GIT (Vadym 10186): `agentradar vault clone <runner-id> <vault-id> [dir]` — the vault as a git repository.
9
+ * `export_vault` mints a 15-minute link to a git bundle of the documents this agent can read; this downloads it and
10
+ * clones from it, so each revision is a commit with its author and `git log` / `git blame` work locally.
11
+ */
12
+ export async function cloneVaultOverCli(runnerId, vaultId, dir, deps = {}) {
13
+ const log = deps.log ?? console.log;
14
+ const logError = deps.logError ?? console.error;
15
+ const target = resolve(dir ?? `vault-${vaultId.slice(0, 8)}`);
16
+ // git clone refuses a non-empty target; say so before asking the server for anything.
17
+ if (await stat(target).then(() => true, () => false)) {
18
+ logError(`${target} already exists. Pick a new folder.`);
19
+ return 1;
20
+ }
21
+ const endpoint = await awpEndpoint(runnerId, deps);
22
+ const body = endpoint && await awpRequest(runnerId, "tools/call", { name: "export_vault", arguments: { vaultId } }, deps);
23
+ if (!endpoint || !body)
24
+ return 1;
25
+ const text = body.result?.content?.[0]?.text;
26
+ const answer = (() => { try {
27
+ return JSON.parse(String(text));
28
+ }
29
+ catch {
30
+ return null;
31
+ } })();
32
+ if (body.result?.isError || !answer?.ok || !answer.url) {
33
+ logError(answer?.error ?? (typeof text === "string" ? text : "AgentRadar did not export the vault."));
34
+ return 1;
35
+ }
36
+ // The link is relative when the server does not know its public address: it is on the MCP's own origin.
37
+ const bundleUrl = new URL(answer.url, endpoint.url);
38
+ const download = await (deps.fetch ?? fetch)(bundleUrl);
39
+ if (!download.ok) {
40
+ logError(`The export link was refused (${download.status}). It lasts 15 minutes; run the clone again.`);
41
+ return 1;
42
+ }
43
+ const scratch = await mkdtemp(join(tmpdir(), "agentradar-vault-"));
44
+ try {
45
+ const bundle = join(scratch, "vault.bundle");
46
+ await writeFile(bundle, Buffer.from(await download.arrayBuffer()));
47
+ await (deps.git ?? ((args) => promisify(execFile)("git", args).then(() => undefined)))(["clone", "--quiet", bundle, target]);
48
+ }
49
+ catch (error) {
50
+ logError(`git could not clone the vault: ${error instanceof Error ? error.message : String(error)}`);
51
+ return 1;
52
+ }
53
+ finally {
54
+ await rm(scratch, { recursive: true, force: true });
55
+ }
56
+ log(`Cloned vault ${vaultId} into ${target}: one commit per revision; git log and git blame work there.`);
57
+ return 0;
58
+ }
59
+ //# sourceMappingURL=vault-clone.js.map
@@ -0,0 +1,135 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { basename, join, posix, relative, resolve, sep } from "node:path";
3
+ import { awpRequest } from "./tool-run.js";
4
+ /**
5
+ * VAULT-IMPORT (Vadym 10190): `agentradar vault import <runner-id> <dir> [--vault <id>] [--source <name>] [--write]`.
6
+ * Reads a folder of Markdown and skills (an Obsidian vault, or a Notion, Confluence or GitBook export) and sends it
7
+ * to `import_vault` in batches. Every batch is dry-run first, so a secret or a refusal anywhere stops the import
8
+ * before any batch is written; `--write` then writes them. Without `--write` it only reports.
9
+ */
10
+ const LIMITS = { files: 2_000, fileBytes: 1_000_000, totalBytes: 50_000_000, batchBytes: 8_000_000 };
11
+ const DOCUMENT = /\.(md|markdown|txt)$/i;
12
+ /** Images and PDFs travel as base64, up to what fits one request; anything else as its path only. */
13
+ const ATTACHMENT = /\.(png|jpe?g|gif|webp|pdf)$/i;
14
+ const ATTACHMENT_BYTES = 6_000_000;
15
+ const SKIPPED_DIRS = new Set([".git", ".obsidian", ".trash", "node_modules"]);
16
+ async function walk(root, dir = root, out = []) {
17
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
18
+ if (entry.isDirectory()) {
19
+ if (!SKIPPED_DIRS.has(entry.name))
20
+ await walk(root, join(dir, entry.name), out);
21
+ }
22
+ else if (entry.isFile())
23
+ out.push(relative(root, join(dir, entry.name)).split(sep).join("/"));
24
+ }
25
+ return out;
26
+ }
27
+ export async function importVaultOverCli(runnerId, dir, options, deps = {}) {
28
+ const log = deps.log ?? console.log;
29
+ const logError = deps.logError ?? console.error;
30
+ const root = resolve(dir);
31
+ if (!(await stat(root).catch(() => null))?.isDirectory()) {
32
+ logError(`${dir} is not a folder.`);
33
+ return 2;
34
+ }
35
+ const paths = (await walk(root)).sort();
36
+ if (paths.length > LIMITS.files) {
37
+ logError(`${paths.length} files: an import is at most ${LIMITS.files}. Nothing was sent.`);
38
+ return 2;
39
+ }
40
+ const skillRoots = paths.filter((p) => posix.basename(p).toLowerCase() === "skill.md").map((p) => posix.dirname(p));
41
+ const skillOf = (p) => skillRoots.find((r) => r === "." || p.startsWith(`${r}/`));
42
+ // A skill folder travels whole in one batch; an attachment travels as its path only, so the server can report it.
43
+ const groups = new Map();
44
+ let total = 0;
45
+ for (const p of paths) {
46
+ const skill = skillOf(p);
47
+ let content = null;
48
+ let contentBase64;
49
+ if (skill === undefined && ATTACHMENT.test(p)) {
50
+ const bytes = await readFile(join(root, p));
51
+ total += bytes.length;
52
+ if (bytes.length <= ATTACHMENT_BYTES)
53
+ contentBase64 = bytes.toString("base64");
54
+ }
55
+ else if (skill !== undefined || DOCUMENT.test(p)) {
56
+ const bytes = await readFile(join(root, p));
57
+ if (bytes.length > LIMITS.fileBytes) {
58
+ logError(`${p} is over ${LIMITS.fileBytes} bytes. Nothing was sent.`);
59
+ return 2;
60
+ }
61
+ total += bytes.length;
62
+ content = bytes.toString("utf8");
63
+ }
64
+ const key = skill ?? p;
65
+ groups.set(key, [...(groups.get(key) ?? []), { path: p, content, ...(contentBase64 ? { contentBase64 } : {}) }]);
66
+ }
67
+ if (total > LIMITS.totalBytes) {
68
+ logError(`${total} bytes: an import is at most ${LIMITS.totalBytes}. Nothing was sent.`);
69
+ return 2;
70
+ }
71
+ const batches = [[]];
72
+ let size = 0;
73
+ for (const group of groups.values()) {
74
+ const bytes = group.reduce((sum, file) => sum + Buffer.byteLength(file.content ?? "") + (file.contentBase64?.length ?? 0) + file.path.length, 0);
75
+ if (size + bytes > LIMITS.batchBytes && batches.at(-1).length) {
76
+ batches.push([]);
77
+ size = 0;
78
+ }
79
+ batches.at(-1).push(...group);
80
+ size += bytes;
81
+ }
82
+ const source = options.source ?? `folder:${basename(root)}`;
83
+ const send = async (files, dryRun) => {
84
+ const body = await awpRequest(runnerId, "tools/call", { name: "import_vault", arguments: {
85
+ ...(options.vaultId ? { vaultId: options.vaultId } : {}), files, source, dryRun, sourcePaths: paths, sourceBytes: total,
86
+ } }, deps);
87
+ const text = body?.result?.content?.[0]?.text;
88
+ return typeof text === "string" ? JSON.parse(text) : null;
89
+ };
90
+ const run = async (dryRun) => {
91
+ const counts = {};
92
+ const refused = [];
93
+ let notInSource = [];
94
+ for (const [n, batch] of batches.entries()) {
95
+ const answer = await send(batch, dryRun);
96
+ // Each call is all-or-nothing; earlier calls of a write stay written, and a rerun skips them as unchanged.
97
+ const done = !dryRun && n > 0 ? ` ${n} of ${batches.length} batches were imported before this one; run the import again to finish (unchanged pages are skipped).` : "";
98
+ if (!answer) {
99
+ if (done)
100
+ logError(done.trim());
101
+ return null;
102
+ }
103
+ if (!answer.ok) {
104
+ logError(`${answer.error ?? "AgentRadar refused the import."}${done}`);
105
+ return null;
106
+ }
107
+ for (const [action, n] of Object.entries(answer.counts ?? {}))
108
+ counts[action] = (counts[action] ?? 0) + n;
109
+ refused.push(...(answer.files ?? []).filter((f) => f.action === "refuse").map((f) => `${f.source}: ${f.reason}`));
110
+ notInSource = answer.notInSource ?? notInSource;
111
+ }
112
+ return { counts, refused, notInSource };
113
+ };
114
+ const planned = await run(true);
115
+ if (!planned)
116
+ return 1;
117
+ const report = (r, verb) => {
118
+ log(`${verb}: ${r.counts.new ?? 0} new, ${r.counts.update ?? 0} updated, ${r.counts.skip ?? 0} unchanged, ${r.counts.refuse ?? 0} left out, ${r.counts.skill ?? 0} skill files (source ${source}).`);
119
+ for (const line of r.refused)
120
+ log(` left out ${line}`);
121
+ if (r.notInSource.length)
122
+ log(` imported before from ${source} and no longer in the folder (kept): ${r.notInSource.join(", ")}`);
123
+ };
124
+ if (!options.write) {
125
+ report(planned, "Dry run");
126
+ log("Nothing was written. Add --write to import.");
127
+ return 0;
128
+ }
129
+ const written = await run(false);
130
+ if (!written)
131
+ return 1;
132
+ report(written, "Imported");
133
+ return 0;
134
+ }
135
+ //# sourceMappingURL=vault-import.js.map
@@ -102,6 +102,26 @@ export function skillsDirFor(harness, env) {
102
102
  return env.AR_BASE ? join(env.AR_BASE, ".claude", "skills") : null;
103
103
  }
104
104
  const SKILLS_DIR = skillsDirFor(AR_AGENT_HARNESS, process.env);
105
+ /**
106
+ * Vadym 9988: the server's start instructions with this agent's brief, where the harness reads standing rules at
107
+ * every session start: Claude's project rules, Codex's per-agent AGENTS.md. Refreshed on the skills cadence.
108
+ */
109
+ export function briefFileFor(harness, env) {
110
+ if (harness === "codex") return env.CODEX_HOME ? join(env.CODEX_HOME, "AGENTS.md") : null;
111
+ return env.AR_BASE ? join(env.AR_BASE, ".claude", "rules", "agentradar-brief.md") : null;
112
+ }
113
+ const BRIEF_FILE = briefFileFor(AR_AGENT_HARNESS, process.env);
114
+ export const BRIEF_MARK = "<!-- Written by AgentRadar and replaced on every sync; edits here are lost. -->";
115
+ /** Replaces the file itself, never a symlink's target, and never a file it did not write (a vendor's AGENTS.md). */
116
+ export function writeBrief(file, text) {
117
+ const current = fs.lstatSync(file, { throwIfNoEntry: false });
118
+ if (current && (!current.isFile() || !fs.readFileSync(file, "utf8").startsWith(BRIEF_MARK))) return false;
119
+ fs.mkdirSync(dirname(file), { recursive: true });
120
+ const temporary = `${file}.${process.pid}.tmp`;
121
+ fs.writeFileSync(temporary, `${BRIEF_MARK}\n${text.trim()}\n`);
122
+ fs.renameSync(temporary, file);
123
+ return true;
124
+ }
105
125
  const SKILLS_SYNC_INTERVAL_MS = Number(process.env.SKILLS_SYNC_INTERVAL_MS ?? "600000");
106
126
  const MANAGED_MARK = ".agentradar-managed";
107
127
 
@@ -312,10 +332,13 @@ export function agentAuthState() {
312
332
  return AR_AGENT_HARNESS === "codex" ? codexAuthState() : claudeAuthState();
313
333
  }
314
334
 
315
- export function runnerState(auth = agentAuthState, sample = capturePane) {
335
+ /** AGENT-GUARD-ACTIVE: the supervisor sets AR_GUARD_MARKER when the session must load agent-guard, whose SessionStart hook writes it. */
336
+ export const guardLoaded = (marker = process.env.AR_GUARD_MARKER) => !marker || fs.existsSync(marker);
337
+
338
+ export function runnerState(auth = agentAuthState, sample = capturePane, guard = guardLoaded) {
316
339
  const authState = auth();
317
340
  if (authState === "unauthenticated") return "auth_required";
318
- if (authState !== "authenticated") return "starting";
341
+ if (authState !== "authenticated" || !guard()) return "starting";
319
342
  const pane = sample();
320
343
  if (pane?.rateLimited) return "rate_limited";
321
344
  return pane?.inputReady ? "ready" : "starting";
@@ -723,6 +746,16 @@ export function writeManagedSkills(skillsDir, skills) {
723
746
  }
724
747
 
725
748
  async function syncSkills() {
749
+ try {
750
+ const init = await mcpPostIsolated("initialize", {
751
+ protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "agentradar-i2-feeder", version: "1.0.0" },
752
+ });
753
+ if (BRIEF_FILE && init?.instructions && !writeBrief(BRIEF_FILE, init.instructions)) {
754
+ console.warn(`[feeder] brief sync: ${BRIEF_FILE} is not AgentRadar's; left alone`);
755
+ }
756
+ } catch (err) {
757
+ console.warn(`[feeder] brief sync failed: ${err instanceof Error ? err.message : String(err)}`);
758
+ }
726
759
  try {
727
760
  const answer = toolJson(await mcpPostIsolated("tools/call", { name: "my_skills", arguments: {} }));
728
761
  // A refusal or an older server keeps whatever this box already has.
@@ -33,6 +33,7 @@ export function formatInjectionFrame(event, cliRunnerId) {
33
33
  const tags = [`source=${e.source ?? "unknown"}`];
34
34
  if (e.channel) tags.push(`channel=${e.channel}`);
35
35
  if (e.conversationId) tags.push(`conversationId=${e.conversationId}`);
36
+ if (e.taskId) tags.push(`taskId=${e.taskId}`); // assignment events: the agent works THIS task, not one dug out of its log
36
37
  if (e.eventId) tags.push(`eventId=${e.eventId}`);
37
38
  const header = `[AgentRadar inbound · ${tags.join(" · ")}]`;
38
39
  const body = oneLine(e.text) || "(no text)";
@@ -70,6 +70,8 @@ BROWSER_APP="${BROWSER_APP:-Google Chrome}" # #150: app to ensure-up fo
70
70
  BROWSER_READY_MAX_SEC="${BROWSER_READY_MAX_SEC:-20}" # #150: bounded wait for the browser proc on cold boot
71
71
  BROWSER_READY_SETTLE_SEC="${BROWSER_READY_SETTLE_SEC:-3}" # #150: settle for extension/native-host after cold launch
72
72
  HEALTH_POLL_SEC="${HEALTH_POLL_SEC:-15}"
73
+ AR_API400_LOOP_MAX="${AR_API400_LOOP_MAX:-3}" # same `API Error: 400` this many times in the pane = poisoned session
74
+ AR_API400_COOLDOWN_SEC="${AR_API400_COOLDOWN_SEC:-300}" # at most one fresh-session heal per this window
73
75
 
74
76
  # --- 2a. R004 HARNESS SELECTION — one documented variable picks the CLI ---------
75
77
  # The Bible names two agent harnesses: Claude Code and Codex. `claude` is the default, so every existing
@@ -175,7 +177,7 @@ if [ -n "${AGENT_TOOL_ENV_FILE}" ]; then
175
177
  # identity). Reject at parse time (visible warning) AND — belt-and-suspenders — the launch lists
176
178
  # the reserved allowlist AFTER TOOL_ENV_ARGS so `env` last-wins keeps reserved vars authoritative.
177
179
  case "${_tline%%=*}" in
178
- HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC|CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF|CODEX_HOME|AR_CLI_RUNNER_ID|AR_AGENTRADAR_BIN|AR_DISCIPLINE_EVERY|CLAUDE_CODE_PLUGIN_CACHE_DIR|AR_RESUME_FLAGS|AR_FRESH_FLAGS|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN)
180
+ HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC|CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF|CODEX_HOME|AR_CLI_RUNNER_ID|AR_AGENTRADAR_BIN|AR_DISCIPLINE_EVERY|CLAUDE_CODE_PLUGIN_CACHE_DIR|AR_GUARD_MARKER|AR_RESUME_FLAGS|AR_FRESH_FLAGS|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN)
179
181
  log "WARN ignoring reserved key '${_tline%%=*}' in ${AGENT_TOOL_ENV_FILE} (cannot override supervisor/agent env)"; continue ;;
180
182
  esac
181
183
  TOOL_ENV_ARGS+=("${_tline}")
@@ -193,7 +195,13 @@ unset CLAUDE_CODE_OAUTH_TOKEN
193
195
  _claude_cfg=()
194
196
  [ -n "${CLAUDE_CONFIG_DIR:-}" ] && _claude_cfg=( CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR}" )
195
197
  # agent-guard (templates/agent-guard): its hooks ask the server what this runner still owes through the CLI.
196
- for _k in AR_CLI_RUNNER_ID AR_AGENTRADAR_BIN AR_DISCIPLINE_EVERY AR_MODEL_SWITCH_DIR CLAUDE_CODE_PLUGIN_CACHE_DIR; do
198
+ # AGENT-GUARD-ACTIVE (Vadym 10699): the guard's SessionStart hook writes AR_GUARD_MARKER, and the feeder and the
199
+ # channel report "starting", never "ready", until it exists. A session that did not load the guard gets no work.
200
+ # Outside the secrets dir, which goes on exit: a pane that outlives this supervisor keeps its proof.
201
+ # The caller's own pane (AR_ATTACH) was not started with --settings, so it is not gated here.
202
+ AR_GUARD_MARKER=""
203
+ [ "${AR_AGENT_HARNESS}" = "claude" ] && [ -n "${CLAUDE_SETTINGS:-}" ] && [ "${AR_ATTACH:-}" != "1" ] && AR_GUARD_MARKER="${HOME}/.agentradar/run/${AGENT_NAME}.guard-loaded"
204
+ for _k in AR_CLI_RUNNER_ID AR_AGENTRADAR_BIN AR_DISCIPLINE_EVERY AR_MODEL_SWITCH_DIR CLAUDE_CODE_PLUGIN_CACHE_DIR AR_GUARD_MARKER; do
197
205
  [ -n "${!_k:-}" ] && _claude_cfg+=( "${_k}=${!_k}" )
198
206
  done
199
207
 
@@ -282,6 +290,16 @@ ensure_browser() {
282
290
  log "WARN browser '${BROWSER_APP}' not detected within ${BROWSER_READY_MAX_SEC}s — launching --chrome anyway (it will retry-connect)"
283
291
  }
284
292
 
293
+ # A session whose history the API rejects (e.g. `messages.240.content.0.thinking: each thinking block must
294
+ # contain thinking`) answers every new turn with the same 400 forever; resuming it replays the same history.
295
+ # True when one identical `API Error: 400` line (digits normalised: the message index moves) fills the pane
296
+ # scrollback AR_API400_LOOP_MAX times. ponytail: only the last 200 lines are read, so a very slow loop could slip by.
297
+ api_error_loop() {
298
+ "${TMUX_BIN}" capture-pane -p -J -t "${TMUX_SESSION}" -S -200 2>/dev/null \
299
+ | grep -o 'API Error: 400 .*' | sed -E 's/[0-9]+/N/g' | sort | uniq -c | sort -rn \
300
+ | awk -v m="${AR_API400_LOOP_MAX}" 'NR==1{ok=($1>=m)} END{exit !ok}'
301
+ }
302
+
285
303
  # --- 3. Ensure the agent's tmux+claude session (idempotent) -------------------
286
304
  ensure_agent() {
287
305
  if session_exists && pane_alive; then
@@ -298,6 +316,9 @@ ensure_agent() {
298
316
  log "session '${TMUX_SESSION}' exists but claude pane is dead — recreating"
299
317
  "${TMUX_BIN}" kill-session -t "${TMUX_SESSION}" 2>/dev/null || true
300
318
  fi
319
+ [ -n "${AR_GUARD_MARKER}" ] && rm -f "${AR_GUARD_MARKER}" # this launch must prove the guard afresh
320
+ _resume_now="${AR_RESUME_FLAGS}"
321
+ if [ "${_FORCE_FRESH:-0}" = 1 ]; then _resume_now="${AR_FRESH_FLAGS}"; _FORCE_FRESH=0; fi # poisoned history: do not resume it
301
322
  ensure_browser # #150: bring up the browser first for --chrome agents (idempotent, no-op otherwise)
302
323
  if [ -n "${PRE_LAUNCH_HOOK}" ] && [ -x "${PRE_LAUNCH_HOOK}" ]; then
303
324
  log "running pre-launch hook: ${PRE_LAUNCH_HOOK}"
@@ -358,10 +379,10 @@ ensure_agent() {
358
379
  TERM="${AR_AGENT_TERM:-tmux-256color}" COLORTERM="${AR_AGENT_COLORTERM:-truecolor}" \
359
380
  LANG="${LANG:-en_US.UTF-8}" LC_ALL="${LC_ALL:-}" LC_CTYPE="${AR_AGENT_LC_CTYPE:-en_US.UTF-8}" \
360
381
  TMPDIR="${TMPDIR:-/tmp}" NODE_OPTIONS="${NODE_OPTIONS:-}" \
361
- CODEX_HOME="${CODEX_HOME:-}" AR_RESUME_FLAGS="${AR_RESUME_FLAGS}" AR_FRESH_FLAGS="${AR_FRESH_FLAGS}" \
382
+ CODEX_HOME="${CODEX_HOME:-}" AR_RESUME_FLAGS="${_resume_now}" AR_FRESH_FLAGS="${AR_FRESH_FLAGS}" \
362
383
  DISABLE_AUTOUPDATER=1 CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF=1 \
363
384
  ${_claude_cfg[@]+"${_claude_cfg[@]}"} ${_tool_env[@]+"${_tool_env[@]}"} \
364
- /bin/sh -c 'if [ -n "${AR_TOOL_ENV_FILE:-}" ]; then while IFS= read -r _l || [ -n "$_l" ]; do case "$_l" in ""|\#*) continue ;; esac; _k=${_l%%=*}; _v=${_l#*=}; case "$_k" in HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC|CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF|CODEX_HOME|AR_CLI_RUNNER_ID|AR_AGENTRADAR_BIN|AR_DISCIPLINE_EVERY|CLAUDE_CODE_PLUGIN_CACHE_DIR|AR_RESUME_FLAGS|AR_FRESH_FLAGS|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN) continue ;; esac; export "$_k=$_v"; done < "$AR_TOOL_ENV_FILE"; unset AR_TOOL_ENV_FILE _l _k _v; fi; RF="${AR_RESUME_FLAGS:-}"; FF="${AR_FRESH_FLAGS:-}"; unset AR_RESUME_FLAGS AR_FRESH_FLAGS; CB="$1"; shift; S=$(date +%s); "$CB" $RF "$@" || [ $(( $(date +%s) - S )) -ge 30 ] || exec "$CB" $FF "$@"' sh "${AGENT_BIN}" "${_launch_flags[@]}" || {
385
+ /bin/sh -c 'if [ -n "${AR_TOOL_ENV_FILE:-}" ]; then while IFS= read -r _l || [ -n "$_l" ]; do case "$_l" in ""|\#*) continue ;; esac; _k=${_l%%=*}; _v=${_l#*=}; case "$_k" in HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC|CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF|CODEX_HOME|AR_CLI_RUNNER_ID|AR_AGENTRADAR_BIN|AR_DISCIPLINE_EVERY|CLAUDE_CODE_PLUGIN_CACHE_DIR|AR_GUARD_MARKER|AR_RESUME_FLAGS|AR_FRESH_FLAGS|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN) continue ;; esac; export "$_k=$_v"; done < "$AR_TOOL_ENV_FILE"; unset AR_TOOL_ENV_FILE _l _k _v; fi; RF="${AR_RESUME_FLAGS:-}"; FF="${AR_FRESH_FLAGS:-}"; unset AR_RESUME_FLAGS AR_FRESH_FLAGS; CB="$1"; shift; S=$(date +%s); "$CB" $RF "$@" || [ $(( $(date +%s) - S )) -ge 30 ] || exec "$CB" $FF "$@"' sh "${AGENT_BIN}" "${_launch_flags[@]}" || {
365
386
  log "tmux could not start agent session '${TMUX_SESSION}'"
366
387
  return 1
367
388
  }
@@ -412,7 +433,7 @@ if [ "${AR_FEED}" = "feeder" ]; then
412
433
  # AR_TOOL_ENV_FILE: the login probe must see the same gateway env the pane runs with.
413
434
  AWP_MCP_URL="${AWP_MCP_URL}" AWP_BEARER_FILE="${AWP_BEARER_FILE}" TMUX_SESSION="${TMUX_SESSION}" \
414
435
  AGENT_PID_PATTERN="${AGENT_PID_PATTERN}" AR_AGENT_HARNESS="${AR_AGENT_HARNESS}" AGENT_BIN="${AGENT_BIN}" \
415
- AR_TOOL_ENV_FILE="${_TOOL_ENV_FILE}" \
436
+ AR_TOOL_ENV_FILE="${_TOOL_ENV_FILE}" AR_GUARD_MARKER="${AR_GUARD_MARKER}" \
416
437
  node "${FEEDER_JS}" &
417
438
  FEEDER_PID=$!
418
439
  log "feeder pid ${FEEDER_PID}"
@@ -449,8 +470,16 @@ while true; do
449
470
  log "feeder exited — exiting so the service manager restarts the whole unit"
450
471
  exit 1
451
472
  fi
473
+ _heal=0
452
474
  if ! pane_alive; then
453
475
  log "agent pane died — recovering it in place"
476
+ _heal=1
477
+ elif [ "${AR_ATTACH:-}" != "1" ] && [ $(( $(date +%s) - ${_LAST_HEAL:-0} )) -ge "${AR_API400_COOLDOWN_SEC}" ] && api_error_loop; then
478
+ log "agent pane is looping on the same API 400 — replacing it with a FRESH session"
479
+ _LAST_HEAL=$(date +%s); _FORCE_FRESH=1; _heal=1
480
+ "${TMUX_BIN}" kill-session -t "${TMUX_SESSION}" 2>/dev/null || true
481
+ fi
482
+ if [ "${_heal}" = 1 ]; then
454
483
  if ensure_agent; then
455
484
  if [ -n "${BRIDGE_PID}" ]; then
456
485
  kill "${BRIDGE_PID}" 2>/dev/null || true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentradar-cli",
3
- "version": "0.2.10",
3
+ "version": "0.3.0",
4
4
  "engines": {
5
5
  "node": ">=20"
6
6
  },
@@ -17,7 +17,10 @@
17
17
  "dist/skill-install.js",
18
18
  "dist/tool-run.js",
19
19
  "dist/sync.js",
20
+ "dist/vault-import.js",
20
21
  "dist/update.js",
22
+ "dist/commands.js",
23
+ "dist/vault-clone.js",
21
24
  "dist/vendor-runner/**",
22
25
  "dist/agentradar-vendor/SKILL.md",
23
26
  "dist/agent-guard",