What the engine owns
The engine owns admitted prompts and run state. The cockpit saves unsent input
and unconfirmed submissions locally in
composer.db, scoped to task and workspace.
It commits this input before a prompt RPC and keeps the original idempotency key
for deliberate retries after a lost acknowledgement. A second window cannot
overwrite a newer draft revision. A late acknowledgement only clears the revision
that was sent. Storage failures leave input visible and block submission.
A fresh window hydrates run state from one snapshot and then follows the feed from the
watermark that snapshot carried, so there is no gap and no double-draw.
Durability
A run is written down before it has any effect:- The
run.startedevent is committed before the shell is spawned. - Output is batched into
run.outputevents, so a burst of lines costs one transaction rather than one per line. run.endedrecords the exit code, the duration and the truncation flag.
running and settles it as interrupted, with a final output line that
says the run was not resumed. A daemon never silently forgets work, and never
pretends a dead run is still going.
Output is bounded. A run that prints megabytes does not fill the database: the
engine keeps the first and last slices and marks the run truncated.
Execution limits
Every daemon has one admission controller shared by every session. Model attempts, tool calls, direct commands and workspace maintenance consume its capacity. A shell tool reuses its tool admission; it does not wait for a second slot. Provider attempts release capacity before their tool continuation. Createexecution.json in the Hiroshi data directory to override defaults:
MemAvailable, and
macOS estimates available RAM from free plus inactive pages. minFreeMemoryMB
overrides the floor. Limits are validated at daemon startup;
unknown keys, non-integers and values outside conservative bounds are rejected.
Restart the daemon after editing limits. Provider credentials stay in their
existing configuration and never enter execution records.
Admission is FIFO within the live queue. The execution log and session snapshot
record queued/running/terminal state, admission and execution deadlines, and
reasons such as capacity, low memory and timeout. The cockpit shows waiting
work in the composer and transcript. Daemon settings show configured ceilings
and the RAM sample taken at connection; /v1/health returns a current sample.
The scheduler checks free RAM every 250 ms. A low-memory sample blocks new work
and aborts active work. Cancellation or expiry removes a waiter permanently.
An abort-ignoring effect keeps its slot until it actually settles.
These are resource controls, not a security sandbox. They do not contain work
delegated to an external service or privileged system process. Commands still
have the account permissions described by the tool approval.
Checkpoint Git remains serialized and synchronous: it must acquire shared
capacity or leave the prompt durably pending, with a visible checkpoint error.
Capacity or RAM recovery retries only those blocked preparations. Each Git
invocation has an eight-second timeout and bounded captured output. On POSIX,
synchronous checkpoint Git has leader timeouts rather than process-group
containment; hooks, global Git configuration and external clean filters are
disabled for its managed object database.
On restart, previously running effects are interrupted. Commands and tools
whose effects may have started are never replayed. Queued commands retain their
original deadline and saved argv, and may start once. Queued tools revalidate
their pinned definition and current permissions after admission. A queued model
attempt does not spend another attempt solely because the daemon restarted.
Protocol 15 adds task permission modes and independent AI review evidence to
the task-owned CLI conversation/recovery contract introduced in protocol 14.
Schema migration 12 adds task access and review storage, extending the durable
CLI bindings and unique vendor ownership from migration 11
without deleting existing history. Older clients refuse the newer protocol;
restart the daemon deliberately after upgrading when existing work has settled.
Older stateless CLI transcripts require explicit fresh-conversation recovery
before admitting another prompt. The fresh action archives its binding and
retains Hiroshi history; it never replays an interrupted step. See
CLI continuation and recovery.
The step
A step is one model call, and it is written down before the provider is asked for anything:step.startedcommits the provider, the model, attempt 1, the claim (the daemon’s pid) andstatus: running.step.outputcarries the text as it streams, bounded like run output.step.completedwrites the assistant message and releases the claim in one transaction.
- One step per prompt. The step names the message it answers, and a unique index enforces it. A delivered prompt that already has a step never gets a second one.
- A retry is the same step. A retryable provider failure — a rate limit, a
transport failure with no output — increments
attempton the same row and recordsstep.retrywith the delay: up to three attempts, jittered exponential backoff. The cockpit can show the retry because it is an event, not a hidden loop. - Retries only happen before output. Once text has reached the transcript,
a failure fails the step with the text kept, marked
stream_truncatedand not retried. Replaying it would duplicate the answer, which is the one thing a retry must not do. - A crash resumes the claim. On startup a running step with no output is
continued as its next attempt with the same id (
step.resumed). A running step that had produced output fails withengine_restart_mid_stream; it is never re-run from the beginning. - A shutdown is not an ending.
stopAllaborts the provider without writing an ending, so the claim survives for the next start. An operator’s interrupt is an ending:step.cancelledwith the reason.
local provider answers deterministically — it echoes the newest
prompt — so the whole lifecycle runs without credentials or a network.
--provider-delay <ms> (or HIROSHI_PROVIDER_DELAY_MS) holds each chunk,
which is how the tests get a window in which to kill the daemon.
Provider adapters add configured models behind the same seam.
Tool calls
A tool call is an effect written down before it happens. A run is the first tool:run.started commits the run and its shell call in the same
transaction, before the process exists, and run.ended ends both with the exit
code and, when there is a reason, the sentence that says what it was. A call
that is still running when the engine dies is settled as interrupted with
that reason on the next start; it is never replayed. The same row will carry
model-requested tools, and step_id says which step asked for one. See
tools and permissions for the gate, results and recovery contract.
Checkpoints
Prompt delivery commits a workspace checkpoint before model work begins. Rewind restores files and the active conversation together, retaining older branches as readable history. Its durable journal and safety checkpoint allow recovery without replaying model or tool effects. Checkpoint refs and objects live in an independent Git database; the user’s index and HEAD stay put.The session inbox
A prompt is not a message yet when it arrives. It is admitted to the session’s inbox first, as its own durable fact (prompt.admitted), and delivered to the
transcript later (prompt.delivered) when the engine reaches a safe boundary.
That ordering is what makes steering possible: the client can keep talking
while a command is going, and the prompt is on disk before anything can act on
it.
- Admission is idempotent. A client sends a
keywith each prompt. The first admission wins; a retry with the same key returns the same item, whether it is still waiting or already delivered, and never turns into a second prompt. - Two modes.
steerandqueue. Both wait while the session is busy; at the boundary every steer is delivered before anything queued, each in admission order. A steer does not stop the step it is steered past: it lands after the answer that was already streaming. - A boundary is no work in flight. A prompt waits while a run is executing and while a step is streaming. The run’s end and the step’s end both ask the inbox to deliver, so the transcript keeps its order: the answer to the prompt in flight comes before the prompt that arrived during it.
- An interrupt stops the step, then the runs.
POST /v1/sessions/:id/interruptcancels the session’s step with the operator’s reason, then writes acancelledending for every run, each with a line carrying that reason. The endings are a boundary, so whatever was waiting is delivered immediately after — and may start the next step. - A crash does not strand the queue. On startup the engine settles the runs a dead process left behind, resumes or fails the steps it claimed, and then delivers the pending inbox: a prompt admitted before the crash is in the transcript instead of waiting for a boundary that will never come.
GET /v1/sessions/:id carries the pending inbox, so a fresh window knows
what is waiting before it follows the feed.
The wire
Desktop discovery, RPC, event feeds and shutdown use a direct loopback transport, so inherited HTTP proxies cannot intercept daemon traffic or its bearer token. External provider requests retain their configured proxy behavior; no shell or system proxy setting needs to be changed. A live daemon that fails a health check is reported as unreachable. Its discovery file and startup lock are preserved instead of launching another database writer. Failed startup waits for an explicit retry in Daemon settings; normal feed reconnection still resumes from the last event sequence.- HTTP on loopback, one JSON response per request, typed errors with a
stable
code. - One SSE feed (
/v1/events) that replays from a sequence number and then follows. Every event carries itsseq; a client that reconnects asks for everything after the last one it applied. - One shape module (
engine/src/protocol.ts): the shapes, the event union and the operations table. The daemon routes on the table and the cockpit’s client is built from it, so a command cannot exist on one side of the wire and not the other, and no client hand writes a path or afetch. - One event union:
EventPayloadsmaps every event type to its body, so a subscriber sees the payload without a cast and the store refuses to record an event whose body does not belong to its type. - A bearer token minted per daemon start, compared in constant time. It lives in the discovery file with owner-only permissions, and it never leaves the machine.
- A protocol version in the handshake. A cockpit that speaks another protocol reports the mismatch and leaves the daemon alone: it may be running commands for someone else.
The feed
The feed is one hub, not one subscription per window. The engine encodes an event once, as it is recorded, and hands the same bytes to every connection: a second window costs a pointer, not aJSON.stringify.
Each connection has its own queue, and the queue has a capacity. A window that
stops reading fills its queue and is closed with an overflow frame that names
the reason; the run, the other windows and the order they see never change. The
client knows the last sequence it applied, so it reconnects from there.
Every connection opens with a hello frame carrying lastSeq, the durable edge
at that moment. The feed replays from the client’s watermark to that edge, one
page at a time as the socket asks for a frame, then follows live. The connection
joins the live fan-out before the watermark is read, and on the daemon’s single
thread nothing can be recorded in between, so an event is either in the replay
or on the live feed: never both, never neither. A lastSeq behind the
watermark the client asked from means the log lost ground (a different
database), and the client takes a fresh snapshot instead of waiting for events
that cannot come.
An idle connection gets a comment heartbeat every fifteen seconds, so a proxy or
a sleeping laptop does not silently turn a live feed into a dead one.
Active history and compaction
Before every provider attempt the engine reloads the durable messages and builds an active request. The provider declares its context window and reserved output; the request budget includes message framing, instructions and serialized tools. Without a tokenizer the estimator conservatively counts one UTF-8 byte per token. The local provider uses a 32,768 token window and reserves 1,024 for output. When the request is too large, acontext.compacted event commits a summary and
an inclusive message sequence boundary in one transaction. The active request
contains that summary plus a bounded recent window starting at a user turn.
System messages and the newest prompt remain intact. A request that cannot fit
those values fails with context_overflow; the prompt is never silently cut.
Compaction is on by default; --no-compaction or HIROSHI_COMPACTION=off disables
new boundaries and fails oversized requests before the provider is called.
The summary is deterministic and extractive: the original goal, labeled excerpts
of recent covered messages, and an explicit omitted-message count. It does not
make another model call or invent decisions. Repeated compactions rebuild from
the original durable prefix rather than recursively summarizing a summary.
Older details may be omitted from active history; every original message remains
in the database and in the session snapshot.
A provider reports overflow with ProviderError('context_overflow', ...). Before
any output, this triggers one forced compaction and a rebuild of the same
step, independently of transient backoff retries. A second overflow or overflow
after output fails with a clear reason. The forced boundary is durable, so a
restart cannot grant another overflow recovery to the same step. This rebuild
keeps the step allowance and attempt number; ordinary transient retries still
increment the attempt as before.
Instructions are { source, text } values, separate from transcript messages.
The default loader rereads the workspace’s AGENTS.md each attempt. Adapters can
supply additional instruction sources through the context options. A
context.prepared event records the request estimate, model budget, active
boundary, SHA-256 source hashes and names of sources added, edited or removed.
It does not store the rendered instruction prose in that ledger.
The Terminal’s Show context control opens the full durable transcript,
compaction summaries at their original boundaries, and changed source names.
Both the session snapshot and the event reducer carry these records, so a
reconnecting or second cockpit sees the same history.
The data directory
HIROSHI_HOME overrides it. Inside:
Lifecycle
The cockpit never kills a daemon it did not need to kill.- Start: the cockpit reads
engine.json. If it is missing or stale, it spawns the engine detached, with no pipes back to the window, and waits for the file to appear. - Adopt: two windows starting at the same moment race for a startup lock. The loser finds the winner’s registry and attaches instead of starting a second daemon.
- Restart: the daemon settings page stops the process and starts a new one. That is the only place the cockpit asks a daemon to exit.
- Crash: a killed daemon leaves its registry behind. The next start reports the stale pid, discards it and takes over the same database.
The CLI
The same binary the cockpit spawns is usable on its own:serve is the default, so bun run engine starts the daemon. --provider-delay <ms> holds the local provider between chunks, which is useful for watching a
stream arrive or for reproducing a crash mid-step. The cockpit looks for a
standalone hiroshi-engine binary next to itself before it falls back to the
checkout, which is how a packaged build runs without bun.
In the cockpit
The right panel’s Terminal surface is the engine’s, not a mock: a command there becomes a run, its output arrives on the feed, and the composer shows the real exit code. Above the inbox is the latest step: its provider, model, status, attempt and the first line of its answer, or the code and sentence of its failure. Below it is the session’s inbox: what was admitted and is still waiting, with its mode, aSTEER/QUEUE switch and an interrupt action while
work is going. Demo fixtures remain available in tests;
the main composer now shows the engine transcript and streams model output,
while the Terminal keeps the step and inbox controls. The
Daemon settings page shows the process, the protocol, the database and the
session count, with the one button that restarts it.