agentradar-cli 0.1.8 → 0.2.0
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/agentradar-vendor/SKILL.md +6 -0
- package/dist/channel.js +150 -0
- package/dist/index.js +133 -10
- package/dist/runner.js +431 -8
- package/dist/setup.js +245 -13
- package/dist/skill-install.js +2 -0
- package/dist/sync.js +89 -0
- package/dist/tool-run.js +300 -0
- package/dist/vendor-runner/feeder.mjs +192 -17
- package/dist/vendor-runner/supervisor/supervise-agent.sh +85 -25
- package/package.json +4 -1
package/dist/tool-run.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* One `KEY='value'` line as `setup.ts` wrote it, single-quoted with `'` escaped as `'\''`.
|
|
6
|
+
* Nothing here is printed: the file holds the runner's bearer.
|
|
7
|
+
*/
|
|
8
|
+
export function envValue(contents, key) {
|
|
9
|
+
const line = contents.split("\n").find((row) => row.startsWith(`${key}=`));
|
|
10
|
+
if (!line)
|
|
11
|
+
return null;
|
|
12
|
+
const raw = line.slice(key.length + 1).trim();
|
|
13
|
+
const quoted = raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2;
|
|
14
|
+
return (quoted ? raw.slice(1, -1).replaceAll("'\\''", "'") : raw) || null;
|
|
15
|
+
}
|
|
16
|
+
export async function runServiceToolOverCli(runnerId, toolKey, argumentsJson, deps = {}) {
|
|
17
|
+
const parameters = argumentsJson === undefined ? {} : parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
|
|
18
|
+
if (!parameters)
|
|
19
|
+
return 1;
|
|
20
|
+
return await callAwpTool(runnerId, "run_service_tool", { toolKey, arguments: parameters, entryPoint: "cli" }, deps);
|
|
21
|
+
}
|
|
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
|
+
export async function callToolOverCli(runnerId, tool, argumentsJson, deps = {}, flags = []) {
|
|
24
|
+
const parameters = argumentsJson === undefined ? {} : parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
|
|
25
|
+
if (!parameters)
|
|
26
|
+
return 1;
|
|
27
|
+
if (flags.length) {
|
|
28
|
+
const typed = await flagArguments(runnerId, tool, flags, deps);
|
|
29
|
+
if (!typed)
|
|
30
|
+
return 1;
|
|
31
|
+
Object.assign(parameters, typed);
|
|
32
|
+
}
|
|
33
|
+
if (tool !== "next_event" || !RUNNER_ID.test(runnerId))
|
|
34
|
+
return await callAwpTool(runnerId, tool, parameters, deps);
|
|
35
|
+
// A bare poll acknowledges the batch the previous poll printed, so the command stays byte-identical and
|
|
36
|
+
// an operator can allow it once. Asking for the next event means the last one was read; a lost or
|
|
37
|
+
// failed poll leaves the file as it was, so delivery stays at-least-once.
|
|
38
|
+
const delivered = join(deps.home ?? homedir(), ".agentradar", "agents", `${runnerId}.delivered.json`);
|
|
39
|
+
if (parameters.ackEventIds === undefined) {
|
|
40
|
+
const ids = await readFile(delivered, "utf8").then((text) => JSON.parse(text), () => []);
|
|
41
|
+
if (Array.isArray(ids) && ids.length)
|
|
42
|
+
parameters.ackEventIds = ids;
|
|
43
|
+
}
|
|
44
|
+
let said = "";
|
|
45
|
+
const code = await callAwpTool(runnerId, tool, parameters, { ...deps, log: (text) => { said = text; (deps.log ?? console.log)(text); } });
|
|
46
|
+
if (code !== 0)
|
|
47
|
+
return code;
|
|
48
|
+
let events = [];
|
|
49
|
+
try {
|
|
50
|
+
events = JSON.parse(said).events;
|
|
51
|
+
}
|
|
52
|
+
catch { /* not a batch: nothing to acknowledge */ }
|
|
53
|
+
const ids = Array.isArray(events) ? events.map((event) => event?.eventId).filter((id) => typeof id === "string") : [];
|
|
54
|
+
await writeFile(delivered, JSON.stringify(ids), { mode: 0o600 }).catch(() => undefined);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* `agentradar run <runner-id> listen`: bare `next_event` polls (each held by the server, each acking the
|
|
59
|
+
* batch before it) until work, a liveness probe or the welcome arrives, or until the next hold could run
|
|
60
|
+
* past `budgetMs`. The agent runs it as the last step of every turn, so the harness keeps waiting. Nine
|
|
61
|
+
* minutes stays under Claude Code's 10-minute Bash cap; Codex waits on it in 5-minute chunks.
|
|
62
|
+
*/
|
|
63
|
+
export async function listenOverCli(runnerId, deps = {}, budgetMs = 540_000) {
|
|
64
|
+
const now = deps.now ?? Date.now;
|
|
65
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
66
|
+
const start = now();
|
|
67
|
+
let longest = 0;
|
|
68
|
+
for (;;) {
|
|
69
|
+
const began = now();
|
|
70
|
+
let said = "";
|
|
71
|
+
const code = await callToolOverCli(runnerId, "next_event", undefined, { ...deps, log: (text) => { said = text; } });
|
|
72
|
+
if (code !== 0)
|
|
73
|
+
return code;
|
|
74
|
+
let batch = null;
|
|
75
|
+
try {
|
|
76
|
+
batch = JSON.parse(said);
|
|
77
|
+
}
|
|
78
|
+
catch { /* not a batch: keep waiting */ }
|
|
79
|
+
// Wake on work, a liveness probe or the welcome; anything else (events: null, an unknown key) waits.
|
|
80
|
+
if ((Array.isArray(batch?.events) && batch.events.length) || batch?.livenessProbe || batch?.welcome) {
|
|
81
|
+
(deps.log ?? console.log)(said);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
// A caller the server does not hold (not yet hired, a run JWT) answers at once; do not spin on it.
|
|
85
|
+
if (now() - began < 10_000)
|
|
86
|
+
await sleep(10_000 - (now() - began));
|
|
87
|
+
longest = Math.max(longest, now() - began);
|
|
88
|
+
if (now() - start + longest > budgetMs) {
|
|
89
|
+
(deps.log ?? console.log)(`No client message in ${Math.round((now() - start) / 60_000)} minutes. Run agentradar run ${runnerId} listen again.`);
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Split `<runner-id> <tool> [--args '<json>'] [--<argument> <value> ...]`; null when it is not that shape. */
|
|
95
|
+
export function parseCallArgs(rest) {
|
|
96
|
+
const positional = [];
|
|
97
|
+
const flags = [];
|
|
98
|
+
let json;
|
|
99
|
+
for (let i = 0; i < rest.length; i++) {
|
|
100
|
+
const arg = rest[i];
|
|
101
|
+
if (!arg.startsWith("--")) {
|
|
102
|
+
positional.push(arg);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const value = rest[++i];
|
|
106
|
+
if (value === undefined)
|
|
107
|
+
return null;
|
|
108
|
+
if (arg === "--args")
|
|
109
|
+
json = value;
|
|
110
|
+
else
|
|
111
|
+
flags.push([arg.slice(2), value]);
|
|
112
|
+
}
|
|
113
|
+
return positional.length === 2 ? { runnerId: positional[0], tool: positional[1], json, flags } : null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* `--text "hi" --conversation-id X` as the tool's own arguments, typed by the inputSchema the server
|
|
117
|
+
* advertises, so an agent never types JSON (braces and quotes) into a shell command.
|
|
118
|
+
*/
|
|
119
|
+
async function flagArguments(runnerId, tool, flags, deps) {
|
|
120
|
+
const logError = deps.logError ?? console.error;
|
|
121
|
+
const listed = await awpRequest(runnerId, "tools/list", {}, deps);
|
|
122
|
+
if (!listed)
|
|
123
|
+
return null;
|
|
124
|
+
const schema = listed.result?.tools?.find((entry) => entry.name === tool)?.inputSchema?.properties ?? {};
|
|
125
|
+
const out = {};
|
|
126
|
+
const camel = (flag) => flag.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
127
|
+
for (let [flag, value] of flags) {
|
|
128
|
+
// --body-file page.md: the argument read from a file, so a multi-line page never sits in a quoted
|
|
129
|
+
// shell argument (Claude Code prompts on a newline followed by # inside one).
|
|
130
|
+
if (flag.endsWith("-file") && !(flag in schema) && !(camel(flag) in schema)) {
|
|
131
|
+
const text = await readArgumentFile(value, deps.cwd ?? process.cwd(), deps.home ?? homedir()).catch((error) => error);
|
|
132
|
+
if (text instanceof Error) {
|
|
133
|
+
logError(`--${flag}: ${text.message}`);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
[flag, value] = [flag.slice(0, -"-file".length), text];
|
|
137
|
+
}
|
|
138
|
+
// --text is what agents reach for on append_task_log, whose argument is --entry.
|
|
139
|
+
const key = flag in schema ? flag : flag === "text" && "entry" in schema ? "entry" : camel(flag);
|
|
140
|
+
if (!(key in schema)) {
|
|
141
|
+
logError(`${tool} has no argument --${flag}. Its arguments: ${Object.keys(schema).map((name) => `--${name}`).join(" ") || "none"}`);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const type = [schema[key].type].flat()[0];
|
|
145
|
+
const typed = coerce(type === "array" ? [schema[key].items?.type].flat()[0] : type, value);
|
|
146
|
+
if (typed === undefined) {
|
|
147
|
+
logError(`--${flag} must be ${type === "array" ? "a list of " + schema[key].items?.type : "a " + type}`);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
// A repeated flag builds a list: --message-ids a --message-ids b.
|
|
151
|
+
out[key] = type === "array" ? [...(out[key] ?? []), typed] : typed;
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* `agentradar run` is always-allowed, so a file flag must not read and upload any file the agent names:
|
|
157
|
+
* only a regular file inside the working folder (after symlinks), and at most 256 KB — and never one in
|
|
158
|
+
* ~/.agentradar, which holds the runner's own key even when the working folder is $HOME.
|
|
159
|
+
* ponytail: a hard link to the key is not caught (same inode, different path); making one is the agent's own act.
|
|
160
|
+
*/
|
|
161
|
+
async function readArgumentFile(path, cwd, home) {
|
|
162
|
+
const real = await realpath(resolve(cwd, path)).catch(() => { throw new Error(`cannot read ${path}`); });
|
|
163
|
+
const within = (dir) => { const inside = relative(dir, real); return inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside); };
|
|
164
|
+
const own = await realpath(join(home, ".agentradar")).catch(() => null);
|
|
165
|
+
if (own && within(own))
|
|
166
|
+
throw new Error(`${path} is refused: a file flag never reads AgentRadar's own folder (~/.agentradar), which holds this agent's key`);
|
|
167
|
+
const info = await stat(real);
|
|
168
|
+
if (!info.isFile() || !within(await realpath(cwd)) || info.size > 256 * 1024) {
|
|
169
|
+
throw new Error(`${path} is refused: a file flag reads only a regular file inside the current folder, up to 256 KB`);
|
|
170
|
+
}
|
|
171
|
+
return await readFile(real, "utf8");
|
|
172
|
+
}
|
|
173
|
+
function coerce(type, value) {
|
|
174
|
+
if (type === "string")
|
|
175
|
+
return value;
|
|
176
|
+
if (type === "number" || type === "integer") {
|
|
177
|
+
const number = value.trim() === "" ? NaN : Number(value);
|
|
178
|
+
return Number.isFinite(number) && (type === "number" || Number.isInteger(number)) ? number : undefined;
|
|
179
|
+
}
|
|
180
|
+
if (type === "boolean")
|
|
181
|
+
return value === "true" ? true : value === "false" ? false : undefined;
|
|
182
|
+
try {
|
|
183
|
+
return JSON.parse(value);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return type === undefined ? value : undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Ruling 49 — `agentradar task create|repeat <runner-id> --args '<json>'`: the agent's own `create_task`
|
|
191
|
+
* or `create_recurring_task`, on the same AWP endpoint with the same key, so who it may assign to is the
|
|
192
|
+
* server's decision exactly as it is over MCP.
|
|
193
|
+
*/
|
|
194
|
+
export async function createTaskOverCli(runnerId, recurring, argumentsJson, deps = {}) {
|
|
195
|
+
const parameters = parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
|
|
196
|
+
if (!parameters)
|
|
197
|
+
return 1;
|
|
198
|
+
return await callAwpTool(runnerId, recurring ? "create_recurring_task" : "create_task", parameters, deps);
|
|
199
|
+
}
|
|
200
|
+
function parseArgumentsObject(argumentsJson, logError) {
|
|
201
|
+
let parsed;
|
|
202
|
+
try {
|
|
203
|
+
parsed = JSON.parse(argumentsJson);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
logError("--args must be a JSON object, e.g. --args '{\"title\":\"a real issue\"}'");
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
210
|
+
logError("--args must be a JSON object");
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
return parsed;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* R251 — `agentradar tool list <runner-id>`: the server's own `list_service_tools`, printed as it
|
|
217
|
+
* answered. The listing is the policy's preview for this agent, so the CLI computes nothing — a second
|
|
218
|
+
* copy of the decision here could only drift from the one the MCP tool and every real call make.
|
|
219
|
+
*/
|
|
220
|
+
export async function listServiceToolsOverCli(runnerId, deps = {}) {
|
|
221
|
+
return await callAwpTool(runnerId, "list_service_tools", {}, deps);
|
|
222
|
+
}
|
|
223
|
+
/** One `tools/call` on the runner's AWP MCP endpoint with the runner's own key; the answer is printed verbatim. */
|
|
224
|
+
export async function callAwpTool(runnerId, name, toolArguments, deps) {
|
|
225
|
+
const log = deps.log ?? console.log;
|
|
226
|
+
const logError = deps.logError ?? console.error;
|
|
227
|
+
const body = await awpRequest(runnerId, "tools/call", { name, arguments: toolArguments }, deps);
|
|
228
|
+
if (!body)
|
|
229
|
+
return 1;
|
|
230
|
+
const text = body.result?.content?.[0]?.text;
|
|
231
|
+
const said = typeof text === "string" ? text : JSON.stringify(body.result ?? {});
|
|
232
|
+
// A refusal is the server's sentence and belongs on stderr with a non-zero exit, or a script that
|
|
233
|
+
// pipes stdout reads "the tool ran" from a body that says it was refused.
|
|
234
|
+
if (body.result?.isError) {
|
|
235
|
+
logError(said);
|
|
236
|
+
return 1;
|
|
237
|
+
}
|
|
238
|
+
log(said);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
242
|
+
/** One JSON-RPC request on the runner's AWP MCP endpoint with the runner's own key; null after logging why it failed. */
|
|
243
|
+
async function awpRequest(runnerId, method, params, deps) {
|
|
244
|
+
const logError = deps.logError ?? console.error;
|
|
245
|
+
if (!RUNNER_ID.test(runnerId)) {
|
|
246
|
+
logError("Invalid runner ID");
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
const envPath = join(deps.home ?? homedir(), ".agentradar", "agents", `${runnerId}.env`);
|
|
250
|
+
let url;
|
|
251
|
+
let bearer;
|
|
252
|
+
const contents = await readFile(envPath, "utf8").catch(() => null);
|
|
253
|
+
if (contents !== null) {
|
|
254
|
+
url = envValue(contents, "AWP_MCP_URL");
|
|
255
|
+
bearer = envValue(contents, "AWP_BEARER");
|
|
256
|
+
if (!url || !bearer) {
|
|
257
|
+
logError(`${envPath} is missing AWP_MCP_URL or AWP_BEARER. Run agentradar setup again.`);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
// Ruling 41 — a hosted agent has no runner file. Its run already carries the server's origin and its
|
|
263
|
+
// per-run JWT (`PAPERCLIP_API_URL`/`PAPERCLIP_API_KEY`), which the AWP MCP admits for a vendorless
|
|
264
|
+
// hosted agent (`server/src/awp/resolve.ts`). The runner file, when present, still wins.
|
|
265
|
+
const env = deps.env ?? process.env;
|
|
266
|
+
const origin = env.PAPERCLIP_API_URL?.trim().replace(/\/+$/, "");
|
|
267
|
+
bearer = env.PAPERCLIP_API_KEY?.trim() || null;
|
|
268
|
+
if (!origin || !bearer) {
|
|
269
|
+
logError(`No runner named ${runnerId}: ${envPath} does not exist. Run agentradar setup first.`);
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
url = `${origin}/api/mcp`;
|
|
273
|
+
}
|
|
274
|
+
const response = await (deps.fetch ?? fetch)(url, {
|
|
275
|
+
method: "POST",
|
|
276
|
+
headers: {
|
|
277
|
+
authorization: `Bearer ${bearer}`,
|
|
278
|
+
"content-type": "application/json",
|
|
279
|
+
// Both, because the endpoint answers JSON only when the client says it takes JSON.
|
|
280
|
+
accept: "application/json, text/event-stream",
|
|
281
|
+
},
|
|
282
|
+
body: JSON.stringify({
|
|
283
|
+
jsonrpc: "2.0",
|
|
284
|
+
id: 1,
|
|
285
|
+
method,
|
|
286
|
+
params,
|
|
287
|
+
}),
|
|
288
|
+
});
|
|
289
|
+
if (!response.ok) {
|
|
290
|
+
logError(`AgentRadar refused the tool call (${response.status}).`);
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
const body = await response.json();
|
|
294
|
+
if (body.error) {
|
|
295
|
+
logError(typeof body.error.message === "string" ? body.error.message : "AgentRadar returned an error");
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
return body;
|
|
299
|
+
}
|
|
300
|
+
//# sourceMappingURL=tool-run.js.map
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Unlike runner.mjs (which spawns a fresh `claude` per event, out-of-context), the
|
|
6
6
|
// feeder pushes each drained client event as the NEXT TURN of a PERSISTENT, always-on
|
|
7
|
-
//
|
|
7
|
+
// agent CLI running in a tmux pane (real in-session continuity — I1). R004: that CLI is
|
|
8
|
+
// Claude Code or Codex, selected by AR_AGENT_HARNESS; injection, acking and the
|
|
9
|
+
// in-progress hint are identical on both, and only the login probe, the idle-composer
|
|
10
|
+
// marker and the pane's argv pattern branch. It:
|
|
8
11
|
// 1. phones home to the single AgentRadar AWP MCP and long-polls `next_event`;
|
|
9
12
|
// 2. injects each event into the live pane via `tmux send-keys` (I2), pinning the
|
|
10
13
|
// EXPLICIT conversationId so the agent's `reply` never guesses the target;
|
|
@@ -18,11 +21,11 @@
|
|
|
18
21
|
// ===========================================================================
|
|
19
22
|
|
|
20
23
|
import { spawn, spawnSync } from "node:child_process";
|
|
21
|
-
import { createHash } from "node:crypto";
|
|
24
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
22
25
|
import fs from "node:fs";
|
|
23
26
|
import http from "node:http";
|
|
24
27
|
import https from "node:https";
|
|
25
|
-
import { dirname, join } from "node:path";
|
|
28
|
+
import { dirname, isAbsolute, join, normalize, sep } from "node:path";
|
|
26
29
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
27
30
|
import { formatInjectionFrame, formatProbeFrame, sendKeysCommands } from "./session-frame.mjs";
|
|
28
31
|
|
|
@@ -45,9 +48,22 @@ const AWP_BEARER = (() => {
|
|
|
45
48
|
return process.env.AWP_BEARER ?? "";
|
|
46
49
|
})();
|
|
47
50
|
const TMUX_SESSION = process.env.TMUX_SESSION ?? "agent";
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
/**
|
|
52
|
+
* R004 — which CLI is in the pane. `claude` is the default, so a feeder started by an older supervisor (or
|
|
53
|
+
* by hand) behaves exactly as before. Only what genuinely differs reads this: how the CLI reports its login
|
|
54
|
+
* state, what its idle composer looks like, and the argv the pane can be found by. The in-progress hint and
|
|
55
|
+
* the volatile-line strip are NOT harness-specific — measured on codex-cli 0.149.1, Codex renders the same
|
|
56
|
+
* `esc to interrupt` working line Claude does (`• Working (1s • esc to interrupt)`), so the existing
|
|
57
|
+
* matchers already cover it and a speculative Codex spinner pattern would be invention, not coverage.
|
|
58
|
+
*/
|
|
59
|
+
export const AR_AGENT_HARNESS = process.env.AR_AGENT_HARNESS === "codex" ? "codex" : "claude";
|
|
60
|
+
// The agent pid marker (same predicate entrypoint.sh uses) — proves the pane is live. Claude is found by its
|
|
61
|
+
// mcp-config argument; Codex takes its MCP servers from CODEX_HOME (an env var, invisible to `ps`), so its
|
|
62
|
+
// per-agent argv token is `--cd <working dir>`. The supervisor passes an exact pattern; this default only
|
|
63
|
+
// matters for a hand-started feeder.
|
|
64
|
+
const AGENT_PID_PATTERN =
|
|
65
|
+
process.env.AGENT_PID_PATTERN ?? (AR_AGENT_HARNESS === "codex" ? "codex.*--cd " : "mcp-config.*awp-only");
|
|
66
|
+
const AGENT_BIN = process.env.AGENT_BIN ?? process.env.CLAUDE_BIN ?? (AR_AGENT_HARNESS === "codex" ? "codex" : "claude");
|
|
51
67
|
const AR_OAUTH_TOKEN_FILE = process.env.AR_OAUTH_TOKEN_FILE ?? "";
|
|
52
68
|
|
|
53
69
|
const MCP_CALL_TIMEOUT_MS = Number(process.env.AWP_MCP_CALL_TIMEOUT_MS ?? "30000");
|
|
@@ -75,6 +91,18 @@ const REPORT_PANE_INTERVAL_MS = Number(process.env.REPORT_PANE_INTERVAL_MS ?? "4
|
|
|
75
91
|
// any single failure well under the 45s interval.
|
|
76
92
|
const PANE_MCP_TIMEOUT_MS = Number(process.env.PANE_MCP_TIMEOUT_MS ?? "10000");
|
|
77
93
|
|
|
94
|
+
// R650 — the agent's AgentRadar skills (bundled + linked) are written into its CLI's skills directory on this
|
|
95
|
+
// cadence. Only directories this feeder created (they carry MANAGED_MARK) are ever replaced or removed; a
|
|
96
|
+
// skill the vendor put there by hand is left alone. 0 disables.
|
|
97
|
+
/** Claude reads `$AR_BASE/.claude/skills`; Codex reads `$CODEX_HOME/skills`, the per-agent home the supervisor requires. */
|
|
98
|
+
export function skillsDirFor(harness, env) {
|
|
99
|
+
if (harness === "codex") return env.CODEX_HOME ? join(env.CODEX_HOME, "skills") : null;
|
|
100
|
+
return env.AR_BASE ? join(env.AR_BASE, ".claude", "skills") : null;
|
|
101
|
+
}
|
|
102
|
+
const SKILLS_DIR = skillsDirFor(AR_AGENT_HARNESS, process.env);
|
|
103
|
+
const SKILLS_SYNC_INTERVAL_MS = Number(process.env.SKILLS_SYNC_INTERVAL_MS ?? "600000");
|
|
104
|
+
const MANAGED_MARK = ".agentradar-managed";
|
|
105
|
+
|
|
78
106
|
function sleep(ms) {
|
|
79
107
|
return new Promise((r) => setTimeout(r, ms));
|
|
80
108
|
}
|
|
@@ -195,8 +223,10 @@ function toolJson(result) {
|
|
|
195
223
|
const text = result?.content?.find((c) => c.type === "text")?.text;
|
|
196
224
|
return text ? JSON.parse(text) : {};
|
|
197
225
|
}
|
|
226
|
+
const runnerSessionId = randomUUID();
|
|
198
227
|
async function nextEvent(ackEventIds, runnerState) {
|
|
199
228
|
const args = {
|
|
229
|
+
sessionId: runnerSessionId,
|
|
200
230
|
...(ackEventIds && ackEventIds.length ? { ackEventIds } : {}),
|
|
201
231
|
...(runnerState ? { runnerState } : {}),
|
|
202
232
|
};
|
|
@@ -208,11 +238,16 @@ async function nextEvent(ackEventIds, runnerState) {
|
|
|
208
238
|
// Work readiness — process liveness is not enough. A live Claude process can
|
|
209
239
|
// be blocked on subscription OAuth, where injected work is consumed as the OAuth code.
|
|
210
240
|
// --------------------------------------------------------------------------
|
|
211
|
-
|
|
241
|
+
function authProbeEnv(extraNames = []) {
|
|
212
242
|
const env = {};
|
|
213
|
-
for (const name of ["HOME", "PATH", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE",
|
|
243
|
+
for (const name of ["HOME", "PATH", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", ...extraNames]) {
|
|
214
244
|
if (typeof process.env[name] === "string") env[name] = process.env[name];
|
|
215
245
|
}
|
|
246
|
+
return env;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function claudeAuthState(run = spawnSync, readFile = fs.readFileSync) {
|
|
250
|
+
const env = authProbeEnv(["CLAUDE_CONFIG_DIR"]);
|
|
216
251
|
if (AR_OAUTH_TOKEN_FILE) {
|
|
217
252
|
try {
|
|
218
253
|
env.CLAUDE_CODE_OAUTH_TOKEN = readFile(AR_OAUTH_TOKEN_FILE, "utf8").trim();
|
|
@@ -220,7 +255,7 @@ export function claudeAuthState(run = spawnSync, readFile = fs.readFileSync) {
|
|
|
220
255
|
return "unknown";
|
|
221
256
|
}
|
|
222
257
|
}
|
|
223
|
-
const r = run(
|
|
258
|
+
const r = run(AGENT_BIN, ["auth", "status", "--json"], { encoding: "utf8", env, timeout: 10000 });
|
|
224
259
|
if (typeof r.stdout !== "string") return "unknown";
|
|
225
260
|
try {
|
|
226
261
|
return JSON.parse(r.stdout).loggedIn === true ? "authenticated" : "unauthenticated";
|
|
@@ -229,7 +264,31 @@ export function claudeAuthState(run = spawnSync, readFile = fs.readFileSync) {
|
|
|
229
264
|
}
|
|
230
265
|
}
|
|
231
266
|
|
|
232
|
-
|
|
267
|
+
/**
|
|
268
|
+
* Codex has no `auth status --json`. Its login probe is `codex login status`, which EXITS 0 when logged in
|
|
269
|
+
* and 1 when not, and prints "Logged in using ChatGPT" / "Not logged in" (measured, codex-cli 0.149.1).
|
|
270
|
+
*
|
|
271
|
+
* The exit code is the signal and the text is only a cross-check, not the other way round: a future build
|
|
272
|
+
* that reworded the line would still exit correctly, whereas reading the words alone would silently start
|
|
273
|
+
* returning "unknown" and park the runner in `starting` forever. A code we do not recognise stays "unknown"
|
|
274
|
+
* rather than becoming "unauthenticated", because `auth_required` is a claim the vendor is asked to act on.
|
|
275
|
+
*
|
|
276
|
+
* CODEX_HOME crosses into the probe because it IS the agent's credential home — probing the default
|
|
277
|
+
* ~/.codex would answer about a different login than the one the pane runs on.
|
|
278
|
+
*/
|
|
279
|
+
export function codexAuthState(run = spawnSync) {
|
|
280
|
+
const r = run(AGENT_BIN, ["login", "status"], { encoding: "utf8", env: authProbeEnv(["CODEX_HOME"]), timeout: 10000 });
|
|
281
|
+
if (r.status === 0) return "authenticated";
|
|
282
|
+
if (r.status === 1) return "unauthenticated";
|
|
283
|
+
return "unknown";
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The login probe for whichever CLI this runner drives. */
|
|
287
|
+
export function agentAuthState() {
|
|
288
|
+
return AR_AGENT_HARNESS === "codex" ? codexAuthState() : claudeAuthState();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function runnerState(auth = agentAuthState, sample = capturePane) {
|
|
233
292
|
const authState = auth();
|
|
234
293
|
if (authState === "unauthenticated") return "auth_required";
|
|
235
294
|
if (authState !== "authenticated") return "starting";
|
|
@@ -392,7 +451,11 @@ export function stripVolatilePaneLines(text) {
|
|
|
392
451
|
return String(text ?? "")
|
|
393
452
|
.split("\n")
|
|
394
453
|
.filter((line) => {
|
|
395
|
-
//
|
|
454
|
+
// The mid-turn interrupt hint (often carries the ticking counter). This first matcher is also what
|
|
455
|
+
// covers CODEX: its whole working line is "• Working (1s • esc to interrupt)" (measured, codex-cli
|
|
456
|
+
// 0.149.1), counter included, so it is stripped entire and the Claude-specific spinner matchers below
|
|
457
|
+
// are simply inert on a Codex pane. No speculative Codex pattern is added here — an unmeasured
|
|
458
|
+
// matcher would look like coverage while stabilising nothing.
|
|
396
459
|
if (/\besc to (?:interrupt|cancel)\b/i.test(line)) return false;
|
|
397
460
|
// claude's spinner line, TWO observed forms — a leading animated glyph + a SINGLE gerund word +
|
|
398
461
|
// the elapsed counter. Tightly anchored (one word before the counter) so real transcript prose
|
|
@@ -421,6 +484,40 @@ export function stripVolatilePaneLines(text) {
|
|
|
421
484
|
.join("\n");
|
|
422
485
|
}
|
|
423
486
|
|
|
487
|
+
/**
|
|
488
|
+
* The durable "this pane can take a turn" marker, which is the one pane signal that genuinely differs
|
|
489
|
+
* between the two CLIs. Claude Code shows its bypass-permissions footer or the `❯` prompt; Codex shows its
|
|
490
|
+
* idle composer placeholder `Ask Codex to do anything` (measured, codex-cli 0.149.1).
|
|
491
|
+
*
|
|
492
|
+
* ponytail: both markers vanish while a modal owns the screen — Claude's login prompt, Codex's trust or
|
|
493
|
+
* rate-limit dialog — which is the behaviour we want, since a pane blocked on a dialog cannot take work.
|
|
494
|
+
* Both also vanish once a human has typed into the composer without submitting; on an unattended runner
|
|
495
|
+
* nothing types there except `send-keys`, which submits immediately. Narrow the marker only if a real pane
|
|
496
|
+
* is observed to defeat it.
|
|
497
|
+
*/
|
|
498
|
+
export function paneInputReady(text, harness = AR_AGENT_HARNESS) {
|
|
499
|
+
const tail = String(text ?? "").split("\n").slice(-12).join("\n");
|
|
500
|
+
return harness === "codex"
|
|
501
|
+
? /Ask Codex to do anything/i.test(tail)
|
|
502
|
+
: /bypass permissions on\b|^\s*❯/im.test(tail);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Why a Claude Code session refused to register its channels, read off the banner it prints — or null.
|
|
507
|
+
* Keyed by the CLI's own skip `kind`. The `auth` banner comes from builds before 2.1.281, which has no auth
|
|
508
|
+
* gate. See docs/claude-code-channels-gates.md.
|
|
509
|
+
*/
|
|
510
|
+
const CHANNEL_BANNERS = [
|
|
511
|
+
[/Channels are not currently available/, "disabled", "tengu_harbor is off: unset CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, set CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF=1, seed or fetch the flag cache, restart the session"],
|
|
512
|
+
[/Channels require claude\.ai authentication/, "auth", "no claude.ai login: run /login in the session, then restart it"],
|
|
513
|
+
[/Channels are not enabled for your org/, "policy", "org managed settings block channels: an admin must set channelsEnabled: true"],
|
|
514
|
+
[/Channels are not available on (?:Bedrock|third-party)/, "provider", "a Bedrock/Vertex/Foundry provider is selected: channels need the first-party API"],
|
|
515
|
+
];
|
|
516
|
+
export function classifyChannelBanner(text) {
|
|
517
|
+
const hit = CHANNEL_BANNERS.find(([re]) => re.test(String(text ?? "")));
|
|
518
|
+
return hit ? { kind: hit[1], action: hit[2] } : null;
|
|
519
|
+
}
|
|
520
|
+
|
|
424
521
|
function capturePane() {
|
|
425
522
|
const r = spawnSync("tmux", ["capture-pane", "-p", "-t", TMUX_SESSION], { encoding: "utf8" });
|
|
426
523
|
if (r.status !== 0 || typeof r.stdout !== "string") {
|
|
@@ -436,13 +533,17 @@ function capturePane() {
|
|
|
436
533
|
// Fingerprint the TRANSCRIPT BODY only — strip the client-side-ticking spinner/counter so a mid-turn
|
|
437
534
|
// API hang (LLM dead, counter still ticking) yields a STABLE hash → stagnation is detectable (§4).
|
|
438
535
|
const hash = createHash("sha256").update(stripVolatilePaneLines(text)).digest("hex").slice(0, 32);
|
|
439
|
-
//
|
|
440
|
-
//
|
|
536
|
+
// Both TUIs show a working line ("… esc to interrupt") while mid-turn — Codex's reads
|
|
537
|
+
// "• Working (1s • esc to interrupt)", measured on codex-cli 0.149.1, so this matcher needs no harness
|
|
538
|
+
// branch. This is only a HINT that triggers a probe on stagnation — it never decides liveness, so a
|
|
539
|
+
// missed/extra hint is harmless.
|
|
441
540
|
const inProgress = /esc to interrupt|\besc to cancel\b/i.test(text);
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
|
|
541
|
+
// The CLI's idle input prompt is the durable readiness signal, and it is the one pane signal that differs
|
|
542
|
+
// per harness (see paneInputReady). The optional bypass-permissions footer disappeared from a live Claude
|
|
543
|
+
// build after several turns, which falsely demoted a healthy pane to `starting` and stopped next_event
|
|
544
|
+
// from draining until its heartbeat aged stale.
|
|
545
|
+
const inputReady = paneInputReady(text);
|
|
546
|
+
return { hash, inProgress, inputReady, channelGate: classifyChannelBanner(text) };
|
|
446
547
|
}
|
|
447
548
|
/**
|
|
448
549
|
* #173b (disc obs) — format-drift detector. If the STRIPPED fingerprint keeps CHANGING while the pane
|
|
@@ -456,6 +557,7 @@ export function looksLikeStripMiss(prev, cur) {
|
|
|
456
557
|
return Boolean(prev && cur && prev.inProgress && cur.inProgress && prev.hash !== cur.hash);
|
|
457
558
|
}
|
|
458
559
|
let _lastPaneSample = null;
|
|
560
|
+
let _lastChannelGate = null;
|
|
459
561
|
let _paneReportCount = 0;
|
|
460
562
|
async function reportPane() {
|
|
461
563
|
// #156 diagnostics: log EVERY outcome (skip / ok+latency / error) under DEBUG_PANE with a monotonic
|
|
@@ -478,6 +580,10 @@ async function reportPane() {
|
|
|
478
580
|
);
|
|
479
581
|
}
|
|
480
582
|
_lastPaneSample = p;
|
|
583
|
+
// ponytail: a hint, not a detector — the banner shows for ~12 s and this samples every 45 s, so most
|
|
584
|
+
// refusals pass unseen. The durable record is `--debug-file`'s "Channel notifications skipped:" line.
|
|
585
|
+
if (p.channelGate && p.channelGate.kind !== _lastChannelGate) console.warn(`[feeder] channels not registered (${p.channelGate.kind}): ${p.channelGate.action}`);
|
|
586
|
+
_lastChannelGate = p.channelGate?.kind ?? null;
|
|
481
587
|
// #156: isolated connection (NOT the shared long-poll socket) so pane sampling never stalls behind
|
|
482
588
|
// the receive long-poll's ALB-killed socket.
|
|
483
589
|
await mcpPostIsolated("tools/call", { name: "report_pane", arguments: { paneHash: p.hash, inProgress: p.inProgress } });
|
|
@@ -492,6 +598,71 @@ async function reportPane() {
|
|
|
492
598
|
}
|
|
493
599
|
}
|
|
494
600
|
|
|
601
|
+
// --------------------------------------------------------------------------
|
|
602
|
+
// R650 skill sync. The server's answer is untrusted input to this box's filesystem: names and paths are
|
|
603
|
+
// checked before anything is written, and every write lands inside a fresh directory under skillsDir.
|
|
604
|
+
// --------------------------------------------------------------------------
|
|
605
|
+
const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,99}$/;
|
|
606
|
+
const isManaged = (dir) => {
|
|
607
|
+
const st = fs.lstatSync(dir, { throwIfNoEntry: false });
|
|
608
|
+
return Boolean(st?.isDirectory() && fs.existsSync(join(dir, MANAGED_MARK)));
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
export function writeManagedSkills(skillsDir, skills) {
|
|
612
|
+
fs.mkdirSync(skillsDir, { recursive: true });
|
|
613
|
+
const written = new Set();
|
|
614
|
+
for (const skill of skills) {
|
|
615
|
+
const name = String(skill?.name ?? "");
|
|
616
|
+
const target = join(skillsDir, name);
|
|
617
|
+
if (!SKILL_NAME.test(name)) { console.warn(`[feeder] skill sync: bad name ${JSON.stringify(name)} skipped`); continue; }
|
|
618
|
+
if (fs.lstatSync(target, { throwIfNoEntry: false }) && !isManaged(target)) {
|
|
619
|
+
console.warn(`[feeder] skill sync: ${name} is not ours, left alone`);
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const files = Array.isArray(skill.files) ? skill.files : [];
|
|
623
|
+
const bad = files.find((f) => typeof f?.path !== "string" || typeof f?.content !== "string" || isAbsolute(f.path)
|
|
624
|
+
|| normalize(f.path).split(sep).includes("..") || [".", MANAGED_MARK].includes(normalize(f.path)));
|
|
625
|
+
if (bad) { console.warn(`[feeder] skill sync: ${name} has an unsafe file path, skipped`); continue; }
|
|
626
|
+
const staging = join(skillsDir, `.${name}.sync-${process.pid}`);
|
|
627
|
+
try {
|
|
628
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
629
|
+
for (const f of files) {
|
|
630
|
+
fs.mkdirSync(dirname(join(staging, f.path)), { recursive: true });
|
|
631
|
+
fs.writeFileSync(join(staging, f.path), f.content);
|
|
632
|
+
}
|
|
633
|
+
fs.writeFileSync(join(staging, MANAGED_MARK), "Written by the AgentRadar runner; replaced on every sync.\n");
|
|
634
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
635
|
+
fs.renameSync(staging, target);
|
|
636
|
+
written.add(name);
|
|
637
|
+
} catch (err) {
|
|
638
|
+
// One skill that cannot be written keeps its previous copy and does not stop the rest of the sync.
|
|
639
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
640
|
+
if (isManaged(target)) written.add(name);
|
|
641
|
+
console.warn(`[feeder] skill sync: ${name} not written: ${err instanceof Error ? err.message : String(err)}`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
// An unlinked skill leaves: every directory we wrote earlier and the server no longer lists.
|
|
645
|
+
for (const entry of fs.readdirSync(skillsDir)) {
|
|
646
|
+
if (!written.has(entry) && isManaged(join(skillsDir, entry))) fs.rmSync(join(skillsDir, entry), { recursive: true, force: true });
|
|
647
|
+
}
|
|
648
|
+
return [...written];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function syncSkills() {
|
|
652
|
+
try {
|
|
653
|
+
const answer = toolJson(await mcpPostIsolated("tools/call", { name: "my_skills", arguments: {} }));
|
|
654
|
+
// A refusal or an older server keeps whatever this box already has.
|
|
655
|
+
if (!answer.ok || !Array.isArray(answer.skills)) {
|
|
656
|
+
console.warn(`[feeder] skill sync: server answered ${answer.error ?? "without skills"}; keeping current copies`);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
const written = writeManagedSkills(SKILLS_DIR, answer.skills);
|
|
660
|
+
console.log(`[feeder] skill sync: ${written.length} skill(s) in ${SKILLS_DIR}`);
|
|
661
|
+
} catch (err) {
|
|
662
|
+
console.warn(`[feeder] skill sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
495
666
|
// --------------------------------------------------------------------------
|
|
496
667
|
// The loop: initialize → poll next_event → inject each into the live pane → ack the
|
|
497
668
|
// delivered ones on the NEXT poll → self-heal on transient MCP errors (runner shape).
|
|
@@ -561,6 +732,10 @@ async function loop() {
|
|
|
561
732
|
_paneTimer = setInterval(() => { void reportPane(); }, REPORT_PANE_INTERVAL_MS);
|
|
562
733
|
_paneTimer.unref?.();
|
|
563
734
|
}
|
|
735
|
+
if (SKILLS_DIR && SKILLS_SYNC_INTERVAL_MS > 0) {
|
|
736
|
+
void syncSkills();
|
|
737
|
+
setInterval(() => { void syncSkills(); }, SKILLS_SYNC_INTERVAL_MS).unref?.();
|
|
738
|
+
}
|
|
564
739
|
while (!_stopping) {
|
|
565
740
|
try {
|
|
566
741
|
await pollOnce();
|