Skip to content
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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
# Changelog

## v2.6.0

One factory, any agent. Every preset except Claude ships `verified: false`; participants verify them with
`factory verify-agent` (see `docs/verify-an-agent.md`). Every fixture except Claude's is synthetic, written
from each CLI's docs.

- New presets: `gemini`, `opencode`, `cursor`, `pi`, `mastracode`. Each declares its pinned `version`,
the API keys it may see (`envKeys`), its skills dir, its context file and how it holds read-only stages
(`readOnlyBy`). A stage's env now drops every other known provider key, so a Codex stage never sees the
Anthropic key. Agents with a custom `command` keep the old behaviour.
- Gemini and Mastra Code parse through a per-run parser (`newParser`), because their events arrive as deltas.
- `factory install --agents a,b,c` links `.claude/skills` into each agent's skills dir and writes an
`AGENTS.md` (or `GEMINI.md`) pointer only when absent. Ported from skills `internal/agents/agents.go`.
- The install always links `.agents/skills` too (the shared dir), and the Dockerfile now installs four CLIs by
default (`claude codex gemini opencode`); pass `--build-arg AGENTS=claude` for the old image.
- `factory doctor` warns when an agent binary's version differs from its pin.
- The Dockerfile takes `--build-arg AGENTS="gemini pi"` and pins every agent's version; a build with two
agents was run. Cursor is host-only (`docker: false`): it has no pinnable download.
- `docs/agents.md`: a matrix generated from the registry (a test fails if they drift), a safety table and an
aider walkthrough. One registry test walks every preset for its fixture, pins, install target and docs.
- Agents page and `GET /api/agents`. New `factory-operator` skill (ported from machinist's skill).
`tests/docs-links.test.ts` checks every `*.md` for unbalanced fences and dead local links (blueprint).
- `FixtureRecorder` replaces a synthetic fixture instead of appending to it.

Not in v2.6:
- Live runs of any agent except Claude. OpenCode and Cursor event shapes are from memory, not from a
capture. Mastra `--mode plan` as read-only, Pi print mode reading stdin and OpenCode reading stdin are
unconfirmed until a participant runs them.
- omp and Amp are cut; either still runs as a custom `command`. Mistral Vibe is config-only.
- `prompt.ts` keeps reading the canonical `.claude/skills`, because `install` always writes it and links the
other agents' dirs to it. There is no `renderPolicy` hook; each preset's `command` applies `stagePolicy`.
- The Agents page shows configuration and pins, not the installed version or doctor rows.

## v2.5.2

Makes v2.5 work on a real run. Every fix has a test that fails when the fix is reverted.
Expand Down
22 changes: 21 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@ FROM oven/bun:1.3.4-slim

ARG GH_CLI_VERSION=2.63.2
ARG NODE_MAJOR=20
# Which agent CLIs the image carries. Every version below equals its preset's `version`
# (tests/agents-registry.test.ts). Cursor has no versioned download, so it is host-only.
ARG AGENTS="claude codex gemini opencode"
ARG CLAUDE_CODE_VERSION=2.1.281
ARG CODEX_VERSION=0.156.1
ARG GEMINI_VERSION=0.61.0
ARG OPENCODE_VERSION=1.18.32
ARG PI_VERSION=0.73.1
ARG MASTRACODE_VERSION=0.42.0
ARG UV_VERSION=0.5.11

RUN apt-get update && apt-get install -y --no-install-recommends \
Expand All @@ -29,11 +37,23 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
-o /tmp/uv.tar.gz \
&& tar -xzf /tmp/uv.tar.gz -C /tmp \
&& mv /tmp/uv-*/uv /tmp/uv-*/uvx /usr/local/bin/ \
&& npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
&& rm -rf /tmp/gh* /tmp/uv* \
&& apt-get purge -y curl gnupg xz-utils && apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*

RUN set -e; for agent in $AGENTS; do \
case "$agent" in \
claude) pkg="@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" ;; \
codex) pkg="@openai/codex@${CODEX_VERSION}" ;; \
gemini) pkg="@google/gemini-cli@${GEMINI_VERSION}" ;; \
opencode) pkg="opencode-ai@${OPENCODE_VERSION}" ;; \
pi) pkg="@mariozechner/pi-coding-agent@${PI_VERSION}" ;; \
mastracode) pkg="mastracode@${MASTRACODE_VERSION}" ;; \
*) echo "unknown agent $agent (cursor is host-only)" >&2; exit 1 ;; \
esac; \
npm install -g "$pkg"; \
done

WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,11 +247,13 @@ The factory knows no agent by name. Each one is config, and `stages` says which
}
```

Presets: `claude`, `codex`. Setting both `preset` and `command` keeps the preset's event parser but runs your command. A `command` agent gets the stage skill plus an artifact contract on stdin, or
Presets: `"preset": "claude"`, `"preset": "codex"`, `"preset": "gemini"`, `"preset": "opencode"`, `"preset": "cursor"`, `"preset": "pi"`, `"preset": "mastracode"` (the
compatibility matrix, safety layers per agent and the bring-your-own walkthrough are in [docs/agents.md](docs/agents.md)).
A preset agent sees only its own provider API keys. Setting both `preset` and `command` keeps the preset's event parser but runs your command. A `command` agent gets the stage skill plus an artifact contract on stdin, or
where `{{prompt}}` / `{{promptFile}}` appears, and writes its results as files under
`$FACTORY_ARTIFACT_DIR`. It runs with no event parser: tokens show as "not reported" and the tool-call cap
cannot be enforced, so the timeout is the backstop and `factory doctor` says so. A shell as the executable may not take `{{prompt}}` as an argument. The guard hook and
`--settings` rules are Claude-only. A preset-less agent inherits your environment (minus `GH_TOKEN`, `GITHUB_TOKEN`, `FACTORY_*`, repo `GIT_*`), including model API keys and `~/.config/gh`: run it in a sandbox until v3.0. The runner's diff check, gates and commit apply to every agent.
`--settings` rules are Claude-only. A preset-less agent inherits your environment, every provider key included (minus `GH_TOKEN`, `GITHUB_TOKEN`, `FACTORY_*`, repo `GIT_*`), including model API keys and `~/.config/gh`: run it in a sandbox until v3.0. The runner's diff check, gates and commit apply to every agent.

Then bring the target repo up to speed and start the loop:

Expand Down
13 changes: 13 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Source: https://github.com/owainlewis/machinist (MIT, Copyright (c) 2026 Owain L
- `src/agents/presets/codex.ts` from `internal/runner/codex_usage.go` [tested by `tests/ported/machinist/usage.test.ts`]
- `src/agents/usage.ts` from `internal/runner/codex_usage.go` [tested by `tests/ported/machinist/usage.test.ts`]
- `src/artifacts.ts` from `internal/protocol/workflow.go` [tested by `tests/ported/machinist/workflow.test.ts`]
- `template/.claude/skills/factory-operator/SKILL.md` from `skills/machinist/SKILL.md` [no upstream test]

## owainlewis/assembler@7cac671

Expand All @@ -35,6 +36,18 @@ Source: https://github.com/owainlewis/assembler (MIT, Copyright (c) 2026 Owain L
- `src/agents/reply.ts` from `src/index.ts` [tested by `tests/ported/assembler/outputs.test.ts`]
- `src/recheck.ts` from `examples/review-pr.ts` [no upstream test]

## owainlewis/skills@e8cadb3

Source: https://github.com/owainlewis/skills (MIT, Copyright (c) 2026 Owain Lewis)

- `src/agent-dirs.ts` from `internal/agents/agents.go` [tested by `tests/ported/skills/agents.test.ts`]

## owainlewis/blueprint@54c952b

Source: https://github.com/owainlewis/blueprint (MIT, Copyright (c) 2026 Owain Lewis)

- `src/docs-links.ts` from `scripts/check_repo.py` [tested by `tests/ported/blueprint/check_repo.test.ts`]

## License text (both projects)

MIT License
Expand Down
25 changes: 19 additions & 6 deletions bin/factory
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ function has(name: string): boolean {
return args.includes(`--${name}`);
}

async function versionOf(bin: string): Promise<string | undefined> {
try {
const proc = Bun.spawn([bin, "--version"], { stdout: "pipe", stderr: "ignore" });
const out = await new Response(proc.stdout).text();
return (await proc.exited) === 0 ? out.trim() : undefined;
} catch {
return undefined;
}
}

async function which(bin: string): Promise<boolean> {
const proc = Bun.spawn(["which", bin], { stdout: "pipe", stderr: "pipe" });
return (await proc.exited) === 0;
Expand Down Expand Up @@ -277,6 +287,7 @@ async function cmdDoctor(): Promise<void> {
github,
git: new GitCommandRunner(),
which,
versionOf,
fileExists,
readFile: async (path) => ((await fileExists(path)) ? await Bun.file(path).text() : undefined),
isExecutable: async (path) => {
Expand Down Expand Up @@ -307,10 +318,10 @@ async function cmdDoctor(): Promise<void> {
// and remoteAddress passed through so handle() can enforce it. Shared by
// `dashboard` (cockpit only, any mode) and `up` (Docker's single-process
// entrypoint: watch + dashboard together).
function serveDashboard(state: FactoryState, github: GitHub, repo: string, autoApproveDefault = false): number {
function serveDashboard(state: FactoryState, github: GitHub, repo: string, autoApproveDefault = false, fleet?: Pick<FactoryConfig, "agents" | "stages">): number {
const port = Number(flag("port") ?? process.env.FACTORY_DASHBOARD_PORT ?? 4100);
const hostname = process.env.FACTORY_DASHBOARD_HOST ?? "127.0.0.1";
const dashboard = createDashboard(state, github, repo, autoApproveDefault);
const dashboard = createDashboard(state, github, repo, autoApproveDefault, undefined, fleet);
const server = Bun.serve({
port,
hostname,
Expand All @@ -323,10 +334,11 @@ function serveDashboard(state: FactoryState, github: GitHub, repo: string, autoA
async function cmdDashboard(): Promise<void> {
const dbPath = flag("db") ?? process.env.FACTORY_DB_PATH ?? DEFAULT_DB_PATH;
const repoDir = flag("repo-dir");
const repo = flag("repo") ?? process.env.FACTORY_REPO ?? (repoDir ? (await loadConfig(resolve(repoDir))).repo : "");
const config = repoDir ? await loadConfig(resolve(repoDir)) : undefined;
const repo = flag("repo") ?? process.env.FACTORY_REPO ?? config?.repo ?? "";
const state = new FactoryState(dbPath);
const github = new GitHub();
serveDashboard(state, github, repo);
serveDashboard(state, github, repo, false, config);
await new Promise(() => {}); // keep the process alive
}

Expand All @@ -345,7 +357,7 @@ async function cmdUp(): Promise<void> {
if (r.paused) console.log(`factory up: paused — ${r.reason}`);
else if (r.processed.length) console.log(`factory up: processed #${r.processed.join(", #")}`);
});
serveDashboard(deps.state, deps.github, config.repo, config.riskPolicy.autoApproveLowRisk);
serveDashboard(deps.state, deps.github, config.repo, config.riskPolicy.autoApproveLowRisk, config);
console.log(`factory up: polling ${config.repo} every ${config.pollIntervalSeconds}s. Ctrl+C to stop.`);
await new Promise(() => {}); // keep the process alive
}
Expand Down Expand Up @@ -373,11 +385,12 @@ async function cmdVerifyAgent(): Promise<void> {

async function cmdInstall(): Promise<void> {
const target = args[1];
if (!target) throw new UsageError("install: usage: factory install <target-dir> [--dry-run] [--update] [--ci]");
if (!target) throw new UsageError("install: usage: factory install <target-dir> [--dry-run] [--update] [--ci] [--agents a,b,c]");
const passthrough = [
...(has("dry-run") ? ["--dry-run"] : []),
...(has("update") ? ["--update"] : []),
...(has("ci") ? ["--ci"] : []),
...(flag("agents") ? ["--agents", flag("agents")!] : []),
];
const script = resolve(import.meta.dir, "..", "install.sh");
const proc = Bun.spawn(["bash", script, target, ...passthrough], { stdout: "inherit", stderr: "inherit" });
Expand Down
18 changes: 15 additions & 3 deletions dashboard/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const ICONS = {
agents: "M8 8h8v8H8zM4 10v4M20 10v4M10 4h4M10 20h4",
theme: "M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9z",
};
const NAV = [["inbox", "Inbox"], ["line", "Line"], ["runs", "Runs"], ["analytics", "Analytics"]];
const NAV = [["inbox", "Inbox"], ["line", "Line"], ["runs", "Runs"], ["analytics", "Analytics"], ["agents", "Agents"]];

const state = { route: routeFromHash(location.hash), inbox: [], repo: "", selected: null, thread: null, filter: "all", data: {}, error: {} };

Expand Down Expand Up @@ -246,9 +246,21 @@ function analyticsView() {
bars("Cost by stage", a.byStage), bars("Cost by agent", a.byAgent))));
}

/* ---- Agents ---- */
function agentsView() {
return h("section", null, heading("Agents", "The coding agents the factory can run, and which stages each one serves."),
stateOr("agents", (a) => !a.agents.length ? quiet("No agents configured", "Add one under agents in .factory/config.json.") : h("div", { class: "table-wrap" }, h("table", null,
h("thead", null, h("tr", null, ["Agent", "Command", "Pinned version", "Verified live", "Stages"].map((c) => h("th", null, c)))),
h("tbody", null, a.agents.map((r) => h("tr", null,
h("td", null, h("span", { class: "status", "data-tone": r.configured ? "ok" : "" }, r.name), r.configured ? null : h("span", { class: "muted" }, " not configured")),
h("td", null, r.binary), h("td", null, r.pin || "Not pinned"),
h("td", null, h("span", { class: "status", "data-tone": r.verified ? "ok" : "warn" }, r.verified ? "Verified" : "Verified by participants: not yet")),
h("td", null, r.stages.length ? r.stages.join(", ") : "None"))))))));
}

/* ---- shell ---- */
const VIEWS = { inbox: inboxView, line: lineView, runs: runsView, task: runsView, analytics: analyticsView };
const LOADERS = { line: ["line", "/api/line"], runs: ["runs", "/api/runs"], task: ["runs", "/api/runs"], analytics: ["analytics", "/api/analytics"] };
const VIEWS = { inbox: inboxView, line: lineView, runs: runsView, task: runsView, analytics: analyticsView, agents: agentsView };
const LOADERS = { line: ["line", "/api/line"], runs: ["runs", "/api/runs"], task: ["runs", "/api/runs"], analytics: ["analytics", "/api/analytics"], agents: ["agents", "/api/agents"] };

function renderNav() {
const nav = document.getElementById("nav");
Expand Down
10 changes: 9 additions & 1 deletion dashboard/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { buildBoard } from "./board";
import { LABEL } from "../src/labels";
import { InboxError, act, buildInbox, type InboxAction } from "../src/inbox";
import { plain } from "../src/display";
import { agentCatalog } from "../src/agents/docs";
import { DEFAULT_CONFIG, type FactoryConfig } from "../src/config";
import { workspacesDir } from "../src/paths";
import { runDir } from "../src/artifacts";
import { analytics } from "./analytics";
Expand Down Expand Up @@ -76,7 +78,7 @@ function plainIssue<T extends { title: string; body: string; comments: { body: s
return { ...issue, title: plain(issue.title), body: plain(issue.body), comments: issue.comments.map((c) => ({ ...c, body: plain(c.body) })) };
}

export function createDashboard(state: FactoryState, github: GitHub, repo: string, autoApproveDefault = false, workspaces = workspacesDir()) {
export function createDashboard(state: FactoryState, github: GitHub, repo: string, autoApproveDefault = false, workspaces = workspacesDir(), fleet: Pick<FactoryConfig, "agents" | "stages"> = DEFAULT_CONFIG) {
const indexHtml = readFileSync(join(here, "public", "index.html"), "utf8");

const sessions = new Map<string, number>();
Expand Down Expand Up @@ -297,6 +299,12 @@ export function createDashboard(state: FactoryState, github: GitHub, repo: strin
label: "GET /api/analytics",
handler: () => json(analytics(state.listRuns(repo || undefined), repo ? allStageRuns() : [])),
},
{
method: "GET",
pattern: /^\/api\/agents$/,
label: "GET /api/agents",
handler: () => json({ agents: agentCatalog(fleet.agents, fleet.stages).map((a) => ({ ...a, name: plain(a.name), binary: plain(a.binary) })) }),
},
{
method: "GET",
pattern: /^\/api\/inbox$/,
Expand Down
66 changes: 66 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Agents

The factory runs any coding agent that has a CLI. Seven have a built-in preset; anything else runs as a
custom `command`. Only Claude is verified live by the maintainers. The others are verified by
participants ([verify an agent](verify-an-agent.md)), so `factory doctor` warns until a real fixture
backs them.

<!-- agents-table:start -->
| Agent | Binary | Pinned version | Verified live | Read-only stages held by | API keys it may see | Skills dir | Docker |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `claude` | `claude` | `2.1.281` | yes | the stage allow-list under `dontAsk` | `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` | `.claude/skills` | pinned |
| `codex` | `codex` | `0.156.1` | not yet | `-s read-only` | `OPENAI_API_KEY` | `.codex/skills` | pinned |
| `gemini` | `gemini` | `0.61.0` | not yet | `--approval-mode plan` | `GEMINI_API_KEY`, `GOOGLE_API_KEY` | `.gemini/skills` | pinned |
| `opencode` | `opencode` | `1.18.32` | not yet | nothing: `run` has no read-only mode, so only the stage prompt and the gate hold | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `OPENROUTER_API_KEY`, `MISTRAL_API_KEY`, `GROQ_API_KEY`, `XAI_API_KEY`, `DEEPSEEK_API_KEY` | `.opencode/skills` | pinned |
| `cursor` | `cursor-agent` | `2026.01.23-916f423` | not yet | `--mode plan` | `CURSOR_API_KEY` | `.cursor/skills` | host only |
| `pi` | `pi` | `0.73.1` | not yet | `--tools read,grep,find,ls` | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `OPENROUTER_API_KEY`, `MISTRAL_API_KEY`, `GROQ_API_KEY`, `XAI_API_KEY`, `DEEPSEEK_API_KEY` | `.pi/agent/skills` | pinned |
| `mastracode` | `mastracode` | `0.42.0` | not yet | `--mode plan` | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `OPENROUTER_API_KEY`, `MISTRAL_API_KEY`, `GROQ_API_KEY`, `XAI_API_KEY`, `DEEPSEEK_API_KEY` | `.mastracode/skills` | pinned |
<!-- agents-table:end -->

The table is generated from the presets (`bun scripts/gen-agents-doc.ts`); a test fails when it drifts.

## Choose agents

`.factory/config.json` names the agents and which stage each one runs:

```json
{
"agents": { "claude": { "preset": "claude" }, "gemini": { "preset": "gemini", "model": "gemini-3-pro" } },
"stages": { "default": "claude", "verify": "gemini" }
}
```

Install the skills into each agent's own directory with `factory install <repo> --agents claude,gemini`.
Skills stay in `.claude/skills`; the other directories are symlinks to it. A context pointer
(`AGENTS.md`, or `GEMINI.md` for Gemini) is written only when the file is absent.

## What keeps a stage safe, per agent

| Layer | Claude | Codex, Gemini, Cursor, Pi, Mastra Code | OpenCode |
| --- | --- | --- | --- |
| Read-only stages cannot edit files | allow-list | the flag in the table above; the runner writes the artifact | no: stage prompt and gate only |
| Path guard hook | yes | no | no |
| Tool-call cap and timeout | yes | yes | yes |
| Only its own provider API keys in the env (GH_TOKEN and dashboard tokens are always stripped) | yes | yes | yes (all multi-provider keys) |

Everything else the runner does (the gate, plan approval, the verdict) is agent-independent.

## Bring your own agent

Any CLI that takes a prompt works as a `command`. Placeholders: `{{prompt}}`, `{{promptFile}}`,
`{{model}}`. With none, the prompt goes to stdin. The agent gets `FACTORY_ARTIFACT_DIR`,
`FACTORY_ISSUE`, `FACTORY_STAGE` and `FACTORY_SCRATCH_DIR`, and writes each stage's files into
`FACTORY_ARTIFACT_DIR`. Worked example with aider:

```json
{
"agents": { "aider": { "command": ["aider", "--yes-always", "--no-auto-commits", "--message-file", "{{promptFile}}"] } },
"stages": { "default": "aider" }
}
```

With no preset the factory cannot read tokens or count tool calls, so usage shows "not reported"
and the timeout is the only cap. `factory doctor` says so.

Cursor has no versioned download, so the Docker image cannot pin it: run it on the host. Mistral Vibe,
omp and Amp have no preset; use the `command` form.
Loading
Loading