Runtime architecture

When a run misbehaves, the question is rarely "what went wrong?" first. It is "where in the source do I look?" Hankweave's runtime spans a CLI parser, a configuration resolver, an orchestrator, two in-process model harnesses, and a set of persistence subsystems, and each of those lives in a different file. Reading the source from index.ts to the end is one way to find a bug; knowing the boundaries is faster.

This page builds that map. It follows a single invocation from the command line to the orchestrator, through one codon's lifecycle, out to the model harnesses, and finally to the files each subsystem writes. Along the way it names the components responsible for each phase, so a code reading or a breakpoint search can start at the right boundary instead of at the top of the file. The page covers the runtime's internal structure for contributors; the public contract remains the hank schema, the CLI, the WebSocket protocol, and the exit codes.

Finding where an invocation reaches the runtime#

When we trace a runtime behavior to its source, we usually have a phase in mind: parsing, validation, a codon, or a persisted record. We'll follow the invocation to the code responsible for each phase, so we can choose where to begin reading or set a breakpoint without reading the source from top to bottom.

Two terms carry the whole trace. A hank is the work definition Hankweave loads; a codon is one sealed agent task in that definition. The runtime coordinates those tasks. The pseudocode below shows the chain at a glance; the numbered steps that follow give each link its file and its behavior.

Output
// Pseudocode mental model, not the implementation.
CLI args → parseCliArgs → resolveSettings → setupExecutionEnvironment → validateHank → new HankweaveRuntime → server.start()
  1. Parse the invocation. The shipped hankweave binary enters at dist/index.js, which calls main() at the bottom of index.ts. parseCliArgs() parses the command-line arguments once into a flat CliArgs object. The parser contains 31 flags, while --help shows 26. See the CLI reference for the five parser-real flags omitted from help.
  2. Resolve configuration. resolveSettings() merges five layers: defaults, hankweave.json, hank overrides, environment variables, and CLI arguments. For the precedence rules at this handoff, see hanks.
  3. Set up the execution directory. setupExecutionEnvironment() hashes the data, finds the newest matching execution directory, or creates <timestamp>-<random>-<dataHash6> under ~/.hankweave-executions/. The -e option pins a directory instead.
  4. Validate before work begins. On the normal run path, validateHank() checks the hank against the published schema, runs local model self-tests for SDK import, credential presence, and the Pi catalog, and applies the strict-reference preflight added in 0.10.0. That preflight checks that file references use portable relative paths, stay inside the hank directory, and do not traverse symlinks. The separate --validate command calls runValidation(), prints the GOOD TO RUN! box on success, and exits 0 or 1 without starting the runtime. The Pi harness also performs a model_catalog check at startup; that later, harness-specific check covers every Pi-routed codon model.
  5. Create the runtime. The entry point constructs new HankweaveRuntime(serverConfig) with the merged configuration, codons list, execution-directory details, and headless flag. HankweaveRuntime is the orchestrator: the component that coordinates codons and the subsystems that record them.
  6. Start the server. server.start() initializes checkpoints, the state manager, event journal, and sentinel manager. It then creates the WebSocket server, using --port or an OS-assigned port when the configured port is 0, and starts the proxy runner after the WebSocket port is known. The server accepts connections after that setup; this page records the proxy runner's ordering, not its separate proxy contract.
  7. Choose the client path. In headless mode, requestAutostart() triggers immediately after server start. In TUI mode, BasicTUI connects as the interactive client after a 100ms delay. A bare invocation–no hank path, flags, or data–takes a different path: runWelcomeWizard() runs and exits; it never executes a hank.

Several of these steps are steered by command-line flags. The table below keeps only the flags that change the architecture's path–which directory is used, which client connects, whether codons start automatically–rather than the full catalogue.

Scroll to explore the table →
FlagArchitectural effect
-e, --execution <path>Uses a specific execution directory; creates it if it does not exist and resumes it when it has state.
--headlessSelects the headless client path; it runs without the TUI.
--no-autostartPrevents automatic codon start after server setup.
-p, --port <port>Selects the WebSocket server port; the default is an automatically selected free port.
--shim-idle-timeout <seconds>Sets the default harness idle timeout; the Pi default is 120 seconds and the Claude Agent SDK default is 180 seconds.
--attachConnects the TUI to an already-running server in read-only mode.

For the complete flag catalogue, including defaults, deprecations, and = syntax, see the CLI reference. Here we keep the flags that help us trace the architecture.

Step 3 mentioned that -e can pin an execution directory. Selecting a directory directly is guarded by a three-tier safety system, reproduced here from the CLI's own --help output:

Output
Execution Safety:
  Hankweave implements a three-tier safety system for execution directories:
  - Tier 1: Cannot use ~/.hankweave-executions/ directly (reserved for auto-managed)
  - Tier 2: Directories with existing .hankweave/ require --force (backs up existing)
  - Tier 3: Non-empty directories show warning and prompt for confirmation

The ~/.hankweave-executions/ path in step 3 is the auto-managed parent; the safety block applies when an execution directory is selected.

We now have a breakpoint boundary: input handling ends at server.start(), and runtime work begins at the orchestrator. From there, we can follow the calls into model work and persistence. A sentinel is a parallel observer of runtime events; its persistence tree is one of the storage edges below. The diagram shows the full map in two equivalent forms–an ASCII figure and a mermaid rendering of the same graph–with the orchestrator at the center and each storage subsystem as an edge labeled with the call that writes to it.

FIG. 1 Runtime map
Read the diagram as text
Output
[CLI]
CLI entry (index.ts)
  |
  | parsed CLI args + positional paths → resolveSettings()
  v
[Config]
Config resolution (config.ts)
  |
  | merged config → setupExecutionEnvironment() → execution dir
  v
[ExecSetup]
Execution setup (execution-setup.ts)
  |
  | execution setup → validateHank() → schema + strict-ref gate pass
  v
[Validate]
Validate (validateHank, config.ts)
  |
  | validateHank() passes → new HankweaveRuntime(serverConfig) → server.start()
  v
[Orchestrator]
HankweaveRuntime (hankweave-runtime.ts)
  |
  | startCodon() → runCodon() → new CodonRunner() → runner.run()
  v
[Harness]
CodonRunner + Harnesses

[Orchestrator] -- handles codon completion → createCheckpoint() → git commit in checkpoints/ --> [Checkpoints]
Checkpoints (.hankweavecheckpoints)

[Orchestrator] -- emits typed ServerEvents → eventJournal.append() → events.jsonl --> [Events]
Event Journal (events.jsonl)

[Orchestrator] -- state transitions → stateManager.transition() → atomic save to state.json --> [State]
State (state.json)

[Orchestrator] -- during codon execution → sentinelManager manages parallel observers --> [Sentinels]
Sentinel Manager
(history/ for conversational JSON; outputs/<id>/ for auto-generated output)

The labels in this map are source-level signposts, not a public API. They identify where to begin a code reading or breakpoint search; the orchestrator's private structure is versioned and non-contractual. The public contract is the hank schema, CLI, WebSocket protocol, and exit codes.

Following the orchestrator through a codon#

The diagram places HankweaveRuntime at the center; this section walks through what it actually does with a codon. We can read it as a coordinator with named boundaries: it sequences codons, manages state transitions, drives checkpoints, routes events to WebSocket clients, and handles rollback and resume. Its private layout is versioned and non-contractual. The public contract is the hank schema, CLI, WebSocket protocol, and exit codes.

A codon runs inside that coordination loop. The runtime holds private references to StateManager, CheckpointGit, EventJournal, SentinelManager, RetryCoordinator, Budget, ArchiveManifestManager, LlmProviderRegistry, a map of codon IDs to CodonRunner instances, ProxyRunner, and Replay. Treat this list as a source-reading map, not a promise about private fields.

The execution plan begins with the codon configuration array. ExecutionPlanner passes ordinary codons through unchanged. A loop is expanded lazily: plan construction adds its first iteration, and expandNextIteration() adds a later iteration after the previous one terminates. The plan therefore follows the loop's actual progress.

When a codon is ready, startCodon() transitions it to starting, resolves watched file patterns through UnifiedFileResolver, emits initial file.updated events for matching files, and delegates to runCodon(). runCodon() creates a CodonRunner, optionally giving it an ExtensionConfig when exhaustWithPrompt is set. It subscribes through setupCodonRunnerEventHandlers() and calls runner.run(previousSessionId). The optional extension path is outside this architecture map; the important boundary here is the runner call.

Completion is the next boundary. handleCodonComplete() collects the outcome, records final cost, creates the checkpoint, copies output files when configured, archives when configured, and calls autoStartNextCodon(). createCheckpoint() is the boundary to follow: a checkpoint seals the codon's recorded state, while CheckpointGit stores the checkpoint commit.

autoStartNextCodon() rebuilds the execution thread through StateManager.getExecutionThread(), reads thread.nextCodonId, expands the next loop iteration when needed, and calls startCodon() again. The loop iterates until all codons complete or one fails. A continuation resumes a run's history, while recovery can roll back to a checkpoint. That sequence–plan, start, run, complete, seal, advance–is the mental model to use when placing a change.

The retry boundary is in HankweaveRuntime, not CodonRunner. handleCodonComplete() asks retryCoordinator.decide() whether to retry; the runtime then accumulates the failed attempt's cost, records it with recordAttempt(), waits through the backoff delay, and re-invokes startCodon() for another attempt. A CodonRunner lifetime is one attempt; the runtime owns the failure policy and respawn.

For the complete event catalog, including the payload fields, category, journaled flag, and sentinel-routed flag of all 36 server events, see the events reference.

Choosing between the two in-process harnesses#

Each CodonRunner needs a model provider, and that is the harness boundary. A harness is an in-process adapter that presents a model provider to the codon runner. The boundary is narrower than older documentation suggests: version 0.10.0 has exactly two harnesses, the Claude Agent SDK harness for Anthropic models, and the embedded Pi harness for everything else addressed as pi/<provider>/<model>. Subprocess shims for gemini-cli, codex, and opencode were removed in 0.8.0.

selectHarness() in provider-ids.ts is the single late-bound dispatch rule. It consumes the resolved model and, when present, the harnessOverride input:

  • When harnessOverride === "pi", it selects the embedded Pi harness.
  • A model whose real provider is Anthropic selects the Claude Agent SDK harness. Amazon Bedrock also selects it when the model ID identifies an Anthropic model.
  • Every other model selects the embedded Pi harness.

The Claude Agent SDK harness calls query() from @anthropic-ai/claude-agent-sdk in process. Its default stream-inactivity timeout is 180 seconds. On first use, it extracts the SDK binary from a vendored tarball into ~/.hankweave/claude-sdk/<version>/.

The embedded Pi harness uses the Pi coding-agent SDK in process. It builds a target with toPiTarget() in the form <provider>/<model>. That step resolves zhipuai to zai, moonshot to moonshotai, and routes moonshotai through OpenRouter. Its default idle timeout is 120 seconds.

Both managers extend BaseProcessManager, a shared TypedEventEmitter: an object that publishes named events with declared shapes. Both expose the same stdout, stderr, error, and exit event contract. CodonRunner wraps either manager uniformly and sends those events through the same parser pipeline. Its createProcessManager() method selects the harness, supplies codon-level model, prompt, system prompt, log path, idle timeout, and budget settings, and returns a ClaudeAgentSDKManager | PiSdkManager | ReplayProcessManager manager. When replay configuration is present, it uses ReplayProcessManager instead of a live provider manager; otherwise, selectHarness() chooses Claude Agent SDK or Pi. Replay is therefore a separate execution path, not a third provider choice.

Since 0.10.0, the Pi path performs a model_catalog preflight at startup. It checks every Pi-routed codon model against Pi's catalog. A miss stops the run before any codon runs and reports Pi model not found: <provider>/<id>. Available '<provider>' models: ….

Idle timeouts are configurable per codon with shimIdleTimeout. The CLI's --shim-idle-timeout sets the default. The default is 120 seconds for Pi and 180 seconds for the Claude Agent SDK. Keep this separate from the WebSocket and proxy --idle-timeout and from budget wall-clock limits. Failure classification is shared: first-match-wins keyword matching examines error text and separates retriable from permanent failures. The complete taxonomy belongs to errors and exit codes, rather than this architecture map.

Mapping what the runtime writes to disk#

Everything the orchestrator and harnesses do leaves a record, and those records converge on one place. The execution directory's .hankweave/ subdirectory is the single persistence root created at server start, where each subsystem records part of a run. The complete directory tree belongs to the execution-directory reference; the table below works in the other direction, from a file to the writer to inspect when that file matters.

Scroll to explore the table →
.hankweave/ entryOwning subsystemWhat it records
state.jsonStateManagerThe authoritative run record: runs, codons with status, cost, and checkpoint SHAs, the execution plan, and the current run pointer.
state.json.bakStateManagerThe backup made before each atomic write, used for recovery if a write crashes mid-operation.
events/events.jsonlFileEventStorage through EventJournalThe append-only JSONL event journal, with one JSON object per line.
checkpoints/.hankweavecheckpointsCheckpointGitThe shadow, non-bare Git repository containing checkpoint commits.
runs/<runId>/HankweaveRuntime creates the directory; CodonRunner writes logsPer-run codon logs.
archive-manifest.jsonArchiveManifestManagerArchive and restore entries for files moved to rigArchive/, keyed by codon and loop iteration.
sentinels/SentinelManagerPersistence tree: conversational-sentinel JSON history in history/; auto-generated sentinel output under outputs/<id>/.
logs/server.logLoggerThe runtime's structured log.
runtime.lockHankweaveRuntimeThe lock that prevents concurrent access to the execution directory.

The writers named in the table each have their own durability behavior. StateManager persists state.json atomically by writing a temporary file and renaming it into place. Before each write it creates state.json.bak, leaving a recovery copy if the write crashes. FileEventStorage writes events/events.jsonl; the runtime serializes appends through eventJournalAppendQueue before they reach the journal.

CheckpointGit initializes a non-bare repository with git.init(false). Its Git metadata is .hankweave/checkpoints/.hankweavecheckpoints, and its work tree is agentRoot under the execution directory; the legacy .git name is a migration source, not the current layout. A checkpoint is a commit of selected workspace files. Fresh runs can produce different commit SHAs when model-produced files differ, so a SHA is not a repeatability promise.

Because the checkpoint repository's metadata and work tree live in nonstandard locations, inspecting its history requires selecting both paths explicitly rather than relying on the current working directory or the main project repository:

⌁ Terminal
EXECUTION=/path/to/execution
GIT_DIR="$EXECUTION/.hankweave/checkpoints/.hankweavecheckpoints"
GIT_WORK_TREE="$EXECUTION/agentRoot"
git --git-dir="$GIT_DIR" --work-tree="$GIT_WORK_TREE" log --oneline

HankweaveRuntime ensures runs/<runId>/ at run start, and CodonRunner writes each codon's log there. Log naming belongs to observe and debug, so this page does not reproduce that rule. ArchiveManifestManager records files moved to rigArchive/. When persistence is enabled, SentinelManager creates/verifies .hankweave/sentinels/history during initialization. Conversational sentinels lazily persist per-instance conversation history there as <sentinelId>-codon-<codonId>.json; auto-generated sentinel output files use .hankweave/sentinels/outputs/<id>/. A sentinel observes runtime events in parallel; its output and state rules belong to sentinel configuration.

logs/server.log is the runtime's own structured log. runtime.lock is written at server start and removed at shutdown. Its presence prevents concurrent access to the same execution directory, and --cleanup refuses to remove a directory while that lock exists.

The server log also closes the loop with the first section of this page: the startup path traced there appears in it as a readable sequence. In a complete one-codon haiku run we can follow Starting Hankweave Runtime v0.10.0, shadow-repository initialization, a new run, the WebSocket listener, the autostart trigger, Starting codon: Write one line, and Shutdown: all codons completed (exit code: 0). The capture below shows those landmarks as they appear in a real run's log, followed by an annotated rendering of the same lines.

Selected lines from the published execution-directory fixture's server.log.

Output
[<ts>] [INFO] Starting Hankweave Runtime v0.10.0 in ~/.hankweave-executions/<exec-id>
[<ts>] [INFO] Shadow git repository initialized with initial commit: <sha>
[<ts>] [INFO] Started new run: <id>
[<ts>] [INFO] WebSocket server listening on port <port>
[<ts>] [INFO] [requestAutostart] Triggering initial autostart
[<ts>] [INFO] Starting codon: Write one line
…
[<ts>] [INFO] Shutdown: all codons completed (exit code: 0)

Reconstructing runs and observing state changes#

The persistence map assumes one tidy run, but a run is not always a single uninterrupted history. The execution thread is the unified codon history across continuation-chained runs. analyzeExecutionThread() reconstructs it by walking backward from the latest run through startingConditions.source.runId until it reaches a fresh start. When you inspect a continuation, follow those run links rather than reading only the newest run.

The state machine in state-manager.ts enforces an eight-state codon lifecycle through the typed CodonTransitions table, with metadata checks supplied by state-transition-guards.ts. See the state machine page for the state names and transition details. When observing a change, we need to account for the queue: transitions are processed sequentially, and transition() is fire-and-forget. It places the request on the internal queue instead of waiting for the applied state.

If we call getState() immediately after transition(), we might not see the change yet. The state machine is eventually consistent: the returned state can lag behind a requested transition. To react to the applied transition, listen for the state manager's on('stateChanged', …) event rather than reading immediately after transition().

At state-manager load, detectCrashedRuns() checks whether a run marked running still has a living operating-system process. If it does not, the loader marks the run crashed and emits a RunCrashed event. This detection occurs while state is loaded, before later contributor tooling interprets the run.

These execution-thread and state-manager modules are source-level documentation for contributors. Their repository-relative files are server/execution-thread.ts and server/state-manager.ts; hankweave/server/... specifiers are not public package imports. The published package exports only ., ./schemas, and ./types; use client and exported types for that public surface.