Runtime architecture

When a run misbehaves, the first question is usually "where in the source do I look?" rather than "what went wrong?" 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 → assertGitAvailable → 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 and --help carry the same 34 long flags, including the deprecated --ignore-data-mismatch; no current flag is omitted from help. The complete flag catalogue, with defaults, deprecations and = syntax, belongs to the CLI reference.
  2. Check for the wizard. Before resolving any hank, main() looks for one of 16 inputs that leave the wizard path: a hank path, --config, a data path, --data, -e/--execution, -i/--input, --init, --help, --version, --validate, --cleanup, --attach, --headless, --replay, --restore-journal or --diet-journal. When none is present, runWelcomeWizard() runs and the process exits 0 without executing a hank. Flags alone are not enough to skip it: -o, -y or --start-new on their own still open the wizard.
  3. 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.
  4. Prove git is available. The CLI runs git --version before the execution directory is created, wiped, or copied into. A missing binary raises GitUnavailableError and exits 1 before any codon runs; the runtime repeats the check before checkpoint work. The execution directory is untouched, but the $schema auto-add runs earlier, so a schema-less hank.json can already have been rewritten on disk. --validate exits before this check. The git requirement itself is owned by start/upgrading.
  5. 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.
  6. Validate before work begins. On the normal run path, validateHank() checks the hank against the published schema, runs local per-model self-tests (SDK import, credential presence, and the Pi model_catalog lookup – no billable API calls), and applies the strict-ref gate, which checks that file references use portable relative paths, stay inside the hank directory, and do not traverse symlinks. --replay passes skipSelfTests, which skips the per-model self-tests and the Pi catalog preflight but still runs the published-schema check and the strict-ref gate. The separate --validate command calls the same validateHank() with self-tests included, prints the GOOD TO RUN! box on success, and exits 0 or 1 without starting the runtime.
  7. 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.
  8. 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.
  9. 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.

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.

Step 5 mentioned that -e can pin an execution directory. The shipped help describes the safety rules for that option in three tiers, reproduced here as published:

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 Tier 1 line in that excerpt is narrower than it reads: a path under ~/.hankweave-executions/ is refused only when it would create a new execution there (no .hankweave/execution-meta.json). An existing managed execution can be passed to -e and resumed. The tier rules as a whole, including when each tier's check applies, belong to the execution-directory reference.

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, 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() records it in preparing and runs rig setup while it is still preparing. A fatal rig failure transitions preparing → failed with failedDuring: "preparing". Rig setup is skipped for --replay, for skipPreCommands, and when an in-run rig-setup checkpoint already exists. Only after rig setup does the runtime transition preparing → starting, load the codon's sentinels, and delegate to runCodon(). The initial file.updated snapshot for the codon comes from its file tracker, not from startCodon(). 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, 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. Hankweave has exactly two in-process harnesses: the Claude Agent SDK harness for Anthropic models, and the embedded Pi harness for everything else addressed as pi/<provider>/<model>.

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 fallback 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 fallback idle timeout is 120 seconds, and while a turn is in flight it allows max(timeout, 300s) of silence.

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.

On a normal (non-replay) start, 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: …. --validate runs the same preflight; --replay skips it with the self-tests.

Idle timeouts are configurable per codon with shimIdleTimeout, merged over the run default set by --shim-idle-timeout. Neither has a configured built-in default: when neither is set, the harness supplies its fallback, 180 seconds on the Claude Agent SDK harness and 120 seconds on the embedded Pi harness. Keep this separate 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 while a run is live; the runtime serializes appends through eventJournalAppendQueue before they reach the journal. While the journal is live, a sidecar events.meta.json tracks schemaVersion, eventSchemaVersion, totalEvents, byteLength and lastEventId. At shutdown, when dietOnFinalize is on (the default), the run ended completed or failed, and the runtime can still prove it owns runtime.lock, finalization replaces the journal with the dieted form: events.jsonl.zst plus journal.meta.json, with large event bodies (4 KB and up) moved to events/cas/ only when they exist. A finished run therefore leaves either the dieted pair or, when the diet was skipped or failed, the plain events.jsonl with its sidecar; --restore-journal rebuilds the raw journal offline. The format and diet contract belong to the event journal page.

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 the runtime banner, shadow-repository initialization, a new run, the WebSocket listener, the autostart trigger, Starting codon: Write one line, the journal diet at finalization, and the clean shutdown. The capture below shows those landmarks as they appear in a real run's log.

Output
[<ts>] [INFO] Starting Hankweave Runtime v0.11.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] Event journal dieted: 15370 → 3104 bytes (0 bodies in CAS, restore with --restore-journal)
...
[<ts>] [INFO] Shutdown: all codons completed (exit code: 0)

Selected lines from the published execution-directory fixture's server.log. The Event journal dieted line is the finalize diet described above; the run's own journal was events.jsonl until that point.

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().

Crashing-run detection has two paths. At state-manager load, detectCrashedRuns() probes the serverPid of runs still marked running other than the current run; a gone process marks the run crashed and emits a RunCrashed event. The current run is handled separately, by the runtime's lock-file check: when the lock's PID is dead or its heartbeat is older than 120 seconds, the runtime marks the run crashed with lastCodonStatus: "unknown". The mechanism is owned by the state machine page.

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.