Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
AGENT_ROUTER_PORT=8787
AGENT_ROUTER_PUBLIC_URL=http://localhost:8787
AGENT_ROUTER_DB=.state/agent-router.sqlite
AGENT_ROUTER_WORKSPACE_ROOT=~/Projects
AGENT_ROUTER_WEBHOOK_SECRET=change-me
AGENT_ROUTER_RUNNER_TOKEN=change-me-too
AGENT_ROUTER_SETUP_SECRET=change-me-three
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
node_modules/
dist/
.state/
agent-router.config.json
.env
.env.*
!.env.example
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ In another terminal, run a local worker lane:
pnpm runner -- --agent codex
```

By default the runner previews the next queued job without claiming it. Add `--execute` to claim the job and start the local agent.

## GitHub App Setup

1. Put the server on a public URL that GitHub can reach. For local development, use a tunnel and set `AGENT_ROUTER_PUBLIC_URL` to the tunnel URL.
Expand Down Expand Up @@ -58,7 +60,9 @@ pnpm setup:url HuntDynamics org

```bash
pnpm dev # start the webhook server
pnpm runner -- --agent codex # claim queued Codex jobs
pnpm runner -- --agent codex # preview the next Codex job
pnpm runner -- --agent codex --execute --once
# claim one job and run Codex locally
pnpm setup:url fschrhunt user # generate a GitHub App manifest URL
pnpm test # run tests
pnpm verify # typecheck and test
Expand All @@ -82,6 +86,20 @@ Use `agent-router.config.json` to restrict which repositories can dispatch jobs.
cp agent-router.config.example.json agent-router.config.json
```

The runner resolves jobs to fixed local worktrees from `workspaceRoot` and `repositories`:

```json
{
"workspaceRoot": "/Volumes/Thorium/Projects",
"repositories": {
"fschrhunt/AgentRouter": "AgentRouter",
"HuntDynamics/Halos": "Halos"
}
}
```

For a `@codex` PR job on `fschrhunt/AgentRouter`, that maps to `/Volumes/Thorium/Projects/AgentRouter/codex` and requires the branch to already be `codex/workspace`.

## Safety Rules

- No agent runs unless a comment or review contains `@codex`, `@claude`, or `@huma`.
Expand Down Expand Up @@ -119,6 +137,7 @@ Runtime state is intentionally local and ignored by git:

- `.env`
- `.state/`
- `agent-router.config.json`
- SQLite files

## License
Expand Down
7 changes: 6 additions & 1 deletion agent-router.config.example.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
{
"workspaceRoot": "~/Projects",
"allowedRepositories": [
"fschrhunt/AgentRouter",
"HuntDynamics/Halos"
]
],
"repositories": {
"fschrhunt/AgentRouter": "AgentRouter",
"HuntDynamics/Halos": "Halos"
}
}
13 changes: 13 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const envSchema = z.object({
AGENT_ROUTER_PORT: z.coerce.number().int().positive().default(8787),
AGENT_ROUTER_PUBLIC_URL: z.string().url().default("http://localhost:8787"),
AGENT_ROUTER_DB: z.string().min(1).default(".state/agent-router.sqlite"),
AGENT_ROUTER_WORKSPACE_ROOT: z.string().min(1).default("~/Projects"),
AGENT_ROUTER_WEBHOOK_SECRET: z.string().min(1),
AGENT_ROUTER_RUNNER_TOKEN: z.string().min(1),
AGENT_ROUTER_SETUP_SECRET: z.string().min(1).optional(),
Expand All @@ -16,18 +17,22 @@ const envSchema = z.object({

const fileConfigSchema = z.object({
allowedRepositories: z.array(z.string()).default([]),
workspaceRoot: z.string().optional(),
repositories: z.record(z.string(), z.string()).default({}),
});

export interface AppConfig {
port: number;
publicUrl: string;
databasePath: string;
workspaceRoot: string;
webhookSecret: string;
runnerToken: string;
setupSecret: string;
githubAppId?: string;
githubPrivateKey?: string;
allowedRepositories: Set<string>;
repositories: Map<string, string>;
}

export function loadConfig(cwd = process.cwd()): AppConfig {
Expand All @@ -41,6 +46,7 @@ export function loadConfig(cwd = process.cwd()): AppConfig {
port: env.AGENT_ROUTER_PORT,
publicUrl: env.AGENT_ROUTER_PUBLIC_URL.replace(/\/$/, ""),
databasePath: path.resolve(cwd, env.AGENT_ROUTER_DB),
workspaceRoot: expandHome(fileConfig.workspaceRoot ?? env.AGENT_ROUTER_WORKSPACE_ROOT),
webhookSecret: env.AGENT_ROUTER_WEBHOOK_SECRET,
runnerToken: env.AGENT_ROUTER_RUNNER_TOKEN,
setupSecret: env.AGENT_ROUTER_SETUP_SECRET ?? env.AGENT_ROUTER_RUNNER_TOKEN,
Expand All @@ -49,10 +55,17 @@ export function loadConfig(cwd = process.cwd()): AppConfig {
? Buffer.from(env.GITHUB_PRIVATE_KEY_B64, "base64").toString("utf8")
: undefined,
allowedRepositories: new Set(fileConfig.allowedRepositories.map((repo) => repo.toLowerCase())),
repositories: new Map(Object.entries(fileConfig.repositories).map(([repo, localName]) => [repo.toLowerCase(), localName])),
};
}

export function isRepositoryAllowed(config: AppConfig, repository: string): boolean {
if (config.allowedRepositories.size === 0) return true;
return config.allowedRepositories.has(repository.toLowerCase());
}

function expandHome(value: string): string {
if (value === "~") return process.env.HOME ?? value;
if (value.startsWith("~/")) return path.join(process.env.HOME ?? "~", value.slice(2));
return value;
}
149 changes: 138 additions & 11 deletions src/runner/main.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,72 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import { Command } from "commander";
import "dotenv/config";
import type { AgentName } from "../types.js";
import { loadConfig } from "../config.js";
import type { AgentJob, AgentName } from "../types.js";
import { buildAgentPrompt } from "./prompt.js";
import { resolveWorktreeTarget, type WorktreeTarget } from "./workspace.js";

const program = new Command()
.requiredOption("--agent <agent>", "agent lane: codex, claude, or huma")
.option("--server <url>", "AgentRouter server URL", "http://localhost:8787")
.option("--once", "claim one job and exit", false);
.option("--once", "claim one job and exit", false)
.option("--execute", "execute the local agent instead of only previewing the next job", false);

program.parse();
const options = program.opts<{ agent: AgentName; server: string; once: boolean }>();
const options = program.opts<{ agent: AgentName; server: string; once: boolean; execute: boolean }>();

if (!["codex", "claude", "huma"].includes(options.agent)) {
throw new Error("agent must be codex, claude, or huma");
}

const token = process.env.AGENT_ROUTER_RUNNER_TOKEN;
if (!token) throw new Error("AGENT_ROUTER_RUNNER_TOKEN is required");
const config = loadConfig();
const token = config.runnerToken;

while (true) {
const job = await claimJob(options.server, options.agent, token);
const job = options.execute
? await claimJob(options.server, options.agent, token)
: await peekJob(options.server, options.agent, token);

if (job) {
console.log(JSON.stringify(job, null, 2));
await completeJob(options.server, job.id, token);
const target = resolveWorktreeTarget(config, job);
const context = collectRunnerContext(job, target);
const prompt = buildAgentPrompt(job, target, context);

if (!options.execute) {
console.log(JSON.stringify({ mode: "preview", job, target, prompt }, null, 2));
} else {
try {
runAgent(job.agent, target, prompt);
await completeJob(options.server, job.id, token);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await failJob(options.server, job.id, token, message);
throw error;
}
}
} else if (options.once) {
break;
}

if (options.once) break;
await new Promise((resolve) => setTimeout(resolve, 3_000));
}

async function claimJob(server: string, agent: AgentName, token: string): Promise<any | null> {
async function peekJob(server: string, agent: AgentName, token: string): Promise<AgentJob | null> {
const res = await fetch(`${server}/runner/jobs/peek?agent=${agent}`, {
headers: { authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`peek failed: ${res.status} ${await res.text()}`);
const payload = await res.json() as { job: AgentJob | null };
return payload.job;
}

async function claimJob(server: string, agent: AgentName, token: string): Promise<AgentJob | null> {
const res = await fetch(`${server}/runner/jobs/next?agent=${agent}`, {
headers: { authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`claim failed: ${res.status} ${await res.text()}`);
const payload = await res.json() as { job: any | null };
const payload = await res.json() as { job: AgentJob | null };
return payload.job;
}

Expand All @@ -49,3 +81,98 @@ async function completeJob(server: string, id: number, token: string): Promise<v
});
if (!res.ok) throw new Error(`complete failed: ${res.status} ${await res.text()}`);
}

async function failJob(server: string, id: number, token: string, error: string): Promise<void> {
const res = await fetch(`${server}/runner/jobs/${id}`, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify({ status: "failed", error }),
});
if (!res.ok) throw new Error(`fail failed: ${res.status} ${await res.text()}`);
}

function collectRunnerContext(job: AgentJob, target: WorktreeTarget): { prSummary: string; checkSummary: string } {
ensureWorktree(target);
ensureBranch(target);

if (!job.isPullRequest) {
return {
prSummary: `Issue: https://github.com/${job.repository}/issues/${job.targetNumber}`,
checkSummary: "Not a pull request job.",
};
}

return {
prSummary: runText("gh", [
"pr",
"view",
String(job.targetNumber),
"--json",
"url,title,state,mergeable,reviewDecision,headRefName,baseRefName,body",
], target.worktreeDir),
checkSummary: runText("gh", ["pr", "checks", String(job.targetNumber)], target.worktreeDir, true),
};
}

function ensureWorktree(target: WorktreeTarget): void {
if (!fs.existsSync(target.worktreeDir)) {
throw new Error(`missing worktree: ${target.worktreeDir}`);
}
}

function ensureBranch(target: WorktreeTarget): void {
const branch = runText("git", ["rev-parse", "--abbrev-ref", "HEAD"], target.worktreeDir).trim();
if (branch !== target.branch) {
throw new Error(`wrong branch in ${target.worktreeDir}: expected ${target.branch}, got ${branch}`);
}
runText("git", ["fetch", "origin", target.branch], target.worktreeDir, true);
runText("git", ["pull", "--ff-only"], target.worktreeDir, true);
}

function runAgent(agent: AgentName, target: WorktreeTarget, prompt: string): void {
if (agent === "codex") {
runStreaming("codex", [
"exec",
"--cd",
target.worktreeDir,
"--ask-for-approval",
"never",
"--sandbox",
"danger-full-access",
prompt,
], target.worktreeDir);
return;
}

if (agent === "claude") {
runStreaming("claude", [
"--print",
"--permission-mode",
"acceptEdits",
"--add-dir",
target.worktreeDir,
prompt,
], target.worktreeDir);
return;
}

throw new Error("Huma execution is not wired yet; job was not run.");
}

function runText(command: string, args: string[], cwd: string, allowFailure = false): string {
const result = spawnSync(command, args, { cwd, encoding: "utf8" });
if (result.status !== 0 && !allowFailure) {
throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
}
return [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
}

function runStreaming(command: string, args: string[], cwd: string): void {
const result = spawnSync(command, args, { cwd, stdio: "inherit" });
if (result.status !== 0) {
throw new Error(`${command} exited with ${result.status}`);
}
}
38 changes: 38 additions & 0 deletions src/runner/prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { AgentJob } from "../types.js";
import type { WorktreeTarget } from "./workspace.js";

export interface RunnerContext {
prSummary: string;
checkSummary: string;
}

export function buildAgentPrompt(job: AgentJob, target: WorktreeTarget, context: RunnerContext): string {
const prUrl = `https://github.com/${job.repository}/pull/${job.targetNumber}`;
return [
`You were summoned from GitHub by @${job.agent}.`,
"",
"Task:",
job.body.trim(),
"",
"Repository:",
`- ${job.repository}`,
`- PR: ${prUrl}`,
`- Worktree: ${target.worktreeDir}`,
`- Branch: ${target.branch}`,
`- Base branch: ${job.pullRequestBaseBranch ?? "unknown"}`,
"",
"Hard rules:",
"- Work only in this existing worktree.",
`- Stay on ${target.branch}.`,
"- Do not create a new branch or worktree.",
"- Do not merge the PR.",
"- Fix only what is relevant to the PR blocker or the GitHub mention.",
"- Verify before claiming success.",
"",
"PR context:",
context.prSummary.trim() || "(no PR summary available)",
"",
"Checks:",
context.checkSummary.trim() || "(no check output available)",
].join("\n");
}
28 changes: 28 additions & 0 deletions src/runner/workspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import path from "node:path";
import type { AppConfig } from "../config.js";
import type { AgentJob } from "../types.js";

export interface WorktreeTarget {
repositoryDir: string;
worktreeDir: string;
branch: string;
}

export function resolveWorktreeTarget(config: AppConfig, job: AgentJob): WorktreeTarget {
const repositoryDir = config.repositories.get(job.repository.toLowerCase()) ?? job.repo;
const branch = job.pullRequestHeadBranch ?? `${job.agent}/workspace`;
const lane = laneFromBranch(branch);
return {
repositoryDir,
branch,
worktreeDir: path.join(config.workspaceRoot, repositoryDir, lane),
};
}

export function laneFromBranch(branch: string): string {
const match = /^(codex|claude|huma)\/workspace$/.exec(branch);
if (!match) {
throw new Error(`unsupported agent branch: ${branch}`);
}
return match[1]!;
}
Loading
Loading