A fleet manager for OpenCode agents over the HTTP API: plan a DAG, dispatch dozens of parallel sessions, give every agent its own lane, and verify the artifacts instead of trusting the agent. A headless foreman for OpenCode.
Requirements: Python 3.9+, and OpenCode installed
(opencode --version works) with its server running:
opencode serve --service # picks a random port; oc-fleet finds itThen install oc-fleet and check the wiring:
git clone https://github.com/DavidPandleton/oc-fleet.git
cd oc-fleet
pipx install . # or: uv tool install .
oc-fleet doctor # checks python, opencode, the server, models, configdoctor prints one line per check and a summary at the end; every [FAIL]
names its fix, and it is safe to re-run at any time. Once it reports
0 failed, dispatch the first task and read the result:
oc-fleet dispatch "List the Python files in this repo" --workdir "$PWD"
oc-fleet show ses_xxx # outcome and the agent's replyOptionally pick a model for each role (the wizard reads the live model catalog from your server):
oc-fleet initMore: oc-fleet stats, oc-fleet sessions 5, oc-fleet watch,
oc-fleet --help. The web dashboard is a single standard-library file:
python3 web/dashboard.py (port 8787).
cd oc-fleet
git pull
pipx install . --force # or: uv tool install . --forceStart with oc-fleet doctor; it names the failing piece and the fix. The
most common walls:
cannot reach the OpenCode server- the server is not running. Start it withopencode serve --service(it picks a random port and oc-fleet finds it automatically).the OpenCode server rejected the credentials- the password changed, usually after a manual restart. Starting the server with--servicerewrites~/.config/opencode/service.json, which oc-fleet reads;$OPENCODE_SERVER_PASSWORDworks too.model must be 'provider/model-id'- model slugs need a provider prefix, e.g.cutad/deepseek-v4-flash.oc-fleet doctorlists what your server offers.ModuleNotFoundErrorfrom a runner or launcher - start the installedoc-fleetcommand, or one of the launch styles listed under Architecture.
python3 web/dashboard.py --port 8787The dashboard uses only the Python standard library. No npm or Flask is
required. Open http://127.0.0.1:8787 to see:
- summary statistics for sessions, tool calls, and success rate
- a recent-session table that refreshes every five seconds
- live completions through an SSE stream
- a browser form for dispatching tasks directly
oc-prompt-lint "Think step by step. Improve everything."
oc-prompt-lint --file task.txt
echo "prompt" | oc-prompt-lint(python3 prompt_lint.py ... works as well when running from a checkout.)
The linter checks prompts against harness behavior measured through 16 controlled probes. It reports ineffective fluff such as chain-of-thought, urgency, and role instructions; unlimited scope; unverifiable output; em-dashes; and destructive verbs without preservation constraints.
The fluff, VAGUE-OUTPUT, and DESTRUCTIVE-NO-GUARD rules understand
negation. A prompt that forbids something is not reported as if it requested
that thing. For example, "Do not use chain of thought" is not the same as
"Think step by step", and "Do not delete anything" is not the same as
"Delete everything". Negation is parsed per sentence, with comma-separated
clauses treated as boundaries, so "Do not retry, and think step by step"
still reports the second clause.
Four additional rules cover specification gaps that can cause agents to invent plausible but incorrect behavior:
| Rule | Detects |
|---|---|
SPEC-UNDEFINED-EDGE |
Open-ended enumerations such as etc and and so on |
SPEC-TEST-ONLY-VALID |
Prompts that verify only the happy-path examples |
SPEC-NO-INVALID-CONTRACT |
Implementation prompts that omit invalid-input behavior |
SPEC-VERIFY-SELF-REFERENTIAL |
Self-referential verification instead of independent evidence |
For example, an agent asked to parse durations may be given valid forms but
not told what "1h1h" means. It can invent a rule that units must be in
descending order, reject legal input, and then mark its own tests as passing.
Three more rules measure a task that is too large for one session, the shape
that produced an 18-minute silent scaffold in the coffee-catalog run:
| Rule | Detects |
|---|---|
BIG-TASK-MANY-STEPS |
Four or more ordered steps chained into one task |
BIG-TASK-MANY-ARTEFACTS |
Five or more distinct files named in one task |
BIG-TASK-NO-CHECKPOINT |
A large task with no stopping point to verify partway |
All three are warnings, never errors. A large prompt is a smell, not a syntax error, and only the foreman knows whether splitting it is worth an extra dispatch. The thresholds sit above the two-file, two-step prompts that already work, so the rule adds signal without flagging every clean task. When a task this size fails, there is no smaller unit to point at, so a failure names one step instead of the whole build.
from orchestrator import Orchestrator, Task
o = Orchestrator(max_parallel=4)
o.add(Task(id="a", prompt="Scaffold module", workdir="/repo"))
o.add(Task(id="b", prompt="Write tests", workdir="/repo", depends_on=["a"]))
o.add(Task(id="c", prompt="Update docs", workdir="/repo", depends_on=["a"], retries=2))
o.run()
print(o.summary())The orchestrator executes a topological DAG, runs independent branches in parallel, retries tasks individually, and skips dependents of failed tasks without stopping unrelated branches.
plan() validates the DAG and returns the full execution map without
dispatching anything: topological order, parallel waves, each task's model,
fallbacks, retries, timeout, hooks, verification, isolation, and operator
risks (missing verification, retry with the same model, shared workdir).
run() builds this plan before it persists the run or dispatches the first
agent, so an invalid dependency, cycle, or empty workdir fails fast.
run(dry_run=True) prints the same map.
When a run is given a store and a run_id, run(resume=True) reads the
previous task records back and adopts any task that already finished, so
a run interrupted by a process restart picks up where it stopped instead
of re-dispatching everything:
from store import RunStore
from orchestrator import Orchestrator
store = RunStore("~/.local/share/oc-fleet/runs.sqlite")
orch = Orchestrator(store=store, run_id="nightly")
orch.add(task)
orch.run(resume=True) # a task already `succeeded` is not run againResume is opt-in. A plain run() still repeats every task, because
silently doing nothing on a second call would be more surprising than
repeating work the caller asked to repeat. Resume is also not
exactly-once: a task that died mid-flight may run its side effect twice,
so keep tasks idempotent if you intend to resume. See MIGRATIONS.md.
run_status() condenses the whole run into one exit code a shell script or
CI step can act on:
| code | meaning |
|---|---|
| 0 | every task succeeded and every required verification passed |
| 1 | an agent failed, timed out, or a setup hook failed (no verification failure) |
| 2 | a verification failed, including a boundary violation |
| 3 | preflight refused the run (validate/plan raised; nothing was dispatched) |
A task that both failed as an agent and broke its artifact contract is
reported as verification_failed (code 2), the more actionable condition,
while agent_status keeps the agent's own verdict so neither fact is lost.
Calling run_status() before run() returns 1, not 0: a run that produced
nothing must never look like a success.
A run that prints started: scaffold and then nothing for eighteen minutes
is indistinguishable from one that died. The poll loop now emits a heartbeat
line on an interval, so a run that is merely slow says so:
heartbeat: scaffold (312s running, 1 tool running, oldest 302s)
Orchestrator(heartbeat_interval=...) controls the cadence (default 30s);
heartbeat_interval=None silences it.
The "oldest" figure is what turns a heartbeat into a diagnosis. OpenCode can
leave a tool call in running with executed: false that never finishes, in
which case outcome stays None forever and the run looks busy until the
task timeout. Fleet.status() therefore reports stuck_seconds and a stuck
flag when a running tool outlives a threshold. That threshold is a guess
about legitimate work, so it is configurable rather than baked in:
Orchestrator(fleet, tool_timeout=1800) # a run with honestly slow steps
Orchestrator(fleet, tool_timeout=60) # a run that should never dawdleFleet.STUCK_AFTER_SECONDS (300s) remains the default. The threshold is
threaded through every poll of the run, and the orchestrator checks the
fleet's signature before passing it, so an older Fleet.status(session_id)
still works unchanged.
Verification commands run whenever the agent had a chance to write to the workdir - success, failure, or timeout alike. A timeout says something about the agent, not about the files it left behind, and the coffee-catalog run showed the cost of conflating the two: a QA agent timed out, verification was skipped, and a passing build was nearly discarded as a failure.
Each task result therefore separates the two facts:
| Field | Values |
|---|---|
agent_status |
succeeded, failed, timed_out |
verification_status |
passed, failed, not_required |
and still collects the artifact manifest when the agent fails, so a failed run reports what it produced.
A finished task carries the token counters the server reported, normalized
onto the record under stats, plus the model actually used under
model_used. Cost is computed only when a price table was supplied to the
orchestrator, through the prices argument or the prices key in
config.json:
from orchestrator import Orchestrator
prices = {"cutad/deepseek-v4-flash": {"input": 0.25, "output": 1.0}}
orch = Orchestrator(prices=prices)The prices are dollars per million tokens. An unknown model, or a call
with no price table at all, leaves estimated_cost as None rather than
0.0. That distinction is deliberate: a task that was never priced must
not look like a task that cost nothing.
Retries can use automatic model fallbacks. Attempt 1 uses model; subsequent
attempts use entries from fallbacks in order. This is useful when the error
comes from a provider, such as provider.invalid-request with empty content,
rather than from the prompt. Retrying the same model would only waste time.
Re-sending the identical prompt is a coin flip. If attempt 1 failed because the agent misread a constraint, attempt 2 repeats the same misreading with no way to know it is repeating. The foreman already holds the evidence - it classified the failure, it has the agent's last message, and it has the verification stderr - so attempt N>1 is the original prompt plus a bounded "previous attempt failed" block:
Previous attempt 1 of this task FAILED. Do not repeat it.
failed_model: cutad/deepseek-v4-flash
failure_class: agent_failed
agent_status: failed
agent_last_message:
<the agent's own last words>
verification_failed:
- <the exact command that failed>
<its stderr>
Attempt 1 is byte-for-byte the original prompt, so a run that never fails is unaffected. The whole prompt is clamped to the same budget as a handoff, so a runaway transcript cannot drown the actual task.
Tasks running in parallel on the same repository can be isolated with Git worktrees:
o.add(Task(id="a", prompt="...", workdir="/repo", repo="/repo",
isolate=True)) # automatically creates an isolated worktreeContext in a prompt is a suggestion; an agent can ignore it. When several agents work the same repository, each one is confined to a declared set of paths and the fleet enforces it mechanically:
o.add(Task(id="backend", prompt="build the API", workdir="/repo",
owns=["backend/**"], isolate=True, repo="/repo",
verify=["pytest backend"]))
o.add(Task(id="frontend", prompt="build the UI", workdir="/repo",
owns=["frontend/**"], isolate=True, repo="/repo",
verify=["npm test"], handoff=True))After an agent finishes, the orchestrator reads the changed files from the
artifact manifest and checks them against owns:
- every changed file matches an owned pattern -> the task proceeds as usual;
- any file falls outside
owns-> the task fails withverification_failedandboundary["verdict"] == "boundary_violation", naming the offending files inboundary["violations"].
owns is empty by default, which means no restriction (the old behaviour).
Boundary checks also run when the agent fails or times out, so a failed agent
that overstepped still reports the overstep.
When tasks share a workdir without isolate=True, the orchestrator refreshes
the Git baseline before each owns-scoped task starts, so one agent's changes
are never attributed to the next. owns and isolate=True together are the
safe combination: a private worktree per lane, and a mechanical check that
each lane stayed inside its own fences.
Overlapping lanes are refused at preflight. If two tasks in the same workdir
declare patterns that can match the same file - src/** against src/db.py,
or two identical patterns - validate() and plan() raise before anything is
dispatched, naming both tasks and the clashing patterns. A boundary that two
agents both claim is not a boundary; catching it up front is cheaper than
untangling a collision mid-run.
validate() also refuses a task whose own fields cannot mean anything. A
negative retries, a non-positive timeout, a non-positive verify_timeout
on a task that has verify commands, an empty verify command, an empty
owns pattern, a fallbacks entry that repeats a model already in the chain,
or an owns pattern that is absolute or climbs out of the workdir with ...
Each of these is knowable from the task alone, and each one, left uncaught,
buys a whole session before it fails.
The check is deliberately structural: it reads the task, never the machine.
validate() does not stat() the workdir, so a plan can be written and
reviewed on a machine that will not run it. What a task promises is checked
here; whether this machine can keep that promise is the executor's problem,
not the plan's.
~/.config/oc-fleet/config.json is used for role-specific models and runtime
settings. A partial configuration is merged with the defaults.
{
"models": {
"scaffold": "cutad/qwen3-8-flash-next",
"logic": "cutad/deepseek-v4-pro",
"docs": "cutad/glm-5.3-flash",
"review": "cutad/qwen3-8-flash-next"
},
"fallbacks": {
"logic": ["cutad/qwen3-8-flash-next"]
},
"max_parallel": 3,
"worktree_root": "/tmp/oc-fleet-worktrees"
}from fleet import Fleet
fleet = Fleet()
session_id = fleet.dispatch(
"Fix the bug",
"/repo",
title="fix",
model="cutad/qwen3-8-flash-next",
)
status = fleet.status(session_id) # {outcome, last_assistant_text}fleet.py: core HTTP clientendpoint.py: endpoint and password discoverycli.py: argparse-based CLIorchestrator.py: DAG runner with retries and parallel branchesprompt_lint.py: linter based on measured harness behaviorconfig.py: role-based model and runtime configurationdoctor.py: first-run diagnostics behindoc-fleet doctorwizard.py: theoc-fleet initmodel and config wizardworktree.py: Git worktree isolation helpersweb/dashboard.py: single-file standard-library dashboardoc-fleet-wait.py: detached waiter for individual sessions
The only runtime prerequisite is a running opencode serve instance. The
password is discovered in this order: $OPENCODE_SERVER_PASSWORD, a
--password-file if given, ~/.config/opencode/service.json, and finally
the serve logs (/tmp/oc_serve.log, ~/.local/share/opencode/serve.log).
Every entry point inserts its own directory on sys.path before importing
its siblings, so cli.py, mcp_server.py, and oc-fleet-wait.py work
however they are launched - run directly, through runpy, from a symlink
in /tmp, or by python3 -m from another directory. A detached runner
that dies on ModuleNotFoundError says nothing, so this is pinned by a
test rather than left to the accident of how CPython treats a script path.
oc-fleet-mcp --store PATH speaks MCP over stdio, so a foreman agent can
drive the fleet without writing glue Python. Tools:
| Tool | Kind | Does |
|---|---|---|
oc_fleet_runs |
read | List persisted runs with their status. |
oc_fleet_status |
read | One run's status and exit code (0/1/2), no dispatch. |
oc_fleet_run_show |
read | One run plus its task records. |
oc_fleet_results |
read | Persisted task results for a run. |
oc_fleet_events |
read | Structured lifecycle events for a run. |
oc_fleet_diff |
read | Diff, changed files, and conflicts for a task's workdir. |
oc_fleet_dispatch |
mutation | Dispatch one task. Needs an explicit workdir. |
oc_fleet_run_dag |
mutation | Run a small DAG. Needs an explicit workdir. |
oc_fleet_cancel |
mutation | Cancel one named session. |
oc_fleet_approve |
mutation | Record approval, only for a verification_passed task. |
oc_fleet_merge |
mutation | Fast-forward a branch, only after a recorded approval. |
Read tools are separated from mutation tools on purpose, and the mutation
tools refuse ambiguity: no shell execution, no implicit workdir, no merge
without an approval, and a merge target must be a plain branch name so a
path cannot be smuggled in where a branch is expected. Errors come back as
{"error": ...} rather than raising.
Two documents cover what stays stable across versions:
CHANGELOG.mdrecords every change, with the run behaviour changes called out separately from the additions.MIGRATIONS.mddocuments the status vocabulary, the exit-code contract, the SQLite store location, resume semantics (and why a resumed side effect can run twice), verifier security, the approval and merge boundary, config migration, and rollback.
Read MIGRATIONS.md before upgrading a run that is in flight.
oc-fleet was built and debugged entirely through OpenCode's HTTP API. It exists because driving multiple agents in parallel from a shell becomes unmanageable quickly.
The implementation accounts for several API quirks discovered by probing a live server rather than relying only on documentation:
- Response envelopes are inconsistent.
/api/sessionreturns adataenvelope while some endpoints return a bare list. Both shapes are handled. Content-Type: application/jsonis mandatory for POST requests.- Authentication uses HTTP Basic auth in the form
opencode:<password>, not Bearer auth. - Completion is signaled by the
session.execution.succeededSSE event, not by anidlemessage.
MIT