GoFastr harness architecture
Read this before adding, moving, or extracting any package under
framework/experimental/harness/. The harness is intentionally narrow at the core and extensible at the edges: the rules below explain why each layer exists, what is locked, and how to add new behavior without growing the loop.
The shape in one paragraph
gofastr harness is a from-scratch agent harness, shipped as a
subcommand of the existing gofastr CLI. It runs an agent loop
against OpenRouter or ZAI GLM (Copilot lands in v0.2 after the rest
of the loop is hardened), reads project context from AGENTS.md
(with vendor-specific files as additive fallbacks), and loads reusable
behavior from SKILL.md packages. The engine is a headless
service with a transport-agnostic wire protocol: bundled clients are
a pure-stdlib TUI and a local web UI built on GoFastr itself, and the
same protocol is exposed over four peer transports (inproc, rest,
ws, mcp-server) so external TUIs, IDE plugins, scripts, other
language clients, remote machines, or other agents (Claude Code,
Codex, Cursor) can drive the same engine. The harness has two preset
profiles selected at boot, --framework (work on GoFastr) and
default (work with GoFastr), each a bundle of skills, MCP servers,
tools, permissions, and a system-prompt header. Third-party Go
dependencies are forbidden; only stdlib and golang.org/x/* are
allowed. The agent loop is small on purpose; everything that is not
orchestration enters through one of five extension seams. Multi-client
attach, originator tracking, total input ordering, and permission
arbitration all live in control/multiplex above the engine; the
engine itself knows about clients, never about transports.
Non-goals
- No third-party Go dependencies.
golang.org/x/*(Go-team subrepos) is acceptable; everything outside stdlib + x/* is not. This includes the TUI (rolled by hand) and MCP (both client and server, from scratch). See § Build order; the cost of these decisions is acknowledged and accepted. - No bundled language-model SDK. Raw HTTP per provider.
- No vendor-specific instruction format as primary. AGENTS.md and SKILL.md are canonical; CLAUDE.md, .cursorrules, GEMINI.md, .windsurfrules, .github/copilot-instructions.md are read if present and appended, never the source of truth.
- No "MVP-shaped" cut corners that would block a later capability, but interfaces ≠ implementations. Every interface listed in this doc is designed up front; packages are placeholder-empty until their build-order phase. See § Build order.
- No replace-directives or pinned framework SHAs for the
dogfooded web client. The harness lives in the GoFastr monorepo
and ships at the same SHA as the framework. Framework changes
that break the web client are fixed in the same PR; the harness
is a direct consumer of
framework/, not a downstream pin.
Hard rules
- The agent loop contains orchestration only. No provider-specific code, no transport-specific code, no profile-specific code, no skill business logic. If a behavior can be expressed as middleware, an event subscriber, or a plugin registration, it lives outside the loop. No exceptions.
- No third-party imports. Stdlib and
golang.org/x/*only. The import boundary is policed bygo.modreview. The inverse boundary, the framework root must not import the experimental harness (so an entire agent runtime never lands in every host app that links the framework, and no root→harness→framework cycle forms), is enforced by a layering test,layering_test.go→TestFrameworkRootDoesNotImportHarness, using the samego list -depsshape as theframework/uiandframework/crudlayering guards. - AGENTS.md is the primary source of project instructions. Vendor
files are additive fallbacks read by separate
ContextSourceimplementations. - SKILL.md is the primary skill format. Three-tier progressive disclosure (name+desc at startup, body on activation, supporting files on explicit reference) is honored end-to-end.
- MCP servers must support two discovery modes.
eager(load every tool schema at startup) andlazy(load only name+desc, fetch schemas on first invocation). The default for high-tool-count servers is lazy. - Profiles are config, not code.
--frameworkand default are two preset bundles loaded fromprofile/framework.tomlandprofile/default.toml. Adding a third profile must never require touching the loop or core packages. The two built-in presets are also embedded into thegofastrbinary (go:embed), andgofastr harness/gofastr harness --frameworkalways load the embedded copy. Aprofile/*.tomlunder the working directory is never read in its place: on a cloned repo that directory is attacker-authored, and a planted profile'sprompt_headerwould be the system prompt outside the untrusted-content tags (rule 12). An explicit--profile <path>loads an on-disk file (no embedded fallback); use it to run an edited profile. - The engine knows about clients, never transports. Transports
are owned by
control/. The engine sees an opaqueClientand tracks per-client identity (originator,identity_class) without ever inspecting how that client is wired. Bridging transports to clients happens atcontrol/multiplex. - All tool calls pass through tool middleware. Permissions, sandboxing, timeouts, redaction, memoization all enter as middleware. The dispatcher itself never embeds policy.
SendInputis totally ordered at the multiplexer. All commands across all transports merge into one queue atcontrol/multiplex; the engine processes one turn at a time perEngineRun. Mid-turnSendInputis rejected withError{Reason: TurnInProgress}; the client mustCancelTurnfirst. No client owns state; everything is replayable from the session log.- MCP is a peer-class transport, in both directions. The
harness consumes external MCP servers as
ToolSources and exposes its own engine as an MCP server so any MCP-capable client (Claude Code, Codex, Cursor, custom agents) can drive a harness session. Same control protocol, different wire. - Every client has an
identity_classset at attach time:humanoragent. Permission prompts (and any future consent-shaped event) honor the distinction. Anagentclient cannot self-approve a permission for a turn it originated:AnswerPermissionfrom the sameClient.ID()that originated the turn is rejected. By default, at least onehumanack is required;--auto-approvebypasses, honestly named. A REST request has no connection to attach, so it dispatches as its token: the token'sidentity_class, and oneClient.ID()for every request made with that token. - Every byte from outside the trust boundary is untrusted
content. AGENTS.md, SKILL.md, MCP-server-supplied tool
descriptions and resource bodies, fetched web pages, tool
results, all are wrapped in clearly-delimited prompt sections
(
<untrusted-...>...</untrusted-...>) with a standing instruction to never follow instructions inside those tags. - Trust-on-first-use for code-adjacent files loaded into prompts
or executed by hooks. Every
SKILL.md,AGENTS.md, and fallback context file is SHA-256 hashed at load time and the hash is stored on the parsed object (skillmd.Skill.SHA256, the contextSectionhash) so callers can compare it across runs. That hash computation is all that is built today. The full TOFU gate, which covers interactive ack with diff on new/changed files, the~/.config/gofastr/harness/approved.lockapproval store, the bulk-ack /dir-trustpolicy for user-owned directories, the per-file review for project-local files, diff-class detection (metadata-only vs code-adjacent vs executable-changes), and the--allow-project-hooksper-hook ack, is not built yet. The design intent below is kept so the shape stays stable, but nothing inharness.New()blocks boot, persists approvals, or classifies diffs today. See § Maturity for what is not built yet. - Bulk-ack policy (intent, not built). Files from user-owned directories (~/.config/gofastr/harness/,framework/experimental/harness/skills/built-ins) would be bulk-approved with one decision per directory (adir-trustentry inapproved.lockcovering all files whose hashes were captured at install time). - Project-local files are never bulk-acked (intent, not built). Files from<repo>/.gofastr/harness/would be individually reviewed every time the hash changes. This is the supply-chain trust boundary. - Diff-class detection (intent, not built). When a previously-approved file changes, the harness would classify the diff (metadata-only, prose, code-adjacent, executable-changes); only code-adjacent and executable-changes would block, prose-only changes auto-promote with a non-blocking notice. Defense in depth comes from rule 12 (untrusted-content tags at runtime), which is built. - Hooks (intent, not built). Project-local hooks (<repo>/.gofastr/harness/) would be gated behind--allow-project-hooksand individually ack'd; no bulk-ack for project-local hooks ever. Today hooks load with no ack gate. CommandandEventare closed sealed unions. Plugins extend via tools, events-via-subscription, and slash commands in their own namespaces, not new wire-protocol verbs. The set of verbs is fixed incontrol/protocol.go; any extension that needs to ride the wire goes through theCustomCommand/CustomEventopen verbs whose payload is plugin-defined JSON. See § Protocol versioning & evolution.- Every wire identifier has a documented format.
SessionID,LogID,CallID,jti, andOriginatorIDare all ULID-prefixed strings (sess_…,log_…,call_…,tok_…,cli_…). See § Glossary → ID formats. Branch ID-rewrite is deterministic from(source_id, new_log_id).
Maturity: what is and isn't built
This document is the design for the harness. Much of it is
aspirational: it describes the target shape so the interfaces stay
stable, but it must never be read as a feature list. As a v0.1
experimental subsystem under framework/experimental/, the following
are not built yet (tracked here so the rest of the doc can keep the
design intent without presenting it as shipped behavior):
- TOFU ack gate. Content SHA-256 hashes for
SKILL.mdand context files are computed at load, but nothing compares them to an approval store, blocks boot on change, or persists acks. There is noapproved.lock, nodir-trustbulk-ack, no diff-class detection (rule 13). - MCP server spawn in
New. Themcpclientpackage exists but is not wired intoharness.New(); no MCP servers are spawned or sha256-pinned at boot. Profilemcp_serversentries are not yet honored by the engine. - Copilot provider and failover. Only OpenRouter and ZAI ship. There is no Copilot adapter and no pre-wired Copilot→OpenRouter failover (that machinery was removed).
- MCP live event push. MCP clients replay a session's events with
resources/readofharness/v1://session/{id}/events?since=<id>, but there is noresources/subscribehandler (initializedoes not advertisesubscribe) and the GET/mcpstream sends keepalives only. A client polls the read to follow a turn. --frameworkdefault model. The default profile targetszai:glm-5.1(GLM-5.1 is the flagship and first in the ZAI catalog).
Where a later section reads as if one of the above is live, treat it as design intent, not current behavior.
Threat model
The harness runs an LLM with Bash, Read, Write, and WebFetch
tools. Any path that lets an actor send SendInput is therefore an
arbitrary-code-execution path unless that path is also gated by
permissions. The threat model below names the principals, the assets,
and the policy each transport enforces. Every subsequent section is
audited against this matrix.
Principals
- Human user at the keyboard. Identity class
human. - Bundled in-process client (TUI, web). Identity class
human(proxies the user). - External client over
rest/wsfrom the same user account. Identity classhumanby default; may be downgraded. - External agent over
mcp(stdio or HTTP). Identity classagent. Confused-deputy hazard; see below. - Project-local code: AGENTS.md, SKILL.md under
<repo>/, hooks under<repo>/.gofastr/harness/, MCP servers spawned by the active profile. Treated as untrusted unless approved (TOFU). - Web content fetched by the agent (
WebFetch). Always untrusted. - MCP-server-supplied tool descriptions and resource bodies. Untrusted; injection hazard.
Assets
- Provider tokens (Copilot, ZAI, OpenRouter) in the keychain or encrypted-file store, plus in-memory during a session.
- The session log at
~/.local/share/gofastr/harness/sessions.db: everyTextDelta,ToolResult, and tool argv. - Repo contents the agent can
Read(incl..env, secrets). - The user's home directory (
Bashreach). - Capability tokens for attach.
- The harness binary and its config (
~/.config/gofastr/harness/).
Defenses by transport
| Transport | Auth | Identity class | Extra policy |
|---|---|---|---|
inproc | trust boundary = process | human | None |
rest over Unix socket | filesystem perms 0600 + capability token | the token's identity_class | Host/Origin checks; X-Harness-Token header |
rest / ws over TCP loopback | capability token + Host/Origin checks | the token's identity_class | Custom header (anti-DNS-rebinding) |
rest / ws over LAN | capability token + TLS | the token's identity_class | Token must be bound to specific session IDs |
mcp stdio | capability token via env var + parent-process attestation | agent | Tool exposed as harness.run_agent_with_shell_access (honest name) |
mcp streamable HTTP | capability token in Authorization header | agent | Same as TCP transport rules |
Token issuance
GET /v1/auth/tokenon the Unix socket emits a 6-digit code to the harness's TTY (or via system notification); the requester POSTs the code back within 60 s to receive the token.- Tokens carry an explicit claim set:
{sessions: [...], commands: [...], identity_class: ..., exp: ts, can_mint: false}. Default expiry 24 h. - Revocation list checked on every request.
- Tokens stored in the OS keychain when shared with bundled clients; never in plaintext config.
Standing rules baked into permissions
- Default Bash blocklist:
security,secret-tool,keyctl,kwalletcli(credential exfiltration paths). WebFetchstripsAuthorizationandX-Harness-Tokenheaders from any URL it is asked to fetch.WebFetchruns an SSRF preflight: it rejects URLs resolving to private/loopback/link-local ranges (incl. cloud metadata169.254.169.254) and re-validates the target on every redirect hop, failing closed. The test-onlyWebFetch.AllowPrivateHostsfield disables the preflight so unit tests can reachhttptestloopback servers; never set it in production.- Untrusted content (rule 12) is the prompt-injection defense.
- TOFU (rule 13) is the supply-chain defense.
What this model does not defend against
- A user knowingly approving a malicious skill or hook (the TOFU diff is presented; the user takes the consequences).
- A compromised harness binary (out of scope; would be detected by OS-level binary signing).
- Bash escapes from
sandbox-exec(sandbox is best-effort). - The agent being prompt-injected into doing something harmful within its allowlist (no protection; the allowlist is the policy boundary).
- Other processes running as the same user reading the credstore
key.
GOFASTR_HARNESS_PASSPHRASEandGOFASTR_HARNESS_MACHINE_KEYlive in the harness's environment, and any process of the same user can read another's environment (/proc/<pid>/environon Linux,ps ewwon macOS) and its memory through a debugger. Encryptingcreds.encat rest protects against a copied disk or backup, not against code already running as you. Every Bash child inherits both variables too; redaction masks aNAME=valueit sees in what the session stores, not what a command sends elsewhere. Use a dedicated user, or a machine key held only by the CI job, where that matters.
Repository-supplied provider keys
.harness-secrets/env is found by walking up from the working
directory, so in a cloned repository it is the author's file. It may
only deliver provider credentials (keys ending _API_KEY or
_TOKEN), and only ones your environment lacks, but those alone
would run your session on the author's account, where the author can
read your prompts on their provider dashboard. So the harness asks
before using them:
- On a terminal,
gofastr harnessnames the file and the keys and asks once. A yes is remembered in~/.config/gofastr/harness/repo-secrets.jsonfor that repository directory and that key set, like an Allow-always permission; a key the file gains later asks again. A no leaves the keys unset. - With nobody to ask (
gofastr harness mcp, whose stdin is the MCP protocol, a pipe, CI), the keys are refused unless an earlier approval covers them or the run passes--allow-repo-secrets, which uses them for that run without remembering.
A key already set in your environment is never asked about: the shell always wins.
gofastr harness refuses the whole file, before it asks anything, when
a key in it is not an environment variable name (capital letters,
digits and _, starting with a letter). A name such as
ESC[2K CR ESC]52;…ZAI_API_KEY ends in _API_KEY, but printed in the
prompt it erased the warning and wrote your clipboard. The refusal
quotes the name instead of printing it, and --allow-repo-secrets does
not override it. The file's path and the repository directory are
printed with terminal control characters removed.
Build order
The non-goal "no MVP-shaped cut corners" stands: every interface listed in this doc exists from day one. But interface ≠ implementation; packages can ship as placeholder-empty until their phase, and the implementation order below is the contract for what must actually work when.
v0.1: demonstrably correct slice
The smallest end-to-end path that proves the architecture: one provider, one transport, three tools, one client, full safety posture.
- Engine: full loop, request + tool middleware, event bus, multiplex, originator + identity-class tracking, total ordering.
- Providers: OpenRouter + ZAI GLM. (No Copilot.)
- Transports:
inproc+rest(Unix socket + loopback, with Host/Origin checks). Nows, nomcp-server. - Tools:
Read,Write,Bash+ permission engine + sandboxing middleware + redaction middleware. - Clients: TUI (pure-stdlib +
golang.org/x/term, accept the scope cost), web client built on GoFastr. - MCP client (consumer): enabled, eager + lazy discovery.
- Skills: SKILL.md loader, 3-tier disclosure, slash-command
registration via
Plugin.Register. - AGENTS.md primary reader + CLAUDE.md fallback (transitional).
- Hooks: user-config only.
--allow-project-hooksdefined but default off. - Persistence: SQLite session log, keychain-encrypted; redaction
middleware on
ToolResult; 30-day TTL on full-content events. - Memory: file-based typed memory.
- Threat model: complete; all defenses listed above active.
- Profiles:
frameworkanddefaultshipped.frameworkdefaults toopenrouter:claude-sonnet-4(not Copilot).
v0.2
- Copilot provider (added; not default for
--frameworkuntil 30 days stable). wstransport.mcp-server transport, stdio only, with the rename:harness.run_agent_with_shell_access(replacessend_input).- Plan mode (
EnterPlanMode/ExitPlanMode). - Hook system extended to project scope behind
--allow-project-hooks. - Cost dashboard (web).
v0.3
mcp-server transport, streamable HTTP with resource subscriptions, prompts, capability negotiation, session resumption.- Background tasks, scheduler, cron.
RoutingProvider(multi-model per turn, added as aProvidercomposition, not middleware).delegatetool (sync-only, blocks parent; see § Future extensions for the explicit scope).- Conformance suite (
control/conformance/): cross-transport parity tests.
v0.4+
- Worktree-isolated subagent runtime (reusing
framework/'s worktree module). - Distributed sessions over
wswith capability tokens. - Time-travel / step-through replay UI.
- Container-sandboxed Bash via tool middleware.
Glossary
| Term | Meaning |
|---|---|
EngineRun | A live agent-loop instance bound to a profile, model, and session log. The thing that processes turns. |
AttachContext | A connection between one Client and one EngineRun. Carries originator identity. Many AttachContexts per EngineRun are normal. |
LogID | Append-only event log identity in the SQLite store. A LogID can be opened into a new EngineRun (resume) or branched. |
Session | Informal umbrella term. In wire protocol and CLI, SessionID resolves to EngineRun + the active LogID it writes to. Use precise names when writing code; only the doc uses "session" loosely. |
Client | An entity attached to an EngineRun via some transport. Has an ID(), an identity_class (human or agent), and a subscription to the event stream. |
Originator | The Client.ID() whose SendInput started the current turn. Used by permission middleware. |
Transport | The wire that carries Commands in and Events out (inproc, rest, ws, mcp). |
untrusted-... block | A clearly-delimited section of the system prompt holding content from outside the trust boundary. Standing instruction prevents the model from following instructions inside. |
| TOFU | Trust-on-first-use. The harness hashes a file on first load and asks the user to ack. Subsequent runs verify the hash. |
ID formats (normative)
All identifiers are ULID-derived (Crockford base32, 26 chars, sortable, URL-safe). Each has a typed prefix so a string can be inspected without context:
| ID | Pattern | Notes |
|---|---|---|
SessionID | ^sess_[0-9A-HJKMNP-TV-Z]{26}$ | One per EngineRun. |
LogID | ^log_[0-9A-HJKMNP-TV-Z]{26}$ | Persistence-layer ID; an EngineRun writes to exactly one LogID. |
CallID | ^call_[0-9A-HJKMNP-TV-Z]{26}$ | One per tool call. |
jti (token ID) | ^tok_[0-9A-HJKMNP-TV-Z]{26}$ | Revocation key. |
OriginatorID (= Client.ID()) | ^cli_[0-9A-HJKMNP-TV-Z]{26}$ | Stable for the lifetime of the client's attach. |
| Event sequence ID | uint64, monotonic per SessionID | Used by every stream-resume mechanism (SSE id, WS lastEventId, MCP resource version). |
Branch ID-rewrite algorithm. When events are copied between
LogIDs at a TurnEnded boundary, every embedded ID is rewritten
as new_id = ulid_from_seed(sha256(old_id || new_log_id)).
Deterministic: two clients branching the same source at the same
boundary produce the same new IDs (good for cache + cross-tool
agreement).
Package map
cmd/gofastr/ `gofastr harness` subcommand (owns CLI flags, profile load, │ harness.go transport + client start, engine bootstrap) + harness_http.go, │ harness_creds.go, harness_mcp.go — the surface New() does not own. │ harness_mcp.go framework/experimental/harness/ ├── harness.go Harness struct — composes the subsystems New() builds ├── ids/ ULID-derived typed IDs (SessionID, CallID, ClientID, …) ├── context/ Project-instruction file readers. │ ├── reader.go Walks context_sources; walk-upward AGENTS.md; concatenates │ └── hash.go sha256Hex helper — hash IS computed; TOFU ack is NOT built ├── skill/ SKILL.md loader + registry. │ ├── registry.go Skill registry, tier-1 catalog, SHA256 lookup, triggers │ └── skillmd/ SKILL.md parser (frontmatter, body); SHA256 set on parse │ ├── skillmd.go │ └── hash.go ├── tool/ Tool / ToolSource interfaces + registry. │ ├── tool.go, registry.go │ ├── builtins/ Read, Write, Edit, Bash, Grep, Glob, Ls, WebFetch, │ │ Agent, Tasks, ToolSearch │ └── permission/ Allow/ask/deny engine + persistable allowlists │ (LoadPersistentRules; rules only — profile preset not loaded yet) ├── mcpclient/ MCP wire client. EXISTS but NOT wired into New (see Maturity). │ ├── client.go stdio + http+sse + streamable HTTP │ └── source.go ToolSource adapter — MCP tools appear identical to built-ins ├── provider/ Provider adapters. │ ├── provider.go Provider interface + Request/StreamEvent/Model/Capabilities │ ├── pricing.go, Pricing + cache-attribution helpers │ │ cache_attrib.go │ ├── openrouter/ OpenAI-compatible client + model catalog + pricing │ ├── zai/ ZAI GLM (static catalog: glm-5.1 first, then 4.6 / 4.5-air / z1) │ ├── credstore/ AES-GCM encrypted-file credential store (credstore.go) │ ├── helper/ In-process credential helper │ └── internal/openai/ Shared OpenAI-shape stream client ├── secrets/ Repo-side secret loader (.harness-secrets/env → env vars) ├── session/ Session log interface + export. │ ├── store.go SessionStore interface │ ├── export.go Export helpers │ └── sqlite/ SQLite event log: sqlite.go, encrypted.go (page-level DEK), │ retention.go (TTL), redact.go (secret-regex), attempts.go. │ (No replay.go / branch.go — step-through replay and │ branch-at-TurnEnded are not built.) ├── memory/ File-based typed auto-memory (memory.go) ├── hook/ Shell-level lifecycle hooks: spec (event, command, blocking) │ + procgroup_unix/windows. No TOFU ack gate on hooks yet. ├── engine/ The agent loop. Orchestration only. │ ├── loop.go Turn loop │ ├── stream.go Provider-shape-agnostic stream parser │ ├── request.go Request middleware chain │ ├── tool_dispatch.go Tool middleware chain + dispatcher │ ├── middleware.go, Request + tool middleware, permission middleware │ │ permission_mw.go │ ├── cancel.go Cancellation tree │ └── events.go Typed event bus + subscriber registry ├── control/ Engine-as-a-service: transport-agnostic protocol. │ ├── protocol.go Wire types (Command, Event) + JSON codec │ ├── client.go Client interface — Subscribe/Send/ID/IdentityClass │ ├── multiplex/ Multi-client routing, originator tracking, total input ordering │ ├── resources/ Catalog aggregation (sessions, profiles, providers, tools, skills) │ ├── auth/ Token issuance (TTY 6-digit confirmation), claims, revocation │ ├── inproc/ Go-channel transport for bundled clients │ ├── rest/ HTTP/REST + SSE; Host/Origin checks; X-Harness-Token │ ├── ws/ WebSocket transport (full duplex) │ └── mcpserver/ MCP-server transport (engine as MCP) — stdio + streamable HTTP ├── client/ Bundled clients (each speaks the control protocol). │ ├── tui/ Pure-stdlib + x/term TUI (terminal.go, modal.go, markdown.go, │ │ search.go, slash.go, spinner.go, style.go, escape.go, time.go) │ └── web/ gofastr-powered local web UI sidecar (web.go) ├── profile/ Profile loader + embedded presets. │ ├── profile.go Profile spec — skills, mcp_servers, tool_packs, permissions, model │ ├── embed.go go:embed fallback for framework.toml + default.toml │ ├── framework.toml Preset: working on GoFastr │ └── default.toml Preset: working with GoFastr (default_model = "zai:glm-5.1") ├── logging/ Structured logger (slog-style levels) ├── slash/ Slash-command parsing + registry ├── tracing/ Span/tracing helpers └── internal/ Private helpers, not part of the public surface ├── clock/ injectable clock └── ulid/ ULID implementation Not present (described in older drafts, removed/not yet built): provider/copilot, provider/routing, tool/pack, plugin/, control/conformance/, session replay/branch, credstore OS-keychain adapters, mcpclient discovery/pin, skill tier/tofu ack, context agentsmd/fallback readers.
The agent loop
The loop is small but it does have a small amount of policy; calling it "150 lines of pure orchestration" was overstated. The honest list:
- Accept input from the multiplexer (
SendInput,ToolResultfrom a prior tool dispatch, or system events). Originator and identity class travel with the input. - Assemble an empty
Requestand pass it through the request middleware chain. Middleware injects: system prompt header, AGENTS.md content, skill metadata + activated skill bodies, selected memory entries, history (possibly compacted), tool schemas, cache hints. The loop never assembles content directly; that's how middleware stays the place to extend. - Send to the provider and parse the stream into typed events.
Emit each event onto the bus. The collector caps what one turn may
buffer — text, thinking, and tool-call argument bytes combined — at
8 MiB (
DefaultMaxStreamBytesPerTurn, per-engine overrideEngine.MaxStreamBytesPerTurn); past the cap the turn fails loudly withErrStreamCapExceededand the partial output is kept for the transcript. This is the stream-side twin of the 64 KiB per-block tool-result cap: a hostile or buggy endpoint cannot grow harness memory without bound for the length of the HTTP timeout. - Dispatch tool calls through the tool middleware chain. Results feed back as input.
- Decide whether to loop or yield. This is the one piece of
policy in the loop. Yield conditions, all explicit:
- The stream ended with no tool calls (model produced a final
answer).
-
CancelTurnarrived. It ends the running turn with reasoncancelledand nothing else: the nextSendInputstarts a fresh turn, and aCancelTurnsent while idle does nothing. - A middleware returned a terminal error. - AYieldcontent block appeared in the response (explicit end-turn signal from a provider that supports it).
Everything else is middleware or an event subscriber: permission prompts, cost accounting, compaction, skill injection, AGENTS.md injection, memory injection, hook firing, diff preview, MCP tool resolution.
┌─────────────────────────────────────────────────────────────┐ │ clients ── transports ──▶ control/multiplex │ │ │ │ │ inputCh (totally ordered) │ │ │ │ │ engine.Loop │ │ │ │ │ request middleware chain │ │ │ │ │ Provider.Chat │ │ │ │ │ stream parse │ │ │ │ │ events ─▶ event bus ─▶ subscribers │ │ │ ▲ │ │ tool calls? yes │ │ │ │ │ │ │ tool middleware chain │ │ │ ↳ EventSink ─────┘ │ │ │ │ │ tool result ─▶ inputCh │ │ │ │ │ end-turn? loop : yield │ └─────────────────────────────────────────────────────────────┘
Extensibility: the five seams
Every extension point is one of these five. New ideas land in a seam, not in the core.
1. Request middleware
type RequestHandler func(*Request) (*Response, error) type RequestMiddleware func(*Request, RequestHandler) (*Response, error)
Composed in order around the provider call. Used for:
- AGENTS.md injection (read once per session, cached)
- Skill injection (activate triggers, inject SKILL.md tier-1 names + active tier-2 bodies)
- Memory injection (relevant entries selected by tag + heuristic)
- Cache-breakpoint placement (provider-aware)
- History compaction trigger
- Provider routing / fallback / retry
- Cost budget enforcement (abort if cap exceeded)
- A/B prompt experiments
- Logging, telemetry, journaling
2. Tool middleware
type EventSink interface { Emit(Event) // ToolCallProgress, partial results, side-channel notices } type Tool interface { Name() string Schema() *jsonschema.Schema Run(ctx context.Context, call *ToolCall, sink EventSink) (*ToolResult, error) } type ToolHandler func(ctx context.Context, call *ToolCall, sink EventSink) (*ToolResult, error) type ToolMiddleware func(ctx context.Context, call *ToolCall, sink EventSink, next ToolHandler) (*ToolResult, error)
The shape is locked upfront so streaming tools, cancellation, and
middleware all share one signature. Middleware can observe partial
results (redaction sees streamed chunks; timeout has a ctx;
permission can interpose before next runs). Used for:
- Permission gate (allow/ask/deny rules, identity-class-aware)
- Sandbox wrap (subshell,
sandbox-execon macOS, container later) - Timeout / cancellation (via
ctx, not error returns) - Result truncation / pagination
- Memoization for read-only tools
- Redaction (strip secrets from outputs and from streamed
ToolCallProgress) - PreToolUse / PostToolUse shell hook invocation
Permission UX (session-scoped allow rules)
Default ask policy without affordances produces ~18 prompts per
non-trivial turn. The permission middleware presents the user with
four choices per PermissionRequested event, not two:
- Allow once: answer this call only.
- Allow this argv-glob for the session: remember
Tool:argv-glob(e.g.Bash:grep *,Bash:find . -name *) for the duration of theEngineRun. - Allow this tool for the session: remember
Tool:*broadly. - Deny.
A visible "Session policy" panel (TUI sidebar, web sidebar) shows the active session-scoped allows; one keystroke revokes any of them.
Pre-shipped quiet-mode preset for known-safe read-only Bash patterns (default ON, configurable per-permission-preset):
Bash: git status, git log, git diff, git branch Bash: ls <repo-path>, pwd, cat <repo-file>, head, tail, wc Bash: grep <repo-path>, rg <repo-path>, find . * Read, Glob, Ls, Grep (anywhere under the repo)
"Under the repo" is enforced: every path one of these reads must
resolve, symlinks included, under the working directory (the directory
the harness runs in, which the file tools and Bash resolve relative
paths against). That covers Read:/Ls: paths, a Glob's --root and
pattern, a Grep's --path and --glob, and every argument and flag
value of cat, head, tail, wc, grep, rg, find, ls and
git; a shell glob counts by what it matches there. A path outside
(/proc/self/environ, ~/.ssh/id_rsa, find / -name creds.enc), a
.. segment, a leading ~, or a symlink out of the repo prompts
instead, and so does a summary the check cannot read. Variables and
command substitution never reach the check: any $, backtick or other
shell metacharacter already prompts.
Never prompts for these. --strict-permissions disables the
preset for users who want every call gated. Because they never
prompt, Read (like Grep) opens regular files only: a FIFO, a
device such as /dev/tty, or a symlink to one is refused, since the
open would block past CancelTurn. Edit applies the same rule. git branch counts
only when it lists: listing flags alone (-a, -r, -v, -vv,
--show-current), and patterns only behind --list.
The preset and argv-glob rules match a Bash call's argv summary,
which spells the call as the shell command it amounts to: a cwd
becomes cd <dir> && in front and an env map becomes
env K=V … (keys sorted). A listed command carrying either one
(GIT_EXTERNAL_DIFF, PATH, or a directory whose .git/config
names a program) is prompted, and a rule written for the bare
command does not match it.
A Grep or Glob summary names where the search reads, the same way:
Grep:<pattern> --path <dir> --glob <glob> and
Glob:<pattern> --root <dir>, each flag only when the call sets it,
and any word holding whitespace or a quote single-quoted. A rule
approved for Grep:TODO (the working directory) does not match
Grep:TODO --path /home/u/.ssh, and no pattern can spell a
summary that names a directory.
Session-scoped allows persist for the EngineRun only; they
never carry across sessions or persist to disk. An Allow always
answer is written to $XDG_CONFIG_HOME/gofastr/harness/permissions.json
with the working directory it was granted in, and is honoured only
while the harness runs in that directory. A summary leaves the working
directory implicit (Bash's default cwd, Grep's default path), so a
global rule would let one approval of go test ./... in your own repo
run a cloned repo's tests with no prompt. A rule saved before rules
carried a directory prompts once more. The user can
promote a session-scoped allow to a profile-level rule via
/permissions:promote <rule>.
3. Event bus
Typed pub/sub. Every interesting state change emits an event:
| Event | Fired when |
|---|---|
TextDelta | provider emits text chunk |
ThinkingDelta | provider emits reasoning chunk (where supported) |
ToolCallStarted | tool dispatch begins |
ToolCallProgress | streaming tool with partial result |
ToolResult | tool finishes (success or error) |
TurnStarted / TurnEnded | loop iteration boundary |
TurnTiming | emitted at TurnEnded with per-component duration map (request middleware, provider TTFB, provider total, each tool wall-time, SQLite write fsync). Web client uses this for the timeline view; operators answer "where was the time?" from a single event. |
CompactionTriggered | history compaction begins |
CostIncremented | new usage tallied |
PermissionRequested | tool middleware needs ask-mode answer |
Cancelled | user or budget aborted the turn |
Error | provider, tool, or middleware error |
StreamGap | a stream resume reached ids that neither the bus window nor the session log holds; events between from and to are unavailable (reason: "unavailable"). It carries id 0, so it never moves a client's resume point. |
TokenExpiring | 5 min before token exp; clients refresh. |
HookTimeout / HookError | a configured hook missed its deadline or exited non-zero. |
MCPServerDown | an MCP server gave up after exhausting restart budget; its tools entered degraded state. |
SessionEnded | engine shutdown reason (idle, user, error, binary-shutdown). |
Clients, hooks, plugins, and the cost dashboard all subscribe.
The bus API in engine:
Subscribe(ctx)delivers the session's own events, live from the moment of the call.SubscribeSince(ctx, since)first replays every event with an id abovesince, then goes live, with nothing lost or repeated between the two. The replay reads the bus's in-memory window (the last 1024 events, up to 8 MiB) and, for older ids, the log set withSetLog.Backlog(ctx, since, limit)returns the same replay as a slice, with no live part.SetLog(log)names theEventLog(any type with the session store'sEventsSince) that replay reads past the window. The harness sets the session store when it creates a session.SubscribeAll(ctx)also delivers the events of sub-agents spawned under the bus. Persistence and audit use it; client streams do not. It never drops: when its buffer is full,Publishwaits for the reader, until the subscription's context ends orClosestarts.Subscribedrops for a slow reader, which recovers what it missed withSubscribeSince.NewSubBus(parent, subAgent)builds a sub-agent's bus. Its ids come from the parent's sequence, so one session has one id order, and every event it publishes carriessubAgentand is forwarded to the parent'sSubscribeAllsubscribers.
Sub-agent events. Engine.Spawn gives each sub-agent its own
sub-bus. The sub-agent's turns, tool calls and results land on the
parent session's log tagged with subAgent (the sub-agent's client
id) in the envelope, so the audit log and sessions export show what
a sub-agent ran. They stay off the parent's client streams (SSE, WS,
MCP, TUI): those carry the parent session's own conversation.
Sub-agent tools. A sub-agent gets the parent's tools minus the
meta tools, TaskList and Agent, so it cannot re-plan the
parent's task list or spawn more agents. Its dispatcher refuses a
call to either by name too, with an error result the model reads,
since a model can name a tool its catalog never showed.
4. Pluggable backends
Single interface per concern. Backends are wired by the profile or by
Plugin.Register.
Five interfaces with concrete polymorphism need; the rest are concrete types or config-driven choices to avoid speculative abstraction.
| Interface | Purpose | Initial impls | Why an interface |
|---|---|---|---|
Provider | LLM transport + chat | zai, openrouter, copilot (v0.2), routing (v0.3) | Each provider's wire format and auth differ deeply |
Transport | Control-plane wire transport | inproc, rest, ws, mcpserver | Wire semantics differ (backpressure, framing, reconnect) |
Client | Anything that drives the engine | tui, web, external (rest/ws/mcp) | Identity class + subscription patterns vary |
ToolSource | Tools the registry exposes | builtins, mcpclient, user-plugin | Lifecycle differs (in-proc vs external process) |
SessionStore | Session log + replay | sqlite | One impl today; second impl (postgres) would force redesign anyway; kept honest as a swap point |
Deliberately not interfaces (concrete types or config lists):
- Skills: one loader (
skill/skillmd). No second loader designed. - Project context: a config list of
(path, label)tuples processed bycontext/reader.go. AGENTS.md and fallback files are six entries in that list, not six implementations. - Memory: one file-backed implementation. A server-backed store would force a different shape.
- Credentials: encrypted-file is the primary type; keychain helpers are opt-in build-tagged plugins, not pluggable via an interface seam.
- Permission policy: one engine with allow/ask/deny rules parameterized by config. Not two implementations.
Switching one of the five real interfaces happens via
profile.With(...) or Plugin.Register, never a fork of the
engine.
5. Plugin contract
type Plugin interface { Register(h *Harness) error }
A plugin can: register request/tool middleware, subscribe to events,
register a ToolSource, register a Transport, register a Provider,
register slash commands (in a namespace it owns; see § Slash
commands), add permission rules, contribute hooks.
Profiles are lists of plugins. New profile = new TOML file referencing existing plugins. New behavior = new plugin shipped as code.
Providers
Two providers ship in v0.1; Copilot lands in v0.2. The abstraction is
Provider:
type Provider interface { Name() string Chat(ctx context.Context, req *Request) (<-chan Event, error) Models(ctx context.Context) ([]Model, error) TokenCount(ctx context.Context, msgs []Message) (int, error) }
v0.1 providers
ZAI GLM. OpenAI-compatible. API key in credstore. Models
(provider/zai/zai.go static catalog, GLM-5.1 flagship and listed
first): glm-5.1, glm-4.6, glm-4.5-air, glm-z1. Endpoint:
api.z.ai/api/paas/v4 (Coding Plan keys must use the dedicated
api.z.ai/api/coding/paas/v4 endpoint; set ZAI_CODING_PLAN=1).
OpenRouter. OpenAI-compatible. API key in credstore. Model
catalog from openrouter.ai/api/v1/models (cached locally with TTL;
pricing metadata feeds the cost dashboard). Endpoint:
openrouter.ai/api/v1/chat/completions. Required headers
HTTP-Referer and X-Title for analytics; some upstream models
require them.
Provider egress is fail-closed. Every provider fetch carries the
API key as a bearer header, so the clients the providers build for
themselves refuse redirects (CheckRedirect returning
http.ErrUseLastResponse, the OIDC spelling): a provider 3xx surfaces
as an HTTP error instead of re-sending the key to whatever origin the
response names. The OpenRouter /models decode is likewise capped at
1 MiB (modelsMaxBody) — a bigger catalog is a misbehaving endpoint,
and the truncated decode errors rather than buffering it. Both are
pinned per package (redirect_security_test.go,
models_security_test.go).
v0.2: GitHub Copilot (deferred for reason)
Copilot's chat API is reverse-engineered, not a documented public
API. Token-exchange shape and Copilot-Integration-Id whitelist have
changed multiple times in 2025. Models available depend on
subscription tier and per-org policy. Streaming response shape differs
subtly from official OpenAI in tool-call delta accumulation and
finish_reason semantics. Auth flow:
POST github.com/login/device/codewith the Copilot client ID- User opens displayed URL, enters displayed code
- Poll
github.com/login/oauth/access_tokenfor the GH token - Exchange via
api.github.com/copilot_internal/v2/token→ short-lived Copilot token (must be refreshed; respectendpoints.apiin the response; GitHub has moved this for some users) - Use Copilot token against
api.githubcopilot.com/chat/completionswithEditor-Version,Copilot-Integration-Idheaders - Model catalog from
api.githubcopilot.com/models, but per-call availability differs from the catalog
When Copilot lands it will not default the --framework profile to
Copilot until 30 days of stable operation. There is no failover path
today: a provider 401 surfaces as an error, not a silent retry on
another provider. (An earlier "pre-wired Copilot→OpenRouter failover"
claim described machinery that was never built and has been removed
from this doc.)
Internal canonical message shape
The engine works in an Anthropic-shape canonical form (it's the most expressive: tool_use/tool_result are modeled as full content blocks, and every content block is typed). Each provider adapter translates outbound and inbound. Two honest limits on this canonicalization:
- Thinking / reasoning blocks are provider-bound. Anthropic
returns signed thinking blocks that must be echoed back verbatim
to the same provider; OpenAI o-series returns reasoning summaries
with different semantics; ZAI GLM has nothing equivalent. The
canonical form encodes these as opaque, provider-stamped
blocks.
SetModelacross families discards thinking; same family preserves it. - Cache hints are per-provider. Anthropic uses
cache_controlon content blocks; OpenAI usesprompt_cache_key(Responses API); ZAI has no documented caching; OpenRouter passes through whatever the upstream takes. The "cache-breakpoint placement" middleware is therefore provider-aware, not generic.
These limits are not implementation laziness; they reflect that the providers genuinely differ at the semantic layer and pretending otherwise produces silent bugs.
MCP discovery: two modes
Both modes are spec-compliant; the difference is when the harness
calls tools/list and tools/get_schema.
Eager (default for low-count servers, ≤20 tools)
At server connect:
tools/list→ all names + descriptionstools/get_schemaper tool → all schemas- Register every tool with the registry; schemas available in system prompt immediately
Lazy (default for high-count servers, >20 tools)
At server connect:
tools/list→ all names + descriptions only- Register name + description placeholders in the registry
- On first invocation of a placeholder tool:
tools/get_schema→ hydrate schema → re-dispatch
The threshold is configurable per server in the profile. The lazy mode maps 1:1 to SKILL.md tier-1 → tier-2 progressive disclosure: tool names + descriptions are tier-1 metadata, schemas are tier-2 body.
Skills (SKILL.md) and context (AGENTS.md)
AGENTS.md
Read by context/agentsmd. Spec-compliant:
- Looks for
AGENTS.mdat the repo root - Nested
AGENTS.mdin subdirectories is supported; the harness walks upward from the working directory and concatenates in path order - Plain markdown, no schema
- Content injected into the system prompt via a request middleware
- Every context file (AGENTS.md and the fallbacks below) is read
through an
os.Rootover its directory (the working directory for.github/copilot-instructions.md), so a symlink that leaves it, such asAGENTS.md -> ~/.ssh/id_ed25519, is skipped. Only regular files up to 256 KiB are read; a FIFO, a device or a larger file is skipped like a missing one
Vendor fallback readers in context/fallback/:
claude_md.goreadsCLAUDE.mdcursorrules.goreads.cursorrulesand.cursor/rules/*.mdcgemini_md.goreadsGEMINI.mdwindsurfrules.goreads.windsurfrulescopilot_md.goreads.github/copilot-instructions.md
Each is a separate ContextSource. The profile lists which to enable.
Default profile enables AGENTS.md only; --framework profile enables
AGENTS.md + CLAUDE.md (because this repo currently has both during
the transition).
SKILL.md
Read by skill/skillmd. Directory layout per the open spec:
skill-name/ ├── SKILL.md Required. YAML frontmatter + markdown body. ├── scripts/ Optional. Executable helpers. ├── references/ Optional. Background docs the agent can read. └── assets/ Optional. Static files.
Frontmatter required fields: name (≤64 chars, lowercase + hyphens),
description (≤1024 chars). Optional fields are passed through.
The three-tier progressive disclosure:
| Tier | What | When loaded |
|---|---|---|
| 1 | name + description | Startup. ~100 tokens per skill. |
| 2 | Body of SKILL.md | When the agent invokes the skill or a trigger fires. |
| 3 | Files in scripts/, references/, assets/ | On explicit reference from tier 2. |
Activation triggers are declared in frontmatter (triggers:;
filename globs, keyword patterns) or invoked explicitly by the user
via /skill-name.
All tree reads — the tier-1 scan, tier-2 activation, and tier-3 file
fetches — go through an os.Root pinned to the skill directory, so a
symlinked SKILL.md, scripts/, or tier-3 file that points outside
the tree is refused rather than read. A symlink whose target stays
inside the tree still resolves.
Skill search paths (in order, last wins):
framework/experimental/harness/skills/(built-in, ships with the binary)~/.config/gofastr/harness/skills/(user-global)<repo>/.gofastr/harness/skills/(project-local)
A later path replacing a same-named skill from an earlier path is a
shadow: precedence stays, but Load prints a warning to stderr naming
the skill and both files. The project directory is untrusted input in a
cloned repo, and a name-squat would otherwise put repo-authored content
into the system prompt under a name the operator chose to trust.
Control plane: engine as a service
The engine is a headless service. It never directly draws to a terminal, opens a window, or speaks a vendor protocol. Anything that wants to drive it does so through the control plane: a transport-agnostic protocol with two message kinds.
// From client → engine. Closed sealed union (rule 14). type Command interface{ isCommand() } type SendInput struct { SessionID string; Content []ContentBlock } type CancelTurn struct { SessionID string } type AnswerPermission struct { SessionID string; CallID string; Decision Decision } type CreateSession struct { Profile string; Resume *string } type AttachSession struct { SessionID string } type DetachSession struct { SessionID string } type SetModel struct { SessionID string; Model string } type EnterPlanMode struct { SessionID string } type ExitPlanMode struct { SessionID string; Approve bool } // Open extension verb for plugin-defined wire commands. // Plugins use this; the engine routes by Namespace to the // registered plugin handler. type CustomCommand struct { SessionID string Namespace string // matches a plugin-claimed slash-command namespace Verb string Payload json.RawMessage } // From engine → client. Closed sealed union; plugins use CustomEvent. type Event interface{ isEvent() } type CustomEvent struct { Namespace, Kind string; Payload json.RawMessage } // see § Extensibility: Event bus for the built-in list
Transports
The same protocol is exposed over multiple transports. The engine
sees a Client:
type IdentityClass uint8 const ( IdentityHuman IdentityClass = iota // user-at-the-keyboard or a proxy for one IdentityAgent // an outer agent driving us ) type Client interface { Subscribe(ctx context.Context) <-chan Event Send(ctx context.Context, cmd Command) error ID() string IdentityClass() IdentityClass }
The four transports phase as follows:
| Transport | Phase | Location | When |
|---|---|---|---|
inproc | v0.1 | Go channels | Bundled TUI / web client within the same process |
rest | v0.1 | HTTP + SSE on Unix socket and/or 127.0.0.1:<port> (LAN opt-in) | Scripts, IDE plugins, other languages |
ws | v0.2 | WebSocket on 127.0.0.1:<port> (LAN opt-in) | Full-duplex remote clients, multi-attach |
mcpserver | v0.2 stdio / v0.3 streamable HTTP | MCP server | Any MCP-capable agent (Claude Code, Codex, Cursor, custom) drives the harness |
rest is the broadest-compat path; ws is preferred for interactive
clients (low-latency event push, no SSE buffering quirks); mcpserver
is the path for agent-driving-agent: any tool that speaks MCP can
pilot a harness session without writing harness-specific code.
Each transport must pass the control/conformance/ cross-transport
parity suite before it is considered "supported." The suite walks the
same scenarios (send / cancel / permission / disconnect / reconnect /
multi-attach) against every transport so cross-transport drift gets
caught early.
Every long-lived control stream is seated: rest SSE, ws, and the
MCP streamable-HTTP GET stream share one control.SeatTable
(control.DefaultStreamSeats = 16 per credential across all three
transports; a Seats field on rest.Server, ws.Handler, and
mcpserver.HTTPHandler wires a separate table). A credential past
the cap is answered 429 at connect. REST JSON responses carry
Cache-Control: no-store. Malformed JSON-RPC into the mcpserver is
answered -32602 (invalid params) with a duplicate/case-folded key
walk at any depth on tools/call and command bodies, and ws grows
its frame buffer while reading under a 60 s payload deadline, so a
peer that dribbles a frame cannot pin the reader forever.
REST API
A handful of resources, JSON bodies, idempotent where possible:
GET /v1/sessions List sessions (active + stored) POST /v1/sessions Create session (profile, optional resume) GET /v1/sessions/{id} Session meta + last N events GET /v1/sessions/{id}/events (SSE) Stream events; supports Last-Event-ID resume POST /v1/sessions/{id}/input SendInput POST /v1/sessions/{id}/cancel CancelTurn POST /v1/sessions/{id}/permission AnswerPermission POST /v1/sessions/{id}/model SetModel POST /v1/sessions/{id}/plan-mode EnterPlanMode / ExitPlanMode DELETE /v1/sessions/{id} End session (does not delete log) GET /v1/profiles List available profiles GET /v1/providers List providers + their model catalogs GET /v1/tools List registered tools (with schemas if eager-loaded) GET /v1/skills List skills (tier-1 metadata) GET /v1/mcp/servers List connected MCP servers + their status GET /v1/health Liveness + version GET /v1/auth/token Issue capability token (when running with --listen)
A token bound to some sessions sees only those in GET /v1/sessions
(live and ?past=true), in the MCP harness.list_sessions tool and in
the harness/v1://sessions resource. A past session carries the start
of its first prompt, so an unfiltered list would read other sessions'
prompts.
POST /v1/sessions/{id}/input accepts either a one-shot synchronous
mode (?wait=turn) or the streaming default (returns 202; events
flow via SSE).
WebSocket API
A single endpoint:
GET /v1/ws?session={id}Bidirectional. Frames are tagged JSON:
{"kind":"command","cmd":"SendInput","sessionId":"…","content":[{"text":"…"}]} {"kind":"event","event":"TextDelta","sessionId":"…","data":{"text":"…"}}
Reconnect resumes from a lastEventId query param.
The upgrade must be a well-formed RFC 6455 handshake (GET, a
Connection: Upgrade token, version 13, a 16-byte key), or it answers
- A command may arrive in fragments: a text frame without FIN, then
continuation frames, with pings between them. A continuation with no message open, a text frame inside one, a fragmented control frame, a control frame over 125 bytes, or a message over 16 MiB closes the socket.
MCP server API
The harness exposes its own engine as an MCP server so any MCP-capable client (Claude Code, Codex, Cursor, custom agents, the GoFastr framework profile of the harness itself) can drive a session without harness-specific bindings. Phasing: stdio in v0.2, streamable HTTP in v0.3.
- stdio (v0.2): clients spawn the harness as a subprocess via
gofastr harness mcp. The harness exits when stdin closes. Capability token passed via env varGOFASTR_HARNESS_TOKEN(not argv, so it never appears inps). On first run from a new parent process (identified by argv0 + binary SHA-256), the harness prompts the user out-of-band on their TTY to authorize the spawn. Authorizations persist in~/.config/gofastr/harness/mcp-parents.lock. - streamable HTTP (v0.3): exposed at
/mcpon the same listener asrest/ws. Bearer token inAuthorization; auth and TLS rules identical tows. A handler mounted without an auth encoder refuses every request with 503: over TCP there is no process boundary to lean on, so unauthenticated is not a posture the transport will serve.
Tools exposed
The tool that runs the agent is named honestly to make the capability visible in MCP UI:
| Tool name | Maps to Command |
|---|---|
harness.list_sessions | (read) |
harness.run_agent_with_shell_access | SendInput: runs the inner agent which can invoke Bash, Read, Write, WebFetch. Outer agent allowlisting this tool is allowlisting RCE-via-LLM. |
harness.cancel_turn | CancelTurn |
harness.answer_permission | AnswerPermission (rejected if originator's ID() matches; agents cannot self-approve) |
harness.set_model | SetModel |
harness.enter_plan_mode / harness.exit_plan_mode | EnterPlanMode / ExitPlanMode |
harness.end_session | (DELETE /sessions/{id}) |
harness.wait_for_turn | synchronous helper: sends input + blocks until TurnEnded |
Session lifecycle verbs (CreateSession, AttachSession,
DetachSession) have no MCP tools: session construction lives in the
harness composition layer above the mux, out of the transport's reach,
and MCP clients are per-call ephemeral so attach/detach have no durable
identity to act on. A tools/list↔dispatch parity test in mcpserver
keeps the advertised list honest.
A startup banner emitted on every mcpserver attach reminds the
outer agent (and the user reading their tool log) that this tool
runs a shell-capable agent. Permission and identity-class rules
from § Multi-client semantics apply: mcpserver clients have
IdentityClass = agent, so they cannot self-approve.
The server runs each request on its own goroutine (up to 32 at once
per connection), so a harness.cancel_turn sent while a wait: "turn"
call is blocked is read and answered. notifications/cancelled naming
a running request cancels it; for a wait: "turn" call that also
cancels the turn it waits on, and the request gets no response, per
MCP. A request id already in flight is refused with -32600.
Over Streamable HTTP every POST shares one in-flight table, so a
notifications/cancelled sent as its own POST reaches a request another
POST is still serving. Entries belong to the bearer token's principal
(claims.JTI) plus the Mcp-Session-Id: a cancel finds only requests
with both, because the client picks the session id. The duplicate-id
check and the 32-request cap count per principal and session across
POSTs; a request past the cap is refused with -32600. Closing a
wait: "turn" request without a cancel stops the wait and leaves the
turn running.
run_agent_with_shell_access has two modes mirroring REST:
synchronous (wait: "turn" blocks until the next TurnEnded and
returns the final assistant message + tool-call summary) and async
(wait: "none" returns immediately; the client reads the events
resource with ?since= to follow the turn).
Resources exposed (URI-addressable, subscribable per MCP)
| URI | What |
|---|---|
harness/v1://sessions | List of sessions (JSON) |
harness/v1://session/{id} | Session metadata + last N events |
harness/v1://session/{id}/events?since=<id> | Events after since (see § Stream resume) |
harness/v1://session/{id}/log | Full append-only log dump |
harness/v1://profiles | Available profiles |
harness/v1://profile/{name} | Profile spec (skills, MCP servers, tools, permissions) |
harness/v1://providers | Provider catalog |
harness/v1://provider/{name}/models | Model list + pricing |
harness/v1://tools | Registered tool schemas |
harness/v1://skills | Skill tier-1 metadata |
harness/v1://skill/{name} | Skill body |
Prompts exposed
Every loaded skill is re-exposed as an MCP prompt. An external MCP
client can call prompts/get harness/v1://skill/{name} and inject the
skill body into its own conversation, or trigger harness.send_input
with a skill: <name> directive to run the skill inside the harness.
Why this matters
This is more than another way to attach a UI: it lets one agent
drive another agent's harness session (agent-driving-agent). An
outer agent (e.g. Claude Code in a different repo) can call
harness.run_agent_with_shell_access and treat a whole harness
session as a single tool call. That works for sub-agents, parallel
runs, CI orchestration, and testing framework changes end-to-end (a
harness in --framework mode driving another harness in default
mode against a sample app). The tool's honest name and the
identity-class enforcement are what keep this transparent instead of
turning into a confused-deputy hazard.
Observability of agent-driven sessions
A session driven by an outer agent has identity-class enforcement
that blocks the agent from self-approving, meaning a
PermissionRequested event has nowhere to land unless a human
client is attached. The harness handles this proactively:
- Auto-attach on MCP invocation. When a session starts via the
mcpservertransport, the harness emits a system notification on the user's machine:"Outer agent <name> started session sess_xyz. /sessions:attach sess_xyz to observe."If--webis enabled globally, the session auto-opens as a new web tab with a banner: "This session was started by an external agent. You are observing ashuman." - Per-parent ack. First-time invocation from a new parent
process prompts: "Always auto-attach sessions from this
parent? [Y/n]." Persists in
mcp-parents.lock. - Permission denial on timeout. If a
PermissionRequestedhas nohumanclient attached and no answer arrives withinpermission_timeout(default 60 s;0keeps it, negative is rejected at construction), the prompt is denied rather than hanging. The inner agent receives a structuredError{Reason: PermissionTimeout}it can plan around. - Cost-in-return-payload. Synchronous
harness.run_agent_with_shell_accesswithwait: "turn"returns the final assistant message plus a summary:{cost: 0.043, turns: 7, tools_used: ["Read", "Bash", "Write"]}. The outer agent (and the outer user reading its tool-call log) can see what the inner session did.
Multi-client semantics
All multi-client behavior lives in control/multiplex/, not in the
engine. The engine sees Client.ID() and Client.IdentityClass();
it never sees how clients are wired.
- A session may have many attached clients simultaneously.
- All clients receive the same event stream (broadcast from
control/multiplex). - Total ordering.
SendInputfrom any client is queued at the multiplexer with a monotonic timestamp set at queue-arrival. Transports never establish ordering directly; they all feed the multiplexer. - No mid-turn input. A second
SendInputarriving while a turn is in progress is rejected withError{Reason: TurnInProgress, OriginatorID: ...}. The sender mustCancelTurnfirst. - Originator tracking. The
Client.ID()of the sender is recorded on the turn and surfaces on every event for that turn asOriginatorID. - Permission arbitration.
PermissionRequestedbroadcasts to all clients with theOriginatorIDfield set.AnswerPermissionis rejected if the answering client'sID()equals theOriginatorID(agents cannot self-approve their own turn); see hard rule 11. By default, at least one client withIdentityClass = humanmust answer; multiple human acks for the same call after the first are no-ops (first-wins, last-loses for audit).--auto-approvedisables the human-ack requirement and is honest about what it does. - Detach is non-destructive. The
EngineRuncontinues; events for the originator's turn keep streaming to remaining clients. If no client remains, the engine continues to completion and the log captures everything; re-attach replays fromlastEventId.
Listening and binding
- Default: engine listens only on a Unix socket at
~/.local/share/gofastr/harness/control.sockwith mode0600. Filesystem perms are not treated as authentication; see Authentication below. A capability token is still required. --listen 127.0.0.1:PORT: bind a loopback port for browser / IDE / external-MCP clients. Capability token required. Additional defenses against same-origin-policy bypasses (DNS rebinding, CSRF): - Reject any request whoseHostheader is not exactlylocalhost:PORTor127.0.0.1:PORT. - Reject any request with anOriginheader not in the explicit allowlist. - The capability token is required in a custom headerX-Harness-Token(notAuthorization) so browsers must preflight; the preflight fails closed for unauthorized origins.- A non-loopback
--listen(a LAN address, a wildcard such as0.0.0.0:PORTor:PORT, or a name that resolves off loopback) is refused at startup. The chat page at/embeds a 24 h human-class token for whoever loads it, and theHostcheck stops DNS rebinding, not a peer that reaches the port and sendsHost: localhost. The check reads the address the listener bound, not the spelling passed. There is no LAN mode. The chat page is never framed (X-Frame-Options: DENY, CSPframe-ancestors 'none'): a page on another site could otherwise load it in an iframe and steer the operator's click onto the permission modal. --no-listen: in-process clients only (engine speaks only throughinproc). Useful for tests or pure-CLI use.gofastr harness mcp(v0.2): launches a one-shot stdio MCP server bound to a single session; exits when stdin closes. Capability token via env varGOFASTR_HARNESS_TOKEN; first-run parent-process attestation required.
Authentication
The harness treats every transport as an authenticated channel, including Unix sockets. The defense-in-depth posture means no single filesystem permission, network rule, or process-trust assumption is load-bearing on its own.
Token claim set
Tokens are internally-issued (no third-party dep) and carry an explicit claim set:
{ "sessions": ["sess_abc", "sess_def"], "commands": ["SendInput", "CancelTurn"], "identity_class": "agent", "exp": 1750000000, "can_mint": false, "nbf": 1747400000, "jti": "tok_xyz" }
sessions: whichSessionIDs the token may attach to (empty array = none; absent = all, only allowed for the initial bootstrap token).commands: whichCommandverbs the token can issue.identity_class:humanoragent. Set at token mint; enforces hard rule 11 on the wire.exp: Unix-second expiry. Default 24 h.can_mint: whether holding this token allows issuing further tokens. Defaultfalse.nbf: not-before timestamp; reject ifnow < nbf.jti: token ID for revocation list.
Issuance
GET /v1/auth/token on the Unix socket initiates issuance with the
desired claim set. The harness picks a confirmation channel
from this priority list, and surfaces in the response which channel
was chosen (so the requester knows where to look):
- An already-attached
human-class client (TUI or bundled web): the 6-digit code appears as a modal inside the existing trusted client. This works when the user is on the web tab only (no visible TTY). - The harness's own TTY if it has a controlling terminal.
- A desktop system notification (
osascripton macOS,notify-sendon Linux). - None available → refuse token issuance with the explicit error:
"Token issuance requires an interactive confirmation channel. For headless/CI: pre-provision a token file. For service managers: launch with --auth-channel notify."
The requester must POST the code back within 60 s to receive the token. This blocks the simplest "any process running as the user mints tokens" attack: the attacker needs concurrent visibility of one of the user's trusted clients.
For headless/CI use, a pre-provisioned token file
(~/.config/gofastr/harness/ci-token.json) is read at boot; the
file must have mode 0400 and be created out-of-band. The
approved.lock for skills/context/hooks is also pre-generated via
gofastr harness ack --emit-lockfile and either committed to the
repo or stored as a CI secret.
CI bootstrap flow (v0.1)
The full CI setup uses three pre-provisioned artifacts:
ci-token.json(mode0400): capability token with claims scoped to whatever the CI job needs to do. Bound to a specific session ID for one-shot runs.approved.lock: generated locally once viagofastr harness ack --emit-lockfileand committed to the repo (or stored as a CI secret). Subsequent CI runs read this and skip TOFU prompts.GOFASTR_HARNESS_MACHINE_KEY: env var holding the credstore key, passed as a CI secret. Replaces the passphrase prompt for the encrypted credential store. The value must decode to exactly 32 bytes; three encodings are accepted: 32 raw bytes, 64 hex characters, or base64 (standard/URL, padded or not). A value that does not decode to 32 bytes is rejected loudly; the harness never silently falls back to a weaker secret.
CI engineers verify acks without launching the agent loop using:
gofastr harness verify-ack # exits non-zero on hash drift(verify-ack is specified here but not yet wired into cmd/gofastr;
today the CLI dispatches only the mcp and creds subcommands, and
anything else starts an interactive session.)
Drift between the lockfile and live content fails the CI run explicitly with the changed file paths in the error, never silently approves.
Revocation
Revoked jtis are stored in ~/.local/state/gofastr/harness/revocations.db
and checked on every request. Revocation reaches held streams too, not
just new handshakes: an open SSE events stream re-verifies the token
between events and on a 10s ticker, and a live WebSocket re-verifies on
every inbound command frame and on the same ticker — both close when the
credential is refused. Revocation API:
DELETE /v1/auth/tokens/{jti}.
Per-transport rules
| Transport | Auth | Notes |
|---|---|---|
inproc | trust boundary = process | No token; engine and client share memory |
rest over Unix socket | token + 0600 perms | Token required even on socket; perms are defense in depth, not authentication |
rest / ws over TCP loopback | token + Host/Origin checks + custom header | Defends against DNS rebinding |
rest / ws over LAN | token + TLS | Token bound to specific sessions |
mcpserver stdio | token via env var + parent attestation | Token never in argv |
mcpserver HTTP | token in Authorization | Same as TCP transport |
Per-session capability tokens are the unit of sharing: handing a token to a teammate or an IDE plugin grants attach rights to one session without exposing the rest of the harness.
Protocol versioning & evolution
The control protocol is the harness's long-term API. Every external client (IDE plugin, TS SDK, Python client,
mobile app, agents driving us over MCP) commits to its shape. This
section is normative; implementers MUST follow it, and the
control/conformance/ suite tests against it.
Handshake (required before any other command)
Every transport speaks the same Handshake envelope before
exchanging Commands. REST: GET /v1/handshake. WS: first frame.
MCP: extends initialize. Stdio: first JSON-RPC message.
{ "protocol_version": "0.1.0", "canonical_form_version": 1, "schema_version_token_claim": 1, "schema_version_profile": 1, "schema_version_session_log": 1, "command_kinds": ["SendInput", "CancelTurn", "AnswerPermission", "CreateSession", "AttachSession", "DetachSession", "SetModel", "EnterPlanMode", "ExitPlanMode", "CustomCommand"], "event_kinds": ["TextDelta", "ThinkingDelta", "ToolCallStarted", "ToolCallProgress", "ToolResult", "TurnStarted", "TurnEnded", "CompactionTriggered", "CostIncremented", "PermissionRequested", "Cancelled", "Error", "StreamGap", "TokenExpiring", "HookTimeout", "SessionEnded", "MCPServerDown", "TurnTiming", "CustomEvent"], "features": ["plan_mode", "branching", "delegate", "rest", "ws", "mcpserver_stdio", "mcpserver_http", "auto_approve", "auto_attach_on_mcp"], "resource_uri_scheme": "harness/v1" }
protocol_versionis SemVer.- Minor bumps are additive-only: new optional
Command/Eventkinds, new optional fields. Clients on an older minor MUST continue to work. - Major bumps require client re-implementation.
- Deprecation cycle is one minor release minimum: a feature marked deprecated in 0.4 may be removed in 0.5 but not earlier.
- Absence of a feature flag is normative: any optional feature MUST be flagged to be relied upon. Built-in features still appear in the array for explicit detection.
Servers MUST reject pre-handshake Commands with
Error{Reason: HandshakeRequired}.
Canonical event envelope
All transports carry the same event JSON. Envelopes are framing only, never the event body.
{ "id": 12345, // uint64, monotonic per SessionID "kind": "TextDelta", // matches event_kinds in handshake "session": "sess_01H…", "originator": "cli_01H…", "ts": "2026-05-23T19:00:00.123Z", "payload": { ... event-specific ... } }
Transport-specific framing:
| Transport | Envelope |
|---|---|
| SSE | id: <id>\nevent: <kind>\ndata: <canonical JSON>\n\n |
| WS | {"frame":"event","body": <canonical JSON>} |
| MCP notifications | notifications/resources/updated with params.contents = canonical JSON |
| inproc (Go channels) | Event Go struct with the same field set |
Unknown-field policy
| Where | Unknown field rule |
|---|---|
Command body fields | Ignore unknown (additive evolution) |
Event body fields | Ignore unknown |
| Token claim set | Reject token if any claim listed in its own critical_claims: [] is unrecognized; ignore other unknowns. Fail-closed on capability-restricting claims. |
| Profile TOML | Warn, do not error on unknown keys (forward-compat for downgrades). Require explicit schema_version bump for breaking renames. |
| MCP resource bodies | Ignore unknown |
Field-name reservations:
x_*: vendor / plugin extensions_*: engine internal, off-the-wire- No other prefix is reserved.
Token claim versioning
Token claim set includes a required ver: <int> field.
{ "ver": 1, "jti": "tok_01H...", "sessions": ["sess_01H..."], "commands": ["SendInput", "CancelTurn"], "identity_class": "agent", "exp": 1750000000, "nbf": 1747400000, "can_mint": false, "critical_claims": ["sessions", "commands"] }
- Verifiers MUST reject any token whose
verthey do not recognize. - Adding a non-capability-restricting claim (e.g.
display_name) does not bumpver. - Adding any claim whose absence weakens security does bump
ver. critical_claimslists fields whose presence-and-meaning matter for security; verifiers must understand all listed claims or reject. Newly-minted v2 tokens listing a v2-only claim incritical_claimsare correctly rejected by v1 verifiers.
Stream resume (unified)
Every transport that delivers events supports resume with the same semantics:
- Client provides last seen sequence ID via:
- SSE:
Last-Event-IDheader (HTTP standard) - WS:lastEventIdquery param on reconnect - MCP:resources/readofharness/v1://session/{id}/events?since=<id>(stdio and POST/mcp). The read returns a JSON array of envelopes, at most 1000; a client that gets a full page reads again withsinceset to the last id. MCP has no live event stream yet: the GET/mcpstream carries keepalives only. - Server replies with all events with
id > sincein order, then continues live. A missing value means "live only"; a value that is not an unsigned integer is refused (400 on SSE and WS, a -32602 error on MCP), never read as zero. - Replay reads the bus window first and the session log for older
ids (
Bus.SubscribeSince). A range neither holds comes back as oneStreamGap{from, to, reason: "unavailable"}envelope with id 0, in its place in the order, never a silent skip. SSE writes it with noid:line. - Events past their retention TTL stay in the log with their payload
replaced by the
«ttl-expired»marker, so they replay in order with the marker as their content.
Canonical message form versioning
The internal Anthropic-shape canonical message form has its own
canonical_form_version (in the handshake). Provider adapters
target an explicit version; the engine asserts they match. When
Anthropic (or any provider) ships a new content block type, the
harness explicitly decides whether to absorb it (bump
canonical_form_version) or wrap it opaquely as a
provider-stamped block (no bump).
SQLite session log: private schema
The on-disk SQLite schema is private. External consumers MUST
NOT read sessions.db directly. The public path for replay,
export, and external tooling is:
- Live:
GET /v1/sessions/{id}/events(SSE) or/v1/ws; replay by id through either, orharness/v1://session/{id}/events?since=<id>(MCP) - Offline:
harness sessions export --session <id> --format jsonlproduces a stable JSONL contract whose shape matches the canonical envelope.
The schema may change between any two minor releases (with a forward-only migration); the JSONL export shape evolves under the same additive rules as wire events.
Plugin distribution model
Plugins are compiled in. The Plugin interface is a clean
internal seam for composition, not a binary-distribution API. Go's
plugin.Open is rejected for ABI fragility. Adding a third-party
capability to a running harness uses MCP server as the
distribution channel: ToolSource consumes it, the harness
adds permissions, the plugin author ships a separate binary
checked by sha256 pinning in the profile.
docs/harness-clients/ will ship reference SDKs (TS, Python, VS
Code skeleton) that drive the harness via the control protocol;
those are clients, not plugins.
Conformance suite is normative
control/conformance/ is the source of truth where this document
and an executable test disagree. The suite is runnable by external
implementers:
gofastr harness conformance --against http://localhost:8080 gofastr harness conformance --against unix:///path/to/control.sock gofastr harness conformance --against stdio:///usr/local/bin/some-other-harness
(The conformance subcommand is specified here but not yet wired into
cmd/gofastr; the suite itself lives in control/conformance/ and runs
via go test.)
The suite is versioned alongside protocol_version. v0.1's suite
runs against v0.1 servers; v0.2 adds new scenarios behind the
v0.2 feature flags.
Test infrastructure (v0.1)
- In-memory transport for PR-gating. A
net.Pipe-backed transport adapter runs the conformance scenarios without the
kernel network stack: fast, deterministic, no port-reuse races. PR CI runs this matrix on every change.
- Real-socket smoke nightly. One pass per platform (macOS / Linux / Windows) of the same scenarios against actual Unix sockets / loopback TCP to catch OS-specific bugs (TCP backpressure, socket EOF semantics, ephemeral exhaustion). Not PR-gating; failures open a tracking issue.
- Fake clock. Disconnect/reconnect/timeout scenarios use an
internal/clockinterface (stdlib-only; define an interface, inject a real or fake implementation). No wall-clock waits in PR-gating tests. - Flake budget. Any single conformance test flaking >0.5% in a rolling 100-run window is auto-quarantined and assigned a tracking issue. No silent retries in CI.
Backward-compat policy (stated explicitly)
- Pre-1.0: breaking changes allowed at every minor bump (0.x → 0.x+1), with one minor of deprecation overlap where feasible.
- 1.0+: SemVer-strict. Breaking changes require a major bump.
- A deprecation that lands in v(N) is removable in v(N+1) but not earlier, which provides one full release cycle of overlap.
- Migration tools ship in the same release that introduces a breaking change.
Clients
Clients are independent of the engine and may live in the same process (bundled) or anywhere (external). Two clients ship in v1.
TUI (bundled, inproc)
Pure stdlib + golang.org/x/term. Architecture:
- Raw mode via
term.MakeRaw(saves/restores termios on exit) - ANSI sequences written to
os.Stdoutdirectly - Input parsing reads bytes from
os.Stdin, decodes escape sequences (cursor keys, function keys, mouse, bracketed paste) - Elm-arch update loop:
(state, msg) → (state, cmd) - Components: scrollback view (virtual list with diff-based redraw), input area (multiline with history), status line, modal overlay (permission prompts, diff preview at TOFU ack, file picker, command palette), sidebar (sessions, skills, MCP, hooks, session-policy)
- Resize handled via
SIGWINCH
Status line layout (left to right, fixed columns):
sess_01HXX… · framework · openrouter:claude-sonnet-4 · $0.04/turn · $0.31/sess · web: http://localhost:8421- Cost meter units explicit (
$N/turn · $N/sess); USD with cents precision; updates at ≤4 Hz to avoid flicker, ≥1 Hz when streaming.--quiet-costhides cost columns. - When
RoutingProviderlands (v0.3) and a turn touches multiple providers, the cost meter shows the split:$0.02 zai + $0.05 openrouter = $0.07/turn. Never silently unified. - The
--webURL is pinned in the status line for the lifetime of the session. OSC 8 hyperlink markup wraps the URL for terminals that support it; raw URL printed alongside as fallback (tmux compat varies).
Ctrl-C semantics. Two-press exit, mirroring git rebase-style
TUIs:
- First Ctrl-C cancels the active turn (
CancelTurn). - Second Ctrl-C within 2 s exits the harness cleanly.
- Between presses, the TUI shows a brief banner:
"Press Ctrl-C again within 2s to exit."
Diff-review UI (used at TOFU ack and at permission-prompt diff preview):
- Side-by-side diff (left = approved hash content, right = current content); single-pane unified-diff fallback when terminal width is too narrow.
- Keys:
n/pnext/previous hunk,aaccept,ddeny,vview full file,ssave to file for offline review. - For new (never-before-ack'd) files, the left pane shows "[new file]" and the right pane shows the full content with syntax highlighting.
Web (bundled, attaches via inproc or ws)
Built on GoFastr itself. The harness imports framework and mounts a
GoFastr App on a random local port chosen at boot. Entities:
Session, Turn, Message, ToolCall, Event. Pages are
server-rendered with island hydration per the GoFastr UI runtime.
Live event push uses SSE (stream.go) bridged from the control-plane
event stream. The web client is started opt-in (--web); when on, it
runs alongside the TUI.
This is real dogfooding: the framework's UI runtime, entity model, hooks, and SSE plumbing all exercise themselves through the harness.
External clients
Anything that can speak HTTP or WebSocket can act as a client, no
different from the bundled ones. Reference implementations to ship
in docs/harness-clients/:
- curl recipes: create a session, send input, stream events.
- TypeScript client: thin npm package wrapping
fetch+ EventSource / WebSocket for IDE-plugin authors. - Python client: same shape for scripts and notebooks.
- VS Code extension skeleton: minimal wiring to attach to a running harness from the editor.
External TUIs (a vim plugin, a Slack bridge, a CI bot, a remote SSH TUI) all use the same protocol. No engine changes needed.
Persistence
Sessions
Session stores, cost ledgers, and export bundles use owner-only permissions. On Windows, their directories and files receive owner-only ACLs; SQLite sidecars inherit the protected directory's owner-only access.
SQLite append-only event log at
~/.local/share/gofastr/harness/sessions.db. User-scoped, not
worktree-scoped: each worktree gets its own EngineRun but shares
the log store. One row per Event emitted on the bus. Indexed by
(LogID, turn, time, jti) plus a covering index for the
"events since id" replay query. Schema is private (see §
Protocol versioning → SQLite session log).
Migration ledger (mandatory from v0.1)
CREATE TABLE schema_migrations ( version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL, sha256 TEXT NOT NULL -- of the migration SQL file );
Migrations live as numbered SQL files under
framework/experimental/harness/session/sqlite/migrations/*.sql. Forward-only.
A harness binary refuses to open a DB whose max(version) exceeds
its known max (so downgrades fail loud). Migrations on the
encrypted page-level DB hold an explicit lock; the doc presents a
"Database is upgrading…" message in the UI during the wait.
Tool-call intent / outcome ledger (mandatory from v0.1)
To survive crashes mid-tool-call without re-executing destructive operations on resume:
CREATE TABLE tool_call_intents ( call_id TEXT PRIMARY KEY, -- call_01H… log_id TEXT NOT NULL, tool_name TEXT NOT NULL, args_hash TEXT NOT NULL, is_mutating BOOLEAN NOT NULL, -- declared per-tool (Read=false, Bash=true) started_at TEXT NOT NULL ); CREATE TABLE tool_call_outcomes ( call_id TEXT PRIMARY KEY REFERENCES tool_call_intents(call_id), outcome TEXT NOT NULL, -- "ok" | "error" | "cancelled" | "timeout" completed_at TEXT NOT NULL, result_ref TEXT -- pointer into events table );
tool_call_intentsis written before the tool process spawns (withfsyncfor mutating tools).tool_call_outcomesis written after the tool returns (fsyncfor mutating tools).- On resume, the engine queries
SELECT * FROM tool_call_intents WHERE call_id NOT IN (SELECT call_id FROM tool_call_outcomes). - For each orphan: if
is_mutating = false, default to "retry." Otherwise halt the resume and present a structured prompt:"Tool call X (Bash 'rm -rf ./build') started at T but did not finish. Mark as: [Failed | Succeeded with note | Abort session]." - Read-only tool sources (
Read,Glob,Grep,Ls,WebFetch) declareis_mutating=false; everything else declarestrue.
The cost is one extra fsync per mutating tool call (~1ms on SSD). The benefit is no destructive re-execution on resume.
Storage policy
At rest. The database file is encrypted with a per-machine key stored in the OS keychain (encrypted-file fallback as in § Credentials). The user's hot keychain unlock is required to read the log; a stolen laptop with the screen locked cannot read it without the user's password.
Redaction middleware. A session/redact.go middleware on the
event-write path matches secret regexes against ToolResult.Content,
ToolCallStarted.Args (the argv of Bash commands often contains
tokens), and TextDelta (pasted secrets). Patterns: AWS access
keys (AKIA…), GitHub PATs (ghp_…, github_pat_…),
-----BEGIN .* KEY-----, Bearer [A-Za-z0-9-_\.]+, JWT-shaped
tokens, common cloud-provider key prefixes, and NAME=value
assignments whose name ends in _API_KEY, _TOKEN, _SECRET,
_PASSWORD, _PASSPHRASE, _ACCESS_KEY or _MACHINE_KEY (the
last two cover the credstore's own GOFASTR_HARNESS_PASSPHRASE and
GOFASTR_HARNESS_MACHINE_KEY, which every Bash child inherits). The
same names redact a JSON member's value, so a Bash call's
"env": {"ZAI_API_KEY": "…"} is caught although the name and the
value are two strings. Matched substrings are
replaced with «redacted:KIND» markers and the original is
discarded, not stored anywhere. The patterns run on each decoded
string of the event payload, never on the serialized JSON, so a
match next to a quote or an escape cannot leave an event that no
longer parses. The export bundle's maintainer pass works the same
way, and bundle.json counts any stored event it could not write
in skipped_events.
TTL. Full-content events (TextDelta, ToolResult body,
ToolCallStarted.Args) are retained for 30 days by default. After
expiry, metadata only (event kind, timestamp, originator, token
counts, costs) survives in the row; the content column is replaced
with «ttl-expired». Configurable per-profile.
- Pin to disable expiry.
/sessions:pin <id>marks a session as no-TTL, no-idle-shutdown. Pinned sessions are listed with★in/sessions:list. - Pre-expiry banner. At boot, if any sessions expire within 7
days, the TUI / web shows a banner:
"3 sessions expire this week: sess_…, sess_…, sess_… — /sessions:pin or /sessions:export to keep them."The banner is dismissable per-session-set.
Operations:
- Resume. Open an existing
LogIDand create a newEngineRunbound to it. Conversation is reconstructed from events up to the last compaction boundary orTurnEnded, whichever is later. - Branch. Copy events up to a
TurnEndedboundary into a newLogID. Branching mid-turn is not supported; the UI must pick aTurnEndedevent as the cut point. Tool_use IDs in the copied events are rewritten so the resumed engine can issue new tool_use blocks without colliding with the original conversation if both branches are later compared. - Replay = step-through. Powers a debugging UI by replaying from the log. Does not re-execute: provider calls are not re-issued. Cache state and live MCP connections are non-replayable; replay shows what happened, not "what would happen again." Re-execution under different conditions is a separate concept and not in scope for v0.x.
- Export. JSONL (event-by-event) or markdown (rendered transcript). The redaction pass and TTL apply equally to exports.
Memory
Markdown files with YAML frontmatter; one file per memory; indexed by
a flat MEMORY.md (same shape as the user's existing auto-memory).
Four types: user, feedback, project, reference. Read by a
request middleware that selects relevant entries per turn (tag match +
description fuzzy match).
Credentials
Encrypted-file is the primary store. AES-GCM with a key derived
from either a user passphrase (interactive use) or a machine-bound
key (CI / headless). Implementation lives in provider/credstore/.
OS keychain integrations are opt-in plugins, build-tagged per platform:
- macOS (
keychain_darwin.go): shells out tosecurity(the CLI), not CGO. Avoids the CGO+keychain entanglement on first run. - Linux (
keychain_linux.go): D-Bus client speakingorg.freedesktop.secrets. Falls back to encrypted-file if no Secret Service available. - Windows (
keychain_windows.go):CredRead/CredWriteviagolang.org/x/sys/windows.
Provider tokens are held in a credential-helper subprocess
(provider/helper/): a separate process that holds the unlocked
secrets and signs provider requests on the harness's behalf. The
agent process never sees the raw token, and the agent's Bash tool
cannot exfiltrate it from the harness's own memory.
Per § Threat model, the default Bash permission preset blocks
security, secret-tool, keyctl, and kwalletcli to prevent
the obvious credential-exfiltration paths via the agent's own
tools.
First-run setup: gofastr harness creds
On first run, provider API keys must be stored in the credstore
before starting the harness. The gofastr harness creds subcommand
manages credentials without booting the full harness:
gofastr harness creds add <provider> <account> <secret> gofastr harness creds list gofastr harness creds delete <provider> <account>
Examples:
# Store an OpenRouter API key gofastr harness creds add openrouter default sk-or-v1-... # Store a ZAI API key gofastr harness creds add zai default <api-key> # List stored providers (no secrets shown) gofastr harness creds list # Remove a stored key gofastr harness creds delete openrouter default
Key resolution (same priority order as gofastr harness):
GOFASTR_HARNESS_MACHINE_KEYenv var: 32-byte key in raw, hex, or base64 encoding. Used for CI/headless where no passphrase prompt is possible.GOFASTR_HARNESS_PASSPHRASEenv var: derives a key via PBKDF2-SHA256 with a per-install salt at~/.config/gofastr/harness/salt.
With neither set, the store refuses to open and gofastr harness
(including harness mcp) refuses to boot: there is no default
passphrase, since one shipped in the repository would make "encrypted
at rest" indistinguishable from plaintext. Set one of the two
variables before storing any credential.
The credstore file is at ~/.config/gofastr/harness/creds.enc.
XDG_CONFIG_HOME overrides the ~/.config base when set.
Entries are keyed by provider and account encoded separately, so a
| inside either one cannot make two pairs share a slot. Files written
before that change used provider|account keys. A legacy key with one
| is still read. add on its pair writes the new key and drops the
legacy one; delete removes both, and the credential with them. A
legacy key with more than one | (p|a|b) could belong to p|a/b
or to p/a|b, so neither pair reads it: Get returns
credstore.ErrAmbiguousLegacyKey and list shows every pair that
could own it. add on any of those pairs replaces the legacy entry;
delete on any of them removes it.
Note on env-var vs credstore: The harness also reads API keys
from OPENROUTER_API_KEY and ZAI_API_KEY environment variables
(and from .harness-secrets/env). Use whichever is more convenient.
The credstore is the recommended path for long-lived developer
machines; env vars suit ephemeral CI environments.
.harness-secrets/env is repo-local, so a cloned repo's copy is
attacker-authored: the loader delivers provider credentials only
(keys ending _API_KEY or _TOKEN, never GOFASTR_*). Proxy,
PATH, loader, and HOME settings in that file are ignored — the
operator's shell stays the only source for anything that shapes the
process. Variables already set in the environment always win over the
file. Even the provider keys are used only with your consent: asked
once per repository and key set on a terminal, refused otherwise unless
remembered or --allow-repo-secrets is passed (§ Threat model,
"Repository-supplied provider keys").
Config
XDG layout:
~/.config/gofastr/harness/config.toml: global defaults~/.config/gofastr/harness/approved.lock: TOFU file hashes~/.config/gofastr/harness/mcp-parents.lock: TOFU parent-process hashes for stdio MCP-server transport~/.local/share/gofastr/harness/sessions.db: session store (encrypted)~/.local/share/gofastr/harness/memory/: auto-memory~/.local/share/gofastr/harness/control.sock: Unix socket~/.local/state/gofastr/harness/log/: harness's own logs (one file per day:harness-YYYYMMDD.log)~/.local/state/gofastr/harness/revocations.db: revoked token jtis<repo>/.gofastr/harness/: project-local overrides (skills, hooks, project profile)
Profiles
A profile is a TOML file that lists which plugins to activate and how to configure them. Two preset profiles ship:
# profile/framework.toml: working on GoFastr itself schema_version = 1 # see § Protocol versioning name = "framework" default_model = "openrouter:anthropic/claude-sonnet-4" # Copilot lands in v0.2; not the default until 30d stable prompt_header = """ You are working on the GoFastr framework. Read core-ui/ARCHITECTURE.md and framework/ARCHITECTURE.md before touching their domains. The hard rules in CLAUDE.md are still load-bearing for this profile during the AGENTS.md transition. """ # Project-instruction sources processed in order, concatenated into the system prompt. # Each entry resolved by context/reader.go; no per-source plugin. context_sources = ["AGENTS.md", "CLAUDE.md"] skill_packs = ["builtin", "gofastr-framework"] # MCP server binaries pinned by sha256 (hard rule, security/threat-model). # Missing or mismatched hash refuses to spawn the server. mcp_servers = [ { name = "gofastr-introspection", cmd = "gofastr", args = ["mcp"], sha256 = "abc123...def", discovery = "lazy" }, ] tool_packs = ["fs", "git", "web", "gofastr"] permissions = "preset/framework.toml" # Project-local hooks are off by default. Enabling requires # --allow-project-hooks AND a TOFU ack per hook. allow_project_hooks = false
# profile/default.toml: working with GoFastr (downstream apps) schema_version = 1 name = "default" default_model = "zai:glm-5.1" prompt_header = """ You are helping the user build an application with the GoFastr framework. Prefer kiln for scaffolding entities and pages; consult docs/ for framework features. """ context_sources = ["AGENTS.md"] skill_packs = ["builtin", "gofastr-apps", "kiln"] mcp_servers = [ { name = "kiln", cmd = "kiln", args = ["mcp"], sha256 = "def456...abc", discovery = "eager" }, ] tool_packs = ["fs", "git", "web"] permissions = "preset/default.toml" allow_project_hooks = false
Selection: gofastr harness --framework or gofastr harness (default).
Override: --profile <path>.
Mid-session profile switching
/profiles:framework (or any other profile) does not morph the
running session; that would produce a model that's mid-thought
in one system prompt and mid-tool-schema in another. Instead, the
command:
- Creates a new
EngineRunagainst the chosen profile. - Prompts the user: "Import the last N turns into the new
session?" (default
N=5). On accept, those turns are copied into the new session's first-turn context as a summary (not a full replay). - Pauses the old
EngineRun(becomes resumable via/sessions:resume <id>). User can return to the old context. - Visible message in the new session:
"Started new session sess_xyz on profile 'framework'. Old session sess_abc is paused; /sessions:resume sess_abc to return."
/profiles:framework --in-place exists for power users who
explicitly want to morph the running session; the harness warns
that the model may be incoherent across the boundary.
Trust gates on profile-loaded content
- MCP server binaries:
sha256is required when the profile is loaded from project scope; optional but recommended in user/global profiles. Refusal to spawn on mismatch. - Skills: every
SKILL.mdis SHA-256 hashed at first load (rule 13); changes prompt a diff and a re-ack. - Context sources: every
AGENTS.md/CLAUDE.md/ etc. is also TOFU'd. NestedAGENTS.mdfiles discovered during walk-up are individually ack'd. - Hooks:
~/.config/gofastr/harness/hooks/*are user-owned and trusted.<repo>/.gofastr/harness/hooks/*are off unless--allow-project-hooksis set AND each hook has been ack'd via TOFU. Hook commands appear in the diff at ack time.
Lifecycle / boot
Boot is split between the gofastr harness subcommand (CLI flags,
profile load, transport + client start) and harness.New() (the
in-process composition). What actually runs today:
- Subcommand. Parses flags, loads the profile (on-disk TOML, or the
go:embedfallback), resolves XDG paths, loads repo secrets into env. New(): context. Builds the contextReaderovercontext_sources. Files are read and SHA-256 hashed, but there is no TOFU ack gate: nothing blocks boot or persists approvals (rule 13 / Maturity).New(): skills. Scans the skill search paths and loads tier-1 metadata; eachSKILL.mdis hashed. Again, no ack gate.New(): tools + permissions. Registers the built-in tool packs from the profile; starts the permission engine and loads its persistent allowlist. No MCP servers spawn:mcpclientexists but is not wired intoNew(), and profilemcp_serversare not yet honored.New(): credentials + providers. Opens the encrypted-file credstore, starts the in-process credential helper, and constructs the OpenRouter + ZAI providers (key resolution: credstore → env).New(): stores + hooks. Opens the SQLite session log, the file-based memory store, and the hook runner; wires the resource catalog.New()returns.- Subcommand: control plane + clients. The subcommand (not
New()) starts the control-plane transports and the bundled TUI/web client, then drives turns.
Limitations called out honestly: the TOFU ack gate, MCP-server spawn + sha256 pinning, the concurrent-worktree one-engine-many-clients socket model, and the graceful-shutdown re-encryption/token-zeroing steps below are design intent, not current behavior (see Maturity).
Shutdown today is Harness.Shutdown(): it closes each session's engine
bus, waits up to 10 seconds for the persist loop to write the events
already queued on it, then cancels the loop, unregisters the engine from
the mux + catalog, and closes the session store. The session event log
is flushed: an append the store refused, or a loop still writing at the
limit, comes back in Shutdown's error with the store's Close error,
so a missing transcript event is reported rather than dropped. Once
Shutdown begins, Harness.CreateSession registers nothing and returns
harness.ErrShutdown, so no session is left running against the closed
store. The rest of the graceful contract (broadcast SessionEnded, stop the
control plane, shutdown MCP servers, restore the terminal, re-encrypt
SQLite, persist the revocation list, zero in-memory tokens) is the
target, not the present implementation.
Concurrent-worktree deployment model
Multiple gofastr harness invocations across worktrees share the
user-scoped log + Unix socket path. v0.1 commits to
one-engine-many-clients:
- The first invocation in a user session binds the Unix socket and becomes the engine.
- Subsequent
gofastr harnessinvocations detect the existing socket and run asinprocclients of the existing engine: no second engine process, no SQLite WAL contention, no duplicate credential helpers. - Sessions are tagged with
working_dir./sessions:listshows the working dir column so the user can pick the right one. - The TUI on attach offers: "Attach to last session in this directory, create new, or pick from list."
Tradeoff: a panic in one worktree's turn kills the engine
process and therefore other worktrees' sessions. The mitigations
are (a) the agent loop has a panic-recovery boundary per turn, so
panics produce an Error event and the engine continues; (b) the
shared SQLite log means re-attach reconstructs every session
quickly after any crash.
Idle-shutdown and resource lifecycle
Detach is non-destructive, but the engine doesn't hold sessions forever:
| Resource | Idle policy |
|---|---|
EngineRun with no attached clients and no turn in progress | session.idle_timeout default 30 min → SessionEnded{reason: idle}. Re-attach spawns a new EngineRun against the same LogID. /sessions:pin <id> opts out. |
| MCP server process | Refcounted by sessions referencing its tools. Last session ends → server gets shutdown (5s grace) → SIGTERM → SIGKILL. Respawn on next reference. |
| In-memory caches (AGENTS.md, skill bodies, model catalogs, MCP schemas, activated-skill tier-2 bodies) | LRU bounded at 32 MB total across the harness; evict on overflow; per-cache size + eviction counters readable at harness/v1://runtime. Each cache declares its weight via a single shared budget so no individual cache can dominate. |
| Provider HTTP connection pools | IdleConnTimeout set to 90 s explicitly; no unbounded keep-alive. |
A long-running --no-listen daemon may run for weeks without
unbounded growth.
Credential-helper supervision
The credential-helper subprocess (provider/helper/) holds
unlocked provider tokens. If it crashes, every subsequent provider
call fails. Supervisor contract:
- Liveness check: heartbeat via Unix-socket EOF detection (helper
writes a
\x00byte every 5 s; engine treats missed heartbeat as crash). - On crash: auto-respawn once per session. Engine pauses any in-flight provider calls and re-issues after respawn.
- On repeated crash (≥2 within 60 s): emit
Error{Reason: CredentialHelperFailed}, escalate to/health, do not loop-respawn. /healthslash command shows helper state along with engine, MCP servers, control plane listeners, credstore, session DB.
MCP server supervision
type MCPServerSupervisor struct { MaxRestarts int // default 3 RestartWindow time.Duration // default 60s GiveUpAfter int // default 5 consecutive failures GiveUpCooldown time.Duration // default 1h }
On child-process exit:
- Within
RestartWindow, restart up toMaxRestartswith exponential backoff (1s, 2s, 4s). - On give-up, emit
MCPServerDown{name, reason, attempts}. - Tools registered by the down server enter degraded state in
the registry, still listed (so the model doesn't hallucinate
their absence), but
Runreturns a structured«mcp-server-unavailable»ToolResult that the model can plan around. /mcp:restart <name>manually retriggers.
Hook timeouts
Hooks are shell commands with explicit deadlines:
| Hook event | Default timeout |
|---|---|
SessionStart | 5 s |
UserPromptSubmit | 5 s |
PreToolUse | 30 s |
PostToolUse | 30 s |
Compact | 60 s |
Stop | 5 s |
Override per-hook in the TOML: { event = "PreToolUse", cmd =
"…", timeout_ms = 60000 }. timeout_ms = 0 keeps the per-event
default; a negative value is rejected at registration. At deadline:
SIGTERM, then SIGKILL at deadline+5 s. Hook stdout/stderr capped at
64 KB; truncated
output is logged with a marker. A hook exiting non-zero emits
HookTimeout (when killed) or HookError{exit_code} (when it
exited on its own).
Crash-mid-turn recovery (cross-reference)
See § Persistence → Tool-call intent / outcome ledger for the specific recovery path. On any abnormal exit, on next boot the engine queries orphan intents and presents the user with a structured choice rather than re-executing.
Token expiry mid-turn
- Tokens carry
TokenExpiringevent 5 min beforeexp. - In-flight subscriptions are allowed to drain for up to 60
seconds past
expbefore the server cuts them. Commands received afterexpare rejected withError{Reason: TokenExpired}; clients refresh viaGET /v1/auth/token.
Future extension shapes (none requires touching the loop)
The design is sized so the following ideas slot in as a plugin, a
middleware, a Provider composition, or a backend swap. Each is
labeled with the seam it uses so we don't pretend a feature is free
when it isn't.
- Multi-model per turn:
RoutingProvidercomposition, NOT middleware. AProviderthat wraps{router, executors[]}. Routing decisions stay inside the composition; cache-control, thinking-block provider-binding, token counting, andCostIncrementedattribution remain per-underlying-provider. Tried as middleware first; middleware is monomorphic (one-request-to-one-provider) and broke on every cross-provider concern. - Parallel tool calls: ToolMiddleware that dispatches to N
concurrent workers and aggregates the results. Engine sees one
ToolResultperToolCalleither way. - Cost budgets: event subscriber on
CostIncrementedplus a RequestMiddleware that aborts when the cap is exceeded. - Distributed sessions: v0.2+ via
wsand v0.3+ viamcpserverHTTP. Laptop client attaches to a server-hosted engine by URL + capability token; engine is unchanged. - Mobile / phone client: a thin React Native app speaking the
same
wsprotocol; no engine work. - IDE plugins (VS Code, JetBrains, Neovim): each is a
Clientimplementation on top ofrest/ws/mcpserver. - Collaborative editing: already a capability the moment
multi-attach lands; the only new code is a UI affordance showing
"who's here" using existing
OriginatorID. - Headless CI mode: pre-provision a token file, attach a
script client, drive a fixed prompt, capture exit from
TurnEnded. No new transport. - Self-modifying skills: a built-in tool that writes SKILL.md files plus an event subscriber that re-runs the skill registry scan. New skills go through the TOFU gate before activation.
- Container-sandboxed execution: ToolMiddleware that wraps
Bash calls in
docker/podman/bwrap/sandbox-exec. - Pi-style cheap-model delegation: a
delegatebuilt-in tool that instantiates a childEngineRunwith a cheaperProvider. Scoped sync-only in v0.x: the tool blocks the parent turn; child events are not surfaced to parent clients (the child has its own log and attach point if observation is needed). A general parent/childEngineGraphmodel (cancellation propagation, event fan-in, multi-session ownership) is a v1+ topic; calling out the limit now keepsdelegatehonest as a middleware-shaped extension. - Replay / time-travel debugging UI: a
Clientimplementation that drives from a session log instead of a live engine. Renders the same events; sends no commands. - Vision / audio:
Providerextension; canonical form already supports image content blocks. Tool middleware handles base64 packaging.
Slash commands
Slash commands are parsed at the client, not the engine. The
client recognizes a /-prefixed input, converts it to a typed
Command, and sends it. The engine never sees raw /-prefixed
strings as input.
The namespace convention is pi-style:
| Form | Meaning |
|---|---|
/foo, /help, /clear, /compact, /cost, /model, /profile, /resume, /quit | Reserved for built-in commands shipped with the harness. |
/skills:name | Invoke a registered skill by name (tier-2 disclosure activates). |
/agents:type | Spawn a sub-agent of the given type. |
/profiles:name | Switch the active profile. |
/sessions:action | Session-management (/sessions:list, /sessions:branch, /sessions:export). |
/mcp:server | MCP-server-scoped actions (/mcp:kiln status). |
/custom:foo, /super-skill:bar, /<your-namespace>:… | User- or plugin-registered namespaces. Plugins claim a namespace via Plugin.Register; conflicts are rejected at boot. |
Discovery is via the control plane:
GET /v1/slash-commands → list of {namespace, name, description, args_schema}The TUI uses this to populate the command palette; external clients (IDE plugins, web client) use the same endpoint.
Bare / (unprefixed) is reserved for built-ins only. This keeps
"what does this command do" predictable: if it's prefix-less, it's
shipped with the harness; if it's namespaced, the namespace tells
you where to look.
Tab-completion contract
The TUI input layer (and any client implementing autocomplete) honors this contract:
/sk<TAB>→/skills:(prefix expansion)/skills:gofa<TAB>→/skills:gofastr-ui(or fuzzy-match list if multiple)/<TAB>(empty after slash) → list of all top-level namespaces + built-ins- Skill / agent / profile listings come from
GET /v1/slash-commandscached for the session; the cache invalidates onSkillRegistryChanged(emitted when TOFU acks a new skill or/skills:reloadruns).
User-defined aliases
~/.config/gofastr/harness/aliases.toml carries short forms:
[aliases] gu = "/skills:gofastr-ui" cb = "/skills:component-build" verify = "/skills:verify-before-claim"
Aliases are themselves TOFU-tracked (a malicious alias is a
code-exec hazard; evil = "/skills:exfiltrate"). Aliases never
expand to built-in commands (no shadowing); conflicts at load time
are rejected with a clear error.
Built-in commands shipped in v0.1
| Command | Effect | |
|---|---|---|
/help | List all commands grouped by namespace | |
/clear | Clear scrollback (does not affect log) | |
/compact | Trigger history compaction now | |
/cost | Detailed cost breakdown for the current session (input tokens, output tokens, cache hits, per-provider rates) | |
/model <name> | SetModel for the current session | |
/profile | Show active profile + skills/MCP/tools loaded | |
/profiles:<name> | Switch profile (see § Profiles → Mid-session profile switching) | |
/sessions:list | Table with status · id · age · profile · model · turns · branch-of · attached-by · working-dir | |
/sessions:resume <id> | Re-attach to a paused or detached session | |
/sessions:pin <id> | Opt out of TTL + idle-shutdown for a session | |
/sessions:branch <id> [at <turn>] | Branch from a TurnEnded boundary (default = latest) | |
| `/sessions:export <id> --format jsonl\ | markdown` | Export session content (post-redaction) |
/skills:<name> | Invoke a registered skill | |
/skills:reload | Re-scan skill search paths; TOFU prompts for any change | |
/agents:<type> | Spawn a subagent | |
/mcp:<server> | Server-scoped action; /mcp:<server> status, /mcp:<server> restart | |
/permissions | Show active session-scoped allow rules | |
/permissions:promote <rule> | Promote a session-scoped rule to profile-level (v0.2) | |
/health | Status of every subsystem: engine, credential helper, MCP servers, control plane listeners, credstore, session DB | |
/web | Show the --web URL (with OSC 8 hyperlink); copy to clipboard via pbcopy/xclip | |
/quit | Two-step exit (see § TUI Ctrl-C semantics) |
User-facing errors
The control protocol carries structured Error events with stable
Reason codes. Clients translate them to human strings; the
harness ships the following canonical strings, which TUI and web
both use verbatim. Reason codes are the wire contract; strings are
the user contract. Both are stable within a minor release.
Reason | TUI / web string |
|---|---|
HandshakeRequired | Send /v1/handshake before any command. See § Protocol versioning. |
TurnInProgress | Another client is sending input to this session (started by <originator> at <ts>). Wait, or send /cancel to interrupt. |
PermissionDenied | Permission denied for <tool> by <client>. To allow: re-prompt and pick 'Allow once / argv-glob / tool / session'. |
PermissionTimeout | Permission for <tool> not answered within <timeout>s. Tool call cancelled. |
TokenExpired | Capability token expired at <exp>. Mint a new one: gofastr harness token --session <id> --identity-class <class>. |
TokenRevoked | Token <jti> was revoked at <ts>. Mint a new one via /v1/auth/token. |
MCPServerSHA256Mismatch | MCP server '<name>' refused to start: the binary at <path> changed. Expected sha256 <expected>, found <actual>. To approve the new binary, run /mcp:approve <name> or edit <profile_path> to update the pin. |
MCPServerUnavailable | MCP server '<name>' is down after <attempts> restart attempts. Its tools are in degraded state. /mcp:restart <name> to retry, or check $XDG_STATE_HOME/gofastr/harness/log/. |
HookHashChanged | <path> changed since you last approved it. Review the diff (TUI: 'v' opens the diff viewer). Re-ack to continue. |
HookTimeout | Hook '<event>' command '<cmd>' exceeded its <timeout>s deadline. Killed. |
RateLimited | Provider <name> rate-limited the request. Retrying in <retry-after>s… (Ctrl-C to cancel, /model to switch). |
BashCancelledMidCommand | Cancelled mid-Bash. The command ran for <duration> and may have modified files. Files possibly affected: <list>. Review /sessions:diff <id> to see what changed. |
NonInteractiveAckRefused | Refusing to silently approve an unseen <kind> in non-interactive mode. Run gofastr harness ack --all once interactively, or pre-populate approved.lock from CI. |
HandshakeVersionMismatch | Client speaks protocol <client_ver>; server speaks <server_ver>. Upgrade <client_or_server>. |
Plugin-introduced errors use Reason: Custom:<namespace>:<code>
with a string payload field the client surfaces verbatim.
Two of the remediation strings above reference gofastr harness token
and gofastr harness ack, specified subcommands that are not yet
wired into cmd/gofastr (which today dispatches only mcp and
creds).
Logging (the harness's own logs)
Distinct from the session event log (which is per-conversation, encrypted, redacted). The harness emits its own operational logs to:
~/.local/state/gofastr/harness/log/harness-YYYYMMDD.log: rolling daily file, plaintext, structured (one JSON object per line:{ts, level, component, msg, fields…}).- stderr when
--log-to-stderris set.
Components: engine, multiplex, provider.<name>,
transport.<name>, mcpclient, mcpserver, skill, hook,
auth. Default level info; per-component overrides via
--log-level engine=debug,provider.copilot=trace.
The raw-provider-request debug mode is a per-session toggle:
harness debug --raw enables full request/response dumps to a
session-scoped file at ~/.local/state/gofastr/harness/debug/<SessionID>.jsonl.
Dumps are written before the redaction middleware on the way in
and after it on the way out, so the debug file contains
unredacted traffic, protected by 0600 mode and the same keychain
encryption as the session log.
Things to read before touching specific areas
| Touching | Read first |
|---|---|
engine/ | This doc, top-to-bottom |
provider/ | This doc § Providers + the provider's API reference |
tool/ | This doc § Tool system + framework/ARCHITECTURE.md § Hooks |
mcpclient/ | The MCP wire protocol spec |
skill/ | https://agentskills.io/specification |
context/ | https://agents.md/ |
control/ | This doc § Control plane, especially auth, multi-client, Threat model |
control/multiplex/ | This doc § Multi-client semantics |
control/mcpserver/ | MCP spec plus this doc § MCP server API, note the renamed tool and identity-class rules |
control/auth/ | This doc § Threat model and § Authentication |
client/tui/ | This doc § TUI; golang.org/x/term package docs |
client/web/ | core-ui/ARCHITECTURE.md (the UI runtime is what the web client dogfoods) |
session/ | This doc § Persistence, particularly redaction and TTL |
profile/ | This doc § Profiles, particularly trust gates |
hook/ | This doc § Profiles, trust gates and --allow-project-hooks |
End
This document is the harness contract: every system it describes is
implemented in framework/experimental/harness/ and exercised by tests. There is
no separate roadmap; future capabilities live in this doc or they do
not exist.
Updates require a PR that references the section being changed. Breaking changes follow § Protocol versioning & evolution → Backward-compat policy.