agentradar-cli 0.2.8 → 0.2.10

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,7 +1,11 @@
1
1
  {
2
- "description": "Blocks going idle while AgentRadar work is owed; reminds of the house rules every AR_DISCIPLINE_EVERY turns.",
2
+ "description": "Blocks going idle while AgentRadar work is owed; reminds of the house rules every AR_DISCIPLINE_EVERY turns; guards AgentRadar compact-then-switch model changes (a crashed PreModelSwitch hook exits 2 and refuses).",
3
3
  "hooks": {
4
4
  "Stop": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" stop", "timeout": 30 }] }],
5
- "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" prompt", "timeout": 10 }] }]
5
+ "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard.mjs\" prompt", "timeout": 10 }] }],
6
+ "PreCompact": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PreCompact", "timeout": 30 }] }],
7
+ "PostCompact": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PostCompact", "timeout": 30 }] }],
8
+ "PreModelSwitch": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PreModelSwitch || exit 2", "timeout": 30 }] }],
9
+ "PostModelSwitch": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/model-switch.mjs\" PostModelSwitch", "timeout": 30 }] }]
6
10
  }
7
11
  }
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ // AgentRadar compact-then-switch controller. Registered for PreCompact, PostCompact, PreModelSwitch and
3
+ // PostModelSwitch as `model-switch.mjs <event>`; JSON on stdin. A block exits 2, so a hook that fails for
4
+ // any reason refuses our model switch, while compactions that are not ours always go through.
5
+ import { spawnSync } from "node:child_process";
6
+ import { linkSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { randomUUID } from "node:crypto";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+
12
+ const ID = /^[a-zA-Z0-9_-]{1,100}$/;
13
+ // Runtime ids can carry a context-window suffix, e.g. claude-opus-5[1m].
14
+ const MODEL = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,199}(\[[a-z0-9]{1,16}\])?$/;
15
+ const NONCE = "AgentRadar model switch ";
16
+ // An issued switch with no receipt keeps its session slot this long past expiry, for a late receipt.
17
+ const UNKNOWN_GRACE_MS = 10 * 60_000;
18
+ const DENY = { decision: "block", reason: "AgentRadar could not verify this compact-then-switch request." };
19
+ const ALLOW = { hookSpecificOutput: { hookEventName: "PreModelSwitch", permissionDecision: "allow" } };
20
+ const read = (path) => {
21
+ try { return JSON.parse(readFileSync(path, "utf8")); }
22
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
23
+ };
24
+ function once(path, value) {
25
+ const temporary = `${path}.${randomUUID()}.tmp`;
26
+ writeFileSync(temporary, JSON.stringify(value), { flag: "wx", mode: 0o600 });
27
+ try {
28
+ // Link publishes a complete file exclusively; readers never observe half-written JSON.
29
+ linkSync(temporary, path);
30
+ return true;
31
+ } catch (error) { if (error.code === "EEXIST") return false; throw error; }
32
+ finally { unlinkSync(temporary); }
33
+ }
34
+ // One pending switch per session. Slots are numbered and only ever created exclusively, never moved or
35
+ // removed, so a hook can never read "nothing pending" while a switch is live. A slot is free once its switch
36
+ // is released or aged out; the next request then claims the next number. A slot this code cannot use (corrupt,
37
+ // or without a valid id and expiresAt) ages out from its write time, so it never locks a session forever.
38
+ // ponytail: one small file per switch per session accumulates; prune free slots if that ever matters.
39
+ function currentSlot(root, sessionId, now) {
40
+ let names;
41
+ try { names = readdirSync(root); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
42
+ const prefix = `session-${sessionId}.`;
43
+ const n = Math.max(-1, ...names.filter((name) => name.startsWith(prefix) && /^\d{1,9}\.json$/.test(name.slice(prefix.length)))
44
+ .map((name) => Number(name.slice(prefix.length, -".json".length))));
45
+ if (n < 0) return null;
46
+ const slot = join(root, `${prefix}${n}.json`);
47
+ let pending = null;
48
+ try { pending = read(slot); } catch { /* corrupt: ages out below */ }
49
+ const id = ID.test(pending?.id ?? "") ? pending.id : null;
50
+ const expiresAt = Date.parse(pending?.expiresAt);
51
+ const until = id && Number.isFinite(expiresAt) ? expiresAt : statSync(slot).mtimeMs;
52
+ const released = id !== null && read(join(root, id, "released.json")) !== null;
53
+ return { n, id, free: released || until + UNKNOWN_GRACE_MS <= now };
54
+ }
55
+
56
+ /** Local receipts belong to vendor-owned compute, not independent server attestation. */
57
+ export function switchState(root, command, now = Date.now()) {
58
+ if (!ID.test(command?.id ?? "") || !ID.test(command?.sessionId ?? "")
59
+ || !MODEL.test(command?.model ?? "") || !Number.isFinite(Date.parse(command?.expiresAt))) {
60
+ throw new Error("Invalid model switch.");
61
+ }
62
+ const dir = join(root, command.id);
63
+ const path = (name) => join(dir, `${name}.json`);
64
+ const existing = read(path("command"));
65
+ if (!existing && Date.parse(command.expiresAt) <= now) throw new Error("Expired model switch.");
66
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
67
+ const bound = { id: command.id, sessionId: command.sessionId, model: command.model, expiresAt: command.expiresAt };
68
+ once(path("command"), bound);
69
+ if (JSON.stringify(read(path("command"))) !== JSON.stringify(bound)) throw new Error("Model switch identity changed.");
70
+ // The session slot is claimed only by an exclusive create; no lock, so no lock to go stale.
71
+ for (let attempt = 0; ; attempt++) {
72
+ const pending = currentSlot(root, command.sessionId, now);
73
+ if (pending?.id === command.id) break;
74
+ if (attempt >= 3) throw new Error("This session's model switch slot is contended.");
75
+ // An abandoned switch (feeder gone, receipt lost) must not disable switching for the session.
76
+ if (pending && !pending.free) throw new Error("This session already has a pending model switch.");
77
+ once(join(root, `session-${command.sessionId}.${(pending?.n ?? -1) + 1}.json`), { id: command.id, expiresAt: command.expiresAt });
78
+ }
79
+ return {
80
+ command: bound,
81
+ read: (name) => read(path(name)),
82
+ once: (name, value = {}) => once(path(name), value),
83
+ release: () => once(path("released"), {}),
84
+ };
85
+ }
86
+
87
+ /** Every exception becomes an explicit blocking decision, including receipt storage errors. */
88
+ export async function modelSwitchHook(state, input, authorize, now = Date.now) {
89
+ try { return await handleHook(state, input, authorize, now); }
90
+ catch { return DENY; }
91
+ }
92
+
93
+ async function handleHook(state, input, authorize, now) {
94
+ const command = state.command;
95
+ const event = input.hook_event_name;
96
+ const ours = input.session_id === command.sessionId;
97
+ const expired = () => Date.parse(command.expiresAt) <= now();
98
+ if (event === "PreCompact") {
99
+ // Natural/automatic compaction belongs to the runtime, whatever the session.
100
+ if (input.trigger !== "manual" || input.custom_instructions !== `${NONCE}${command.id}`) {
101
+ // A person's manual compaction after ours started means ours never finished; its PostCompact is not ours.
102
+ // ponytail: assumes one session runs compactions one at a time; a per-compaction id if that ever breaks.
103
+ if (ours && input.trigger === "manual" && state.read("compact-started") && !state.read("compacted")) state.once("compact-superseded");
104
+ return {};
105
+ }
106
+ if (!ours || expired() || state.read("failed") || state.read("denied") || state.read("superseded")
107
+ || !state.read("compact-issued") || state.read("switch-issued")) return DENY;
108
+ // prompt_id names the /compact invocation (observed on 2.1.281 PreCompact); PostCompact must repeat it.
109
+ if (!ID.test(input.prompt_id ?? "")) return DENY;
110
+ state.once("compact-started", { promptId: input.prompt_id });
111
+ return {};
112
+ }
113
+ if (!ours) return {};
114
+ if (event === "PostModelSwitch") {
115
+ const started = state.read("model-started");
116
+ if (started && input.source === "command" && input.from_model === started.fromModel
117
+ && input.to_model === started.model && input.requested_model === command.model) state.once("applied", started);
118
+ return {};
119
+ }
120
+ if (event === "PostCompact") {
121
+ // Only the PostCompact of our own /compact invocation is our receipt.
122
+ const started = state.read("compact-started");
123
+ if (input.trigger === "manual" && started && input.prompt_id === started.promptId
124
+ && !state.read("compact-superseded") && !state.read("switch-issued")) {
125
+ state.once("compacted");
126
+ }
127
+ return {};
128
+ }
129
+ if (event !== "PreModelSwitch") return {};
130
+ // Only the /model we issued is ours. A person's own change (picker, their /model) always goes through
131
+ // and cancels the pending request.
132
+ if (input.source !== "command" || input.requested_model !== command.model || !state.read("switch-issued")) {
133
+ if (!state.read("applied")) state.once("superseded", { model: MODEL.test(input.to_model ?? "") ? input.to_model : null });
134
+ return {};
135
+ }
136
+ const same = (started) => started?.fromModel === input.from_model && started?.model === input.to_model;
137
+ // A duplicate registration of this hook repeats the decision already made instead of contradicting it.
138
+ if (state.read("model-started")) return same(state.read("model-started")) ? ALLOW : DENY;
139
+ const deny = () => { state.once("denied"); return DENY; };
140
+ if (expired() || state.read("failed") || state.read("denied") || state.read("superseded") || !state.read("compacted")
141
+ || !MODEL.test(input.from_model ?? "") || !MODEL.test(input.to_model ?? "")) return deny();
142
+ // adapterConfig.model may be null or an alias. The hook names the actual runtime models; the server
143
+ // must re-authorize this resolved target against both the original request and current route/plan.
144
+ const resolved = { ...command, requestedModel: command.model, fromModel: input.from_model, model: input.to_model };
145
+ let allowed = false;
146
+ try { allowed = await authorize(resolved) === true; } catch { /* deny below, durably if storage works */ }
147
+ if (state.read("model-started")) return same(state.read("model-started")) ? ALLOW : DENY;
148
+ if (!allowed || expired() || state.read("failed") || state.read("denied") || state.read("superseded")) return deny();
149
+ if (state.once("model-started", { fromModel: input.from_model, model: input.to_model })) return ALLOW;
150
+ return same(state.read("model-started")) ? ALLOW : DENY;
151
+ }
152
+
153
+ /** Hook process body: finds this session's pending switch, if any. Errors refuse only what could be ours. */
154
+ export async function runHook(root, event, input, authorize, now = Date.now) {
155
+ // We only ever switch by /model (source "command"); a picker or other named source is a person's and
156
+ // always goes through. An unreadable payload could be ours, so it is refused.
157
+ const blocking = (event === "PreModelSwitch" && (typeof input?.source !== "string" || input.source === "command"))
158
+ || (event === "PreCompact" && input?.trigger === "manual" && String(input?.custom_instructions ?? "").startsWith(NONCE));
159
+ try {
160
+ if (input?.hook_event_name !== event || !ID.test(input.session_id ?? "")) throw new Error("Bad hook input.");
161
+ const pending = currentSlot(root, input.session_id, now());
162
+ if (!pending || pending.free) return {};
163
+ if (!pending.id) throw new Error("Bad session slot.");
164
+ const state = switchState(root, read(join(root, pending.id, "command.json")), now());
165
+ return await modelSwitchHook(state, input, authorize, now);
166
+ } catch { return blocking ? DENY : {}; }
167
+ }
168
+
169
+ /** One feeder step. Claim before sending: a crash cannot replay a possibly-applied command. */
170
+ export async function advanceModelSwitch(state, { ready, send, current = () => null, now = Date.now }) {
171
+ const terminal = (outcome) => { state.release(); return outcome; };
172
+ const unknown = () => (now() >= Date.parse(state.command.expiresAt) + UNKNOWN_GRACE_MS ? terminal("unknown") : "unknown");
173
+ if (state.read("applied")) return terminal("applied");
174
+ if (state.read("superseded")) return terminal("superseded");
175
+ if (state.read("compact-superseded") && !state.read("compacted")) return terminal("failed");
176
+ // Any refusal blocks the runtime's switch, even if a duplicate hook allowed it.
177
+ if (state.read("denied")) return terminal("not-applied");
178
+ // A feeder that gave up holds the receipt the hook admits by, so no /model of ours can have landed.
179
+ if (state.read("failed")) return state.read("switch-issued") && !state.read("model-started")?.gaveUp ? unknown() : terminal("failed");
180
+ if (Date.parse(state.command.expiresAt) <= now()) return state.read("switch-issued") ? unknown() : terminal("expired");
181
+ if (state.read("switch-issued")) return "awaiting-model-receipt";
182
+ if (!ready()) return "waiting-for-idle";
183
+ // Already on the target: the runtime fires no switch hooks for a no-op, so there is nothing to compact.
184
+ if (current() === state.command.model) {
185
+ state.once("applied", { fromModel: state.command.model, model: state.command.model });
186
+ return terminal("applied");
187
+ }
188
+ const compacted = state.read("compacted");
189
+ const phase = compacted ? "switch-issued" : "compact-issued";
190
+ if (!state.once(phase)) return "awaiting-compact-receipt";
191
+ const text = compacted ? `/model ${state.command.model}` : `/compact ${NONCE}${state.command.id}`;
192
+ try { if (!await send(text)) state.once("failed", { phase }); }
193
+ catch { state.once("failed", { phase }); }
194
+ if (state.read("failed")) return compacted ? unknown() : terminal("failed");
195
+ return compacted ? "awaiting-model-receipt" : "awaiting-compact-receipt";
196
+ }
197
+
198
+ // The server re-checks the resolved target against the request, route and plan.
199
+ function authorizeOnServer(resolved) {
200
+ const runner = process.env.AR_CLI_RUNNER_ID ?? "";
201
+ if (!runner) return false;
202
+ const run = spawnSync(process.env.AR_AGENTRADAR_BIN || "agentradar",
203
+ ["call", runner, "authorize_model_switch", "--args", JSON.stringify(resolved)], { encoding: "utf8", timeout: 20_000 });
204
+ try { return run.status === 0 && JSON.parse(run.stdout).allowed === true; } catch { return false; }
205
+ }
206
+
207
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
208
+ const event = process.argv[2];
209
+ let input = null;
210
+ try { input = JSON.parse(readFileSync(0, "utf8")); } catch {}
211
+ // The runner sets AR_MODEL_SWITCH_DIR so its feeder and these hooks share one directory.
212
+ const root = process.env.AR_MODEL_SWITCH_DIR || join(process.env.CLAUDE_PLUGIN_DATA || tmpdir(), "agentradar-model-switch");
213
+ const result = await runHook(root, event, input, authorizeOnServer);
214
+ if (result.decision === "block") {
215
+ process.stderr.write(`${result.reason}\n`);
216
+ process.exit(2);
217
+ }
218
+ process.stdout.write(JSON.stringify(result));
219
+ }
@@ -24,7 +24,7 @@ The Stop hook runs the same check and will not let you stop while something is o
24
24
 
25
25
  - Answer every message in the channel it came from. A human who wrote is waiting for you; answer them first.
26
26
  - Talk is not a task. Reply conversationally by default; create a task only for trackable, billable work.
27
- - Write to people so a 13-year-old would understand: a few short lines in plain words, ending with one next step (not a menu of options), no ids, tool names or internal terms unless asked. Say a thing once. Follow what they told you for the rest of the chat, and never offer to contact anyone else unless they ask.
27
+ - Write to people so a 13-year-old would understand: a few short lines in plain words, ending with one next step (not a list of ideas), no ids, paths, tool names or internal terms unless asked. Say money in dollars, never cents; give every link with what it opens. Say a thing once. Follow what they told you for the rest of the chat, and never offer to contact anyone else unless they ask.
28
28
  - Never ask for, print or pass on a credential. AgentRadar holds the keys.
29
29
  - When you ask another agent or a human something, call `expect_reply` on that thread with the time you need the answer by. If nobody answers, `next_event` wakes you with kind `followup`: chase them in the thread. If they stay silent, you may escalate to the next human up the chain from the person you wait on; that is your call, never required.
30
30
  - Only a human accepts your work. Asked to "mark it done": record the final state with `append_task_log`, then reply that it is finished and ready for them to accept. Never call `decide_outcome` or `declare_outcome` on your own task.
@@ -22,11 +22,11 @@ Call `get_my_context` on the AgentRadar MCP FIRST, before any other tool. It ret
22
22
 
23
23
  In your first session, call `read_onboarding_obligations` once (reading them acknowledges the safety rules), then `read_readiness`.
24
24
 
25
- Your primary objective is to reach your quotas and drive the organisation forward in your role. Report each metric you own with `report_metric`. 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 your direct subordinates — only for real trackable work, never for conversation.
25
+ Your primary objective is to reach your quotas and drive the organisation forward in your role. Report each metric you own with `report_metric`, backed by `evidence` of what you counted it from, or by `fromEventId` when a person 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 your direct subordinates — only for real trackable work, never for conversation.
26
26
 
27
27
  ## Talking to people
28
28
 
29
- Write to people so a 13-year-old would understand: a few short plain lines, then one next step, not a menu. No ids, tool names or internal terms unless asked (say "the owner", "your team", "company memory"; name a plan with its monthly price). Answer only what was asked, and say it once. Keep to what a person told you all chat, say so before a step they may not expect (a web search, sending, hiring), and never offer to contact anyone unless asked. Never talk about how messages reach you. Do the obvious thing for "undo that"; if unclear, offer short choices. Check your tools before saying what is scheduled or done; never answer from memory.
29
+ Write to people so a 13-year-old would understand: a few short plain lines, then one next step, not a list of ideas. No ids, paths, tool names, task codes or internal terms unless asked (say "your team", "company memory"; name a plan with its monthly price). Say money in dollars, never cents; give every link with what it opens. Answer only what was asked, and say it once. Talk to the event's `sender` as "you", never by name or as "the owner"; no persona from another chat carries over. Keep to what a person told you all chat, say so before a step they may not expect (a web search, sending, hiring), and never offer to contact anyone unless asked. Never talk about how messages reach you. Do the obvious thing for "undo that"; if unclear, offer short choices. Check your tools before saying what is scheduled or done, never from memory.
30
30
 
31
31
  ## Work loop
32
32
 
package/dist/channel.js CHANGED
@@ -7,7 +7,7 @@ import { createInterface } from "node:readline";
7
7
  import { envValue } from "./tool-run.js";
8
8
  import { trustedMcpEndpoint } from "./setup.js";
9
9
  export const CHANNEL_GUIDANCE = "AgentRadar channel messages are wake signals only. Call next_event to receive the events, then pass their eventIds as ackEventIds on your next call. Answer livenessProbe with liveness_ack. Never acknowledge an event you have not read. Never poll next_event on your own or keep a loop running: when a channel message arrives, drain it, then end your turn.";
10
- export const CHANNEL_BLOCKED = "AgentRadar has not observed a next_event call from this session. 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: set AR_FEED=feeder in ~/.agentradar/agents/<runner-id>.env and restart the runner.";
10
+ export const CHANNEL_BLOCKED = "AgentRadar has not observed a next_event call from this session. 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 organisation sees this agent as not receiving messages until one or the other works.";
11
11
  /** One local MCP, forwarding tools to AWP. Wake signals never consume the durable queue. */
12
12
  export async function startChannel(runnerId, deps = {}) {
13
13
  if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(runnerId))
@@ -24,6 +24,9 @@ export async function startChannel(runnerId, deps = {}) {
24
24
  let sequence = 0;
25
25
  let polling = false;
26
26
  let agentPolled = false;
27
+ // Vadym 10342: an unconfirmed channel is reported to AgentRadar (runnerState channel_unconfirmed), so the owner sees
28
+ // the agent is not receiving messages; "ready" again once the session does call next_event.
29
+ let unconfirmed = null;
27
30
  let warning;
28
31
  const send = (value) => new Promise((resolve, reject) => {
29
32
  output.write(`${JSON.stringify(value)}\n`, (error) => error ? reject(error) : resolve());
@@ -68,7 +71,14 @@ export async function startChannel(runnerId, deps = {}) {
68
71
  if (!selftestStarted)
69
72
  logError("AgentRadar self-test could not start; retrying after the next poll.");
70
73
  }
71
- const response = await next({ runnerState: "ready" });
74
+ if (unconfirmed === "pending") {
75
+ await next({ runnerState: "channel_unconfirmed" });
76
+ unconfirmed = "reported";
77
+ }
78
+ if (unconfirmed === "reported" && agentPolled)
79
+ unconfirmed = null;
80
+ // While reported, no runnerState: the stored state stands, and the poll still drains for wake signals.
81
+ const response = await next(unconfirmed ? {} : { runnerState: "ready" });
72
82
  if (response.error || response.result?.isError)
73
83
  throw new Error("AgentRadar refused next_event; check the runner key and its poll permission");
74
84
  const content = response.result?.content;
@@ -104,8 +114,10 @@ export async function startChannel(runnerId, deps = {}) {
104
114
  else if (message.method === "notifications/initialized") {
105
115
  if (!polling) {
106
116
  polling = true;
107
- warning = setTimeout(() => { if (!agentPolled)
108
- logError(CHANNEL_BLOCKED); }, 30_000);
117
+ warning = setTimeout(() => { if (!agentPolled) {
118
+ logError(CHANNEL_BLOCKED);
119
+ unconfirmed = "pending";
120
+ } }, deps.confirmMs ?? 30_000);
109
121
  void poll();
110
122
  }
111
123
  }
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
3
3
  import { installVendorSkill } from "./skill-install.js";
4
4
  import { startChannel } from "./channel.js";
5
5
  import { runRestartSelftest, startVendorRunner } from "./runner.js";
6
- import { joinOrganisation, leaveOrganisation, setupVendorAgent } from "./setup.js";
6
+ import { cancelJoin, joinOrganisation, leaveOrganisation, setupVendorAgent } from "./setup.js";
7
7
  import { callAwpTool, callToolOverCli, createTaskOverCli, listServiceToolsOverCli, parseCallArgs, runServiceToolOverCli } from "./tool-run.js";
8
8
  import { installSyncTimer, syncAgentDir } from "./sync.js";
9
9
  // `run` is the one prefix every agent-facing command shares, so one harness "always allow agentradar run"
@@ -15,7 +15,7 @@ export const runArgs = (raw) => raw[0] !== "run" ? raw
15
15
  async function main() {
16
16
  const args = runArgs(process.argv.slice(2));
17
17
  if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
18
- 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] | 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 <runner-id> --args '<json>' | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
18
+ 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 <runner-id> --args '<json>' | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
19
19
  return;
20
20
  }
21
21
  if (args[0] === "leave" && args.length === 2) {
@@ -55,21 +55,34 @@ async function main() {
55
55
  await setupVendorAgent({ trust: others.includes("--trust"), harness });
56
56
  return;
57
57
  }
58
+ if (args[0] === "join" && args[1] === "cancel") {
59
+ if (args.length !== 3 || args[2].startsWith("-")) {
60
+ console.error("Usage: agentradar join cancel <join-link|request-id>");
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+ process.exitCode = await cancelJoin(args[2]);
65
+ return;
66
+ }
58
67
  if (args[0] === "join") {
59
68
  const named = {};
60
69
  const urls = [];
61
70
  for (let i = 1; i < args.length; i++) {
62
- if (args[i] === "--name" || args[i] === "--model")
71
+ if (["--name", "--model", "--attach-tmux", "--session"].includes(args[i]))
63
72
  named[args[i].slice(2)] = args[++i] ?? "-";
64
- else if (args[i] !== "--trust")
73
+ else if (!["--trust", "--attach-current", "--allow-bypass", "--revoke-bypass", "--feeder"].includes(args[i]))
65
74
  urls.push(args[i]);
66
75
  }
67
- if (urls.length !== 1 || [urls[0], ...Object.values(named)].some((value) => value.startsWith("-"))) {
68
- console.error("Usage: agentradar join <join-link> [--name <your name>] [--model <your model id>] [--trust]");
76
+ if (urls.length !== 1 || [urls[0], ...Object.values(named)].some((value) => value.startsWith("-")) || (named["attach-tmux"] && args.includes("--attach-current"))) {
77
+ 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]");
69
78
  process.exitCode = 1;
70
79
  return;
71
80
  }
72
- process.exitCode = await joinOrganisation(urls[0], { trust: args.includes("--trust"), agentName: named.name, model: named.model });
81
+ process.exitCode = await joinOrganisation(urls[0], {
82
+ trust: args.includes("--trust"), agentName: named.name, model: named.model, session: named.session,
83
+ attach: args.includes("--attach-current") ? "current" : named["attach-tmux"],
84
+ revokeBypass: args.includes("--revoke-bypass"), feeder: args.includes("--feeder"),
85
+ });
73
86
  return;
74
87
  }
75
88
  if (args.length === 2 && args[0] === "runner" && args[1] === "start") {
@@ -129,7 +142,7 @@ async function main() {
129
142
  return;
130
143
  }
131
144
  const help = args.length === 1 && (args[0] === "--help" || args[0] === "-h");
132
- (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] | 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 <runner-id> --args '<json>' | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
145
+ (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 <runner-id> --args '<json>' | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
133
146
  if (!help)
134
147
  process.exitCode = 1;
135
148
  }
package/dist/setup.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { execFile, spawn } from "node:child_process";
3
3
  import { createInterface } from "node:readline";
4
- import { chmod, cp, lstat, mkdir, readFile, readlink, realpath, rename, rm, symlink, writeFile } from "node:fs/promises";
4
+ import { chmod, cp, lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
5
5
  import { basename, dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { homedir } from "node:os";
8
8
  import * as tls from "node:tls";
9
9
  import { promisify } from "node:util";
10
+ import { syncJoinedSkills } from "./skill-install.js";
10
11
  const DEFAULT_API_URL = "https://vendor.agentradar.com/api";
11
12
  /** The service's CLI build (the CLI itself and the agent-guard plugin): `installCli` keeps it current, never the npx cache. */
12
13
  const cliDist = (home) => join(home, ".agentradar", "cli", "current", "dist");
@@ -117,7 +118,9 @@ export function trustedMcpEndpoint(value) {
117
118
  return endpoint.protocol === "https:" || loopbackHttp ? endpoint : null;
118
119
  }
119
120
  /** `installation: null` (join) removes the project's `agentradar` entry: a joined agent acts through `agentradar run`. */
120
- async function installProjectMcp(configPath, cwd, installation, trust) {
121
+ async function installProjectMcp(configPath, cwd, installation, trust,
122
+ /** Vadym 10342: a joined Claude Code agent's AgentRadar channel (`agentradar channel <runner-id>`, stdio). */
123
+ channel) {
121
124
  const endpoint = installation && trustedMcpEndpoint(installation.mcpEndpoint);
122
125
  if (installation && (!endpoint || !BEARER_SHAPE.test(installation.bearer))) {
123
126
  throw new Error("AgentRadar returned an invalid setup payload");
@@ -147,7 +150,10 @@ async function installProjectMcp(configPath, cwd, installation, trust) {
147
150
  const mcpServers = project.mcpServers && typeof project.mcpServers === "object" && !Array.isArray(project.mcpServers)
148
151
  ? project.mcpServers
149
152
  : {};
150
- if (installation && endpoint) {
153
+ if (channel) {
154
+ mcpServers.agentradar = channel;
155
+ }
156
+ else if (installation && endpoint) {
151
157
  mcpServers.agentradar = {
152
158
  type: "http",
153
159
  url: endpoint.toString(),
@@ -335,7 +341,7 @@ async function codexHookHashes(codexHome, env) {
335
341
  child.kill();
336
342
  }
337
343
  }
338
- async function installRunner(home, projectPath, approved, service) {
344
+ async function installRunner(home, projectPath, approved, service, feed) {
339
345
  if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(approved.runnerId)) {
340
346
  throw new Error("AgentRadar returned an invalid runner ID");
341
347
  }
@@ -362,6 +368,8 @@ async function installRunner(home, projectPath, approved, service) {
362
368
  if (harness === "codex") {
363
369
  const { codexHome, keys } = await codexServiceHome(home, approved.runnerId, service.env);
364
370
  joined.push(`CODEX_HOME=${shellValue(codexHome)}`);
371
+ if (service.bypass)
372
+ joined.push(`CODEX_EXTRA_FLAGS=${shellValue("--dangerously-bypass-approvals-and-sandbox")}`);
365
373
  toolKeys = keys;
366
374
  }
367
375
  else if (harness === "generic") {
@@ -375,7 +383,10 @@ async function installRunner(home, projectPath, approved, service) {
375
383
  ...(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, "\\$&"))}`);
376
384
  }
377
385
  else {
378
- joined.push(`AR_RESUME_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`, `AR_FRESH_FLAGS=${shellValue(`--session-id ${service.sessionId}`)}`);
386
+ // The caller's own conversation, both times: a resume that fails tries it once more, never a fresh session.
387
+ joined.push(`AR_RESUME_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`, `AR_FRESH_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`);
388
+ // A person's model switch: the feeder and agent-guard's hooks meet in this directory, keyed by the session.
389
+ joined.push(`AR_CLAUDE_SESSION_ID=${shellValue(service.sessionId)}`, `AR_MODEL_SWITCH_DIR=${shellValue(join(configDir, "model-switch"))}`);
379
390
  const configDirEnv = service.env.CLAUDE_CONFIG_DIR?.trim();
380
391
  if (configDirEnv)
381
392
  joined.push(`CLAUDE_CONFIG_DIR=${shellValue(resolve(configDirEnv))}`);
@@ -385,9 +396,18 @@ async function installRunner(home, projectPath, approved, service) {
385
396
  await writePrivateFile(settings, `${JSON.stringify({
386
397
  extraKnownMarketplaces: { agentradar: { source: { source: "directory", path: join(cliDist(home), "agent-guard") } } },
387
398
  enabledPlugins: { "agent-guard@agentradar": true },
399
+ // Nobody sits at the pane to accept bypass mode's warning screen; the operator's consent was given at approval.
400
+ ...(service.bypass ? { skipDangerousModePermissionPrompt: true } : {}),
388
401
  }, null, 2)}\n`);
402
+ if (service.bypass)
403
+ joined.push(`CLAUDE_EXTRA_FLAGS=${shellValue("--permission-mode bypassPermissions")}`);
389
404
  joined.push(`CLAUDE_SETTINGS=${shellValue(settings)}`, `CLAUDE_CODE_PLUGIN_CACHE_DIR=${shellValue(join(configDir, "plugins"))}`);
390
405
  }
406
+ if (service.attach) {
407
+ // The caller's own tmux pane: the feeder types into it; the supervisor never starts, kills or streams it.
408
+ // TMUX names the caller's tmux server, which a service started by launchd or systemd would not find.
409
+ 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"}( |$)`)}`]));
410
+ }
391
411
  const toolEnv = Object.entries(service.env)
392
412
  .filter(([key, value]) => (TOOL_ENV.test(key) || toolKeys.includes(key)) && key !== "ANTHROPIC_API_KEY" && value && !value.includes("\n"))
393
413
  .map(([key, value]) => `${key}=${value}`);
@@ -397,15 +417,19 @@ async function installRunner(home, projectPath, approved, service) {
397
417
  joined.push(`AGENT_TOOL_ENV_FILE=${shellValue(toolEnvFile)}`);
398
418
  }
399
419
  }
400
- await writePrivateFile(mcpConfig, `${JSON.stringify(service ? { mcpServers: {} } : {
401
- mcpServers: {
402
- agentradar: {
403
- type: "http",
404
- url: endpoint.toString(),
405
- headers: { Authorization: `Bearer ${approved.installation.bearer}` },
420
+ // R764 + Vadym 10342: a pushed service session loads exactly one MCP, the AgentRadar channel, from the CLI copy
421
+ // the service runs; the supervisor adds --dangerously-load-development-channels server:agentradar.
422
+ await writePrivateFile(mcpConfig, `${JSON.stringify(feed === "push"
423
+ ? { mcpServers: { agentradar: { type: "stdio", command: process.execPath, args: [join(cliDist(home), "index.js"), "channel", approved.runnerId] } } }
424
+ : service ? { mcpServers: {} } : {
425
+ mcpServers: {
426
+ agentradar: {
427
+ type: "http",
428
+ url: endpoint.toString(),
429
+ headers: { Authorization: `Bearer ${approved.installation.bearer}` },
430
+ },
406
431
  },
407
- },
408
- }, null, 2)}\n`);
432
+ }, null, 2)}\n`);
409
433
  await writePrivateFile(envPath, [
410
434
  `AGENT_NAME=${shellValue(approved.runnerId)}`,
411
435
  `AR_BASE=${shellValue(projectPath)}`,
@@ -416,6 +440,8 @@ async function installRunner(home, projectPath, approved, service) {
416
440
  // default, so writing `AR_AGENT_HARNESS=claude` unconditionally would change nothing except to make
417
441
  // every existing env file look as though a choice had been made.
418
442
  ...(harness ? [`AR_AGENT_HARNESS=${shellValue(harness)}`] : []),
443
+ // How work reaches the session (Vadym 10342): the Claude Code channel pushes it; without this line the feeder types it.
444
+ ...(feed ? [`AR_FEED=${feed}`] : []),
419
445
  ...joined,
420
446
  "",
421
447
  ].join("\n"));
@@ -505,6 +531,8 @@ export async function setupVendorAgent(deps = {}) {
505
531
  }
506
532
  throw new Error("AgentRadar setup code expired; run setup again");
507
533
  }
534
+ 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`;
535
+ export const BYPASS_REVOKED = "Bypass-permissions mode is off on this machine now; the agent asks before acting again.";
508
536
  /** A sortable key for `x.y.z[-pre]`; a release sorts above its prereleases. ponytail: prereleases of one version tie. */
509
537
  const versionRank = (version) => {
510
538
  const [core = "", pre] = version.split(/-(.*)/);
@@ -554,6 +582,25 @@ async function installAgentService(runnerId, home, env) {
554
582
  env: { ...env, HOME: home, NODE_BIN: process.execPath, AGENTRADAR_JS: join(dist, "index.js") },
555
583
  });
556
584
  }
585
+ /**
586
+ * The Claude conversation that ran `agentradar join`: `--session`, else the CLAUDE_CODE_SESSION_ID Claude Code
587
+ * sets in its shell, else the newest transcript Claude Code keeps for this folder. Never a fresh one.
588
+ */
589
+ async function callerSession(deps, env, home, projectPath) {
590
+ let id = deps.session ?? env.CLAUDE_CODE_SESSION_ID;
591
+ if (!id) {
592
+ const config = env.CLAUDE_CONFIG_DIR?.trim() ? resolve(env.CLAUDE_CONFIG_DIR.trim()) : join(home, ".claude");
593
+ const dir = join(config, "projects", projectPath.replace(/[^A-Za-z0-9]/g, "-"));
594
+ const transcripts = await Promise.all((await readdir(dir).catch(() => [])).filter((name) => name.endsWith(".jsonl"))
595
+ .map(async (name) => ({ id: name.slice(0, -6), at: (await stat(join(dir, name))).mtimeMs })));
596
+ id = transcripts.sort((a, b) => b.at - a.at)[0]?.id;
597
+ }
598
+ if (!id)
599
+ throw new Error("Found no Claude Code conversation in this folder to continue. Run join from inside your agent's session, or pass --session <id>.");
600
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id))
601
+ throw new Error(`Not a Claude Code session id: ${JSON.stringify(id)}`);
602
+ return id;
603
+ }
557
604
  /**
558
605
  * Mode A — the organisation's own agent, on its own compute, joining through a join link.
559
606
  *
@@ -591,19 +638,60 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
591
638
  }
592
639
  }
593
640
  const label = harness === "generic" ? basename((env.AGENTRADAR_AGENT_BIN ?? "").trim()) : harness === "claude" ? "Claude Code" : "Codex";
641
+ // Vadym 10342: every Claude Code agent connects through the AgentRadar channel. The tmux feeder is the fallback:
642
+ // another CLI, an explicit opt-in (--feeder, AGENTRADAR_FEED=feeder, or a pane named with --attach-*), or a
643
+ // channel the session never confirms (channel.ts reports it and tells the operator to re-run with --feeder).
644
+ const channel = harness === "claude" && !deps.feeder && env.AGENTRADAR_FEED?.trim().toLowerCase() !== "feeder" && !deps.attach;
594
645
  // The agent is a persistent session the service owns, not this one: no MCP entry in this folder (it acts
595
646
  // through `agentradar run`), a runner env pinned to its own session, and a LaunchAgent / systemd unit.
596
- const startJoined = async (requestId, agentId, installation, saved) => {
647
+ // Which session the agent is, settled before any request: a key is issued once, so never claim one for a session that cannot run.
648
+ const settle = async () => {
649
+ const tmux = deps.tmux ?? (async (args) => (await promisify(execFile)(env.TMUX_BIN || "tmux", args, { env })).stdout);
650
+ // Attach to the caller when it already lives in tmux: asked for by flag, or a Claude Code session in a tmux
651
+ // pane. The service's own session (join-<request>) re-running join is not a caller to attach to.
652
+ let attach = deps.attach === "current" ? env.TMUX_PANE : deps.attach;
653
+ if (deps.attach === "current" && !attach)
654
+ throw new Error("--attach-current needs a session running inside tmux");
655
+ // Never in channel mode: nothing types into the caller's pane.
656
+ if (!channel && !deps.attach && harness === "claude" && env.CLAUDECODE && env.TMUX && env.TMUX_PANE)
657
+ attach = env.TMUX_PANE;
658
+ if (attach) {
659
+ // A pane id (%3) dies with the tmux server; session:window.pane comes back when its session is recreated.
660
+ attach = (await tmux(["display-message", "-p", "-t", attach, "#{session_name}:#{window_index}.#{pane_index}"])).trim();
661
+ if (!attach)
662
+ throw new Error("tmux found no such pane");
663
+ if (!deps.attach && attach.startsWith("join-"))
664
+ attach = undefined;
665
+ }
666
+ // Unattended, the service resumes the caller's own Claude conversation, never a stranger's. An attached
667
+ // pane is never started from here, so there its session id only keys the model switch.
668
+ const sessionId = harness !== "claude" ? undefined
669
+ : await callerSession(deps, env, home, await realpath(cwd)).catch((error) => { if (attach || channel)
670
+ return undefined; throw error; });
671
+ return { attach, sessionId };
672
+ };
673
+ let where;
674
+ /**
675
+ * The owner's bypass grant on the portal approval is the consent for an unattended service session (lead
676
+ * 10-04, after the Codex run stopped on an approval prompt nobody could answer); `--revoke-bypass` takes it
677
+ * back on this machine. An attached pane keeps the permission mode it already runs in.
678
+ */
679
+ const localBypass = (approvedBypass, revoked) => approvedBypass && !revoked && !where.attach;
680
+ const startJoined = async (requestId, agentId, installation, approvedBypass, bypass, saved) => {
597
681
  const runnerId = `join-${requestId}`;
598
- // The service's Claude session id: a valid UUID derived from the request, so a re-run resumes the same one.
599
- const hex = createHash("sha256").update(requestId).digest("hex");
600
- const sessionId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
682
+ const { attach } = where;
601
683
  const projectPath = harness === "claude"
602
684
  ? await installProjectMcp(claudeConfigPath(home, env), cwd, null, deps.trust === true)
603
685
  : await realpath(cwd);
604
- const runnerEnv = await installRunner(home, projectPath, { status: "approved", agentId, runnerId, installation, harness }, { sessionId, env });
686
+ const runnerEnv = await installRunner(home, projectPath, { status: "approved", agentId, runnerId, installation, harness }, { sessionId: where.sessionId ?? requestId, env, attach, bypass }, channel ? "push" : undefined);
605
687
  await saved?.();
606
688
  log(`Runner installed → ${runnerEnv}`);
689
+ if (bypass)
690
+ log(bypassOnNotice(enrolmentUrl));
691
+ // Best effort: a failed first sync never stops the join; the service's feeder syncs them again.
692
+ await syncJoinedSkills(runnerId, { home, fetch: fetchImpl, log }).catch((error) => {
693
+ log(`Your AgentRadar skills are not installed yet (${String(error?.message ?? error).split("\n")[0]}); the runner installs them once it is running.`);
694
+ });
607
695
  try {
608
696
  await (deps.installService ?? ((id) => installAgentService(id, home, env)))(runnerId);
609
697
  }
@@ -611,7 +699,24 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
611
699
  log(`Could not start the service: ${error.message.split("\n")[0]}. Start it by hand with: agentradar runner start ${runnerId}`);
612
700
  return 1;
613
701
  }
614
- log(`Connected. Your agent now runs in its own ${label} session in ${projectPath} (tmux session ${runnerId}): 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}`);
702
+ if (attach) {
703
+ 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}`);
704
+ if (approvedBypass)
705
+ log("Your organisation allowed bypass-permissions mode; this session keeps the permission mode it already runs in.");
706
+ log("Running as a service; the delivery restarts on crash and reboot.");
707
+ return 0;
708
+ }
709
+ const mode = bypass ? ", in bypass-permissions mode as your organisation allowed" : "";
710
+ if (channel) {
711
+ 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}`);
712
+ log(`If your organisation's Claude plan blocks channels, deliver with the feeder instead: agentradar join ${enrolmentUrl} --feeder`);
713
+ log("Running as a service; it restarts on crash and reboot.");
714
+ return 0;
715
+ }
716
+ // Only Claude resumes the caller's own conversation; Codex and a generic CLI start their own session.
717
+ log(harness === "claude"
718
+ ? `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}`
719
+ : `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}`);
615
720
  log("Running as a service; it restarts on crash and reboot.");
616
721
  return 0;
617
722
  };
@@ -627,6 +732,7 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
627
732
  throw new Error("That is not an AgentRadar join link (expected https://…/invite/<token>)");
628
733
  }
629
734
  const api = (path) => new URL(`/api/${path}`, link.origin);
735
+ where = await settle();
630
736
  const mcpEndpoint = env.AWP_MCP_URL || api("mcp").toString();
631
737
  const joinsDir = join(home, ".agentradar", "joins");
632
738
  await mkdir(joinsDir, { recursive: true, mode: 0o700 });
@@ -646,7 +752,15 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
646
752
  const bearer = envValue(saved, "AWP_BEARER");
647
753
  if (!bearer)
648
754
  throw new Error("This machine left that organisation (agentradar leave). A key is issued once; ask your organisation for a new join link.");
649
- return startJoined(state.requestId, state.agentId, { mcpEndpoint: envValue(saved, "AWP_MCP_URL") ?? mcpEndpoint, bearer });
755
+ // `--revoke-bypass` is remembered on this machine, so a later re-run keeps the prompts on.
756
+ if (deps.revokeBypass && state.bypassConfirmed !== false) {
757
+ state = { ...state, bypassConfirmed: false };
758
+ await writePrivateFile(statePath, JSON.stringify(state));
759
+ log(BYPASS_REVOKED);
760
+ }
761
+ const approvedBypass = state.bypassPermissions === true;
762
+ const bypass = localBypass(approvedBypass, state.bypassConfirmed === false);
763
+ return startJoined(state.requestId, state.agentId, { mcpEndpoint: envValue(saved, "AWP_MCP_URL") ?? mcpEndpoint, bearer }, approvedBypass, bypass);
650
764
  }
651
765
  if (!state) {
652
766
  const agentName = (deps.agentName ?? process.env.AGENTRADAR_AGENT_NAME ?? basename(cwd)).trim();
@@ -698,8 +812,11 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
698
812
  throw new Error("AgentRadar returned an invalid key claim");
699
813
  }
700
814
  log("Approved.");
701
- const joined = { ...state, agentId: claimed.agentId };
702
- return startJoined(state.requestId, claimed.agentId, { mcpEndpoint, bearer: claimed.token }, () => writePrivateFile(statePath, JSON.stringify(joined)));
815
+ // Bypass-permissions only when the operator consented at approval; the server says so with the key.
816
+ const approvedBypass = claimed.bypassPermissions === true;
817
+ const bypass = localBypass(approvedBypass, false);
818
+ const joined = { ...state, agentId: claimed.agentId, ...(approvedBypass ? { bypassPermissions: true } : {}) };
819
+ return startJoined(state.requestId, claimed.agentId, { mcpEndpoint, bearer: claimed.token }, approvedBypass, bypass, () => writePrivateFile(statePath, JSON.stringify(joined)));
703
820
  }
704
821
  const said = await failureMessage(response);
705
822
  if (response.status === 409 && said.includes("must be approved")) {
@@ -709,12 +826,61 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
709
826
  if (response.status === 409 && said.includes("rejected")) {
710
827
  throw new Error("Your organisation declined this join request. Ask it for a new join link.");
711
828
  }
829
+ if (response.status === 409 && said.includes("withdrawn")) {
830
+ throw new Error("This join request was withdrawn (agentradar join cancel). Ask your organisation for a new join link.");
831
+ }
712
832
  if (response.status === 409 && /already (?:used|claimed)/.test(said)) {
713
833
  throw new Error("This join link's key was already claimed. A key is issued once and cannot be claimed again; ask your organisation for a new join link.");
714
834
  }
715
835
  throw new Error(said);
716
836
  }
717
837
  }
838
+ /**
839
+ * `agentradar join cancel <join-link|request-id>`: withdraw this machine's pending join request. Only the
840
+ * claim secret saved here proves it is the requester; the state file goes once the server agrees.
841
+ */
842
+ export async function cancelJoin(target, deps = {}) {
843
+ const fetchImpl = deps.fetch ?? fetch;
844
+ const log = deps.log ?? console.log;
845
+ const joinsDir = join(deps.home ?? homedir(), ".agentradar", "joins");
846
+ const token = /^https?:\/\//.test(target)
847
+ ? /^\/(?:api\/invites|invite)\/([^/]+?)(?:\.md)?(?:\/|$)/.exec(new URL(target).pathname)?.[1]
848
+ : undefined;
849
+ let statePath;
850
+ let state;
851
+ for (const name of token ? [`${createHash("sha256").update(token).digest("hex")}.json`] : await readdir(joinsDir).catch(() => [])) {
852
+ if (!name.endsWith(".json"))
853
+ continue;
854
+ const read = await readFile(join(joinsDir, name), "utf8").then((text) => JSON.parse(text), () => undefined);
855
+ if (read && (token || read.requestId === target)) {
856
+ statePath = join(joinsDir, name);
857
+ state = read;
858
+ break;
859
+ }
860
+ }
861
+ if (!state || !statePath) {
862
+ log(`No join request from this machine for ${target}.`);
863
+ return 1;
864
+ }
865
+ if (state.agentId) {
866
+ log(`That join was already approved. To leave the organisation: agentradar leave join-${state.requestId}`);
867
+ return 1;
868
+ }
869
+ // The secret goes only back to the AgentRadar host that issued it.
870
+ const origin = token ? new URL(target).origin : state.approvalUrl ? new URL(state.approvalUrl).origin : "";
871
+ if (!origin || !trustedMcpEndpoint(origin)) {
872
+ log("Pass the join link instead of the request id: agentradar join cancel <join-link>");
873
+ return 1;
874
+ }
875
+ const response = await postJson(fetchImpl, new URL(`/api/join-requests/${encodeURIComponent(state.requestId)}/cancel`, origin), { claimSecret: state.claimSecret });
876
+ if (!response.ok) {
877
+ log(`Could not withdraw the join request: ${await failureMessage(response)}`);
878
+ return 1;
879
+ }
880
+ await rm(statePath, { force: true });
881
+ log("Join request withdrawn. Nobody can approve it now, and the name you asked for is free again.");
882
+ return 0;
883
+ }
718
884
  /**
719
885
  * `agentradar leave <runner-id>`: stop and remove the service, end the agent's session, and delete its env
720
886
  * file, bearer and config. The folder the agent worked in stays, with the work.
@@ -742,7 +908,9 @@ export async function leaveOrganisation(runnerId, deps = {}) {
742
908
  await execute("systemctl", ["--user", "disable", "--now", `agentradar-agent@${runnerId}`]).catch(() => undefined);
743
909
  }
744
910
  // The service stopping leaves the session running; `=` matches the tmux session name exactly.
745
- await execute(envValue(saved, "TMUX_BIN") ?? "tmux", ["kill-session", "-t", `=${envValue(saved, "TMUX_SESSION") ?? runnerId}`]).catch(() => undefined);
911
+ // An attached pane is the caller's own session: it stays.
912
+ if (envValue(saved, "AR_ATTACH") !== "1")
913
+ await execute(envValue(saved, "TMUX_BIN") ?? "tmux", ["kill-session", "-t", `=${envValue(saved, "TMUX_SESSION") ?? runnerId}`]).catch(() => undefined);
746
914
  for (const path of [envPath, join(home, ".agentradar", "agents", `${runnerId}.delivered.json`)])
747
915
  await rm(path, { force: true });
748
916
  for (const dir of ["run", "config", "codex"])
@@ -1,6 +1,9 @@
1
- import { chmod, lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
1
+ import { chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
- import { join } from "node:path";
3
+ import { dirname, isAbsolute, join, normalize, sep } from "node:path";
4
+ import { awpRequest, envValue } from "./tool-run.js";
5
+ const MANAGED_MARK = ".agentradar-managed";
6
+ const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,99}$/;
4
7
  async function ensureDirectory(path) {
5
8
  try {
6
9
  const info = await lstat(path);
@@ -38,7 +41,98 @@ export async function installVendorSkill(deps = {}) {
38
41
  });
39
42
  }
40
43
  // The runner keeps this skill current from AgentRadar; the marker says the folder is AgentRadar's to replace.
41
- await writeFile(join(skillDir, ".agentradar-managed"), "Written by AgentRadar; replaced on every sync.\n");
44
+ await writeFile(join(skillDir, MANAGED_MARK), "Written by AgentRadar; replaced on every sync.\n");
42
45
  (deps.log ?? console.log)("AgentRadar vendor skill installed for this project.");
43
46
  }
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
+ };
68
+ 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];
117
+ }
118
+ /**
119
+ * R650 — a joined agent's AgentRadar skills (`my_skills`: the bundled set plus the ones linked to it), written
120
+ * at join into the directory its CLI reads, the feeder's `skillsDirFor`: Claude `$AR_BASE/.claude/skills`,
121
+ * Codex `$CODEX_HOME/skills`. Done here because a session that acts only through `agentradar run` has no
122
+ * feeder; where the service runs, its feeder keeps them current.
123
+ */
124
+ export async function syncJoinedSkills(runnerId, deps) {
125
+ const env = await readFile(join(deps.home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
126
+ const root = envValue(env, "AR_AGENT_HARNESS") === "codex" ? envValue(env, "CODEX_HOME") : envValue(env, "AR_BASE");
127
+ if (!root)
128
+ return;
129
+ const skillsDir = envValue(env, "AR_AGENT_HARNESS") === "codex" ? join(root, "skills") : join(root, ".claude", "skills");
130
+ const body = await awpRequest(runnerId, "tools/call", { name: "my_skills", arguments: {} }, { home: deps.home, fetch: deps.fetch, logError: deps.log });
131
+ const text = body?.result?.content?.find((c) => c.type === "text")?.text;
132
+ const answer = (text ? JSON.parse(text) : {});
133
+ if (!answer.ok || !Array.isArray(answer.skills))
134
+ throw new Error("AgentRadar did not return your skills");
135
+ const written = await writeManagedSkills(skillsDir, answer.skills);
136
+ deps.log(`Installed ${written.length} AgentRadar skill(s) in ${skillsDir}.`);
137
+ }
44
138
  //# sourceMappingURL=skill-install.js.map
package/dist/tool-run.js CHANGED
@@ -244,7 +244,7 @@ export async function callAwpTool(runnerId, name, toolArguments, deps) {
244
244
  }
245
245
  const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
246
246
  /** One JSON-RPC request on the runner's AWP MCP endpoint with the runner's own key; null after logging why it failed. */
247
- async function awpRequest(runnerId, method, params, deps) {
247
+ export async function awpRequest(runnerId, method, params, deps) {
248
248
  const logError = deps.logError ?? console.error;
249
249
  if (!RUNNER_ID.test(runnerId)) {
250
250
  logError("Invalid runner ID");
@@ -24,6 +24,7 @@ import { createHash, randomUUID } from "node:crypto";
24
24
  import fs from "node:fs";
25
25
  import http from "node:http";
26
26
  import https from "node:https";
27
+ import { homedir } from "node:os";
27
28
  import { dirname, isAbsolute, join, normalize, sep } from "node:path";
28
29
  import { fileURLToPath, pathToFileURL } from "node:url";
29
30
  import { formatInjectionFrame, formatProbeFrame, formatWelcomeFrame, sendKeysCommands } from "./session-frame.mjs";
@@ -230,6 +231,7 @@ async function nextEvent(ackEventIds, runnerState) {
230
231
  sessionId: runnerSessionId,
231
232
  ...(ackEventIds && ackEventIds.length ? { ackEventIds } : {}),
232
233
  ...(runnerState ? { runnerState } : {}),
234
+ ...(_switchId ? { modelSwitchId: _switchId } : {}),
233
235
  };
234
236
  const result = await mcpPost("tools/call", { name: "next_event", arguments: args }, { timeoutMs: NEXT_EVENT_TIMEOUT_MS });
235
237
  return toolJson(result); // { events: [...] }
@@ -663,6 +665,14 @@ const isManaged = (dir) => {
663
665
  const st = fs.lstatSync(dir, { throwIfNoEntry: false });
664
666
  return Boolean(st?.isDirectory() && fs.existsSync(join(dir, MANAGED_MARK)));
665
667
  };
668
+ const sameSkill = (dir, files) => {
669
+ try {
670
+ const have = fs.readdirSync(dir, { recursive: true }).filter((p) => p !== MANAGED_MARK && fs.statSync(join(dir, p)).isFile());
671
+ return have.length === files.length && files.every((f) => fs.readFileSync(join(dir, f.path), "utf8") === f.content);
672
+ } catch {
673
+ return false;
674
+ }
675
+ };
666
676
 
667
677
  export function writeManagedSkills(skillsDir, skills) {
668
678
  fs.mkdirSync(skillsDir, { recursive: true });
@@ -679,7 +689,11 @@ export function writeManagedSkills(skillsDir, skills) {
679
689
  const bad = files.find((f) => typeof f?.path !== "string" || typeof f?.content !== "string" || isAbsolute(f.path)
680
690
  || normalize(f.path).split(sep).includes("..") || [".", MANAGED_MARK].includes(normalize(f.path)));
681
691
  if (bad) { console.warn(`[feeder] skill sync: ${name} has an unsafe file path, skipped`); continue; }
692
+ // An unchanged skill is not rewritten: the agent's CLI may be reading it (Codex skipped a SKILL.md that a
693
+ // periodic resync had just removed).
694
+ if (sameSkill(target, files)) { written.add(name); continue; }
682
695
  const staging = join(skillsDir, `.${name}.sync-${process.pid}`);
696
+ const old = join(skillsDir, `.${name}.old-${process.pid}`);
683
697
  try {
684
698
  fs.rmSync(staging, { recursive: true, force: true });
685
699
  for (const f of files) {
@@ -687,12 +701,16 @@ export function writeManagedSkills(skillsDir, skills) {
687
701
  fs.writeFileSync(join(staging, f.path), f.content);
688
702
  }
689
703
  fs.writeFileSync(join(staging, MANAGED_MARK), "Written by the AgentRadar runner; replaced on every sync.\n");
690
- fs.rmSync(target, { recursive: true, force: true });
704
+ // Rename aside, then into place: the skill is missing for two renames, not for a recursive delete.
705
+ fs.rmSync(old, { recursive: true, force: true });
706
+ if (fs.existsSync(target)) fs.renameSync(target, old);
691
707
  fs.renameSync(staging, target);
708
+ fs.rmSync(old, { recursive: true, force: true });
692
709
  written.add(name);
693
710
  } catch (err) {
694
711
  // One skill that cannot be written keeps its previous copy and does not stop the rest of the sync.
695
712
  fs.rmSync(staging, { recursive: true, force: true });
713
+ if (!fs.existsSync(target) && fs.existsSync(old)) fs.renameSync(old, target);
696
714
  if (isManaged(target)) written.add(name);
697
715
  console.warn(`[feeder] skill sync: ${name} not written: ${err instanceof Error ? err.message : String(err)}`);
698
716
  }
@@ -719,6 +737,176 @@ async function syncSkills() {
719
737
  }
720
738
  }
721
739
 
740
+ // --------------------------------------------------------------------------
741
+ // A person's model switch (switch_model). The runner compacts, then types /model; agent-guard's hooks write
742
+ // the receipts and ask the server to re-authorize the model the runtime resolved. This side types and reports.
743
+ // --------------------------------------------------------------------------
744
+ const MODEL_SWITCH_STEP_MS = 2000;
745
+ // How long clients wait on a /model whose receipt never came before the pane is theirs again.
746
+ const MODEL_SWITCH_CONFIRM_MS = 2 * 60_000;
747
+ let _switchId = null;
748
+ let _switching = false;
749
+ let _nextSwitch = null;
750
+
751
+ const readJson = (path) => { try { return JSON.parse(fs.readFileSync(path, "utf8")); } catch { return null; } };
752
+ function writeJson(path, value) {
753
+ const temporary = `${path}.${process.pid}.tmp`;
754
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: fs.statSync(path, { throwIfNoEntry: false })?.mode ?? 0o600 });
755
+ fs.renameSync(temporary, path);
756
+ }
757
+
758
+ /**
759
+ * The model the session last ran on: the newest transcript line that names one; null when unknown.
760
+ * ponytail: a restart onto a model changed outside the session (settings edited by hand) still reads the last
761
+ * turn's model; ask the runtime directly if that ever happens.
762
+ */
763
+ export function sessionModel(home, sessionId) {
764
+ return transcript(home, sessionId)?.model ?? null;
765
+ }
766
+
767
+ /** Claude Code declines /compact on a near-empty session (PreCompact fires, PostCompact never does); its own line says so. */
768
+ export function compactDeclined(home, sessionId, id) {
769
+ return transcript(home, sessionId)?.declined.has(`AgentRadar model switch ${id}`) ?? false;
770
+ }
771
+
772
+ // Read every step while a switch waits, so incrementally: each read parses only the whole lines appended since
773
+ // the last. A first read starts this far from the end; an older model line reads as unknown, so the switch runs.
774
+ const TRANSCRIPT_TAIL = 1 << 20;
775
+ const _transcripts = new Map();
776
+ function transcript(home, sessionId) {
777
+ let projects;
778
+ try { projects = fs.readdirSync(join(home, "projects")); } catch { return null; }
779
+ for (const project of projects) {
780
+ const path = join(home, "projects", project, `${sessionId}.jsonl`);
781
+ let fd;
782
+ try { fd = fs.openSync(path, "r"); } catch { continue; }
783
+ try {
784
+ const size = fs.fstatSync(fd).size;
785
+ let seen = _transcripts.get(path);
786
+ if (!seen || size < seen.offset) {
787
+ _transcripts.set(path, seen = { offset: Math.max(0, size - TRANSCRIPT_TAIL), model: null, declined: new Set() });
788
+ }
789
+ const buffer = Buffer.alloc(size - seen.offset);
790
+ fs.readSync(fd, buffer, 0, buffer.length, seen.offset);
791
+ const whole = buffer.lastIndexOf(10) + 1;
792
+ seen.offset += whole;
793
+ // A line cut by the first read fails to parse and is skipped.
794
+ for (const line of buffer.subarray(0, whole).toString("utf8").split("\n")) {
795
+ let entry;
796
+ try { entry = JSON.parse(line); } catch { continue; }
797
+ // A /model (ours or a person's) names the new model only by marketing name, and none follows until the
798
+ // next turn: unknown, so the switch runs rather than being wrongly taken as already applied.
799
+ if (entry?.type === "user" && String(entry.message?.content).includes("<command-name>/model</command-name>")) seen.model = null;
800
+ const model = entry?.type === "assistant" ? entry.message?.model
801
+ : entry?.attachment?.type === "model" ? entry.attachment.identity?.modelId : null;
802
+ // "<synthetic>" marks a message Claude Code made locally, not a model.
803
+ if (typeof model === "string" && /^[a-zA-Z0-9]/.test(model)) seen.model = model;
804
+ if (entry?.subtype === "local_command" && entry.commandRun?.command === "compact"
805
+ && String(entry.content).includes("Not enough messages to compact.")) seen.declined.add(entry.commandRun.args);
806
+ }
807
+ return seen;
808
+ } finally { fs.closeSync(fd); }
809
+ }
810
+ return null;
811
+ }
812
+
813
+ /**
814
+ * /model also saves its pick as the default for every new session on this login. That default is the
815
+ * vendor's, so it goes back; the runner's own --settings file keeps the switch across this runner's restarts.
816
+ * ponytail: a person's own /model later is not written there, so a restart returns to the last requested switch.
817
+ */
818
+ function keepSwitchedModel(state, result, home, ownSettings) {
819
+ const before = state.read("user-default");
820
+ const path = join(home, "settings.json");
821
+ const user = before && readJson(path);
822
+ if (user && [state.command.model, result.model].includes(user.model)) {
823
+ if (before.model === undefined) delete user.model;
824
+ else user.model = before.model;
825
+ writeJson(path, user);
826
+ }
827
+ const own = ownSettings && readJson(ownSettings);
828
+ if (own && result.outcome === "applied") writeJson(ownSettings, { ...own, model: result.model });
829
+ }
830
+
831
+ async function reportModelSwitch(args) {
832
+ const answer = toolJson(await mcpPostIsolated("tools/call", { name: "model_switch_result", arguments: args }));
833
+ if (!answer.ok) throw new Error(answer.error ?? "model_switch_result refused");
834
+ }
835
+
836
+ const paneIdle = () => { const p = capturePane(); return Boolean(p && !p.inProgress && p.inputReady); };
837
+
838
+ /** Carry out one model switch from next_event, then report how it ended. Single-flight; never throws. */
839
+ export async function driveModelSwitch(command, {
840
+ dir = process.env.AR_MODEL_SWITCH_DIR,
841
+ sessionId = process.env.AR_CLAUDE_SESSION_ID,
842
+ home = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"),
843
+ ownSettings = process.env.CLAUDE_SETTINGS,
844
+ ready = paneIdle,
845
+ type = (text) => injectFrame(text, "model switch"),
846
+ current = () => sessionModel(home, sessionId),
847
+ declined = (id) => compactDeclined(home, sessionId, id),
848
+ report = reportModelSwitch,
849
+ step = MODEL_SWITCH_STEP_MS,
850
+ confirmWait = MODEL_SWITCH_CONFIRM_MS,
851
+ now = Date.now,
852
+ } = {}) {
853
+ if (_switching || !command?.id || _switchId === command.id) return;
854
+ _switchId = command.id;
855
+ _switching = true;
856
+ let result = { outcome: "failed" };
857
+ try {
858
+ // Only Claude Code has the compact and model-switch hooks this relies on.
859
+ if (AR_AGENT_HARNESS !== "claude" || !dir || !sessionId) throw new Error("this runner cannot switch models");
860
+ const { switchState, advanceModelSwitch } = await import("../agent-guard/scripts/model-switch.mjs");
861
+ const state = switchState(dir, { ...command, sessionId });
862
+ const send = (text) => {
863
+ if (text.startsWith("/model ")) {
864
+ state.once("user-default", { model: readJson(join(home, "settings.json"))?.model });
865
+ state.once("model-sent", { at: now() });
866
+ }
867
+ return type(text);
868
+ };
869
+ const giveUpAt = Date.parse(command.expiresAt) + confirmWait + 60_000;
870
+ // ponytail: a fixed 2 s step; watch the receipt files if switch latency ever matters.
871
+ for (;;) {
872
+ const outcome = await advanceModelSwitch(state, { ready, send, current });
873
+ if (state.read("released")) {
874
+ result = { outcome, ...(state.read("applied") ?? state.read("model-started") ?? state.read("superseded") ?? {}) };
875
+ break;
876
+ }
877
+ // Nothing to compact is as good as compacted: the next step types /model.
878
+ if (state.read("compact-started") && !state.read("compacted") && declined(command.id)) state.once("compacted", { declined: true });
879
+ const sent = state.read("model-sent");
880
+ if ((sent && now() - sent.at >= confirmWait) || now() > giveUpAt) {
881
+ // No receipt in time: hand the pane back to clients. Marked failed, the hooks refuse our /model if it
882
+ // lands late; the session slot stays held until it ages out, so a new switch waits for that too.
883
+ // Giving up claims the receipt the hook's admit claims, exclusively: exactly one wins. A hook that
884
+ // loses refuses our /model; one that won made the switch, which may or may not have landed.
885
+ // A re-drive after a lost report finds its own earlier claim.
886
+ const gaveUp = state.once("model-started", { gaveUp: true }) || state.read("model-started")?.gaveUp === true;
887
+ if (gaveUp) state.once("failed", { phase: "gave-up" });
888
+ result = gaveUp ? { outcome: "failed" } : { outcome: "unknown", ...state.read("model-started") };
889
+ break;
890
+ }
891
+ await sleep(step);
892
+ }
893
+ // A person's own pick stays their default; any other ending puts back what our /model changed.
894
+ if (result.outcome !== "superseded") keepSwitchedModel(state, result, home, ownSettings);
895
+ } catch (err) {
896
+ console.warn(`[feeder] model switch ${command.id}: ${err instanceof Error ? err.message : String(err)}`);
897
+ } finally {
898
+ _switching = false;
899
+ }
900
+ try {
901
+ await report({ id: command.id, outcome: result.outcome, fromModel: result.fromModel ?? null, model: result.model ?? null });
902
+ console.log(`[feeder] model switch ${command.id}: ${result.outcome}`);
903
+ } catch (err) {
904
+ // The server keeps offering an unreported switch; driving it again only re-reads its ending and reports.
905
+ console.warn(`[feeder] model switch ${command.id}: report failed: ${err instanceof Error ? err.message : String(err)}`);
906
+ setTimeout(() => { if (_switchId === command.id) _switchId = null; }, ERROR_DELAY_MS).unref?.();
907
+ }
908
+ }
909
+
722
910
  // --------------------------------------------------------------------------
723
911
  // The loop: initialize → poll next_event → inject each into the live pane → ack the
724
912
  // delivered ones on the NEXT poll → self-heal on transient MCP errors (runner shape).
@@ -730,7 +918,7 @@ let _lastRunnerState = null;
730
918
 
731
919
  /** One receive→inject→ack-select cycle (extracted from loop() + exported so the #173b _pendingAck
732
920
  * fence is directly testable). Sets `_pendingAck` = the eventIds to ack on the NEXT poll. */
733
- export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, deliverEvent = injectEvent) {
921
+ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, deliverEvent = injectEvent, drive = driveModelSwitch) {
734
922
  // next_event refreshes the portal's runner heartbeat. Never call it for a detached/orphan feeder: a
735
923
  // process without its owned tmux session cannot receive work, so polling would paint a false Live state
736
924
  // while every event stays unacknowledged. The supervisor recreates the session; until then the durable
@@ -740,7 +928,12 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
740
928
  await sleep(IDLE_DELAY_MS);
741
929
  return;
742
930
  }
743
- const state = readiness();
931
+ // A switch from the last poll starts after that poll's batch was typed, so it never types over one.
932
+ if (_nextSwitch) void drive(_nextSwitch);
933
+ _nextSwitch = null;
934
+ let state = readiness();
935
+ // The switch owns the pane until it ends: nothing is drained meanwhile, so nothing is typed over it.
936
+ if (_switching && state === "ready") state = "starting";
744
937
  if (state !== _lastRunnerState) {
745
938
  console.log(`[feeder] runner state ${_lastRunnerState ?? "unknown"} → ${state}`);
746
939
  _lastRunnerState = state;
@@ -748,7 +941,8 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
748
941
  // The server treats non-ready calls as status-only heartbeats: no drain and no ack. Keep any pending ack
749
942
  // until readiness returns, and defensively ignore events from an older server that does not know the gate.
750
943
  const canWork = state === "ready";
751
- const { events = [], livenessProbe, welcome } = await nextEvent(canWork && _pendingAck.length ? _pendingAck : undefined, state);
944
+ const { events = [], livenessProbe, welcome, modelSwitch } = await nextEvent(canWork && _pendingAck.length ? _pendingAck : undefined, state);
945
+ _nextSwitch = modelSwitch ?? null;
752
946
  if (!canWork) {
753
947
  await sleep(IDLE_DELAY_MS);
754
948
  return;
@@ -77,7 +77,7 @@ HEALTH_POLL_SEC="${HEALTH_POLL_SEC:-15}"
77
77
  # interactive CLI: the env file names its binary, arguments, resume/fresh flags, pid pattern and how work
78
78
  # reaches it (AR_FEED). Anything else is REFUSED by name: a supervisor that quietly fell back to Claude for an
79
79
  # unrecognized value would come up healthy, keep a pane alive, and answer nothing the vendor expected.
80
- # Tried: claude and codex with AR_FEED=feeder. Untried: generic, AR_FEED=push (Claude channels).
80
+ # Tried: claude and codex with AR_FEED=feeder; claude with AR_FEED=push (the AgentRadar channel, R764). Untried: generic.
81
81
  AR_AGENT_HARNESS="${AR_AGENT_HARNESS:-claude}"
82
82
  case "${AR_AGENT_HARNESS}" in
83
83
  claude|codex|generic) : ;;
@@ -93,6 +93,8 @@ case "${AR_FEED}" in
93
93
  acp) die "AR_FEED=acp is not built yet; use feeder or push" ;;
94
94
  *) die "AR_FEED must be feeder or push (got '${AR_FEED}')" ;;
95
95
  esac
96
+ # push is Claude Code channels only: no other harness has a channel to push into.
97
+ [ "${AR_FEED}" = "push" ] && [ "${AR_AGENT_HARNESS}" != "claude" ] && die "AR_FEED=push needs AR_AGENT_HARNESS=claude (got '${AR_AGENT_HARNESS}')"
96
98
  # Launched only when the resume form exits non-zero (nothing to resume). Empty = the same argv without it.
97
99
  AR_FRESH_FLAGS="${AR_FRESH_FLAGS:-}"
98
100
 
@@ -140,6 +142,8 @@ else
140
142
  _launch_flags=( ${CLAUDE_EXTRA_FLAGS} --strict-mcp-config --mcp-config="${MCP_CONFIG}" )
141
143
  # `agentradar join` enables the agent-guard plugin for this session only, through a settings file.
142
144
  [ -n "${CLAUDE_SETTINGS:-}" ] && _launch_flags+=( --settings "${CLAUDE_SETTINGS}" )
145
+ # R764 + Vadym 10342: the AgentRadar channel (the stdio MCP `agentradar` in $MCP_CONFIG) pushes work in; no feeder types.
146
+ [ "${AR_FEED}" = "push" ] && _launch_flags+=( --dangerously-load-development-channels server:agentradar )
143
147
  # Pane-alive predicate — MUST be unique per agent. Default to the FULL mcp-config path (each agent's
144
148
  # differs by directory), NOT the basename: on a multi-agent box every agent's config is named
145
149
  # `awp-only.json`, so a basename match would find ANOTHER agent's live claude and mask THIS agent's
@@ -189,7 +193,7 @@ unset CLAUDE_CODE_OAUTH_TOKEN
189
193
  _claude_cfg=()
190
194
  [ -n "${CLAUDE_CONFIG_DIR:-}" ] && _claude_cfg=( CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR}" )
191
195
  # agent-guard (templates/agent-guard): its hooks ask the server what this runner still owes through the CLI.
192
- for _k in AR_CLI_RUNNER_ID AR_AGENTRADAR_BIN AR_DISCIPLINE_EVERY CLAUDE_CODE_PLUGIN_CACHE_DIR; do
196
+ for _k in AR_CLI_RUNNER_ID AR_AGENTRADAR_BIN AR_DISCIPLINE_EVERY AR_MODEL_SWITCH_DIR CLAUDE_CODE_PLUGIN_CACHE_DIR; do
193
197
  [ -n "${!_k:-}" ] && _claude_cfg+=( "${_k}=${!_k}" )
194
198
  done
195
199
 
@@ -284,6 +288,12 @@ ensure_agent() {
284
288
  log "agent session '${TMUX_SESSION}' already live"
285
289
  return 0
286
290
  fi
291
+ # AR_ATTACH=1 (`agentradar join` from a session already in tmux): the pane is the caller's own. Never start,
292
+ # kill or replace it; whatever supervises it brings it back, and the feeder waits until then.
293
+ if [ "${AR_ATTACH:-}" = "1" ]; then
294
+ log "attached pane '${TMUX_SESSION}' has no live ${AR_AGENT_HARNESS}; waiting for its own supervisor"
295
+ return 1
296
+ fi
287
297
  if session_exists && ! pane_alive; then
288
298
  log "session '${TMUX_SESSION}' exists but claude pane is dead — recreating"
289
299
  "${TMUX_BIN}" kill-session -t "${TMUX_SESSION}" 2>/dev/null || true
@@ -356,11 +366,27 @@ ensure_agent() {
356
366
  return 1
357
367
  }
358
368
  # wait for the pane to come up
359
- for _ in $(seq 1 30); do pane_alive && { log "agent pane is live"; return 0; }; sleep 1; done
369
+ # In the background: the watch must not hold up the monitor loop when the warning never shows.
370
+ for _ in $(seq 1 30); do pane_alive && { log "agent pane is live"; accept_dev_channels & return 0; }; sleep 1; done
360
371
  log "agent pane did not come up within 30s"
361
372
  return 1
362
373
  }
363
374
 
375
+ # A pushed session starts on Claude Code's "Loading development channels" warning, and nobody sits at an unattended
376
+ # pane to confirm it. The channel is this machine's own installed CLI (`agentradar channel <runner>`), so the
377
+ # service confirms it, once per launch, only while that exact warning is on screen.
378
+ accept_dev_channels() {
379
+ [ "${AR_FEED}" = "push" ] || return 0
380
+ for _ in $(seq 1 30); do
381
+ if "${TMUX_BIN}" capture-pane -p -t "=${TMUX_SESSION}:" 2>/dev/null | grep -q "Loading development channels"; then
382
+ "${TMUX_BIN}" send-keys -t "=${TMUX_SESSION}:" Enter
383
+ log "confirmed Claude Code's development-channels warning for the AgentRadar channel"
384
+ return 0
385
+ fi
386
+ sleep 1
387
+ done
388
+ }
389
+
364
390
  # --- 4. Run: ensure agent, run feeder in foreground, monitor both -------------
365
391
  FEEDER_PID=""
366
392
  cleanup() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentradar-cli",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "engines": {
5
5
  "node": ">=20"
6
6
  },
@@ -28,7 +28,7 @@
28
28
  "access": "public"
29
29
  },
30
30
  "scripts": {
31
- "build": "tsc && mkdir -p dist/agentradar-vendor dist/vendor-runner/supervisor && cp ../../skills/agentradar-vendor/SKILL.md dist/agentradar-vendor/SKILL.md && cp ../../templates/vendor-runner/feeder.mjs ../../templates/vendor-runner/pane-live.sh ../../templates/vendor-runner/session-frame.mjs ../../templates/vendor-runner/terminal-bridge.mjs dist/vendor-runner/ && cp ../../templates/vendor-runner/supervisor/supervise-agent.sh ../../templates/vendor-runner/supervisor/install-agent.sh ../../templates/vendor-runner/supervisor/com.agentradar.agent.plist.template ../../templates/vendor-runner/supervisor/agentradar-agent@.service.template dist/vendor-runner/supervisor/ && rm -rf dist/agent-guard && cp -R ../../templates/agent-guard dist/agent-guard && rm dist/agent-guard/scripts/guard.test.mjs && chmod +x dist/index.js dist/vendor-runner/pane-live.sh dist/vendor-runner/supervisor/supervise-agent.sh dist/vendor-runner/supervisor/install-agent.sh",
31
+ "build": "tsc && mkdir -p dist/agentradar-vendor dist/vendor-runner/supervisor && cp ../../skills/agentradar-vendor/SKILL.md dist/agentradar-vendor/SKILL.md && cp ../../templates/vendor-runner/feeder.mjs ../../templates/vendor-runner/pane-live.sh ../../templates/vendor-runner/session-frame.mjs ../../templates/vendor-runner/terminal-bridge.mjs dist/vendor-runner/ && cp ../../templates/vendor-runner/supervisor/supervise-agent.sh ../../templates/vendor-runner/supervisor/install-agent.sh ../../templates/vendor-runner/supervisor/com.agentradar.agent.plist.template ../../templates/vendor-runner/supervisor/agentradar-agent@.service.template dist/vendor-runner/supervisor/ && rm -rf dist/agent-guard && cp -R ../../templates/agent-guard dist/agent-guard && rm dist/agent-guard/scripts/*.test.mjs && chmod +x dist/index.js dist/vendor-runner/pane-live.sh dist/vendor-runner/supervisor/supervise-agent.sh dist/vendor-runner/supervisor/install-agent.sh",
32
32
  "clean": "rm -rf dist",
33
33
  "prepack": "pnpm run build",
34
34
  "typecheck": "tsc --noEmit",