Architecture
packages/
engine/ @oharness/engine — the harness; does not know what drives it
server/ @oharness/server — browser frontend, HTTP + SSE; deployable alone
sync/ @oharness/sync — optional transcript sync server
cli/ oharness — terminal and headless frontends, all four bins
Dependencies run one way: a frontend consumes the engine, never the reverse. Since the split into packages, the resolver enforces most of that on its own — an undeclared import simply fails — but a relative path that climbs out of one package into another would still resolve, so the layering test also checks for that. The engine contains no console.*, no process.stdout, and never touches the TTY. When it needs to ask the user something it calls an injected permissionPrompt; when it has progress to report it yields an AgentEvent. Who renders is the frontend's business.
The boundary is enforced
A layering convention that lives only in a document lasts until the first person in a hurry. test/layering.test.ts reads the actual source:
| Assertion | Why |
|---|---|
| The engine imports no frontend | A reverse dependency ends embeddability, and stops a second frontend being addable |
| The engine never writes to stdout | stdout belongs to whatever is rendering |
| Only the logger writes to stderr | Diagnostics on the conventional channel, through one seam a frontend can redirect |
| The engine never touches stdin or readline | A scheduled run has nobody at the keyboard |
| The library entry exports no frontend | An embedding app should not be dragged into a terminal UI |
Neutral history
Session transcripts are stored in the shape defined in packages/engine/src/core/types.ts, not in any vendor's format. One detail earns its place: a tool result carries toolName as well as toolCallId, because Gemini keys tool results by function name rather than by call id.
Capability flags are load-bearing
Models disagree about reasoning parameters, and sending the wrong shape is a 400, not a degradation. So the differences are data on ModelSpec: thinkingMode, effortLevels, maxEffortWithThinkingDisabled, supportsSampling. The adapter dispatches on them per request.
Compaction does not destroy
When the transcript is summarised, what was dropped is appended to an archive alongside the session. The recall tool searches it, so a compacted detail is retrievable rather than gone. Compaction also never splits a tool call from its result — a transcript that does is rejected by every provider.