agentradar-cli 0.2.7 → 0.2.9

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.
@@ -26,7 +26,7 @@ Your primary objective is to reach your quotas and drive the organisation forwar
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/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, terminalConfirm } 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>] | 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"].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>] [--allow-bypass | --revoke-bypass]");
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
+ allowBypass: args.includes("--allow-bypass"), revokeBypass: args.includes("--revoke-bypass"), confirm: terminalConfirm(),
85
+ });
73
86
  return;
74
87
  }
75
88
  if (args.length === 2 && args[0] === "runner" && args[1] === "start") {
@@ -89,7 +102,7 @@ async function main() {
89
102
  const call = args[0] === "call";
90
103
  const parsed = parseCallArgs(args.slice(call ? 1 : 2));
91
104
  if (!parsed || (!call && parsed.flags.length)) {
92
- console.error(call ? "Usage: agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>']" : "Usage: agentradar tool run <runner-id> <tool-key> [--args '<json>']");
105
+ 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>']");
93
106
  process.exitCode = 1;
94
107
  return;
95
108
  }
@@ -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>] | 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");
@@ -362,6 +363,8 @@ async function installRunner(home, projectPath, approved, service) {
362
363
  if (harness === "codex") {
363
364
  const { codexHome, keys } = await codexServiceHome(home, approved.runnerId, service.env);
364
365
  joined.push(`CODEX_HOME=${shellValue(codexHome)}`);
366
+ if (service.bypass)
367
+ joined.push(`CODEX_EXTRA_FLAGS=${shellValue("--dangerously-bypass-approvals-and-sandbox")}`);
365
368
  toolKeys = keys;
366
369
  }
367
370
  else if (harness === "generic") {
@@ -375,7 +378,10 @@ async function installRunner(home, projectPath, approved, service) {
375
378
  ...(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
379
  }
377
380
  else {
378
- joined.push(`AR_RESUME_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`, `AR_FRESH_FLAGS=${shellValue(`--session-id ${service.sessionId}`)}`);
381
+ // The caller's own conversation, both times: a resume that fails tries it once more, never a fresh session.
382
+ joined.push(`AR_RESUME_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`, `AR_FRESH_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`);
383
+ // A person's model switch: the feeder and agent-guard's hooks meet in this directory, keyed by the session.
384
+ joined.push(`AR_CLAUDE_SESSION_ID=${shellValue(service.sessionId)}`, `AR_MODEL_SWITCH_DIR=${shellValue(join(configDir, "model-switch"))}`);
379
385
  const configDirEnv = service.env.CLAUDE_CONFIG_DIR?.trim();
380
386
  if (configDirEnv)
381
387
  joined.push(`CLAUDE_CONFIG_DIR=${shellValue(resolve(configDirEnv))}`);
@@ -385,9 +391,18 @@ async function installRunner(home, projectPath, approved, service) {
385
391
  await writePrivateFile(settings, `${JSON.stringify({
386
392
  extraKnownMarketplaces: { agentradar: { source: { source: "directory", path: join(cliDist(home), "agent-guard") } } },
387
393
  enabledPlugins: { "agent-guard@agentradar": true },
394
+ // Nobody sits at the pane to accept bypass mode's warning screen; the operator's consent was given at approval.
395
+ ...(service.bypass ? { skipDangerousModePermissionPrompt: true } : {}),
388
396
  }, null, 2)}\n`);
397
+ if (service.bypass)
398
+ joined.push(`CLAUDE_EXTRA_FLAGS=${shellValue("--permission-mode bypassPermissions")}`);
389
399
  joined.push(`CLAUDE_SETTINGS=${shellValue(settings)}`, `CLAUDE_CODE_PLUGIN_CACHE_DIR=${shellValue(join(configDir, "plugins"))}`);
390
400
  }
401
+ if (service.attach) {
402
+ // The caller's own tmux pane: the feeder types into it; the supervisor never starts, kills or streams it.
403
+ // TMUX names the caller's tmux server, which a service started by launchd or systemd would not find.
404
+ 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"}( |$)`)}`]));
405
+ }
391
406
  const toolEnv = Object.entries(service.env)
392
407
  .filter(([key, value]) => (TOOL_ENV.test(key) || toolKeys.includes(key)) && key !== "ANTHROPIC_API_KEY" && value && !value.includes("\n"))
393
408
  .map(([key, value]) => `${key}=${value}`);
@@ -505,6 +520,27 @@ export async function setupVendorAgent(deps = {}) {
505
520
  }
506
521
  throw new Error("AgentRadar setup code expired; run setup again");
507
522
  }
523
+ /** "[y/N]": only y or yes (any case) is a yes; Enter alone is No. */
524
+ export const saidYes = (answer) => /^y(es)?$/i.test(answer.trim());
525
+ /** The default `confirm`: a real terminal only, "[y/N]" with No unless they type y or yes. */
526
+ export function terminalConfirm() {
527
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
528
+ return undefined;
529
+ return async (question) => {
530
+ const { createInterface } = await import("node:readline/promises");
531
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
532
+ try {
533
+ return saidYes(await rl.question(`${question} [y/N] `));
534
+ }
535
+ finally {
536
+ rl.close();
537
+ }
538
+ };
539
+ }
540
+ 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`;
541
+ export const BYPASS_REVOKED = "Bypass-permissions mode is off on this machine now; the agent asks before acting again.";
542
+ export const BYPASS_QUESTION = "Allow this agent to run without permission prompts on this machine?";
543
+ export const BYPASS_NOT_CONFIRMED = "Bypass-permissions mode stays off: your organisation allowed it, but nobody on this machine confirmed it. Run the join again in a terminal and answer yes, or add --allow-bypass.";
508
544
  /** A sortable key for `x.y.z[-pre]`; a release sorts above its prereleases. ponytail: prereleases of one version tie. */
509
545
  const versionRank = (version) => {
510
546
  const [core = "", pre] = version.split(/-(.*)/);
@@ -554,6 +590,25 @@ async function installAgentService(runnerId, home, env) {
554
590
  env: { ...env, HOME: home, NODE_BIN: process.execPath, AGENTRADAR_JS: join(dist, "index.js") },
555
591
  });
556
592
  }
593
+ /**
594
+ * The Claude conversation that ran `agentradar join`: `--session`, else the CLAUDE_CODE_SESSION_ID Claude Code
595
+ * sets in its shell, else the newest transcript Claude Code keeps for this folder. Never a fresh one.
596
+ */
597
+ async function callerSession(deps, env, home, projectPath) {
598
+ let id = deps.session ?? env.CLAUDE_CODE_SESSION_ID;
599
+ if (!id) {
600
+ const config = env.CLAUDE_CONFIG_DIR?.trim() ? resolve(env.CLAUDE_CONFIG_DIR.trim()) : join(home, ".claude");
601
+ const dir = join(config, "projects", projectPath.replace(/[^A-Za-z0-9]/g, "-"));
602
+ const transcripts = await Promise.all((await readdir(dir).catch(() => [])).filter((name) => name.endsWith(".jsonl"))
603
+ .map(async (name) => ({ id: name.slice(0, -6), at: (await stat(join(dir, name))).mtimeMs })));
604
+ id = transcripts.sort((a, b) => b.at - a.at)[0]?.id;
605
+ }
606
+ if (!id)
607
+ 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>.");
608
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id))
609
+ throw new Error(`Not a Claude Code session id: ${JSON.stringify(id)}`);
610
+ return id;
611
+ }
557
612
  /**
558
613
  * Mode A — the organisation's own agent, on its own compute, joining through a join link.
559
614
  *
@@ -593,17 +648,60 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
593
648
  const label = harness === "generic" ? basename((env.AGENTRADAR_AGENT_BIN ?? "").trim()) : harness === "claude" ? "Claude Code" : "Codex";
594
649
  // The agent is a persistent session the service owns, not this one: no MCP entry in this folder (it acts
595
650
  // through `agentradar run`), a runner env pinned to its own session, and a LaunchAgent / systemd unit.
596
- const startJoined = async (requestId, agentId, installation, saved) => {
651
+ // Which session the agent is, settled before any request: a key is issued once, so never claim one for a session that cannot run.
652
+ const settle = async () => {
653
+ const tmux = deps.tmux ?? (async (args) => (await promisify(execFile)(env.TMUX_BIN || "tmux", args, { env })).stdout);
654
+ // Attach to the caller when it already lives in tmux: asked for by flag, or a Claude Code session in a tmux
655
+ // pane. The service's own session (join-<request>) re-running join is not a caller to attach to.
656
+ let attach = deps.attach === "current" ? env.TMUX_PANE : deps.attach;
657
+ if (deps.attach === "current" && !attach)
658
+ throw new Error("--attach-current needs a session running inside tmux");
659
+ if (!deps.attach && harness === "claude" && env.CLAUDECODE && env.TMUX && env.TMUX_PANE)
660
+ attach = env.TMUX_PANE;
661
+ if (attach) {
662
+ // A pane id (%3) dies with the tmux server; session:window.pane comes back when its session is recreated.
663
+ attach = (await tmux(["display-message", "-p", "-t", attach, "#{session_name}:#{window_index}.#{pane_index}"])).trim();
664
+ if (!attach)
665
+ throw new Error("tmux found no such pane");
666
+ if (!deps.attach && attach.startsWith("join-"))
667
+ attach = undefined;
668
+ }
669
+ // Unattended, the service resumes the caller's own Claude conversation, never a stranger's. An attached
670
+ // pane is never started from here, so there its session id only keys the model switch.
671
+ const sessionId = harness !== "claude" ? undefined
672
+ : await callerSession(deps, env, home, await realpath(cwd)).catch((error) => { if (attach)
673
+ return undefined; throw error; });
674
+ return { attach, sessionId };
675
+ };
676
+ let where;
677
+ /**
678
+ * Lead 10-04: the approver's consent alone never turns bypass on here. This machine says yes too: a flag, or
679
+ * a person at the terminal; with neither (an agent running the join, a script) it stays off and says why.
680
+ * Asked only when it would apply: approved, and not an attached pane (that keeps its own mode).
681
+ */
682
+ const localBypass = async (approvedBypass, confirmedBefore) => {
683
+ if (!approvedBypass || where.attach)
684
+ return false;
685
+ const yes = confirmedBefore || deps.allowBypass === true || (deps.confirm ? await deps.confirm(BYPASS_QUESTION) : false);
686
+ if (!yes)
687
+ log(BYPASS_NOT_CONFIRMED);
688
+ return yes;
689
+ };
690
+ const startJoined = async (requestId, agentId, installation, approvedBypass, bypass, saved) => {
597
691
  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)}`;
692
+ const { attach } = where;
601
693
  const projectPath = harness === "claude"
602
694
  ? await installProjectMcp(claudeConfigPath(home, env), cwd, null, deps.trust === true)
603
695
  : await realpath(cwd);
604
- const runnerEnv = await installRunner(home, projectPath, { status: "approved", agentId, runnerId, installation, harness }, { sessionId, env });
696
+ const runnerEnv = await installRunner(home, projectPath, { status: "approved", agentId, runnerId, installation, harness }, { sessionId: where.sessionId ?? requestId, env, attach, bypass });
605
697
  await saved?.();
606
698
  log(`Runner installed → ${runnerEnv}`);
699
+ if (bypass)
700
+ log(bypassOnNotice(enrolmentUrl));
701
+ // Best effort: a failed first sync never stops the join; the service's feeder syncs them again.
702
+ await syncJoinedSkills(runnerId, { home, fetch: fetchImpl, log }).catch((error) => {
703
+ log(`Your AgentRadar skills are not installed yet (${String(error?.message ?? error).split("\n")[0]}); the runner installs them once it is running.`);
704
+ });
607
705
  try {
608
706
  await (deps.installService ?? ((id) => installAgentService(id, home, env)))(runnerId);
609
707
  }
@@ -611,7 +709,18 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
611
709
  log(`Could not start the service: ${error.message.split("\n")[0]}. Start it by hand with: agentradar runner start ${runnerId}`);
612
710
  return 1;
613
711
  }
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}`);
712
+ if (attach) {
713
+ 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}`);
714
+ if (approvedBypass)
715
+ log("Your organisation allowed bypass-permissions mode; this session keeps the permission mode it already runs in.");
716
+ log("Running as a service; the delivery restarts on crash and reboot.");
717
+ return 0;
718
+ }
719
+ const mode = bypass ? ", in bypass-permissions mode as your organisation allowed and this machine confirmed" : "";
720
+ // Only Claude resumes the caller's own conversation; Codex and a generic CLI start their own session.
721
+ log(harness === "claude"
722
+ ? `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}`
723
+ : `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
724
  log("Running as a service; it restarts on crash and reboot.");
616
725
  return 0;
617
726
  };
@@ -627,6 +736,7 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
627
736
  throw new Error("That is not an AgentRadar join link (expected https://…/invite/<token>)");
628
737
  }
629
738
  const api = (path) => new URL(`/api/${path}`, link.origin);
739
+ where = await settle();
630
740
  const mcpEndpoint = env.AWP_MCP_URL || api("mcp").toString();
631
741
  const joinsDir = join(home, ".agentradar", "joins");
632
742
  await mkdir(joinsDir, { recursive: true, mode: 0o700 });
@@ -646,7 +756,15 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
646
756
  const bearer = envValue(saved, "AWP_BEARER");
647
757
  if (!bearer)
648
758
  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 });
759
+ // Lead 10-04: the remembered yes is revocable. `--revoke-bypass` forgets it and the service runs with prompts.
760
+ if (deps.revokeBypass && state.bypassConfirmed) {
761
+ state = { ...state, bypassConfirmed: false };
762
+ await writePrivateFile(statePath, JSON.stringify(state));
763
+ log(BYPASS_REVOKED);
764
+ }
765
+ const approvedBypass = state.bypassPermissions === true;
766
+ const bypass = deps.revokeBypass ? false : await localBypass(approvedBypass, state.bypassConfirmed === true);
767
+ return startJoined(state.requestId, state.agentId, { mcpEndpoint: envValue(saved, "AWP_MCP_URL") ?? mcpEndpoint, bearer }, approvedBypass, bypass);
650
768
  }
651
769
  if (!state) {
652
770
  const agentName = (deps.agentName ?? process.env.AGENTRADAR_AGENT_NAME ?? basename(cwd)).trim();
@@ -698,8 +816,11 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
698
816
  throw new Error("AgentRadar returned an invalid key claim");
699
817
  }
700
818
  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)));
819
+ // Bypass-permissions only when the operator consented at approval; the server says so with the key.
820
+ const approvedBypass = claimed.bypassPermissions === true;
821
+ const bypass = await localBypass(approvedBypass, false);
822
+ const joined = { ...state, agentId: claimed.agentId, ...(approvedBypass ? { bypassPermissions: true } : {}), ...(bypass ? { bypassConfirmed: true } : {}) };
823
+ return startJoined(state.requestId, claimed.agentId, { mcpEndpoint, bearer: claimed.token }, approvedBypass, bypass, () => writePrivateFile(statePath, JSON.stringify(joined)));
703
824
  }
704
825
  const said = await failureMessage(response);
705
826
  if (response.status === 409 && said.includes("must be approved")) {
@@ -709,12 +830,61 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
709
830
  if (response.status === 409 && said.includes("rejected")) {
710
831
  throw new Error("Your organisation declined this join request. Ask it for a new join link.");
711
832
  }
833
+ if (response.status === 409 && said.includes("withdrawn")) {
834
+ throw new Error("This join request was withdrawn (agentradar join cancel). Ask your organisation for a new join link.");
835
+ }
712
836
  if (response.status === 409 && /already (?:used|claimed)/.test(said)) {
713
837
  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
838
  }
715
839
  throw new Error(said);
716
840
  }
717
841
  }
842
+ /**
843
+ * `agentradar join cancel <join-link|request-id>`: withdraw this machine's pending join request. Only the
844
+ * claim secret saved here proves it is the requester; the state file goes once the server agrees.
845
+ */
846
+ export async function cancelJoin(target, deps = {}) {
847
+ const fetchImpl = deps.fetch ?? fetch;
848
+ const log = deps.log ?? console.log;
849
+ const joinsDir = join(deps.home ?? homedir(), ".agentradar", "joins");
850
+ const token = /^https?:\/\//.test(target)
851
+ ? /^\/(?:api\/invites|invite)\/([^/]+?)(?:\.md)?(?:\/|$)/.exec(new URL(target).pathname)?.[1]
852
+ : undefined;
853
+ let statePath;
854
+ let state;
855
+ for (const name of token ? [`${createHash("sha256").update(token).digest("hex")}.json`] : await readdir(joinsDir).catch(() => [])) {
856
+ if (!name.endsWith(".json"))
857
+ continue;
858
+ const read = await readFile(join(joinsDir, name), "utf8").then((text) => JSON.parse(text), () => undefined);
859
+ if (read && (token || read.requestId === target)) {
860
+ statePath = join(joinsDir, name);
861
+ state = read;
862
+ break;
863
+ }
864
+ }
865
+ if (!state || !statePath) {
866
+ log(`No join request from this machine for ${target}.`);
867
+ return 1;
868
+ }
869
+ if (state.agentId) {
870
+ log(`That join was already approved. To leave the organisation: agentradar leave join-${state.requestId}`);
871
+ return 1;
872
+ }
873
+ // The secret goes only back to the AgentRadar host that issued it.
874
+ const origin = token ? new URL(target).origin : state.approvalUrl ? new URL(state.approvalUrl).origin : "";
875
+ if (!origin || !trustedMcpEndpoint(origin)) {
876
+ log("Pass the join link instead of the request id: agentradar join cancel <join-link>");
877
+ return 1;
878
+ }
879
+ const response = await postJson(fetchImpl, new URL(`/api/join-requests/${encodeURIComponent(state.requestId)}/cancel`, origin), { claimSecret: state.claimSecret });
880
+ if (!response.ok) {
881
+ log(`Could not withdraw the join request: ${await failureMessage(response)}`);
882
+ return 1;
883
+ }
884
+ await rm(statePath, { force: true });
885
+ log("Join request withdrawn. Nobody can approve it now, and the name you asked for is free again.");
886
+ return 0;
887
+ }
718
888
  /**
719
889
  * `agentradar leave <runner-id>`: stop and remove the service, end the agent's session, and delete its env
720
890
  * file, bearer and config. The folder the agent worked in stays, with the work.
@@ -742,7 +912,9 @@ export async function leaveOrganisation(runnerId, deps = {}) {
742
912
  await execute("systemctl", ["--user", "disable", "--now", `agentradar-agent@${runnerId}`]).catch(() => undefined);
743
913
  }
744
914
  // 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);
915
+ // An attached pane is the caller's own session: it stays.
916
+ if (envValue(saved, "AR_ATTACH") !== "1")
917
+ await execute(envValue(saved, "TMUX_BIN") ?? "tmux", ["kill-session", "-t", `=${envValue(saved, "TMUX_SESSION") ?? runnerId}`]).catch(() => undefined);
746
918
  for (const path of [envPath, join(home, ".agentradar", "agents", `${runnerId}.delivered.json`)])
747
919
  await rm(path, { force: true });
748
920
  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
@@ -21,6 +21,8 @@ export async function runServiceToolOverCli(runnerId, toolKey, argumentsJson, de
21
21
  }
22
22
  /** `agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>']`: any AWP tool from a shell, so a just-joined agent works before its harness reloads MCP tools. */
23
23
  export async function callToolOverCli(runnerId, tool, argumentsJson, deps = {}, flags = []) {
24
+ if (flags.some(([flag]) => flag === "help"))
25
+ return await describeTools(runnerId, tool, deps);
24
26
  const parameters = argumentsJson === undefined ? {} : parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
25
27
  if (!parameters)
26
28
  return 1;
@@ -65,6 +67,10 @@ export function parseCallArgs(rest) {
65
67
  positional.push(arg);
66
68
  continue;
67
69
  }
70
+ if (arg === "--help") {
71
+ flags.push(["help", ""]);
72
+ continue;
73
+ }
68
74
  const value = rest[++i];
69
75
  if (value === undefined)
70
76
  return null;
@@ -73,7 +79,37 @@ export function parseCallArgs(rest) {
73
79
  else
74
80
  flags.push([arg.slice(2), value]);
75
81
  }
76
- return positional.length === 2 ? { runnerId: positional[0], tool: positional[1], json, flags } : null;
82
+ const help = flags.some(([flag]) => flag === "help");
83
+ return positional.length === 2 || (help && positional.length === 1)
84
+ ? { runnerId: positional[0], tool: positional[1] ?? "", json, flags } : null;
85
+ }
86
+ async function listedTools(runnerId, deps) {
87
+ const listed = await awpRequest(runnerId, "tools/list", {}, deps);
88
+ return listed ? listed.result?.tools ?? [] : null;
89
+ }
90
+ // An agent that guessed a tool name from memory needs the real list, or it goes looking with commands
91
+ // its harness has not allowed and the session stalls on a permission prompt.
92
+ const unknownTool = (runnerId, tool, tools) => `${tool} is not a tool on this server. Its tools: ${tools.map((entry) => entry.name).join(" ")}. `
93
+ + `\`agentradar run ${runnerId} <tool> --help\` shows a tool's arguments.`;
94
+ /** `agentradar run <runner-id> [<tool>] --help`: the server's own tool list or one tool's arguments. */
95
+ async function describeTools(runnerId, tool, deps) {
96
+ const tools = await listedTools(runnerId, deps);
97
+ if (!tools)
98
+ return 1;
99
+ const log = deps.log ?? console.log;
100
+ if (!tool) {
101
+ log(`Tools: ${tools.map((entry) => entry.name).join(" ")}. \`agentradar run ${runnerId} <tool> --help\` shows a tool's arguments.`);
102
+ return 0;
103
+ }
104
+ const entry = tools.find((candidate) => candidate.name === tool);
105
+ if (!entry) {
106
+ (deps.logError ?? console.error)(unknownTool(runnerId, tool, tools));
107
+ return 1;
108
+ }
109
+ const required = entry.inputSchema?.required ?? [];
110
+ const names = Object.keys(entry.inputSchema?.properties ?? {}).map((name) => `--${name}${required.includes(name) ? " (required)" : ""}`);
111
+ log(`${tool}: ${entry.description ?? ""}\nArguments: ${names.join(" ") || "none"}`);
112
+ return 0;
77
113
  }
78
114
  /**
79
115
  * `--text "hi" --conversation-id X` as the tool's own arguments, typed by the inputSchema the server
@@ -81,10 +117,15 @@ export function parseCallArgs(rest) {
81
117
  */
82
118
  async function flagArguments(runnerId, tool, flags, deps) {
83
119
  const logError = deps.logError ?? console.error;
84
- const listed = await awpRequest(runnerId, "tools/list", {}, deps);
85
- if (!listed)
120
+ const tools = await listedTools(runnerId, deps);
121
+ if (!tools)
122
+ return null;
123
+ const entry = tools.find((candidate) => candidate.name === tool);
124
+ if (!entry) {
125
+ logError(unknownTool(runnerId, tool, tools));
86
126
  return null;
87
- const schema = listed.result?.tools?.find((entry) => entry.name === tool)?.inputSchema?.properties ?? {};
127
+ }
128
+ const schema = entry.inputSchema?.properties ?? {};
88
129
  const out = {};
89
130
  const camel = (flag) => flag.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
90
131
  for (let [flag, value] of flags) {
@@ -203,7 +244,7 @@ export async function callAwpTool(runnerId, name, toolArguments, deps) {
203
244
  }
204
245
  const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
205
246
  /** One JSON-RPC request on the runner's AWP MCP endpoint with the runner's own key; null after logging why it failed. */
206
- async function awpRequest(runnerId, method, params, deps) {
247
+ export async function awpRequest(runnerId, method, params, deps) {
207
248
  const logError = deps.logError ?? console.error;
208
249
  if (!RUNNER_ID.test(runnerId)) {
209
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;
@@ -189,7 +189,7 @@ unset CLAUDE_CODE_OAUTH_TOKEN
189
189
  _claude_cfg=()
190
190
  [ -n "${CLAUDE_CONFIG_DIR:-}" ] && _claude_cfg=( CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR}" )
191
191
  # 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
192
+ for _k in AR_CLI_RUNNER_ID AR_AGENTRADAR_BIN AR_DISCIPLINE_EVERY AR_MODEL_SWITCH_DIR CLAUDE_CODE_PLUGIN_CACHE_DIR; do
193
193
  [ -n "${!_k:-}" ] && _claude_cfg+=( "${_k}=${!_k}" )
194
194
  done
195
195
 
@@ -284,6 +284,12 @@ ensure_agent() {
284
284
  log "agent session '${TMUX_SESSION}' already live"
285
285
  return 0
286
286
  fi
287
+ # AR_ATTACH=1 (`agentradar join` from a session already in tmux): the pane is the caller's own. Never start,
288
+ # kill or replace it; whatever supervises it brings it back, and the feeder waits until then.
289
+ if [ "${AR_ATTACH:-}" = "1" ]; then
290
+ log "attached pane '${TMUX_SESSION}' has no live ${AR_AGENT_HARNESS}; waiting for its own supervisor"
291
+ return 1
292
+ fi
287
293
  if session_exists && ! pane_alive; then
288
294
  log "session '${TMUX_SESSION}' exists but claude pane is dead — recreating"
289
295
  "${TMUX_BIN}" kill-session -t "${TMUX_SESSION}" 2>/dev/null || true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentradar-cli",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
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",