From 9608bc2ed586b550f28e2c013f8009131fb2b698 Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Fri, 2 Oct 2026 20:59:53 +0000 Subject: [PATCH 1/4] fix(web): no console error when the bundle has no import.meta.env factory serve's Bun bundle leaves import.meta.env undefined, so every page load logged a TypeError from the Agentation check (seen in #67's live leg). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/main.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/web/main.tsx b/src/web/main.tsx index 7a1d0fb..8139765 100644 --- a/src/web/main.tsx +++ b/src/web/main.tsx @@ -22,9 +22,10 @@ createRoot(container).render( /** * Agentation's click-to-annotate overlay, dev-only. Dynamically imported so * it never lands in the initial bundle — `factory serve`'s Bun-bundled - * production build has no `FACTORY_AGENTATION` set and never fetches it. + * production build has no `import.meta.env` at all (hence `?.`) and never + * fetches it. */ -if (["1", "true"].includes(import.meta.env.FACTORY_AGENTATION ?? "")) { +if (["1", "true"].includes(import.meta.env?.FACTORY_AGENTATION ?? "")) { void import("agentation").then(({ Agentation }) => { const mount = document.body.appendChild(document.createElement("div")); createRoot(mount).render(); From f65ae0ce4de94d9bdeafa0553fbba3e2c6d202fa Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Fri, 2 Oct 2026 21:05:53 +0000 Subject: [PATCH 2/4] test(e2e): live leg of the ACP agents through factory serve and the UI Finding 14: the implement-issue round trip on claude/haiku and opencode/big-pickle concurrently, an external_directory ask, agent crash, provider error, UI cancel and daemon shutdown. Finding 13 gains the corrections later layers proved (reported vs running Claude model, absolute cwd, mid-turn window, tool titles). Refs #74, #75, #76 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/findings/13-acp-agents.md | 39 +++++- docs/findings/14-acp-live-leg.md | 206 +++++++++++++++++++++++++++++++ docs/findings/README.md | 3 +- 3 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 docs/findings/14-acp-live-leg.md diff --git a/docs/findings/13-acp-agents.md b/docs/findings/13-acp-agents.md index 680a896..4a45577 100644 --- a/docs/findings/13-acp-agents.md +++ b/docs/findings/13-acp-agents.md @@ -54,6 +54,12 @@ step. - **The default comes from the host.** Claude started on `haiku` (the host's `~/.claude/settings.json`), opencode on `opencode/big-pickle`. So a workflow that sets no model runs on whatever the operator's machine says. + + > **Correction (#71, see "Later corrections" below):** "started on `haiku`" is what + > claude-agent-acp *reports* as `currentValue`. With the user's settings switched off, the + > report still says `haiku`, but the Agent SDK runs its own default, a 1M-context model. A + > reported current model is not evidence of the running model. Only an explicit + > `set_config_option` pins it. - **opencode's ids are not the ones factory uses.** The first run asked for `opencode-go/big-pickle`, the id AGENTS.md recommends, and failed: under ACP the model is `opencode/big-pickle`. The adapter refuses an unoffered id, so this surfaced as @@ -106,6 +112,8 @@ So #24's policy ("never ask, allow") moves into the `requestPermission` callback applies to every agent. `opencode.json` and `prepareWorkspace`'s only job go away. One untested case: an opencode ask for a path outside the project (`external_directory`) should now be answered by the callback, where it used to deadlock. The spike never triggered one. +(Settled in finding 14: opencode asked, with title `/tmp/…/outside` and options +`once / always / reject`. The callback chose `always`, and the write landed.) ### 5. Cancel is immediate and leaves no process behind @@ -175,7 +183,8 @@ Both agents produced only AG-UI types the SPA already folds (`RUN_*`, `TEXT_MESS | opencode | default | `opencode/big-pickle` | 715 (providers `opencode-go`, `opencode`, `omniroute`) | | opencode | `XDG_CONFIG_HOME` → empty dir | `opencode/big-pickle` | 39 (`opencode-go`, `opencode`) | -- **Claude's starting model ignores `settingSources`.** claude-agent-acp builds its own +- **Claude's starting model ignores `settingSources`.** (Corrected below: this is the + *reported* model, which is not the one that runs.) claude-agent-acp builds its own `SettingsManager` (`dist/acp-agent.js`, `new SettingsManager(params.cwd, …)`) and takes the user's `model`, `permissions.defaultMode` and `availableModels` from it, whatever the SDK is told. `settingSources` still reaches the SDK (the adapter spreads the client's options over its @@ -277,3 +286,31 @@ Claude not logged in. The second comes from the bundled CLI's `auth status --jso (`bun …/claude-agent-acp/dist/index.js --cli auth status --json`, ~0.25 s): logged out means no claude.ai login, no API key source and the first-party backend. On this host both pass. With `HOME` pointed at an empty dir and opencode off PATH, both are reported. + +## Later corrections (#69, #71, #72, #67; same day) + +Later layers proved parts of this finding wrong. The text above is left as measured. Where it +was wrong: + +- **§1 and the first addendum: Claude's reported model is not its running model.** With + `hostSettings: "ignore"`, claude-agent-acp still reports the user's `model` setting (`haiku`) + as the model option's `currentValue`. The Agent SDK under it runs its own default. The first + production adapter trusted the report and skipped `set_config_option` when the requested model + matched it, so a `haiku` step ran on a 1M-context model. It cost 0.080 USD for 330 output tokens, + and the model called itself "Claude Opus 5.5". After the fix (always set the model, #69 + `61856f2`), the same step reported a 200k window, cost 0.050 USD, and said "I'm Claude Haiku + 4.5". §1's evidence that *setting* the model works (the `set_config_option` reply and + `usage_update._meta["_claude/model"]`) stands. +- **§1: ACP's `cwd` must be absolute.** The spike always passed an absolute scratch path. The + daemon's default workspace root is relative, and claude-agent-acp rejects that (#69, + `7f9e998`). +- **§2: Claude's window size changes mid-turn.** Claude's mid-turn `usage_update`s report + `size: 1000000` for `haiku`. Only the final one, which also carries the cost, reports `200000` + (#72). Cost arrives only on the final update, so a step cancelled mid-turn usually has none. +- **§4: `external_directory`** was triggered and answered in finding 14. +- **§7: tool titles.** On Claude, the title `translateAcpStream` keeps is the generic first one + (`Edit`, `Read File`, `Terminal`). The descriptive title and the real input arrive in later + `tool_call_update`s, and the adapter now forwards them as `acp.tool-call` chunks (#72). +- **"What this does not settle"**: an agent crashing mid-turn and an error from an agent were run + live in finding 14. The crash is detected; its tool processes outlive it (#74). Provider + errors arrive as JSON-RPC errors rather than `RUN_ERROR`. diff --git a/docs/findings/14-acp-live-leg.md b/docs/findings/14-acp-live-leg.md new file mode 100644 index 0000000..445248f --- /dev/null +++ b/docs/findings/14-acp-live-leg.md @@ -0,0 +1,206 @@ +# 14 — ACP agents, live through `factory serve` and the UI + +2026-10-02, issue #67. This is the live leg of epic #62, run on the full stack (#69, #71, #72, +#73). Versions: `@agentclientprotocol/claude-agent-acp` 0.85.1 (Claude Code 2.1.286), +`@agentclientprotocol/sdk` 1.7.0, `@tanstack/ai-acp` 0.3.20, opencode 1.18.31, bun 1.4.2. Models: +Claude `haiku`, opencode `opencode/big-pickle`. + +## Question + +Does the ACP runtime hold up on the paths the spike and the unit tests could not reach? Those +paths are: + +- a real multi-step workflow on each agent, through the daemon and the UI; +- an opencode ask outside the project; +- an agent crashing mid-turn; +- an error reported by an agent; +- cancel from the UI; +- two concurrent runs, one per agent; +- daemon shutdown with agents mid-turn. + +## Setup + +Everything lived in `/tmp/factory-live67/`, away from the production instance on port 3005 and +from GitHub: + +- `origin.git`: a local bare origin with `HEAD → main`, seeded with a bun package + (`src/index.ts` with `greet`, and `src/index.test.ts`). +- `project/`: a clone of it. `factory init` scaffolded `.factory/`, and its generated config + loaded unchanged (`agent: { default: "claude", models: { claude: "sonnet", opencode: + "opencode/big-pickle" } }`). The config was then edited: + - `repo.slug: "local/fixture"`; + - `agent.models.claude: "haiku"`; + - `maxConcurrentRuns: 3`. + + `node_modules/@frebreco/factory` is a symlink to the worktree. +- Two workflows: + - `implement-issue`: `e2e/implement-issue.ts` without the tree-snapshot asserts. It runs + implement → `bun test` → fix/review → `bun test` → structured `pr-metadata` → + `ctx.writeBack`. + - `probe`: one `ctx.agent` step with a free prompt, then `git status`. +- `bin/gh`, a fake `gh` on the daemon's `PATH`. It prints issue #1 ("add slugify") for + `gh issue view` and a fake URL for `gh pr create`. Write-back pushed to the local origin. +- `factory serve --port 3067 --db /tmp/factory-live67/factory.db`, started from inside a Claude + Code session (so with `CLAUDE_*` variables set, which the adapter scrubs). +- The browser was playwright-cli (chromium) in `nix develop`. Run events were read from the test + db. Process trees were walked from the daemon's pid (`ps --ppid`, recursively). +- `/tmp/factory-live67/diag.ts` drove `acpAdapter` directly with `onDiagnostic` for the two + questions the event log cannot answer: whether a permission ask happened, and the agent's raw + stop reason. + +## Results + +| # | scenario | result | +|---|---|---| +| 1 | `implement-issue` on Claude, started from the UI's New run dialog | **pass**: completed in 57.0 s | +| 2 | `implement-issue` on opencode (`factory start --agent opencode`), watched in the UI | **pass**: completed in 133.6 s | +| 3 | two concurrent runs, one per agent (1 and 2 overlapped by 45 s) | **pass** | +| 4 | opencode `external_directory` ask | **pass**: asked, answered `always`, file written | +| 5 | agent crashing mid-turn (`kill -9`) | **pass** for the run (the step fails with the exit code); **defect**: tool processes outlive it → #74 | +| 6 | an error from an agent | **pass**: a provider error fails the step with the agent's message; no live `RUN_ERROR` (see 6) | +| 7 | cancel from the UI, per agent | **pass**: 0 processes left | +| 8 | daemon shutdown with both agents mid-tool | **pass**: exit in 108 ms, both `RunCancelled`, 0 processes 3 s later | + +### 1–3. The multi-step workflow on both agents, concurrently + +| | Claude / haiku | opencode / big-pickle | +|---|---|---| +| `implement` | 26.1 s, 171 chunks, 24.5k/200k ctx, $0.059 | 28.9 s, 180 chunks, 9.7k/200k ctx, $0 | +| `bun test` | exit 0 | exit 0 | +| `fix` (review) | 22.6 s, 23.5k ctx, $0.027 | 95.3 s, 1494 chunks, 20.1k ctx, $0 | +| `bun test` | exit 0 | exit 0 | +| `pr-metadata` | 7.9 s, structured on tier 1 | 9.1 s, structured on tier 1 | +| write-back | `factory/issue-1-add-slugify` pushed | the same branch name collided and was retried as `…-2900c9b0` (D32) | +| run | completed, 57.0 s, run cost **$0.104** in the UI | completed, 133.6 s | + +The Claude run started at 20:44:36 and the opencode run at 20:44:48. They overlapped until +20:45:33 in separate trees under `.factory/workspaces/`. The fake `gh` logged three +`issue view` calls and one `pr create` per run. Once both runs had finished, no +`claude-agent-acp`, `claude` or `opencode acp` process was left. + +In the UI, run detail showed each step row as `171 chunks · 24.5k/200k ctx · $0.059`. The step +inspector showed `agent claude`, `model haiku`, `context 24.5k / 200k · 12%` and the four token +counts. While a Claude step ran, its row read `…/1m ctx` (#72's mid-turn window). + +### 4. opencode asks outside the project + +The `probe` prompt asked opencode to create `/tmp/factory-live67/outside/ext-opencode.txt` with +its write tool and read it back. Through the daemon, the step completed in 5.9 s, the file held +`factory-ext-ok`, and the run's tree stayed clean (`git status` empty). The same prompt through +`diag.ts` shows the ask: + +``` ++6595ms {"kind":"permission","title":"/tmp/factory-live67/outside", + "options":[{"optionId":"once","kind":"allow_once"},{"optionId":"always","kind":"allow_always"}, + {"optionId":"reject","kind":"reject_once"}],"chosen":"always"} ++22550ms {"kind":"done","stopReason":"end_turn",…} +``` + +#24's deadlock does not recur. The callback answers the ask, and no file is written into the +tree. + +### 5. An agent crashing mid-turn + +The `probe` asked the agent to run `python3 -c "import time; time.sleep(120)"`. Claude Code +refuses a long `sleep`, which is why the command uses python. Mid-tool, the agent process was +`kill -9`ed: + +| killed | step result | processes after | +|---|---|---| +| Claude's `claude-agent-acp` | failed at 32.0 s: `claude exited (code 137); stderr:` + the last 12 stderr lines; `RunFailed` | `claude`, its `zsh` and `python3` reparented to init and alive until the `sleep` ended, about 90 s later | +| Claude's `claude` binary (`SIGTERM`) | failed at 17.8 s: `Internal error: The Claude Agent process exited unexpectedly. Please start a new session.` | none | +| `opencode acp` | failed at 6.1 s: `opencode exited (code 137)` | `python3` reparented to init | + +The exit race in the adapter works: the step fails promptly, with the exit code and stderr. The +adapter kills only the agent's own pid, though, and that pid is already dead. Process groups +explain the leftovers: + +- `claude` shares the daemon's group, and its shell runs in its own session; +- opencode's tool has its own group (`pgid == pid`). + +So nothing reaches the orphans. A `SIGTERM` to `claude` does clean up its shell, which is why +the second row leaves nothing. Filed as **#74**, with the options. + +### 6. An error from an agent + +`translateAcpStream` emits `RUN_ERROR` only for `stopReason: "refusal"` +(`@tanstack/ai-acp/dist/esm/stream/translate.js`). Other agent errors come back as a JSON-RPC +error on `session/prompt`, which the adapter turns into a failed step. Attempts: + +- **Refusal:** Anthropic's refusal test string as the prompt, on `haiku` and on `sonnet`. Both + ended `end_turn`; sonnet answered "I don't see a task in your message". The string did not + trigger a refusal through Claude Code. No other benign way to make Claude refuse was found. The + path stays covered by the adapter's unit test (fake agent with `stopReason: "refusal"` → + `RUN_ERROR` chunk + `runError` signal). +- **Provider error:** opencode on `opencode-go/muse-spark-1.3-contributor`, a model this + workspace may not use. Through the daemon, the step failed after 71.6 s (opencode retries + upstream) with `Internal error: Upstream request failed: This Go model trains on request data. + Allow paid endpoints that train on request data in your workspace's Privacy settings to use + it.`, and the run failed with the same message. The step recorded `context: {used: 0, size: + 1048576}`. +- **Gated Claude models:** `claude-fable-5-1` and `claude-opus-4-6` both answered normally, so no + entitlement error was available to provoke. +- **Unknown model:** fails before the prompt, as recorded in #69 and #71. Not repeated here. + +An agent-side error therefore reaches the operator as a failed step carrying the agent's own +message. Two cosmetic points: the transcript has no closing `RUN_ERROR` chunk for it, and the +recorded `error` string has an Effect stack trace appended after the message. + +### 7. Cancel from the UI + +| | started | cancelled at | step | processes 3 s later | +|---|---|---|---|---| +| Claude | New run dialog, `probe` with the python sleep | 40.4 s | `cancelled`, `RunCancelled` | 0 of 4 (`claude-agent-acp`, `claude`, `zsh`, `python3`) | +| opencode | `POST /api/runs` with `agent: {agent: "opencode"}` | 6.9 s | `cancelled`, `RunCancelled` | 0 of 2 (`opencode acp`, `python3`) | + +The button needs two clicks: **cancel**, then **confirm cancel** within 4 s. Driven by separate +playwright-cli calls, the window lapsed twice before both clicks went in one `run-code`. This is +a tooling note, not a defect. The cancelled Claude step recorded `context.size: 1000000`, the +mid-turn figure (see ADR 0013's amendments to §5), and no cost. + +### 8. Daemon shutdown with agents mid-turn + +Two `probe` runs, Claude and opencode, were both inside the python sleep. The process tree before +shutdown: + +``` +claude-agent-acp → claude → zsh → python3 +opencode acp → python3 +``` + +`kill -TERM `. The daemon exited **108 ms** later. Both runs recorded `AgentStepFinished +{outcome: "cancelled"}` and `RunCancelled` at +7.0 s. At the daemon's exit, only `claude` was +still alive, reparented to init. 3 s later none of the six processes were. + +The suspected defect from #73 (the daemon gives up after the 1 s teardown wait while the +adapter's kill timer is 2 s) did not show. Both agents settle `session/cancel` in milliseconds, +and `claude` exits on its own once its stdin closes. It would matter only for an agent that +ignores both. It is recorded in #74, whose fix (kill the agent's process group on exit) would +cover it. + +## Other observations + +- **Claude Code sources the operator's shell.** Every shell command Claude runs is + `zsh -c source ~/.claude/shell-snapshots/snapshot-zsh-.sh … && `. Claude Code + writes the snapshot once per session from the operator's login shell, into + `$CLAUDE_CONFIG_DIR/shell-snapshots/`, and removes it when the session ends (the runs' + snapshot files were gone afterwards). The snapshot holds shell + functions, aliases and options. `settingSources` doesn't cover it: it is host state of the same + kind as `PATH`. Accepted and recorded in ADR 0013 (§3 residue). +- **The New run dialog cannot choose an agent or model.** Its raw-JSON mode edits only the + workflow input. So every opencode run here was started with the CLI or `curl` (the config + default is `claude`). Filed as **#75**. +- **Workspaces clone at the origin's `HEAD`, not `repo.baseBranch`.** This is #72's note, + confirmed in `allocateWorkspace` (`git clone `, no `--branch`). It is pre-existing + and not ACP-specific. Filed as **#76**. +- **Every page load logged a `TypeError`** (`reading 'FACTORY_AGENTATION'`), because + `factory serve`'s bundle has no `import.meta.env`. Fixed on this layer (`?.`). + +## Issues filed + +- #74 (bug, parent #62): a `SIGKILL`ed agent leaves its in-flight tool processes running. +- #75 (task, parent #62): choose agent and model in the New run dialog. +- #76 (bug, no parent: pre-existing): clone the run's tree at `repo.baseBranch`. + +Conclusions → [`adr/0013-agents-over-acp.md`](../adr/0013-agents-over-acp.md) (accepted, with +amendments). diff --git a/docs/findings/README.md b/docs/findings/README.md index db49475..798f0e2 100644 --- a/docs/findings/README.md +++ b/docs/findings/README.md @@ -85,5 +85,6 @@ Conclusions → D43 ([`decisions.md`](../decisions.md)); ADR 0004 carries a supe | Document | Subtask | Headline finding | |---|---|---| | [`13-acp-agents.md`](./13-acp-agents.md) | Spike: one ACP adapter for opencode and Claude Code, through the real runtime | Both agents ran the hello-shaped workflow end to end with a workflow-chosen model, tier-1 structured output, permission answered client-side and a 2–3 ms clean cancel; model ids are per agent (opencode's big-pickle is `opencode/big-pickle`), and usage means "last message" on opencode but "whole turn" on Claude. | +| [`14-acp-live-leg.md`](./14-acp-live-leg.md) | Issue #67: the ACP runtime live through `factory serve` and the UI | Both agents ran the full implement-issue round trip concurrently, and the `external_directory` ask, UI cancel and daemon shutdown all came out clean. A `SIGKILL`ed agent fails its step properly but orphans its in-flight tool processes (#74). Agents report provider errors as JSON-RPC errors, not `RUN_ERROR`. | -Conclusions → [`adr/0013-agents-over-acp.md`](../adr/0013-agents-over-acp.md) (proposed). +Conclusions → [`adr/0013-agents-over-acp.md`](../adr/0013-agents-over-acp.md) (accepted; amended from finding 13's later corrections and finding 14). From e99889365c8f3d0e786f893115543c86da8a3972 Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Fri, 2 Oct 2026 21:05:53 +0000 Subject: [PATCH 3/4] docs(adr): accept ADR 0013 with the implementation's amendments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0013 is accepted, with the implementation's amendments recorded: - always set the model; - a step-start model failure; - opencode isolation via XDG_CONFIG_HOME + shell.env plugin + OPENCODE_TEST_HOME; - the shell-snapshot residue; - latest-reported cost and the mid-turn window; - acp.tool-call; - the absolute cwd; - process cleanup (#74). ADR 0007 is marked superseded, and ADR 0012 notes that §3/§6 are amended. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0007-opencode-serve-per-run-port.md | 5 + docs/adr/0012-agent-runtime-seam.md | 10 ++ docs/adr/0013-agents-over-acp.md | 115 ++++++++++++++++--- 3 files changed, 113 insertions(+), 17 deletions(-) diff --git a/docs/adr/0007-opencode-serve-per-run-port.md b/docs/adr/0007-opencode-serve-per-run-port.md index 3b70c6f..0c0e077 100644 --- a/docs/adr/0007-opencode-serve-per-run-port.md +++ b/docs/adr/0007-opencode-serve-per-run-port.md @@ -2,6 +2,11 @@ ## Status +**Superseded 2026-10-02 by [ADR 0013](./0013-agents-over-acp.md).** Agents now speak ACP over +stdio, one process per step, so there is no `opencode serve` and no port to race for. The +free-port probe was deleted with the opencode adapter (#66). Kept as the record of why the race +existed. + Accepted (2026-09-15). Live-leg-proven, not pre-implementation: the failure it fixes recurred deterministically in phase 5's P6 concurrency leg before it, and the retried leg after the fix passed clean (`docs/findings/11-phase5-live-leg.md`, finding L1). diff --git a/docs/adr/0012-agent-runtime-seam.md b/docs/adr/0012-agent-runtime-seam.md index 61c8860..12e6af1 100644 --- a/docs/adr/0012-agent-runtime-seam.md +++ b/docs/adr/0012-agent-runtime-seam.md @@ -2,6 +2,16 @@ ## Status +**Amended 2026-10-02 by [ADR 0013](./0013-agents-over-acp.md):** + +- **§3** has no job left. Permission asks are answered by the ACP adapter's callback, so + `AgentAdapter.prepareWorkspace` and `StartRunOptions.prepareWorkspace` are deleted (#66). +- **§6** is overtaken. There is no sandbox provider: the agent runs on the host in the run's tree + over stdio. + +§1, §2, §4 and §5 stand. §2's rule held as written: the `AgentSignal` union grew `usage` because +factory gained fields for context and cost. + Accepted, 2026-09-18. Decided **ahead of the implementation**, deliberately: this seam decides what several tickets in the Effect epic are allowed to move, so it has to exist before the code does. Unlike ADR 0001, nothing here is distilled from a spike — it is a boundary statement, and the diff --git a/docs/adr/0013-agents-over-acp.md b/docs/adr/0013-agents-over-acp.md index d074095..dbb8dcd 100644 --- a/docs/adr/0013-agents-over-acp.md +++ b/docs/adr/0013-agents-over-acp.md @@ -2,7 +2,10 @@ ## Status -Proposed, 2026-10-02. Based on the spike in finding 13 (`docs/findings/13-acp-agents.md`), +Accepted, 2026-10-02. Implemented by #63–#66 (PRs #69, #71, #72, #73) and validated live in #67 +(finding 14, `docs/findings/14-acp-live-leg.md`). The implementation amended §2, §3, §5 and §6. +The changes are marked inline and listed under **Amendments** at the end. Proposed the same day, +based on the spike in finding 13 (`docs/findings/13-acp-agents.md`), which ran a two-step workflow through the real runtime on both agents. The approach comes from the sibling project canvas, where it runs in production use (canvas findings 01, 04, 06, 12). @@ -96,7 +99,8 @@ invalid `sonnet`. that default comes from the host's settings (finding 13 §1). For the same reason it is not enough to switch the host's settings off (§3): claude-agent-acp reads the user's model setting regardless. `DEFAULT_MODEL` is deleted. `factory init` writes `agent.default` and -`agent.models`. An unresolvable model fails the run at start. +`agent.models`. An unresolvable model fails the step at its start, before `AgentStepStarted` and +before any agent process (amended: not the run at start, see Amendments). **A model the agent does not offer fails the step before the prompt is sent.** The error names the agent and lists some of the models it does offer (finding 13 §1). Model ids are passed @@ -135,11 +139,13 @@ does not copy them into its config. `XDG_CONFIG_HOME` pointed at an empty directory works: the global plugins and the custom `omniroute` provider disappear, while the auth-based `opencode` and `opencode-go` providers remain. But the variable is inherited by every tool the agent runs, which would hide - `~/.config/gh` (gh's auth) and git's XDG config too. **The exact mechanism is left to the - implementation ticket**: an opencode-specific variable if one exists, otherwise a directory - that leaves those tools' configs in place. + `~/.config/gh` (gh's auth) and git's XDG config too. The implementation ticket chose the + mechanism (amended, see Amendments): `XDG_CONFIG_HOME` and `OPENCODE_TEST_HOME` point at empty, + factory-owned directories, and a factory plugin hands the host's `XDG_CONFIG_HOME` back to + every shell command. - **Both:** the agent's environment is scrubbed of the parent's `CLAUDE_*` / `CLAUDECODE` - variables. A daemon started from inside a Claude Code session otherwise passes its + variables, except the credential ones (`CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_OAUTH_TOKEN`, + `CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_USE_VERTEX`). A daemon started from inside a Claude Code session otherwise passes its effort, subagent model and session ids down to the agents it runs. ### 4. Headless permission is the client's answer @@ -168,7 +174,8 @@ validated like the model. It is not added now. step): - `context: { used, size }` — the context in use and the window size, as the agent measured them; -- `cost: { amount, currency }` — what the agent says the step cost. +- `cost: { amount, currency }` — what the agent says the step cost: the latest cost any + `usage_update` of the step carried (amended, see Amendments). opencode reports `cost` as `0 USD` for a free model; it is recorded as reported. @@ -192,7 +199,9 @@ one prompt. The process ends with the step. - **No session pool:** canvas keeps sessions open because people return to them; a step never does. - **Cancel:** sends ACP `session/cancel`, then kills the process after a 2 s grace and in - `finally`. Both agents settle the turn as `cancelled` in milliseconds. + `finally`. Both agents settle the turn as `cancelled` in milliseconds. Finding 14 confirms + this from the UI and on daemon shutdown. An agent killed from outside (`SIGKILL`) fails the + step with its exit code, but its in-flight tool processes keep running (#74). - **Port race:** gone (ADR 0007), since stdio needs no port. - **Sandbox:** the agent runs on the host in the tree, exactly as under `localProcessSandbox`. A future container sandbox wraps the command (`docker exec -i …`), which stdio makes simpler @@ -215,18 +224,90 @@ An MCP `submit_output` tool would give schema-validated output from both agents Old run logs stay readable, because chunks are opaque. - **Transcripts show coarser tool names**: ACP tool kinds (`read`, `edit`, `execute`) instead of opencode's own. The tool's title (`Edit math.ts`) is in the ACP update but does not reach - `TOOL_CALL_START`; the SPA may want to read it from the chunk's raw event. -- **`agent-step.ts` gets simpler.** The abort-chain commentary and the teardown grace were about - `@tanstack/ai`'s chat engine sitting on an aborted opencode stream. `abortableIterable` stays - until the replay and fake adapters are checked against the simpler contract. + `TOOL_CALL_START`. The adapter forwards it in an `acp.tool-call` chunk (see Amendments). +- **`agent-step.ts` gets simpler.** The abort-chain commentary went. `abortableIterable` and + the 1 s `AGENT_TEARDOWN_GRACE_MS` stay (#66). A generator's `return()` queues behind a pending + `next()`, which is generic to any adapter that waits on its agent. - **New dependencies:** `@agentclientprotocol/sdk`, `@agentclientprotocol/claude-agent-acp` (bundles the Claude Agent SDK) and `@tanstack/ai-acp`. `@tanstack/ai` moves to 0.64. + `@tanstack/ai-sandbox` stays, because `@tanstack/ai-acp` imports it at runtime. - **Claude runs need a Claude login (or API key) on the daemon's host**, and opencode runs need `opencode` on `PATH`. The daemon checks the configured agents at start and names what is missing, rather than failing the first step. - **AGENTS.md's model advice changes**: the preferred test model is `opencode/big-pickle`. -- **Untested until the implementation:** - - an opencode ask outside the project (`external_directory`), now answered by the callback; - - an agent crashing mid-turn (the exit race is wired, never triggered); - - `RUN_ERROR` from an agent; - - whether Claude's `settingSources` also keeps the user's skills and `CLAUDE.md` out. +- **Untested until the implementation**, now settled: + - an opencode ask outside the project (`external_directory`) is answered by the callback + (`allow_always`), and the step completes (finding 14); + - an agent crashing mid-turn fails the step with its exit code and stderr tail (finding 14). + Its tool processes outlive it (#74); + - an error from an agent: ACP agents report provider errors as a JSON-RPC error on + `session/prompt`, not as a stop reason. The step fails with the agent's message. + `translateAcpStream` emits `RUN_ERROR` only for `stopReason: "refusal"`, which no live + attempt provoked; the adapter's unit test covers it (finding 14); + - Claude's `settingSources` keeps the user's skills, plugins and `CLAUDE.md` out (finding 13, + addendum 2). + +## Amendments + +What the implementation (#63–#66) and the live leg (#67) changed or settled. The decisions above +are edited only to point here. + +- **§2: the model is always set, even when it already looks current.** With host settings + ignored, claude-agent-acp reports the user's `model` setting (`haiku` on the spike's host) as + the session's current model. The Agent SDK actually runs its own default, a 1M-context model. + An adapter that skipped `set_config_option` on a match ran `haiku` steps on that default + (0.080 USD for 330 output tokens, and the model said it was Opus). The adapter now always + sends the model (#69, `61856f2`). Finding 13 §1's reading of Claude's `currentValue` is + corrected there. +- **§2: an unresolvable model fails the step, not the run.** A per-call option can still supply + the model, so the run can't know at `RunStarted`. The failure comes before `AgentStepStarted` + and before any process is spawned. The error names the levels that could supply a model. There + is no built-in fallback for config-less paths (#40's acceptance asked for one): factory keeps no + model of its own. Without a config, the agent is the built-in `DEFAULT_AGENT` (`"opencode"`) + and every step must name its model. +- **§2: the run level.** `factory start --agent/--model` and `POST /api/runs`'s + `agent: { agent?, model? }` are the run level, beside a schedule's `agent`. This repository's + own config defaults to `opencode`, so its runs stay on a free model. `factory init` writes + `claude`. +- **§3: the opencode mechanism.** `OPENCODE_CONFIG_DIR` only adds a directory, so it hides + nothing. Three things together keep opencode off the host's settings: + - `XDG_CONFIG_HOME` points at an empty factory directory. This hides `~/.config/opencode`. + - A factory plugin (`acp-opencode-plugin.ts`, loaded through `OPENCODE_CONFIG_CONTENT`) uses + opencode's `shell.env` hook to restore the host's `XDG_CONFIG_HOME` for every shell command, + so `gh` and `git` keep their config. + - `OPENCODE_TEST_HOME` points at an empty directory. This hides opencode's home-level reads: + `~/.claude/skills`, `~/.agents/skills`, `~/.claude/CLAUDE.md` and `~/.opencode`. + + `OPENCODE_TEST_HOME` is undocumented, a test hook by its name. If opencode drops it, the host's + skills come back into runs and nothing breaks. The host's `OPENCODE_CONFIG*` variables are + dropped. Evidence: finding 13, addendum 2. +- **§3: residue that `settingSources` does not cover.** Claude Code snapshots the operator's + login shell into `$CLAUDE_CONFIG_DIR/shell-snapshots/` once per session, and sources the + snapshot before every shell command (`zsh -c source …/snapshot-zsh-*.sh …`). The snapshot holds + shell functions, aliases and options. The agent's shell therefore carries the operator's shell + setup, much as it carries their `PATH`. This is accepted. Hiding it would mean replacing + `CLAUDE_CONFIG_DIR`, which is where the login lives (finding 14). +- **§4.** `prepareWorkspace` and `writeHeadlessPermissions` are gone (#66), as decided. Write-back's + stray-artifact cleanup (D16: `.tanstack-projected-*` and a top-level `data/`) went with them. + It deleted real changes under `data/`, and nothing writes those artifacts any more. + `WriteBackFinished.cleanedArtifacts` is optional. +- **§5: cost is "latest reported".** Claude sends cost only on a turn's final `usage_update`. The + figure is cumulative per session, and a step is one session, so an update without a cost + keeps the earlier figure. A Claude step cancelled mid-turn usually records no cost. Claude's + mid-turn updates report a 1M window for `haiku`, and only the final one reports 200k. So a + step cancelled mid-turn records the 1M figure (finding 14). Factory records what the agent + says. +- **§5 / Consequences: tool titles.** `translateAcpStream` keeps only a tool call's first title, + which on Claude is generic (`Edit`, `Terminal`). The adapter follows each titled + `tool_call` / `tool_call_update` with a `CUSTOM` `acp.tool-call` chunk + (`{ toolCallId, title, input? }`, no signal). The SPA labels transcript tool calls with it (#72). +- **§6: ACP needs an absolute `cwd`.** The default workspace root (`.factory/workspaces`) is + relative, so the adapter resolves it (#69, `7f9e998`). +- **§6: process cleanup.** Cancel, daemon shutdown and a `SIGTERM`ed `claude` binary leave no + process behind (finding 14). An agent `SIGKILL`ed from outside leaves its in-flight tool + processes running, because the adapter kills only the agent's own pid (#74). The daemon's 1 s + teardown wait is shorter than the adapter's 2 s kill timer. That matters only for an agent that + ignores `session/cancel` and stdin's close, and none did live. +- **Replay corpus.** Re-recorded through the ACP adapter for both agents + (`scripts/record-corpus.ts`). The line format grows to `{step, chunk, signal?}`, so replay + knows no agent's event names (#66). From a6f32b85542bac272d9fd677c018e8ac79f5ea06 Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Fri, 2 Oct 2026 21:05:53 +0000 Subject: [PATCH 4/4] docs: agent and model choice, Claude and opencode requirements Site docs, README and the factory init template now cover: - agent/model at every level and the resolution rule; - agent.default, agent.models and hostSettings; - --agent/--model; - a Claude login or opencode on PATH. AGENTS.md now describes the ACP runtime and gives opencode/big-pickle and Claude haiku/sonnet as the test models. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 9 ++++-- README.md | 40 ++++++++++++++++++++------ sample/README.md | 2 +- sample/workflows/implement-issue.ts | 2 +- site/content/docs/before-you-run-it.md | 13 +++++---- site/content/docs/cli.md | 5 ++++ site/content/docs/configuration.md | 30 +++++++++++++++++++ site/content/docs/quick-start.md | 15 +++++++--- site/content/docs/writing-workflows.md | 30 +++++++++++++++++-- src/init.ts | 14 +++++++-- 10 files changed, 135 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 67ab22f..20c4b3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,9 @@ factory the SPA), `bun:sqlite` for persistence - Effect v4 (`4.0.0-rc.*`) for the run lifecycle and scheduling; Effect Schema is the schema language throughout — workflow inputs, agent structured output, config. - - `@tanstack/ai` with `-opencode` and `-sandbox-local-process` for the agent runtime. + - agent runtime: one ACP adapter (`@agentclientprotocol/sdk`) driving `opencode acp` and Claude Code + (`@agentclientprotocol/claude-agent-acp`); `@tanstack/ai-acp` turns ACP updates into AG-UI chunks + (ADR 0013) - React 19 SPA: tanstack router and query, tailwind v4, shadcn primitives - oxfmt, oxlint (type-aware, with the `@effect/tsgo` and react rules) - playwright for the browser legs @@ -48,4 +50,7 @@ factory - a prod instance of factory may be running on this machine (`bun src/cli serve --port 3005`, log at `/tmp/factory-serve.log`) - we use factory to build factory - - when doing real agent tests via opencode the preferred model is `opencode-go/big-pickle` + - real agent tests: opencode on `opencode/big-pickle` (free), Claude on `haiku` (cheap) or `sonnet`. + Model ids are the agent's own (`opencode/big-pickle`, not `opencode-go/big-pickle`). Live legs run a + scratch daemon on a spare port (3005 is prod) over a local bare origin and a fake `gh`, so + write-back stays local (`docs/findings/14-acp-live-leg.md` has the setup) diff --git a/README.md b/README.md index f4d30ce..6d44d7a 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,12 @@ bun add -d @frebreco/factory ``` **Bun ≥ 1.4.1 is required** — factory ships TypeScript and runs on Bun. You -also need the [`opencode`](https://opencode.ai) CLI, logged in (that is the -agent doing the work), and [`gh`](https://cli.github.com), authenticated (that -is what opens the pull requests). +also need a coding agent, logged in on this machine (that is what does the +work): **Claude Code** (a Claude login via the `claude` CLI, or +`ANTHROPIC_API_KEY`; factory bundles the agent itself) or +[**opencode**](https://opencode.ai) (the CLI on `PATH`, with a provider logged +in). And [`gh`](https://cli.github.com), authenticated (that is what opens the +pull requests). `factory serve` names whatever is missing when it starts. ### 2. Initialise @@ -116,7 +119,7 @@ import { defineWorkflow, Schema } from "@frebreco/factory"; export default defineWorkflow("my-workflow", { input: Schema.Struct({ topic: Schema.String }), output: Schema.Struct({ prUrl: Schema.NullOr(Schema.String) }), - agent: { model: "opencode-go/glm-5.3-flash" }, + agent: { agent: "opencode", model: "opencode/big-pickle" }, run: async (ctx, input) => { /* ... */ @@ -129,7 +132,7 @@ export default defineWorkflow("my-workflow", { | `id` | The name you start it by — in the UI's picker and in `factory start ` | | `input` | A schema. It generates the UI's form and validates `--input` **before** the run starts | | `output` | Optional. What `run` resolves to, recorded in the run's history | -| `agent` | Optional defaults for every agent step — mainly `model` | +| `agent` | Optional defaults for every agent step: `agent` (`claude` / `opencode`) and `model` | | `run` | Your workflow: `async (ctx, input) => output` | ### The `ctx` you get @@ -158,6 +161,18 @@ meta.output; // { title, body } | undefined meta.finalText; // the raw text, always ``` +### Choosing the agent and model + +`agent` and `model` can be named on the `ctx.agent` call, the run +(`factory start --agent/--model`, a schedule's `agent`), the workflow, and the +config (`agent.default`, `agent.models`). The most specific level that names an +agent wins. The model comes from the most specific level that names one, at or +above the level that chose the agent, and otherwise from `agent.models[agent]`. +A model never carries across to another agent. Ids are the agent's own (`haiku`, +`sonnet`, `opus`; `opencode/big-pickle`), passed verbatim. Factory always sends +one, and a step with no model, or with one the agent doesn't offer, fails +before the prompt is sent. + ### Registering it A workflow exists because it is imported into your config. There is no @@ -191,6 +206,10 @@ export default defineConfig({ identity: { name: "Factory", email: "factory@acme.example" }, }, workflows: [hello], + agent: { + default: "claude", + models: { claude: "sonnet", opencode: "opencode/big-pickle" }, + }, maxConcurrentRuns: 2, retainedWorkspaces: 10, }); @@ -200,6 +219,9 @@ export default defineConfig({ | -------------------- | -------------------- | ------------------------------------------------------------------------------------ | | `repo` | — | The repository runs clone from, and where write-back opens PRs | | `workflows` | — | The registry. Imported = available | +| `agent.default` | `"opencode"` | The agent a step runs on unless something more specific names one | +| `agent.models` | `{}` | Each agent's model unless something more specific names one. No built-in model | +| `agent.hostSettings` | `"ignore"` per agent | `"include"` lets runs see your own `~/.claude` / `~/.config/opencode` settings | | `maxConcurrentRuns` | `3` | Runs allowed in flight at once. Over the limit is **refused, not queued** | | `retainedWorkspaces` | `10` | Finished working trees kept for inspection, oldest evicted first. Must be ≥ the limit | | `workspaceRoot` | `.factory/workspaces`| Where per-run working trees live | @@ -216,6 +238,7 @@ your workflows are not: commit those. factory init [--dir ] [--force] # scaffold .factory/ (never overwrites without --force) factory serve [--port ] [--db ] # the daemon: HTTP API, SSE, and the web UI factory start --input [--watch] [--url ] + [--agent claude|opencode] [--model ] factory runs # every run this project has recorded factory log # replay one run's full history ``` @@ -283,13 +306,14 @@ simply serves the API and the UI — nothing runs unless you ask for it. Factory is a proof of concept, and it is honest about what it is not: - **There is no sandbox isolation.** Agents run as your user, on your machine, - with your `gh` and `opencode` credentials, in a clone of your repository. - Point it at repositories and issues you trust. + with your `gh`, Claude and opencode credentials, in a clone of your + repository, and every permission they ask for is granted. Point it at + repositories and issues you trust. - **Workflow inputs reach the agent's prompt.** Anyone who can reach the daemon can start a run, so do not expose the port beyond your machine, and never interpolate an input straight into `ctx.exec`. - **It needs your machine's logins.** There is no credential injection yet: - `opencode` and `gh` must be authenticated on the host. + `gh` and the agents you use must be authenticated on the host. --- diff --git a/sample/README.md b/sample/README.md index 4278d1d..8cc1460 100644 --- a/sample/README.md +++ b/sample/README.md @@ -27,7 +27,7 @@ authoring surface (D1: plain async control flow over `ctx`): 1. `ctx.agent("implement", ...)` — the agent reads the issue with `gh issue view` and implements it with tests (all steps run on - `opencode-go/glm-5.3-flash`, set as the workflow's `agent.model`). + opencode with `opencode-go/glm-5.3-flash`, set as the workflow's `agent`). 2. `ctx.exec(["bun", "test"])` — non-zero exit is a branch, not a throw. 3. one conditional fix step if the tests failed; the tests run again. 4. `ctx.agent("pr-metadata", ...)` — structured output supplies diff --git a/sample/workflows/implement-issue.ts b/sample/workflows/implement-issue.ts index 47747ca..4ea982a 100644 --- a/sample/workflows/implement-issue.ts +++ b/sample/workflows/implement-issue.ts @@ -42,7 +42,7 @@ const PrMetadataOutput = Schema.Struct({ export default defineWorkflow("implement-issue", { input: Input, output: Output, - agent: { model: "opencode-go/glm-5.3-flash" }, + agent: { agent: "opencode", model: "opencode-go/glm-5.3-flash" }, run: async (ctx, input) => { // Step 1: implement the issue in the prepared tree (cloned by the runtime). await ctx.agent( diff --git a/site/content/docs/before-you-run-it.md b/site/content/docs/before-you-run-it.md index 5ccdf5e..a945da0 100644 --- a/site/content/docs/before-you-run-it.md +++ b/site/content/docs/before-you-run-it.md @@ -6,11 +6,14 @@ order: 7 Factory is a proof of concept, and it's honest about what it is not. -- **There's no sandbox isolation.** Agents run as your user, on your machine, with your `gh` and - `opencode` credentials, in a clone of your repository. Point it at repositories and issues you - trust. +- **There's no sandbox isolation.** Agents run as your user, on your machine, with your `gh`, + Claude and opencode credentials, in a clone of your repository. Every permission an agent asks + for is granted, including writes outside the working tree. Point it at repositories and issues + you trust. - **Workflow inputs reach the agent's prompt.** Anyone who can reach the daemon can start a run, so don't expose the port beyond your machine, and never interpolate an input straight into `ctx.exec`. -- **It needs your machine's logins.** There's no credential injection yet: `opencode` and `gh` - must be authenticated on the host. +- **It needs your machine's logins.** There's no credential injection yet: `gh` and the agents + you use (a Claude login, or `opencode` with a provider) must be authenticated on the host. + Your own agent settings stay out of runs by default (`agent.hostSettings`), but your shell + setup doesn't: Claude Code sources a snapshot of your login shell before each command. diff --git a/site/content/docs/cli.md b/site/content/docs/cli.md index 7c004b1..98a87a4 100644 --- a/site/content/docs/cli.md +++ b/site/content/docs/cli.md @@ -8,6 +8,7 @@ order: 6 factory init [--dir ] [--force] # scaffold .factory/ (never overwrites without --force) factory serve [--port ] [--db ] # the daemon: HTTP API, SSE, and the web UI factory start --input [--watch] [--url ] + [--agent claude|opencode] [--model ] factory runs # every run this project has recorded factory log # replay one run's full history ``` @@ -16,6 +17,10 @@ factory log # replay one run's full history else. `start` talks to a running daemon over HTTP: set `--url` or `FACTORY_URL` if it's not on `http://localhost:3000`. +`--agent` and `--model` choose the agent and model for this run's steps, unless a step names its +own. A model given without `--agent` applies to the run's agent: the config default, or whatever +the workflow names. + `factory start --watch` exits `0` when the run completes, `1` when it fails, and `130` when it's cancelled. diff --git a/site/content/docs/configuration.md b/site/content/docs/configuration.md index ca56516..242663f 100644 --- a/site/content/docs/configuration.md +++ b/site/content/docs/configuration.md @@ -18,6 +18,10 @@ export default defineConfig({ identity: { name: "Factory", email: "factory@acme.example" }, }, workflows: [hello], + agent: { + default: "claude", + models: { claude: "sonnet", opencode: "opencode/big-pickle" }, + }, maxConcurrentRuns: 2, retainedWorkspaces: 10, }); @@ -27,10 +31,36 @@ export default defineConfig({ | --------------------- | ----------------------- | ------------------------------------------------------------------------------------ | | `repo` | — | The repository runs clone from, and where write-back opens PRs. | | `workflows` | — | The registry. Imported means available. | +| `agent.default` | `"opencode"` | The agent a step runs on when nothing more specific names one: `"claude"` or `"opencode"`. | +| `agent.models` | `{}` | Each agent's model when nothing more specific names one. There is no built-in model. | +| `agent.hostSettings` | `"ignore"` per agent | Whether runs see your own agent settings. See below. | | `maxConcurrentRuns` | `3` | Runs allowed in flight at once. Over the limit is refused, not queued. | | `retainedWorkspaces` | `10` | Finished working trees kept for inspection, oldest evicted first. Must be at least the limit. | | `workspaceRoot` | `.factory/workspaces` | Where per-run working trees live. | +`factory init` writes `default: "claude"` and a model for both agents. A run's steps need a model +from some level ([Choosing the agent and model](/docs/writing-workflows#choosing-the-agent-and-model)): +with no `agent.models` entry, every step has to name one. + +## Agents and your machine's settings + +A run uses the project's agent configuration: the repository's `CLAUDE.md`, `AGENTS.md`, +`.claude/` and `opencode.json`. By default it ignores yours, so a run behaves the same whichever +machine it runs on: + +- **Claude** loads project and local settings only. Your `~/.claude` skills, plugins and + `CLAUDE.md` stay out. +- **opencode** gets an empty config and home directory of its own. Your `~/.config/opencode` + plugins and providers, and the skills under `~/.claude` and `~/.agents`, stay out. The agent's + shell commands still see your real config directory, so `gh` and `git` keep working. + +Logins are not settings: Claude's login and opencode's provider auth stay available either way. +To give runs your own skills, plugins or providers, opt in per agent: + +```ts +agent: { hostSettings: { claude: "include", opencode: "ignore" } } +``` + Everything factory generates at runtime — the event database, the working trees — lives under `.factory/` and is gitignored by `init`. Your config and your workflows are not: commit those. diff --git a/site/content/docs/quick-start.md b/site/content/docs/quick-start.md index c28dc51..c93109e 100644 --- a/site/content/docs/quick-start.md +++ b/site/content/docs/quick-start.md @@ -12,9 +12,15 @@ Factory goes in your project as a dev dependency. bun add -d @frebreco/factory ``` -Bun 1.4.1 or later is required. Factory ships TypeScript and runs on Bun. You also need the -[`opencode`](https://opencode.ai) CLI, logged in (that's the agent doing the work), and -[`gh`](https://cli.github.com), authenticated (that's what opens the pull requests). +Bun 1.4.1 or later is required. Factory ships TypeScript and runs on Bun. You also need: + +- a coding agent, logged in on this machine. That's what does the work. + - **Claude Code**: a Claude login (sign in once with the `claude` CLI) or `ANTHROPIC_API_KEY`. + Factory bundles the agent itself. + - **opencode**: the [`opencode`](https://opencode.ai) CLI on `PATH`, with a provider logged in. +- [`gh`](https://cli.github.com), authenticated. That's what opens the pull requests. + +`factory serve` checks the agents your config uses when it starts, and logs what's missing. ## 2. Initialize @@ -25,7 +31,8 @@ bunx factory init That writes a config and one small workflow, and adds the right lines to your `.gitignore`. `init` reads your repo's `origin` remote and git identity to fill the config in, so there is usually nothing to edit. Open `.factory/factory.config.ts` and check the `repo` block points at the -repository you want pull requests opened against. +repository you want pull requests opened against. The `agent` block picks Claude Code on `sonnet`; +switch `default` to `"opencode"` if that's the agent you have. ## 3. Start the daemon diff --git a/site/content/docs/writing-workflows.md b/site/content/docs/writing-workflows.md index 7277fc4..4e74de2 100644 --- a/site/content/docs/writing-workflows.md +++ b/site/content/docs/writing-workflows.md @@ -13,7 +13,7 @@ import { defineWorkflow, Schema } from "@frebreco/factory"; export default defineWorkflow("my-workflow", { input: Schema.Struct({ topic: Schema.String }), output: Schema.Struct({ prUrl: Schema.NullOr(Schema.String) }), - agent: { model: "opencode-go/glm-5.3-flash" }, + agent: { agent: "opencode", model: "opencode/big-pickle" }, run: async (ctx, input) => { /* ... */ @@ -26,7 +26,7 @@ export default defineWorkflow("my-workflow", { | `id` | The name you start it by — in the UI's picker and in `factory start ` | | `input` | A schema. It generates the UI's form and validates `--input` before the run starts | | `output` | Optional. What `run` resolves to, recorded in the run's history | -| `agent` | Optional defaults for every agent step — mainly `model` | +| `agent` | Optional defaults for every agent step: `agent` and `model` | | `run` | Your workflow: `async (ctx, input) => output` | ## The `ctx` you get @@ -55,6 +55,32 @@ meta.output; // { title, body } | undefined meta.finalText; // the raw text, always ``` +## Choosing the agent and model + +A step runs on one of two coding agents, `claude` (Claude Code) or `opencode`, on a model that +agent offers. You can name either at four levels, from most to least specific: + +1. the call: `ctx.agent("review", prompt, { agent: "claude", model: "opus" })`; +2. the run: `factory start --agent/--model`, `agent` in `POST /api/runs`, or a schedule's `agent`; +3. the workflow: `defineWorkflow`'s `agent` field; +4. the config: `agent.default` and `agent.models` (see [Configuration](/docs/configuration)). + +The agent comes from the most specific level that names one. The model comes from the most specific +level that names one, but only among the level that chose the agent and the levels above it in +that list. If none of them names a model, the agent's entry in `agent.models` applies. So a +model never carries over to a different agent. A workflow pinned to +`{ agent: "claude", model: "sonnet" }` that calls `ctx.agent(name, prompt, { agent: "opencode" })` +runs that step on opencode's configured model, not on `sonnet`. + +Model ids belong to the agent and are passed through verbatim: `sonnet`, `haiku`, `opus` or a full +`claude-…` id for Claude, and `provider/model` for opencode (`opencode/big-pickle`). Factory always +sends a model and keeps no default of its own. A step fails before the agent starts if no level +names a model. It also fails before the prompt is sent if the agent doesn't offer the model, and +the error lists some of the ids the agent does offer. + +Every permission the agent asks for is granted, including access to files outside the working +tree. A run never waits on a question nobody is there to answer. + ## Registering it A workflow exists because it's imported into your config. There's no directory scan and no magic: diff --git a/src/init.ts b/src/init.ts index 12c986f..0798080 100644 --- a/src/init.ts +++ b/src/init.ts @@ -146,13 +146,21 @@ export default defineConfig({ // The coding agent a step runs on ("claude" or "opencode") unless the // workflow, the run or the ctx.agent call names one, and the model each // agent runs on unless one of those names a model. Factory always sends a - // model; ids are the agent's own and passed verbatim. + // model; ids are the agent's own and passed verbatim ("haiku", "opus", + // "opencode/big-pickle", …). Claude needs a Claude login on this machine + // (log in with the \`claude\` CLI, or set ANTHROPIC_API_KEY); opencode needs + // \`opencode\` on PATH, logged in. \`factory serve\` names what is missing at start. agent: { default: "claude", models: { claude: "sonnet", opencode: "opencode/big-pickle", }, + // Runs read this project's agent settings (CLAUDE.md, AGENTS.md, + // .claude/, opencode.json) but not yours from ~/.claude or + // ~/.config/opencode. Set an agent to "include" to give runs your own + // skills, plugins and providers. Logins are available either way. + // hostSettings: { claude: "ignore", opencode: "ignore" }, }, // How many runs may be in flight at once. A run over the limit is refused, @@ -195,7 +203,9 @@ export default defineWorkflow("hello", { run: async (ctx, input) => { // 1. One agent step. \`ctx.dir\` is a working tree the runtime cloned for - // this run alone — the agent is already in it. + // this run alone — the agent is already in it. It runs on the config's + // default agent and model; a third argument such as + // \`{ agent: "opencode", model: "opencode/big-pickle" }\` picks others. await ctx.agent( "implement", \`You are working in a git checkout of this project.