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/setup.js
CHANGED
|
@@ -8,6 +8,7 @@ import { homedir } from "node:os";
|
|
|
8
8
|
import * as tls from "node:tls";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
10
|
import { syncJoinedSkills } from "./skill-install.js";
|
|
11
|
+
import { cliHeader, noteCliUpdate } from "./update.js";
|
|
11
12
|
const DEFAULT_API_URL = "https://vendor.agentradar.com/api";
|
|
12
13
|
/** The service's CLI build (the CLI itself and the agent-guard plugin): `installCli` keeps it current, never the npx cache. */
|
|
13
14
|
const cliDist = (home) => join(home, ".agentradar", "cli", "current", "dist");
|
|
@@ -28,11 +29,13 @@ const SUPERVISOR_HARNESS = {
|
|
|
28
29
|
generic: "generic",
|
|
29
30
|
};
|
|
30
31
|
async function postJson(fetchImpl, url, body) {
|
|
31
|
-
|
|
32
|
+
const response = await fetchImpl(url, {
|
|
32
33
|
method: "POST",
|
|
33
|
-
headers: { "content-type": "application/json", accept: "application/json", "cache-control": "no-store" },
|
|
34
|
+
headers: { ...(await cliHeader()), "content-type": "application/json", accept: "application/json", "cache-control": "no-store" },
|
|
34
35
|
body: JSON.stringify(body),
|
|
35
36
|
});
|
|
37
|
+
await noteCliUpdate(response.headers);
|
|
38
|
+
return response;
|
|
36
39
|
}
|
|
37
40
|
/**
|
|
38
41
|
* The server's own words for a refusal, when it sent any.
|
|
@@ -117,7 +120,7 @@ export function trustedMcpEndpoint(value) {
|
|
|
117
120
|
endpoint.hostname.endsWith(".localhost"));
|
|
118
121
|
return endpoint.protocol === "https:" || loopbackHttp ? endpoint : null;
|
|
119
122
|
}
|
|
120
|
-
/**
|
|
123
|
+
/** AgentRadar is CLI-only: a project entry for `agentradar` is only ever removed (never written), and an agent acts through `agentradar run`. */
|
|
121
124
|
async function installProjectMcp(configPath, cwd, installation, trust,
|
|
122
125
|
/** Vadym 10342: a joined Claude Code agent's AgentRadar channel (`agentradar channel <runner-id>`, stdio). */
|
|
123
126
|
channel) {
|
|
@@ -153,14 +156,6 @@ channel) {
|
|
|
153
156
|
if (channel) {
|
|
154
157
|
mcpServers.agentradar = channel;
|
|
155
158
|
}
|
|
156
|
-
else if (installation && endpoint) {
|
|
157
|
-
mcpServers.agentradar = {
|
|
158
|
-
type: "http",
|
|
159
|
-
url: endpoint.toString(),
|
|
160
|
-
headers: { Authorization: `Bearer ${installation.bearer}` },
|
|
161
|
-
timeout: 1_800_000,
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
159
|
else {
|
|
165
160
|
delete mcpServers.agentradar;
|
|
166
161
|
}
|
|
@@ -178,6 +173,68 @@ channel) {
|
|
|
178
173
|
await chmod(configPath, 0o600);
|
|
179
174
|
return projectPath;
|
|
180
175
|
}
|
|
176
|
+
/** Drop the `[mcp_servers.agentradar]` table (and its sub-tables) when it is the old HTTP one (has a `url`). Null = nothing to drop. */
|
|
177
|
+
export function stripCodexAgentradar(toml) {
|
|
178
|
+
const lines = toml.split("\n");
|
|
179
|
+
const out = [];
|
|
180
|
+
let dropped = false;
|
|
181
|
+
for (let i = 0; i < lines.length; i++) {
|
|
182
|
+
if (!/^\s*\[\s*mcp_servers\s*\.\s*["']?agentradar["']?\s*[\].]/.test(lines[i])) {
|
|
183
|
+
out.push(lines[i]);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
let end = i + 1;
|
|
187
|
+
while (end < lines.length && !/^\s*\[/.test(lines[end]))
|
|
188
|
+
end++;
|
|
189
|
+
// A following [mcp_servers.agentradar.http_headers] belongs to the same entry.
|
|
190
|
+
while (end < lines.length && /^\s*\[\s*mcp_servers\s*\.\s*["']?agentradar["']?\s*\./.test(lines[end])) {
|
|
191
|
+
end++;
|
|
192
|
+
while (end < lines.length && !/^\s*\[/.test(lines[end]))
|
|
193
|
+
end++;
|
|
194
|
+
}
|
|
195
|
+
if (/^\s*url\s*=/m.test(lines.slice(i, end).join("\n"))) {
|
|
196
|
+
dropped = true;
|
|
197
|
+
i = end - 1;
|
|
198
|
+
}
|
|
199
|
+
else
|
|
200
|
+
out.push(lines[i]);
|
|
201
|
+
}
|
|
202
|
+
return dropped ? out.join("\n") : null;
|
|
203
|
+
}
|
|
204
|
+
/** Upgrade and sync: remove the old AgentRadar HTTP MCP entry an older `agentradar setup` or join wrote: Claude's project entry, the project .mcp.json and ~/.codex/config.toml. Silent unless it removes one. */
|
|
205
|
+
export async function dropStaleMcp(cwd, home, log = console.log, env = process.env) {
|
|
206
|
+
const said = (where) => log(`Removed the old AgentRadar MCP entry from ${where}: AgentRadar is CLI-only now, use agentradar run <runner-id> <tool>.`);
|
|
207
|
+
const configPath = claudeConfigPath(home, env);
|
|
208
|
+
try {
|
|
209
|
+
const projectPath = await realpath(cwd);
|
|
210
|
+
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
211
|
+
if (config.projects?.[projectPath]?.mcpServers?.agentradar?.type === "http") {
|
|
212
|
+
await installProjectMcp(configPath, cwd, null, false);
|
|
213
|
+
said(configPath);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch { /* no Claude config, or not a project it knows: nothing stale to remove */ }
|
|
217
|
+
const mcpJson = join(cwd, ".mcp.json");
|
|
218
|
+
try {
|
|
219
|
+
const parsed = JSON.parse(await readFile(mcpJson, "utf8"));
|
|
220
|
+
const entry = parsed.mcpServers?.agentradar;
|
|
221
|
+
if (entry && (entry.type === "http" || entry.url)) {
|
|
222
|
+
delete parsed.mcpServers.agentradar;
|
|
223
|
+
await writeFile(mcpJson, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
224
|
+
said(mcpJson);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch { /* no .mcp.json, or not JSON we can edit: leave it */ }
|
|
228
|
+
const codexToml = join(env.CODEX_HOME?.trim() || join(home, ".codex"), "config.toml");
|
|
229
|
+
try {
|
|
230
|
+
const stripped = stripCodexAgentradar(await readFile(codexToml, "utf8"));
|
|
231
|
+
if (stripped !== null) {
|
|
232
|
+
await writeFile(codexToml, stripped);
|
|
233
|
+
said(codexToml);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch { /* no Codex config: nothing stale to remove */ }
|
|
237
|
+
}
|
|
181
238
|
export function shellValue(value) {
|
|
182
239
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
183
240
|
}
|
|
@@ -392,7 +449,7 @@ async function installRunner(home, projectPath, approved, service, feed) {
|
|
|
392
449
|
const envPath = join(agentDir, `${approved.runnerId}.env`);
|
|
393
450
|
const joined = [];
|
|
394
451
|
if (service) {
|
|
395
|
-
joined.push(`
|
|
452
|
+
joined.push(`AR_AGENTRADAR_BIN=${shellValue(join(cliDist(home), "index.js"))}`);
|
|
396
453
|
let toolKeys = [];
|
|
397
454
|
if (harness === "codex") {
|
|
398
455
|
const { codexHome, keys } = await codexServiceHome(home, approved.runnerId, service.env);
|
|
@@ -412,32 +469,43 @@ async function installRunner(home, projectPath, approved, service, feed) {
|
|
|
412
469
|
...(service.env.AGENTRADAR_INPUT_READY_PATTERN ? [`AR_INPUT_READY_PATTERN=${shellValue(service.env.AGENTRADAR_INPUT_READY_PATTERN)}`] : []), `AGENT_PID_PATTERN=${shellValue(basename(bin).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))}`);
|
|
413
470
|
}
|
|
414
471
|
else {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
joined.push(`
|
|
472
|
+
if (service.inPlace)
|
|
473
|
+
joined.push("AR_INPLACE=1"); // the user's own launcher runs claude; no supervisor reads this file
|
|
474
|
+
else {
|
|
475
|
+
// The caller's own conversation, both times: a resume that fails tries it once more, never a fresh session.
|
|
476
|
+
joined.push(`AR_RESUME_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`, `AR_FRESH_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`);
|
|
477
|
+
// A person's model switch: the feeder and agent-guard's hooks meet in this directory, keyed by the session.
|
|
478
|
+
joined.push(`AR_CLAUDE_SESSION_ID=${shellValue(service.sessionId)}`, `AR_MODEL_SWITCH_DIR=${shellValue(join(configDir, "model-switch"))}`);
|
|
479
|
+
const configDirEnv = service.env.CLAUDE_CONFIG_DIR?.trim();
|
|
480
|
+
if (configDirEnv)
|
|
481
|
+
joined.push(`CLAUDE_CONFIG_DIR=${shellValue(resolve(configDirEnv))}`);
|
|
482
|
+
}
|
|
422
483
|
// agent-guard, enabled for this session only (`--settings`), loaded from the copy inside this package, with
|
|
423
484
|
// its plugin state under the runner's config so the vendor's own Claude plugins are never touched.
|
|
424
485
|
const settings = join(configDir, "agentradar-settings.json");
|
|
425
486
|
await writePrivateFile(settings, `${JSON.stringify({
|
|
426
487
|
extraKnownMarketplaces: { agentradar: { source: { source: "directory", path: join(cliDist(home), "agent-guard") } } },
|
|
427
488
|
enabledPlugins: { "agent-guard@agentradar": true },
|
|
489
|
+
// Vadym 12201: AskUserQuestion blocks the turn on a pick nobody at the pane makes, stalling every channel.
|
|
490
|
+
permissions: { deny: ["AskUserQuestion"] },
|
|
428
491
|
// Nobody sits at the pane to accept bypass mode's warning screen; the operator's consent was given at approval.
|
|
429
492
|
...(service.bypass ? { skipDangerousModePermissionPrompt: true } : {}),
|
|
430
493
|
}, null, 2)}\n`);
|
|
431
|
-
if (service.
|
|
432
|
-
|
|
433
|
-
|
|
494
|
+
if (!service.inPlace) {
|
|
495
|
+
if (service.bypass)
|
|
496
|
+
joined.push(`CLAUDE_EXTRA_FLAGS=${shellValue("--permission-mode bypassPermissions")}`);
|
|
497
|
+
joined.push(`CLAUDE_SETTINGS=${shellValue(settings)}`, `CLAUDE_CODE_PLUGIN_CACHE_DIR=${shellValue(join(configDir, "plugins"))}`);
|
|
498
|
+
// Vadym 13444: the agent keeps every MCP and channel it already has; AgentRadar's channel is added next to them.
|
|
499
|
+
joined.push("AR_MCP_STRICT=0");
|
|
500
|
+
}
|
|
434
501
|
}
|
|
435
502
|
if (service.attach) {
|
|
436
503
|
// The caller's own tmux pane: the feeder types into it; the supervisor never starts, kills or streams it.
|
|
437
504
|
// TMUX names the caller's tmux server, which a service started by launchd or systemd would not find.
|
|
438
505
|
joined.push("AR_ATTACH=1", `TMUX_SESSION=${shellValue(service.attach)}`, "TERMINAL_BRIDGE_ENABLED=0", ...(service.env.TMUX ? [`TMUX=${shellValue(service.env.TMUX)}`] : []), ...(harness === "generic" ? [] : [`AGENT_PID_PATTERN=${shellValue(`(^|/)${harness ?? "claude"}( |$)`)}`]));
|
|
439
506
|
}
|
|
440
|
-
|
|
507
|
+
// An in-place join has no supervisor to read tool.env, so the caller's gateway credentials are not copied to disk.
|
|
508
|
+
const toolEnv = service.inPlace ? [] : Object.entries(service.env)
|
|
441
509
|
.filter(([key, value]) => (TOOL_ENV.test(key) || toolKeys.includes(key)) && key !== "ANTHROPIC_API_KEY" && value && !value.includes("\n"))
|
|
442
510
|
.map(([key, value]) => `${key}=${value}`);
|
|
443
511
|
if (toolEnv.length) {
|
|
@@ -450,21 +518,15 @@ async function installRunner(home, projectPath, approved, service, feed) {
|
|
|
450
518
|
// the service runs; the supervisor adds --dangerously-load-development-channels server:agentradar.
|
|
451
519
|
await writePrivateFile(mcpConfig, `${JSON.stringify(feed === "push"
|
|
452
520
|
? { mcpServers: { agentradar: { type: "stdio", command: process.execPath, args: [join(cliDist(home), "index.js"), "channel", approved.runnerId] } } }
|
|
453
|
-
:
|
|
454
|
-
mcpServers: {
|
|
455
|
-
agentradar: {
|
|
456
|
-
type: "http",
|
|
457
|
-
url: endpoint.toString(),
|
|
458
|
-
headers: { Authorization: `Bearer ${approved.installation.bearer}` },
|
|
459
|
-
},
|
|
460
|
-
},
|
|
461
|
-
}, null, 2)}\n`);
|
|
521
|
+
: { mcpServers: {} }, null, 2)}\n`);
|
|
462
522
|
await writePrivateFile(envPath, [
|
|
463
523
|
`AGENT_NAME=${shellValue(approved.runnerId)}`,
|
|
464
524
|
`AR_BASE=${shellValue(projectPath)}`,
|
|
465
525
|
`AWP_MCP_URL=${shellValue(endpoint.toString())}`,
|
|
466
526
|
`AWP_BEARER=${shellValue(approved.installation.bearer)}`,
|
|
467
527
|
`MCP_CONFIG=${shellValue(mcpConfig)}`,
|
|
528
|
+
// Every convention line in the brief and skills says `agentradar run $AR_CLI_RUNNER_ID <tool>`.
|
|
529
|
+
`AR_CLI_RUNNER_ID=${shellValue(approved.runnerId)}`,
|
|
468
530
|
// Written only when the vendor declared a runtime. An absent variable is the supervisor's own
|
|
469
531
|
// default, so writing `AR_AGENT_HARNESS=claude` unconditionally would change nothing except to make
|
|
470
532
|
// every existing env file look as though a choice had been made.
|
|
@@ -542,17 +604,17 @@ export async function setupVendorAgent(deps = {}) {
|
|
|
542
604
|
const runnerEnv = await installRunner(home, projectPath, approved);
|
|
543
605
|
// Print the path, not just "installed". D3 was a silent write to the wrong file under a
|
|
544
606
|
// relocated CLAUDE_CONFIG_DIR; naming the target turns that class of failure into a glance.
|
|
545
|
-
log(`AgentRadar
|
|
607
|
+
log(`AgentRadar runner set up for this project (no MCP entry; the agent acts through agentradar run) → ${configPath}`);
|
|
546
608
|
log(trust
|
|
547
609
|
? `--trust: pre-accepted the trust dialog for ${projectPath}. Claude Code will run this folder's existing hooks and settings without asking.`
|
|
548
|
-
: `Claude Code will ask you to trust ${projectPath} on first launch — approve it there. Not pre-accepted, because that would trust everything already in the folder, not just
|
|
610
|
+
: `Claude Code will ask you to trust ${projectPath} on first launch — approve it there. Not pre-accepted, because that would trust everything already in the folder, not just AgentRadar. Re-run with --trust for unattended onboarding.`);
|
|
549
611
|
log(`Runner installed → ${runnerEnv}`);
|
|
550
612
|
if ((approved.harness ?? "").trim().toLowerCase() === "codex") {
|
|
551
613
|
// Said BEFORE the vendor runs the runner, because the supervisor's refusal comes after they
|
|
552
614
|
// think setup succeeded. Codex reads its MCP servers from $CODEX_HOME/config.toml — there is no
|
|
553
615
|
// `--strict-mcp-config` to point at the file this setup just wrote — so the isolation boundary is
|
|
554
616
|
// a per-agent home the vendor supplies, and the supervisor fails closed without it.
|
|
555
|
-
log(`This agent runs on Codex. Set CODEX_HOME in ${runnerEnv} to a per-agent Codex home whose config.toml declares
|
|
617
|
+
log(`This agent runs on Codex. Set CODEX_HOME in ${runnerEnv} to a per-agent Codex home whose config.toml declares no MCP servers (the agent acts through the CLI); the runner refuses to start without it.`);
|
|
556
618
|
}
|
|
557
619
|
log(`Start it with: agentradar runner start ${approved.runnerId}`);
|
|
558
620
|
log("On macOS the runner uses your existing Claude Code Keychain-backed login. CLAUDE_CODE_OAUTH_TOKEN is ignored; ANTHROPIC_API_KEY is prohibited.");
|
|
@@ -561,6 +623,7 @@ export async function setupVendorAgent(deps = {}) {
|
|
|
561
623
|
throw new Error("AgentRadar setup code expired; run setup again");
|
|
562
624
|
}
|
|
563
625
|
export const bypassOnNotice = (link) => `Bypass-permissions mode is ON on this machine: the agent runs without permission prompts. To turn it off: agentradar join ${link} --revoke-bypass`;
|
|
626
|
+
export const CLAUDE_CHANNEL_ONLY = "A Claude Code agent receives work only through the AgentRadar channel. --feeder, AGENTRADAR_FEED=feeder, --attach-tmux and --attach-current are for Codex and other CLIs; run join again without them.";
|
|
564
627
|
export const BYPASS_REVOKED = "Bypass-permissions mode is off on this machine now; the agent asks before acting again.";
|
|
565
628
|
/** A sortable key for `x.y.z[-pre]`; a release sorts above its prereleases. ponytail: prereleases of one version tie. */
|
|
566
629
|
const versionRank = (version) => {
|
|
@@ -667,36 +730,34 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
667
730
|
}
|
|
668
731
|
}
|
|
669
732
|
const label = harness === "generic" ? basename((env.AGENTRADAR_AGENT_BIN ?? "").trim()) : harness === "claude" ? "Claude Code" : "Codex";
|
|
670
|
-
// Vadym 10342:
|
|
671
|
-
//
|
|
672
|
-
|
|
673
|
-
|
|
733
|
+
// Vadym 10342 + 13430: a Claude Code agent connects through the AgentRadar channel only; nothing is ever typed
|
|
734
|
+
// into its input. The tmux feeder is for the CLIs that have no channel (Codex, generic).
|
|
735
|
+
const channel = harness === "claude";
|
|
736
|
+
// Vadym 13444: a Claude Code agent joins in place. Nothing is started, resumed or replaced; the user's own launcher
|
|
737
|
+
// gets two files and the flags to add (channel-only-claude.md §2). `--service` keeps the AgentRadar-run service.
|
|
738
|
+
const inPlace = channel && !deps.service;
|
|
739
|
+
if (channel && (deps.feeder || env.AGENTRADAR_FEED?.trim().toLowerCase() === "feeder" || deps.attach))
|
|
740
|
+
throw new Error(CLAUDE_CHANNEL_ONLY);
|
|
674
741
|
// The agent is a persistent session the service owns, not this one: no MCP entry in this folder (it acts
|
|
675
742
|
// through `agentradar run`), a runner env pinned to its own session, and a LaunchAgent / systemd unit.
|
|
676
743
|
// Which session the agent is, settled before any request: a key is issued once, so never claim one for a session that cannot run.
|
|
677
744
|
const settle = async () => {
|
|
678
745
|
const tmux = deps.tmux ?? (async (args) => (await promisify(execFile)(env.TMUX_BIN || "tmux", args, { env })).stdout);
|
|
679
|
-
// Attach to the caller
|
|
680
|
-
// pane. The service's own session (join-<request>) re-running join is not a caller to attach to.
|
|
746
|
+
// Attach to the caller's tmux pane when asked for by flag (Codex and generic only; Claude Code is refused above).
|
|
681
747
|
let attach = deps.attach === "current" ? env.TMUX_PANE : deps.attach;
|
|
682
748
|
if (deps.attach === "current" && !attach)
|
|
683
749
|
throw new Error("--attach-current needs a session running inside tmux");
|
|
684
|
-
// Never in channel mode: nothing types into the caller's pane.
|
|
685
|
-
if (!channel && !deps.attach && harness === "claude" && env.CLAUDECODE && env.TMUX && env.TMUX_PANE)
|
|
686
|
-
attach = env.TMUX_PANE;
|
|
687
750
|
if (attach) {
|
|
688
751
|
// A pane id (%3) dies with the tmux server; session:window.pane comes back when its session is recreated.
|
|
689
752
|
attach = (await tmux(["display-message", "-p", "-t", attach, "#{session_name}:#{window_index}.#{pane_index}"])).trim();
|
|
690
753
|
if (!attach)
|
|
691
754
|
throw new Error("tmux found no such pane");
|
|
692
|
-
if (!deps.attach && attach.startsWith("join-"))
|
|
693
|
-
attach = undefined;
|
|
694
755
|
}
|
|
695
756
|
// Unattended, the service resumes the caller's own Claude conversation, never a stranger's. An attached
|
|
696
757
|
// pane is never started from here, so there its session id only keys the model switch.
|
|
697
|
-
const sessionId = harness !== "claude" ? undefined
|
|
698
|
-
: await callerSession(deps, env, home, await realpath(cwd)).catch((error) => { if (
|
|
699
|
-
|
|
758
|
+
const sessionId = harness !== "claude" || inPlace ? undefined
|
|
759
|
+
: await callerSession(deps, env, home, await realpath(cwd)).catch((error) => { if (deps.session)
|
|
760
|
+
throw error; return undefined; });
|
|
700
761
|
return { attach, sessionId };
|
|
701
762
|
};
|
|
702
763
|
let where;
|
|
@@ -705,14 +766,14 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
705
766
|
* 10-04, after the Codex run stopped on an approval prompt nobody could answer); `--revoke-bypass` takes it
|
|
706
767
|
* back on this machine. An attached pane keeps the permission mode it already runs in.
|
|
707
768
|
*/
|
|
708
|
-
const localBypass = (approvedBypass, revoked) => approvedBypass && !revoked && !where.attach;
|
|
769
|
+
const localBypass = (approvedBypass, revoked) => approvedBypass && !revoked && !where.attach && !inPlace;
|
|
709
770
|
const startJoined = async (requestId, agentId, installation, approvedBypass, bypass, saved) => {
|
|
710
771
|
const runnerId = `join-${requestId}`;
|
|
711
772
|
const { attach } = where;
|
|
712
773
|
const projectPath = harness === "claude"
|
|
713
774
|
? await installProjectMcp(claudeConfigPath(home, env), cwd, null, deps.trust === true)
|
|
714
775
|
: await realpath(cwd);
|
|
715
|
-
const runnerEnv = await installRunner(home, projectPath, { status: "approved", agentId, runnerId, installation, harness }, { sessionId: where.sessionId ?? requestId, env, attach, bypass }, channel ? "push" : undefined);
|
|
776
|
+
const runnerEnv = await installRunner(home, projectPath, { status: "approved", agentId, runnerId, installation, harness }, { sessionId: where.sessionId ?? requestId, env, attach, bypass, inPlace }, channel ? "push" : undefined);
|
|
716
777
|
await saved?.();
|
|
717
778
|
log(`Runner installed → ${runnerEnv}`);
|
|
718
779
|
if (bypass)
|
|
@@ -721,6 +782,15 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
721
782
|
await syncJoinedSkills(runnerId, { home, fetch: fetchImpl, log }).catch((error) => {
|
|
722
783
|
log(`Your AgentRadar skills are not installed yet (${String(error?.message ?? error).split("\n")[0]}); the runner installs them once it is running.`);
|
|
723
784
|
});
|
|
785
|
+
if (inPlace) {
|
|
786
|
+
const config = join(home, ".agentradar", "config", runnerId);
|
|
787
|
+
if (approvedBypass)
|
|
788
|
+
log("Your company allowed bypass-permissions mode; this session keeps the permission mode it already runs in.");
|
|
789
|
+
log(`Connected, nothing started: your session, its MCPs and its channels are untouched. To receive client messages, restart your agent ONCE with these flags added to its own launch command (keep what it already has, including --continue):
|
|
790
|
+
--mcp-config ${join(config, "awp-only.json")} --dangerously-load-development-channels server:agentradar --settings ${join(config, "agentradar-settings.json")}
|
|
791
|
+
and AR_CLI_RUNNER_ID=${runnerId} in its environment. Accept the one-time development-channel prompt when it appears. To stop: agentradar leave ${runnerId}, then drop those flags.`);
|
|
792
|
+
return 0;
|
|
793
|
+
}
|
|
724
794
|
try {
|
|
725
795
|
await (deps.installService ?? ((id) => installAgentService(id, home, env)))(runnerId);
|
|
726
796
|
}
|
|
@@ -738,14 +808,10 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
738
808
|
const mode = bypass ? ", in bypass-permissions mode as your company allowed" : "";
|
|
739
809
|
if (channel) {
|
|
740
810
|
log(`Connected through the AgentRadar channel. This conversation now continues as a service in ${projectPath} (tmux session ${runnerId}${mode}): client messages are pushed into it and it answers there, with no restart from you. Exit this session now (/exit) so only the service carries on; watch it with: tmux attach -t ${runnerId}. To stop it: agentradar leave ${runnerId}`);
|
|
741
|
-
log(`If your company's Claude plan blocks channels, deliver with the feeder instead: agentradar join ${enrolmentUrl} --feeder`);
|
|
742
811
|
log("Running as a service; it restarts on crash and reboot.");
|
|
743
812
|
return 0;
|
|
744
813
|
}
|
|
745
|
-
|
|
746
|
-
log(harness === "claude"
|
|
747
|
-
? `Connected. This conversation now continues as a service in ${projectPath} (tmux session ${runnerId}${mode}): your welcome and every client message are typed into it, and it answers there. Exit this session now (/exit) so only the service carries on; watch it with: tmux attach -t ${runnerId}. To stop it: agentradar leave ${runnerId}`
|
|
748
|
-
: `Connected. Your agent now runs in its own ${label} session in ${projectPath} (tmux session ${runnerId}${mode}): your welcome and every client message are typed into it, and it answers there. There is nothing more to do in this session. To stop it: agentradar leave ${runnerId}`);
|
|
814
|
+
log(`Connected. Your agent now runs in its own ${label} session in ${projectPath} (tmux session ${runnerId}${mode}): your welcome and every client message are typed into it, and it answers there. There is nothing more to do in this session. To stop it: agentradar leave ${runnerId}`);
|
|
749
815
|
log("Running as a service; it restarts on crash and reboot.");
|
|
750
816
|
return 0;
|
|
751
817
|
};
|
|
@@ -937,8 +1003,8 @@ export async function leaveOrganisation(runnerId, deps = {}) {
|
|
|
937
1003
|
await execute("systemctl", ["--user", "disable", "--now", `agentradar-agent@${runnerId}`]).catch(() => undefined);
|
|
938
1004
|
}
|
|
939
1005
|
// The service stopping leaves the session running; `=` matches the tmux session name exactly.
|
|
940
|
-
// An attached pane is the caller's own session: it stays.
|
|
941
|
-
if (envValue(saved, "AR_ATTACH") !== "1")
|
|
1006
|
+
// An attached pane or an in-place join is the caller's own session: it stays.
|
|
1007
|
+
if (envValue(saved, "AR_ATTACH") !== "1" && envValue(saved, "AR_INPLACE") !== "1")
|
|
942
1008
|
await execute(envValue(saved, "TMUX_BIN") ?? "tmux", ["kill-session", "-t", `=${envValue(saved, "TMUX_SESSION") ?? runnerId}`]).catch(() => undefined);
|
|
943
1009
|
for (const path of [envPath, join(home, ".agentradar", "agents", `${runnerId}.delivered.json`)])
|
|
944
1010
|
await rm(path, { force: true });
|
package/dist/skill-install.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { chmod, lstat, mkdir, readFile,
|
|
1
|
+
import { chmod, lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { basename, dirname,
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { awpRequest, envValue } from "./tool-run.js";
|
|
5
5
|
const MANAGED_MARK = ".agentradar-managed";
|
|
6
|
-
const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,99}$/;
|
|
7
6
|
async function ensureDirectory(path) {
|
|
8
7
|
try {
|
|
9
8
|
const info = await lstat(path);
|
|
@@ -44,76 +43,10 @@ export async function installVendorSkill(deps = {}) {
|
|
|
44
43
|
await writeFile(join(skillDir, MANAGED_MARK), "Written by AgentRadar; replaced on every sync.\n");
|
|
45
44
|
(deps.log ?? console.log)("AgentRadar vendor skill installed for this project.");
|
|
46
45
|
}
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
* The feeder's `writeManagedSkills` (templates/vendor-runner/feeder.mjs), for the CLI: the server's answer is
|
|
50
|
-
* untrusted input to this box, so names and paths are checked, each skill is staged then renamed into place,
|
|
51
|
-
* a folder without the marker (the vendor's own) is never touched, and a managed skill no longer listed goes.
|
|
52
|
-
*/
|
|
53
|
-
const sameSkill = async (dir, files) => {
|
|
54
|
-
try {
|
|
55
|
-
const have = [];
|
|
56
|
-
for (const p of await readdir(dir, { recursive: true }))
|
|
57
|
-
if (p !== MANAGED_MARK && (await stat(join(dir, p))).isFile())
|
|
58
|
-
have.push(p);
|
|
59
|
-
for (const f of files)
|
|
60
|
-
if (await readFile(join(dir, f.path), "utf8") !== f.content)
|
|
61
|
-
return false;
|
|
62
|
-
return have.length === files.length;
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
};
|
|
46
|
+
const runnerSkills = () => import(new URL(import.meta.url.endsWith(".ts")
|
|
47
|
+
? "../../../templates/vendor-runner/feeder.mjs" : "./vendor-runner/feeder.mjs", import.meta.url).href);
|
|
68
48
|
export async function writeManagedSkills(skillsDir, skills) {
|
|
69
|
-
await
|
|
70
|
-
const written = new Set();
|
|
71
|
-
for (const skill of skills) {
|
|
72
|
-
const name = String(skill?.name ?? "");
|
|
73
|
-
const target = join(skillsDir, name);
|
|
74
|
-
if (!SKILL_NAME.test(name))
|
|
75
|
-
continue;
|
|
76
|
-
if (await lstat(target).then(() => true, () => false) && !(await isManaged(target)))
|
|
77
|
-
continue;
|
|
78
|
-
const files = (Array.isArray(skill.files) ? skill.files : []);
|
|
79
|
-
if (files.some((f) => typeof f?.path !== "string" || typeof f?.content !== "string" || isAbsolute(f.path)
|
|
80
|
-
|| normalize(f.path).split(sep).includes("..") || [".", MANAGED_MARK].includes(normalize(f.path))))
|
|
81
|
-
continue;
|
|
82
|
-
// An unchanged skill is not rewritten: the agent's CLI may be reading it.
|
|
83
|
-
if (await sameSkill(target, files)) {
|
|
84
|
-
written.add(name);
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
const staging = join(skillsDir, `.${name}.sync-${process.pid}`);
|
|
88
|
-
const old = join(skillsDir, `.${name}.old-${process.pid}`);
|
|
89
|
-
try {
|
|
90
|
-
await rm(staging, { recursive: true, force: true });
|
|
91
|
-
for (const f of files) {
|
|
92
|
-
await mkdir(dirname(join(staging, f.path)), { recursive: true });
|
|
93
|
-
await writeFile(join(staging, f.path), f.content);
|
|
94
|
-
}
|
|
95
|
-
await writeFile(join(staging, MANAGED_MARK), "Written by the AgentRadar runner; replaced on every sync.\n");
|
|
96
|
-
// Rename aside, then into place: the skill is missing for two renames, not for a recursive delete.
|
|
97
|
-
await rm(old, { recursive: true, force: true });
|
|
98
|
-
await rename(target, old).catch((err) => { if (err?.code !== "ENOENT")
|
|
99
|
-
throw err; });
|
|
100
|
-
await rename(staging, target);
|
|
101
|
-
await rm(old, { recursive: true, force: true });
|
|
102
|
-
written.add(name);
|
|
103
|
-
}
|
|
104
|
-
catch {
|
|
105
|
-
await rm(staging, { recursive: true, force: true });
|
|
106
|
-
if (!(await lstat(target).then(() => true, () => false)))
|
|
107
|
-
await rename(old, target).catch(() => { });
|
|
108
|
-
if (await isManaged(target))
|
|
109
|
-
written.add(name);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
for (const entry of await readdir(skillsDir)) {
|
|
113
|
-
if (!written.has(entry) && await isManaged(join(skillsDir, entry)))
|
|
114
|
-
await rm(join(skillsDir, entry), { recursive: true, force: true });
|
|
115
|
-
}
|
|
116
|
-
return [...written];
|
|
49
|
+
return (await runnerSkills()).writeManagedSkills(skillsDir, skills);
|
|
117
50
|
}
|
|
118
51
|
/**
|
|
119
52
|
* R650 — a joined agent's AgentRadar skills (`my_skills`: the bundled set plus the ones linked to it), written
|
|
@@ -149,22 +82,25 @@ export function skillsDirFor(harness, env) {
|
|
|
149
82
|
return dir.includes("\0") ? null : join(dir);
|
|
150
83
|
}
|
|
151
84
|
export async function syncJoinedSkills(runnerId, deps) {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
const
|
|
85
|
+
const file = await readFile(join(deps.home, ".agentradar", "agents", `${runnerId}.env`), "utf8").catch(() => null);
|
|
86
|
+
// A hosted session has no runner file: it is Claude Code, and its channel's env carries AR_BASE (hosted-session-start.mjs).
|
|
87
|
+
const value = (name) => file === null ? process.env[name] ?? null : envValue(file, name);
|
|
88
|
+
const harness = file === null ? "claude" : value("AR_AGENT_HARNESS");
|
|
89
|
+
const skillsDir = skillsDirFor(harness, (name) => name === "HOME" ? deps.home : value(name));
|
|
155
90
|
if (!skillsDir)
|
|
156
91
|
return;
|
|
157
92
|
const codex = harness === "codex";
|
|
158
|
-
const root = (codex ?
|
|
93
|
+
const root = (codex ? value("CODEX_HOME") : value("AR_BASE")) ?? "";
|
|
159
94
|
// Vadym 9988: the server's start instructions with this agent's brief, before its first session starts.
|
|
160
95
|
// Same file and mark as the runner's feeder, which keeps it fresh from then on.
|
|
161
96
|
const init = await awpRequest(runnerId, "initialize", {
|
|
162
97
|
protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "agentradar-cli", version: "1.0.0" },
|
|
163
|
-
}, { home: deps.home, fetch: deps.fetch, logError: deps.log });
|
|
98
|
+
}, { home: deps.home, fetch: deps.fetch, logError: deps.log, surface: "skill-sync" });
|
|
164
99
|
const briefFile = codex ? join(root, "AGENTS.md") : join(root, ".claude", "rules", "agentradar-brief.md");
|
|
165
100
|
if (root && init?.result?.instructions)
|
|
166
101
|
await writeBrief(briefFile, init.result.instructions);
|
|
167
|
-
const
|
|
102
|
+
const have = (await runnerSkills()).heldSkillZips(skillsDir);
|
|
103
|
+
const body = await awpRequest(runnerId, "tools/call", { name: "my_skills", arguments: { have } }, { home: deps.home, fetch: deps.fetch, logError: deps.log, surface: "skill-sync" });
|
|
168
104
|
const text = body?.result?.content?.find((c) => c.type === "text")?.text;
|
|
169
105
|
const answer = (text ? JSON.parse(text) : {});
|
|
170
106
|
if (!answer.ok || !Array.isArray(answer.skills))
|