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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ 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_LOCAL_AGENT=main
AGENT_ROUTER_PULLY_EVENT_URL=http://127.0.0.1:17655/github-event
AGENT_ROUTER_HUMA_AGENT=main
GITHUB_APP_ID=
GITHUB_PRIVATE_KEY_B64=
AGENT_ROUTER_GITHUB_APPS_JSON=
11 changes: 8 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,25 @@ 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 a local OpenClaw-backed lane.
- `src/runner/` claims jobs for configured agent lanes.

## Project Rules

- Triggering must stay explicit: `@SummonAgent codex`, `@SummonAgent claude`, or the configured local third lane.
- Triggering must stay explicit: `@SummonAgent codex`, `@SummonAgent claude`, or `@SummonAgent huma`.
- 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`
- `local/workspace`
- `huma/workspace`
- Do not push follow-up work onto a branch after its PR is merged or closed unless that branch is still the active fixed workspace lane for new work.
- 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.

## Worktrees

Fixed worktrees stay put: `main/`, `codex/`, `claude/`, and `huma/`. Agents work only in their assigned worktree on the fixed workspace branches: `codex/workspace`, `claude/workspace`, and `huma/workspace`. Do not create extra worktrees or topic branches for normal work.

## Definition Of Done

Changes are complete when `pnpm verify` passes and any GitHub App behavior has been tested with a signed webhook fixture or a live test repository.
53 changes: 39 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# AgentRouter

AgentRouter is a GitHub App router for explicit SummonAgent commands across personal and organization repositories. It lets one installed app listen across selected repos and queue work only when someone deliberately calls an agent lane.
AgentRouter is a GitHub App router for explicit SummonAgent commands across personal and organization repositories. It lets installed private apps listen across their accounts and queue work only when someone deliberately calls a configured lane.

It listens for GitHub comments and reviews, validates that the sender is allowed to dispatch work, and creates jobs from commands like:

- `@SummonAgent codex fix this`
- `@SummonAgent claude review this`
- `@SummonAgent local handle this`
- `@SummonAgent huma 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 All @@ -24,13 +24,13 @@ Set the required values in `.env`, then start the router:
pnpm dev
```

In another terminal, run a local worker lane:
In another terminal, run a worker lane:

```bash
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.
By default the runner previews the next queued job without claiming it. Add `--execute` to claim the job and start the selected agent.

## GitHub App Setup

Expand Down Expand Up @@ -70,24 +70,26 @@ pnpm verify # typecheck and test

## Configuration

Copy `.env.example` to `.env` and set:
For the background service, keep private runtime values in `~/.agentrouter/.env`. For one-off development, a repo-local `.env` also works. Set:

- `AGENT_ROUTER_WEBHOOK_SECRET`
- `AGENT_ROUTER_RUNNER_TOKEN`
- `AGENT_ROUTER_SETUP_SECRET`
- `GITHUB_APP_ID`
- `GITHUB_PRIVATE_KEY_B64`
- `AGENT_ROUTER_LOCAL_AGENT` optionally selects the OpenClaw agent id for the local third lane. It defaults to `main`.
- `AGENT_ROUTER_PULLY_EVENT_URL` points at Pully's local event listener. The default is `http://127.0.0.1:17655/github-event`.
- `AGENT_ROUTER_HUMA_AGENT` optionally selects the OpenClaw agent id for the Huma lane. It defaults to `main`.
- `AGENT_ROUTER_GITHUB_APPS_JSON` optionally configures multiple private GitHub Apps for one router.

`GITHUB_PRIVATE_KEY_B64` should be the GitHub App private key PEM encoded as base64.
`GITHUB_PRIVATE_KEY_B64` should be the GitHub App private key PEM encoded as base64. For private apps across multiple accounts, set `AGENT_ROUTER_GITHUB_APPS_JSON` to an array of app credentials; each app stays private to its owning account while AgentRouter serves them together.

Use `agent-router.config.json` to restrict which repositories can dispatch jobs. Start from:

```bash
cp agent-router.config.example.json agent-router.config.json
```

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

```json
{
Expand All @@ -99,7 +101,7 @@ The runner resolves jobs to fixed local worktrees from `workspaceRoot` and `repo
"laneBranches": {
"codex": "codex/workspace",
"claude": "claude/workspace",
"local": "local/workspace"
"huma": "huma/workspace"
}
}
```
Expand All @@ -108,17 +110,17 @@ For a `@SummonAgent codex` PR job on `OWNER/REPOSITORY`, that maps to `~/Project

## Safety Rules

- 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.
- 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 runner lanes behind SummonAgent. 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 local` -> `local/workspace`
- `@SummonAgent huma` -> `huma/workspace`
- Deployment-specific branch names can be set in `laneBranches`.
- AgentRouter blocks fork PRs and unexpected PR branches instead of creating new branches.

Expand All @@ -135,7 +137,30 @@ 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 third lane runs through OpenClaw with `openclaw agent --agent ${AGENT_ROUTER_LOCAL_AGENT:-main} --message ...`.
The Huma lane runs through OpenClaw with `openclaw agent --agent ${AGENT_ROUTER_HUMA_AGENT:-main} --message ...`.

## Pully Feed

Pully can read open PRs from AgentRouter instead of searching GitHub directly:

```bash
curl -H "Authorization: Bearer $AGENT_ROUTER_RUNNER_TOKEN" \
http://127.0.0.1:8787/pully/pull-requests
```

The feed discovers repositories from the installed GitHub Apps, so new repos appear automatically after the relevant app is installed on that account. If installation discovery fails, AgentRouter falls back to `agent-router.config.json` repositories and reports the discovery error in `failures`.

AgentRouter also pings Pully's local event listener when GitHub sends PR/comment/review webhooks. That replaces the old per-repo Pully workflow path for live menu bar updates.

## Launch Agent

After changes are merged into `main` and `~/.agentrouter/.env` exists, install the login service from the `main/` worktree:

```bash
scripts/install_launch_agent.sh
```

The LaunchAgent runs from `/Volumes/Thorium/Projects/AgentRouter/main`, not an agent worktree.

## Development

Expand Down
2 changes: 1 addition & 1 deletion agent-router.config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@
"laneBranches": {
"codex": "codex/workspace",
"claude": "claude/workspace",
"local": "local/workspace"
"huma": "huma/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 local commands.",
"description": "GitHub App router for explicit @SummonAgent commands.",
"scripts": {
"dev": "tsx src/server/main.ts",
"runner": "tsx src/runner/main.ts",
Expand Down
61 changes: 61 additions & 0 deletions scripts/install_launch_agent.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail

LABEL="co.fischer.agentrouter"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
PROJECT_DIR="/Volumes/Thorium/Projects/AgentRouter/main"
PNPM_BIN="${PNPM_BIN:-/opt/homebrew/bin/pnpm}"
LOG_DIR="$HOME/Library/Logs/AgentRouter"

if [ ! -x "$PNPM_BIN" ]; then
echo "pnpm not found at $PNPM_BIN" >&2
exit 1
fi

if [ ! -d "$PROJECT_DIR" ]; then
echo "missing AgentRouter main worktree: $PROJECT_DIR" >&2
exit 1
fi

if [ ! -f "$HOME/.agentrouter/.env" ]; then
echo "missing $HOME/.agentrouter/.env" >&2
echo "Create it from the private runtime values before installing the LaunchAgent." >&2
exit 1
fi

mkdir -p "$LOG_DIR"

cat > "$PLIST" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>$PNPM_BIN</string>
<string>dev</string>
</array>
<key>WorkingDirectory</key>
<string>$PROJECT_DIR</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>$LOG_DIR/stdout.log</string>
<key>StandardErrorPath</key>
<string>$LOG_DIR/stderr.log</string>
</dict>
</plist>
PLIST

chmod 644 "$PLIST"

launchctl bootout "gui/$(id -u)" "$PLIST" >/dev/null 2>&1 || true
launchctl bootstrap "gui/$(id -u)" "$PLIST"
launchctl kickstart -k "gui/$(id -u)/$LABEL"
launchctl print "gui/$(id -u)/$LABEL" >/dev/null

echo "installed: $PLIST"
56 changes: 55 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import fs from "node:fs";
import path from "node:path";
import dotenv from "dotenv";
import { z } from "zod";
import "dotenv/config";

dotenv.config({ path: path.join(process.env.HOME ?? "", ".agentrouter/.env"), override: false, quiet: true });
dotenv.config({ override: false, quiet: true });

const envSchema = z.object({
AGENT_ROUTER_PORT: z.coerce.number().int().positive().default(8787),
Expand All @@ -11,10 +14,19 @@ const envSchema = z.object({
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(),
AGENT_ROUTER_PULLY_EVENT_URL: z.string().url().default("http://127.0.0.1:17655/github-event"),
AGENT_ROUTER_GITHUB_APPS_JSON: z.string().optional(),
GITHUB_APP_ID: z.string().optional(),
GITHUB_PRIVATE_KEY_B64: z.string().optional(),
});

const githubAppCredentialSchema = z.object({
name: z.string().min(1).default("default"),
appId: z.string().min(1),
privateKeyB64: z.string().min(1),
webhookSecret: z.string().min(1),
});

const fileConfigSchema = z.object({
allowedRepositories: z.array(z.string()).default([]),
workspaceRoot: z.string().optional(),
Expand All @@ -30,13 +42,22 @@ export interface AppConfig {
webhookSecret: string;
runnerToken: string;
setupSecret: string;
pullyEventUrl: string;
githubAppId?: string;
githubPrivateKey?: string;
githubApps: GitHubAppCredential[];
allowedRepositories: Set<string>;
repositories: Map<string, string>;
laneBranches: Map<string, string>;
}

export interface GitHubAppCredential {
name: string;
appId: string;
privateKey: string;
webhookSecret: string;
}

export function loadConfig(cwd = process.cwd()): AppConfig {
const env = envSchema.parse(process.env);
const configPath = path.join(cwd, "agent-router.config.json");
Expand All @@ -52,10 +73,12 @@ export function loadConfig(cwd = process.cwd()): AppConfig {
webhookSecret: env.AGENT_ROUTER_WEBHOOK_SECRET,
runnerToken: env.AGENT_ROUTER_RUNNER_TOKEN,
setupSecret: env.AGENT_ROUTER_SETUP_SECRET ?? env.AGENT_ROUTER_RUNNER_TOKEN,
pullyEventUrl: env.AGENT_ROUTER_PULLY_EVENT_URL,
githubAppId: env.GITHUB_APP_ID,
githubPrivateKey: env.GITHUB_PRIVATE_KEY_B64
? Buffer.from(env.GITHUB_PRIVATE_KEY_B64, "base64").toString("utf8")
: undefined,
githubApps: loadGitHubApps(env),
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 All @@ -72,3 +95,34 @@ function expandHome(value: string): string {
if (value.startsWith("~/")) return path.join(process.env.HOME ?? "~", value.slice(2));
return value;
}

function loadGitHubApps(env: z.infer<typeof envSchema>): GitHubAppCredential[] {
const apps: GitHubAppCredential[] = [];

if (env.AGENT_ROUTER_GITHUB_APPS_JSON) {
const parsed = z.array(githubAppCredentialSchema).parse(JSON.parse(env.AGENT_ROUTER_GITHUB_APPS_JSON));
apps.push(...parsed.map((app) => ({
name: app.name,
appId: app.appId,
privateKey: Buffer.from(app.privateKeyB64, "base64").toString("utf8"),
webhookSecret: app.webhookSecret,
})));
}

if (env.GITHUB_APP_ID && env.GITHUB_PRIVATE_KEY_B64) {
apps.push({
name: "default",
appId: env.GITHUB_APP_ID,
privateKey: Buffer.from(env.GITHUB_PRIVATE_KEY_B64, "base64").toString("utf8"),
webhookSecret: env.AGENT_ROUTER_WEBHOOK_SECRET,
});
}

const seen = new Set<string>();
return apps.filter((app) => {
const key = app.appId;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
Loading
Loading