agentradar-cli 0.3.6 → 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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "description": "Marks the session as guarded (the runner reports ready only then); blocks going idle while AgentRadar work is owed; reminds of the house rules every AR_DISCIPLINE_EVERY turns; reports compactions to AgentRadar; guards AgentRadar compact-then-switch model changes (a crashed PreModelSwitch hook exits 2 and refuses).",
3
3
  "hooks": {
4
- "SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" start", "timeout": 10 }] }],
5
- "Stop": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" stop", "timeout": 30 }] }],
6
- "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" prompt", "timeout": 10 }] }],
7
- "PreCompact": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PreCompact", "timeout": 30 }, { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/compaction-report.mjs\" PreCompact", "timeout": 30 }] }],
8
- "PostCompact": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PostCompact", "timeout": 30 }, { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/compaction-report.mjs\" PostCompact", "timeout": 30 }] }],
9
- "PreModelSwitch": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PreModelSwitch || exit 2", "timeout": 30 }] }],
10
- "PostModelSwitch": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PostModelSwitch", "timeout": 30 }] }]
4
+ "SessionStart": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" start", "timeout": 10 }] }],
5
+ "Stop": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" stop", "timeout": 30 }] }],
6
+ "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" prompt", "timeout": 10 }] }],
7
+ "PreCompact": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PreCompact", "timeout": 30 }, { "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/compaction-report.mjs\" PreCompact", "timeout": 30 }] }],
8
+ "PostCompact": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PostCompact", "timeout": 30 }, { "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/compaction-report.mjs\" PostCompact", "timeout": 30 }] }],
9
+ "PreModelSwitch": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PreModelSwitch || exit 2", "timeout": 30 }] }],
10
+ "PostModelSwitch": [{ "hooks": [{ "type": "command", "command": "AR_HOOK=1 node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PostModelSwitch", "timeout": 30 }] }]
11
11
  }
12
12
  }
@@ -20,6 +20,12 @@ You act through `agentradar call <runner-id> <tool>`. Your runner id is in `$AR_
20
20
 
21
21
  The Stop hook runs the same check and will not let you stop while something is owed.
22
22
 
23
+ ## Hosted workspace lifetime
24
+
25
+ There is no runtime cap. Running turns, tools and background jobs keep your hosted workspace awake, even when they are silent; only a truly idle workspace auto-stops. Save checkpoints and results to files as you work: a deployment or unexpected stop can still end processes. After a restart notice, check those files and restart unfinished work idempotently; never assume an old background process resumed.
26
+
27
+ In a hosted workspace, every teammate you start keeps its own process in the workspace memory, even while it waits. At most 2 can run at once; a 3rd start is refused. As soon as a teammate is idle or its work is done, stop it: `SendMessage` it `{"type":"shutdown_request"}` and let it shut down. Start a new one when new work needs it.
28
+
23
29
  ## Always
24
30
 
25
31
  - Answer every message in the channel it came from. A human who wrote is waiting for you; answer them first.
@@ -26,7 +26,7 @@ Keep each human's page in company memory current, every fact cited, as the Human
26
26
 
27
27
  In your first session, call `read_onboarding_obligations` once (reading them acknowledges the safety rules), then `read_readiness`.
28
28
 
29
- Your primary objective is to reach your quotas and drive the organisation forward in your role. Report each metric you own, and your subordinates' when needed, with `report_metric`: only true numbers, in good faith, each backed by `evidence` of what you counted it from, or by `fromEventId` when a human told you the number. You may create metrics for yourself; you can never change your own quota — your superior sets it. Talk only in shared spaces (channels and tasks). You can create one-off tasks (`create_task`) and repeated tasks (`create_recurring_task`) for yourself, your colleagues and anyone below you on the org chart — only for real trackable work, never for chat. You also manage your subordinates' tasks and repeated tasks (`update_task`, `update_recurring_task`, `stop_recurring_task`) and their metrics: edit a definition (`edit_metric`) or remove a wrong value with a reason (`void_metric_record`).
29
+ Your primary objective is to reach your quotas and drive the organisation forward in your role. Report each metric you own, and your subordinates' when needed, with `report_metric`: only true numbers, in good faith, each backed by `evidence` of what you counted it from, or by `fromEventId` when a human told you the number. You may create metrics for yourself; you can never change your own quota — your superior sets it. Talk only in shared spaces (channels and tasks). You can create one-off tasks (`create_task`) and repeated tasks (`create_recurring_task`) for yourself, your colleagues and anyone below you on the org chart — only for real trackable work, never for chat. You also manage your subordinates' tasks and repeated tasks (`update_task`, `update_recurring_task`, `stop_recurring_task`, `start_recurring_task`, `archive_recurring_task`, `restore_recurring_task`) and their metrics: edit a definition (`edit_metric`) or remove a wrong value with a reason (`void_metric_record`).
30
30
 
31
31
  ## Talking to humans
32
32
 
package/dist/channel.js CHANGED
@@ -1,28 +1,44 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { existsSync, statSync } from "node:fs";
3
- import { readFile } from "node:fs/promises";
3
+ import { readFile, rm } from "node:fs/promises";
4
4
  import { createRequire } from "node:module";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
- import { envValue } from "./tool-run.js";
8
+ import { awpEndpoint } from "./tool-run.js";
9
9
  import { noteCliUpdate } from "./update.js";
10
10
  import { trustedMcpEndpoint } from "./setup.js";
11
11
  import { syncJoinedSkills } from "./skill-install.js";
12
12
  export const CHANNEL_GUIDANCE = "AgentRadar channel messages are wake signals only. Receive the events with `agentradar run $AR_CLI_RUNNER_ID inbox next`; the next call acknowledges the batch you read. Answer a liveness check with `agentradar run $AR_CLI_RUNNER_ID liveness_ack --nonce <nonce>`. Never acknowledge an event you have not read. Never poll on your own or keep a loop running: when a channel message arrives, drain it, then end your turn.";
13
- export const CHANNEL_BLOCKED = "AgentRadar has not seen this session run `agentradar run $AR_CLI_RUNNER_ID inbox next`. Channel delivery is unconfirmed. On Claude Team/Enterprise an Owner must enable Channels (channelsEnabled); the development flag cannot override that policy. Until then, deliver with the feeder: run your join again with --feeder (agentradar join <your join link> --feeder). Your company sees this agent as not receiving messages until one or the other works.";
13
+ export const CHANNEL_BLOCKED = "AgentRadar has not seen this session run `agentradar run $AR_CLI_RUNNER_ID inbox next`. Channel delivery is unconfirmed. On Claude Team/Enterprise an Owner must enable Channels (channelsEnabled); the development flag cannot override that policy. Your company sees this agent as not receiving messages until the channel works.";
14
+ // The runner's frame text (templates/vendor-runner, shipped beside this file as dist/vendor-runner), so both say a restart one way.
15
+ const runnerFrame = () => import(new URL(import.meta.url.endsWith(".ts")
16
+ ? "../../../templates/vendor-runner/session-frame.mjs" : "./vendor-runner/session-frame.mjs", import.meta.url).href);
14
17
  /** The channel: a local stdio MCP with no tools, only wake signals. The agent acts through the CLI; wake signals never consume the durable queue. */
18
+ /** The wake line. Zero events never says "run inbox next": that call has nothing to return. Each reason names its own tool. */
19
+ export function wakeText(runnerId, count, payload) {
20
+ const run = `agentradar run ${runnerId}`;
21
+ if (count)
22
+ return `${payload.firstStep ? `${payload.firstStep} ` : ""}${count} new AgentRadar events; run \`${run} inbox next\`${payload.livenessProbe ? " to receive your liveness check" : ""}.`;
23
+ return [
24
+ payload.onboarding && `AgentRadar session started, no messages waiting. Run \`${run} get_my_context\` now for your identity, peers and rules.`,
25
+ payload.livenessProbe && `AgentRadar liveness check, no messages waiting. Run \`${run} liveness_ack --nonce ${payload.livenessProbe.nonce}\` now.`,
26
+ ].filter(Boolean).join(" ");
27
+ }
15
28
  export async function startChannel(runnerId, deps = {}) {
16
29
  if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(runnerId))
17
30
  throw new Error("Invalid runner ID");
18
31
  const home = deps.home ?? homedir();
19
- const config = await readFile(join(home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
20
- const endpoint = trustedMcpEndpoint(envValue(config, "AWP_MCP_URL") ?? "");
21
- const bearer = envValue(config, "AWP_BEARER");
22
- if (!endpoint || !bearer)
23
- throw new Error("Runner configuration requires a trusted AWP_MCP_URL and AWP_BEARER");
24
32
  const output = deps.output ?? process.stdout;
25
33
  const logError = deps.logError ?? console.error;
34
+ // The runner file, else a hosted session's own PAPERCLIP_API_* (hosted-session-start.mjs puts them in this server's env).
35
+ const target = await awpEndpoint(runnerId, { home, logError });
36
+ const endpoint = trustedMcpEndpoint(target?.url ?? "");
37
+ const bearer = target?.bearer;
38
+ if (!endpoint || !bearer)
39
+ throw new Error("Runner configuration requires a trusted AWP_MCP_URL and AWP_BEARER");
40
+ // TRC-RESTART-REASON: the hosted bridge leaves why the last workspace stopped; the first wake with work says it, once.
41
+ const notice = join(home, ".agentradar", "run", "restart-notice.json");
26
42
  const sessionId = randomUUID();
27
43
  const stop = new AbortController();
28
44
  let sequence = 0;
@@ -65,9 +81,14 @@ export async function startChannel(runnerId, deps = {}) {
65
81
  return JSON.parse(json);
66
82
  };
67
83
  const next = (args = {}) => remote("tools/call", { name: "next_event", arguments: { ...args, sessionId } });
68
- const pause = () => new Promise((resolve) => {
69
- const done = () => { clearTimeout(timer); stop.signal.removeEventListener("abort", done); resolve(); };
84
+ // AGENT-GUARD-ACTIVE: the supervisor's marker, written by agent-guard's SessionStart hook in this session.
85
+ const guardLoaded = () => !process.env.AR_GUARD_MARKER || existsSync(process.env.AR_GUARD_MARKER);
86
+ const pause = (until) => new Promise((resolve) => {
87
+ const done = () => { clearTimeout(timer); clearInterval(watch); stop.signal.removeEventListener("abort", done); resolve(); };
70
88
  const timer = setTimeout(done, deps.retryMs ?? 3_000);
89
+ // COLD-START-WARMUP: a booting session's work is held until it polls ready, so poll the moment the marker lands.
90
+ const watch = until && setInterval(() => { if (until())
91
+ done(); }, 100);
71
92
  stop.signal.addEventListener("abort", done, { once: true });
72
93
  if (stop.signal.aborted)
73
94
  done();
@@ -76,8 +97,10 @@ export async function startChannel(runnerId, deps = {}) {
76
97
  let lastWake = "";
77
98
  let lastSent = 0;
78
99
  let selftestStarted = false;
100
+ let guarded = true;
79
101
  while (!stop.signal.aborted) {
80
102
  try {
103
+ guarded = guardLoaded(); // first, so a call that throws after the marker lands still gets the full pause
81
104
  if (!selftestStarted) {
82
105
  // Establish the heartbeat without entering the empty queue's 200-second hold first.
83
106
  const connected = await next({ runnerState: "starting" });
@@ -95,8 +118,6 @@ export async function startChannel(runnerId, deps = {}) {
95
118
  if (unconfirmed === "reported" && polled())
96
119
  unconfirmed = null;
97
120
  // While reported, no runnerState: the stored state stands, and the poll still drains for wake signals.
98
- // AGENT-GUARD-ACTIVE: the supervisor's marker, written by agent-guard's SessionStart hook in this session.
99
- const guarded = !process.env.AR_GUARD_MARKER || existsSync(process.env.AR_GUARD_MARKER);
100
121
  const response = await next(unconfirmed ? {} : { runnerState: guarded ? "ready" : "starting" });
101
122
  if (response.error || response.result?.isError)
102
123
  throw new Error("AgentRadar refused next_event; check the runner key and its poll permission");
@@ -105,10 +126,13 @@ export async function startChannel(runnerId, deps = {}) {
105
126
  const events = payload.events ?? [];
106
127
  const wake = JSON.stringify([events.map((event) => event.eventId), payload.livenessProbe?.nonce, Boolean(payload.onboarding)]);
107
128
  if ((events.length || payload.livenessProbe || payload.onboarding) && (wake !== lastWake || Date.now() - lastSent >= 30_000)) {
129
+ const restart = events.length ? (await runnerFrame()).restartLine(await readFile(notice, "utf8").then(JSON.parse, () => null), runnerId) : "";
108
130
  await send({ jsonrpc: "2.0", method: "notifications/claude/channel", params: {
109
- content: `${payload.firstStep ? `${payload.firstStep} ` : ""}${events.length} new AgentRadar events; run \`agentradar run ${runnerId} inbox next\`${payload.livenessProbe ? " to receive your liveness check" : ""}.`,
131
+ content: `${restart ? `${restart} ` : ""}${wakeText(runnerId, events.length, payload)}`,
110
132
  meta: { count: String(events.length) },
111
133
  } });
134
+ if (restart)
135
+ await rm(notice, { force: true });
112
136
  lastWake = wake;
113
137
  lastSent = Date.now();
114
138
  }
@@ -120,7 +144,7 @@ export async function startChannel(runnerId, deps = {}) {
120
144
  if (!stop.signal.aborted)
121
145
  logError(/below the minimum/.test(said) ? said : "AgentRadar channel poll failed; retrying. Check connectivity and the runner key.");
122
146
  }
123
- await pause();
147
+ await pause(guarded ? undefined : guardLoaded);
124
148
  }
125
149
  };
126
150
  const handle = async (message) => {
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { installVendorSkill } from "./skill-install.js";
5
5
  import { startChannel } from "./channel.js";
6
6
  import { runRestartSelftest, startVendorRunner } from "./runner.js";
7
7
  import { cancelJoin, CODEX_GUARD_REFUSAL, dropStaleMcp, ensureCodexGuard, joinOrganisation, leaveOrganisation, setupVendorAgent } from "./setup.js";
8
- import { callAwpTool, callToolOverCli, createTaskOverCli, INBOX_SEARCH_USAGE, METRIC_REPORT_USAGE, PROPOSE_HUMAN_USAGE, inboxSearchOverCli, LISTING_DESCRIBE_USAGE, listServiceToolsOverCli, parseCallArgs, PERMISSION_VERB_TOOLS, runServiceToolOverCli, TASK_VERB_TOOLS } from "./tool-run.js";
8
+ import { callAwpTool, callToolOverCli, EXIT, parseCallArgs } from "./tool-run.js";
9
9
  import { installSyncTimer, syncAgentDir } from "./sync.js";
10
10
  import { importVaultOverCli } from "./vault-import.js";
11
11
  import { cloneVaultOverCli } from "./vault-clone.js";
@@ -17,25 +17,41 @@ export const runArgs = (raw) => raw[0] !== "run" ? raw
17
17
  : raw[1] === "join" ? raw.slice(1)
18
18
  : /^https?:\/\//.test(raw[1] ?? "") ? ["join", ...raw.slice(1)]
19
19
  : ["call", ...raw.slice(1)];
20
+ const USAGE = [
21
+ "Usage: agentradar <noun> [<sub-noun>] <verb> [--<flag> <value> | --<flag>-file <path> ...] [--dry-run] [--json] [--runner <id>]",
22
+ " Every agent command comes from the server. agentradar --help lists the groups (as $AR_CLI_RUNNER_ID),",
23
+ " agentradar <group> --help a group's commands, agentradar <command> --help its flags.",
24
+ " Examples:",
25
+ " agentradar inbox next",
26
+ " agentradar channels reply --thread-id <id> --text \"On it\"",
27
+ " agentradar tasks create --title \"Draft the brief\" --dry-run",
28
+ " The runner is $AR_CLI_RUNNER_ID, or --runner <id>. --dry-run prints the exact call and sends nothing.",
29
+ " Exit codes: 0 done; 1 refused or unreachable (the reason is on stderr); 2 the command was wrong.",
30
+ "Local commands:",
31
+ " agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--no-service | --service]",
32
+ " agentradar join cancel <join-link|request-id>",
33
+ " agentradar setup [--trust] [--harness claude|codex]",
34
+ " agentradar leave <runner-id>",
35
+ " agentradar channel <runner-id>",
36
+ " agentradar selftest <runner-id> [--status|--restart]",
37
+ " agentradar runner start <runner-id>",
38
+ " agentradar call <runner-id> <tool | command> [--<flag> <value> ...] [--args '<json>']",
39
+ " agentradar vault import <runner-id> <dir> [--vault <vault-id>] [--source <name>] [--write]",
40
+ " agentradar vault clone <runner-id> <vault-id> [dir]",
41
+ " agentradar secrets pull|renew|env <runner-id> | agentradar secrets exec <runner-id> -- <command>",
42
+ " agentradar secrets upload|rotate <runner-id> --env-file <path> | --from-env <NAME,...> | --prompt <NAME> [--replace]",
43
+ " agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]",
44
+ " agentradar admin broadcast --dry-run|--send --target all --text <text> --cta <next step>",
45
+ " agentradar upgrade | agentradar --version",
46
+ ].join("\n");
20
47
  async function main() {
21
48
  const args = runArgs(process.argv.slice(2));
22
49
  if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
23
- console.log("Usage: agentradar run <join-link> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] [--harness claude|codex] | agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--feeder] | agentradar join cancel <join-link|request-id> | agentradar leave <runner-id> | agentradar channel <runner-id> | agentradar selftest <runner-id> [--status|--restart] | agentradar runner start <runner-id> | agentradar tool list <runner-id> | agentradar tool run <runner-id> <tool-key> [--args '<json>'] | agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar task create|repeat|make-recurring <runner-id> --args '<json>' | agentradar inbox search <runner-id> [--query <text>] [--<filter> <value> ...] [--json] | agentradar listing describe <runner-id> --capabilities <text> | agentradar permissions mine|roles|set|request|grant-tool <runner-id> [--<argument> <value> ...] | agentradar vault import <runner-id> <dir> [--vault <vault-id>] [--source <name>] [--write] | agentradar vault clone <runner-id> <vault-id> [dir] | agentradar metric report <runner-id> --metric-id <id> --value <n> --period-start <iso> --period-end <iso> [--evidence <text>] | agentradar secrets pull|renew <runner-id> | agentradar secrets exec <runner-id> -- <command> | agentradar propose-human <runner-id> --name <name> --email <email> --role-id <id> | agentradar secrets upload <runner-id> --env-file <path> | --from-env <NAME,...> | --prompt <NAME> [--replace] | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>] | agentradar admin broadcast --dry-run|--send --target all --text <text> --cta <next step>");
24
- console.log([
25
- "agentradar run <runner-id> <noun> <verb> [--<argument> <value> ...] [--dry-run] [--json]",
26
- " Every agent tool, grouped in AgentRadar terms: inbox, thread, message, task, metric, vault, ...",
27
- " agentradar run <runner-id> --help the command groups",
28
- " agentradar run <runner-id> thread --help a group's verbs",
29
- " agentradar run <runner-id> thread reply --help one command's arguments",
30
- " Examples:",
31
- " agentradar run r1 inbox next",
32
- " agentradar run r1 thread reply --thread-id <id> --text \"On it\"",
33
- " agentradar run r1 inbox ack --message-ids <id> --message-ids <id>",
34
- " agentradar run r1 task create --title \"Draft the brief\" --dry-run",
35
- " --dry-run prints the exact call and sends nothing. Output is JSON. Exit codes: 0 done; 1 refused or",
36
- " unreachable (reason on stderr); 2 the command was wrong.",
37
- "agentradar upgrade install the latest agentradar-cli",
38
- ].join("\n"));
50
+ console.log(USAGE);
51
+ // The agent's own groups, from its server, when this shell is a runner's.
52
+ const runner = process.env.AR_CLI_RUNNER_ID;
53
+ if (runner)
54
+ process.exitCode = await callToolOverCli(runner, "", undefined, {}, [["help", ""]]);
39
55
  return;
40
56
  }
41
57
  if (args.length === 1 && args[0] === "upgrade") {
@@ -70,7 +86,7 @@ async function main() {
70
86
  await startChannel(args[1]);
71
87
  return;
72
88
  }
73
- if (args[0] === "selftest" && args.length === 3 && args[2] === "--restart") {
89
+ if (args[0] === "selftest" && args[1] !== "run" && args.length === 3 && args[2] === "--restart") {
74
90
  // A native-channel session resumes in this terminal; print the result after it closes, not over its screen.
75
91
  const report = [];
76
92
  process.once("exit", () => { if (report.length)
@@ -78,7 +94,7 @@ async function main() {
78
94
  await runRestartSelftest(args[1], { log: (text) => report.push(text) });
79
95
  return;
80
96
  }
81
- if (args[0] === "selftest" && (args.length === 2 || (args.length === 3 && args[2] === "--status"))) {
97
+ if (args[0] === "selftest" && args[1] !== "run" && (args.length === 2 || (args.length === 3 && args[2] === "--status"))) {
82
98
  process.exitCode = await callAwpTool(args[1], "run_selftest", { statusOnly: args[2] === "--status" }, {});
83
99
  return;
84
100
  }
@@ -110,18 +126,18 @@ async function main() {
110
126
  for (let i = 1; i < args.length; i++) {
111
127
  if (["--name", "--model", "--attach-tmux", "--session"].includes(args[i]))
112
128
  named[args[i].slice(2)] = args[++i] ?? "-";
113
- else if (!["--trust", "--attach-current", "--allow-bypass", "--revoke-bypass", "--feeder"].includes(args[i]))
129
+ else if (!["--trust", "--attach-current", "--allow-bypass", "--revoke-bypass", "--feeder", "--service", "--no-service"].includes(args[i]))
114
130
  urls.push(args[i]);
115
131
  }
116
- if (urls.length !== 1 || [urls[0], ...Object.values(named)].some((value) => value.startsWith("-")) || (named["attach-tmux"] && args.includes("--attach-current"))) {
117
- console.error("Usage: agentradar join <join-link> [--name <your name>] [--model <your model id>] [--trust] [--attach-tmux <session[:window.pane]> | --attach-current] [--session <claude session id>] [--revoke-bypass] [--feeder]");
132
+ if (urls.length !== 1 || [urls[0], ...Object.values(named)].some((value) => value.startsWith("-")) || (named["attach-tmux"] && args.includes("--attach-current")) || (args.includes("--service") && args.includes("--no-service"))) {
133
+ console.error("Usage: agentradar join <join-link> [--name <your name>] [--model <your model id>] [--trust] [--attach-tmux <session[:window.pane]> | --attach-current (Codex and other CLIs)] [--session <claude session id>] [--revoke-bypass] [--no-service | --service]");
118
134
  process.exitCode = 1;
119
135
  return;
120
136
  }
121
137
  process.exitCode = await joinOrganisation(urls[0], {
122
138
  trust: args.includes("--trust"), agentName: named.name, model: named.model, session: named.session,
123
139
  attach: args.includes("--attach-current") ? "current" : named["attach-tmux"],
124
- revokeBypass: args.includes("--revoke-bypass"), feeder: args.includes("--feeder"),
140
+ revokeBypass: args.includes("--revoke-bypass"), feeder: args.includes("--feeder"), service: args.includes("--service"),
125
141
  });
126
142
  return;
127
143
  }
@@ -134,21 +150,16 @@ async function main() {
134
150
  process.exitCode = await startVendorRunner(args[2]);
135
151
  return;
136
152
  }
137
- if (args.length === 3 && args[0] === "tool" && args[1] === "list") {
138
- process.exitCode = await listServiceToolsOverCli(args[2]);
139
- return;
140
- }
141
- if ((args[0] === "tool" && args[1] === "run") || args[0] === "call") {
142
- const call = args[0] === "call";
143
- const parsed = parseCallArgs(args.slice(call ? 1 : 2));
144
- if (!parsed || (!call && parsed.flags.length)) {
145
- console.error(call ? "Usage: agentradar run <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar run <runner-id> [<tool>] --help" : "Usage: agentradar tool run <runner-id> <tool-key> [--args '<json>']");
146
- process.exitCode = 1;
153
+ if (args[0] === "call") {
154
+ // `call <runner-id> <tool | command>`: the runner named in place, for a shell that has no AR_CLI_RUNNER_ID.
155
+ const parsed = parseCallArgs(args.slice(1));
156
+ const [runnerId, ...words] = parsed?.words ?? [];
157
+ if (!parsed || !runnerId || words.length > 3 || (!words.length && !parsed.flags.some(([flag]) => flag === "help"))) {
158
+ console.error("Usage: agentradar call <runner-id> <tool | command> [--<flag> <value> ...] [--args '<json>'] | agentradar call <runner-id> [<command>] --help");
159
+ process.exitCode = EXIT.usage;
147
160
  return;
148
161
  }
149
- process.exitCode = call
150
- ? await callToolOverCli(parsed.runnerId, parsed.tool, parsed.json, {}, parsed.flags)
151
- : await runServiceToolOverCli(parsed.runnerId, parsed.tool, parsed.json);
162
+ process.exitCode = await callToolOverCli(runnerId, words.join(" "), parsed.json, {}, parsed.flags);
152
163
  return;
153
164
  }
154
165
  if (args[0] === "vault" && args[1] === "import") {
@@ -171,61 +182,8 @@ async function main() {
171
182
  process.exitCode = await cloneVaultOverCli(args[2], args[3], args[4]);
172
183
  return;
173
184
  }
174
- if (args[0] === "inbox" && args[1] === "search") {
175
- const parsed = parseCallArgs([...args.slice(2).filter((arg) => arg !== "--json"), "inbox_search"]);
176
- if (!parsed || parsed.tool !== "inbox_search") {
177
- console.error(INBOX_SEARCH_USAGE);
178
- process.exitCode = 1;
179
- return;
180
- }
181
- process.exitCode = await inboxSearchOverCli(parsed.runnerId, parsed.flags, args.includes("--json"));
182
- return;
183
- }
184
- if (args[0] === "metric" && args[1] === "report") {
185
- // report_metric over the same endpoint and key: an agent's own metric, a subordinate's, or a person's below it.
186
- const parsed = parseCallArgs([...args.slice(2), "report_metric"]);
187
- if (!parsed || !parsed.flags.some(([flag]) => flag === "metric-id")) {
188
- console.error(METRIC_REPORT_USAGE);
189
- process.exitCode = 1;
190
- return;
191
- }
192
- process.exitCode = await callToolOverCli(parsed.runnerId, parsed.tool, undefined, {}, parsed.flags);
193
- return;
194
- }
195
- if (args[0] === "propose-human") {
196
- // R013: the Chief invites a human it found into a vacant role, the MCP `propose_human` over the same endpoint and key.
197
- const parsed = parseCallArgs([...args.slice(1), "propose_human"]);
198
- if (!parsed || !["name", "email", "role-id"].every((k) => parsed.flags.some(([flag]) => flag === k))) {
199
- console.error(PROPOSE_HUMAN_USAGE);
200
- process.exitCode = 1;
201
- return;
202
- }
203
- process.exitCode = await callToolOverCli(parsed.runnerId, parsed.tool, undefined, {}, parsed.flags);
204
- return;
205
- }
206
- if (args[0] === "listing" && args[1] === "describe") {
207
- // UAT U1: the agent's public catalog description, the MCP `set_listing_description` over the same endpoint and key.
208
- const parsed = parseCallArgs([...args.slice(2), "set_listing_description"]);
209
- if (!parsed || !parsed.flags.some(([flag]) => flag === "capabilities")) {
210
- console.error(LISTING_DESCRIBE_USAGE);
211
- process.exitCode = 1;
212
- return;
213
- }
214
- process.exitCode = await callToolOverCli(parsed.runnerId, parsed.tool, undefined, {}, parsed.flags);
215
- return;
216
- }
217
- if (args[0] === "permissions" && args[1] && Object.hasOwn(PERMISSION_VERB_TOOLS, args[1])) {
218
- const tool = PERMISSION_VERB_TOOLS[args[1]];
219
- const parsed = parseCallArgs([...args.slice(2), tool]);
220
- if (!parsed || parsed.tool !== tool) {
221
- console.error("Usage: agentradar permissions mine|roles|set|request|grant-tool <runner-id> [--<argument> <value> ...]");
222
- process.exitCode = 1;
223
- return;
224
- }
225
- process.exitCode = await callToolOverCli(parsed.runnerId, tool, parsed.json, {}, parsed.flags);
226
- return;
227
- }
228
- if (args[0] === "secrets") {
185
+ // The local secrets verbs take the runner in place; `secrets list|upload|grant|revoke|rotate --<flag>` are the server's.
186
+ if (args[0] === "secrets" && ["pull", "renew", "env", "exec", "upload", "rotate"].includes(args[1] ?? "") && args[2] && !args[2].startsWith("-")) {
229
187
  process.exitCode = await secretsOverCli(args.slice(1));
230
188
  return;
231
189
  }
@@ -233,15 +191,6 @@ async function main() {
233
191
  process.exitCode = await adminOverCli(args.slice(1));
234
192
  return;
235
193
  }
236
- if (args[0] === "task" && args[1] && Object.hasOwn(TASK_VERB_TOOLS, args[1])) {
237
- if (args.length !== 5 || args[3] !== "--args") {
238
- console.error("Usage: agentradar task create|repeat|make-recurring <runner-id> --args '<json>'");
239
- process.exitCode = 1;
240
- return;
241
- }
242
- process.exitCode = await createTaskOverCli(args[2], args[1], args[4]);
243
- return;
244
- }
245
194
  if (args.length === 2 && args[0] === "skill" && args[1] === "install") {
246
195
  await installVendorSkill();
247
196
  return;
@@ -264,10 +213,17 @@ async function main() {
264
213
  process.exitCode = await syncAgentDir(args[1]);
265
214
  return;
266
215
  }
267
- const help = args.length === 1 && (args[0] === "--help" || args[0] === "-h");
268
- (help ? console.log : console.error)("Usage: agentradar run <join-link> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] | agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--feeder] | agentradar join cancel <join-link|request-id> | agentradar leave <runner-id> | agentradar channel <runner-id> | agentradar selftest <runner-id> [--status|--restart] | agentradar runner start <runner-id> | agentradar tool list <runner-id> | agentradar tool run <runner-id> <tool-key> [--args '<json>'] | agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar task create|repeat|make-recurring <runner-id> --args '<json>' | agentradar inbox search <runner-id> [--query <text>] [--<filter> <value> ...] [--json] | agentradar listing describe <runner-id> --capabilities <text> | agentradar permissions mine|roles|set|request|grant-tool <runner-id> [--<argument> <value> ...] | agentradar metric report <runner-id> --metric-id <id> --value <n> --period-start <iso> --period-end <iso> [--evidence <text>] | agentradar secrets pull|renew <runner-id> | agentradar secrets exec <runner-id> -- <command> | agentradar propose-human <runner-id> --name <name> --email <email> --role-id <id> | agentradar secrets upload <runner-id> --env-file <path> | --from-env <NAME,...> | --prompt <NAME> [--replace] | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>] | agentradar admin broadcast --dry-run|--send --target all --text <text> --cta <next step>");
269
- if (!help)
270
- process.exitCode = 1;
216
+ // #1871: every other command is the server's, `<noun> [<sub-noun>] <verb>`, run as this shell's runner.
217
+ const parsed = parseCallArgs(args);
218
+ const runner = parsed?.flags.find(([flag]) => flag === "runner")?.[1] ?? process.env.AR_CLI_RUNNER_ID;
219
+ if (!parsed || !runner || parsed.words.length > 3 || (!parsed.words.length && !parsed.flags.some(([flag]) => flag === "help"))) {
220
+ console.error(USAGE);
221
+ if (parsed && !runner)
222
+ console.error("Agent commands run as a runner: set AR_CLI_RUNNER_ID or pass --runner <id>.");
223
+ process.exitCode = EXIT.usage;
224
+ return;
225
+ }
226
+ process.exitCode = await callToolOverCli(runner, parsed.words.join(" "), parsed.json, {}, parsed.flags.filter(([flag]) => flag !== "runner"));
271
227
  }
272
228
  /**
273
229
  * 🔴 `error.message` ALONE IS TWO WORDS ON THE FAILURE A VENDOR IS MOST LIKELY TO HIT. Node's `fetch`
package/dist/setup.js CHANGED
@@ -469,13 +469,17 @@ async function installRunner(home, projectPath, approved, service, feed) {
469
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, "\\$&"))}`);
470
470
  }
471
471
  else {
472
- // The caller's own conversation, both times: a resume that fails tries it once more, never a fresh session.
473
- joined.push(`AR_RESUME_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`, `AR_FRESH_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`);
474
- // A person's model switch: the feeder and agent-guard's hooks meet in this directory, keyed by the session.
475
- joined.push(`AR_CLAUDE_SESSION_ID=${shellValue(service.sessionId)}`, `AR_MODEL_SWITCH_DIR=${shellValue(join(configDir, "model-switch"))}`);
476
- const configDirEnv = service.env.CLAUDE_CONFIG_DIR?.trim();
477
- if (configDirEnv)
478
- 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
+ }
479
483
  // agent-guard, enabled for this session only (`--settings`), loaded from the copy inside this package, with
480
484
  // its plugin state under the runner's config so the vendor's own Claude plugins are never touched.
481
485
  const settings = join(configDir, "agentradar-settings.json");
@@ -487,16 +491,21 @@ async function installRunner(home, projectPath, approved, service, feed) {
487
491
  // Nobody sits at the pane to accept bypass mode's warning screen; the operator's consent was given at approval.
488
492
  ...(service.bypass ? { skipDangerousModePermissionPrompt: true } : {}),
489
493
  }, null, 2)}\n`);
490
- if (service.bypass)
491
- joined.push(`CLAUDE_EXTRA_FLAGS=${shellValue("--permission-mode bypassPermissions")}`);
492
- 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
+ }
493
501
  }
494
502
  if (service.attach) {
495
503
  // The caller's own tmux pane: the feeder types into it; the supervisor never starts, kills or streams it.
496
504
  // TMUX names the caller's tmux server, which a service started by launchd or systemd would not find.
497
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"}( |$)`)}`]));
498
506
  }
499
- 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)
500
509
  .filter(([key, value]) => (TOOL_ENV.test(key) || toolKeys.includes(key)) && key !== "ANTHROPIC_API_KEY" && value && !value.includes("\n"))
501
510
  .map(([key, value]) => `${key}=${value}`);
502
511
  if (toolEnv.length) {
@@ -614,6 +623,7 @@ export async function setupVendorAgent(deps = {}) {
614
623
  throw new Error("AgentRadar setup code expired; run setup again");
615
624
  }
616
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.";
617
627
  export const BYPASS_REVOKED = "Bypass-permissions mode is off on this machine now; the agent asks before acting again.";
618
628
  /** A sortable key for `x.y.z[-pre]`; a release sorts above its prereleases. ponytail: prereleases of one version tie. */
619
629
  const versionRank = (version) => {
@@ -720,36 +730,34 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
720
730
  }
721
731
  }
722
732
  const label = harness === "generic" ? basename((env.AGENTRADAR_AGENT_BIN ?? "").trim()) : harness === "claude" ? "Claude Code" : "Codex";
723
- // Vadym 10342: every Claude Code agent connects through the AgentRadar channel. The tmux feeder is the fallback:
724
- // another CLI, an explicit opt-in (--feeder, AGENTRADAR_FEED=feeder, or a pane named with --attach-*), or a
725
- // channel the session never confirms (channel.ts reports it and tells the operator to re-run with --feeder).
726
- 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);
727
741
  // The agent is a persistent session the service owns, not this one: no MCP entry in this folder (it acts
728
742
  // through `agentradar run`), a runner env pinned to its own session, and a LaunchAgent / systemd unit.
729
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.
730
744
  const settle = async () => {
731
745
  const tmux = deps.tmux ?? (async (args) => (await promisify(execFile)(env.TMUX_BIN || "tmux", args, { env })).stdout);
732
- // Attach to the caller when it already lives in tmux: asked for by flag, or a Claude Code session in a tmux
733
- // 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).
734
747
  let attach = deps.attach === "current" ? env.TMUX_PANE : deps.attach;
735
748
  if (deps.attach === "current" && !attach)
736
749
  throw new Error("--attach-current needs a session running inside tmux");
737
- // Never in channel mode: nothing types into the caller's pane.
738
- if (!channel && !deps.attach && harness === "claude" && env.CLAUDECODE && env.TMUX && env.TMUX_PANE)
739
- attach = env.TMUX_PANE;
740
750
  if (attach) {
741
751
  // A pane id (%3) dies with the tmux server; session:window.pane comes back when its session is recreated.
742
752
  attach = (await tmux(["display-message", "-p", "-t", attach, "#{session_name}:#{window_index}.#{pane_index}"])).trim();
743
753
  if (!attach)
744
754
  throw new Error("tmux found no such pane");
745
- if (!deps.attach && attach.startsWith("join-"))
746
- attach = undefined;
747
755
  }
748
756
  // Unattended, the service resumes the caller's own Claude conversation, never a stranger's. An attached
749
757
  // pane is never started from here, so there its session id only keys the model switch.
750
- const sessionId = harness !== "claude" ? undefined
751
- : await callerSession(deps, env, home, await realpath(cwd)).catch((error) => { if (attach || channel)
752
- 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; });
753
761
  return { attach, sessionId };
754
762
  };
755
763
  let where;
@@ -758,14 +766,14 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
758
766
  * 10-04, after the Codex run stopped on an approval prompt nobody could answer); `--revoke-bypass` takes it
759
767
  * back on this machine. An attached pane keeps the permission mode it already runs in.
760
768
  */
761
- const localBypass = (approvedBypass, revoked) => approvedBypass && !revoked && !where.attach;
769
+ const localBypass = (approvedBypass, revoked) => approvedBypass && !revoked && !where.attach && !inPlace;
762
770
  const startJoined = async (requestId, agentId, installation, approvedBypass, bypass, saved) => {
763
771
  const runnerId = `join-${requestId}`;
764
772
  const { attach } = where;
765
773
  const projectPath = harness === "claude"
766
774
  ? await installProjectMcp(claudeConfigPath(home, env), cwd, null, deps.trust === true)
767
775
  : await realpath(cwd);
768
- 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);
769
777
  await saved?.();
770
778
  log(`Runner installed → ${runnerEnv}`);
771
779
  if (bypass)
@@ -774,6 +782,15 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
774
782
  await syncJoinedSkills(runnerId, { home, fetch: fetchImpl, log }).catch((error) => {
775
783
  log(`Your AgentRadar skills are not installed yet (${String(error?.message ?? error).split("\n")[0]}); the runner installs them once it is running.`);
776
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
+ }
777
794
  try {
778
795
  await (deps.installService ?? ((id) => installAgentService(id, home, env)))(runnerId);
779
796
  }
@@ -791,14 +808,10 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
791
808
  const mode = bypass ? ", in bypass-permissions mode as your company allowed" : "";
792
809
  if (channel) {
793
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}`);
794
- log(`If your company's Claude plan blocks channels, deliver with the feeder instead: agentradar join ${enrolmentUrl} --feeder`);
795
811
  log("Running as a service; it restarts on crash and reboot.");
796
812
  return 0;
797
813
  }
798
- // Only Claude resumes the caller's own conversation; Codex and a generic CLI start their own session.
799
- log(harness === "claude"
800
- ? `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}`
801
- : `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}`);
802
815
  log("Running as a service; it restarts on crash and reboot.");
803
816
  return 0;
804
817
  };
@@ -990,8 +1003,8 @@ export async function leaveOrganisation(runnerId, deps = {}) {
990
1003
  await execute("systemctl", ["--user", "disable", "--now", `agentradar-agent@${runnerId}`]).catch(() => undefined);
991
1004
  }
992
1005
  // The service stopping leaves the session running; `=` matches the tmux session name exactly.
993
- // An attached pane is the caller's own session: it stays.
994
- 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")
995
1008
  await execute(envValue(saved, "TMUX_BIN") ?? "tmux", ["kill-session", "-t", `=${envValue(saved, "TMUX_SESSION") ?? runnerId}`]).catch(() => undefined);
996
1009
  for (const path of [envPath, join(home, ".agentradar", "agents", `${runnerId}.delivered.json`)])
997
1010
  await rm(path, { force: true });
@@ -82,13 +82,15 @@ export function skillsDirFor(harness, env) {
82
82
  return dir.includes("\0") ? null : join(dir);
83
83
  }
84
84
  export async function syncJoinedSkills(runnerId, deps) {
85
- const env = await readFile(join(deps.home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
86
- const harness = envValue(env, "AR_AGENT_HARNESS");
87
- 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));
88
90
  if (!skillsDir)
89
91
  return;
90
92
  const codex = harness === "codex";
91
- const root = (codex ? envValue(env, "CODEX_HOME") : envValue(env, "AR_BASE")) ?? "";
93
+ const root = (codex ? value("CODEX_HOME") : value("AR_BASE")) ?? "";
92
94
  // Vadym 9988: the server's start instructions with this agent's brief, before its first session starts.
93
95
  // Same file and mark as the runner's feeder, which keeps it fresh from then on.
94
96
  const init = await awpRequest(runnerId, "initialize", {