agentradar-cli 0.1.9 → 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 +109 -10
- package/dist/runner.js +431 -8
- package/dist/setup.js +236 -12
- package/dist/skill-install.js +2 -0
- package/dist/tool-run.js +300 -0
- package/dist/vendor-runner/feeder.mjs +189 -19
- package/dist/vendor-runner/supervisor/supervise-agent.sh +85 -25
- package/package.json +3 -1
|
@@ -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,8 +484,38 @@ export function stripVolatilePaneLines(text) {
|
|
|
421
484
|
.join("\n");
|
|
422
485
|
}
|
|
423
486
|
|
|
424
|
-
|
|
425
|
-
|
|
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;
|
|
426
519
|
}
|
|
427
520
|
|
|
428
521
|
function capturePane() {
|
|
@@ -440,14 +533,17 @@ function capturePane() {
|
|
|
440
533
|
// Fingerprint the TRANSCRIPT BODY only — strip the client-side-ticking spinner/counter so a mid-turn
|
|
441
534
|
// API hang (LLM dead, counter still ticking) yields a STABLE hash → stagnation is detectable (§4).
|
|
442
535
|
const hash = createHash("sha256").update(stripVolatilePaneLines(text)).digest("hex").slice(0, 32);
|
|
443
|
-
//
|
|
444
|
-
//
|
|
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.
|
|
445
540
|
const inProgress = /esc to interrupt|\besc to cancel\b/i.test(text);
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
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.
|
|
449
545
|
const inputReady = paneInputReady(text);
|
|
450
|
-
return { hash, inProgress, inputReady };
|
|
546
|
+
return { hash, inProgress, inputReady, channelGate: classifyChannelBanner(text) };
|
|
451
547
|
}
|
|
452
548
|
/**
|
|
453
549
|
* #173b (disc obs) — format-drift detector. If the STRIPPED fingerprint keeps CHANGING while the pane
|
|
@@ -461,6 +557,7 @@ export function looksLikeStripMiss(prev, cur) {
|
|
|
461
557
|
return Boolean(prev && cur && prev.inProgress && cur.inProgress && prev.hash !== cur.hash);
|
|
462
558
|
}
|
|
463
559
|
let _lastPaneSample = null;
|
|
560
|
+
let _lastChannelGate = null;
|
|
464
561
|
let _paneReportCount = 0;
|
|
465
562
|
async function reportPane() {
|
|
466
563
|
// #156 diagnostics: log EVERY outcome (skip / ok+latency / error) under DEBUG_PANE with a monotonic
|
|
@@ -483,6 +580,10 @@ async function reportPane() {
|
|
|
483
580
|
);
|
|
484
581
|
}
|
|
485
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;
|
|
486
587
|
// #156: isolated connection (NOT the shared long-poll socket) so pane sampling never stalls behind
|
|
487
588
|
// the receive long-poll's ALB-killed socket.
|
|
488
589
|
await mcpPostIsolated("tools/call", { name: "report_pane", arguments: { paneHash: p.hash, inProgress: p.inProgress } });
|
|
@@ -497,6 +598,71 @@ async function reportPane() {
|
|
|
497
598
|
}
|
|
498
599
|
}
|
|
499
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
|
+
|
|
500
666
|
// --------------------------------------------------------------------------
|
|
501
667
|
// The loop: initialize → poll next_event → inject each into the live pane → ack the
|
|
502
668
|
// delivered ones on the NEXT poll → self-heal on transient MCP errors (runner shape).
|
|
@@ -566,6 +732,10 @@ async function loop() {
|
|
|
566
732
|
_paneTimer = setInterval(() => { void reportPane(); }, REPORT_PANE_INTERVAL_MS);
|
|
567
733
|
_paneTimer.unref?.();
|
|
568
734
|
}
|
|
735
|
+
if (SKILLS_DIR && SKILLS_SYNC_INTERVAL_MS > 0) {
|
|
736
|
+
void syncSkills();
|
|
737
|
+
setInterval(() => { void syncSkills(); }, SKILLS_SYNC_INTERVAL_MS).unref?.();
|
|
738
|
+
}
|
|
569
739
|
while (!_stopping) {
|
|
570
740
|
try {
|
|
571
741
|
await pollOnce();
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
# all env params). It idempotently ensures BOTH halves of an always-on agent are
|
|
8
8
|
# up, then blocks so KeepAlive/Restart revives the whole thing on any death:
|
|
9
9
|
#
|
|
10
|
-
# 1. a tmux session ($TMUX_SESSION) running the agent:
|
|
10
|
+
# 1. a tmux session ($TMUX_SESSION) running the agent CLI named by $AR_AGENT_HARNESS:
|
|
11
11
|
# claude --continue $CLAUDE_EXTRA_FLAGS --strict-mcp-config --mcp-config=$MCP_CONFIG
|
|
12
|
-
#
|
|
13
|
-
#
|
|
12
|
+
# codex resume --last $CODEX_EXTRA_FLAGS --cd $AR_BASE (MCP confined by $CODEX_HOME)
|
|
13
|
+
# in $AR_BASE, with a SCRUBBED allowlist env + the CLI's own persisted login.
|
|
14
|
+
# Strict MCP flags (Claude) / a per-agent CODEX_HOME (Codex) allow ONLY AgentRadar's config;
|
|
14
15
|
# 2. the feeder ($FEEDER_JS) OUTSIDE that session, holding $AWP_BEARER, long-
|
|
15
16
|
# polling next_event and tmux-injecting each event into the pane.
|
|
16
17
|
#
|
|
@@ -20,7 +21,8 @@
|
|
|
20
21
|
# starts it on boot → same. Composes with the stateless AWP MCP (#111/#112:
|
|
21
22
|
# reconnect, no -32001) + `claude --continue` (context resumes) = hands-free revive.
|
|
22
23
|
#
|
|
23
|
-
# HARD RULE: claude.ai OAuth ONLY. Refuses to start if
|
|
24
|
+
# HARD RULE (claude harness): claude.ai OAuth ONLY. Refuses to start if
|
|
25
|
+
# ANTHROPIC_API_KEY is set. Codex authenticates only through $CODEX_HOME/auth.json.
|
|
24
26
|
# =============================================================================
|
|
25
27
|
set -euo pipefail
|
|
26
28
|
|
|
@@ -51,7 +53,6 @@ set +a
|
|
|
51
53
|
: "${AR_BASE:?AR_BASE required (the agent working dir)}"
|
|
52
54
|
: "${AWP_MCP_URL:?AWP_MCP_URL required}"
|
|
53
55
|
: "${AWP_BEARER:?AWP_BEARER required}"
|
|
54
|
-
: "${MCP_CONFIG:?MCP_CONFIG required (path to awp-only.json)}"
|
|
55
56
|
TMUX_SESSION="${TMUX_SESSION:-${AGENT_NAME}}"
|
|
56
57
|
FEEDER_JS="${FEEDER_JS:-$(cd "$(dirname "$0")/.." && pwd)/feeder.mjs}"
|
|
57
58
|
PANE_LIVE_SH="${PANE_LIVE_SH:-$(cd "$(dirname "$0")/.." && pwd)/pane-live.sh}"
|
|
@@ -68,11 +69,56 @@ BROWSER_APP="${BROWSER_APP:-Google Chrome}" # #150: app to ensure-up fo
|
|
|
68
69
|
BROWSER_READY_MAX_SEC="${BROWSER_READY_MAX_SEC:-20}" # #150: bounded wait for the browser proc on cold boot
|
|
69
70
|
BROWSER_READY_SETTLE_SEC="${BROWSER_READY_SETTLE_SEC:-3}" # #150: settle for extension/native-host after cold launch
|
|
70
71
|
HEALTH_POLL_SEC="${HEALTH_POLL_SEC:-15}"
|
|
71
|
-
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
|
|
72
|
+
|
|
73
|
+
# --- 2a. R004 HARNESS SELECTION — one documented variable picks the CLI ---------
|
|
74
|
+
# The Bible names two agent harnesses: Claude Code and Codex. `claude` is the default, so every existing
|
|
75
|
+
# agent env file keeps launching exactly what it launched before. Anything that is neither is REFUSED by
|
|
76
|
+
# name: a supervisor that quietly fell back to Claude for an unrecognized value would come up healthy,
|
|
77
|
+
# keep a pane alive, and answer nothing the vendor expected.
|
|
78
|
+
AR_AGENT_HARNESS="${AR_AGENT_HARNESS:-claude}"
|
|
79
|
+
case "${AR_AGENT_HARNESS}" in
|
|
80
|
+
claude|codex) : ;;
|
|
81
|
+
*) die "AR_AGENT_HARNESS must be 'claude' or 'codex' (got '${AR_AGENT_HARNESS}')" ;;
|
|
82
|
+
esac
|
|
83
|
+
|
|
84
|
+
# What actually differs between the two CLIs, measured against `claude` and codex-cli 0.149.1:
|
|
85
|
+
# the binary, the resume argument, how connectors are confined, and the argv the pane can be found by.
|
|
86
|
+
# Everything else below — the env -i allowlist, the 0600 bearer file, the feeder, the bridge, the recovery
|
|
87
|
+
# loop — is shared, because nothing about it is harness-specific.
|
|
88
|
+
if [ "${AR_AGENT_HARNESS}" = "codex" ]; then
|
|
89
|
+
AGENT_BIN="${CODEX_BIN:-codex}"
|
|
90
|
+
# `codex resume --last` continues the most recent recorded session and exits non-zero when there is none,
|
|
91
|
+
# so the launch falls back to a fresh `codex` exactly as the Claude path falls back from `--continue`.
|
|
92
|
+
AR_RESUME_FLAGS="resume --last"
|
|
93
|
+
# 🔴 CODEX HAS NO `--strict-mcp-config`. Its MCP servers are read from $CODEX_HOME/config.toml, so
|
|
94
|
+
# CODEX_HOME *is* the connector-isolation boundary rather than a convenience, and it is REQUIRED. Point it
|
|
95
|
+
# at a per-agent home whose config.toml declares the AgentRadar MCP and nothing else. Failing closed here
|
|
96
|
+
# is the point: letting it default to the vendor's own ~/.codex would hand the client's agent every MCP
|
|
97
|
+
# server that vendor has ever configured — exactly the exposure `--strict-mcp-config` closes on the Claude
|
|
98
|
+
# path, and it would present as a working agent.
|
|
99
|
+
[ -n "${CODEX_HOME:-}" ] || die "AR_AGENT_HARNESS=codex requires CODEX_HOME — a per-agent Codex home whose config.toml declares ONLY the AgentRadar MCP server"
|
|
100
|
+
[ -d "${CODEX_HOME}" ] || die "CODEX_HOME not found: ${CODEX_HOME}"
|
|
101
|
+
CODEX_EXTRA_FLAGS="${CODEX_EXTRA_FLAGS:-}"
|
|
102
|
+
# shellcheck disable=SC2086 # CODEX_EXTRA_FLAGS is intentionally unquoted so it word-splits into flags.
|
|
103
|
+
_launch_flags=( ${CODEX_EXTRA_FLAGS} --cd "${AR_BASE}" )
|
|
104
|
+
# Pane-alive predicate — MUST be unique per agent. `--cd <AR_BASE>` is the per-agent argv token on this
|
|
105
|
+
# harness (CODEX_HOME is an env var, invisible to `ps -o command=`), so it plays the role the full
|
|
106
|
+
# mcp-config path plays for Claude: a pattern that cannot match another agent's live process.
|
|
107
|
+
_agent_pid_pattern_default="${AGENT_BIN}.*--cd ${AR_BASE}"
|
|
108
|
+
else
|
|
109
|
+
AGENT_BIN="${CLAUDE_BIN}"
|
|
110
|
+
AR_RESUME_FLAGS="--continue"
|
|
111
|
+
: "${MCP_CONFIG:?MCP_CONFIG required (path to awp-only.json)}"
|
|
112
|
+
# SC2086: CLAUDE_EXTRA_FLAGS is intentionally UNQUOTED so "--chrome" (etc.) word-splits into flags.
|
|
113
|
+
# shellcheck disable=SC2086
|
|
114
|
+
_launch_flags=( ${CLAUDE_EXTRA_FLAGS} --strict-mcp-config --mcp-config="${MCP_CONFIG}" )
|
|
115
|
+
# Pane-alive predicate — MUST be unique per agent. Default to the FULL mcp-config path (each agent's
|
|
116
|
+
# differs by directory), NOT the basename: on a multi-agent box every agent's config is named
|
|
117
|
+
# `awp-only.json`, so a basename match would find ANOTHER agent's live claude and mask THIS agent's
|
|
118
|
+
# death (silent death — the exact failure #145 kills). Override per agent for extra scoping if needed.
|
|
119
|
+
_agent_pid_pattern_default="mcp-config.*${MCP_CONFIG}"
|
|
120
|
+
fi
|
|
121
|
+
AGENT_PID_PATTERN="${AGENT_PID_PATTERN:-${_agent_pid_pattern_default}}"
|
|
76
122
|
|
|
77
123
|
# Optional per-agent TOOL credentials — a vendor brain's OWN MCP-tool API keys (e.g. an SEO agent's
|
|
78
124
|
# WEBFLOW_TOKEN / PERPLEXITY_API_KEY). These are ADDED to the scrubbed launch env so the agent's own
|
|
@@ -97,7 +143,7 @@ if [ -n "${AGENT_TOOL_ENV_FILE}" ]; then
|
|
|
97
143
|
# identity). Reject at parse time (visible warning) AND — belt-and-suspenders — the launch lists
|
|
98
144
|
# the reserved allowlist AFTER TOOL_ENV_ARGS so `env` last-wins keeps reserved vars authoritative.
|
|
99
145
|
case "${_tline%%=*}" in
|
|
100
|
-
HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN)
|
|
146
|
+
HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC|CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF|CODEX_HOME|AR_RESUME_FLAGS|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN)
|
|
101
147
|
log "WARN ignoring reserved key '${_tline%%=*}' in ${AGENT_TOOL_ENV_FILE} (cannot override supervisor/agent env)"; continue ;;
|
|
102
148
|
esac
|
|
103
149
|
TOOL_ENV_ARGS+=("${_tline}")
|
|
@@ -110,11 +156,18 @@ fi
|
|
|
110
156
|
# env -i below still scrubs everything else; strict MCP flags, not CLAUDE_CONFIG_DIR, isolate connectors.
|
|
111
157
|
unset CLAUDE_CONFIG_DIR CLAUDE_CODE_OAUTH_TOKEN
|
|
112
158
|
|
|
113
|
-
# HARD RULE — OAuth only, never a pay-as-you-go API key.
|
|
114
|
-
|
|
159
|
+
# HARD RULE — OAuth only, never a pay-as-you-go API key. Scoped to the Claude harness because that is where
|
|
160
|
+
# the variable decides anything: `claude` prefers ANTHROPIC_API_KEY over the subscription session, so its
|
|
161
|
+
# mere presence changes who pays. `codex` (>= 0.122) ignores OPENAI_API_KEY entirely and reads credentials
|
|
162
|
+
# only from $CODEX_HOME/auth.json, so there is no equivalent env var to refuse — a Codex vendor chooses
|
|
163
|
+
# subscription or API-key billing by how that home was logged in, and a refusal here would guard nothing
|
|
164
|
+
# while blocking boxes that carry the variable for unrelated work.
|
|
165
|
+
if [ "${AR_AGENT_HARNESS}" = "claude" ]; then
|
|
166
|
+
[ -z "${ANTHROPIC_API_KEY:-}" ] || die "ANTHROPIC_API_KEY is set — refuse to start (claude.ai OAuth only)"
|
|
167
|
+
fi
|
|
115
168
|
|
|
116
169
|
command -v "${TMUX_BIN}" >/dev/null 2>&1 || die "tmux not found (${TMUX_BIN})"
|
|
117
|
-
command -v "${
|
|
170
|
+
command -v "${AGENT_BIN}" >/dev/null 2>&1 || die "${AR_AGENT_HARNESS} CLI not found (${AGENT_BIN})"
|
|
118
171
|
[ -f "${FEEDER_JS}" ] || die "feeder not found: ${FEEDER_JS}"
|
|
119
172
|
[ -f "${PANE_LIVE_SH}" ] || die "pane liveness check not found: ${PANE_LIVE_SH}"
|
|
120
173
|
[ -d "${AR_BASE}" ] || die "AR_BASE dir not found: ${AR_BASE}"
|
|
@@ -210,17 +263,18 @@ ensure_agent() {
|
|
|
210
263
|
# The agent's own tool creds (from AGENT_TOOL_ENV_FILE) are NOT inlined here anymore (#166): they were staged to a 0600 file in 2b and are passed only as the PATH
|
|
211
264
|
# AR_TOOL_ENV_FILE, which the wrapper reads+exports before exec — so no tool VALUE appears on the env -i
|
|
212
265
|
# argv / tmux pane_start_command. Reserved keys were already rejected at tool-file parse time (2b).
|
|
213
|
-
log "starting agent in tmux '${TMUX_SESSION}' (
|
|
214
|
-
# SC2086: CLAUDE_EXTRA_FLAGS is intentionally UNQUOTED so "--chrome" (etc.) word-splits into flags.
|
|
266
|
+
log "starting agent (${AR_AGENT_HARNESS}) in tmux '${TMUX_SESSION}' (tool-env file: ${_TOOL_ENV_FILE:-none})"
|
|
215
267
|
# _tool_env uses the ${arr[@]+"${arr[@]}"} form so an EMPTY array is safe under `set -u` (bash 3.2).
|
|
216
268
|
# First-launch handling (#153/H4 durable-feeder): `claude --continue` EXITS 1 when the cwd has NO prior
|
|
217
269
|
# conversation (for example, a fresh engagement folder) → the pane dies → the supervisor
|
|
218
|
-
# crash-loops. So try
|
|
219
|
-
# session) when there's nothing to resume. Self-healing: once a conversation exists,
|
|
270
|
+
# crash-loops. So try the resume form, and FALL BACK to a fresh session (which stays up AND creates the
|
|
271
|
+
# session) when there's nothing to resume. Self-healing: once a conversation exists, resume picks it up;
|
|
220
272
|
# if it's ever lost, we cleanly start fresh instead of exit-looping. (--continue exits 1 on no-session,
|
|
221
|
-
# verified — so the `||` fallback is reliable
|
|
222
|
-
#
|
|
223
|
-
|
|
273
|
+
# verified — so the `||` fallback is reliable, and `codex resume --last` behaves the same way.)
|
|
274
|
+
# R004: the resume argument is the ONE harness-specific token inside the wrapper, so it arrives as
|
|
275
|
+
# AR_RESUME_FLAGS ("--continue" or "resume --last") and is word-split there — the same idiom this launch
|
|
276
|
+
# already uses for the extra flags. The wrapper copies it to a local and unsets it before exec so it does
|
|
277
|
+
# not linger in the agent's environment.
|
|
224
278
|
# #166: pass the tool creds as a FILE PATH (AR_TOOL_ENV_FILE), never inline KEY=VALUE on env -i.
|
|
225
279
|
_tool_env=()
|
|
226
280
|
[ -n "${_TOOL_ENV_FILE}" ] && _tool_env=( AR_TOOL_ENV_FILE="${_TOOL_ENV_FILE}" )
|
|
@@ -239,6 +293,10 @@ ensure_agent() {
|
|
|
239
293
|
# visible once there is colour to look at. LC_ALL stays empty on purpose: an empty LC_ALL does not
|
|
240
294
|
# override, so LC_CTYPE governs and a vendor can still set LANG per agent.
|
|
241
295
|
#
|
|
296
|
+
# Channels: CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF=1 keeps the tengu_harbor flag readable from the
|
|
297
|
+
# disk cache even if telemetry is off, and it and CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC are reserved so a
|
|
298
|
+
# tool file cannot turn channels off. See docs/claude-code-channels-gates.md.
|
|
299
|
+
#
|
|
242
300
|
# 🔴 This comment block lives ABOVE the command, not inside it. A `#` comment between two
|
|
243
301
|
# `\`-continued lines TERMINATES the command at its unescaped newline: tmux would receive only the
|
|
244
302
|
# args up to SHELL=…, the pane would start with no `claude` at all, and the `TERM=… claude` tail
|
|
@@ -251,9 +309,10 @@ ensure_agent() {
|
|
|
251
309
|
TERM="${AR_AGENT_TERM:-tmux-256color}" COLORTERM="${AR_AGENT_COLORTERM:-truecolor}" \
|
|
252
310
|
LANG="${LANG:-en_US.UTF-8}" LC_ALL="${LC_ALL:-}" LC_CTYPE="${AR_AGENT_LC_CTYPE:-en_US.UTF-8}" \
|
|
253
311
|
TMPDIR="${TMPDIR:-/tmp}" NODE_OPTIONS="${NODE_OPTIONS:-}" \
|
|
254
|
-
|
|
312
|
+
CODEX_HOME="${CODEX_HOME:-}" AR_RESUME_FLAGS="${AR_RESUME_FLAGS}" \
|
|
313
|
+
DISABLE_AUTOUPDATER=1 CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF=1 \
|
|
255
314
|
${_tool_env[@]+"${_tool_env[@]}"} \
|
|
256
|
-
/bin/sh -c 'if [ -n "${AR_TOOL_ENV_FILE:-}" ]; then while IFS= read -r _l || [ -n "$_l" ]; do case "$_l" in ""|\#*) continue ;; esac; _k=${_l%%=*}; _v=${_l#*=}; case "$_k" in HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN) continue ;; esac; export "$_k=$_v"; done < "$AR_TOOL_ENV_FILE"; unset AR_TOOL_ENV_FILE _l _k _v; fi; CB="$1"; shift; "$CB"
|
|
315
|
+
/bin/sh -c 'if [ -n "${AR_TOOL_ENV_FILE:-}" ]; then while IFS= read -r _l || [ -n "$_l" ]; do case "$_l" in ""|\#*) continue ;; esac; _k=${_l%%=*}; _v=${_l#*=}; case "$_k" in HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC|CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF|CODEX_HOME|AR_RESUME_FLAGS|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN) continue ;; esac; export "$_k=$_v"; done < "$AR_TOOL_ENV_FILE"; unset AR_TOOL_ENV_FILE _l _k _v; fi; RF="${AR_RESUME_FLAGS:-}"; unset AR_RESUME_FLAGS; CB="$1"; shift; "$CB" $RF "$@" || exec "$CB" "$@"' sh "${AGENT_BIN}" "${_launch_flags[@]}" || {
|
|
257
316
|
log "tmux could not start agent session '${TMUX_SESSION}'"
|
|
258
317
|
return 1
|
|
259
318
|
}
|
|
@@ -282,7 +341,8 @@ log "starting feeder (${FEEDER_JS}) → session '${TMUX_SESSION}'"
|
|
|
282
341
|
# #152: pass AWP_BEARER_FILE (a 0600 PATH), NEVER the raw AWP_BEARER — the feeder reads the file, so the
|
|
283
342
|
# bearer is not in the feeder process's environment (`ps eww`) or argv. AWP_MCP_URL is not a secret.
|
|
284
343
|
AWP_MCP_URL="${AWP_MCP_URL}" AWP_BEARER_FILE="${AWP_BEARER_FILE}" TMUX_SESSION="${TMUX_SESSION}" \
|
|
285
|
-
AGENT_PID_PATTERN="${AGENT_PID_PATTERN}"
|
|
344
|
+
AGENT_PID_PATTERN="${AGENT_PID_PATTERN}" AR_AGENT_HARNESS="${AR_AGENT_HARNESS}" AGENT_BIN="${AGENT_BIN}" \
|
|
345
|
+
node "${FEEDER_JS}" &
|
|
286
346
|
FEEDER_PID=$!
|
|
287
347
|
log "feeder pid ${FEEDER_PID}"
|
|
288
348
|
|
|
@@ -316,7 +376,7 @@ while true; do
|
|
|
316
376
|
exit 1
|
|
317
377
|
fi
|
|
318
378
|
if ! pane_alive; then
|
|
319
|
-
log "agent
|
|
379
|
+
log "agent pane died — recovering it in place"
|
|
320
380
|
if ensure_agent; then
|
|
321
381
|
if [ -n "${BRIDGE_PID}" ]; then
|
|
322
382
|
kill "${BRIDGE_PID}" 2>/dev/null || true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentradar-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Secure AgentRadar vendor setup and skill installer",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
"dist/index.js",
|
|
11
11
|
"dist/setup.js",
|
|
12
12
|
"dist/runner.js",
|
|
13
|
+
"dist/channel.js",
|
|
13
14
|
"dist/skill-install.js",
|
|
15
|
+
"dist/tool-run.js",
|
|
14
16
|
"dist/sync.js",
|
|
15
17
|
"dist/vendor-runner/**",
|
|
16
18
|
"dist/agentradar-vendor/SKILL.md",
|