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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ 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
AGENT_ROUTER_HUMA_AGENT=main
AGENT_ROUTER_LOCAL_AGENT=main
GITHUB_APP_ID=
GITHUB_PRIVATE_KEY_B64=
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AgentRouter

GitHub App router for explicit SummonAgent commands across Fischer's personal and HuntDynamics repositories.
GitHub App router for explicit SummonAgent commands across selected personal and organization repositories.

## Run

Expand All @@ -17,16 +17,16 @@ pnpm runner --agent codex --execute --once
- `src/server/` starts the HTTP webhook, setup callback, and runner API.
- `src/github/` parses GitHub events, manifests, and checks actor permissions.
- `src/store/` persists jobs in SQLite.
- `src/runner/` claims jobs for Codex, Claude, or Huma local lanes.
- `src/runner/` claims jobs for Codex, Claude, or a local OpenClaw-backed lane.

## Project Rules

- Triggering must stay explicit: `@SummonAgent codex`, `@SummonAgent claude`, or `@SummonAgent huma`.
- Raw `@codex`, `@claude`, and `@huma` mentions are ignored because Codex/Claude/Huma are not separate GitHub Apps.
- Triggering must stay explicit: `@SummonAgent codex`, `@SummonAgent claude`, or the configured local third lane.
- Raw `@codex`, `@claude`, and raw third-lane mentions are ignored because individual agents are not separate GitHub Apps.
- Whoever opened the PR owns the fix. PR jobs must stay on the matching fixed lane branch:
- `codex/workspace`
- `claude/workspace`
- `huma/workspace`
- `local/workspace`
- Never act on bot-authored events.
- Keep repository allowlisting enabled before wiring real agent execution.
- Do not store GitHub private keys, webhook secrets, runner tokens, or agent API keys in git.
Expand Down
36 changes: 21 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ It listens for GitHub comments and reviews, validates that the sender is allowed

- `@SummonAgent codex fix this`
- `@SummonAgent claude review this`
- `@SummonAgent huma handle this`
- `@SummonAgent local handle this`

The first version is intentionally conservative: it stores jobs locally, exposes a small runner API, and only dispatches when `@SummonAgent` is mentioned with a supported lane name. No ambient code review, no surprise bot activity, no repo-by-repo workflow file required.

Expand Down Expand Up @@ -44,13 +44,13 @@ pnpm dev
3. Open the setup URL for your personal account:

```bash
pnpm setup:url fschrhunt user
pnpm setup:url YOUR_USER user
```

For Hunt. org setup, use:
For organization setup, use:

```bash
pnpm setup:url HuntDynamics org
pnpm setup:url YOUR_ORG org
```

4. GitHub will redirect back to `/setup/callback`. AgentRouter exchanges the temporary manifest code and writes generated credentials to `.state/github-app.env` with file mode `0600`.
Expand All @@ -63,7 +63,7 @@ pnpm dev # start the webhook server
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 setup:url YOUR_USER user # generate a GitHub App manifest URL
pnpm test # run tests
pnpm verify # typecheck and test
```
Expand All @@ -77,7 +77,7 @@ Copy `.env.example` to `.env` and set:
- `AGENT_ROUTER_SETUP_SECRET`
- `GITHUB_APP_ID`
- `GITHUB_PRIVATE_KEY_B64`
- `AGENT_ROUTER_HUMA_AGENT` optionally selects the OpenClaw agent id for the Huma lane. It defaults to `main`.
- `AGENT_ROUTER_LOCAL_AGENT` optionally selects the OpenClaw agent id for the local third lane. It defaults to `main`.

`GITHUB_PRIVATE_KEY_B64` should be the GitHub App private key PEM encoded as base64.

Expand All @@ -91,29 +91,35 @@ The runner resolves jobs to fixed local worktrees from `workspaceRoot` and `repo

```json
{
"workspaceRoot": "/Volumes/Thorium/Projects",
"workspaceRoot": "~/Projects",
"repositories": {
"fschrhunt/AgentRouter": "AgentRouter",
"HuntDynamics/Halos": "Halos"
"OWNER/REPOSITORY": "Repository",
"ORG/REPOSITORY": "OrgRepository"
},
"laneBranches": {
"codex": "codex/workspace",
"claude": "claude/workspace",
"local": "local/workspace"
}
}
```

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

## Safety Rules

- No agent runs unless a comment or review contains `@SummonAgent` plus `codex`, `claude`, or `huma`.
- Raw `@codex`, `@claude`, and `@huma` mentions are ignored.
- Codex, Claude, and Huma are local runner lanes behind this one GitHub App. They are not separate GitHub Apps.
- No agent runs unless a comment or review contains `@SummonAgent` plus `codex`, `claude`, or `local`.
- Raw `@codex`, `@claude`, and `@local` mentions are ignored.
- Codex, Claude, and the local third lane are runner lanes behind this one GitHub App. They are not separate GitHub Apps.
- Bot users are ignored.
- Senders must have `write`, `maintain`, or `admin` repository permission.
- Repositories can be allowlisted in `agent-router.config.json`.
- The runner claims jobs over an authenticated local API.
- PR fix jobs only run on fixed lane branches:
- `@SummonAgent codex` -> `codex/workspace`
- `@SummonAgent claude` -> `claude/workspace`
- `@SummonAgent huma` -> `huma/workspace`
- `@SummonAgent local` -> `local/workspace`
- Deployment-specific branch names can be set in `laneBranches`.
- AgentRouter blocks fork PRs and unexpected PR branches instead of creating new branches.

## Webhook Events
Expand All @@ -129,7 +135,7 @@ When a valid mention is accepted, AgentRouter stores a queued job. Runners claim

For pull requests, jobs include the PR head branch and base branch. The router only queues a PR job when the PR head branch matches the mentioned agent's fixed workspace branch in the same repository.

The Huma lane runs through OpenClaw with `openclaw agent --agent ${AGENT_ROUTER_HUMA_AGENT:-main} --message ...`.
The third lane runs through OpenClaw with `openclaw agent --agent ${AGENT_ROUTER_LOCAL_AGENT:-main} --message ...`.

## Development

Expand Down
13 changes: 9 additions & 4 deletions agent-router.config.example.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
{
"workspaceRoot": "~/Projects",
"allowedRepositories": [
"fschrhunt/AgentRouter",
"HuntDynamics/Halos"
"OWNER/REPOSITORY",
"ORG/REPOSITORY"
],
"repositories": {
"fschrhunt/AgentRouter": "AgentRouter",
"HuntDynamics/Halos": "Halos"
"OWNER/REPOSITORY": "Repository",
"ORG/REPOSITORY": "OrgRepository"
},
"laneBranches": {
"codex": "codex/workspace",
"claude": "claude/workspace",
"local": "local/workspace"
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"type": "module",
"description": "GitHub App router for @SummonAgent codex, claude, and huma commands.",
"description": "GitHub App router for @SummonAgent codex, claude, and local commands.",
"scripts": {
"dev": "tsx src/server/main.ts",
"runner": "tsx src/runner/main.ts",
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const fileConfigSchema = z.object({
allowedRepositories: z.array(z.string()).default([]),
workspaceRoot: z.string().optional(),
repositories: z.record(z.string(), z.string()).default({}),
laneBranches: z.record(z.string(), z.string()).default({}),
});

export interface AppConfig {
Expand All @@ -33,6 +34,7 @@ export interface AppConfig {
githubPrivateKey?: string;
allowedRepositories: Set<string>;
repositories: Map<string, string>;
laneBranches: Map<string, string>;
}

export function loadConfig(cwd = process.cwd()): AppConfig {
Expand All @@ -56,6 +58,7 @@ export function loadConfig(cwd = process.cwd()): AppConfig {
: undefined,
allowedRepositories: new Set(fileConfig.allowedRepositories.map((repo) => repo.toLowerCase())),
repositories: new Map(Object.entries(fileConfig.repositories).map(([repo, localName]) => [repo.toLowerCase(), localName])),
laneBranches: new Map(Object.entries(fileConfig.laneBranches).map(([lane, branch]) => [lane.toLowerCase(), branch])),
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/github/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const summonMention = /\B@summonagent\b/i;
const commandPatterns: Array<[AgentName, RegExp]> = [
["codex", /\bcodex\b/i],
["claude", /\bclaude\b/i],
["huma", /\bhuma\b/i],
["local", /\b(?:local|openclaw)\b/i],
];

export function detectAgentMention(body: string): AgentName | null {
Expand Down
2 changes: 1 addition & 1 deletion src/github/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function buildManifest(config: AppConfig, params: ManifestParams): Record
redirect_url: `${config.publicUrl}/setup/callback`,
callback_urls: [`${config.publicUrl}/setup/callback`],
setup_url: `${config.publicUrl}/setup/done`,
description: "Routes explicit @SummonAgent codex/claude/huma commands to local agent lanes.",
description: "Routes explicit @SummonAgent codex/claude/local commands to local agent lanes.",
public: false,
default_permissions: {
contents: "read",
Expand Down
10 changes: 5 additions & 5 deletions src/runner/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ 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")
.requiredOption("--agent <agent>", "agent lane: codex, claude, or local")
.option("--server <url>", "AgentRouter server URL", "http://localhost:8787")
.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; execute: boolean }>();

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

const config = loadConfig();
Expand Down Expand Up @@ -159,11 +159,11 @@ function runAgent(agent: AgentName, target: WorktreeTarget, prompt: string): voi
return;
}

const humaAgent = process.env.AGENT_ROUTER_HUMA_AGENT ?? "main";
const localAgent = process.env.AGENT_ROUTER_LOCAL_AGENT ?? "main";
runStreaming("openclaw", [
"agent",
"--agent",
humaAgent,
localAgent,
"--message",
prompt,
], target.worktreeDir);
Expand Down
4 changes: 2 additions & 2 deletions src/runner/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export interface WorktreeTarget {

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 branch = job.pullRequestHeadBranch ?? config.laneBranches.get(job.agent) ?? `${job.agent}/workspace`;
const lane = laneFromBranch(branch);
return {
repositoryDir,
Expand All @@ -20,7 +20,7 @@ export function resolveWorktreeTarget(config: AppConfig, job: AgentJob): Worktre
}

export function laneFromBranch(branch: string): string {
const match = /^(codex|claude|huma)\/workspace$/.exec(branch);
const match = /^([a-z0-9-]+)\/workspace$/i.exec(branch);
if (!match) {
throw new Error(`unsupported agent branch: ${branch}`);
}
Expand Down
11 changes: 6 additions & 5 deletions src/server/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export async function handleWebhook(

if (trigger.isPullRequest) {
const pr = await getPullRequestContext(octokit, trigger);
const routing = evaluatePullRequestRouting(trigger, pr);
const routing = evaluatePullRequestRouting(trigger, pr, deps.config);
if (!routing.allowed) {
await postIssueComment(octokit, trigger, routing.message);
return { status: 202, body: { blocked: true, reason: routing.reason } };
Expand All @@ -61,19 +61,20 @@ export function isRunnerAuthorized(expected: string, provided: string | undefine
}

export function parseAgent(value: string | null): AgentName | null {
if (value === "codex" || value === "claude" || value === "huma") return value;
if (value === "codex" || value === "claude" || value === "local") return value;
return null;
}

export function fixedLaneBranch(agent: AgentName): string {
return `${agent}/workspace`;
export function fixedLaneBranch(agent: AgentName, config?: AppConfig): string {
return config?.laneBranches.get(agent) ?? `${agent}/workspace`;
}

export function evaluatePullRequestRouting(
trigger: TriggerRequest,
pr: PullRequestContext,
config?: AppConfig,
): { allowed: true } | { allowed: false; reason: string; message: string } {
const expectedBranch = fixedLaneBranch(trigger.agent);
const expectedBranch = fixedLaneBranch(trigger.agent, config);
if (pr.headRepository.toLowerCase() !== trigger.repository.toLowerCase()) {
return {
allowed: false,
Expand Down
4 changes: 2 additions & 2 deletions src/server/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export async function handleSetupCallback(config: AppConfig, cwd: string, url: U
<html lang="en">
<head><meta charset="utf-8"><title>AgentRouter App Created</title></head>
<body>
<h1>AgentRouter GitHub App Created</h1>
<h1>Repository GitHub App Created</h1>
<p>App: <a href="${escapeHtml(conversion.html_url)}">${escapeHtml(conversion.slug)}</a></p>
<p>Credentials were written to <code>${escapeHtml(envPath)}</code> with mode <code>0600</code>.</p>
<p>Copy those values into your runtime environment, then restart AgentRouter.</p>
Expand All @@ -64,7 +64,7 @@ export async function handleSetupCallback(config: AppConfig, cwd: string, url: U
}

export function parseManifestParams(url: URL): ManifestParams {
const account = url.searchParams.get("account") || "fschrhunt";
const account = url.searchParams.get("account") || "OWNER";
const accountType = url.searchParams.get("type") === "org" ? "org" : "user";
const appName = url.searchParams.get("name") || undefined;
return { account, accountType, appName };
Expand Down
2 changes: 1 addition & 1 deletion src/setup-url.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { loadConfig } from "./config.js";

const config = loadConfig();
const account = process.argv[2] ?? "fschrhunt";
const account = process.argv[2] ?? "OWNER";
const type = process.argv[3] === "org" ? "org" : "user";
const url = new URL("/setup", config.publicUrl);
url.searchParams.set("account", account);
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type AgentName = "codex" | "claude" | "huma";
export type AgentName = "codex" | "claude" | "local";

export type TriggerSource =
| "issue_comment"
Expand Down
16 changes: 8 additions & 8 deletions test/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ describe("detectAgentMention", () => {
it("detects supported agent commands after SummonAgent is mentioned", () => {
expect(detectAgentMention("@SummonAgent codex fix this")).toBe("codex");
expect(detectAgentMention("@summonagent claude review this")).toBe("claude");
expect(detectAgentMention("route to @SummonAgent huma")).toBe("huma");
expect(detectAgentMention("route to @SummonAgent local")).toBe("local");
});

it("does not trigger on raw agent mentions", () => {
expect(detectAgentMention("please @codex fix this")).toBeNull();
expect(detectAgentMention("@claude review this")).toBeNull();
expect(detectAgentMention("route to @huma")).toBeNull();
expect(detectAgentMention("route to @local")).toBeNull();
});

it("ignores partial words and missing lane commands", () => {
Expand All @@ -24,17 +24,17 @@ describe("detectAgentMention", () => {
describe("normalizeGitHubEvent", () => {
it("normalizes issue comments into trigger requests", () => {
const trigger = normalizeGitHubEvent("issue_comment", {
sender: { login: "fschrhunt" },
sender: { login: "OWNER" },
installation: { id: 123 },
repository: { full_name: "fschrhunt/AgentRouter" },
issue: { number: 7, html_url: "https://github.com/fschrhunt/AgentRouter/issues/7" },
repository: { full_name: "OWNER/REPOSITORY" },
issue: { number: 7, html_url: "https://github.com/OWNER/REPOSITORY/issues/7" },
comment: { body: "@SummonAgent codex make it so", html_url: "https://github.com/comment" },
});

expect(trigger).toMatchObject({
agent: "codex",
actor: "fschrhunt",
repository: "fschrhunt/AgentRouter",
actor: "OWNER",
repository: "OWNER/REPOSITORY",
targetNumber: 7,
isPullRequest: false,
installationId: 123,
Expand All @@ -45,7 +45,7 @@ describe("normalizeGitHubEvent", () => {
const trigger = normalizeGitHubEvent("issue_comment", {
sender: { login: "dependabot[bot]" },
installation: { id: 123 },
repository: { full_name: "fschrhunt/AgentRouter" },
repository: { full_name: "OWNER/REPOSITORY" },
issue: { number: 7 },
comment: { body: "@SummonAgent codex make it so" },
});
Expand Down
Loading
Loading