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/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
- return fetchImpl(url, {
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
- /** `installation: null` (join) removes the project's `agentradar` entry: a joined agent acts through `agentradar run`. */
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(`AR_CLI_RUNNER_ID=${shellValue(approved.runnerId)}`, `AR_AGENTRADAR_BIN=${shellValue(join(cliDist(home), "index.js"))}`);
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
- // The caller's own conversation, both times: a resume that fails tries it once more, never a fresh session.
416
- joined.push(`AR_RESUME_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`, `AR_FRESH_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`);
417
- // A person's model switch: the feeder and agent-guard's hooks meet in this directory, keyed by the session.
418
- joined.push(`AR_CLAUDE_SESSION_ID=${shellValue(service.sessionId)}`, `AR_MODEL_SWITCH_DIR=${shellValue(join(configDir, "model-switch"))}`);
419
- const configDirEnv = service.env.CLAUDE_CONFIG_DIR?.trim();
420
- if (configDirEnv)
421
- joined.push(`CLAUDE_CONFIG_DIR=${shellValue(resolve(configDirEnv))}`);
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.bypass)
432
- joined.push(`CLAUDE_EXTRA_FLAGS=${shellValue("--permission-mode bypassPermissions")}`);
433
- joined.push(`CLAUDE_SETTINGS=${shellValue(settings)}`, `CLAUDE_CODE_PLUGIN_CACHE_DIR=${shellValue(join(configDir, "plugins"))}`);
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
- const toolEnv = Object.entries(service.env)
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
- : service ? { mcpServers: {} } : {
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 MCP installed for this project → ${configPath}`);
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 this MCP. Re-run with --trust for unattended onboarding.`);
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 ONLY the AgentRadar MCP server; the runner refuses to start without it.`);
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: every Claude Code agent connects through the AgentRadar channel. The tmux feeder is the fallback:
671
- // another CLI, an explicit opt-in (--feeder, AGENTRADAR_FEED=feeder, or a pane named with --attach-*), or a
672
- // channel the session never confirms (channel.ts reports it and tells the operator to re-run with --feeder).
673
- const channel = harness === "claude" && !deps.feeder && env.AGENTRADAR_FEED?.trim().toLowerCase() !== "feeder" && !deps.attach;
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 when it already lives in tmux: asked for by flag, or a Claude Code session in a tmux
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 (attach || channel)
699
- return undefined; throw error; });
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
- // Only Claude resumes the caller's own conversation; Codex and a generic CLI start their own session.
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 });
@@ -1,9 +1,8 @@
1
- import { chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
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, isAbsolute, join, normalize, sep } from "node:path";
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 isManaged = async (dir) => (await lstat(dir).then((info) => info.isDirectory(), () => false)) && (await lstat(join(dir, MANAGED_MARK)).then(() => true, () => false));
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 mkdir(skillsDir, { recursive: true });
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 env = await readFile(join(deps.home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
153
- const harness = envValue(env, "AR_AGENT_HARNESS");
154
- const skillsDir = skillsDirFor(harness, (name) => name === "HOME" ? deps.home : envValue(env, name));
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 ? envValue(env, "CODEX_HOME") : envValue(env, "AR_BASE")) ?? "";
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 body = await awpRequest(runnerId, "tools/call", { name: "my_skills", arguments: {} }, { home: deps.home, fetch: deps.fetch, logError: deps.log });
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))