Library API
Library first, binary second. The metaharness crate is the primary face; metaharness-cli is a
thin process boundary over it.
Everything is in the library beside
main.rs, so an integration test can read the real verb surface instead of a copy of it.
The whole loop
use metaharness::{Input, Metaharness};
use metaharness::protocol::{Command, Decision, DecisionMode, Event, Kind};
let mut run = Metaharness::new(Kind::Claude)
.with_decisions(DecisionMode::Ask)
.with_prompt("tidy the imports")
.start(Input::FromSpec)?; // spawns the real `claude`
while let Some(line) = run.next_event()? {
if let Event::ToolRequested { call_id, decision_required: true, .. } = &line.event {
run.send(Command::ToolDecide {
call_id: call_id.clone(),
decision: Decision::Allow,
})?;
}
}
The builder
Metaharness::new(Kind) and then any of:
| Method | Sets |
|---|---|
.with_hermetic(HermeticMode) | off / on / strict |
.with_prompt(impl Into<String>) | the opening prompt |
.with_frame(Frame) | an in-memory frame |
.with_frame_file(impl Into<PathBuf>) | a sealed metaharness.frame/1 document |
.with_decisions(DecisionMode) | Frame or Ask |
.with_tool_surface(ToolSurface) | Native or Owned (refused) |
.with_credentials(CredentialSource) | OperatorLogin, ApiKey, None |
.with_model(impl Into<String>) | the model to ask for |
.with_max_turns(u32) | a ceiling on turns |
.with_plugin_dir(impl Into<PathBuf>) | a plugin directory, repeatable |
.with_cwd(impl Into<PathBuf>) | an operator-named working directory |
.with_strict_version(bool) | refuse on a version outside the pin |
.with_audit(bool) | judge the run |
.with_spec_file(impl Into<PathBuf>) | the expectation document |
.with_auditor(impl Into<String>) | the auditor argv prefix |
.with_auditor_arg(impl Into<String>) | one pass-through auditor argument |
A run-spec document
A caller that holds a run's settings as data, such as a section of its own run file, reads them
with RunSpec::from_document(&serde_json::Value) or RunSpec::from_document_str(&str). Both are
in metaharness-protocol and need no clap feature. The keys are the run verb's long flags
without their dashes, plus kind and auditor-args. Values use the flag's own words
("hermetic": "strict"), with JSON booleans for boolean flags and arrays for repeatable ones. An
absent key takes RunSpec::new's value. An unknown key, or a value of the wrong type, is a
RunSpecDocumentError that names the key. check_spec then refuses what the kind does not take,
such as max-turns on codex, before anything starts (design amendment a64).
Starting
| Method | Use |
|---|---|
.start(Input) | Against the real vendor binary. Dispatches runner and seam by Kind. |
.start_with(Input, &mut dyn ProcessRunner, &mut dyn SeamFactory) | Against a runner you supply — the scripted fake every C3 vector runs against. |
.start_with_clock(…, Box<dyn Clock>) | The same, with the clock supplied. |
The clock is a seam because a decision deadline has to be able to expire in a test; a vector that slept for real would buy a slow suite and prove the same thing.
Input
| Variant | Meaning |
|---|---|
Input::Prompt(String) | Start with this prompt. Sets RunSpec::prompt, so the spec stays the one place a run is described. |
Input::FromSpec | Start with whatever prompt the spec already carries — the driven case, where the caller built the whole spec. |
Refusals are ordered
Every refusal a start can raise is checked in the order that tells a caller the most useful thing first: the spec's own faults before the machine's.
A vendor binary that is absent or unrunnable arrives as Refusal::Io, which is exit 2 —
metaharness could not do its job, never a verdict about the run.
Driving a run
| Method | Returns |
|---|---|
run.next_event() | io::Result<Option<EventLine>> |
run.poll_event() | io::Result<EventPoll>: one event, or Progress, Idle, DecisionPending or Ended, without waiting out a decision budget. The poll that sees the child's stream end never returns stream.closed; it comes on a later poll |
run.drain() | io::Result<Vec<EventLine>>: every event to the end, deciding nothing |
run.send(Command) | io::Result<CommandOutcome> |
run.send_as(id, Command) | the same, with your own command id |
run.send_line(line) | io::Result<Option<CommandOutcome>> for one raw metaharness.command/1 line, text or bytes: the binary's answer to a steering line, None for a blank one |
A line that does not parse, or is not UTF-8, is answered MALFORMED under its string id or
-. Once stream.closed is out, all three still return the outcome and put no event after it.
Why the two vendors need different runners
start dispatches on Kind because the two vendors do not put their record and their calls in
the same place:
| Vendor | Record | Calls |
|---|---|---|
| Claude Code | down one pipe | down the same pipe |
| Codex | a session rollout file | a hook channel beside it |
Crate layout
| Crate | Role |
|---|---|
metaharness-protocol | The wire, the frame, the hermetic contract, the seam traits. Depends on no adapter. |
metaharness | The library face: builder, run loop, spawning, audit. |
metaharness-claude | Everything claude-specific. |
metaharness-codex | Everything codex-specific. |
metaharness-cli | The verb surface. |
The seam traits live in the protocol crate rather than in the library face because they are the neutral shape an adapter fills in. Nothing there names a vendor — which is what keeps an embedder from accidentally depending on which harness is inside.