Imported from secantdev/secant (
src/harness/AGENTS.md). Install upstream withnpx skills add secantdev/secant --skill harness. Copyright stays with the author.
harness — Module-local notes
Inherits the engineering baseline; records only non-obvious local facts. Ownership and import direction are the policy table's, not restated here.
Invariants
- The public entry (
harness.ts) is the whole Interface surface: the Adapter Interface, the evidence-bearing profile, and the factory a composition root calls. No native frame, protocol type, or conversation-id value crosses it; the declared exceptions are the Workspace path (PrepareOptions.workspace, the directory every Session runs against) and the one additional writable directory (writableDirectory), the named native-Adapter test seams on their override types (including Codex's recorder-only observer and between-Turn app-server lifecycle control), and the executable env constants (CLAUDE_CODE_EXECUTABLE_ENV/SECANT_CLAUDE_CODEandCODEX_EXECUTABLE_ENV/SECANT_CODEX) — the synchronous discovery outcome and static served-capability table that Preflight shares with the Adapter (the resolved spawn target stays private), and the frozen static Harness input-rule declarations (ADR 0040), typed locally and checked structurally against Workflow by composition; and the permission-bridge factory (startPermissionBridge), exported so the fixture recorder composes the production bridge instead of a copy (#127 D3) and so redaction tests register a bearer the way production does (#334); its surface is named Session attachments (launch flags, bearer, URL, and opaque call declarations) and teardown, never an MCP type; and the safe cause translator (translateCause, #316), the one bounded, redacting record of a failure cause that M8's operational log and M11's Detailed diagnostics write; and the optional phase observer each prepare takes (HarnessPhaseObserver, #322), which carries only the semantic phase, an optional closed semanticstep(#325), the Session key, elapsed time, and a typedHarnessFailure; no typed field carries a frame, argv, RPC name, or coordinate (mapping in harness-adapters), though a translated Codex cause may name its RPC method in bounded message or stack text. Recovery coordinates cross the Seam only as opaqueRecoveryCoordinatevalues, never Run truth; callers never decide from their contents. Native protocol models and qualification stay private to each Adapter and re-export nothing native. - No Routing, Step kind, retry budget, or Run policy knowledge lives here; those are above the Seam. A Turn is one mechanical exchange, not a
judgement that a Step succeeded — the closed Turn results (
not-started,completed,failed,interrupted,lost) are mechanical truth, and the Step kind decides the Attempt outcome above the Seam. - Terminal ordering is exact: an Adapter publishes remaining events, drops pending Steers, expires requests and unanswered Agent calls, then closes
the producer and settles the one authoritative result. No event is observable after the result settles. The fake enforces this with an
emit after resultguard; a real Adapter must hold the same order. - Child reuse across Turns (a result may settle before the native
close) is in harness-adapters. - Operational failures are typed values (
HarnessFailure,ControlReceiptrejections,RecordingReceipt,CleanupReport). Only caller-contract violations throw: a second concurrent Turn on one Prepared Harness, a Turn afterclose, or a Turn beyond what an Adapter can serve. Control races (expired,already-settled,shape-mismatch,unsupported) are rejected receipts, never throws. - Durable admission precedes content.
startTurnreturns a handle before native acceptance, but an Adapter awaitsrecorder.admitbefore sending content; arecorded: falsereceipt (or a thrown recorder) proves the Turnnot-started. A recovery coordinate revealed only after acceptance is recorded throughrecorder.checkpoint; a late checkpoint failure is reported separately and never rewrites a settled result. closeis idempotent and returns the same report each call; cleanup failure is separate and cannot rewrite a settled Turn.- Secrets Secant itself introduces are redacted from failures and diagnostics. Excluding raw protocol, private reasoning, and duplicate transcript
content is Interface design, not generic secret redaction — a
HarnessFailurestill preserves all useful Harness-originated diagnostics and its cause. One private registry (secrets.ts) owns it: a minter registers a secret when it hands it out, and nothing registers through the Interface. A secret stays registered for the whole Secant invocation, so a cause translated after its bridge closes still redacts it; the cost is one short token per Session. The Seam'sredactSecretskeeps a cause an Error with its name and bounded cause chain; it returns secret-free values unchanged. Redactor and translator share one cause-depth bound; a redacted tail beyond it becomes null so translation still marks the cut. The translator redacts each string before cutting it; its byte bounds are Interface facts pinned by its tests, in serialized UTF-8 bytes. - Steer ids are caller-supplied and opaque. Each accepted Steer emits one
steersettlement with its text and send time, even before its receipt resolves. Native correlation and pending state stay inside the Adapter; delivery means model exposure, never compliance (#356). - Steer is a profile capability like the others (
HarnessProfile.steer, evidence-bearing). An Adapter derives itssteerreceipt from it rather than hard-coding a second rejection; Codex and Claude Code (#359) declare it available, and the fake's script decides it through the profile it supplies. SessionFacts.commands(#359) are the typed leading words a Harness runs as its own commands in the Session (Claude Code's initslash_commandsas/name; Codex lists none). They are data for ADR 0040's Steer check above the Seam, never a native list.- Model selection is a profile fact.
modelSelectiondeclares where a model can be chosen (launch,per-turn, both, orunavailable) and carries aModelDeclaration(ADR 0034): an exhaustivelist,suggestedpicks that are neither exhaustive nor validated, orfree-text.listandsuggestedentries share{model, label, efforts, defaultEffort?}; emptyeffortsis a model without an effort setting, andsuggestedandfree-textcarry a declaration-leveleffortsfor any other name. Codex declareslaunch-and-per-turnwith themodel/listentries observed at qualification; Claude Code declareslaunchwith its documented aliases assuggested, each offering the five--helpefforts and no default effort, since which levels a Claude model honours is observed, never catalogued.modelObservationseparately declares whether the effective model is read from native evidence; both observe it. Amodelevent'sModelObservationcarries the effort beside a known model (absent: unknown); each event replaces the last, and the result'seffectiveModelis the last observed (#345). PreparedHarness.readDefaults()(#341) is the Harness's own default Model choice, read lazily and once per Prepared Harness so a Run's prepare never pays for it:reported, or the Adapter's declaredfallbackwith its reason (Codex: themodel/listdefault at its own default effort; Claude Code: Opus (latest) at medium). Claude probes settings outside the profile cache (#347); a failed read falls back, never throws. AneffortLockcarries an opaquesource. Composition's qualify path is its one caller.PrepareOptions.processandphases(#333) serve that prepare and its Prepared Harness alone; an Adapter keeps only its qualification cache across prepares, so a cache hit never reuses an earlier caller's Process or observer.PrepareOptions.writableDirectory(#214) is validated by the one sharedwritableDirectoryFailure(writable-directory.ts) before anything native runs (not an existing absolute directory ⇒ typedwritable-directory-unavailable). Claude Code forwards it as--add-diron every launch; Codex sends a per-threadsandbox_workspace_write.writable_rootsconfig override and refuses the Turnwritable-directory-refusedonly when an acknowledgedworkspaceWritesandbox omits it (read-only defers to approvals).- Each
TurnRequestcarries itsmodelChoice(ADR 0034);modelChoiceRefusalrefuses one outside a declaredlist, even an empty one (suggestedandfree-textadmit any), as anot-startedmodel-unavailableTurn before admission, never a substitution. Codex sends model and effort onturn/start(#345), Claude Code the model as--modelon the launch serving the Turn (a reused live child keeps its model, #348) and no effort yet (#348). The observed effective model never copies the request.
Interrupt, recovery, and cleanup
- Codex control timeouts and native RPC errors refuse the call while native terminal truth owns the Turn; unexpected refusals emit a live activity diagnostic. A timed-out Interrupt stays sent: retries and Steer are refused, and later connection loss leaves interruption unknown. A native RPC error resets it to idle. Refusal alone preserves attachment; malformed responses and transport failures still lose and detach the Turn.
- A Turn settles
interruptedonly on confirmed interruption: a matching native terminal isactive-turn(Codex; Claude Code since #346), a graceful process stopprocess-only. A force-kill, lost connection, or unconfirmed termination settles itlostwithinterruption-unknown. Windows has no graceful stage (process notes), so a process stop of a live child there truthfully settleslost. The profile's interruption evidence states each Harness's stop and its per-OS fallback; the conformanceinterruptOutcomeandrecoveryInterruptOutcomeoptions pin them. Windows launch evidence selects confirm-then-reap: close the producer, reap, then settleinterrupted. EOF cannot erase native truth. Cleanup has its own Session-keyed phase; an incomplete reap retains ownership and prevents a duplicate native process. - Recovery is caller- and history-driven: a relaunch of a Session that already ran, or any Turn carrying
resume, resumes that exact native conversation. A resume the native side does not acknowledge is arecovery-phase failure that marks the Sessionunusable; recovery never silently starts a fresh conversation. Codex app-server replacement failures leave Sessions detached; only an unacknowledged thread resume makes its Session unusable. Each Adapter's resume mechanics are in harness-adapters.
Tests
- The
tests/harnessdomain owns the deterministic fake Adapter, shared conformance, and native replayers. Recorded and residual synthetic cases live intests/harness/fixtures/<harness>/<case>/with arecording.jsonsidecar and opt-in recorder. - Prepare/lifecycle cases run all Adapters; Codex replay covers exact-thread recovery, approvals, native Steer, and leftover re-delivery, and
Claude replay covers native and pending Steer. Other control groups stay capability-specific.
Structured clarifications, after-acceptance checkpoint, load-with-replay, and caller-contract violations remain fake-only. The fake performs load-with-replay:
resumed Turn re-emits the Session's transcript history (
assistant-content,tool-activity), drops a scripted entry that repeats a replayed one, then emitsREPLAY_BARRIER(anactivity) before any live event — history is historical by position, inside the closed vocabulary. - Native Adapter and replayer conformance that launches real children runs only in standalone runtime conformance (#198); scripted Process failure cases through the Claude Code Seam run in the semantic suite (#332). The layer rules are in testing.
- Leftover recording race: a leftover Steer lands only in the few milliseconds after a native turn's Stop hook completes, so its recorder steers from the stdout observer and retries; a line-buffered pass-through shim missed 40 of 40, so any recorder shim forwards raw bytes (#357).
- Replayer startup-signal race: a Bun child's
process.on("SIGTERM")handler is only honoured once installed — a SIGTERM delivered before the child's top-level code runs hits the default disposition and kills it (this is a startup race, not abun testlimitation; plainbunshows the same window). So the replayer installs its SIGTERM handler at startup, and interrupt/close cases wait for thesessionevent (init observed) before interrupting. Never signal a freshly spawned child before it has announced readiness. - The replayer's
case.jsonvocabulary (tests/harness/fixtures/README.mdis the reference): acontrolstep (#346: take the next stdincontrol_requestand emit recorded bytes echoing itsrequest_id, or swallow it to model an unconfirmed stop; stdin is read while steps run;cancelQueuedrequirescancel_queued), asteerstep and a Turn'suuid(#359: echo the message's minted uuid in later bytes),ignoreSigterm(swallow SIGTERM → force-kill path; moot on Windows, where every live child is force-killed regardless), per-turnexitAfter(exit without a result → lost/corruption) andworkingAreaPatch(applied in the launch's--add-dirdirectory), aresumesection replayed when the launch has--resume, andsessions[](#224: the Nth fresh--session-idlaunch after the first playssessions[N-1], one conversation per human-controlled Repeat iteration).
Read next
- Read harness-adapters before changing Claude Code or Codex Adapter internals.
- Read ADR 0022 before changing the Interface; Spec #107 fixes the M3 event vocabulary, result names, and control race values.
