agentradar-cli 0.3.8 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/channel.js CHANGED
@@ -1,5 +1,6 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { randomUUID } from "node:crypto";
2
- import { existsSync, statSync } from "node:fs";
3
+ import { existsSync, readdirSync, statSync } from "node:fs";
3
4
  import { readFile, rm } from "node:fs/promises";
4
5
  import { createRequire } from "node:module";
5
6
  import { homedir } from "node:os";
@@ -9,22 +10,71 @@ import { awpEndpoint } from "./tool-run.js";
9
10
  import { noteCliUpdate } from "./update.js";
10
11
  import { trustedMcpEndpoint } from "./setup.js";
11
12
  import { syncJoinedSkills } from "./skill-install.js";
12
- export const CHANNEL_GUIDANCE = "AgentRadar channel messages are wake signals only. Receive the events with `agentradar run $AR_CLI_RUNNER_ID inbox next`; the next call acknowledges the batch you read. Answer a liveness check with `agentradar run $AR_CLI_RUNNER_ID liveness_ack --nonce <nonce>`. Never acknowledge an event you have not read. Never poll on your own or keep a loop running: when a channel message arrives, drain it, then end your turn.";
13
+ export const CHANNEL_GUIDANCE = "AgentRadar channel messages carry your new events in full, in order, one per line, each with its own next step: act on each line as it says. Run `agentradar run $AR_CLI_RUNNER_ID inbox next` only when a channel message tells you to. Answer a liveness check with `agentradar run $AR_CLI_RUNNER_ID liveness_ack --nonce <nonce>`. Never acknowledge an event you have not read. Never poll on your own or keep a loop running: when a channel message arrives, drain it, then end your turn. Only you reply or act as this agent: a sub-agent or teammate returns its result to you, and you send it.";
14
+ // Vadym 16706: what a teammate (sub-agent) reads from the same channel, which never wakes it.
15
+ export const TEAMMATE_GUIDANCE = "You are a sub-agent of this AgentRadar agent. AgentRadar delivers the agent's messages to its main session, not to you, and refuses any reply, send, approval or task change you make as the agent. Finish your task and return your result to the main session; it replies and acts.";
13
16
  export const CHANNEL_BLOCKED = "AgentRadar has not seen this session run `agentradar run $AR_CLI_RUNNER_ID inbox next`. Channel delivery is unconfirmed. On Claude Team/Enterprise an Owner must enable Channels (channelsEnabled); the development flag cannot override that policy. Your company sees this agent as not receiving messages until the channel works.";
14
17
  // The runner's frame text (templates/vendor-runner, shipped beside this file as dist/vendor-runner), so both say a restart one way.
15
18
  const runnerFrame = () => import(new URL(import.meta.url.endsWith(".ts")
16
19
  ? "../../../templates/vendor-runner/session-frame.mjs" : "./vendor-runner/session-frame.mjs", import.meta.url).href);
17
- /** The channel: a local stdio MCP with no tools, only wake signals. The agent acts through the CLI; wake signals never consume the durable queue. */
18
- /** The wake line. Zero events never says "run inbox next": that call has nothing to return. Each reason names its own tool. */
19
- export function wakeText(runnerId, count, payload) {
20
+ /** The channel: a local stdio MCP with no tools. It types each event into the wake as the feeder did; the agent acts through the CLI. */
21
+ /** The wake line with no events. Zero events never says "run inbox next": that call has nothing to return. Each reason names its own tool. */
22
+ export function wakeText(runnerId, payload) {
20
23
  const run = `agentradar run ${runnerId}`;
21
- if (count)
22
- return `${payload.firstStep ? `${payload.firstStep} ` : ""}${count} new AgentRadar events; run \`${run} inbox next\`${payload.livenessProbe ? " to receive your liveness check" : ""}.`;
23
24
  return [
24
25
  payload.onboarding && `AgentRadar session started, no messages waiting. Run \`${run} get_my_context\` now for your identity, peers and rules.`,
25
26
  payload.livenessProbe && `AgentRadar liveness check, no messages waiting. Run \`${run} liveness_ack --nonce ${payload.livenessProbe.nonce}\` now.`,
26
27
  ].filter(Boolean).join(" ");
27
28
  }
29
+ /**
30
+ * ACK-CHANNEL-EVENT-INLINE: the wake lists every event in order, each in the feeder's own frame (fenced sender text, the read
31
+ * step for a preview, the reply command), so the agent answers without first running `inbox next` (#2749 cost a turn).
32
+ * Claude Code wraps the content in a <channel> tag, so a sender cannot write one to close it early.
33
+ */
34
+ export function eventsText(frame, runnerId, events, payload) {
35
+ const lines = events.map((event, i) => frame.formatInjectionFrame(i === 0 && payload.firstStep ? { ...event, firstStep: payload.firstStep } : event, runnerId));
36
+ if (payload.livenessProbe)
37
+ lines.push(frame.formatProbeFrame(payload.livenessProbe, runnerId));
38
+ // Each frame ends "Then stop." (it was written for one event per wake): only the last line may stop the agent.
39
+ return lines.map((line, i) => i < lines.length - 1 ? line.replace(/ Then stop\.$/, " Then handle the next line.") : line).join("\n").replace(/<(\/?)channel/gi, "‹$1channel");
40
+ }
41
+ let ownHostIsTeammate;
42
+ let psFailed = false;
43
+ /**
44
+ * True when this channel runs under a Claude Code teammate (started with --agent-id). Claude Code gives every teammate
45
+ * pane the session's --mcp-config, so each teammate ran its own channel under a new sessionId: Hormozi's 2 teammates and
46
+ * his main session flipped the runner session on every poll and starved his queue for hours (10-23).
47
+ */
48
+ export function hostIsTeammate(pid) {
49
+ // This process's own host never changes and the walk is up to 8 blocking ps spawns: asked once, not on every request.
50
+ // A walk whose ps failed (a timeout under load) is not remembered, or a false would stick for the process's life.
51
+ if (pid === undefined) {
52
+ if (ownHostIsTeammate !== undefined)
53
+ return ownHostIsTeammate;
54
+ psFailed = false;
55
+ const own = hostIsTeammate(process.ppid);
56
+ if (!psFailed)
57
+ ownHostIsTeammate = own;
58
+ return own;
59
+ }
60
+ for (let hops = 0; pid > 1 && hops < 8; hops++) {
61
+ let row;
62
+ try {
63
+ row = execFileSync("ps", ["-o", "ppid=,args=", "-p", String(pid)], { encoding: "utf8", timeout: 5_000 }).trim();
64
+ }
65
+ catch {
66
+ psFailed = true;
67
+ return false;
68
+ }
69
+ const [, parent, args] = row.match(/^(\d+)\s+(.*)$/) ?? [];
70
+ // A shell's args hold the whole command text (`zsh -c 'agentradar permissions propose-tool --agent-id x'`), whose --agent-id is a CLI flag, not a teammate.
71
+ const words = args?.split(/\s+/) ?? [];
72
+ if (!/(^|\/)-?(?:ba|z|da|k|fi)?sh$/.test(words[0] ?? "") && words.some((arg) => arg.startsWith("--agent-id")))
73
+ return true;
74
+ pid = Number(parent);
75
+ }
76
+ return false;
77
+ }
28
78
  export async function startChannel(runnerId, deps = {}) {
29
79
  if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(runnerId))
30
80
  throw new Error("Invalid runner ID");
@@ -45,19 +95,26 @@ export async function startChannel(runnerId, deps = {}) {
45
95
  let polling = false;
46
96
  let agentPolled = false;
47
97
  const startedAt = Date.now();
48
- // The CLI rewrites <runner>.delivered.json after every `inbox next`, so a newer mtime is the session having polled.
98
+ // The CLI rewrites <runner>.delivered.json after every `inbox next` and touches it after every other call, so a newer
99
+ // mtime is the session having acted.
49
100
  const delivered = join(home, ".agentradar", "agents", `${runnerId}.delivered.json`);
50
- const polled = () => agentPolled ||= (() => { try {
51
- return statSync(delivered).mtimeMs >= startedAt;
101
+ const calledSince = (t) => { try {
102
+ return statSync(delivered).mtimeMs >= t;
52
103
  }
53
104
  catch {
54
105
  return false;
55
- } })();
106
+ } };
107
+ const polled = () => agentPolled ||= calledSince(startedAt);
56
108
  // Vadym 10342: an unconfirmed channel is reported to AgentRadar (runnerState channel_unconfirmed), so the owner sees
57
109
  // the agent is not receiving messages; "ready" again once the session does run `inbox next`.
58
110
  let unconfirmed = null;
59
111
  let warning;
60
112
  let skillSync;
113
+ let teammateNotices;
114
+ // Detached sync and notice passes: the channel drains them on stop, so none still writes into the home dir after it returns.
115
+ const pending = new Set();
116
+ const syncAbort = new AbortController(); // aborts a sync request still hung when the drain bound passes
117
+ const track = (work) => { pending.add(work); void work.finally(() => pending.delete(work)); };
61
118
  const send = (value) => new Promise((resolve, reject) => {
62
119
  output.write(`${JSON.stringify(value)}\n`, (error) => error ? reject(error) : resolve());
63
120
  });
@@ -83,9 +140,9 @@ export async function startChannel(runnerId, deps = {}) {
83
140
  const next = (args = {}) => remote("tools/call", { name: "next_event", arguments: { ...args, sessionId } });
84
141
  // AGENT-GUARD-ACTIVE: the supervisor's marker, written by agent-guard's SessionStart hook in this session.
85
142
  const guardLoaded = () => !process.env.AR_GUARD_MARKER || existsSync(process.env.AR_GUARD_MARKER);
86
- const pause = (until) => new Promise((resolve) => {
143
+ const pause = (ms, until) => new Promise((resolve) => {
87
144
  const done = () => { clearTimeout(timer); clearInterval(watch); stop.signal.removeEventListener("abort", done); resolve(); };
88
- const timer = setTimeout(done, deps.retryMs ?? 3_000);
145
+ const timer = setTimeout(done, ms);
89
146
  // COLD-START-WARMUP: a booting session's work is held until it polls ready, so poll the moment the marker lands.
90
147
  const watch = until && setInterval(() => { if (until())
91
148
  done(); }, 100);
@@ -98,6 +155,18 @@ export async function startChannel(runnerId, deps = {}) {
98
155
  let lastSent = 0;
99
156
  let selftestStarted = false;
100
157
  let guarded = true;
158
+ // Inlined events, acked once the session has made a call after the wake that carried them (the feeder acked on pane receipt).
159
+ let unacked = [];
160
+ // NEXT-EVENT-POLL-BACKOFF: a session that never acts got the same wake re-pushed every 30 s. Each re-push of the SAME
161
+ // unchanged set doubles that gap up to 5 min; a new event, a probe or a session call resets it. The 3 s poll stays, so a
162
+ // new event is pushed at the next poll; the server already parks served-unacked messages (redelivery.ts).
163
+ let resends = 0;
164
+ const resendMs = deps.resendMs ?? 30_000;
165
+ // NEXT-EVENT-POLL-BACKOFF: a failing poll (server down, key refused) retried every 3 s forever. Each consecutive failure
166
+ // doubles the pause up to 20x (60 s), jittered to 50-100% so runners cut off together do not return in step; any poll
167
+ // that gets through resets it.
168
+ const retryMs = deps.retryMs ?? 3_000;
169
+ let failures = 0;
101
170
  while (!stop.signal.aborted) {
102
171
  try {
103
172
  guarded = guardLoaded(); // first, so a call that throws after the marker lands still gets the full pause
@@ -118,46 +187,61 @@ export async function startChannel(runnerId, deps = {}) {
118
187
  if (unconfirmed === "reported" && polled())
119
188
  unconfirmed = null;
120
189
  // While reported, no runnerState: the stored state stands, and the poll still drains for wake signals.
121
- const response = await next(unconfirmed ? {} : { runnerState: guarded ? "ready" : "starting" });
190
+ // Receipt only: a preview still needs read_message before any reply (server gate). A "starting" poll cannot ack.
191
+ const ack = guarded && unacked.length && calledSince(lastSent) ? unacked : null;
192
+ const response = await next({ ...(unconfirmed ? {} : { runnerState: guarded ? "ready" : "starting" }), ...(ack ? { ackEventIds: ack } : {}) });
122
193
  if (response.error || response.result?.isError)
123
194
  throw new Error("AgentRadar refused next_event; check the runner key and its poll permission");
124
195
  const content = response.result?.content;
125
196
  const payload = JSON.parse(content?.find((item) => item.type === "text")?.text ?? "{}");
197
+ if (ack)
198
+ unacked = [];
126
199
  const events = payload.events ?? [];
127
200
  const wake = JSON.stringify([events.map((event) => event.eventId), payload.livenessProbe?.nonce, Boolean(payload.onboarding)]);
128
- if ((events.length || payload.livenessProbe || payload.onboarding) && (wake !== lastWake || Date.now() - lastSent >= 30_000)) {
129
- const restart = events.length ? (await runnerFrame()).restartLine(await readFile(notice, "utf8").then(JSON.parse, () => null), runnerId) : "";
201
+ const same = wake === lastWake;
202
+ if (!same || calledSince(lastSent))
203
+ resends = 0;
204
+ if ((events.length || payload.livenessProbe || payload.onboarding) && (!same || Date.now() - lastSent >= Math.min(resendMs * 2 ** resends, resendMs * 10))) {
205
+ const frame = await runnerFrame();
206
+ const restart = events.length ? frame.restartLine(await readFile(notice, "utf8").then(JSON.parse, () => null), runnerId) : "";
130
207
  await send({ jsonrpc: "2.0", method: "notifications/claude/channel", params: {
131
- content: `${restart ? `${restart} ` : ""}${wakeText(runnerId, events.length, payload)}`,
208
+ content: `${restart ? `${restart}\n` : ""}${events.length ? eventsText(frame, runnerId, events, payload) : wakeText(runnerId, payload)}`,
132
209
  meta: { count: String(events.length) },
133
210
  } });
134
211
  if (restart)
135
212
  await rm(notice, { force: true });
213
+ if (same)
214
+ resends += 1;
136
215
  lastWake = wake;
137
216
  lastSent = Date.now();
217
+ unacked = events.flatMap((event) => event.eventId ? [event.eventId] : []);
138
218
  }
139
- // Never ack a count-only wake: only the agent's tool call confirms receipt of event content.
219
+ failures = 0;
140
220
  }
141
221
  catch (error) {
222
+ failures += 1;
142
223
  const said = error.message;
143
224
  // Below the server's minimum CLI the retry cannot succeed: say how to upgrade instead.
144
225
  if (!stop.signal.aborted)
145
- logError(/below the minimum/.test(said) ? said : "AgentRadar channel poll failed; retrying. Check connectivity and the runner key.");
226
+ logError(/^upgrade required|below the minimum/.test(said) ? said : "AgentRadar channel poll failed; retrying. Check connectivity and the runner key.");
146
227
  }
147
- await pause(guarded ? undefined : guardLoaded);
228
+ await pause(failures ? Math.min(retryMs * 2 ** failures, retryMs * 20) * (0.5 + Math.random() / 2) : retryMs, guarded ? undefined : guardLoaded);
148
229
  }
149
230
  };
231
+ let teammate;
232
+ const isTeammate = () => teammate ??= deps.teammate ?? hostIsTeammate();
150
233
  const handle = async (message) => {
151
234
  if (message.method === "initialize") {
152
235
  await send({ jsonrpc: "2.0", id: message.id, result: {
153
236
  protocolVersion: "2024-11-05",
154
237
  capabilities: { experimental: { "claude/channel": {} } },
155
238
  serverInfo: { name: "agentradar", version: createRequire(import.meta.url)("../package.json").version },
156
- instructions: CHANNEL_GUIDANCE,
239
+ instructions: isTeammate() ? TEAMMATE_GUIDANCE : CHANNEL_GUIDANCE,
157
240
  } });
158
241
  }
159
242
  else if (message.method === "notifications/initialized") {
160
- if (!polling) {
243
+ // Only the main session polls, so one agent keeps one runner session; a teammate's channel stays connected and silent.
244
+ if (!polling && !isTeammate()) {
161
245
  polling = true;
162
246
  warning = setTimeout(() => { if (!polled()) {
163
247
  logError(CHANNEL_BLOCKED);
@@ -166,11 +250,29 @@ export async function startChannel(runnerId, deps = {}) {
166
250
  void poll();
167
251
  // Vadym 11038: a push-mode session runs no feeder, so the channel keeps its skills and brief current as the feeder
168
252
  // does: at connect and every 10 minutes, dropping skills no longer linked. stdout is the MCP stream; logs go to stderr.
169
- const sync = () => syncJoinedSkills(runnerId, { home, fetch: deps.fetch, log: logError })
253
+ const sync = () => syncJoinedSkills(runnerId, { home, fetch: deps.fetch, log: logError, signal: syncAbort.signal })
170
254
  .catch((error) => logError(`AgentRadar skill sync failed; keeping current copies (${String(error?.message ?? error).split("\n")[0]}).`));
171
- void sync();
172
- skillSync = setInterval(() => { void sync(); }, deps.skillsSyncMs ?? 600_000);
255
+ track(sync());
256
+ skillSync = setInterval(() => { track(sync()); }, deps.skillsSyncMs ?? 600_000);
173
257
  skillSync.unref?.();
258
+ // TEAMMATE-IDLE-STOP: the hosted bridge leaves teammate-stopped-<name>.txt for each idle teammate it stopped or teammate that ended; each is said once.
259
+ const run = join(home, ".agentradar", "run");
260
+ let noticing = false; // a slow pass is not started again before it has removed its file, or the notice is said twice
261
+ teammateNotices = setInterval(() => track((async () => {
262
+ if (noticing)
263
+ return;
264
+ noticing = true;
265
+ try {
266
+ for (const file of readdirSync(run).filter((name) => /^teammate-stopped-.+\.txt$/.test(name))) {
267
+ await send({ jsonrpc: "2.0", method: "notifications/claude/channel", params: { content: await readFile(join(run, file), "utf8"), meta: { count: "0" } } });
268
+ await rm(join(run, file), { force: true });
269
+ }
270
+ }
271
+ finally {
272
+ noticing = false;
273
+ }
274
+ })().catch(() => { })), deps.teammateNoticeMs ?? 5_000);
275
+ teammateNotices.unref?.();
174
276
  }
175
277
  }
176
278
  else if (message.id !== undefined) {
@@ -199,6 +301,10 @@ export async function startChannel(runnerId, deps = {}) {
199
301
  finally {
200
302
  clearTimeout(warning);
201
303
  clearInterval(skillSync);
304
+ clearInterval(teammateNotices);
305
+ // Bounded wait, so a hung fetch cannot hold the exit; then its request is aborted so it cannot write after we return.
306
+ await Promise.race([Promise.allSettled([...pending]), new Promise((resolve) => setTimeout(resolve, deps.drainMs ?? 2_000).unref())]);
307
+ syncAbort.abort();
202
308
  stop.abort();
203
309
  }
204
310
  }
package/dist/github.js ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Vadym 18123: an agent someone gave GitHub access uses plain `gh` and `git` as itself, with nothing to set up.
3
+ *
4
+ * setup writes the runner's `gh` shim and a git config naming this CLI as git's credential helper for the agent's
5
+ * GitHub host: the AgentRadar proxy host for a linked account (remotes of the client's orgs are rewritten to it;
6
+ * any other github.com remote stays plain git), else
7
+ * github.com for a GitHub App token. The supervisor runs it at launch and puts both on the agent's PATH and
8
+ * GIT_CONFIG; `gh` and `git` then sign in as the agent.
9
+ * credential git's credential helper: mints a fresh `github_token` for each request and hands it to git on its pipe.
10
+ * gh the shim's target: mints a token and runs the real `gh` with it in the environment. A command whose repositories
11
+ * all belong to someone else (a public repo, an open-source checkout, the agent's own account) is refused, with why,
12
+ * except `gh api`: the proxy serves GET on public repositories of any owner (GH-PROXY-PUBLIC-READS).
13
+ * A token is never written to disk or put on a command line. Without one, gh runs as it would have (the machine's login).
14
+ */
15
+ import { execFile, spawn } from "node:child_process";
16
+ import { constants } from "node:fs";
17
+ import { access, chmod, mkdir, realpath, rename, writeFile } from "node:fs/promises";
18
+ import { homedir } from "node:os";
19
+ import { delimiter, join } from "node:path";
20
+ import { awpRequest, RUNNER_ID } from "./tool-run.js";
21
+ export const GITHUB_USAGE = "Usage: agentradar github setup <runner-id> | agentradar github gh <runner-id> -- <gh args> | agentradar github credential <runner-id> get";
22
+ const HOST = /^[A-Za-z0-9.-]+(:\d+)?$/;
23
+ const OWNER = /^[A-Za-z0-9-]+$/;
24
+ export const githubDir = (runnerId, deps) => join(deps.home ?? homedir(), ".agentradar", "run", "github", runnerId);
25
+ const sq = (s) => `'${s.replace(/'/g, "'\\''")}'`;
26
+ /** One `github_token` call with the runner's own key. */
27
+ export async function mintGithub(runnerId, deps) {
28
+ const said = await awpRequest(runnerId, "tools/call", { name: "github_token", arguments: {} }, deps);
29
+ const text = said?.result?.content?.[0]?.text ?? "";
30
+ let body;
31
+ try {
32
+ body = JSON.parse(text);
33
+ }
34
+ catch {
35
+ return { error: text || "AgentRadar did not answer" };
36
+ }
37
+ if (said?.result?.isError || !body.ok || !body.token)
38
+ return { error: body.error ?? text };
39
+ if (body.host !== undefined && !HOST.test(body.host))
40
+ return { error: "AgentRadar returned an invalid GitHub host" };
41
+ const owners = Array.isArray(body.owners) ? body.owners.filter((o) => typeof o === "string" && OWNER.test(o)) : undefined;
42
+ return { token: body.token, host: body.host, owners, ...(typeof body.login === "string" && OWNER.test(body.login) ? { login: body.login } : {}) };
43
+ }
44
+ /** The include file the supervisor points GIT_CONFIG at; written whole and renamed, so git never reads half of it. */
45
+ async function writeGitconfig(runnerId, { host, owners }, bin, deps) {
46
+ const dir = githubDir(runnerId, deps);
47
+ const helper = `!${sq(bin)} github credential ${runnerId}`.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
48
+ // The empty helper drops the machine's own helpers (a keychain login) for this host, so git signs in as the agent.
49
+ const lines = [`[credential "https://${host ?? "github.com"}"]`, "\thelper =", `\thelper = "${helper}"`];
50
+ // Only the client's orgs go through the proxy; a public repo or the vendor's own stays plain git. An older AgentRadar
51
+ // that names no owners gets the old whole-github.com rewrite.
52
+ // ponytail: insteadOf is a case-sensitive prefix; a remote spelling the org in other capitals than GitHub's stays plain git.
53
+ if (host)
54
+ for (const owner of owners ? [...new Set(owners.flatMap((o) => [o, o.toLowerCase()]))].map((o) => `${o}/`) : [""]) {
55
+ lines.push(`[url "https://${host}/${owner}"]`, `\tinsteadOf = https://github.com/${owner}`, `\tinsteadOf = git@github.com:${owner}`, `\tinsteadOf = ssh://git@github.com/${owner}`);
56
+ }
57
+ await mkdir(dir, { recursive: true });
58
+ const tmp = join(dir, `gitconfig.${process.pid}`);
59
+ await writeFile(tmp, lines.join("\n") + "\n", { mode: 0o600 });
60
+ await rename(tmp, join(dir, "gitconfig"));
61
+ }
62
+ export async function githubSetup(runnerId, deps) {
63
+ const logError = deps.logError ?? console.error;
64
+ const bin = (deps.env ?? process.env).AR_AGENTRADAR_BIN;
65
+ if (!bin) {
66
+ logError("AR_AGENTRADAR_BIN is not set.");
67
+ return 1;
68
+ }
69
+ const shim = join(githubDir(runnerId, deps), "bin", "gh");
70
+ await mkdir(join(githubDir(runnerId, deps), "bin"), { recursive: true });
71
+ await writeFile(shim, `#!/bin/sh\nexec ${sq(bin)} github gh ${runnerId} -- "$@"\n`);
72
+ await chmod(shim, 0o755);
73
+ const minted = await mintGithub(runnerId, deps);
74
+ // A failed mint keeps the last config: no GitHub access yet, or a passing outage, is not a reason to drop it.
75
+ if ("error" in minted) {
76
+ logError(`No GitHub token for this agent: ${minted.error}`);
77
+ return 1;
78
+ }
79
+ await writeGitconfig(runnerId, minted, bin, deps);
80
+ return 0;
81
+ }
82
+ /** git's `credential.helper` protocol: answer `get` for the agent's own host, stay silent for any other. */
83
+ export async function githubCredential(runnerId, op, deps) {
84
+ if (op !== "get")
85
+ return 0;
86
+ const input = await (deps.stdin ?? (async () => { const chunks = []; for await (const chunk of process.stdin)
87
+ chunks.push(chunk); return Buffer.concat(chunks).toString("utf8"); }))();
88
+ const asked = Object.fromEntries(input.split("\n").filter((l) => l.includes("=")).map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)]));
89
+ const minted = await mintGithub(runnerId, deps);
90
+ if ("error" in minted) {
91
+ (deps.logError ?? console.error)(`agentradar: no GitHub token for this agent (${minted.error}).`);
92
+ return 0;
93
+ }
94
+ // A link or unlink since launch moves the host: rewrite the config so the next git call goes to the right one.
95
+ const bin = (deps.env ?? process.env).AR_AGENTRADAR_BIN;
96
+ if (bin)
97
+ await writeGitconfig(runnerId, minted, bin, deps).catch(() => { });
98
+ if (asked.protocol === "https" && asked.host === (minted.host ?? "github.com")) {
99
+ (deps.log ?? console.log)(`username=x-access-token\npassword=${minted.token}`);
100
+ }
101
+ return 0;
102
+ }
103
+ /** The real `gh` on PATH, skipping this runner's own shim however PATH spells it (a HOME ending in "/", a symlink). */
104
+ async function realGh(shim, env) {
105
+ const self = await realpath(shim).catch(() => shim);
106
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
107
+ if (!dir || !(await access(join(dir, "gh"), constants.X_OK).then(() => true, () => false)))
108
+ continue;
109
+ if (await realpath(join(dir, "gh")).catch(() => "") !== self)
110
+ return join(dir, "gh");
111
+ }
112
+ return null;
113
+ }
114
+ const IN_URL = /(?:^|[/@])github\.com[/:]([A-Za-z0-9-]+)(?=\/|$)/gim;
115
+ const ownerOf = (value = "") => value.match(new RegExp(IN_URL.source, "i"))?.[1] ?? value.match(/^([A-Za-z0-9-]+)\/[A-Za-z0-9._-]+$/)?.[1];
116
+ /** The repository owners a gh command names: -R/--repo, `gh repo <verb> OWNER/REPO`, github.com URLs, `gh api` repos|orgs|users paths. */
117
+ export function ghTargets(args) {
118
+ return args.flatMap((arg, i) => {
119
+ const named = arg === "-R" || arg === "--repo" ? ownerOf(args[i + 1]) : arg.startsWith("--repo=") ? ownerOf(arg.slice(7))
120
+ : /^-R./.test(arg) ? ownerOf(arg.slice(2)) : new RegExp(IN_URL.source, "i").test(arg) ? ownerOf(arg)
121
+ : args[0] === "api" ? arg.match(/^\/?(?:repos|orgs|users)\/([A-Za-z0-9-]+)/)?.[1]
122
+ : args[0] === "repo" && i === 2 && !arg.startsWith("-") ? ownerOf(arg) : undefined;
123
+ return named ? [named] : [];
124
+ });
125
+ }
126
+ /**
127
+ * The owner gh must not run for, or null for the proxy. Named owners decide; without any, the checkout's remotes do
128
+ * (a client's remote is rewritten to the proxy host). Nothing to go on (no repo, `gh api user`) stays on the proxy.
129
+ */
130
+ async function outsideOwner(args, host, owners, env, cwd) {
131
+ let targets = ghTargets(args);
132
+ if (!targets.length) {
133
+ const remotes = await new Promise((resolve) => execFile("git", ["remote", "-v"], { env, cwd, timeout: 5000 }, (_e, out) => resolve(String(out ?? ""))));
134
+ if (remotes.includes(`://${host}/`))
135
+ return null;
136
+ targets = [...remotes.matchAll(IN_URL)].map((m) => m[1]);
137
+ }
138
+ const client = new Set(owners.map((o) => o.toLowerCase()));
139
+ return targets.length && !targets.some((t) => client.has(t.toLowerCase())) ? targets[0] : null;
140
+ }
141
+ export async function githubGh(runnerId, args, deps) {
142
+ const logError = deps.logError ?? console.error;
143
+ const env = deps.env ?? process.env;
144
+ const gh = await realGh(join(githubDir(runnerId, deps), "bin", "gh"), env);
145
+ if (!gh) {
146
+ logError("agentradar: gh is not installed on this machine.");
147
+ return 127;
148
+ }
149
+ // Already inside the shim (gh running gh, or a PATH the check above still missed): the token is in the environment,
150
+ // so run gh as is and mint nothing. Without this a loop mints a token at every level.
151
+ if (env.AR_GH_SHIMMED === "1")
152
+ return await run(gh, args, env, deps.cwd);
153
+ const minted = await mintGithub(runnerId, deps);
154
+ const childEnv = { ...env, AR_GH_SHIMMED: "1" };
155
+ if ("error" in minted) {
156
+ logError(`agentradar: no GitHub token for this agent (${minted.error}); running gh with this machine's own login.`);
157
+ }
158
+ else {
159
+ if (env.AR_AGENTRADAR_BIN)
160
+ await writeGitconfig(runnerId, minted, env.AR_AGENTRADAR_BIN, deps).catch(() => { });
161
+ const outside = minted.host && minted.owners && args[0] !== "api" ? await outsideOwner(args, minted.host, minted.owners, env, deps.cwd) : null;
162
+ if (outside) {
163
+ // Not run at all: gh signed out refuses every command ("gh auth login", exit 4), and the agent's token reaches only
164
+ // the client's orgs. Anonymous git still clones public repositories.
165
+ const clients = minted.owners.join(", ");
166
+ const reply = "run `agentradar run $AR_CLI_RUNNER_ID reply` in Slack, or `agentradar run $AR_CLI_RUNNER_ID telegram_reply`, `agentradar run $AR_CLI_RUNNER_ID email_reply`, " +
167
+ "`agentradar run $AR_CLI_RUNNER_ID whatsapp_reply` or `agentradar run $AR_CLI_RUNNER_ID discord_reply` for the conversation you work in";
168
+ logError(outside.toLowerCase() === minted.login?.toLowerCase()
169
+ ? `agentradar: ${outside} is your own GitHub account, so gh did not run: repositories under your own account are read-only by design. ` +
170
+ `Read public ones with \`gh api repos/${outside}/<repo>\` or git clone; private ones there are not reachable. Your work belongs in your client's GitHub orgs (${clients}). ` +
171
+ `If you need a repository there, ask your client to create it: ${reply}.`
172
+ : `agentradar: gh works only on your client's GitHub orgs (${clients}), so it did not run for ${outside}. To read a public repository there, ` +
173
+ `use \`gh api repos/${outside}/<repo>\` (reads only) or git clone https://github.com/${outside}/<repo>. If ${outside} is your client's, ask your client to add it: ${reply}.`);
174
+ return 1;
175
+ }
176
+ if (minted.host)
177
+ Object.assign(childEnv, { GH_HOST: minted.host, GH_ENTERPRISE_TOKEN: minted.token });
178
+ else
179
+ childEnv.GH_TOKEN = minted.token;
180
+ }
181
+ return await run(gh, args, childEnv, deps.cwd);
182
+ }
183
+ const run = (bin, args, env, cwd) => new Promise((resolve) => {
184
+ const child = spawn(bin, args, { stdio: "inherit", env, cwd });
185
+ child.on("error", () => resolve(127));
186
+ child.on("exit", (code) => resolve(code ?? 1));
187
+ });
188
+ export async function githubOverCli(args, deps = {}) {
189
+ const [verb, runnerId, ...rest] = args;
190
+ if (!runnerId || !RUNNER_ID.test(runnerId)) {
191
+ (deps.logError ?? console.error)(GITHUB_USAGE);
192
+ return 2;
193
+ }
194
+ try {
195
+ if (verb === "setup" && !rest.length)
196
+ return await githubSetup(runnerId, deps);
197
+ if (verb === "credential" && rest.length === 1)
198
+ return await githubCredential(runnerId, rest[0], deps);
199
+ if (verb === "gh" && rest[0] === "--")
200
+ return await githubGh(runnerId, rest.slice(1), deps);
201
+ }
202
+ catch (error) {
203
+ (deps.logError ?? console.error)(error instanceof Error ? error.message : String(error));
204
+ return 1;
205
+ }
206
+ (deps.logError ?? console.error)(GITHUB_USAGE);
207
+ return 2;
208
+ }
209
+ //# sourceMappingURL=github.js.map
package/dist/index.js CHANGED
@@ -4,13 +4,15 @@ import { homedir } from "node:os";
4
4
  import { installVendorSkill } from "./skill-install.js";
5
5
  import { startChannel } from "./channel.js";
6
6
  import { runRestartSelftest, startVendorRunner } from "./runner.js";
7
- import { cancelJoin, CODEX_GUARD_REFUSAL, dropStaleMcp, ensureCodexGuard, joinOrganisation, leaveOrganisation, setupVendorAgent } from "./setup.js";
8
- import { callAwpTool, callToolOverCli, EXIT, parseCallArgs } from "./tool-run.js";
7
+ import { cancelJoin, CODEX_GUARD_REFUSAL, dropStaleMcp, ensureCodexGuard, joinOrganisation, joinRuntime, leaveOrganisation, setupVendorAgent } from "./setup.js";
8
+ import { callAwpTool, callToolOverCli, EXIT, parseCallArgs, schemaOverCli } from "./tool-run.js";
9
9
  import { installSyncTimer, syncAgentDir } from "./sync.js";
10
10
  import { importVaultOverCli } from "./vault-import.js";
11
11
  import { cloneVaultOverCli } from "./vault-clone.js";
12
+ import { ASSET_USAGE, getAssetOverCli } from "./asset-get.js";
12
13
  import { secretsOverCli } from "./secrets.js";
13
14
  import { adminOverCli } from "./admin.js";
15
+ import { githubOverCli } from "./github.js";
14
16
  // `run` is the one prefix every agent-facing command shares, so one harness "always allow agentradar run"
15
17
  // covers a whole session: `run <link>` (or `run join <link>`) joins, `run <runner-id> <tool>` calls a tool.
16
18
  export const runArgs = (raw) => raw[0] !== "run" ? raw
@@ -28,21 +30,24 @@ const USAGE = [
28
30
  " The runner is $AR_CLI_RUNNER_ID, or --runner <id>. --dry-run prints the exact call and sends nothing.",
29
31
  " Exit codes: 0 done; 1 refused or unreachable (the reason is on stderr); 2 the command was wrong.",
30
32
  "Local commands:",
31
- " agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--no-service | --service]",
33
+ " agentradar join <join-link> [--name <n>] [--model <m>] [--runtime claude|codex|other] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--no-service | --service]",
32
34
  " agentradar join cancel <join-link|request-id>",
33
35
  " agentradar setup [--trust] [--harness claude|codex]",
34
36
  " agentradar leave <runner-id>",
35
37
  " agentradar channel <runner-id>",
36
38
  " agentradar selftest <runner-id> [--status|--restart]",
37
39
  " agentradar runner start <runner-id>",
38
- " agentradar call <runner-id> <tool | command> [--<flag> <value> ...] [--args '<json>']",
40
+ " agentradar call <runner-id> <tool | command> [--<flag> <value> ...] [--input <json|file|->]",
39
41
  " agentradar vault import <runner-id> <dir> [--vault <vault-id>] [--source <name>] [--write]",
40
42
  " agentradar vault clone <runner-id> <vault-id> [dir]",
43
+ " agentradar asset get <runner-id> <asset-id> [file]",
41
44
  " agentradar secrets pull|renew|env <runner-id> | agentradar secrets exec <runner-id> -- <command>",
42
45
  " agentradar secrets upload|rotate <runner-id> --env-file <path> | --from-env <NAME,...> | --prompt <NAME> [--replace]",
46
+ " agentradar github setup <runner-id> | agentradar github gh <runner-id> -- <gh args> | agentradar github credential <runner-id> get",
43
47
  " agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]",
44
48
  " agentradar admin broadcast --dry-run|--send --target all --text <text> --cta <next step>",
45
- " agentradar upgrade | agentradar --version",
49
+ " agentradar admin notice --dry-run|--send --agent <id> --ref <key> --file <text file> --cta <next step> [--wake]",
50
+ " agentradar schema <command> | agentradar upgrade | agentradar version [--json]",
46
51
  ].join("\n");
47
52
  async function main() {
48
53
  const args = runArgs(process.argv.slice(2));
@@ -78,8 +83,9 @@ async function main() {
78
83
  process.exitCode = await leaveOrganisation(args[1]);
79
84
  return;
80
85
  }
81
- if (args.length === 1 && (args[0] === "--version" || args[0] === "-v")) {
82
- console.log(createRequire(import.meta.url)("../package.json").version);
86
+ if ((args.length === 1 && (args[0] === "--version" || args[0] === "-v")) || (args[0] === "version" && (args.length === 1 || (args.length === 2 && args[1] === "--json")))) {
87
+ const version = createRequire(import.meta.url)("../package.json").version;
88
+ console.log(args[1] === "--json" ? JSON.stringify({ version }) : version);
83
89
  return;
84
90
  }
85
91
  if (args[0] === "channel" && args.length === 2) {
@@ -124,18 +130,25 @@ async function main() {
124
130
  const named = {};
125
131
  const urls = [];
126
132
  for (let i = 1; i < args.length; i++) {
127
- if (["--name", "--model", "--attach-tmux", "--session"].includes(args[i]))
133
+ if (["--name", "--model", "--attach-tmux", "--session", "--runtime"].includes(args[i]))
128
134
  named[args[i].slice(2)] = args[++i] ?? "-";
129
135
  else if (!["--trust", "--attach-current", "--allow-bypass", "--revoke-bypass", "--feeder", "--service", "--no-service"].includes(args[i]))
130
136
  urls.push(args[i]);
131
137
  }
132
138
  if (urls.length !== 1 || [urls[0], ...Object.values(named)].some((value) => value.startsWith("-")) || (named["attach-tmux"] && args.includes("--attach-current")) || (args.includes("--service") && args.includes("--no-service"))) {
133
- console.error("Usage: agentradar join <join-link> [--name <your name>] [--model <your model id>] [--trust] [--attach-tmux <session[:window.pane]> | --attach-current (Codex and other CLIs)] [--session <claude session id>] [--revoke-bypass] [--no-service | --service]");
139
+ console.error("Usage: agentradar join <join-link> [--name <your name>] [--model <your model id>] [--runtime <claude|codex|other>] [--trust] [--attach-tmux <session[:window.pane]> | --attach-current (Codex and other CLIs)] [--session <claude session id>] [--revoke-bypass] [--no-service | --service]");
140
+ process.exitCode = 1;
141
+ return;
142
+ }
143
+ // --revoke-bypass only edits what an earlier join wrote, so it needs no runtime.
144
+ const runtime = args.includes("--revoke-bypass") ? { harness: undefined } : joinRuntime(named.runtime, process.env);
145
+ if ("refusal" in runtime) {
146
+ console.error(runtime.refusal);
134
147
  process.exitCode = 1;
135
148
  return;
136
149
  }
137
150
  process.exitCode = await joinOrganisation(urls[0], {
138
- trust: args.includes("--trust"), agentName: named.name, model: named.model, session: named.session,
151
+ harness: runtime.harness, trust: args.includes("--trust"), agentName: named.name, model: named.model, session: named.session,
139
152
  attach: args.includes("--attach-current") ? "current" : named["attach-tmux"],
140
153
  revokeBypass: args.includes("--revoke-bypass"), feeder: args.includes("--feeder"), service: args.includes("--service"),
141
154
  });
@@ -155,7 +168,7 @@ async function main() {
155
168
  const parsed = parseCallArgs(args.slice(1));
156
169
  const [runnerId, ...words] = parsed?.words ?? [];
157
170
  if (!parsed || !runnerId || words.length > 3 || (!words.length && !parsed.flags.some(([flag]) => flag === "help"))) {
158
- console.error("Usage: agentradar call <runner-id> <tool | command> [--<flag> <value> ...] [--args '<json>'] | agentradar call <runner-id> [<command>] --help");
171
+ console.error("Usage: agentradar call <runner-id> <tool | command> [--<flag> <value> ...] [--input <json|file|->] | agentradar call <runner-id> [<command>] --help");
159
172
  process.exitCode = EXIT.usage;
160
173
  return;
161
174
  }
@@ -173,6 +186,15 @@ async function main() {
173
186
  process.exitCode = await importVaultOverCli(runnerId, dir, { vaultId: flag("vault"), source: flag("source"), write: rest.includes("--write") });
174
187
  return;
175
188
  }
189
+ if (args[0] === "asset" && args[1] === "get") {
190
+ if (args.length < 4 || args.length > 5) {
191
+ console.error(ASSET_USAGE);
192
+ process.exitCode = 2;
193
+ return;
194
+ }
195
+ process.exitCode = await getAssetOverCli(args[2], args[3], args[4]);
196
+ return;
197
+ }
176
198
  if (args[0] === "vault" && args[1] === "clone") {
177
199
  if (args.length < 4 || args.length > 5) {
178
200
  console.error("Usage: agentradar vault clone <runner-id> <vault-id> [dir]");
@@ -187,6 +209,11 @@ async function main() {
187
209
  process.exitCode = await secretsOverCli(args.slice(1));
188
210
  return;
189
211
  }
212
+ // Vadym 18123: the supervisor's launch-time setup, the `gh` shim and git's credential helper; `github <server command>` stays the server's.
213
+ if (args[0] === "github" && ["setup", "credential", "gh"].includes(args[1] ?? "") && args[2] && !args[2].startsWith("-")) {
214
+ process.exitCode = await githubOverCli(args.slice(1));
215
+ return;
216
+ }
190
217
  if (args[0] === "admin") {
191
218
  process.exitCode = await adminOverCli(args.slice(1));
192
219
  return;
@@ -216,6 +243,10 @@ async function main() {
216
243
  // #1871: every other command is the server's, `<noun> [<sub-noun>] <verb>`, run as this shell's runner.
217
244
  const parsed = parseCallArgs(args);
218
245
  const runner = parsed?.flags.find(([flag]) => flag === "runner")?.[1] ?? process.env.AR_CLI_RUNNER_ID;
246
+ if (parsed && runner && parsed.words[0] === "schema" && parsed.words.length > 1 && parsed.words.length <= 4) {
247
+ process.exitCode = await schemaOverCli(runner, parsed.words.slice(1).join(" "));
248
+ return;
249
+ }
219
250
  if (!parsed || !runner || parsed.words.length > 3 || (!parsed.words.length && !parsed.flags.some(([flag]) => flag === "help"))) {
220
251
  console.error(USAGE);
221
252
  if (parsed && !runner)