agentradar-cli 0.1.9 → 0.2.1

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.
@@ -2,9 +2,11 @@
2
2
  // ===========================================================================
3
3
  // AgentRadar I2 feeder — the INTERACTIVE-HOST variant of the vendor runner.
4
4
  //
5
- // Unlike runner.mjs (which spawns a fresh `claude` per event, out-of-context), the
6
- // feeder pushes each drained client event as the NEXT TURN of a PERSISTENT, always-on
7
- // `claude` running in a tmux pane (real in-session continuity — I1). It:
5
+ // The feeder pushes each drained client event as the NEXT TURN of a PERSISTENT, always-on
6
+ // agent CLI running in a tmux pane (real in-session continuity — I1). R004: that CLI is
7
+ // Claude Code or Codex, selected by AR_AGENT_HARNESS; injection, acking and the
8
+ // in-progress hint are identical on both, and only the login probe, the idle-composer
9
+ // marker and the pane's argv pattern branch. It:
8
10
  // 1. phones home to the single AgentRadar AWP MCP and long-polls `next_event`;
9
11
  // 2. injects each event into the live pane via `tmux send-keys` (I2), pinning the
10
12
  // EXPLICIT conversationId so the agent's `reply` never guesses the target;
@@ -18,13 +20,13 @@
18
20
  // ===========================================================================
19
21
 
20
22
  import { spawn, spawnSync } from "node:child_process";
21
- import { createHash } from "node:crypto";
23
+ import { createHash, randomUUID } from "node:crypto";
22
24
  import fs from "node:fs";
23
25
  import http from "node:http";
24
26
  import https from "node:https";
25
- import { dirname, join } from "node:path";
27
+ import { dirname, isAbsolute, join, normalize, sep } from "node:path";
26
28
  import { fileURLToPath, pathToFileURL } from "node:url";
27
- import { formatInjectionFrame, formatProbeFrame, sendKeysCommands } from "./session-frame.mjs";
29
+ import { formatInjectionFrame, formatProbeFrame, formatWelcomeFrame, sendKeysCommands } from "./session-frame.mjs";
28
30
 
29
31
  const PANE_LIVE_SH = join(dirname(fileURLToPath(import.meta.url)), "pane-live.sh");
30
32
 
@@ -45,9 +47,24 @@ const AWP_BEARER = (() => {
45
47
  return process.env.AWP_BEARER ?? "";
46
48
  })();
47
49
  const TMUX_SESSION = process.env.TMUX_SESSION ?? "agent";
48
- // The claude pid marker (same predicate entrypoint.sh uses) — proves the pane is live.
49
- const AGENT_PID_PATTERN = process.env.AGENT_PID_PATTERN ?? "mcp-config.*awp-only";
50
- const CLAUDE_BIN = process.env.CLAUDE_BIN ?? "claude";
50
+ /**
51
+ * R004 — which CLI is in the pane. `claude` is the default, so a feeder started by an older supervisor (or
52
+ * by hand) behaves exactly as before. Only what genuinely differs reads this: how the CLI reports its login
53
+ * state, what its idle composer looks like, and the argv the pane can be found by. The in-progress hint and
54
+ * the volatile-line strip are NOT harness-specific — measured on codex-cli 0.149.1, Codex renders the same
55
+ * `esc to interrupt` working line Claude does (`• Working (1s • esc to interrupt)`), so the existing
56
+ * matchers already cover it and a speculative Codex spinner pattern would be invention, not coverage.
57
+ */
58
+ export const AR_AGENT_HARNESS = ["codex", "generic"].includes(process.env.AR_AGENT_HARNESS) ? process.env.AR_AGENT_HARNESS : "claude";
59
+ // A joined agent acts through `agentradar run <id> …` (its human's "always allow"), so frames name that command.
60
+ const AR_CLI_RUNNER_ID = process.env.AR_CLI_RUNNER_ID || undefined;
61
+ // The agent pid marker (same predicate entrypoint.sh uses) — proves the pane is live. Claude is found by its
62
+ // mcp-config argument; Codex takes its MCP servers from CODEX_HOME (an env var, invisible to `ps`), so its
63
+ // per-agent argv token is `--cd <working dir>`. The supervisor passes an exact pattern; this default only
64
+ // matters for a hand-started feeder.
65
+ const AGENT_PID_PATTERN =
66
+ process.env.AGENT_PID_PATTERN ?? (AR_AGENT_HARNESS === "codex" ? "codex.*--cd " : "mcp-config.*awp-only");
67
+ const AGENT_BIN = process.env.AGENT_BIN ?? process.env.CLAUDE_BIN ?? (AR_AGENT_HARNESS === "codex" ? "codex" : "claude");
51
68
  const AR_OAUTH_TOKEN_FILE = process.env.AR_OAUTH_TOKEN_FILE ?? "";
52
69
 
53
70
  const MCP_CALL_TIMEOUT_MS = Number(process.env.AWP_MCP_CALL_TIMEOUT_MS ?? "30000");
@@ -56,7 +73,7 @@ const MCP_CALL_TIMEOUT_MS = Number(process.env.AWP_MCP_CALL_TIMEOUT_MS ?? "30000
56
73
  // cycle ("fetch failed — re-establishing"). SEPARATE from the server's liveness cadence
57
74
  // (NEXT_EVENT_LONGPOLL_MS, pinned 240s) — see #171. Leave NEXT_EVENT_HOLD_MS unset for the 200s default.
58
75
  const NEXT_EVENT_HOLD_MS = Number(process.env.NEXT_EVENT_HOLD_MS ?? "200000");
59
- // Watchdog past the server hold so a dead socket aborts → self-heal (matches runner.mjs). At 200s the
76
+ // Watchdog past the server hold so a dead socket aborts → self-heal. At 200s the
60
77
  // watchdog is 230s, still under the ~240s ALB idle-timeout.
61
78
  const NEXT_EVENT_TIMEOUT_MS = NEXT_EVENT_HOLD_MS + 30000;
62
79
  const IDLE_DELAY_MS = Number(process.env.IDLE_DELAY_MS ?? "1000");
@@ -75,13 +92,25 @@ const REPORT_PANE_INTERVAL_MS = Number(process.env.REPORT_PANE_INTERVAL_MS ?? "4
75
92
  // any single failure well under the 45s interval.
76
93
  const PANE_MCP_TIMEOUT_MS = Number(process.env.PANE_MCP_TIMEOUT_MS ?? "10000");
77
94
 
95
+ // R650 — the agent's AgentRadar skills (bundled + linked) are written into its CLI's skills directory on this
96
+ // cadence. Only directories this feeder created (they carry MANAGED_MARK) are ever replaced or removed; a
97
+ // skill the vendor put there by hand is left alone. 0 disables.
98
+ /** Claude reads `$AR_BASE/.claude/skills`; Codex reads `$CODEX_HOME/skills`, the per-agent home the supervisor requires. */
99
+ export function skillsDirFor(harness, env) {
100
+ if (harness === "codex") return env.CODEX_HOME ? join(env.CODEX_HOME, "skills") : null;
101
+ return env.AR_BASE ? join(env.AR_BASE, ".claude", "skills") : null;
102
+ }
103
+ const SKILLS_DIR = skillsDirFor(AR_AGENT_HARNESS, process.env);
104
+ const SKILLS_SYNC_INTERVAL_MS = Number(process.env.SKILLS_SYNC_INTERVAL_MS ?? "600000");
105
+ const MANAGED_MARK = ".agentradar-managed";
106
+
78
107
  function sleep(ms) {
79
108
  return new Promise((r) => setTimeout(r, ms));
80
109
  }
81
110
 
82
111
  // --------------------------------------------------------------------------
83
112
  // Minimal MCP client over Streamable-HTTP (initialize + tools/call) — the proven
84
- // shape from runner.mjs, incl. the dead-socket watchdog + session-id handling.
113
+ // shape, incl. the dead-socket watchdog + session-id handling.
85
114
  // --------------------------------------------------------------------------
86
115
  let _sessionId = null;
87
116
  function parseMcpBody(text) {
@@ -195,8 +224,10 @@ function toolJson(result) {
195
224
  const text = result?.content?.find((c) => c.type === "text")?.text;
196
225
  return text ? JSON.parse(text) : {};
197
226
  }
227
+ const runnerSessionId = randomUUID();
198
228
  async function nextEvent(ackEventIds, runnerState) {
199
229
  const args = {
230
+ sessionId: runnerSessionId,
200
231
  ...(ackEventIds && ackEventIds.length ? { ackEventIds } : {}),
201
232
  ...(runnerState ? { runnerState } : {}),
202
233
  };
@@ -208,11 +239,29 @@ async function nextEvent(ackEventIds, runnerState) {
208
239
  // Work readiness — process liveness is not enough. A live Claude process can
209
240
  // be blocked on subscription OAuth, where injected work is consumed as the OAuth code.
210
241
  // --------------------------------------------------------------------------
211
- export function claudeAuthState(run = spawnSync, readFile = fs.readFileSync) {
242
+ /** The login probe runs with the pane's env: the allowlist plus the tool-env file (a model gateway's
243
+ * ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN, a Codex provider key). Without the file a gateway-authenticated
244
+ * pane reads "not logged in" and never receives work. */
245
+ export function authProbeEnv(extraNames = [], source = process.env, readFile = fs.readFileSync) {
212
246
  const env = {};
213
- for (const name of ["HOME", "PATH", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "CLAUDE_CONFIG_DIR"]) {
214
- if (typeof process.env[name] === "string") env[name] = process.env[name];
247
+ for (const name of ["HOME", "PATH", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", ...extraNames]) {
248
+ if (typeof source[name] === "string") env[name] = source[name];
249
+ }
250
+ if (source.AR_TOOL_ENV_FILE) {
251
+ let text = "";
252
+ try { text = readFile(source.AR_TOOL_ENV_FILE, "utf8"); } catch { /* staged by the supervisor; absent = none */ }
253
+ // Already validated and reserved-key-filtered by the supervisor; never let it replace the allowlist.
254
+ for (const line of text.split("\n")) {
255
+ const at = line.indexOf("=");
256
+ const key = line.slice(0, at);
257
+ if (at > 0 && !(key in env)) env[key] = line.slice(at + 1);
258
+ }
215
259
  }
260
+ return env;
261
+ }
262
+
263
+ export function claudeAuthState(run = spawnSync, readFile = fs.readFileSync) {
264
+ const env = authProbeEnv(["CLAUDE_CONFIG_DIR"]);
216
265
  if (AR_OAUTH_TOKEN_FILE) {
217
266
  try {
218
267
  env.CLAUDE_CODE_OAUTH_TOKEN = readFile(AR_OAUTH_TOKEN_FILE, "utf8").trim();
@@ -220,7 +269,7 @@ export function claudeAuthState(run = spawnSync, readFile = fs.readFileSync) {
220
269
  return "unknown";
221
270
  }
222
271
  }
223
- const r = run(CLAUDE_BIN, ["auth", "status", "--json"], { encoding: "utf8", env, timeout: 10000 });
272
+ const r = run(AGENT_BIN, ["auth", "status", "--json"], { encoding: "utf8", env, timeout: 10000 });
224
273
  if (typeof r.stdout !== "string") return "unknown";
225
274
  try {
226
275
  return JSON.parse(r.stdout).loggedIn === true ? "authenticated" : "unauthenticated";
@@ -229,7 +278,39 @@ export function claudeAuthState(run = spawnSync, readFile = fs.readFileSync) {
229
278
  }
230
279
  }
231
280
 
232
- export function runnerState(auth = claudeAuthState, sample = capturePane) {
281
+ /**
282
+ * Codex has no `auth status --json`. Its login probe is `codex login status`, which EXITS 0 when logged in
283
+ * and 1 when not, and prints "Logged in using ChatGPT" / "Not logged in" (measured, codex-cli 0.149.1).
284
+ *
285
+ * The exit code is the signal and the text is only a cross-check, not the other way round: a future build
286
+ * that reworded the line would still exit correctly, whereas reading the words alone would silently start
287
+ * returning "unknown" and park the runner in `starting` forever. A code we do not recognise stays "unknown"
288
+ * rather than becoming "unauthenticated", because `auth_required` is a claim the vendor is asked to act on.
289
+ *
290
+ * CODEX_HOME crosses into the probe because it IS the agent's credential home — probing the default
291
+ * ~/.codex would answer about a different login than the one the pane runs on.
292
+ */
293
+ export function codexAuthState(run = spawnSync, readFile = fs.readFileSync) {
294
+ // A custom model_provider (a gateway) authenticates with its own env_key; `codex login status` only
295
+ // knows the OpenAI login and says "Not logged in" for it forever (measured, 0.149.1).
296
+ let config = "";
297
+ try { config = readFile(join(process.env.CODEX_HOME ?? "", "config.toml"), "utf8"); } catch { /* no config */ }
298
+ const provider = /^model_provider\s*=\s*"([^"]+)"/m.exec(config.split(/^\[/m)[0])?.[1];
299
+ if (provider && provider !== "openai") return "authenticated";
300
+ const r = run(AGENT_BIN, ["login", "status"], { encoding: "utf8", env: authProbeEnv(["CODEX_HOME"]), timeout: 10000 });
301
+ if (r.status === 0) return "authenticated";
302
+ if (r.status === 1) return "unauthenticated";
303
+ return "unknown";
304
+ }
305
+
306
+ /** The login probe for whichever CLI this runner drives. */
307
+ export function agentAuthState() {
308
+ // A generic CLI has no login probe we know of; its pane's input marker is the readiness signal.
309
+ if (AR_AGENT_HARNESS === "generic") return "authenticated";
310
+ return AR_AGENT_HARNESS === "codex" ? codexAuthState() : claudeAuthState();
311
+ }
312
+
313
+ export function runnerState(auth = agentAuthState, sample = capturePane) {
233
314
  const authState = auth();
234
315
  if (authState === "unauthenticated") return "auth_required";
235
316
  if (authState !== "authenticated") return "starting";
@@ -246,6 +327,8 @@ export function paneIsLive(run = spawnSync) {
246
327
  env: {
247
328
  PATH: process.env.PATH,
248
329
  TMUX_BIN: process.env.TMUX_BIN ?? "tmux",
330
+ // The service's tmux server lives under TMUX_TMPDIR when the installer had one; without it tmux asks /tmp's.
331
+ ...(process.env.TMUX_TMPDIR ? { TMUX_TMPDIR: process.env.TMUX_TMPDIR } : {}),
249
332
  TMUX_SESSION,
250
333
  AGENT_PID_PATTERN,
251
334
  },
@@ -345,7 +428,7 @@ async function injectEvent(event) {
345
428
  return false;
346
429
  }
347
430
  const injected = await injectFrame(
348
- formatInjectionFrame(event),
431
+ formatInjectionFrame(event, AR_CLI_RUNNER_ID),
349
432
  `eventId=${event.eventId} (source=${event.source}, conversationId=${event.conversationId ?? "-"})`,
350
433
  );
351
434
  if (!injected) return false;
@@ -367,9 +450,18 @@ async function injectEvent(event) {
367
450
  * double-count or mis-ack a real event.
368
451
  * Best-effort: if the pane is dead we DON'T inject (the probe then times out server-side → the
369
452
  * act-signal treats genuine silence as act-dead, the correct verdict). */
370
- async function injectProbe(probe) {
453
+ // The server re-offers an outstanding probe on every poll and does not hold the poll while it is out, so an
454
+ // un-deduped feeder typed the same check into the pane about once a second. Once per nonce, re-typed only if
455
+ // it is still unanswered after PROBE_REINJECT_MS (the first copy may have been swallowed by a busy pane).
456
+ const PROBE_REINJECT_MS = Number(process.env.PROBE_REINJECT_MS ?? "180000");
457
+ let _lastProbe = { nonce: null, at: 0 };
458
+ export async function injectProbe(probe, welcome, inject = injectFrame, now = Date.now) {
371
459
  if (!probe || !probe.nonce || !probe.instruction) return;
372
- await injectFrame(formatProbeFrame(probe), `liveness-probe nonce=${probe.nonce}`);
460
+ if (probe.nonce === _lastProbe.nonce && now() - _lastProbe.at < PROBE_REINJECT_MS) return;
461
+ const frame = welcome?.next ? formatWelcomeFrame(welcome, probe) : formatProbeFrame(probe, AR_CLI_RUNNER_ID);
462
+ if (await inject(frame, `${welcome?.next ? "welcome + " : ""}liveness-probe nonce=${probe.nonce}`)) {
463
+ _lastProbe = { nonce: probe.nonce, at: now() };
464
+ }
373
465
  }
374
466
 
375
467
  // --------------------------------------------------------------------------
@@ -392,7 +484,11 @@ export function stripVolatilePaneLines(text) {
392
484
  return String(text ?? "")
393
485
  .split("\n")
394
486
  .filter((line) => {
395
- // the mid-turn interrupt hint (often carries the ticking counter)
487
+ // The mid-turn interrupt hint (often carries the ticking counter). This first matcher is also what
488
+ // covers CODEX: its whole working line is "• Working (1s • esc to interrupt)" (measured, codex-cli
489
+ // 0.149.1), counter included, so it is stripped entire and the Claude-specific spinner matchers below
490
+ // are simply inert on a Codex pane. No speculative Codex pattern is added here — an unmeasured
491
+ // matcher would look like coverage while stabilising nothing.
396
492
  if (/\besc to (?:interrupt|cancel)\b/i.test(line)) return false;
397
493
  // claude's spinner line, TWO observed forms — a leading animated glyph + a SINGLE gerund word +
398
494
  // the elapsed counter. Tightly anchored (one word before the counter) so real transcript prose
@@ -421,8 +517,42 @@ export function stripVolatilePaneLines(text) {
421
517
  .join("\n");
422
518
  }
423
519
 
424
- export function paneInputReady(text) {
425
- return /bypass permissions on\b|^\s*❯/im.test(String(text ?? "").split("\n").slice(-12).join("\n"));
520
+ /**
521
+ * The durable "this pane can take a turn" marker, which is the one pane signal that genuinely differs
522
+ * between the two CLIs. Claude Code shows its bypass-permissions footer or the `❯` prompt; Codex shows its
523
+ * idle composer placeholder `Ask Codex to do anything` (measured, codex-cli 0.149.1).
524
+ *
525
+ * ponytail: both markers vanish while a modal owns the screen — Claude's login prompt, Codex's trust or
526
+ * rate-limit dialog — which is the behaviour we want, since a pane blocked on a dialog cannot take work.
527
+ * Both also vanish once a human has typed into the composer without submitting; on an unattended runner
528
+ * nothing types there except `send-keys`, which submits immediately. Narrow the marker only if a real pane
529
+ * is observed to defeat it.
530
+ */
531
+ export function paneInputReady(text, harness = AR_AGENT_HARNESS, readyPattern = process.env.AR_INPUT_READY_PATTERN) {
532
+ // A fresh Claude pane draws its prompt near the top and leaves the rest of the screen blank.
533
+ // Only whole blank rows go; a generic marker like "^> $" keeps its trailing space.
534
+ const tail = String(text ?? "").replace(/\n\s*$/, "").split("\n").slice(-12).join("\n");
535
+ // generic: the env file may name the CLI's idle-composer marker; without one, a live pane counts as ready.
536
+ if (harness === "generic") return readyPattern ? new RegExp(readyPattern, "im").test(tail) : true;
537
+ return harness === "codex"
538
+ ? /Ask Codex to do anything/i.test(tail)
539
+ : /bypass permissions on\b|^\s*❯/im.test(tail);
540
+ }
541
+
542
+ /**
543
+ * Why a Claude Code session refused to register its channels, read off the banner it prints — or null.
544
+ * Keyed by the CLI's own skip `kind`. The `auth` banner comes from builds before 2.1.281, which has no auth
545
+ * gate. See docs/claude-code-channels-gates.md.
546
+ */
547
+ const CHANNEL_BANNERS = [
548
+ [/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"],
549
+ [/Channels require claude\.ai authentication/, "auth", "no claude.ai login: run /login in the session, then restart it"],
550
+ [/Channels are not enabled for your org/, "policy", "org managed settings block channels: an admin must set channelsEnabled: true"],
551
+ [/Channels are not available on (?:Bedrock|third-party)/, "provider", "a Bedrock/Vertex/Foundry provider is selected: channels need the first-party API"],
552
+ ];
553
+ export function classifyChannelBanner(text) {
554
+ const hit = CHANNEL_BANNERS.find(([re]) => re.test(String(text ?? "")));
555
+ return hit ? { kind: hit[1], action: hit[2] } : null;
426
556
  }
427
557
 
428
558
  function capturePane() {
@@ -440,14 +570,17 @@ function capturePane() {
440
570
  // Fingerprint the TRANSCRIPT BODY only — strip the client-side-ticking spinner/counter so a mid-turn
441
571
  // API hang (LLM dead, counter still ticking) yields a STABLE hash → stagnation is detectable (§4).
442
572
  const hash = createHash("sha256").update(stripVolatilePaneLines(text)).digest("hex").slice(0, 32);
443
- // claude's TUI shows a working line ("… esc to interrupt") while mid-turn. This is only a HINT that
444
- // triggers a probe on stagnation — it never decides liveness, so a missed/extra hint is harmless.
573
+ // Both TUIs show a working line ("… esc to interrupt") while mid-turn — Codex's reads
574
+ // "• Working (1s • esc to interrupt)", measured on codex-cli 0.149.1, so this matcher needs no harness
575
+ // branch. This is only a HINT that triggers a probe on stagnation — it never decides liveness, so a
576
+ // missed/extra hint is harmless.
445
577
  const inProgress = /esc to interrupt|\besc to cancel\b/i.test(text);
446
- // Claude's idle input prompt is the durable readiness signal. The optional bypass-permissions footer
447
- // disappeared from a live Claude build after several turns, which falsely demoted a healthy pane to
448
- // `starting` and stopped next_event from draining until its heartbeat aged stale.
578
+ // The CLI's idle input prompt is the durable readiness signal, and it is the one pane signal that differs
579
+ // per harness (see paneInputReady). The optional bypass-permissions footer disappeared from a live Claude
580
+ // build after several turns, which falsely demoted a healthy pane to `starting` and stopped next_event
581
+ // from draining until its heartbeat aged stale.
449
582
  const inputReady = paneInputReady(text);
450
- return { hash, inProgress, inputReady };
583
+ return { hash, inProgress, inputReady, channelGate: classifyChannelBanner(text) };
451
584
  }
452
585
  /**
453
586
  * #173b (disc obs) — format-drift detector. If the STRIPPED fingerprint keeps CHANGING while the pane
@@ -461,6 +594,7 @@ export function looksLikeStripMiss(prev, cur) {
461
594
  return Boolean(prev && cur && prev.inProgress && cur.inProgress && prev.hash !== cur.hash);
462
595
  }
463
596
  let _lastPaneSample = null;
597
+ let _lastChannelGate = null;
464
598
  let _paneReportCount = 0;
465
599
  async function reportPane() {
466
600
  // #156 diagnostics: log EVERY outcome (skip / ok+latency / error) under DEBUG_PANE with a monotonic
@@ -483,6 +617,10 @@ async function reportPane() {
483
617
  );
484
618
  }
485
619
  _lastPaneSample = p;
620
+ // ponytail: a hint, not a detector — the banner shows for ~12 s and this samples every 45 s, so most
621
+ // refusals pass unseen. The durable record is `--debug-file`'s "Channel notifications skipped:" line.
622
+ if (p.channelGate && p.channelGate.kind !== _lastChannelGate) console.warn(`[feeder] channels not registered (${p.channelGate.kind}): ${p.channelGate.action}`);
623
+ _lastChannelGate = p.channelGate?.kind ?? null;
486
624
  // #156: isolated connection (NOT the shared long-poll socket) so pane sampling never stalls behind
487
625
  // the receive long-poll's ALB-killed socket.
488
626
  await mcpPostIsolated("tools/call", { name: "report_pane", arguments: { paneHash: p.hash, inProgress: p.inProgress } });
@@ -497,6 +635,71 @@ async function reportPane() {
497
635
  }
498
636
  }
499
637
 
638
+ // --------------------------------------------------------------------------
639
+ // R650 skill sync. The server's answer is untrusted input to this box's filesystem: names and paths are
640
+ // checked before anything is written, and every write lands inside a fresh directory under skillsDir.
641
+ // --------------------------------------------------------------------------
642
+ const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,99}$/;
643
+ const isManaged = (dir) => {
644
+ const st = fs.lstatSync(dir, { throwIfNoEntry: false });
645
+ return Boolean(st?.isDirectory() && fs.existsSync(join(dir, MANAGED_MARK)));
646
+ };
647
+
648
+ export function writeManagedSkills(skillsDir, skills) {
649
+ fs.mkdirSync(skillsDir, { recursive: true });
650
+ const written = new Set();
651
+ for (const skill of skills) {
652
+ const name = String(skill?.name ?? "");
653
+ const target = join(skillsDir, name);
654
+ if (!SKILL_NAME.test(name)) { console.warn(`[feeder] skill sync: bad name ${JSON.stringify(name)} skipped`); continue; }
655
+ if (fs.lstatSync(target, { throwIfNoEntry: false }) && !isManaged(target)) {
656
+ console.warn(`[feeder] skill sync: ${name} is not ours, left alone`);
657
+ continue;
658
+ }
659
+ const files = Array.isArray(skill.files) ? skill.files : [];
660
+ const bad = files.find((f) => typeof f?.path !== "string" || typeof f?.content !== "string" || isAbsolute(f.path)
661
+ || normalize(f.path).split(sep).includes("..") || [".", MANAGED_MARK].includes(normalize(f.path)));
662
+ if (bad) { console.warn(`[feeder] skill sync: ${name} has an unsafe file path, skipped`); continue; }
663
+ const staging = join(skillsDir, `.${name}.sync-${process.pid}`);
664
+ try {
665
+ fs.rmSync(staging, { recursive: true, force: true });
666
+ for (const f of files) {
667
+ fs.mkdirSync(dirname(join(staging, f.path)), { recursive: true });
668
+ fs.writeFileSync(join(staging, f.path), f.content);
669
+ }
670
+ fs.writeFileSync(join(staging, MANAGED_MARK), "Written by the AgentRadar runner; replaced on every sync.\n");
671
+ fs.rmSync(target, { recursive: true, force: true });
672
+ fs.renameSync(staging, target);
673
+ written.add(name);
674
+ } catch (err) {
675
+ // One skill that cannot be written keeps its previous copy and does not stop the rest of the sync.
676
+ fs.rmSync(staging, { recursive: true, force: true });
677
+ if (isManaged(target)) written.add(name);
678
+ console.warn(`[feeder] skill sync: ${name} not written: ${err instanceof Error ? err.message : String(err)}`);
679
+ }
680
+ }
681
+ // An unlinked skill leaves: every directory we wrote earlier and the server no longer lists.
682
+ for (const entry of fs.readdirSync(skillsDir)) {
683
+ if (!written.has(entry) && isManaged(join(skillsDir, entry))) fs.rmSync(join(skillsDir, entry), { recursive: true, force: true });
684
+ }
685
+ return [...written];
686
+ }
687
+
688
+ async function syncSkills() {
689
+ try {
690
+ const answer = toolJson(await mcpPostIsolated("tools/call", { name: "my_skills", arguments: {} }));
691
+ // A refusal or an older server keeps whatever this box already has.
692
+ if (!answer.ok || !Array.isArray(answer.skills)) {
693
+ console.warn(`[feeder] skill sync: server answered ${answer.error ?? "without skills"}; keeping current copies`);
694
+ return;
695
+ }
696
+ const written = writeManagedSkills(SKILLS_DIR, answer.skills);
697
+ console.log(`[feeder] skill sync: ${written.length} skill(s) in ${SKILLS_DIR}`);
698
+ } catch (err) {
699
+ console.warn(`[feeder] skill sync failed: ${err instanceof Error ? err.message : String(err)}`);
700
+ }
701
+ }
702
+
500
703
  // --------------------------------------------------------------------------
501
704
  // The loop: initialize → poll next_event → inject each into the live pane → ack the
502
705
  // delivered ones on the NEXT poll → self-heal on transient MCP errors (runner shape).
@@ -526,7 +729,7 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
526
729
  // The server treats non-ready calls as status-only heartbeats: no drain and no ack. Keep any pending ack
527
730
  // until readiness returns, and defensively ignore events from an older server that does not know the gate.
528
731
  const canWork = state === "ready";
529
- const { events = [], livenessProbe } = await nextEvent(canWork && _pendingAck.length ? _pendingAck : undefined, state);
732
+ const { events = [], livenessProbe, welcome } = await nextEvent(canWork && _pendingAck.length ? _pendingAck : undefined, state);
530
733
  if (!canWork) {
531
734
  await sleep(IDLE_DELAY_MS);
532
735
  return;
@@ -538,7 +741,7 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
538
741
  // agent answers via liveness_ack(nonce), not an ackEventIds echo. THIS IS THE FENCE: injectProbe is
539
742
  // a separate path from the events→delivered→_pendingAck flow below, so a probe can never be acked
540
743
  // as a delivered event (no double-count / no mis-ack of a real event).
541
- if (livenessProbe) await injectProbe(livenessProbe);
744
+ if (livenessProbe) await injectProbe(livenessProbe, welcome);
542
745
  if (events.length === 0) {
543
746
  await sleep(IDLE_DELAY_MS);
544
747
  return;
@@ -566,6 +769,10 @@ async function loop() {
566
769
  _paneTimer = setInterval(() => { void reportPane(); }, REPORT_PANE_INTERVAL_MS);
567
770
  _paneTimer.unref?.();
568
771
  }
772
+ if (SKILLS_DIR && SKILLS_SYNC_INTERVAL_MS > 0) {
773
+ void syncSkills();
774
+ setInterval(() => { void syncSkills(); }, SKILLS_SYNC_INTERVAL_MS).unref?.();
775
+ }
569
776
  while (!_stopping) {
570
777
  try {
571
778
  await pollOnce();
@@ -24,9 +24,11 @@ function oneLine(s) {
24
24
  /**
25
25
  * Build the single-line injection frame for one drained next_event event.
26
26
  * event: { eventId, source, channel?, conversationId?, text }
27
+ * cliRunnerId: set when the session acts through `agentradar run <id> <tool>` (a joined agent) rather than an
28
+ * MCP connection — the command its human already chose "always allow" for, where an MCP tool would prompt.
27
29
  * Returns a one-line string safe to `tmux send-keys -l` then Enter.
28
30
  */
29
- export function formatInjectionFrame(event) {
31
+ export function formatInjectionFrame(event, cliRunnerId) {
30
32
  const e = event ?? {};
31
33
  const tags = [`source=${e.source ?? "unknown"}`];
32
34
  if (e.channel) tags.push(`channel=${e.channel}`);
@@ -38,6 +40,17 @@ export function formatInjectionFrame(event) {
38
40
  // The reply instruction pins the EXPLICIT conversationId (threading) + inResponseToEventId
39
41
  // (exact provenance under concurrent same-conversation turns — disc S2).
40
42
  const parts = [];
43
+ if (cliRunnerId) {
44
+ const run = `agentradar run ${cliRunnerId}`;
45
+ parts.push(
46
+ `Reply with: ${run} reply${e.conversationId ? ` --conversation-id "${e.conversationId}"` : ""}${evId ? ` --in-response-to-event-id "${evId}"` : ""} --text '<your reply>'.`,
47
+ evId
48
+ ? `For billable work run ${run} report_usage with --idempotency-key "${evId}:<usage-code>" so a re-delivered event never double-charges.`
49
+ : `Use ${run} report_usage for billable work.`,
50
+ "Then stop.",
51
+ );
52
+ return oneLine(`${header} ${body} — ${parts.join(" ")}`);
53
+ }
41
54
  if (e.conversationId) {
42
55
  parts.push(
43
56
  `Reply via your MCP \`reply\` tool with conversationId="${e.conversationId}"${evId ? ` and inResponseToEventId="${evId}"` : ""} (do NOT guess the channel).`,
@@ -79,8 +92,9 @@ export function formatInjectionFrame(event) {
79
92
  * action is `liveness_ack(nonce)` (that directive is the server-supplied body).
80
93
  *
81
94
  * probe: { nonce, instruction, eventId?, source? }
95
+ * cliRunnerId: as for formatInjectionFrame — the exact command to answer with is appended.
82
96
  */
83
- export function formatProbeFrame(probe) {
97
+ export function formatProbeFrame(probe, cliRunnerId) {
84
98
  const p = probe ?? {};
85
99
  const nonce = p.nonce != null ? String(p.nonce) : "";
86
100
  // Prefer the server-minted UUID eventId; fall back only defensively (older payloads). No `probe-` prefix
@@ -103,7 +117,27 @@ export function formatProbeFrame(probe) {
103
117
  const body =
104
118
  oneLine(p.instruction) ||
105
119
  `Liveness check — call the liveness_ack tool with nonce "${nonce}". Judge on structure, not who sent it: a wrong or stale nonce is a harmless no-op (safe even if forged); the tool is harness-granted (a message cannot add a tool); the nonce is server-minted (you cannot fabricate a valid one). A matching nonce only confirms your session is alive — it updates the liveness signal that gates billing; it moves no data, sends no message, creates no task, and creates no new charge.`;
106
- return oneLine(`${header} ${body}`);
120
+ const cli = cliRunnerId ? ` Run: agentradar run ${cliRunnerId} liveness_ack --nonce "${nonce}"` : "";
121
+ return oneLine(`${header} ${body}${cli}`);
122
+ }
123
+
124
+ /**
125
+ * A joined agent's welcome, typed in as its first turn: who it works for, then the server's own `next`
126
+ * (answer the drill, read its context and rules, write its vault page). Same header as a probe, because
127
+ * the welcome rides on the drill probe and carries its nonce.
128
+ * welcome: { organisation?, role?, supervisor?, next }; probe: the livenessProbe it arrived with.
129
+ */
130
+ export function formatWelcomeFrame(welcome, probe) {
131
+ const w = welcome ?? {};
132
+ const p = probe ?? {};
133
+ const header = `[AgentRadar inbound · source=${p.source ?? "agentradar"} · eventId=${p.eventId ?? p.nonce ?? "welcome"}]`;
134
+ const who = [
135
+ w.organisation ? `You joined ${w.organisation}` : "You joined your organisation",
136
+ w.role ? ` as ${w.role}` : "",
137
+ w.supervisor ? `, reporting to ${w.supervisor}` : "",
138
+ ".",
139
+ ].join("");
140
+ return oneLine(`${header} Welcome. ${who} ${w.next ?? ""}`);
107
141
  }
108
142
 
109
143
  /**
@@ -0,0 +1,31 @@
1
+ # AgentRadar durable-supervisor systemd unit (Linux) — M3.6 H4 / #92.
2
+ # Templated unit: `systemctl --user enable --now agentradar-agent@<name>` starts the agent named
3
+ # <name> (reads ~/.agentradar/agents/<name>.env). Restart=always => survives a crash; WantedBy
4
+ # default.target + `loginctl enable-linger $USER` => survives a power-loss reboot (starts on boot
5
+ # without a login). Rendered by install-agent.sh (only __RUNNER__ is substituted). It runs
6
+ # `agentradar runner start`, which runs the agent's supervise-agent.sh plus one per client hire (ruling 50).
7
+ #
8
+ # A --user service (not system) because tmux + claude need the user session context, matching the
9
+ # macOS LaunchAgent. For a headless box, run once: `loginctl enable-linger <user>`.
10
+ [Unit]
11
+ Description=AgentRadar durable agent supervisor (%i)
12
+ After=network-online.target
13
+ Wants=network-online.target
14
+ # Never give up: StartLimitIntervalSec=0 disables the flap limiter so systemd retries forever (matching
15
+ # launchd's KeepAlive+ThrottleInterval). A default limiter would enter `failed` after N flaps and STOP —
16
+ # a silent death until manual `reset-failed`, which is exactly what this template must never do.
17
+ StartLimitIntervalSec=0
18
+
19
+ [Service]
20
+ Type=simple
21
+ # The runner reads ~/.agentradar/agents/%i.env.
22
+ # systemd --user starts with a bare PATH; the agent CLI must resolve as it did for the installing user.
23
+ Environment="PATH=__PATH__"
24
+ ExecStart=__RUNNER__ %i
25
+ Restart=always
26
+ RestartSec=15
27
+ # Give the tmux pane + feeder time to come up before systemd considers it started.
28
+ TimeoutStartSec=90
29
+
30
+ [Install]
31
+ WantedBy=default.target
@@ -0,0 +1,62 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <!--
4
+ AgentRadar durable-supervisor LaunchAgent (macOS) — M3.6 H4 / #92.
5
+ Rendered per agent by install-agent.sh (placeholders __LIKE_THIS__ are substituted).
6
+ RunAtLoad + KeepAlive => survives a crash, and a power-loss reboot restarts it whenever
7
+ `agentradar runner start` exits (which it does when the agent's own supervise-agent.sh exits, i.e. when
8
+ either the claude pane or the feeder dies). The runner, not the supervisor, is what this keeps alive, so
9
+ each client hire (ruling 50) runs its own supervisor under it too.
10
+ ⚠️ REBOOT-SURVIVAL IS CONDITIONAL ON AUTO-LOGIN: this is a LaunchAgent (user session), NOT a
11
+ LaunchDaemon — tmux + `claude` (and `--chrome`) need the user's GUI context. A user LaunchAgent
12
+ only starts once a session is logged in, so an UNATTENDED power-loss reboot revives it ONLY if
13
+ auto-login is enabled for the account. Enable + verify auto-login (see MINI-CUTOVER-RUNBOOK.md
14
+ Part 1) — otherwise every LaunchAgent stays dormant after an unattended reboot (the 20h outage).
15
+ -->
16
+ <plist version="1.0">
17
+ <dict>
18
+ <key>Label</key>
19
+ <string>com.agentradar.agent.__AGENT_NAME__</string>
20
+
21
+ <key>ProgramArguments</key>
22
+ <array>
23
+ <string>__NODE__</string>
24
+ <string>__AGENTRADAR_JS__</string>
25
+ <string>runner</string>
26
+ <string>start</string>
27
+ <string>__AGENT_NAME__</string>
28
+ </array>
29
+
30
+ <!-- Start on login/boot. -->
31
+ <key>RunAtLoad</key>
32
+ <true/>
33
+
34
+ <!-- Restart whenever it exits (our script exits on any half dying). ThrottleInterval avoids a
35
+ tight respawn loop if it can't come up (e.g. bad token). -->
36
+ <key>KeepAlive</key>
37
+ <true/>
38
+ <key>ThrottleInterval</key>
39
+ <integer>15</integer>
40
+
41
+ <!-- launchd starts with a bare PATH; the agent CLI (often ~/.local/bin/claude) must resolve as it does
42
+ for the user who installed the service. TMUX_TMPDIR is written only when the installer had one. -->
43
+ <key>EnvironmentVariables</key>
44
+ <dict>
45
+ <key>HOME</key>
46
+ <string>__HOME__</string>
47
+ <key>PATH</key>
48
+ <string>__PATH__</string>__EXTRA_ENV__
49
+ </dict>
50
+
51
+ <key>WorkingDirectory</key>
52
+ <string>__AR_BASE__</string>
53
+
54
+ <key>StandardOutPath</key>
55
+ <string>__LOG_DIR__/__AGENT_NAME__.out.log</string>
56
+ <key>StandardErrorPath</key>
57
+ <string>__LOG_DIR__/__AGENT_NAME__.err.log</string>
58
+
59
+ <key>ProcessType</key>
60
+ <string>Interactive</string>
61
+ </dict>
62
+ </plist>