agentradar-cli 0.3.5 → 0.3.7

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 ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * ADMIN-BROADCAST (Vadym 12310/12828): `agentradar admin broadcast` for an instance admin. Authenticates with a board
3
+ * API key in AGENTRADAR_ADMIN_TOKEN, never a runner key; the server refuses anyone who is not an instance admin.
4
+ */
5
+ export const ADMIN_BROADCAST_USAGE = "Usage: agentradar admin broadcast --dry-run|--send --target all --text <text> --cta <next step>"
6
+ + " (env AGENTRADAR_ADMIN_TOKEN = an instance admin's board API key; AGENTRADAR_API_URL = the API base)";
7
+ export async function adminOverCli(args, deps = {}) {
8
+ const env = deps.env ?? process.env, log = deps.log ?? console.log, logError = deps.logError ?? console.error;
9
+ const named = {};
10
+ const modes = args.slice(1).filter((arg) => arg === "--dry-run" || arg === "--send");
11
+ for (let i = 1; i < args.length; i++) {
12
+ if (["--target", "--text", "--cta"].includes(args[i]))
13
+ named[args[i].slice(2)] = args[++i] ?? "";
14
+ else if (!modes.includes(args[i]))
15
+ modes.push("?");
16
+ }
17
+ if (args[0] !== "broadcast" || modes.length !== 1 || modes[0] === "?" || named.target !== "all" || !named.text || !named.cta) {
18
+ logError(ADMIN_BROADCAST_USAGE);
19
+ return 2;
20
+ }
21
+ const token = env.AGENTRADAR_ADMIN_TOKEN?.trim();
22
+ if (!token) {
23
+ logError("Set AGENTRADAR_ADMIN_TOKEN to an instance admin's board API key");
24
+ return 2;
25
+ }
26
+ 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" }),
30
+ });
31
+ const text = await res.text();
32
+ if (!res.ok) {
33
+ logError(`Refused (${res.status}): ${text}`);
34
+ return 1;
35
+ }
36
+ log(text);
37
+ return 0;
38
+ }
39
+ //# sourceMappingURL=admin.js.map
@@ -1,12 +1,12 @@
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; 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; 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 }] }],
5
- "Stop": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" stop", "timeout": 30 }] }],
6
- "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" prompt", "timeout": 10 }] }],
7
- "PreCompact": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PreCompact", "timeout": 30 }] }],
8
- "PostCompact": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PostCompact", "timeout": 30 }] }],
9
- "PreModelSwitch": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PreModelSwitch || exit 2", "timeout": 30 }] }],
10
- "PostModelSwitch": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PostModelSwitch", "timeout": 30 }] }]
4
+ "SessionStart": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" start", "timeout": 10 }] }],
5
+ "Stop": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" stop", "timeout": 30 }] }],
6
+ "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" prompt", "timeout": 10 }] }],
7
+ "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
+ "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 }] }],
9
+ "PreModelSwitch": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PreModelSwitch || exit 2", "timeout": 30 }] }],
10
+ "PostModelSwitch": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PostModelSwitch", "timeout": 30 }] }]
11
11
  }
12
12
  }
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ // Reports this session's compactions to AgentRadar (Vadym 11657). Registered for PreCompact and PostCompact as
3
+ // `compaction-report.mjs <event>`; JSON on stdin. Observation only: it never blocks (exit 0 whatever happens).
4
+ // PostCompact stdin carries no token counts, so they come from the transcript's compact_boundary line.
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
10
+
11
+ const ID = /^[a-zA-Z0-9_-]{1,100}$/;
12
+ const TAIL_BYTES = 1024 * 1024;
13
+ /** The last lines of a transcript, read from its last 1 MB only (a long session's file is far larger). */
14
+ const tail = (path) => {
15
+ let fd;
16
+ try {
17
+ fd = openSync(path, "r");
18
+ const { size } = fstatSync(fd);
19
+ const buf = Buffer.alloc(Math.min(size, TAIL_BYTES));
20
+ readSync(fd, buf, 0, buf.length, size - buf.length);
21
+ return buf.toString("utf8").split("\n").slice(-200);
22
+ } catch { return []; } finally { if (fd !== undefined) closeSync(fd); }
23
+ };
24
+
25
+ /** The newest compact_boundary written at or after `sinceMs`, as { preTokens, postTokens, durationMs }. */
26
+ export function lastBoundary(transcriptPath, sinceMs) {
27
+ for (const line of tail(transcriptPath).reverse()) {
28
+ if (!line.includes('"compact_boundary"')) continue;
29
+ try {
30
+ const entry = JSON.parse(line);
31
+ if (Date.parse(entry.timestamp) < sinceMs) return null;
32
+ const { preTokens = null, postTokens = null, durationMs = null } = entry.compactMetadata ?? {};
33
+ return { preTokens, postTokens, durationMs };
34
+ } catch { /* partial line: keep looking */ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /** One hook call. `send` posts a report; `stateDir` holds the start time between the two hooks. */
40
+ export async function reportCompaction(event, input, { send, followUp, stateDir = tmpdir(), now = Date.now }) {
41
+ if (input?.hook_event_name !== event || !ID.test(input.session_id ?? "")) return;
42
+ const marker = join(stateDir, `agentradar-compaction-${input.session_id}.json`);
43
+ const trigger = typeof input.trigger === "string" ? input.trigger.slice(0, 40) : null;
44
+ if (event === "PreCompact") {
45
+ writeFileSync(marker, JSON.stringify({ at: now() }), { mode: 0o600 });
46
+ return send({ phase: "start", trigger });
47
+ }
48
+ if (event !== "PostCompact") return;
49
+ let startedAt = now();
50
+ if (existsSync(marker)) { try { startedAt = JSON.parse(readFileSync(marker, "utf8")).at; } catch { /* keep now */ } }
51
+ rmSync(marker, { force: true });
52
+ // The compact_boundary line (the only source of token counts) is written after this hook returns, so it is
53
+ // 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
+ }
57
+
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
+ for (let waited = 0; waited <= waitMs; waited += pollMs) {
61
+ const boundary = lastBoundary(transcriptPath, startedAt - 1_000);
62
+ if (boundary) return send({ phase: "tokens", trigger, ...boundary });
63
+ await sleep(pollMs);
64
+ }
65
+ }
66
+
67
+ 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 });
69
+ const spawnFollowUp = (job) => spawn(process.execPath, [fileURLToPath(import.meta.url), "tokens", JSON.stringify(job)], { detached: true, stdio: "ignore" }).unref();
70
+
71
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
72
+ try {
73
+ if (process.argv[2] === "tokens") await reportTokens(JSON.parse(process.argv[3]), { send: sendToServer });
74
+ else await reportCompaction(process.argv[2], JSON.parse(readFileSync(0, "utf8")), { send: sendToServer, followUp: spawnFollowUp });
75
+ } catch { /* observation only */ }
76
+ }
@@ -20,6 +20,12 @@ You act through `agentradar call <runner-id> <tool>`. Your runner id is in `$AR_
20
20
 
21
21
  The Stop hook runs the same check and will not let you stop while something is owed.
22
22
 
23
+ ## Hosted workspace lifetime
24
+
25
+ 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
+
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.
28
+
23
29
  ## Always
24
30
 
25
31
  - Answer every message in the channel it came from. A human who wrote is waiting for you; answer them first.
@@ -1,30 +1,32 @@
1
1
  ---
2
2
  name: agentradar-vendor
3
3
  description: >
4
- Run an AgentRadar vendor agent on vendor-owned compute through the single
5
- AgentRadar MCP. Use when receiving client work, replying, reporting usage,
4
+ Run an AgentRadar vendor agent on vendor-owned compute through the
5
+ AgentRadar CLI. Use when receiving client work, replying, reporting usage,
6
6
  or checking context for an AgentRadar engagement.
7
7
  ---
8
8
 
9
9
  # AgentRadar vendor agent
10
10
 
11
- You work on the vendor's computer and connect to AgentRadar through the `agentradar` MCP only. AgentRadar holds every client channel credential; never ask for or store a client's Slack, email, Telegram, or AgentRadar token.
11
+ 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.
12
+
13
+ You work on the vendor's computer and act on AgentRadar through the `agentradar` CLI only (`agentradar run $AR_CLI_RUNNER_ID <tool>`). AgentRadar holds every client channel credential; never ask for or store a client's Slack, email, Telegram, or AgentRadar token.
12
14
 
13
15
  ## Authentication
14
16
 
15
17
  - Authenticate Claude Code interactively with `/login` and the vendor's claude.ai subscription.
16
18
  - Never use `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`.
17
- - Never print, inspect, copy, or move the AgentRadar MCP authorization header. The setup CLI stores it in the project-local entry of `~/.claude.json`, outside the project repository.
19
+ - Never print, inspect, copy, or move the AgentRadar key. The setup CLI stores it in your runner config under `~/.agentradar/agents/`, outside the project repository.
18
20
 
19
21
  ## Start of every session
20
22
 
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.
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.
22
24
 
23
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.
24
26
 
25
27
  In your first session, call `read_onboarding_obligations` once (reading them acknowledges the safety rules), then `read_readiness`.
26
28
 
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 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`) 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 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`).
28
30
 
29
31
  ## Talking to humans
30
32
 
@@ -32,10 +34,10 @@ Write to humans so a 13-year-old would understand: a few short plain lines, then
32
34
 
33
35
  ## Work loop
34
36
 
35
- 1. Use the AgentRadar MCP to receive the next event or assigned work.
37
+ 1. Run `agentradar run $AR_CLI_RUNNER_ID inbox next` to receive the next event or assigned work.
36
38
  2. Read the supplied engagement context before acting.
37
39
  3. Do the work in this project folder on the vendor's compute.
38
- 4. Reply through the MCP so AgentRadar can enforce the client boundary and audit the action.
39
- 5. Report actual usage through the MCP when the work incurs billable time or metered extras.
40
+ 4. Reply with `agentradar run $AR_CLI_RUNNER_ID reply` so AgentRadar can enforce the client boundary and audit the action.
41
+ 5. Report actual usage with `report_usage` when the work incurs billable time or metered extras.
40
42
 
41
- Do not connect directly to a client's workspace or bypass the MCP with direct channel/API calls.
43
+ Do not connect directly to a client's workspace or bypass AgentRadar with direct channel/API calls.
package/dist/channel.js CHANGED
@@ -1,34 +1,60 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync } from "node:fs";
3
- import { readFile } from "node:fs/promises";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { readFile, rm } from "node:fs/promises";
4
4
  import { createRequire } from "node:module";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
- import { envValue } from "./tool-run.js";
8
+ import { awpEndpoint } from "./tool-run.js";
9
+ import { noteCliUpdate } from "./update.js";
9
10
  import { trustedMcpEndpoint } from "./setup.js";
10
11
  import { syncJoinedSkills } from "./skill-install.js";
11
- export const CHANNEL_GUIDANCE = "AgentRadar channel messages are wake signals only. Call next_event to receive the events, then pass their eventIds as ackEventIds on your next call. Answer livenessProbe with liveness_ack. Never acknowledge an event you have not read. Never poll next_event on your own or keep a loop running: when a channel message arrives, drain it, then end your turn.";
12
- export const CHANNEL_BLOCKED = "AgentRadar has not observed a next_event call from this session. Channel delivery is unconfirmed. On Claude Team/Enterprise an Owner must enable Channels (channelsEnabled); the development flag cannot override that policy. Until then, deliver with the feeder: run your join again with --feeder (agentradar join <your join link> --feeder). Your company sees this agent as not receiving messages until one or the other works.";
13
- /** One local MCP, forwarding tools to AWP. Wake signals never consume the durable queue. */
12
+ export const CHANNEL_GUIDANCE = "AgentRadar channel messages are wake signals only. Receive the events with `agentradar run $AR_CLI_RUNNER_ID inbox next`; the next call acknowledges the batch you read. Answer a liveness check with `agentradar run $AR_CLI_RUNNER_ID liveness_ack --nonce <nonce>`. Never acknowledge an event you have not read. Never poll on your own or keep a loop running: when a channel message arrives, drain it, then end your turn.";
13
+ export const CHANNEL_BLOCKED = "AgentRadar has not seen this session run `agentradar run $AR_CLI_RUNNER_ID inbox next`. Channel delivery is unconfirmed. On Claude Team/Enterprise an Owner must enable Channels (channelsEnabled); the development flag cannot override that policy. Your company sees this agent as not receiving messages until the channel works.";
14
+ // The runner's frame text (templates/vendor-runner, shipped beside this file as dist/vendor-runner), so both say a restart one way.
15
+ const runnerFrame = () => import(new URL(import.meta.url.endsWith(".ts")
16
+ ? "../../../templates/vendor-runner/session-frame.mjs" : "./vendor-runner/session-frame.mjs", import.meta.url).href);
17
+ /** The channel: a local stdio MCP with no tools, only wake signals. The agent acts through the CLI; wake signals never consume the durable queue. */
18
+ /** The wake line. Zero events never says "run inbox next": that call has nothing to return. Each reason names its own tool. */
19
+ export function wakeText(runnerId, count, payload) {
20
+ const run = `agentradar run ${runnerId}`;
21
+ if (count)
22
+ return `${payload.firstStep ? `${payload.firstStep} ` : ""}${count} new AgentRadar events; run \`${run} inbox next\`${payload.livenessProbe ? " to receive your liveness check" : ""}.`;
23
+ return [
24
+ payload.onboarding && `AgentRadar session started, no messages waiting. Run \`${run} get_my_context\` now for your identity, peers and rules.`,
25
+ payload.livenessProbe && `AgentRadar liveness check, no messages waiting. Run \`${run} liveness_ack --nonce ${payload.livenessProbe.nonce}\` now.`,
26
+ ].filter(Boolean).join(" ");
27
+ }
14
28
  export async function startChannel(runnerId, deps = {}) {
15
29
  if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(runnerId))
16
30
  throw new Error("Invalid runner ID");
17
31
  const home = deps.home ?? homedir();
18
- const config = await readFile(join(home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
19
- const endpoint = trustedMcpEndpoint(envValue(config, "AWP_MCP_URL") ?? "");
20
- const bearer = envValue(config, "AWP_BEARER");
21
- if (!endpoint || !bearer)
22
- throw new Error("Runner configuration requires a trusted AWP_MCP_URL and AWP_BEARER");
23
32
  const output = deps.output ?? process.stdout;
24
33
  const logError = deps.logError ?? console.error;
34
+ // The runner file, else a hosted session's own PAPERCLIP_API_* (hosted-session-start.mjs puts them in this server's env).
35
+ const target = await awpEndpoint(runnerId, { home, logError });
36
+ const endpoint = trustedMcpEndpoint(target?.url ?? "");
37
+ const bearer = target?.bearer;
38
+ if (!endpoint || !bearer)
39
+ throw new Error("Runner configuration requires a trusted AWP_MCP_URL and AWP_BEARER");
40
+ // TRC-RESTART-REASON: the hosted bridge leaves why the last workspace stopped; the first wake with work says it, once.
41
+ const notice = join(home, ".agentradar", "run", "restart-notice.json");
25
42
  const sessionId = randomUUID();
26
43
  const stop = new AbortController();
27
44
  let sequence = 0;
28
45
  let polling = false;
29
46
  let agentPolled = false;
47
+ const startedAt = Date.now();
48
+ // The CLI rewrites <runner>.delivered.json after every `inbox next`, so a newer mtime is the session having polled.
49
+ const delivered = join(home, ".agentradar", "agents", `${runnerId}.delivered.json`);
50
+ const polled = () => agentPolled ||= (() => { try {
51
+ return statSync(delivered).mtimeMs >= startedAt;
52
+ }
53
+ catch {
54
+ return false;
55
+ } })();
30
56
  // Vadym 10342: an unconfirmed channel is reported to AgentRadar (runnerState channel_unconfirmed), so the owner sees
31
- // the agent is not receiving messages; "ready" again once the session does call next_event.
57
+ // the agent is not receiving messages; "ready" again once the session does run `inbox next`.
32
58
  let unconfirmed = null;
33
59
  let warning;
34
60
  let skillSync;
@@ -39,10 +65,11 @@ export async function startChannel(runnerId, deps = {}) {
39
65
  const response = await (deps.fetch ?? fetch)(endpoint, {
40
66
  method: "POST",
41
67
  redirect: "error",
42
- headers: { "x-agentradar-cli": createRequire(import.meta.url)("../package.json").version, authorization: `Bearer ${bearer}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
68
+ headers: { "x-agentradar-cli": createRequire(import.meta.url)("../package.json").version, "x-agentradar-surface": "channel", authorization: `Bearer ${bearer}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
43
69
  body: JSON.stringify({ jsonrpc: "2.0", id: ++sequence, method, params }),
44
70
  signal: AbortSignal.any([stop.signal, AbortSignal.timeout(230_000)]),
45
71
  });
72
+ await noteCliUpdate(response.headers, logError);
46
73
  if (!response.ok) {
47
74
  const said = response.status === 426 ? await response.json().then((body) => body.error?.message).catch(() => null) : null;
48
75
  throw new Error(said ?? `AgentRadar refused the channel call (${response.status})`);
@@ -54,9 +81,14 @@ export async function startChannel(runnerId, deps = {}) {
54
81
  return JSON.parse(json);
55
82
  };
56
83
  const next = (args = {}) => remote("tools/call", { name: "next_event", arguments: { ...args, sessionId } });
57
- const pause = () => new Promise((resolve) => {
58
- const done = () => { clearTimeout(timer); stop.signal.removeEventListener("abort", done); resolve(); };
84
+ // AGENT-GUARD-ACTIVE: the supervisor's marker, written by agent-guard's SessionStart hook in this session.
85
+ const guardLoaded = () => !process.env.AR_GUARD_MARKER || existsSync(process.env.AR_GUARD_MARKER);
86
+ const pause = (until) => new Promise((resolve) => {
87
+ const done = () => { clearTimeout(timer); clearInterval(watch); stop.signal.removeEventListener("abort", done); resolve(); };
59
88
  const timer = setTimeout(done, deps.retryMs ?? 3_000);
89
+ // COLD-START-WARMUP: a booting session's work is held until it polls ready, so poll the moment the marker lands.
90
+ const watch = until && setInterval(() => { if (until())
91
+ done(); }, 100);
60
92
  stop.signal.addEventListener("abort", done, { once: true });
61
93
  if (stop.signal.aborted)
62
94
  done();
@@ -65,8 +97,10 @@ export async function startChannel(runnerId, deps = {}) {
65
97
  let lastWake = "";
66
98
  let lastSent = 0;
67
99
  let selftestStarted = false;
100
+ let guarded = true;
68
101
  while (!stop.signal.aborted) {
69
102
  try {
103
+ guarded = guardLoaded(); // first, so a call that throws after the marker lands still gets the full pause
70
104
  if (!selftestStarted) {
71
105
  // Establish the heartbeat without entering the empty queue's 200-second hold first.
72
106
  const connected = await next({ runnerState: "starting" });
@@ -81,11 +115,9 @@ export async function startChannel(runnerId, deps = {}) {
81
115
  await next({ runnerState: "channel_unconfirmed" });
82
116
  unconfirmed = "reported";
83
117
  }
84
- if (unconfirmed === "reported" && agentPolled)
118
+ if (unconfirmed === "reported" && polled())
85
119
  unconfirmed = null;
86
120
  // While reported, no runnerState: the stored state stands, and the poll still drains for wake signals.
87
- // AGENT-GUARD-ACTIVE: the supervisor's marker, written by agent-guard's SessionStart hook in this session.
88
- const guarded = !process.env.AR_GUARD_MARKER || existsSync(process.env.AR_GUARD_MARKER);
89
121
  const response = await next(unconfirmed ? {} : { runnerState: guarded ? "ready" : "starting" });
90
122
  if (response.error || response.result?.isError)
91
123
  throw new Error("AgentRadar refused next_event; check the runner key and its poll permission");
@@ -94,10 +126,13 @@ export async function startChannel(runnerId, deps = {}) {
94
126
  const events = payload.events ?? [];
95
127
  const wake = JSON.stringify([events.map((event) => event.eventId), payload.livenessProbe?.nonce, Boolean(payload.onboarding)]);
96
128
  if ((events.length || payload.livenessProbe || payload.onboarding) && (wake !== lastWake || Date.now() - lastSent >= 30_000)) {
129
+ const restart = events.length ? (await runnerFrame()).restartLine(await readFile(notice, "utf8").then(JSON.parse, () => null), runnerId) : "";
97
130
  await send({ jsonrpc: "2.0", method: "notifications/claude/channel", params: {
98
- content: `${payload.firstStep ? `${payload.firstStep} ` : ""}${events.length} new AgentRadar events; call next_event${payload.livenessProbe ? " to receive your liveness check" : ""}.`,
131
+ content: `${restart ? `${restart} ` : ""}${wakeText(runnerId, events.length, payload)}`,
99
132
  meta: { count: String(events.length) },
100
133
  } });
134
+ if (restart)
135
+ await rm(notice, { force: true });
101
136
  lastWake = wake;
102
137
  lastSent = Date.now();
103
138
  }
@@ -109,14 +144,14 @@ export async function startChannel(runnerId, deps = {}) {
109
144
  if (!stop.signal.aborted)
110
145
  logError(/below the minimum/.test(said) ? said : "AgentRadar channel poll failed; retrying. Check connectivity and the runner key.");
111
146
  }
112
- await pause();
147
+ await pause(guarded ? undefined : guardLoaded);
113
148
  }
114
149
  };
115
150
  const handle = async (message) => {
116
151
  if (message.method === "initialize") {
117
152
  await send({ jsonrpc: "2.0", id: message.id, result: {
118
153
  protocolVersion: "2024-11-05",
119
- capabilities: { tools: {}, experimental: { "claude/channel": {} } },
154
+ capabilities: { experimental: { "claude/channel": {} } },
120
155
  serverInfo: { name: "agentradar", version: createRequire(import.meta.url)("../package.json").version },
121
156
  instructions: CHANNEL_GUIDANCE,
122
157
  } });
@@ -124,7 +159,7 @@ export async function startChannel(runnerId, deps = {}) {
124
159
  else if (message.method === "notifications/initialized") {
125
160
  if (!polling) {
126
161
  polling = true;
127
- warning = setTimeout(() => { if (!agentPolled) {
162
+ warning = setTimeout(() => { if (!polled()) {
128
163
  logError(CHANNEL_BLOCKED);
129
164
  unconfirmed = "pending";
130
165
  } }, deps.confirmMs ?? 30_000);
@@ -142,16 +177,6 @@ export async function startChannel(runnerId, deps = {}) {
142
177
  if (message.method === "ping") {
143
178
  await send({ jsonrpc: "2.0", id: message.id, result: {} });
144
179
  }
145
- else if (message.method === "tools/list" || message.method === "tools/call") {
146
- const params = message.params ?? {};
147
- const isNext = message.method === "tools/call" && params.name === "next_event";
148
- const response = isNext ? await next(params.arguments ?? {}) : await remote(message.method, params);
149
- if (isNext && !response.error && !response.result?.isError) {
150
- agentPolled = true;
151
- clearTimeout(warning);
152
- }
153
- await send({ jsonrpc: "2.0", id: message.id, ...(response.error ? { error: response.error } : { result: response.result }) });
154
- }
155
180
  else {
156
181
  await send({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Method not found" } });
157
182
  }