agentradar-cli 0.3.9 → 0.3.11

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/runner.js CHANGED
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { promisify } from "node:util";
7
- import { shellValue, trustedMcpEndpoint, writePrivateFile } from "./setup.js";
7
+ import { claimPendingJoin, removeAgentService, shellValue, trustedMcpEndpoint, writePrivateFile } from "./setup.js";
8
8
  import { callAwpTool, envValue } from "./tool-run.js";
9
9
  import { cliHeader, installedVersion, noteCliUpdate, revertCli, runningVersion, selfUpdate } from "./update.js";
10
10
  const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
@@ -224,7 +224,16 @@ export async function startVendorRunner(runnerId, home = homedir(), deps = {}) {
224
224
  if (!RUNNER_ID.test(runnerId))
225
225
  throw new Error("Invalid runner ID");
226
226
  const log = deps.log ?? console.log;
227
+ // UAT-17: a join installed before its approval waits here for the key; a no-op once the env has one.
228
+ if (!await claimPendingJoin(runnerId, { home, fetch: deps.fetch, sleep: deps.sleep, log }))
229
+ return 1;
227
230
  const envPath = join(home, ".agentradar", "agents", `${runnerId}.env`);
231
+ // UAT-17-INPLACE: the user's own launcher runs an in-place session, so this service only claimed the key; it removes itself.
232
+ if (envValue(await readFile(envPath, "utf8").catch(() => ""), "AR_INPLACE") === "1") {
233
+ log("Key claimed for the in-place session; its own launcher runs it with the AgentRadar channel. This service is done and removes itself.");
234
+ await (deps.removeService ?? ((id) => removeAgentService(id, { home })))(runnerId);
235
+ return 0;
236
+ }
228
237
  const agentDir = dirname(envPath);
229
238
  const supervisor = join(dirname(fileURLToPath(import.meta.url)), "vendor-runner", "supervisor", "supervise-agent.sh");
230
239
  const spawnChild = deps.spawn ?? ((command, args) => spawn(command, args, { stdio: "inherit", env: process.env }));
package/dist/setup.js CHANGED
@@ -427,12 +427,15 @@ async function codexHookHashes(codexHome, env) {
427
427
  child.kill();
428
428
  }
429
429
  }
430
- async function installRunner(home, projectPath, approved, service, feed) {
430
+ const CODEX_BYPASS = "--dangerously-bypass-approvals-and-sandbox";
431
+ const CLAUDE_BYPASS = "--permission-mode bypassPermissions";
432
+ /** `pending`: a join not approved yet. The env carries no key, only where the service claims it (claimPendingJoin). */
433
+ async function installRunner(home, projectPath, approved, service, feed, pending) {
431
434
  if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(approved.runnerId)) {
432
435
  throw new Error("AgentRadar returned an invalid runner ID");
433
436
  }
434
437
  const endpoint = trustedMcpEndpoint(approved.installation.mcpEndpoint);
435
- if (!endpoint || !BEARER_SHAPE.test(approved.installation.bearer)) {
438
+ if (!endpoint || (!pending && !BEARER_SHAPE.test(approved.installation.bearer))) {
436
439
  throw new Error("AgentRadar returned an invalid setup payload");
437
440
  }
438
441
  const agentDir = join(home, ".agentradar", "agents");
@@ -455,7 +458,7 @@ async function installRunner(home, projectPath, approved, service, feed) {
455
458
  const { codexHome, keys } = await codexServiceHome(home, approved.runnerId, service.env);
456
459
  joined.push(`CODEX_HOME=${shellValue(codexHome)}`);
457
460
  if (service.bypass)
458
- joined.push(`CODEX_EXTRA_FLAGS=${shellValue("--dangerously-bypass-approvals-and-sandbox")}`);
461
+ joined.push(`CODEX_EXTRA_FLAGS=${shellValue(CODEX_BYPASS)}`);
459
462
  toolKeys = keys;
460
463
  }
461
464
  else if (harness === "generic") {
@@ -493,7 +496,7 @@ async function installRunner(home, projectPath, approved, service, feed) {
493
496
  }, null, 2)}\n`);
494
497
  if (!service.inPlace) {
495
498
  if (service.bypass)
496
- joined.push(`CLAUDE_EXTRA_FLAGS=${shellValue("--permission-mode bypassPermissions")}`);
499
+ joined.push(`CLAUDE_EXTRA_FLAGS=${shellValue(CLAUDE_BYPASS)}`);
497
500
  joined.push(`CLAUDE_SETTINGS=${shellValue(settings)}`, `CLAUDE_CODE_PLUGIN_CACHE_DIR=${shellValue(join(configDir, "plugins"))}`);
498
501
  // Vadym 13444: the agent keeps every MCP and channel it already has; AgentRadar's channel is added next to them.
499
502
  joined.push("AR_MCP_STRICT=0");
@@ -523,7 +526,9 @@ async function installRunner(home, projectPath, approved, service, feed) {
523
526
  `AGENT_NAME=${shellValue(approved.runnerId)}`,
524
527
  `AR_BASE=${shellValue(projectPath)}`,
525
528
  `AWP_MCP_URL=${shellValue(endpoint.toString())}`,
526
- `AWP_BEARER=${shellValue(approved.installation.bearer)}`,
529
+ ...(pending
530
+ ? [`AR_JOIN_STATE=${shellValue(pending.state)}`, `AR_JOIN_CLAIM_URL=${shellValue(pending.claimUrl)}`]
531
+ : [`AWP_BEARER=${shellValue(approved.installation.bearer)}`]),
527
532
  `MCP_CONFIG=${shellValue(mcpConfig)}`,
528
533
  // Every convention line in the brief and skills says `agentradar run $AR_CLI_RUNNER_ID <tool>`.
529
534
  `AR_CLI_RUNNER_ID=${shellValue(approved.runnerId)}`,
@@ -674,6 +679,106 @@ async function installAgentService(runnerId, home, env) {
674
679
  env: { ...env, HOME: home, NODE_BIN: process.execPath, AGENTRADAR_JS: join(dist, "index.js") },
675
680
  });
676
681
  }
682
+ /** What a key claim's answer means: null = not decided yet, keep polling; else the refusal in plain words. */
683
+ function claimRefusal(status, said) {
684
+ // A deploy's 502/503 or a rate limit is not the company's answer; refusing on one would remove the service.
685
+ if (status >= 500 || status === 429 || (status === 409 && said.includes("must be approved")))
686
+ return null;
687
+ if (status === 409 && said.includes("rejected"))
688
+ return "Your company declined this join request. Ask it for a new join link.";
689
+ if (status === 409 && said.includes("withdrawn"))
690
+ return "This join request was withdrawn (agentradar join cancel). Ask your company for a new join link.";
691
+ if (status === 409 && /already (?:used|claimed)/.test(said))
692
+ return "This join link's key was already claimed. A key is issued once and cannot be claimed again; ask your company for a new join link.";
693
+ return said;
694
+ }
695
+ /**
696
+ * UAT-17 (lead ruling B): an agent's shell tool kills a command that runs for minutes, so a join that
697
+ * waited for the approval in the foreground never saw it. The service is now installed with the request, and
698
+ * `runner start` calls this first: while the runner env holds no key it polls the claim every 10 s, writes the
699
+ * key, and records the approval in the join state the foreground command watches. Then the supervisor starts
700
+ * the session, whose first poll brings the welcome. A refusal is recorded there too, and the service removes
701
+ * itself. The service is the only claimant, so a key is never claimed twice. False = stopped without a key.
702
+ */
703
+ export async function claimPendingJoin(runnerId, deps = {}) {
704
+ const home = deps.home ?? homedir();
705
+ const log = deps.log ?? console.log;
706
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
707
+ const { envValue } = await import("./tool-run.js");
708
+ const envPath = join(home, ".agentradar", "agents", `${runnerId}.env`);
709
+ const saved = await readFile(envPath, "utf8").catch(() => "");
710
+ const statePath = envValue(saved, "AR_JOIN_STATE");
711
+ if (!statePath || envValue(saved, "AWP_BEARER"))
712
+ return true;
713
+ const leave = deps.leave ?? (() => leaveOrganisation(runnerId, { home, log }));
714
+ const state = await readFile(statePath, "utf8").then((text) => JSON.parse(text), () => null);
715
+ const refuse = async (said) => {
716
+ // Another claimant (the foreground fallback when the service install threw) already wrote the key: start on it, never remove it.
717
+ if (envValue(await readFile(envPath, "utf8").catch(() => ""), "AWP_BEARER"))
718
+ return true;
719
+ if (state)
720
+ await writePrivateFile(statePath, JSON.stringify({ ...state, refused: said }));
721
+ log(said);
722
+ await leave();
723
+ return false;
724
+ };
725
+ // The state file goes with `agentradar join cancel`; the claim secret goes only to the AgentRadar host that issued it.
726
+ if (!state)
727
+ return refuse("This join request was withdrawn (agentradar join cancel). Ask your company for a new join link.");
728
+ const claimUrl = trustedMcpEndpoint(envValue(saved, "AR_JOIN_CLAIM_URL") ?? "");
729
+ if (!claimUrl)
730
+ return refuse(`${envPath} names no AgentRadar address to claim the key from; run the join again.`);
731
+ for (let poll = 1; poll <= (deps.polls ?? Infinity); poll++) {
732
+ let response;
733
+ try {
734
+ response = await postJson(deps.fetch ?? fetch, claimUrl, { claimSecret: state.claimSecret, runnerId });
735
+ }
736
+ catch {
737
+ log(`Could not reach ${claimUrl.host}; retrying…`);
738
+ await sleep(10_000);
739
+ continue;
740
+ }
741
+ if (response.status === 201) {
742
+ const claimed = await response.json();
743
+ if (typeof claimed.token !== "string" || !BEARER_SHAPE.test(claimed.token) || typeof claimed.agentId !== "string") {
744
+ return refuse("AgentRadar returned an invalid key claim");
745
+ }
746
+ // Bypass-permissions only when the operator consented at approval, and never for a pane the caller runs.
747
+ const token = claimed.token;
748
+ const approvedBypass = claimed.bypassPermissions === true;
749
+ const bypass = approvedBypass && envValue(saved, "AR_ATTACH") !== "1" && envValue(saved, "AR_INPLACE") !== "1";
750
+ const harness = envValue(saved, "AR_AGENT_HARNESS") ?? "claude";
751
+ // The same file installRunner writes for an approved join, line for line.
752
+ const lines = saved.split("\n").filter((line) => !line.startsWith("AR_JOIN_CLAIM_URL="))
753
+ .map((line) => line.startsWith("AR_JOIN_STATE=") ? `AWP_BEARER=${shellValue(token)}` : line);
754
+ const insert = (flag, at) => lines.splice(at < 0 ? lines.length - 1 : at, 0, flag);
755
+ if (bypass && harness === "codex")
756
+ insert(`CODEX_EXTRA_FLAGS=${shellValue(CODEX_BYPASS)}`, lines.findIndex((line) => line.startsWith("CODEX_HOME=")) + 1 || -1);
757
+ if (bypass && harness === "claude") {
758
+ insert(`CLAUDE_EXTRA_FLAGS=${shellValue(CLAUDE_BYPASS)}`, lines.findIndex((line) => line.startsWith("CLAUDE_SETTINGS=")));
759
+ const settings = join(home, ".agentradar", "config", runnerId, "agentradar-settings.json");
760
+ const current = JSON.parse(await readFile(settings, "utf8"));
761
+ await writePrivateFile(settings, `${JSON.stringify({ ...current, skipDangerousModePermissionPrompt: true }, null, 2)}\n`);
762
+ }
763
+ // The key on disk first: a state that says approved always has its key.
764
+ await writePrivateFile(envPath, lines.join("\n"));
765
+ await writePrivateFile(statePath, JSON.stringify({ ...state, agentId: claimed.agentId, ...(approvedBypass ? { bypassPermissions: true } : {}) }));
766
+ log("Approved: key claimed, starting the agent.");
767
+ // Best effort: the feeder syncs them again.
768
+ await syncJoinedSkills(runnerId, { home, fetch: deps.fetch, log }).catch((error) => {
769
+ log(`Your AgentRadar skills are not installed yet (${String(error?.message ?? error).split("\n")[0]}); the runner installs them once it is running.`);
770
+ });
771
+ return true;
772
+ }
773
+ const refusal = claimRefusal(response.status, await failureMessage(response));
774
+ if (refusal === null) {
775
+ await sleep(10_000);
776
+ continue;
777
+ }
778
+ return refuse(refusal);
779
+ }
780
+ return false;
781
+ }
677
782
  /**
678
783
  * The Claude conversation that ran `agentradar join`: `--session`, else the CLAUDE_CODE_SESSION_ID Claude Code
679
784
  * sets in its shell, else the newest transcript Claude Code keeps for this folder. Never a fresh one.
@@ -702,6 +807,26 @@ async function callerSession(deps, env, home, projectPath) {
702
807
  * restarts the service) instead of filing a second request. The key is written only into the 0600 runner
703
808
  * env file and is never printed.
704
809
  */
810
+ /**
811
+ * UAT-30 (Vadym 13405): a join names the runtime it runs on. `--runtime claude|codex|other`, else what the shell
812
+ * itself proves (AGENTRADAR_AGENT_BIN, CLAUDECODE, CODEX_HOME). Nothing is assumed: an unknown or unsupported one is
813
+ * refused before any request, with the command to run next.
814
+ */
815
+ export function joinRuntime(flag, env) {
816
+ const map = { claude: "claude", codex: "codex", other: "generic" };
817
+ if (flag !== undefined) {
818
+ const key = flag.trim().toLowerCase();
819
+ const harness = Object.hasOwn(map, key) ? map[key] : undefined;
820
+ return harness ? { harness } : { refusal: `Unsupported runtime ${JSON.stringify(flag)}: AgentRadar joins Claude Code (claude), Codex (codex) or another CLI you launch yourself (other). Nothing was requested. Now: run the same command again with --runtime other and AGENTRADAR_AGENT_BIN set to your CLI's command, or ask your company which runtime it expects.` };
821
+ }
822
+ if (env.AGENTRADAR_AGENT_BIN?.trim())
823
+ return { harness: "generic" };
824
+ if (env.CLAUDECODE)
825
+ return { harness: "claude" };
826
+ if (env.CODEX_HOME)
827
+ return { harness: "codex" };
828
+ return { refusal: "AgentRadar cannot tell which runtime you run on, so nothing was requested. Now: run the same command again with --runtime claude (Claude Code), --runtime codex (Codex) or --runtime other (any other CLI; set AGENTRADAR_AGENT_BIN to its command first). Use the one that is true for you; do not guess." };
829
+ }
705
830
  export async function joinOrganisation(enrolmentUrl, deps = {}) {
706
831
  const fetchImpl = deps.fetch ?? fetch;
707
832
  const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -783,21 +908,29 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
783
908
  log(`Your AgentRadar skills are not installed yet (${String(error?.message ?? error).split("\n")[0]}); the runner installs them once it is running.`);
784
909
  });
785
910
  if (inPlace) {
786
- const config = join(home, ".agentradar", "config", runnerId);
787
911
  if (approvedBypass)
788
912
  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.`);
913
+ log(`Connected, nothing started: your session, its MCPs and its channels are untouched. To receive client messages, ${restartInPlace(runnerId)}`);
792
914
  return 0;
793
915
  }
794
916
  try {
795
- await (deps.installService ?? ((id) => installAgentService(id, home, env)))(runnerId);
917
+ await installService(runnerId);
796
918
  }
797
919
  catch (error) {
798
920
  log(`Could not start the service: ${error.message.split("\n")[0]}. Start it by hand with: agentradar runner start ${runnerId}`);
799
921
  return 1;
800
922
  }
923
+ return connected(runnerId, projectPath, approvedBypass, bypass);
924
+ };
925
+ const restartInPlace = (runnerId) => {
926
+ const config = join(home, ".agentradar", "config", runnerId);
927
+ return `restart your agent ONCE with these flags added to its own launch command (keep what it already has, including --continue):
928
+ --mcp-config ${join(config, "awp-only.json")} --dangerously-load-development-channels server:agentradar --settings ${join(config, "agentradar-settings.json")}
929
+ 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.`;
930
+ };
931
+ const installService = deps.installService ?? ((id) => installAgentService(id, home, env));
932
+ const connected = (runnerId, projectPath, approvedBypass, bypass) => {
933
+ const { attach } = where;
801
934
  if (attach) {
802
935
  log(`Connected. Your welcome and every client message are typed into this session (tmux ${attach}), and you answer here. Nothing else was started. To stop: agentradar leave ${runnerId}`);
803
936
  if (approvedBypass)
@@ -858,6 +991,12 @@ and AR_CLI_RUNNER_ID=${runnerId} in its environment. Accept the one-time develop
858
991
  return startJoined(state.requestId, state.agentId, { mcpEndpoint: envValue(saved, "AWP_MCP_URL") ?? mcpEndpoint, bearer }, approvedBypass, bypass);
859
992
  }
860
993
  if (!state) {
994
+ // index.ts skips the runtime gate for --revoke-bypass (it edits an earlier join); a NEW join still needs a proven runtime.
995
+ if (deps.revokeBypass && !deps.harness) {
996
+ const proven = joinRuntime(undefined, env);
997
+ if ("refusal" in proven)
998
+ throw new Error(proven.refusal);
999
+ }
861
1000
  const agentName = (deps.agentName ?? process.env.AGENTRADAR_AGENT_NAME ?? basename(cwd)).trim();
862
1001
  if (!agentName)
863
1002
  throw new Error("Run join from the agent project folder");
@@ -887,6 +1026,59 @@ and AR_CLI_RUNNER_ID=${runnerId} in its environment. Accept the one-time develop
887
1026
  const claimUrl = api(`join-requests/${encodeURIComponent(state.requestId)}/claim-api-key`);
888
1027
  // Named off the join request, which exists before the key does, so the server can record it at claim.
889
1028
  const runnerId = `join-${state.requestId}`;
1029
+ {
1030
+ // UAT-17: the service waits for the approval and claims the key (claimPendingJoin); this command only watches.
1031
+ // In place (UAT-17-INPLACE) the service only claims: the user's own launcher runs the session the channel pushes into.
1032
+ const { refused: _retried, ...pending } = state;
1033
+ await writePrivateFile(statePath, JSON.stringify(pending));
1034
+ const projectPath = harness === "claude"
1035
+ ? await installProjectMcp(claudeConfigPath(home, env), cwd, null, deps.trust === true)
1036
+ : await realpath(cwd);
1037
+ // A re-run never rewrites an env the service already holds: it may be writing the claimed key into it right now.
1038
+ const runnerEnv = join(home, ".agentradar", "agents", `${runnerId}.env`);
1039
+ if (!/^(?:AR_JOIN_STATE|AWP_BEARER)=/m.test(await readFile(runnerEnv, "utf8").catch(() => ""))) {
1040
+ await installRunner(home, projectPath, { status: "approved", agentId: "", runnerId, installation: { mcpEndpoint, bearer: "" }, harness }, { sessionId: where.sessionId ?? state.requestId, env, attach: where.attach, inPlace }, channel ? "push" : undefined, { state: statePath, claimUrl: claimUrl.toString() });
1041
+ }
1042
+ let serviced = true;
1043
+ try {
1044
+ await installService(runnerId);
1045
+ }
1046
+ catch (error) {
1047
+ serviced = false;
1048
+ log(`Could not start the service yet (${error.message.split("\n")[0]}); keep this command running until it says Connected.`);
1049
+ }
1050
+ if (serviced) {
1051
+ log(inPlace
1052
+ ? `The AgentRadar service is installed and waits for the approval by itself. Once your company approves, it claims your key, and your welcome arrives in this session through the AgentRadar channel. Your session, its MCPs and its channels are untouched. To receive the welcome and client messages, ${restartInPlace(runnerId)}
1053
+ You can restart now or after the approval: until then the channel waits. You do not need to run anything again, and stopping this command is safe.`
1054
+ : `The AgentRadar service is installed and waits for the approval by itself. Once your company approves, it claims your key and delivers your welcome to your agent (${where.attach ? `typed into tmux ${where.attach}` : `tmux session ${runnerId}`}). You do not need to run anything again, and stopping this command is safe.`);
1055
+ for (let poll = 1;; poll++) {
1056
+ const now = await readFile(statePath, "utf8").then((text) => JSON.parse(text), () => ({}));
1057
+ if (now.refused)
1058
+ throw new Error(now.refused);
1059
+ if (now.agentId) {
1060
+ log("Approved.");
1061
+ log(`Runner installed → ${runnerEnv}`);
1062
+ if (inPlace) {
1063
+ log("Your key is claimed. Your welcome arrives through the AgentRadar channel once your agent runs with the flags above.");
1064
+ return 0;
1065
+ }
1066
+ const approvedBypass = now.bypassPermissions === true;
1067
+ const bypass = localBypass(approvedBypass, false);
1068
+ if (bypass)
1069
+ log(bypassOnNotice(enrolmentUrl));
1070
+ return connected(runnerId, projectPath, approvedBypass, bypass);
1071
+ }
1072
+ if (poll > 1 && (poll - 1) % 3 === 0)
1073
+ log(`Still waiting for your company to approve (${(poll - 1) * 10} s). The service connects you by itself once they do; stopping this command is safe.`);
1074
+ if (poll > (deps.polls ?? Infinity)) {
1075
+ log("Still waiting for approval. The service connects you by itself once your company approves; you do not need to run anything again.");
1076
+ return 0;
1077
+ }
1078
+ await sleep(10_000);
1079
+ }
1080
+ }
1081
+ }
890
1082
  for (let poll = 1;; poll++) {
891
1083
  if (poll > (deps.polls ?? Infinity)) {
892
1084
  log(`Still waiting for approval. Run this same command again: agentradar run ${enrolmentUrl}`);
@@ -913,21 +1105,10 @@ and AR_CLI_RUNNER_ID=${runnerId} in its environment. Accept the one-time develop
913
1105
  const joined = { ...state, agentId: claimed.agentId, ...(approvedBypass ? { bypassPermissions: true } : {}) };
914
1106
  return startJoined(state.requestId, claimed.agentId, { mcpEndpoint, bearer: claimed.token }, approvedBypass, bypass, () => writePrivateFile(statePath, JSON.stringify(joined)));
915
1107
  }
916
- const said = await failureMessage(response);
917
- if (response.status === 409 && said.includes("must be approved")) {
918
- await sleep(10_000);
919
- continue;
920
- }
921
- if (response.status === 409 && said.includes("rejected")) {
922
- throw new Error("Your company declined this join request. Ask it for a new join link.");
923
- }
924
- if (response.status === 409 && said.includes("withdrawn")) {
925
- throw new Error("This join request was withdrawn (agentradar join cancel). Ask your company for a new join link.");
926
- }
927
- if (response.status === 409 && /already (?:used|claimed)/.test(said)) {
928
- throw new Error("This join link's key was already claimed. A key is issued once and cannot be claimed again; ask your company for a new join link.");
929
- }
930
- throw new Error(said);
1108
+ const refusal = claimRefusal(response.status, await failureMessage(response));
1109
+ if (refusal !== null)
1110
+ throw new Error(refusal);
1111
+ await sleep(10_000);
931
1112
  }
932
1113
  }
933
1114
  /**
@@ -980,6 +1161,19 @@ export async function cancelJoin(target, deps = {}) {
980
1161
  * `agentradar leave <runner-id>`: stop and remove the service, end the agent's session, and delete its env
981
1162
  * file, bearer and config. The folder the agent worked in stays, with the work.
982
1163
  */
1164
+ /** The runner's LaunchAgent / systemd unit, unit file first: stopping it last ends a service that removes itself. */
1165
+ export async function removeAgentService(runnerId, deps = {}) {
1166
+ const home = deps.home ?? homedir();
1167
+ const execute = deps.execute ?? ((command, args) => promisify(execFile)(command, args));
1168
+ const label = `com.agentradar.agent.${runnerId}`;
1169
+ if ((deps.platform ?? process.platform) === "darwin") {
1170
+ for (const dir of [join(home, "Library", "LaunchAgents"), join(home, ".agentradar", "staged")])
1171
+ await rm(join(dir, `${label}.plist`), { force: true });
1172
+ await execute("launchctl", ["bootout", `gui/${process.getuid?.()}/${label}`]).catch(() => undefined);
1173
+ }
1174
+ else
1175
+ await execute("systemctl", ["--user", "disable", "--now", `agentradar-agent@${runnerId}`]).catch(() => undefined);
1176
+ }
983
1177
  export async function leaveOrganisation(runnerId, deps = {}) {
984
1178
  if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(runnerId))
985
1179
  throw new Error("Invalid runner ID");
@@ -993,23 +1187,16 @@ export async function leaveOrganisation(runnerId, deps = {}) {
993
1187
  return 1;
994
1188
  }
995
1189
  const { envValue } = await import("./tool-run.js");
996
- if ((deps.platform ?? process.platform) === "darwin") {
997
- const label = `com.agentradar.agent.${runnerId}`;
998
- await execute("launchctl", ["bootout", `gui/${process.getuid?.()}/${label}`]).catch(() => undefined);
999
- await rm(join(home, "Library", "LaunchAgents", `${label}.plist`), { force: true });
1000
- await rm(join(home, ".agentradar", "staged", `${label}.plist`), { force: true });
1001
- }
1002
- else {
1003
- await execute("systemctl", ["--user", "disable", "--now", `agentradar-agent@${runnerId}`]).catch(() => undefined);
1004
- }
1005
- // The service stopping leaves the session running; `=` matches the tmux session name exactly.
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")
1008
- await execute(envValue(saved, "TMUX_BIN") ?? "tmux", ["kill-session", "-t", `=${envValue(saved, "TMUX_SESSION") ?? runnerId}`]).catch(() => undefined);
1190
+ // Files first: a service that leaves by itself (a refused join, claimPendingJoin) ends when it is stopped.
1009
1191
  for (const path of [envPath, join(home, ".agentradar", "agents", `${runnerId}.delivered.json`)])
1010
1192
  await rm(path, { force: true });
1011
1193
  for (const dir of ["run", "config", "codex"])
1012
1194
  await rm(join(home, ".agentradar", dir, runnerId), { recursive: true, force: true });
1195
+ await removeAgentService(runnerId, { ...deps, home, execute });
1196
+ // The service stopping leaves the session running; `=` matches the tmux session name exactly.
1197
+ // An attached pane or an in-place join is the caller's own session: it stays.
1198
+ if (envValue(saved, "AR_ATTACH") !== "1" && envValue(saved, "AR_INPLACE") !== "1")
1199
+ await execute(envValue(saved, "TMUX_BIN") ?? "tmux", ["kill-session", "-t", `=${envValue(saved, "TMUX_SESSION") ?? runnerId}`]).catch(() => undefined);
1013
1200
  log(`Left: the service is stopped and removed, and its key is deleted. The folder ${envValue(saved, "AR_BASE") ?? ""} stays, with the work.`);
1014
1201
  return 0;
1015
1202
  }
@@ -1,6 +1,6 @@
1
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, join } from "node:path";
3
+ import { basename, join } from "node:path";
4
4
  import { awpRequest, envValue } from "./tool-run.js";
5
5
  const MANAGED_MARK = ".agentradar-managed";
6
6
  async function ensureDirectory(path) {
@@ -45,8 +45,8 @@ export async function installVendorSkill(deps = {}) {
45
45
  }
46
46
  const runnerSkills = () => import(new URL(import.meta.url.endsWith(".ts")
47
47
  ? "../../../templates/vendor-runner/feeder.mjs" : "./vendor-runner/feeder.mjs", import.meta.url).href);
48
- export async function writeManagedSkills(skillsDir, skills) {
49
- return (await runnerSkills()).writeManagedSkills(skillsDir, skills);
48
+ export async function writeManagedSkills(skillsDir, skills, runner) {
49
+ return (await runnerSkills()).writeManagedSkills(skillsDir, skills, runner);
50
50
  }
51
51
  /**
52
52
  * R650 — a joined agent's AgentRadar skills (`my_skills`: the bundled set plus the ones linked to it), written
@@ -54,17 +54,6 @@ export async function writeManagedSkills(skillsDir, skills) {
54
54
  * Codex `$CODEX_HOME/skills`. Done here because a session that acts only through `agentradar run` has no
55
55
  * feeder; where the service runs, its feeder keeps them current.
56
56
  */
57
- const BRIEF_MARK = "<!-- Written by AgentRadar and replaced on every sync; edits here are lost. -->";
58
- /** Replaces the file itself, never a symlink's target, and never a file it did not write (a vendor's AGENTS.md). */
59
- async function writeBrief(file, text) {
60
- const current = await lstat(file).catch(() => null);
61
- if (current && (!current.isFile() || !(await readFile(file, "utf8")).startsWith(BRIEF_MARK)))
62
- return;
63
- await mkdir(dirname(file), { recursive: true });
64
- const temporary = `${file}.${randomUUID()}.tmp`;
65
- await writeFile(temporary, `${BRIEF_MARK}\n${text.trim()}\n`);
66
- await rename(temporary, file);
67
- }
68
57
  /** Where each CLI reads skills. The same table as templates/vendor-runner/feeder.mjs; skill-install.test.ts pins them equal. */
69
58
  export const SKILL_DIRS = {
70
59
  claude: "$AR_BASE/.claude/skills",
@@ -89,23 +78,24 @@ export async function syncJoinedSkills(runnerId, deps) {
89
78
  const skillsDir = skillsDirFor(harness, (name) => name === "HOME" ? deps.home : value(name));
90
79
  if (!skillsDir)
91
80
  return;
92
- const codex = harness === "codex";
93
- const root = (codex ? value("CODEX_HOME") : value("AR_BASE")) ?? "";
81
+ const runner = value("AR_CLI_RUNNER_ID") ?? undefined;
94
82
  // Vadym 9988: the server's start instructions with this agent's brief, before its first session starts.
95
83
  // Same file and mark as the runner's feeder, which keeps it fresh from then on.
96
84
  const init = await awpRequest(runnerId, "initialize", {
97
85
  protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "agentradar-cli", version: "1.0.0" },
98
- }, { home: deps.home, fetch: deps.fetch, logError: deps.log, surface: "skill-sync" });
99
- const briefFile = codex ? join(root, "AGENTS.md") : join(root, ".claude", "rules", "agentradar-brief.md");
100
- if (root && init?.result?.instructions)
101
- await writeBrief(briefFile, init.result.instructions);
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" });
86
+ }, { home: deps.home, fetch: deps.fetch, logError: deps.log, surface: "skill-sync", signal: deps.signal });
87
+ // The feeder's own brief path and writer: one file per runner (MULTI-ORG-PUSH §0).
88
+ const skills = await runnerSkills();
89
+ const briefFile = skills.briefFileFor(harness ?? "claude", { AR_BASE: value("AR_BASE") ?? undefined, CODEX_HOME: value("CODEX_HOME") ?? undefined, AR_CLI_RUNNER_ID: runner });
90
+ if (briefFile && init?.result?.instructions)
91
+ skills.writeBrief(briefFile, init.result.instructions);
92
+ const have = skills.heldSkillZips(skillsDir);
93
+ const body = await awpRequest(runnerId, "tools/call", { name: "my_skills", arguments: { have } }, { home: deps.home, fetch: deps.fetch, logError: deps.log, surface: "skill-sync", signal: deps.signal });
104
94
  const text = body?.result?.content?.find((c) => c.type === "text")?.text;
105
95
  const answer = (text ? JSON.parse(text) : {});
106
96
  if (!answer.ok || !Array.isArray(answer.skills))
107
97
  throw new Error("AgentRadar did not return your skills");
108
- const written = await writeManagedSkills(skillsDir, answer.skills);
98
+ const written = await writeManagedSkills(skillsDir, answer.skills, runner);
109
99
  deps.log(`Installed ${written.length} AgentRadar skill(s) in ${skillsDir}.`);
110
100
  }
111
101
  //# sourceMappingURL=skill-install.js.map