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 +39 -0
- package/dist/agent-guard/hooks/hooks.json +8 -8
- package/dist/agent-guard/scripts/compaction-report.mjs +76 -0
- package/dist/agent-guard/skills/agentradar-discipline/SKILL.md +6 -0
- package/dist/agentradar-vendor/SKILL.md +12 -10
- package/dist/channel.js +57 -32
- package/dist/index.js +67 -103
- package/dist/runner.js +16 -11
- package/dist/secrets.js +3 -1
- package/dist/setup.js +124 -58
- package/dist/skill-install.js +14 -78
- package/dist/tool-run.js +167 -172
- package/dist/update.js +27 -2
- package/dist/vendor-runner/feeder.mjs +183 -23
- package/dist/vendor-runner/session-frame.mjs +97 -36
- package/dist/vendor-runner/skill-zip.mjs +76 -0
- package/dist/vendor-runner/supervisor/supervise-agent.sh +81 -15
- package/dist/vendor-runner/terminal-bridge.mjs +5 -0
- package/package.json +3 -3
- package/dist/commands.js +0 -74
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
|
|
5
|
-
AgentRadar
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
39
|
-
5. Report actual usage
|
|
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
|
|
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 {
|
|
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.
|
|
12
|
-
export const CHANNEL_BLOCKED = "AgentRadar has not
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
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" &&
|
|
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: `${
|
|
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: {
|
|
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 (!
|
|
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
|
}
|