agentradar-cli 0.3.4 → 0.3.6

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/setup.js CHANGED
@@ -8,6 +8,7 @@ import { homedir } from "node:os";
8
8
  import * as tls from "node:tls";
9
9
  import { promisify } from "node:util";
10
10
  import { syncJoinedSkills } from "./skill-install.js";
11
+ import { cliHeader, noteCliUpdate } from "./update.js";
11
12
  const DEFAULT_API_URL = "https://vendor.agentradar.com/api";
12
13
  /** The service's CLI build (the CLI itself and the agent-guard plugin): `installCli` keeps it current, never the npx cache. */
13
14
  const cliDist = (home) => join(home, ".agentradar", "cli", "current", "dist");
@@ -28,11 +29,13 @@ const SUPERVISOR_HARNESS = {
28
29
  generic: "generic",
29
30
  };
30
31
  async function postJson(fetchImpl, url, body) {
31
- return fetchImpl(url, {
32
+ const response = await fetchImpl(url, {
32
33
  method: "POST",
33
- headers: { "content-type": "application/json", accept: "application/json", "cache-control": "no-store" },
34
+ headers: { ...(await cliHeader()), "content-type": "application/json", accept: "application/json", "cache-control": "no-store" },
34
35
  body: JSON.stringify(body),
35
36
  });
37
+ await noteCliUpdate(response.headers);
38
+ return response;
36
39
  }
37
40
  /**
38
41
  * The server's own words for a refusal, when it sent any.
@@ -117,7 +120,7 @@ export function trustedMcpEndpoint(value) {
117
120
  endpoint.hostname.endsWith(".localhost"));
118
121
  return endpoint.protocol === "https:" || loopbackHttp ? endpoint : null;
119
122
  }
120
- /** `installation: null` (join) removes the project's `agentradar` entry: a joined agent acts through `agentradar run`. */
123
+ /** AgentRadar is CLI-only: a project entry for `agentradar` is only ever removed (never written), and an agent acts through `agentradar run`. */
121
124
  async function installProjectMcp(configPath, cwd, installation, trust,
122
125
  /** Vadym 10342: a joined Claude Code agent's AgentRadar channel (`agentradar channel <runner-id>`, stdio). */
123
126
  channel) {
@@ -153,14 +156,6 @@ channel) {
153
156
  if (channel) {
154
157
  mcpServers.agentradar = channel;
155
158
  }
156
- else if (installation && endpoint) {
157
- mcpServers.agentradar = {
158
- type: "http",
159
- url: endpoint.toString(),
160
- headers: { Authorization: `Bearer ${installation.bearer}` },
161
- timeout: 1_800_000,
162
- };
163
- }
164
159
  else {
165
160
  delete mcpServers.agentradar;
166
161
  }
@@ -178,6 +173,68 @@ channel) {
178
173
  await chmod(configPath, 0o600);
179
174
  return projectPath;
180
175
  }
176
+ /** Drop the `[mcp_servers.agentradar]` table (and its sub-tables) when it is the old HTTP one (has a `url`). Null = nothing to drop. */
177
+ export function stripCodexAgentradar(toml) {
178
+ const lines = toml.split("\n");
179
+ const out = [];
180
+ let dropped = false;
181
+ for (let i = 0; i < lines.length; i++) {
182
+ if (!/^\s*\[\s*mcp_servers\s*\.\s*["']?agentradar["']?\s*[\].]/.test(lines[i])) {
183
+ out.push(lines[i]);
184
+ continue;
185
+ }
186
+ let end = i + 1;
187
+ while (end < lines.length && !/^\s*\[/.test(lines[end]))
188
+ end++;
189
+ // A following [mcp_servers.agentradar.http_headers] belongs to the same entry.
190
+ while (end < lines.length && /^\s*\[\s*mcp_servers\s*\.\s*["']?agentradar["']?\s*\./.test(lines[end])) {
191
+ end++;
192
+ while (end < lines.length && !/^\s*\[/.test(lines[end]))
193
+ end++;
194
+ }
195
+ if (/^\s*url\s*=/m.test(lines.slice(i, end).join("\n"))) {
196
+ dropped = true;
197
+ i = end - 1;
198
+ }
199
+ else
200
+ out.push(lines[i]);
201
+ }
202
+ return dropped ? out.join("\n") : null;
203
+ }
204
+ /** Upgrade and sync: remove the old AgentRadar HTTP MCP entry an older `agentradar setup` or join wrote: Claude's project entry, the project .mcp.json and ~/.codex/config.toml. Silent unless it removes one. */
205
+ export async function dropStaleMcp(cwd, home, log = console.log, env = process.env) {
206
+ const said = (where) => log(`Removed the old AgentRadar MCP entry from ${where}: AgentRadar is CLI-only now, use agentradar run <runner-id> <tool>.`);
207
+ const configPath = claudeConfigPath(home, env);
208
+ try {
209
+ const projectPath = await realpath(cwd);
210
+ const config = JSON.parse(await readFile(configPath, "utf8"));
211
+ if (config.projects?.[projectPath]?.mcpServers?.agentradar?.type === "http") {
212
+ await installProjectMcp(configPath, cwd, null, false);
213
+ said(configPath);
214
+ }
215
+ }
216
+ catch { /* no Claude config, or not a project it knows: nothing stale to remove */ }
217
+ const mcpJson = join(cwd, ".mcp.json");
218
+ try {
219
+ const parsed = JSON.parse(await readFile(mcpJson, "utf8"));
220
+ const entry = parsed.mcpServers?.agentradar;
221
+ if (entry && (entry.type === "http" || entry.url)) {
222
+ delete parsed.mcpServers.agentradar;
223
+ await writeFile(mcpJson, `${JSON.stringify(parsed, null, 2)}\n`);
224
+ said(mcpJson);
225
+ }
226
+ }
227
+ catch { /* no .mcp.json, or not JSON we can edit: leave it */ }
228
+ const codexToml = join(env.CODEX_HOME?.trim() || join(home, ".codex"), "config.toml");
229
+ try {
230
+ const stripped = stripCodexAgentradar(await readFile(codexToml, "utf8"));
231
+ if (stripped !== null) {
232
+ await writeFile(codexToml, stripped);
233
+ said(codexToml);
234
+ }
235
+ }
236
+ catch { /* no Codex config: nothing stale to remove */ }
237
+ }
181
238
  export function shellValue(value) {
182
239
  return `'${value.replaceAll("'", "'\\''")}'`;
183
240
  }
@@ -392,7 +449,7 @@ async function installRunner(home, projectPath, approved, service, feed) {
392
449
  const envPath = join(agentDir, `${approved.runnerId}.env`);
393
450
  const joined = [];
394
451
  if (service) {
395
- joined.push(`AR_CLI_RUNNER_ID=${shellValue(approved.runnerId)}`, `AR_AGENTRADAR_BIN=${shellValue(join(cliDist(home), "index.js"))}`);
452
+ joined.push(`AR_AGENTRADAR_BIN=${shellValue(join(cliDist(home), "index.js"))}`);
396
453
  let toolKeys = [];
397
454
  if (harness === "codex") {
398
455
  const { codexHome, keys } = await codexServiceHome(home, approved.runnerId, service.env);
@@ -425,6 +482,8 @@ async function installRunner(home, projectPath, approved, service, feed) {
425
482
  await writePrivateFile(settings, `${JSON.stringify({
426
483
  extraKnownMarketplaces: { agentradar: { source: { source: "directory", path: join(cliDist(home), "agent-guard") } } },
427
484
  enabledPlugins: { "agent-guard@agentradar": true },
485
+ // Vadym 12201: AskUserQuestion blocks the turn on a pick nobody at the pane makes, stalling every channel.
486
+ permissions: { deny: ["AskUserQuestion"] },
428
487
  // Nobody sits at the pane to accept bypass mode's warning screen; the operator's consent was given at approval.
429
488
  ...(service.bypass ? { skipDangerousModePermissionPrompt: true } : {}),
430
489
  }, null, 2)}\n`);
@@ -450,21 +509,15 @@ async function installRunner(home, projectPath, approved, service, feed) {
450
509
  // the service runs; the supervisor adds --dangerously-load-development-channels server:agentradar.
451
510
  await writePrivateFile(mcpConfig, `${JSON.stringify(feed === "push"
452
511
  ? { mcpServers: { agentradar: { type: "stdio", command: process.execPath, args: [join(cliDist(home), "index.js"), "channel", approved.runnerId] } } }
453
- : service ? { mcpServers: {} } : {
454
- mcpServers: {
455
- agentradar: {
456
- type: "http",
457
- url: endpoint.toString(),
458
- headers: { Authorization: `Bearer ${approved.installation.bearer}` },
459
- },
460
- },
461
- }, null, 2)}\n`);
512
+ : { mcpServers: {} }, null, 2)}\n`);
462
513
  await writePrivateFile(envPath, [
463
514
  `AGENT_NAME=${shellValue(approved.runnerId)}`,
464
515
  `AR_BASE=${shellValue(projectPath)}`,
465
516
  `AWP_MCP_URL=${shellValue(endpoint.toString())}`,
466
517
  `AWP_BEARER=${shellValue(approved.installation.bearer)}`,
467
518
  `MCP_CONFIG=${shellValue(mcpConfig)}`,
519
+ // Every convention line in the brief and skills says `agentradar run $AR_CLI_RUNNER_ID <tool>`.
520
+ `AR_CLI_RUNNER_ID=${shellValue(approved.runnerId)}`,
468
521
  // Written only when the vendor declared a runtime. An absent variable is the supervisor's own
469
522
  // default, so writing `AR_AGENT_HARNESS=claude` unconditionally would change nothing except to make
470
523
  // every existing env file look as though a choice had been made.
@@ -542,17 +595,17 @@ export async function setupVendorAgent(deps = {}) {
542
595
  const runnerEnv = await installRunner(home, projectPath, approved);
543
596
  // Print the path, not just "installed". D3 was a silent write to the wrong file under a
544
597
  // relocated CLAUDE_CONFIG_DIR; naming the target turns that class of failure into a glance.
545
- log(`AgentRadar MCP installed for this project → ${configPath}`);
598
+ log(`AgentRadar runner set up for this project (no MCP entry; the agent acts through agentradar run) → ${configPath}`);
546
599
  log(trust
547
600
  ? `--trust: pre-accepted the trust dialog for ${projectPath}. Claude Code will run this folder's existing hooks and settings without asking.`
548
- : `Claude Code will ask you to trust ${projectPath} on first launch — approve it there. Not pre-accepted, because that would trust everything already in the folder, not just this MCP. Re-run with --trust for unattended onboarding.`);
601
+ : `Claude Code will ask you to trust ${projectPath} on first launch — approve it there. Not pre-accepted, because that would trust everything already in the folder, not just AgentRadar. Re-run with --trust for unattended onboarding.`);
549
602
  log(`Runner installed → ${runnerEnv}`);
550
603
  if ((approved.harness ?? "").trim().toLowerCase() === "codex") {
551
604
  // Said BEFORE the vendor runs the runner, because the supervisor's refusal comes after they
552
605
  // think setup succeeded. Codex reads its MCP servers from $CODEX_HOME/config.toml — there is no
553
606
  // `--strict-mcp-config` to point at the file this setup just wrote — so the isolation boundary is
554
607
  // a per-agent home the vendor supplies, and the supervisor fails closed without it.
555
- log(`This agent runs on Codex. Set CODEX_HOME in ${runnerEnv} to a per-agent Codex home whose config.toml declares ONLY the AgentRadar MCP server; the runner refuses to start without it.`);
608
+ log(`This agent runs on Codex. Set CODEX_HOME in ${runnerEnv} to a per-agent Codex home whose config.toml declares no MCP servers (the agent acts through the CLI); the runner refuses to start without it.`);
556
609
  }
557
610
  log(`Start it with: agentradar runner start ${approved.runnerId}`);
558
611
  log("On macOS the runner uses your existing Claude Code Keychain-backed login. CLAUDE_CODE_OAUTH_TOKEN is ignored; ANTHROPIC_API_KEY is prohibited.");
@@ -1,9 +1,8 @@
1
- import { chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
1
+ import { chmod, lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
- import { basename, dirname, isAbsolute, join, normalize, sep } from "node:path";
3
+ import { basename, dirname, join } from "node:path";
4
4
  import { awpRequest, envValue } from "./tool-run.js";
5
5
  const MANAGED_MARK = ".agentradar-managed";
6
- const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,99}$/;
7
6
  async function ensureDirectory(path) {
8
7
  try {
9
8
  const info = await lstat(path);
@@ -44,76 +43,10 @@ export async function installVendorSkill(deps = {}) {
44
43
  await writeFile(join(skillDir, MANAGED_MARK), "Written by AgentRadar; replaced on every sync.\n");
45
44
  (deps.log ?? console.log)("AgentRadar vendor skill installed for this project.");
46
45
  }
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
- };
46
+ const runnerSkills = () => import(new URL(import.meta.url.endsWith(".ts")
47
+ ? "../../../templates/vendor-runner/feeder.mjs" : "./vendor-runner/feeder.mjs", import.meta.url).href);
68
48
  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];
49
+ return (await runnerSkills()).writeManagedSkills(skillsDir, skills);
117
50
  }
118
51
  /**
119
52
  * R650 — a joined agent's AgentRadar skills (`my_skills`: the bundled set plus the ones linked to it), written
@@ -160,11 +93,12 @@ export async function syncJoinedSkills(runnerId, deps) {
160
93
  // Same file and mark as the runner's feeder, which keeps it fresh from then on.
161
94
  const init = await awpRequest(runnerId, "initialize", {
162
95
  protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "agentradar-cli", version: "1.0.0" },
163
- }, { home: deps.home, fetch: deps.fetch, logError: deps.log });
96
+ }, { home: deps.home, fetch: deps.fetch, logError: deps.log, surface: "skill-sync" });
164
97
  const briefFile = codex ? join(root, "AGENTS.md") : join(root, ".claude", "rules", "agentradar-brief.md");
165
98
  if (root && init?.result?.instructions)
166
99
  await writeBrief(briefFile, init.result.instructions);
167
- const body = await awpRequest(runnerId, "tools/call", { name: "my_skills", arguments: {} }, { home: deps.home, fetch: deps.fetch, logError: deps.log });
100
+ const have = (await runnerSkills()).heldSkillZips(skillsDir);
101
+ const body = await awpRequest(runnerId, "tools/call", { name: "my_skills", arguments: { have } }, { home: deps.home, fetch: deps.fetch, logError: deps.log, surface: "skill-sync" });
168
102
  const text = body?.result?.content?.find((c) => c.type === "text")?.text;
169
103
  const answer = (text ? JSON.parse(text) : {});
170
104
  if (!answer.ok || !Array.isArray(answer.skills))
package/dist/tool-run.js CHANGED
@@ -2,7 +2,7 @@ import { readFile, realpath, stat, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { commandFor, DEPRECATED_TOOLS, toolFor } from "./commands.js";
5
- import { cliHeader, newer, runningVersion } from "./update.js";
5
+ import { cliHeader, noteCliUpdate, withCliUpdate } from "./update.js";
6
6
  /**
7
7
  * One `KEY='value'` line as `setup.ts` wrote it, single-quoted with `'` escaped as `'\''`.
8
8
  * Nothing here is printed: the file holds the runner's bearer.
@@ -335,31 +335,9 @@ export async function callAwpTool(runnerId, name, toolArguments, deps) {
335
335
  logError(said);
336
336
  return 1;
337
337
  }
338
- log(said);
339
- await cliVersionNotice(said, logError);
338
+ log(withCliUpdate(said));
340
339
  return 0;
341
340
  }
342
- /**
343
- * Vadym 9970: the server names the CLI it expects (`cli: { latest, min }`, on a successful get_my_context).
344
- * When this one is behind, the agent reads it in its own output (stderr, so --json stdout stays parseable).
345
- */
346
- async function cliVersionNotice(said, logError) {
347
- let cli;
348
- try {
349
- cli = JSON.parse(said).cli;
350
- }
351
- catch {
352
- return;
353
- }
354
- if (!cli || typeof cli.latest !== "string")
355
- return;
356
- const mine = await runningVersion().catch(() => null);
357
- if (!mine || !newer(cli.latest, mine))
358
- return;
359
- logError(typeof cli.min === "string" && newer(cli.min, mine)
360
- ? `agentradar ${mine} is below the minimum ${cli.min}: upgrade required. Run \`agentradar upgrade\`.`
361
- : `agentradar ${cli.latest} is available (you run ${mine}). Run \`agentradar upgrade\`.`);
362
- }
363
341
  const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
364
342
  /** One JSON-RPC request on the runner's AWP MCP endpoint with the runner's own key; null after logging why it failed. */
365
343
  export async function awpRequest(runnerId, method, params, deps) {
@@ -407,7 +385,7 @@ async function postAwp(url, bearer, method, params, deps) {
407
385
  const response = await (deps.fetch ?? fetch)(url, {
408
386
  method: "POST",
409
387
  headers: {
410
- ...(await cliHeader()),
388
+ ...(await cliHeader(deps.surface)),
411
389
  authorization: `Bearer ${bearer}`,
412
390
  "content-type": "application/json",
413
391
  // Both, because the endpoint answers JSON only when the client says it takes JSON.
@@ -420,6 +398,7 @@ async function postAwp(url, bearer, method, params, deps) {
420
398
  params,
421
399
  }),
422
400
  });
401
+ await noteCliUpdate(response.headers, logError);
423
402
  if (!response.ok) {
424
403
  // 426: this CLI is below the server's minimum; its sentence says how to upgrade.
425
404
  const said = response.status === 426 ? await response.json().then((body) => body.error?.message).catch(() => null) : null;
package/dist/update.js CHANGED
@@ -16,8 +16,33 @@ export const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
16
16
  const serviceCopy = (root) => basename(dirname(root)) === "cli" && basename(dirname(dirname(root))) === ".agentradar";
17
17
  /** The version this process runs, whatever `current` points at now. */
18
18
  export const runningVersion = async () => JSON.parse(await readFile(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
19
- /** Sent as `x-agentradar-cli` on every MCP call, so the server can refuse a CLI below its minimum (self-release card 2). */
20
- export const cliHeader = async () => ({ "x-agentradar-cli": await runningVersion().catch(() => "") });
19
+ /**
20
+ * Sent as `x-agentradar-cli` on every MCP call, so the server can refuse a CLI below its minimum (self-release card 2),
21
+ * and as `x-agentradar-surface` so it can count which client a call came from (MCPR-1).
22
+ */
23
+ export const cliHeader = async (surface = "cli") => ({ "x-agentradar-cli": await runningVersion().catch(() => ""), "x-agentradar-surface": surface });
24
+ let behind = null;
25
+ export const forgetCliUpdate = () => { behind = null; };
26
+ export async function noteCliUpdate(headers, logError = console.error) {
27
+ const latest = headers?.get("x-agentradar-cli-latest");
28
+ const current = latest && !behind ? await runningVersion().catch(() => "") : "";
29
+ if (!latest || !current || !newer(latest, current) || behind)
30
+ return;
31
+ behind = { current, latest, command: "agentradar upgrade" };
32
+ logError(`agentradar ${latest} is available (you run ${current}). Run \`agentradar upgrade\`.`);
33
+ }
34
+ /** A JSON-object output with `cliUpdate` added when this CLI is behind; anything else, or a current CLI, unchanged. */
35
+ export function withCliUpdate(text) {
36
+ if (!behind)
37
+ return text;
38
+ try {
39
+ const body = JSON.parse(text);
40
+ return body && typeof body === "object" && !Array.isArray(body) ? JSON.stringify({ ...body, cliUpdate: behind }, null, text.includes("\n") ? 2 : 0) : text;
41
+ }
42
+ catch {
43
+ return text;
44
+ }
45
+ }
21
46
  /** The version on disk: for a service's copy, the one `current` points at, which another runner may have moved. */
22
47
  export const installedVersion = async (root = PACKAGE_ROOT) => JSON.parse(await readFile(join(serviceCopy(root) ? join(dirname(root), "current") : root, "package.json"), "utf8")).version;
23
48
  export const newer = (a, b) => {
@@ -28,6 +28,7 @@ import { homedir } from "node:os";
28
28
  import { basename, dirname, isAbsolute, join, normalize, sep } from "node:path";
29
29
  import { fileURLToPath, pathToFileURL } from "node:url";
30
30
  import { formatInjectionFrame, formatProbeFrame, formatWelcomeFrame, sendKeysCommands } from "./session-frame.mjs";
31
+ import { unpackSkillZip } from "./skill-zip.mjs";
31
32
 
32
33
  const PANE_LIVE_SH = join(dirname(fileURLToPath(import.meta.url)), "pane-live.sh");
33
34
 
@@ -78,6 +79,11 @@ const NEXT_EVENT_HOLD_MS = Number(process.env.NEXT_EVENT_HOLD_MS ?? "200000");
78
79
  // watchdog is 230s, still under the ~240s ALB idle-timeout.
79
80
  const NEXT_EVENT_TIMEOUT_MS = NEXT_EVENT_HOLD_MS + 30000;
80
81
  const IDLE_DELAY_MS = Number(process.env.IDLE_DELAY_MS ?? "1000");
82
+ // A starting runner rechecks readiness this often inside its IDLE_DELAY_MS wait, so a cold start's work goes out
83
+ // as soon as the session is ready rather than up to a whole wait (plus a CLI login probe) later.
84
+ const READY_POLL_MS = Number(process.env.READY_POLL_MS ?? "200");
85
+ // A good login probe holds this long. The probe spawns the CLI; run every poll, it competed with the booting session.
86
+ const AUTH_RECHECK_MS = Number(process.env.AUTH_RECHECK_MS ?? "60000");
81
87
  const ERROR_DELAY_MS = Number(process.env.ERROR_DELAY_MS ?? "3000");
82
88
  // How long to wait for the pane to come back (mid-revive) before giving up this round.
83
89
  const PANE_WAIT_MS = Number(process.env.PANE_WAIT_MS ?? "15000");
@@ -166,6 +172,7 @@ async function mcpPost(method, params, { notification = false, timeoutMs = MCP_C
166
172
  "content-type": "application/json",
167
173
  accept: "application/json, text/event-stream",
168
174
  authorization: `Bearer ${AWP_BEARER}`,
175
+ "x-agentradar-surface": "feeder",
169
176
  };
170
177
  if (_sessionId) headers["mcp-session-id"] = _sessionId;
171
178
  const body = { jsonrpc: "2.0", method, params: params ?? {} };
@@ -217,6 +224,7 @@ function mcpPostIsolated(method, params, { timeoutMs = PANE_MCP_TIMEOUT_MS } = {
217
224
  "content-type": "application/json",
218
225
  accept: "application/json, text/event-stream",
219
226
  authorization: `Bearer ${AWP_BEARER}`,
227
+ "x-agentradar-surface": "feeder",
220
228
  "content-length": Buffer.byteLength(payload),
221
229
  },
222
230
  timeout: timeoutMs,
@@ -344,22 +352,49 @@ export function codexAuthState(run = spawnSync, readFile = fs.readFileSync) {
344
352
  }
345
353
 
346
354
  /** The login probe for whichever CLI this runner drives. */
347
- export function agentAuthState() {
355
+ function harnessAuthState() {
348
356
  // A generic CLI has no login probe we know of; its pane's input marker is the readiness signal.
349
357
  if (AR_AGENT_HARNESS === "generic") return "authenticated";
350
358
  return AR_AGENT_HARNESS === "codex" ? codexAuthState() : claudeAuthState();
351
359
  }
352
360
 
361
+ let _authOkUntil = 0;
362
+ /** True while a good login probe still holds. */
363
+ export const authHeld = (now = Date.now()) => now < _authOkUntil;
364
+ /** Only "authenticated" is held: a logged-out or unanswered probe is asked again on the next poll. `fresh` skips the hold. */
365
+ export function agentAuthState(fresh = false, now = Date.now(), probe = harnessAuthState) {
366
+ if (!fresh && authHeld(now)) return "authenticated";
367
+ const state = probe();
368
+ _authOkUntil = state === "authenticated" ? now + AUTH_RECHECK_MS : 0;
369
+ return state;
370
+ }
371
+
372
+ /** Waits up to `budgetMs`, ending early once the runner reads ready. True when it did. */
373
+ export async function waitForReady(readiness, budgetMs = IDLE_DELAY_MS, step = READY_POLL_MS, sleepFn = sleep) {
374
+ if (!(step > 0)) step = 200; // READY_POLL_MS of 0 or junk would spin
375
+ for (let waited = 0; waited < budgetMs; waited += step) {
376
+ await sleepFn(Math.min(step, budgetMs - waited));
377
+ if (readiness() === "ready") return true;
378
+ }
379
+ return false;
380
+ }
381
+
353
382
  /** AGENT-GUARD-ACTIVE: the supervisor sets AR_GUARD_MARKER when the session must load agent-guard, whose SessionStart hook writes it. */
354
383
  export const guardLoaded = (marker = process.env.AR_GUARD_MARKER) => !marker || fs.existsSync(marker);
355
384
 
356
385
  export function runnerState(auth = agentAuthState, sample = capturePane, guard = guardLoaded) {
386
+ const held = authHeld();
357
387
  const authState = auth();
358
388
  if (authState === "unauthenticated") return "auth_required";
359
389
  if (authState !== "authenticated" || !guard()) return "starting";
360
390
  const pane = sample();
361
391
  if (pane?.rateLimited) return "rate_limited";
362
- return pane?.inputReady ? "ready" : "starting";
392
+ if (!pane?.inputReady) return "starting";
393
+ if (!held) return "ready";
394
+ // A held probe may keep a pane starting, never make it ready: a logged-out Claude still draws ❯ (its login menu,
395
+ // or the composer after a token loss), and work typed there is acked and lost. Ask once more before "ready".
396
+ const fresh = auth(true);
397
+ return fresh === "authenticated" ? "ready" : fresh === "unauthenticated" ? "auth_required" : "starting";
363
398
  }
364
399
 
365
400
  /**
@@ -601,6 +636,15 @@ export function paneInputReady(text, harness = AR_AGENT_HARNESS, readyPattern =
601
636
  : /bypass permissions on\b|^\s*❯/im.test(tail);
602
637
  }
603
638
 
639
+ /**
640
+ * Vadym 12490: the permission mode Claude Code's footer names ("⏸ manual mode on"), or null when no footer shows.
641
+ * A hosted session must read "bypass permissions"; anything else is reported so the server can alert.
642
+ */
643
+ export function panePermissionMode(text) {
644
+ const tail = String(text ?? "").replace(/\n\s*$/, "").split("\n").slice(-12).join("\n");
645
+ return /\b(manual mode|accept edits|auto-accept edits|plan mode|bypass permissions) on\b/i.exec(tail)?.[1].toLowerCase() ?? null;
646
+ }
647
+
604
648
  /**
605
649
  * Why a Claude Code session refused to register its channels, read off the banner it prints — or null.
606
650
  * Keyed by the CLI's own skip `kind`. The `auth` banner comes from builds before 2.1.281, which has no auth
@@ -642,7 +686,7 @@ function capturePane() {
642
686
  // build after several turns, which falsely demoted a healthy pane to `starting` and stopped next_event
643
687
  // from draining until its heartbeat aged stale.
644
688
  const inputReady = paneInputReady(text);
645
- return { hash, inProgress, inputReady, rateLimited: paneRateLimited(text), channelGate: classifyChannelBanner(text) };
689
+ return { hash, inProgress, inputReady, rateLimited: paneRateLimited(text), channelGate: classifyChannelBanner(text), mode: panePermissionMode(text) };
646
690
  }
647
691
  /**
648
692
  * #173b (disc obs) — format-drift detector. If the STRIPPED fingerprint keeps CHANGING while the pane
@@ -685,7 +729,10 @@ async function reportPane() {
685
729
  _lastChannelGate = p.channelGate?.kind ?? null;
686
730
  // #156: isolated connection (NOT the shared long-poll socket) so pane sampling never stalls behind
687
731
  // the receive long-poll's ALB-killed socket.
688
- await mcpPostIsolated("tools/call", { name: "report_pane", arguments: { paneHash: p.hash, inProgress: p.inProgress } });
732
+ // Vadym 12490: a hosted session always runs in full auto; a vendor's own runner may choose its mode.
733
+ const offMode = process.env.AR_CLI_RUNNER_ID === "hosted" && p.mode && p.mode !== "bypass permissions" ? p.mode : null;
734
+ if (offMode) console.warn(`[feeder] hosted session in ${offMode}, not full auto`);
735
+ await mcpPostIsolated("tools/call", { name: "report_pane", arguments: { paneHash: p.hash, inProgress: p.inProgress, ...(offMode ? { permissionMode: offMode } : {}) } });
689
736
  if (process.env.DEBUG_PANE) {
690
737
  console.log(`[feeder] report_pane #${n}: OK hash=${p.hash.slice(0, 8)} inProgress=${p.inProgress} (${Date.now() - t0}ms)`);
691
738
  }
@@ -706,14 +753,49 @@ const isManaged = (dir) => {
706
753
  const st = fs.lstatSync(dir, { throwIfNoEntry: false });
707
754
  return Boolean(st?.isDirectory() && fs.existsSync(join(dir, MANAGED_MARK)));
708
755
  };
756
+ // Same bytes and the same mode for every file, and nothing else in the directory.
709
757
  const sameSkill = (dir, files) => {
710
758
  try {
711
759
  const have = fs.readdirSync(dir, { recursive: true }).filter((p) => p !== MANAGED_MARK && fs.statSync(join(dir, p)).isFile());
712
- return have.length === files.length && files.every((f) => fs.readFileSync(join(dir, f.path), "utf8") === f.content);
760
+ return have.length === files.length && files.every((f) => fs.readFileSync(join(dir, f.path)).equals(f.bytes)
761
+ && (fs.statSync(join(dir, f.path)).mode & 0o777) === f.mode);
713
762
  } catch {
714
763
  return false;
715
764
  }
716
765
  };
766
+ const markText = (sha) => `Written by the AgentRadar runner; replaced on every sync.\n${sha ? `sha256:${sha}\n` : ""}`;
767
+ const markedSha = (dir) => {
768
+ try {
769
+ return /^sha256:([0-9a-f]{64})$/m.exec(fs.readFileSync(join(dir, MANAGED_MARK), "utf8"))?.[1] ?? null;
770
+ } catch {
771
+ return null;
772
+ }
773
+ };
774
+
775
+ /** SKILL-ZIP-S3: what `my_skills` is told this box holds, so an unchanged zip is not sent again. */
776
+ export function heldSkillZips(skillsDir) {
777
+ const held = {};
778
+ for (const entry of fs.existsSync(skillsDir) ? fs.readdirSync(skillsDir) : []) {
779
+ const sha = SKILL_NAME.test(entry) && isManaged(join(skillsDir, entry)) ? markedSha(join(skillsDir, entry)) : null;
780
+ if (sha) held[entry] = sha;
781
+ }
782
+ return held;
783
+ }
784
+
785
+ // Each skill comes as a zip (its bytes checked against its sha256 and unpacked by skill-zip.mjs, exec bits kept), as a
786
+ // zip's sha256 alone when this box already holds it, or, from an older server, as files of text.
787
+ function skillFiles(skill, target) {
788
+ const zip = skill.zip;
789
+ if (zip && typeof zip === "object") {
790
+ if (typeof zip.base64 === "string") return { sha: zip.sha256, files: unpackSkillZip(Buffer.from(zip.base64, "base64"), zip.sha256) };
791
+ if (isManaged(target) && markedSha(target) === zip.sha256) return "held";
792
+ throw new Error("the server sent no bytes for a zip this box does not hold");
793
+ }
794
+ const files = Array.isArray(skill.files) ? skill.files : [];
795
+ if (files.some((f) => typeof f?.path !== "string" || typeof f?.content !== "string" || isAbsolute(f.path)
796
+ || normalize(f.path).split(sep).includes(".."))) return null;
797
+ return { sha: null, files: files.map((f) => ({ path: normalize(f.path), bytes: Buffer.from(f.content), mode: 0o644 })) };
798
+ }
717
799
 
718
800
  export function writeManagedSkills(skillsDir, skills) {
719
801
  fs.mkdirSync(skillsDir, { recursive: true });
@@ -726,22 +808,38 @@ export function writeManagedSkills(skillsDir, skills) {
726
808
  console.warn(`[feeder] skill sync: ${name} is not ours, left alone`);
727
809
  continue;
728
810
  }
729
- const files = Array.isArray(skill.files) ? skill.files : [];
730
- const bad = files.find((f) => typeof f?.path !== "string" || typeof f?.content !== "string" || isAbsolute(f.path)
731
- || normalize(f.path).split(sep).includes("..") || [".", MANAGED_MARK].includes(normalize(f.path)));
732
- if (bad) { console.warn(`[feeder] skill sync: ${name} has an unsafe file path, skipped`); continue; }
811
+ let got;
812
+ try {
813
+ got = skillFiles(skill, target);
814
+ } catch (err) {
815
+ // A zip that fails its check keeps the copy this box already has.
816
+ if (isManaged(target)) written.add(name);
817
+ console.warn(`[feeder] skill sync: ${name} not written: ${err instanceof Error ? err.message : String(err)}`);
818
+ continue;
819
+ }
820
+ if (got === "held") { written.add(name); continue; }
821
+ if (!got || got.files.some((f) => [".", MANAGED_MARK].includes(f.path))) {
822
+ console.warn(`[feeder] skill sync: ${name} has an unsafe file path, skipped`);
823
+ continue;
824
+ }
825
+ const { sha, files } = got;
733
826
  // An unchanged skill is not rewritten: the agent's CLI may be reading it (Codex skipped a SKILL.md that a
734
- // periodic resync had just removed).
735
- if (sameSkill(target, files)) { written.add(name); continue; }
827
+ // periodic resync had just removed). Only its mark is brought up to date.
828
+ if (sameSkill(target, files)) {
829
+ if (markedSha(target) !== sha) fs.writeFileSync(join(target, MANAGED_MARK), markText(sha));
830
+ written.add(name);
831
+ continue;
832
+ }
736
833
  const staging = join(skillsDir, `.${name}.sync-${process.pid}`);
737
834
  const old = join(skillsDir, `.${name}.old-${process.pid}`);
738
835
  try {
739
836
  fs.rmSync(staging, { recursive: true, force: true });
740
837
  for (const f of files) {
741
838
  fs.mkdirSync(dirname(join(staging, f.path)), { recursive: true });
742
- fs.writeFileSync(join(staging, f.path), f.content);
839
+ fs.writeFileSync(join(staging, f.path), f.bytes, { mode: f.mode });
840
+ fs.chmodSync(join(staging, f.path), f.mode); // the umask must not take the exec bit back
743
841
  }
744
- fs.writeFileSync(join(staging, MANAGED_MARK), "Written by the AgentRadar runner; replaced on every sync.\n");
842
+ fs.writeFileSync(join(staging, MANAGED_MARK), markText(sha));
745
843
  // Rename aside, then into place: the skill is missing for two renames, not for a recursive delete.
746
844
  fs.rmSync(old, { recursive: true, force: true });
747
845
  if (fs.existsSync(target)) fs.renameSync(target, old);
@@ -775,7 +873,7 @@ async function syncSkills() {
775
873
  console.warn(`[feeder] brief sync failed: ${err instanceof Error ? err.message : String(err)}`);
776
874
  }
777
875
  try {
778
- const answer = toolJson(await mcpPostIsolated("tools/call", { name: "my_skills", arguments: {} }));
876
+ const answer = toolJson(await mcpPostIsolated("tools/call", { name: "my_skills", arguments: { have: heldSkillZips(SKILLS_DIR) } }));
779
877
  // A refusal or an older server keeps whatever this box already has.
780
878
  if (!answer.ok || !Array.isArray(answer.skills)) {
781
879
  console.warn(`[feeder] skill sync: server answered ${answer.error ?? "without skills"}; keeping current copies`);
@@ -969,7 +1067,7 @@ let _lastRunnerState = null;
969
1067
 
970
1068
  /** One receive→inject→ack-select cycle (extracted from loop() + exported so the #173b _pendingAck
971
1069
  * fence is directly testable). Sets `_pendingAck` = the eventIds to ack on the NEXT poll. */
972
- export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, deliverEvent = injectEvent, drive = driveModelSwitch) {
1070
+ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, deliverEvent = injectEvent, drive = driveModelSwitch, wait = waitForReady) {
973
1071
  // next_event refreshes the portal's runner heartbeat. Never call it for a detached/orphan feeder: a
974
1072
  // process without its owned tmux session cannot receive work, so polling would paint a false Live state
975
1073
  // while every event stays unacknowledged. The supervisor recreates the session; until then the durable
@@ -995,7 +1093,10 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
995
1093
  const { events = [], livenessProbe, welcome, modelSwitch, firstStep } = await nextEvent(canWork && _pendingAck.length ? _pendingAck : undefined, state);
996
1094
  _nextSwitch = modelSwitch ?? null;
997
1095
  if (!canWork) {
998
- await sleep(IDLE_DELAY_MS);
1096
+ // Signed in but still booting: poll again the moment the pane is ready. Each check is a file stat and a pane
1097
+ // capture; the login probe stays held, so it does not spawn the CLI beside the booting session.
1098
+ if (state === "starting" && !_switching && authHeld()) await wait(readiness);
1099
+ else await sleep(IDLE_DELAY_MS);
999
1100
  return;
1000
1101
  }
1001
1102
  _pendingAck = [];