CodingAgentDriver is the single interface a LoopOver miner runs a coding agent through. It lets the miner drive
either a local CLI subprocess (e.g. claude / codex) or an in-process Agent SDK query() loop behind one
provider-agnostic contract, so the rest of the miner — planning, attempt logging, metering, gate polling — never
has to know which backend actually did the work.
The interface itself lives in @loopover/engine
(packages/loopover-engine/src/miner/coding-agent-driver.ts);
the orchestration around it (mode gating, invocation, the factory) lives in the sibling modules described below.
See also: Observing your miner — point Grafana at the miner's local SQLite ledgers (attempt log, prediction ledger) to see what the driver actually did.
The design deliberately mirrors the review stack's SelfHostAi (src/selfhost/ai.ts) rather than inventing a new
pattern: a single run() method, provider-agnostic task/result types, and injected dependencies (spawn fn,
clock, filesystem) on the concrete implementations rather than hardcoded globals. That injection is what keeps every
driver unit-testable without real IO — the same reason SelfHostAi takes an injected SpawnFn.
The interface defines only the contract. Implementations MAY perform real IO; they never make GitHub writes or autonomous continue/stop decisions — the task handed to a driver is already scoped.
type CodingAgentDriverTask = {
attemptId: string; // stable id for this attempt (keys the attempt log)
workingDirectory: string; // the ONLY directory a driver may edit (see worktree isolation below)
acceptanceCriteriaPath: string; // path to the immutable acceptance-criteria file written before the run
instructions: string; // the metadata-only prompt (no source contents)
maxTurns: number; // hard cap on agent iterations for this attempt
};
type CodingAgentDriverResult = {
ok: boolean;
changedFiles: readonly string[];
summary: string;
transcript?: string; // opaque provider transcript for operator inspection
turnsUsed?: number;
error?: string;
};
interface CodingAgentDriver {
run(task: CodingAgentDriverTask): Promise<CodingAgentDriverResult>;
}Two reference implementations ship today for tests: createFakeCodingAgentDriver (records the last task, no IO) and
createNoopCodingAgentDriver (default-OFF stub). The two real backends — a CLI-subprocess driver (#4266) and an
Agent-SDK driver (#4267) — register in createCodingAgentDriver as claude-cli, codex-cli, and agent-sdk
(CODING_AGENT_DRIVER_NAMES: ["noop", "claude-cli", "codex-cli", "agent-sdk"]).
A driver never runs in isolation. The neighborhood it plugs into:
| Concern | Module | What it provides |
|---|---|---|
| Execution mode | coding-agent-mode.ts |
paused / dry_run / live with deny-toward-safety precedence; codingAgentModeExecutes(mode) is the single "should this attempt actually spawn?" boolean. A dry_run is a pure no-op at the driver boundary. |
| Invocation | coding-agent-invoke.ts |
invokeCodingAgentDriver(driver, task, mode?, log?) — gates on the mode, calls driver.run, and streams lifecycle events to an AttemptLogSink. |
| Factory | driver-factory.ts |
createCodingAgentDriver(options) resolves a driver by configured name; resolveConfiguredCodingAgentDriverNames / isConfiguredCodingAgentDriver deny unknown names by default; runCodingAgentAttempt(options) is the top-level "resolve + invoke" convenience. |
| Attempt log | attempt-log.ts (#4294) |
ATTEMPT_LOG_EVENT_TYPES + normalizeAttemptLogEvent + createAttemptLogBuffer + formatAttemptLogJsonl — an append-only, JSONL-exportable event trace per attempt, independent of any driver's own transcript. Durable persistence: packages/loopover-miner/lib/attempt-log.js (sibling SQLite store; imports the engine normalizer). |
| Metering | attempt-metering.ts (#4311) |
accumulateAttemptUsage / meterAttemptUsage / evaluateAttemptBudget over AttemptBudgetAxis (tokens / turns / wallClockMs / costUsd). |
| Acceptance criteria | (#4271) | The immutable criteria file at task.acceptanceCriteriaPath, written before the driver starts. |
| Worktree isolation | (#4269) | Each attempt's task.workingDirectory is a dedicated git worktree; a driver must never edit outside it. |
To add a driver beyond the CLI-subprocess and Agent-SDK backends:
- Implement the interface. Export a
create<Name>CodingAgentDriver(deps)factory returning aCodingAgentDriver. Take every side-effecting dependency (spawn fn,query()client, clock, fs) as an injected argument — do not reach for globals — so the contract suite can drive it with fakes (mirrorsSpawnFninjection inSelfHostAi). - Honor the task scoping. Only edit inside
task.workingDirectory; stop attask.maxTurns; read the acceptance criteria fromtask.acceptanceCriteriaPath; never make a GitHub write. - Return the result shape faithfully. Set
okfrom whether acceptance was met, listchangedFiles, and surface failures viaerror(a clean failure) rather than throwing — the invoker records either outcome. - Register it in the factory. Add the name to
CODING_AGENT_DRIVER_NAMESand wirecreateCodingAgentDriver, soresolveConfiguredCodingAgentDriverNamescan select it from config (unknown names stay denied by default). - Get covered by the parity/contract suite (#4296): the suite runs the same scenario fixtures — a clean success, a clean failure, a budget/timeout, and a malformed acceptance-criteria input — against every driver with an injected backend, asserting identical SHAPE and edge-case handling (not identical output, which is inherently non-deterministic across backends).
runCodingAgentAttempt/invokeCodingAgentDriver (driver-factory.ts/coding-agent-invoke.ts) are real, tested,
and remain available as a lower-level composable, but production does not call either of them today. The
real construction + invocation path, as actually wired into packages/loopover-miner, is:
constructProductionCodingAgentDriver(env) (packages/loopover-miner/lib/coding-agent-construction.js)
└─ createCodingAgentDriver({ providerName, spawn, ... }) (driver-factory.ts) → the configured CodingAgentDriver
│ (house-rule hooks attached by default for agent-sdk via buildHouseRulesAgentSdkHooks -- see below)
└─ becomes IterateLoopDeps.driver, handed to runIterateLoop (iterate-loop.ts)
runIterateLoop(input, deps) (packages/loopover-engine/src/miner/iterate-loop.ts)
└─ per iteration, runDriverSafely(input, deps, task):
├─ if !codingAgentModeExecutes(input.mode): → invokeCodingAgentDriver(deps.driver, mode, task, {...})
│ (paused/dry_run only -- records a shadow/no-op attempt-log event, never spawns the real driver)
└─ else (live): → deps.driver.run(task) directly, NOT via invokeCodingAgentDriver
→ edits inside task.workingDirectory only, ≤ task.maxTurns
├─ evaluateSelfReviewOutcome(...) → self-review the resulting diff
├─ decideNextActionWithReason(state) → continue | handoff | abandon
└─ logDecision(...) records ONE attempt-log event per iteration (continue/handoff/abandon), not a
raw driver-start/succeed/fail pair the way invokeCodingAgentDriver's own wrapper would
The attempt log (JSONL) is the durable, provider-independent record of what happened, independent of whichever backend's own transcript, and the input to the miner's manage-phase and self-improve loops.
packages/loopover-miner/lib/coding-agent-house-rules.js's runHouseRulesEnforcedCodingAgentAttempt (a
drop-in wrapper over runCodingAgentAttempt) exists for a caller that wants the alternate, non-iterate-loop
path with house-rule enforcement built in by default -- no such caller exists in production today either; the
real house-rule enforcement for the live agent-sdk provider happens via buildHouseRulesAgentSdkHooks,
attached directly in constructProductionCodingAgentDriver.
Metering: attempt-metering.ts's accumulateAttemptUsage/evaluateAttemptBudget are wired into
iterate-loop.ts for real (#5395) -- every iteration
accumulates real turns/costUsd/wallClockMs (tokens stays an honest 0; no driver reports a real
per-iteration token count today) into a running AttemptMeterTotals, and runIterateLoop's optional
input.budget: AttemptBudget is evaluated against it each iteration via the SAME costCeilingReached signal
iterate-policy.ts already exposed for the (now-removed) turns-only maxTotalTurns ceiling -- a genuine
mid-attempt abort, not just a between-cycle cap. packages/loopover-miner/lib/attempt-input-builder.js's
buildAttemptLoopInput sets budget from the SAME AmsPolicySpec.capLimits the Governor's cross-cycle
GovernorCapUsage already uses (packages/loopover-miner/lib/loop-cli.js's governorState.saveCapUsage
between loop cycles) -- one attempt can no longer burn through the entire cross-cycle budget before anything
reacts. wallClockMs uses a real injected clock (IterateLoopDeps.nowMs, defaulting to Date.now) measured
around each iteration's driver invocation. The result's finalMeterTotals/budgetBreaches fields surface
which axis (if any) triggered an abandon, for an operator reading the attempt log back.
operations-runbook.md— SQLitebusy_timeoutconcurrency, corruption recovery, multi-process collisions, post-upgrade migration (#4875).env-reference.md— env vars including ledger path overrides.../DEPLOYMENT.md— laptop vs fleet deployment and state directory layout.miner-goal-spec.md— per-repo.loopover-miner.ymltargeting policy.../README.md#mcp-server— theloopover-miner-mcpread-only tool surface for querying this driver's resolved status (provider, model env-var name, CLI presence) and the rest of AMS's local state over MCP.