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.
- package/dist/agent-guard/hooks/hooks.json +6 -2
- package/dist/agent-guard/scripts/model-switch.mjs +219 -0
- package/dist/agent-guard/skills/agentradar-discipline/SKILL.md +1 -1
- package/dist/agentradar-vendor/SKILL.md +2 -2
- package/dist/channel.js +16 -4
- package/dist/index.js +21 -8
- package/dist/setup.js +191 -23
- package/dist/skill-install.js +97 -3
- package/dist/tool-run.js +1 -1
- package/dist/vendor-runner/feeder.mjs +198 -4
- package/dist/vendor-runner/supervisor/supervise-agent.sh +29 -3
- package/package.json +2 -2
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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);
|
|
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 (
|
|
71
|
+
if (["--name", "--model", "--attach-tmux", "--session"].includes(args[i]))
|
|
63
72
|
named[args[i].slice(2)] = args[++i] ?? "-";
|
|
64
|
-
else if (
|
|
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], {
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
702
|
-
|
|
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
|
-
|
|
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"])
|
package/dist/skill-install.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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",
|