Files
zero/docs/HOW_ZERO_WORKS.md
KRATOS c1937dfac7 feat(acp): add standard session list and resume (#914)
* feat(acp): add standard session list and resume

Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(acp): preserve notification wire order

Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(acp): bind sessions to persisted workspaces

Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(acp): compare workspaces by filesystem identity, not by spelling

@Vasanthdev2004's three findings, all reproduced before changing anything.

TWO SPELLINGS OF ONE DIRECTORY WERE TWO WORKSPACES. Both comparisons were string
equality on ResolveWorkspaceRoot output, and that resolver is abs plus
filepath.Clean plus a stat — it does not fold case and does not resolve
junctions. A session persisted from the TUI was unresumable from an editor
holding a different spelling of the same project folder, and session/list
filtered by the other spelling returned nothing, which makes it an invisible
failure rather than a reported one. It lands on exactly the case this feature
exists for, and on the two processes most likely to disagree about spelling.

os.SameFile asks the filesystem which directories these are, which is the
question. filepath.EvalSymlinks is NOT the fix on Windows — it normalises a
drive letter and returns a junction path unchanged, so the alias survives it,
and junctions need no privilege. String equality stays as the fast path, and a
stat failure falls back to it rather than widening the match: this gate refuses
access to another workspace's files and configuration, so an unanswerable
comparison denies.

THE TEST COULD NOT SEE ANY OF IT. testDeps resolves with the identity function,
so the guard degenerated to "are these two strings different" fed two unrelated
temp directories — it could only ever answer yes. The rejection direction was
pinned and the acceptance direction was asserted nowhere. The new test uses a
resolver reproducing the production normalisation and drives the ACCEPTANCE
direction through an alias, skipping if the filesystem folds the alias away so
it never passes vacuously. Reverting to string equality fails it three ways:
load, resume, and a list that returns zero.

THE WIRE KEYS ARE AN EXTERNAL CONTRACT. Nothing pinned sessionId, cwd, title,
updatedAt, _meta, modelId, createdAt, sessions, nextCursor, cursor, loadSession,
promptCapabilities, sessionCapabilities, list or resume, so renaming a Go field
would break every client and leave the suite green. Renaming modelId to model_id
now fails.

Origin-Session: local-79d7a0 | Claude Code | 5 prompts
Origin-Snapshot: c175cabb9d50
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(acp): test the alias guard where the bug lives, and stop listing unusable sessions

THE TEST COULD NOT RUN ON THE PLATFORM THE BUG IS FROM. @Vasanthdev2004's point,
and he is right that it is not a nit. The alias test built its second name with
os.Symlink, which needs a privilege an ordinary Windows session does not hold, so
it SKIPPED there — and Windows is where junctions exist and where this defect came
from. The identity guard was verified by CI on the two platforms that never had
the problem. It now builds the alias with mklink /J on Windows, which needs no
privilege and is how he found the defect in the first place, and keeps the symlink
arm elsewhere.

This is the second time in this series a correct fix shipped with a test that
could not exercise it: the same helper shape was added to internal/memory for the
same reason a day earlier.

A SESSION WITH NO PERSISTED WORKSPACE IS NOT RESUMABLE, SO IT IS NOT LISTED.
CodeRabbit's finding. activatePersistedSession refuses an empty Cwd, but the
listing advertised it anyway — a menu entry that only fails when taken. The test
asserts both halves, because the listing is only correct relative to what resume
will accept: the omitted session is checked to really fail on resume, and a usable
session is checked to survive the filter.

Mutation-checked: removing the filter advertises the unusable session again.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(acp): resolve every listed workspace, not only when a filter was supplied

@anandh8x's P1, both halves reproduced before changing anything.

The loop resolved item.Cwd only when a cwd filter was present, and skipped only a
blank one. Two shapes stayed on the menu that session/resume then refuses:

  - a session whose persisted workspace has since been deleted, advertised as
    resumable
  - a legacy entry holding a relative path, reported as cwd "." although ACP
    requires SessionInfo.cwd to be absolute

Every entry is now resolved unconditionally, anything that cannot resolve is
omitted, and the RESOLVED root is what SessionInfo carries — absolute as the
contract requires, and the same value the client hands back on resume. The
optional identity filter then applies to the resolved roots, which is also where
it belonged.

Listing is a menu: activatePersistedSession resolves and refuses what it cannot
reach, so anything this loop cannot resolve is something a client would be
offered and then denied.

Mutation-checked: restoring the resolve-only-when-filtered shape re-advertises
the deleted workspace and re-emits the relative cwd.

Origin-Session: local-76c8d7 | Claude Code | 6 prompts
Origin-Snapshot: 259b715cf0fd
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* test(acp): assert the relative entry is retained, not merely absolute

CodeRabbit's catch, and the test was genuinely weaker than it looked. It checked
that the deleted workspace was gone, the live one kept, and every listed cwd
absolute — all of which a "fix" that simply DISCARDED any non-absolute entry
would satisfy, while losing a resumable session.

Presence is now asserted separately from spelling: the relative entry must still
be listed, and listed with an absolute path.

Mutation-checked: skipping non-absolute entries instead of resolving them now
fails with "a session with a resolvable relative workspace was dropped rather
than normalised". The first attempt at that mutation did not compile, so it
proved nothing until it was rewritten — worth saying, because a mutation that
fails to build looks exactly like a test that passes.

Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(acp): a relative persisted workspace is omitted, not rebased

Reported by @anandh8x. Resolving a stored relative cwd does not recover the
session's workspace, it invents one: ResolveWorkspaceRoot joins it against
whatever directory the ACP server happens to be running in, and that invented
absolute path was then advertised as the session's workspace and accepted as
its home on resume.

Reproduced on f2c6fc9a, a session persisted with cwd ".":

  LISTED legacy-rel as cwd="/Users/kratos/dev/f914/internal/acp"
  resume with an UNRELATED workspace -> err=... cwd does not match its persisted workspace
  resume with NO cwd (falls back to ".") -> err=<nil>

The mismatch check does its job when the client names a workspace, so the only
opening was the fallback path, where the rebased value was compared against
itself and always agreed. A conversation created for one project could be
resumed against another project's files, configuration and tools.

Both doors now take the same guard: handleSessionList omits an entry whose
persisted cwd is not absolute, and activatePersistedSession refuses one rather
than resolving it. The original base is not knowable from the metadata, so
guessing at it is not an option a fix can take.

This reverses an earlier assertion in TestSessionListResolvesEveryWorkspace,
which expected the relative entry to be normalised and retained. That was
requested in review on the grounds that dropping it loses a resumable session.
It does, but the entry was never resumable into its own workspace, only into
this process's. The test now asserts it is dropped, and a new
TestResumeRefusesARelativePersistedWorkspace covers the fallback path that the
listing filter alone leaves open.

Both guards mutation-checked: removing either one fails its test.
Pre-existing on this branch and on its merge-base, unrelated to this change:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider both exit 3 in this environment.

Origin-Session: local-8cd239 | Claude Code | 11 prompts
Origin-Snapshot: 365efe3045f2
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* test(acp): the resume guard is asserted through both activating methods

Raised by CodeRabbit: the test is named for resume and called session/load.

session/load and session/resume are separate entry points that today share
activatePersistedSession, so an assertion through either one passes while the
guard holds — but the name promised a surface it was not touching. Both are now
named explicitly, which keeps that true: if resume is ever given its own path,
this fails rather than quietly covering half of what it claims to.

With the guard removed, both methods accept a relative persisted workspace on
the no-cwd fallback. The named-workspace case was already refused by the
existing mismatch check; the fallback was the only door open, and it is open on
both.

Not taken in this PR, from the same review: threading a rooted directory handle
through ResolveWorkspaceRoot and workspace construction so a root rename or link
swap cannot redirect later file operations. That is a real question and a
pre-existing one — this change adds a refusal and no path handling — but it is a
capability refactor across workspace and tool access with its own race test, not
something to fold into a session-list fix. Worth its own issue.

Origin-Session: local-8cd239 | Claude Code | 11 prompts
Origin-Snapshot: 365efe3045f2
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(acp): activation requires an absolute cwd in the REQUEST, not just the record

Reported by @jatmn.

ResumeSessionParams is a type alias for LoadSessionParams, so JSON decoding turns
an OMITTED resume cwd into an empty string. That blank reached the shared
activation path, whose blank-cwd fallback substitutes meta.Cwd — so
{"sessionId":"known"} activated a persisted session, even though ACP v1 requires
session/resume to carry an absolute working directory.

This is a different hole from the persisted-cwd one fixed earlier on this branch.
That guard asks whether the STORED workspace is identifiable; this asks whether
the CALLER named one at all. The earlier fix does not cover it, because a stored
absolute cwd passes that check and the blank request then silently inherits it.

requestedWorkspace validates the request's own cwd before anything reaches the
fallback: absent, empty and whitespace-only are all invalid params, and so is a
relative path. Applied to both activating methods rather than to resume alone —
load's omitted-cwd fallback was inheriting the same way, and leaving one door
open is how this class survived the last fix.

Mutation-checked at the wire, which is where the defect lives: making the blank
case return no error compiles and fails the regression on four separate requests
— session/load and session/resume, each with cwd omitted and with cwd blank. A
Go-level test would not have caught it, since the defect is in decoding an absent
field.

Two notes from a verification pass, neither a defect:

Error precedence changed: a blank cwd with an UNKNOWN session id now reports the
cwd problem instead of "session not found". Kept deliberately and pinned — it
stops the server confirming whether a session exists to a request that named no
workspace.

AdditionalDirectories is declared on two params structs and consumed nowhere in
the repo. Not a hole today, but it is the same shape — client-supplied paths with
no absoluteness rule — so the field now carries a note saying it must go through
requestedWorkspace when wired up.

Rebased onto ad34dc8d. go test -race ./internal/acp/ -count=5: clean.
Pre-existing here and on main: TestRunDoctorFormatsRedactedProviderDiagnostics
and TestRunDoctorConnectivityProbesProvider exit 3 in this environment.

Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(acp): resume the conversation a session became, and fail closed when it cannot

Three defects in ACP session restoration, all reported by @jatmn.

loadHistory read the raw event log and kept only EventMessage. A compacted
session stores its original prefix alongside an EventCompaction naming the
events it replaced and carrying their summary, so restoring from the raw log
replayed superseded turns AND dropped the summary that replaced them. It now
reads the same rehydrated view the TUI and exec paths use, and explicitly
projects the compaction summary -- switching readers alone would still drop it,
because rehydration substitutes the compaction event in place of what it
replaced.

historyErr only suppressed replay and raised a warning: the session was
registered and reported ready regardless, so an unreadable events file left the
caller holding a live, promptable session ID whose next prompt ran as a fresh
conversation under the old identity. Resume now fails. Load keeps the
best-effort policy deliberately rather than by inheriting the shared helper.

Tool calls and their results were dropped from session/load, so a restored
transcript showed prose asserting edits with no record that any tool ran. They
now replay through the same toolCallStart/toolCallResult mapping a live turn
uses, keyed on the stored toolCallId so results pair with their calls. They do
not enter turnRecord, so load and resume still consume the same effective
history. Resume stays replay-free.

Also asserts that an unset sessionCapabilities is omitted rather than
serialized as null, raised by CodeRabbit.

* fix(acp): persist tool activity for session replay

* test(acp): bound replay notification waits

* fix(acp): preserve session restore invariants

* fix(acp): restore session lifecycle boundaries

* fix(acp): preserve replay and restore identity

* fix(acp): commit a completed turn as one store batch

A completed turn was buffered until its outcome was known and then persisted
one event per call. Each call took and released the store's session lock, so a
second ACP instance holding the same session -- its turnMu is its own, only the
store is shared -- could append its whole turn between two of them: user A,
user B, answer B, answer A. Every write was individually locked and the log
was valid, but a fresh load pairs prose by order and reconstructed three wrong
turns from two right ones. Before this branch the user and assistant events
went to AppendEvents together; buffering until the outcome is known was the
right change and splitting the commit into singleton writes was not.

The whole buffer now goes through one Store.AppendEvents call, which writes the
batch under one lock and one file write. That is the contiguity being restored:
not a disk transaction, and not a promise about a crash mid-fsync. The outcome
gate still runs first; a hard failure still commits nothing; cancellation still
commits through the same boundary. A persistence failure is now the failure of
the whole batch, so there is no dependent suffix to protect and the rewritten
regression asserts that nothing of the turn is durable, the warning is raised,
and a fresh load replays none of it.

The two-instance regression holds both writers at the commit boundary until
each has arrived and then delegates to the real batch API under the real lock;
it observes what production hands it rather than batching anything itself, so
reverting to singleton writes fails it before any interleaving has to be
provoked. The restored prompt keeps both prose pairs and no historical tool
output.

Also normalizes the expected workspace in the listing test through
filepath.Clean, matching the resolver's output contract, so a TMPDIR spelled
with a ".." component no longer fails a correct result.

Rebased onto main to pick up the ACP browser metadata, specialist model
restoration and worktree pointer protection changes. Both reported by @jatmn.
2026-09-12 18:18:39 +05:30

38 KiB

How Zero Works

This document explains Zero's architecture at a high level: the layers, module boundaries, data flow, and runtime concepts behind the interactive TUI and the headless zero exec mode.

It is written as a progressive map: each high-level section introduces the concepts you need for orientation, then points to the later section that explains the mechanics in more detail.

Big Picture

Zero is a Go CLI application with two main user-facing surfaces:

  • Interactive TUI (zero): a Bubble Tea application that renders chat, tool cards, permission prompts, model/provider pickers, and session controls.
  • Headless exec (zero exec ...): a scriptable command runner that uses the same agent loop and tools, but writes text, JSON, or stream-JSON events.

Both surfaces converge on the same core agent runtime:

flowchart TD
    User[User / automation] --> CLI[cmd/zero -> internal/cli]

    CLI --> TUI[Interactive TUI<br/>internal/tui]
    CLI --> Exec[Headless exec<br/>internal/cli/exec.go]
    CLI --> ACP[ACP / editor bridge<br/>internal/acp]

    TUI --> Agent[Agent loop<br/>internal/agent]
    Exec --> Agent
    ACP --> Agent

    Agent --> Provider[Provider runtime<br/>internal/zeroruntime.Provider]
    Provider --> APIs[OpenAI / Anthropic / Gemini / compatible APIs]

    Agent --> Registry[Tool registry<br/>internal/tools.Registry]
    Registry --> CoreTools[Core tools<br/>read/edit/shell/search/plan]
    Registry --> MCP[MCP tools<br/>internal/mcp]
    Registry --> Specialists[Specialist tools<br/>internal/specialist]
    Registry --> Plugins[Plugin tools<br/>internal/plugins]
    Registry --> LocalControl[Browser / terminal / desktop helpers]

    Agent --> Sandbox[Permission + sandbox engine<br/>internal/sandbox]
    Agent --> Hooks[Hooks dispatcher<br/>internal/hooks]
    Agent --> Sessions[Session event store<br/>internal/sessions]

The important split is:

Architecture Spine

The easiest way to understand Zero is not as a set of independent packages, but as one spine with several mechanisms attached to it:

flowchart LR
    Surface[Surface<br/>TUI / exec / ACP] --> Run[Run request<br/>prompt + runtime options]
    Run --> Agent[Agent loop<br/>turns, tools, recovery]
    Agent --> Capabilities[Capabilities<br/>providers + tools + sandbox]
    Capabilities --> Events[Runtime events<br/>text, tool calls, permissions, usage]
    Events --> Surface
    Events --> Sessions[Session log<br/>resume, fork, rewind, history]

    Config[Config / guidelines / skills] --> Run
    Extensions[MCP / specialists / plugins / hooks] --> Capabilities

The TUI + agent loop is the main story because it explains the normal user experience: type a prompt, watch the model stream, approve tool use, see file changes, and receive a final answer. Other mechanisms are still important, but most of them attach to the same spine:

Mechanism Where it attaches What it changes
Config, provider profiles, model registry Before a run starts Which provider/model is used and what defaults apply.
Project/user guidelines, skills, specialists context Prompt construction What instructions and available capabilities the model sees.
Tool registry, MCP, plugins, local-control helpers Capability layer Which actions the model may request.
Permission policy, sandbox, command-prefix grants Tool execution Whether a requested action can run, needs approval, or is denied.
Hooks Tool lifecycle Optional before/after observers or blockers around tool calls.
Sessions, checkpoints, compaction metadata Event persistence/context What can be resumed, forked, rewound, or summarized for later turns.
Stream-JSON Surface layer How the same runtime events are emitted for automation instead of rendered in the TUI.

So this document focuses first on the TUI/agent-loop bridge, then explains the supporting mechanisms in terms of where they connect to that bridge.

Use this as the reading path:

If you see this concept in a high-level flow Read this detail section
Surface submits a run Startup Flow, then How the TUI and Agent Loop Connect
Agent builds context and advances turns Agent Loop
“Maybe compact context” Context Budget and Compaction
Model requests a tool Tool Execution Lifecycle, then Tools and Capabilities
Tool needs approval or sandbox access Permission and Sandbox Model
Events appear in the UI or stream-JSON How the TUI and Agent Loop Connect, Stream-JSON Protocol
Resume, fork, rewind, or sub-agent history Session Storage and History

Main Packages

The sections above describe the architecture in concepts. If you want to connect those concepts to implementation details, use this table as the code map before reading the deeper flows below.

Package Role
cmd/zero Tiny binary entrypoint; calls internal/cli.Run.
internal/cli Command parsing, config resolution, provider creation, registry setup, TUI/exec/ACP launch paths.
internal/tui Bubble Tea model/update/view code, transcript rendering, composer, prompts, slash commands, setup wizard.
internal/agent Core loop that sends messages to the model, streams output, executes tools, handles permissions, compaction, retries, and final answers.
internal/zeroruntime Provider-neutral message, tool-call, stream, usage, and image types. Providers implement Provider.StreamCompletion.
internal/providers Factory that turns a resolved provider profile into an OpenAI, Anthropic, Gemini, Codex, or compatible provider adapter.
internal/tools Tool interface, registry, core read/write/shell/network tools, local-control wrappers, tool safety metadata, redaction, output ceilings.
internal/sandbox Permission policy, path/network checks, grant store, command-prefix approvals, and platform sandbox backends.
internal/sessions Local append-only session storage: metadata.json + events.jsonl.
internal/config User/project config resolution, active provider, preferences, sandbox/tool settings.
internal/mcp MCP server config, client runtime, permission store, and MCP tool registration.
internal/specialist / internal/swarm Sub-agent manifests and team/member orchestration exposed as tools.
internal/localcontrol / internal/browser Runtime helpers used by config-gated local-control tool wrappers.
internal/plugins / internal/skills / internal/hooks Extension surfaces loaded into the agent context, tool registry, or tool lifecycle.
internal/streamjson Machine-readable protocol for zero exec --input-format/--output-format stream-json.

Startup Flow

Interactive TUI (zero)

sequenceDiagram
    participant Main as cmd/zero
    participant CLI as internal/cli
    participant Config as internal/config
    participant TUI as internal/tui
    participant Agent as internal/agent

    Main->>CLI: Run(args)
    CLI->>Config: resolve config for workspace
    CLI->>CLI: build provider
    CLI->>CLI: build tool registry
    CLI->>CLI: register core, config-gated local-control, specialists, MCP, plugins
    CLI->>CLI: create sandbox engine + session store
    CLI->>TUI: run Bubble Tea with Options
    TUI->>TUI: Init / Update / View event loop
    TUI->>Agent: agent.Run on submitted prompt

The TUI owns terminal interaction. It tracks composer state, transcript rows, scrolling, permission dialogs, model/provider pickers, setup wizard state, and visual updates. When the user submits a prompt, the TUI builds agent.Options with callbacks like OnText, OnToolCall, OnToolResult, OnPermission, OnAskUser, and OnUsage. Those callbacks convert runtime events into visible transcript rows and session events.

Headless exec (zero exec)

flowchart TD
    A[Parse exec flags] --> B[Resolve config + provider]
    B --> C[Build tool registry]
    C --> D[Resolve prompt / stream-json input / images]
    D --> E[Prepare or resume session]
    E --> F[Create stream-json/text/json writer]
    F --> G[agent.Run]
    G --> H[Write final output and exit code]

zero exec uses the same agent.Run loop as the TUI, but without interactive terminal state. It can persist sessions, resume/fork existing sessions, run in stream-JSON mode for automation, and always requires a stronger completion signal so a no-tool assistant response is not treated as success while work is still pending.

Agent Loop

The core loop lives in internal/agent.Run. Conceptually, every turn follows this cycle:

flowchart TD
    Start[Build system prompt + user prompt] --> Expose[Partition visible tools]
    Expose --> Compact[Maybe compact context]
    Compact --> Request[Send CompletionRequest to provider]
    Request --> Stream[Collect streamed text, reasoning, usage, tool calls]

    Stream --> HasTools{Tool calls?}
    HasTools -- No --> FinalCheck{Final answer acceptable?}
    FinalCheck -- Yes --> Done[Return Result]
    FinalCheck -- No --> Nudge[Append user nudge and continue]

    HasTools -- Yes --> Execute[Execute tool calls]
    Execute --> Permission[Permission / sandbox / hooks]
    Permission --> ToolResult[Append tool result messages]
    ToolResult --> Diagnostics[Post-edit diagnostics / self-correct]
    Diagnostics --> Expose

Key responsibilities of the loop:

  1. Build prompt state — combines the system prompt, project/user guidelines, loaded skills/specialist instructions, the current user prompt, and images. The resulting message list is the model-facing context that later tool results are appended to. Resumed session history is replayed by the caller before a new run, not passed as a separate agent.Run history field.
  2. Expose tools — starts from the registry built by the CLI, then filters tool definitions by permission mode, enabled or disabled filters, and deferred-loading rules. Tool schemas also count toward context budget, so they matter for compaction decisions.
  3. Call provider — sends a provider-neutral zeroruntime.CompletionRequest.
  4. Stream events — forwards text, reasoning, tool-call deltas, and usage to the caller via callbacks.
  5. Execute tools — decodes tool arguments, checks filters, applies permissions and sandbox policy, dispatches hooks, runs the registered tool, redacts output, and appends a tool message. See Tool Execution Lifecycle.
  6. Recover and guard — handles malformed tool calls, repeated failures, empty turns, context pressure, stream reconnect/retry, and max-turn fallback. Context pressure is handled by Context Budget and Compaction.
  7. Finalize — returns a final answer or a structured incomplete/stop reason.

The model never directly mutates the workspace. It asks for tool calls; Zero validates and executes those calls through the registry, permission policy, and sandbox engine.

Tool Execution Lifecycle

A tool call is part of the model conversation, not a side channel that bypasses it. The model proposes an action, Zero decides whether that action is allowed to run, the tool returns a structured result, and that result becomes context for the next provider request.

sequenceDiagram
    participant Model as LLM / provider
    participant Agent as agent.Run
    participant Policy as filters + permissions + sandbox
    participant Hooks as hooks
    participant Tool as tool implementation
    participant Surface as TUI / exec callbacks
    participant Transcript as model conversation + session events

    Agent->>Model: request with messages + available tool schemas
    Model-->>Agent: assistant text and/or tool_call(name, args)
    Agent-->>Surface: stream text / tool-call preview
    Agent->>Agent: collect and decode tool arguments
    Agent->>Policy: check tool filters, permission mode, sandbox policy

    alt denied before execution
        Policy-->>Agent: denied result
        Agent-->>Surface: tool failed/denied event
        Agent->>Transcript: append tool result explaining denial
    else needs user approval
        Policy-->>Surface: permission request
        Surface-->>Policy: allow / deny / remember decision
        Policy-->>Agent: decision
    end

    alt approved to execute
        Agent->>Hooks: beforeTool hooks
        Hooks-->>Agent: allow / block / annotate
        Agent->>Tool: execute(ctx, args)
        Tool-->>Agent: output, error, metadata
        Agent->>Hooks: afterTool hooks
        Agent->>Agent: redact secrets, apply semantic output budget, enforce byte ceiling
        Agent->>Agent: classify success/failure
        Agent-->>Surface: OnToolResult callback
        Agent->>Transcript: append tool result message
    end

    Agent->>Model: next request includes prior assistant tool_call + tool result
    Model-->>Agent: continue with more tools or final answer

Conceptually, one requested tool goes through these phases:

  1. Proposal — the provider streams assistant content and may emit one or more tool calls. Zero can show the partial tool arguments in the TUI while they are still streaming, but nothing has executed yet.
  2. Decode and gate — the agent validates JSON arguments, checks whether the tool is enabled, applies permission mode, asks the sandbox engine about path, network, shell, or command-prefix constraints, and may invoke permission callbacks.
  3. Execute — the selected registry entry runs locally, through MCP, as a specialist/sub-agent, or through a plugin/local-control helper. Hooks can run before or after this step.
  4. Normalize result — Zero turns the outcome into an agent ToolResult: a success, failure, denial, cancellation, timeout, or validation error with output/metadata that may be redacted or truncated.
  5. Append to context — the result is appended as a tool-result message paired with the assistant tool call. The next model request includes that result, so the model can inspect it and decide what to do next.
  6. Render and persist — the result is surfaced to the TUI, stream-JSON writer, or ACP client. TUI and exec record rich tool/session events for resume/history; ACP persists conversational messages and completed tool-call starts/results for load replay. Live streaming details and permission exchanges remain client notifications rather than durable history.

Tool success and failure are both informative context. A successful read_file result gives the model file contents; a failed edit_file result gives the model an error such as “old string not found”; a denied shell command tells the model that the action is not currently permitted. The model is expected to use that feedback to continue, choose another tool, ask the user, or explain the blocker.

The judgement that “information is sufficient” is made by the model, not by the TUI. Zero controls the loop mechanics: it keeps appending messages, enforces safety, retries or nudges around malformed/empty/incomplete turns, and stops on hard limits. But the semantic decision to call another tool versus produce a final answer comes from the next provider response. In other words:

flowchart TD
    Result[Tool result appended to conversation] --> Next[Next provider request]
    Next --> Decide{Model response}
    Decide -- More information/action needed --> MoreTools[Request another tool]
    MoreTools --> Result
    Decide -- User input needed --> AskUser[Call ask_user or explain question]
    AskUser --> Result
    Decide -- Enough information --> Final[Final assistant answer]
    Decide -- Invalid / empty / unsafe --> Guard[Agent guardrail, retry, denial, or stop reason]
    Guard --> Next

This is why tool output is usually more than UI decoration. It is part of the working memory for the next model turn, subject to context-window pressure and compaction. The TUI may summarize or prettify it for the user, but the agent loop also stores the machine-facing result that the model sees.

Context Budget and Compaction

The Maybe compact context step in the agent-loop diagram is Zero's context-window pressure valve. It is not a separate memory engine; it rewrites the current model-facing conversation so the next provider request fits within the configured context window.

Compaction is enabled only when the run knows a positive ContextWindow. When it is enabled, the loop does this near the top of each turn:

flowchart TD
    Tools[Partition visible tools] --> Estimate[Estimate messages + tool-definition tokens]
    Estimate --> Under{Under threshold?}
    Under -- Yes --> Keep[Keep context as-is]
    Under -- No --> Grown{Grown past low-water mark?}
    Grown -- No --> Keep
    Grown -- Yes --> Prune[Prune stale large tool outputs]
    Prune --> Recheck{Back under threshold?}
    Recheck -- Yes --> KeepPruned[Use pruned context]
    Recheck -- No --> Summarize[Summarize oldest middle]
    Summarize --> Replace[system messages + summary + recent suffix]

The trigger is intentionally approximate:

  • Zero estimates request size using message text, images, tool-call arguments, and the tool definitions advertised on that turn.
  • The proactive threshold is roughly two-thirds of the context window.
  • Provider usage from completed turns is used to calibrate the rough estimate over time, so the budget gauge and compaction decision track the provider more closely after a few turns.
  • A low-water mark prevents the loop from re-summarizing the same already-compacted history on every turn.

When compaction actually runs, it tries to preserve the parts most likely to be needed by the model:

  • leading system messages stay verbatim;
  • the most recent turns stay verbatim (CompactionPreserveLast, defaulting to the agent's normal preserve count);
  • the preserved suffix is widened to a provider-valid turn boundary so tool-result messages are not replayed without their corresponding assistant tool call;
  • the older middle is summarized into one user-role message labeled [Summary of earlier conversation];
  • structured state that should not be paraphrased away, such as active plan/loaded skill state, is preserved alongside the prose summary.

There are two compaction paths:

  1. Proactive compaction runs before the provider call when the estimated request is over threshold.
  2. Reactive compaction runs after a provider returns a context-limit style error. Zero compacts once and retries that same turn; if the provider still rejects the request, the error is surfaced rather than looping forever.

Compaction failures are conservative. If the summarizer fails during proactive compaction, Zero keeps the original messages and tries to continue. It does not drop conversation history just to make room.

Presentation Layer: Bubble Tea TUI

internal/tui is a Bubble Tea application. The usual Bubble Tea shape applies:

flowchart LR
    Init[model.Init] --> Update[model.Update]
    Update --> View[model.View]
    View --> Terminal[Terminal frame]
    Terminal --> Msg[tea.Msg: key, mouse, window, agent event]
    Msg --> Update

The TUI is intentionally not the agent brain. It is responsible for:

  • collecting user input from the composer;
  • rendering assistant text, reasoning, tool cards, permissions, plans, usage, files, and sidebars;
  • handling slash commands and modal surfaces;
  • converting agent callbacks into Bubble Tea messages;
  • persisting visible session events;
  • allowing live provider/model/permission changes.

When a prompt is submitted, the TUI starts a background command that invokes agent.Run. Streaming callbacks send messages back into the Bubble Tea update loop, so rendering remains responsive while the agent is working.

How the TUI and Agent Loop Connect

A common source of confusion is that Zero has two event loops running at once:

  1. Bubble Tea loop — owns terminal input, in-memory UI state, and frames.
  2. Agent loop — owns model turns, tool execution, permissions, retries, and stop conditions.

They are connected by tea.Cmd and agent.Options callbacks. The TUI never runs provider/tool logic directly inside Update; instead, submit handling gates and dispatches a prompt, the TUI starts a background command, and that command calls agent.Run with callbacks that send runtime events back to the Bubble Tea program. Function names in this section are illustrative code anchors, not API boundaries.

sequenceDiagram
    participant User
    participant TUI as Bubble Tea model<br/>internal/tui
    participant Cmd as tea.Cmd goroutine<br/>runAgentWithOptions
    participant Agent as agent.Run<br/>internal/agent
    participant Provider as Provider.StreamCompletion
    participant Tools as Tool registry + sandbox
    participant Store as Session store

    User->>TUI: type prompt / press Enter
    TUI->>TUI: validate/gate submit and append visible user row
    TUI->>Cmd: launch tea.Cmd with runID + cancellable context
    Cmd->>Agent: agent.Run(prompt, provider, options)
    Agent->>Provider: stream completion request
    Provider-->>Agent: text / reasoning / tool-call deltas
    Agent-->>Cmd: OnText / OnReasoning / OnToolCallStart callbacks
    Cmd-->>TUI: runtimeMessageSink(tea.Msg)
    TUI->>TUI: Update appends/updates transcript rows
    TUI-->>User: View renders new frame

    Agent->>Tools: execute collected tool call
    Tools->>Tools: permission, sandbox, hooks, redaction
    Tools-->>Agent: tool result
    Agent-->>Cmd: OnToolCall / OnToolResult / OnPermission / OnUsage
    Cmd->>Store: batch session events for resume/history
    Cmd-->>TUI: final agentResponseMsg
    TUI->>TUI: mark run complete, flush rows/events, unlock composer

In the TUI path, the same event can have three representations:

Runtime thing Agent representation TUI representation Session representation
Assistant text streamed provider text forwarded through OnText incremental assistant row text assistant message event
Reasoning preview OnReasoning callback collapsible/preview reasoning row usually not the same as final assistant text
Tool call agent.ToolCall from collected provider stream tool card, specialist card, or plan panel update EventToolCall
Tool result agent.ToolResult result card/status, changed files, plan/specialist updates EventToolResult
Permission prompt/audit PermissionRequest / PermissionEvent modal prompt or permission row permission request/decision events
Usage/context provider usage + measured context context gauge / usage metadata usage event

One interactive turn, end to end

For a normal prompt in the interactive TUI:

  1. Submit handling validates composer state and command gates, then dispatches the prompt to the launch path.
  2. The launch path appends immediate UI rows, creates a cancellable run context, and starts runAgent as a tea.Cmd; run bookkeeping assigns the runID used to correlate later events.
  3. runAgentWithOptions copies the current UI-controlled runtime choices into agent.Options: provider/model labels, permission mode, cwd, session ID, images, registry, response style, context window, and self-correction hooks.
  4. The command wraps agent callbacks. For example:
    • OnText sends incremental assistant text into the TUI message sink.
    • OnToolCallStart / OnToolCallDelta show live tool-argument streaming.
    • OnToolCall creates visible tool rows and appends session events.
    • OnToolResult creates result rows, updates plan/specialist side panels, and records changed files.
    • OnPermissionRequest sends a prompt to the TUI and blocks the agent loop until the user answers or the run context is cancelled.
    • OnAskUser does the same for model-initiated clarification questions.
  5. agent.Run builds provider-neutral messages and tool definitions, streams a provider response, executes any requested tools through the registry/sandbox, appends tool results back into the model conversation, and repeats until it has a final answer or a stop condition.
  6. When agent.Run returns, the command emits agentResponseMsg. The Bubble Tea loop marks the run complete, flushes queued rows/session events, updates usage and file state, and re-enables the composer.

The important architectural point: the TUI is stateful and reactive, but the agent loop is the source of runtime truth for model/tool progress. Presentation state is derived from callback events and persisted session events; workspace changes happen only through tools executed by the agent runtime.

Feature flows use the same bridge

For one interactive turn, the supporting systems do not run beside the TUI/agent loop; they run inside or around it:

flowchart TD
    Submit[TUI submit] --> Options[Build agent.Options]
    Options --> Context[Assemble context<br/>config, guidelines, history, skills]
    Context --> ProviderCall[Provider stream]
    ProviderCall --> StreamEvents[Text/reasoning/tool-call events]
    StreamEvents --> Render[TUI render callbacks]

    ProviderCall --> ToolCall{Tool call?}
    ToolCall -- No --> Final[Final response]
    ToolCall -- Yes --> ToolGate[Tool gate<br/>filters, permissions, sandbox]
    ToolGate --> HooksBefore[beforeTool hooks]
    HooksBefore --> Execute[Tool / MCP / specialist / plugin]
    Execute --> HooksAfter[afterTool hooks]
    HooksAfter --> ToolResult[Tool result message]
    ToolResult --> Persist[Session events]
    ToolResult --> Context

    Final --> Persist
    Persist --> Resume[Resume / fork / rewind / parent metadata]

Most interactive features are variations of that same bridge rather than separate architectures:

  • Slash commands either mutate TUI state directly (/model, /permission, /help) or start short tea.Cmd jobs that later return a message to Update (/doctor, /search, /compact, session commands).
  • Permissions sit between a model-requested tool call and actual execution: the agent blocks on OnPermissionRequest, the TUI surfaces a modal and sends the decision back through a channel, and the session log records the request/decision events.
  • Plans, specialists/sub-agents, and ask-user prompts all follow the same runtime pattern: the model requests a capability, the agent records the call/result in its conversation, and the TUI mirrors the visible state as plan panels, specialist cards, or question forms. Specialist session metadata can carry parent/root and sub-agent/spec fields, with richer linkage depending on the specialist or swarm path.
  • Streaming tool writes start as provider tool-call deltas before execution; the TUI shows the arguments live, then replaces/augments them with the actual ToolResult after the tool runs.
  • Compaction is an agent-loop context-management step; the TUI may show that it happened, but the purpose is to keep the next provider request within budget.
  • Stream-JSON is another surface over the same callbacks: zero exec serializes the events as JSON protocol events instead of Bubble Tea views.

So a feature-specific deep dive should usually explain: what user event starts it, which tea.Msg/callback carries it, which agent/tool event is authoritative, and which session events make it resumable.

Runtime Provider Layer

Providers are hidden behind this interface in internal/zeroruntime:

type Provider interface {
    StreamCompletion(ctx context.Context, request CompletionRequest) (<-chan StreamEvent, error)
}

The agent loop only knows about provider-neutral messages, tool definitions, stream events, reasoning blocks, images, and token usage. Provider adapters in internal/providers/* translate those neutral types to provider-specific APIs:

  • OpenAI and OpenAI-compatible APIs;
  • Anthropic and Anthropic-compatible APIs;
  • Gemini;
  • Codex / ChatGPT OAuth-backed runtime.

internal/providers.New resolves the configured model through the model registry/catalog, normalizes API model IDs and base URLs, attaches API keys or OAuth resolvers, and returns the concrete adapter.

Tools and Capabilities

Tools implement a common interface and are registered by name in internal/tools.Registry.

flowchart TD
    AgentToolCall[Tool call from model] --> Decode[Decode JSON args]
    Decode --> Filter[Enabled/disabled + permission-mode filter]
    Filter --> Precheck[Pre-permission rejecters]
    Precheck --> SandboxEval[Sandbox Evaluate]
    SandboxEval --> Prompt{Needs user approval?}
    Prompt -- Yes --> Decision[Permission callback]
    Prompt -- No --> Run[Registry.RunWithOptions]
    Decision --> Run
    Run --> Redact[Redact secrets]
    Redact --> Budget[Token-aware semantic output budget]
    Budget --> Ceiling[Existing hard byte ceiling]
    Ceiling --> Message[Return one tool result to model]

Oversized results use deterministic, provider-neutral estimated-token budgets. Policies retain useful structure for files, search matches, tests, process logs, diffs, and worker conclusions; tools without a declared category use a UTF-8-safe head/tail fallback. The estimate is intentionally conservative for non-ASCII text and is not exact provider tokenization. Existing byte ceilings remain the authoritative safety limit. When the existing spill mechanism can persist the complete redacted text received by the budgeting layer, the result includes its safe spill reference. This does not imply capture of subprocess bytes already discarded by a tool's established internal buffer.

Core tool groups include:

  • Read-only tools: file reads, directory listing, glob, grep, LSP navigation, skills, ask_user, permission requests.
  • Write tools: write_file, edit_file, apply_patch, update_plan.
  • Shell tools: exec_command, write_stdin, legacy bash.
  • Network tools: web_fetch and optional web search backend. web_fetch validates the URL before the permission prompt, refuses loopback/private/ special-use addresses, and dials the address it resolved. With HTTP_PROXY/HTTPS_PROXY set, the proxy is dialed instead and resolves the target itself, so destination enforcement ends at the proxy; see the note in the README.
  • Local-control tools: config-gated wrappers for browser, terminal, desktop, and artifact helpers backed by packages such as internal/localcontrol and internal/browser.
  • Extension tools: MCP tools, specialists/sub-agents, swarm tools, plugins.

Tool definitions include safety metadata: side-effect class, permission level, reason, and whether prompt-gated tools are advertised in auto-like modes. The agent uses this metadata plus per-turn filters and deferred-loading state to choose which registered tools the model can see and whether a call can run immediately.

Permission and Sandbox Model

Zero has two related but separate checks:

  1. Tool permission metadata says whether a tool is allowed, denied, or needs approval.
  2. Sandbox policy evaluates the actual request: path scope, network access, destructive shell patterns, explicit escalation, persistent/session grants, and platform isolation availability.
flowchart TD
    Call[Tool call] --> Safety[Tool Safety metadata]
    Call --> Scope[Workspace/additional roots scope]
    Safety --> Engine[Sandbox Engine Evaluate]
    Scope --> Engine
    Engine --> Allow[Allow]
    Engine --> Prompt[Prompt user]
    Engine --> Deny[Deny]
    Prompt --> Grant[Turn/session/persistent grant]
    Grant --> Execute[Execute tool]
    Allow --> Execute
    Deny --> Result[Return denied tool result]

The sandbox engine can:

  • auto-allow workspace writes for safe file-edit tools;
  • block reads/writes outside the allowed scope;
  • prompt for recoverable outside-workspace access;
  • deny or prompt for network depending on policy;
  • detect destructive shell commands;
  • run shell commands through a platform backend when available;
  • remember session or persistent grants;
  • support narrow command-prefix approvals.

The user-facing TUI and zero exec mode both route permission requests through callbacks, but the TUI can show interactive modals while headless mode generally returns non-interactive fallback behavior unless a protocol/client handles it.

Session Storage and History

Sessions live locally under Zero's data directory. A session directory contains:

  • metadata.json — session ID, title, cwd, provider/model, parent/root IDs, spec/sub-agent fields, timestamps, and event count.
  • events.jsonl — append-only event log with messages, tool calls/results, permissions, usage, checkpoints, forks, compaction, and specs. TUI and exec use the richer event stream; ACP currently records conversational message history while streaming tool and permission updates to the client.
flowchart LR
    TUI[TUI callbacks] --> Store[internal/sessions.Store]
    Exec[exec recorder] --> Store
    Store --> Metadata[metadata.json]
    Store --> Events[events.jsonl]
    Events --> Resume[resume]
    Events --> Fork[fork]
    Events --> Rewind[rewind/checkpoints]
    Events --> Children[parent/root metadata]

This is why sessions can be resumed, forked, rewound, and inspected without a remote service. Zero stores the local event history; providers only receive the messages needed for the current completion request.

Stream-JSON Protocol

zero exec --output-format stream-json emits newline-delimited JSON events for integrations and CI. The protocol is implemented in internal/streamjson and is specified in STREAM_JSON_PROTOCOL.md.

Common output event types include:

  • run_start, text, reasoning, tool_call, tool_result;
  • permission, permission_request, permission_decision;
  • checkpoint, restore, usage, final, warning, error, run_end.

Input stream-JSON can provide prompt or message events and image payloads. The CLI parses those events, resolves a single prompt, validates/decodes images, and passes the result into the same agent loop used everywhere else.

Extensibility Flow

Zero's extension surfaces are loaded before the agent loop starts:

flowchart TD
    Config[Config resolution] --> Registry[Tool registry]
    Registry --> Core[Core tools]
    Registry --> MCP[MCP server tools]
    Registry --> Specialist[Specialist / Task tools]
    Registry --> Plugin[Plugin tools]

    Config --> Prompt[System prompt context]
    Prompt --> Guidelines[AGENTS.md / ZERO.md]
    Prompt --> Skills[Skills list]
    Prompt --> SpecialistContext[Specialist descriptions]

    ToolLifecycle[Tool lifecycle] --> Hooks[beforeTool / afterTool hooks]
  • Project/user guidelines become prompt context.
  • Skills are instruction packs the model can load on demand.
  • Specialists are sub-agents callable through the Task tool.
  • MCP servers contribute external tools.
  • Plugins can add tools, hooks, and skill roots. Bootstrap always registers a multi-root skill tool: primary Zero skills dir, optional ~/.agents/skills, then plugin skill roots (earlier wins). internal/skills owns that merge via LoadFromRoots; single-root Load remains for install/write.
  • Hooks can observe or block tool lifecycle events.

End-to-End Data Flow

A typical interactive edit request looks like this:

sequenceDiagram
    participant U as User
    participant T as TUI
    participant A as Agent loop
    participant P as Provider
    participant R as Tool registry
    participant S as Sandbox
    participant FS as Workspace/session files

    U->>T: Type prompt and press Enter
    T->>A: agent.Run(prompt, provider, options)
    A->>P: CompletionRequest(messages, tool definitions)
    P-->>A: Stream text + tool call edit_file
    A->>S: Evaluate edit_file args
    S-->>A: allow/prompt/deny decision
    A->>T: permission callback if prompt needed
    T-->>A: user decision
    A->>R: Run edit_file
    R->>FS: Write workspace file
    R-->>A: Tool result + changed files
    A->>P: Next CompletionRequest with tool result
    P-->>A: Final answer
    A-->>T: Result + callbacks
    T->>FS: Append session events
    T-->>U: Render final transcript

The same request in zero exec follows the same provider/tool/sandbox path, but streaming events are written to stdout/stderr instead of rendered in Bubble Tea.

Mental Model

When you work on Zero, start by locating the change on the four-layer spine from Architecture Spine — Surface, Agent loop, Capabilities, Persistence — then jump to the package that owns that boundary:

  • UI behavior? Start in internal/tui.
  • Command-line behavior? Start in internal/cli.
  • Model/tool-turn behavior? Start in internal/agent.
  • A new capability? Register a tool in internal/tools or an extension surface.
  • Safety policy? Start in internal/sandbox and agent permission wiring.
  • Resume/history behavior? Start in internal/sessions and session recorders.