Skip to content

Architecture

This document is a map of how agent6 runs end-to-end. The diagrams are mermaid (mermaid fenced blocks render natively on GitHub). For per-file conventions and stability rules see AGENTS.md. For the security model (threat model, defense layers, sandbox isolation), see security.md.

Layering

ui  ──▶  app  ──▶  workflows  ──▶  tools  ──▶  sandbox
                       └─▶ providers (anthropic | openai)

ui/ is the presentation layer and composition root: ui/cli, ui/tui, ui/web, ui/acp (the four front-ends), ui/mcp_server.py (agent6 as an MCP server), and the write helpers ui/spawn + ui/notify, over the shared headless read-model fold (viewmodel). app/ sits between: the application pipelines that compose the engine but are not a front-end, taking the presentation, process-spawn, and run-dir bridge callables the front-end injects. Boundaries are enforced by tach (see tach.toml). Workflows never import each other; the engine (app and everything below) never imports the UI. Crossing a boundary is almost always a sign of the wrong design.

  • cli (src/agent6/ui/cli/): argument parsing, optional TUI spawn, top-level dispatch. Picks a workflow. cli_main is the one error boundary: an OperatorError (agent6.errors; ConfigError and MemoryStoreError subclass it) means the operator's input or file is bad and prints as an ERROR: refusal at exit 2; anything else is a bug and crash-reports with a saved traceback at exit 1. Config is resolved by config/layer.py (built-in secure defaults < global ~/.config/agent6/config.toml < per-repo config < --config FILE), with paths + sudo/root resolution in paths.py and API keys in secrets.py. Per-repo state (config and run state together) lives out of the workspace under $XDG_STATE_HOME/agent6/<repo-id>/; the base is settable via the global-only [agent6].state_dir or the AGENT6_STATE_HOME env var. The id is keyed on the PROJECT (the nearest enclosing .git), so running from a subdirectory reaches the same runs, memories and config. Every config edit, from any surface, goes through config/write.py: one lock-held cycle that validates the written value standalone, revalidates the merged config, and rolls back -- or keeps the write with a "kept as written" error when the fail-open lock was not held (docs/config.md). Roles: worker drives run/resume, planner drives plan (falls back to worker), reviewer drives review + the in-loop critic.
  • app (src/agent6/app/): the application pipelines composed over the engine but never a front-end -- the run/resume/fork/machine-agent lifecycles, the run-branch merge + finalize, provider construction, the sandbox cross-checks (app.confine), and the --parallel fan-out + coordinator dispatch. Never imports agent6.ui: what it can't do itself (own a terminal, render a live view, spawn a detached agent6) the front-end injects as frozen callables (SessionFrontend, LaneRuntime), and its output goes through an injected two-channel Reporter. That keeps ui/ presentation-only.
  • workflows (src/agent6/workflows/): two exist, loop (the agent loop driving agent6 run / agent6 resume) and review (the read-only review pass driving agent6 review). The single-turn code_review call shape lives here too (code_review.py); the agent loop makes its own provider calls inline.
  • tools (src/agent6/tools/): the fixed tool surface the LLM sees, plus dispatch.
  • sandbox (src/agent6/sandbox/): the agent6-jail launcher and its policy. Every boundary is per command; the agent process itself is never confined.

Workflow: run

This is the agent. One provider, one model, one message history. The model drives by calling tools; the workflow dispatches tools, snapshots state, and tracks budget.

stateDiagram-v2
    [*] --> snapshot
    snapshot --> llm_call
    llm_call --> dispatch: model emits tool calls
    llm_call --> [*]: budget exhausted
    dispatch --> snapshot: non-terminal tool
    dispatch --> commit: run_verify_command (exit 0)
    commit --> snapshot
    dispatch --> [*]: finish_session

Notes:

  • One LLM, one history, one loop. By default there is no planner→worker handoff, no critic step, no separate reviewer agent: multi-step work is the model calling the next tool in the same conversation. The in-loop critic and the adversarial review panel are opt-in ([review] config) and layer onto this same history.
  • Snapshot before every LLM call. loop_state.json is rewritten in the session directory (<state-dir>/<repo-id>/sessions/<bucket>/<session-id>/, out of the workspace) before each provider request, with a per-turn copy under checkpoints/<NNNN>.json (see "Session state on disk"). agent6 resume <session-id> rehydrates from loop_state.json, agent6 fork --at-turn N from the matching checkpoint; combined with the per-tool transcripts under transcripts/, any interrupted run can be replayed deterministically up to the model call that comes next.
  • Per-step commits fire when run_verify_command returns 0, via git_ops.py from outside the jail, onto the run's detached chain (refs/agent6/<id>/head, staged through a temp index; branch_per_run also advances a visible agent6/<id> branch). HEAD, your index, and your checkout are never touched, so operator or model git activity mid-run cannot collide with the run's record. Every passing step commits, so a run stays resumable and forkable; how those commits consolidate onto your branch is chosen later at agent6 sessions merge time via git.merge_strategy (squash / merge / ff).
  • DAG-as-scaffold. add_task / update_task / list_tasks write to a curator-owned side store: the worker's task breakdown, with explicit ordering edges via depends_on (on add_task at creation or update_task after; cycle-checked by the curator). They do not pick which tool runs next, but agent6 reads the DAG to keep a small or weak model focused on a long task. Each turn it surfaces the current task -- the cursor when it still points at an open subtask, else the first dependency-satisfied pending subtask -- into the prompt, advances the cursor as tasks pass, and marks the surfaced task in_progress. It also refuses finish_session while the worker's own subtasks are still open (capped, so a task it cannot close cannot stall the run forever). The surfaced banner survives tier-1 elision and is re-injected after each tier-2 restart, so the worker always sees its current task without it being re-appended every turn. If the focus task holds for many turns with no forward motion (a weak model grinding one task without concluding or decomposing it), a nudge offers to split / pass / skip it -- re-firing periodically up to a small cap (a weak model was seen ignoring a single nudge); any progress resets the counter, so a healthy run never sees it.
  • Standing tasks and the park. A run can carry a STANDING task (run --standing "<goal>", or add_task(standing=true)): the frontier's never-passing fallback, worked only when no ordinary subtask is ready -- new work always outranks it. While one exists, the soft out-of-work endings (finish_session, the settled family, a quiet turn) convert into re-entry; faults, operator verbs, the iteration cap, and a spent budget still end the run, and a re-entry with no tool call since the last one is a spin, honoured as the original end. Its sibling for a watched run: on an INTERACTIVE run (run -i / resume -i), a quiet turn parks the same in-memory conversation on the steer bridge instead of ending -- a steer from any composer or the pause menu continues it in place, and abort/undo/detach keep their meanings. One park-instead-of-exit mechanism, two triggers.
  • Context compaction. Long runs are kept inside the model's context window in two tiers (thresholds in [context]): at drop_at_chars the oldest tool_results are replaced by a short placeholder naming the elided call (read_file src/x.py ...), with reads of files the worker edited in the last few turns elided last (a placeholder there would force a paid re-read before the next edit; it is a priority, not an exemption, so the bound holds). A large read_file result decays in two stages: first to a placeholder carrying a distilled gist of the file's load-bearing facts (one batched reviewer-model call per drop event; measured on the longhorizon bench, bare elision of reference docs halves a retention task's score under a small window), then under continued pressure to the bare marker, oldest gists first, so the byte bound always holds. Hot files are never gisted (their content is changing under edits). At summarise_at_chars the elided history is summarised by the reviewer model and the conversation restarts from (task + summary). The curator-owned task DAG survives the restart: agent6 re-surfaces the current task into the fresh context (above), so the worker resumes the right task instead of starting over. At that tier-2 restart agent6 also asks the summariser which tracked tasks the transcript shows finished and what new work it found, then marks the finished ones passed and queues the new ones in the DAG -- so task state stays accurate even though weak models rarely call update_task themselves. Compaction is visible, not silent: the events carry what was elided (call identities, gist paths) and the full restart summary, every conversation view marks elisions and restarts in place, and /status shows the elided/gist counts. The operator steers it with /compact [focus] (pause menu and composers; the focus text is appended to the summariser request) and /pin <text>, a steer the loop records and re-injects verbatim into every tier-2 restart (total pins capped at 4000 chars; an over-cap pin is delivered as an ordinary steer and the refusal is loud). Pins persist in the resume snapshot.
  • Cross-run memory. At run start the loop loads the active entries from the per-repo memory store (<state-dir>/<repo-id>/memories/, written by agent6 memory add or a previous run) and injects them as a size-capped <memories> block after the repo priors; newest entries win when the cap trims. The worker persists new knowledge with add_memory and retires stale entries with invalidate_memory (non-destructive; the body stays on disk for agent6 memory list --all). Run mode only: plan and ask read memories but cannot write them, machine modes see neither. A resumed run keeps the <memories> block frozen in its snapshot, like the rest of its system prompt. Because models never call add_memory unprompted (measured: 46 bench legs, zero calls), the loop surfaces it twice: an advisory when verify first goes green after a red one, and a once-deferred finish_session after such a recovery if nothing was recorded. Each fires at most once per run and never on a run whose verify never failed. agent6 memory pin <id> exempts a load-bearing entry from the block's newest-win trim (never from the byte bound: pins over the cap elide oldest-first, and memory list warns).
  • Notes scratchpad. One durable rewritable document per repo (<state-dir>/<repo-id>/notes.md, 16000 chars), injected verbatim as <notes> and rewritten whole with write_notes; read_notes re-reads it mid-session. agent6 notes show prints it and agent6 notes edit opens it in $EDITOR, so the operator reads and edits the same file the agent does. Where memories are append-only so an invalidation keeps its audit trail, notes are restructured freely -- resolved items deleted, duplicates merged -- which is what keeps a working document readable past twenty sessions. Whole-file replace, not patch. Over the cap the write is refused, not truncated: dropping the tail loses the newest thinking, and the refusal is what forces a prune. The block clips at the same cap on the way in, because the file is the operator's to edit and one written by hand reaches the prompt having passed no check. Never in the workspace, so never in a diff and never mounted into the jail. Same mode rule as memories: machine and agent modes see none. A resumed run keeps the <notes> block frozen in its snapshot like the rest of its system prompt, but read_notes always reads the file, so a later leg can still pick up what it wrote.
  • Skills. At run start the loop resolves operator-installed SKILL.md packs (<data-dir>/skills/, plus [skills].extra_dirs) through the dispatcher's single resolution, so the <skills> system-prompt index and what use_skill serves can never diverge. always-state skills inject their full text; the rest get one index line each and load on demand. Run mode only. Delivery is measured, not assumed: small models never call use_skill from the index alone, so the reliable paths are always, /name in the pause menu, and run --skill (see docs/config.md).
  • finish_session(summary) is the only terminal tool. Calling it emits a session.end event and returns control to the CLI.

Workflow: review

A single read-only pass (src/agent6/workflows/review.py) over a diff (working tree, branch-vs-base, or arbitrary range) using the workflows/code_review.py call shape. Produces structured findings; no edits, no commits, no run_command.

stateDiagram-v2
    [*] --> collect_diff
    collect_diff --> code_review
    code_review --> [*]

Parallel runs: fan-out and coordinator dispatch

Three consumers drive one primitive (a task run as a subordinate isolated run whose branch joins back): run --parallel, the web/TUI composer's /parallel new-work directive (it spawns run --parallel), and a live run's /parallel steer. All three share ONE grammar, src/agent6/directive.py (a pure-stdlib leaf both workflows and ui import): /parallel [spec] <task> [/parallel [spec] <task>]..., where spec is an optional lane count or model list (omitted = one lane) and parse_spec maps a spec to one model per lane -- the same value grammar as run --parallel <spec>. A segment's first token counts as a spec when it contains a comma or a slash (model ids are provider/model shaped); a bare comma-less slash-less name (opus) stays task text, and a task whose first word is a path (src/foo.py) parses as a bogus model spec -- start the task with a verb. Before any clone, a spec's models are checked against the models a lane can actually run: lanes inherit the WORKER provider (only the model is overridden per lane), so the universe is the worker's configured model plus the worker provider's cached listing, in one helper, src/agent6/models/validate.py: an unknown model refuses early with a did-you-mean when the worker provider has a cache to check against, or proceeds with a warning when none does (a fresh/offline machine is never blocked on a regenerable cache). All three consumers validate through this one helper -- the run --parallel CLI preflight, the composer submit paths, and the coordinator's ui-built group dispatcher (so workflows needs no models dependency; a refused dispatch raises and the loop's group-failure feedback carries the message). sessions compare is not one of them; it only reads already-finished runs to rank them, never cloning, importing, or joining. The primitive is pure git plumbing in src/agent6/workflows/subrun.py (no LLM, no UI, no process spawning), over git_ops:

  • clone_workspace(origin, dest): plain git clone of a disposable lane workspace.
  • import_run(origin, lane_repo, branch, lane_session_dir, origin_state): fetches the lane's branch into origin and moves its session dir under <origin_state>/sessions/runs/; refuses to overwrite an existing branch or session dir.
  • join_branch(workspace, branch): merges a branch into the current branch; returns the merged sha, or None after an aborted conflict.
  • LaneSpawner / GroupLaneSpawner Protocols: one lane, or a sibling group, dispatched and awaited to completion.

src/agent6/app/parallel.py is the orchestrator: it implements the spawner Protocols and is the only module that knows how to actually run a lane; the detached spawn it drives (ui.spawn, the same path attach/resume use) is injected as a LaneRuntime by the CLI adapter ui/cli/parallel.py. Lane liveness/stop requests go straight to the run-dir bridge (agent6.sessions.ipc), which agent6.app already depends on. agent6.app is the application layer: pipelines composed over the engine, never importing agent6.ui.

  • agent6 run --parallel N|model-a,model-b (dispatch_parallel / run_parallel): plans one LaneSpec per lane, spawns each as an ordinary detached agent6 run (its own jail and run_commands policy -- see security.md), symlinks each lane's live session dir into <origin_state>/sessions/runs/ as soon as it is located, and polls until every lane is terminal. Every hub (agent6 sessions, the TUI, the web hub) resolves that symlink like any other session dir, so a fan-out is visible live, not just at the end. On completion each lane is imported (import_run) and the symlink is replaced by the real directory; an un-imported lane (failed to start, still running, import refused) keeps its clone and symlink rather than losing the only copy of its work. Imported candidates are auto-compared (workflows/judge.py's structured judge call when a reviewer model is configured, else a mechanical verify-then-cost ranking) into a ranked report with agent6 sessions merge <id> lines. The auto-compare also stamps a compare block (rank/of/winner/ranked_by/rationale/ judge_cost_usd+judge_cost_partial, the judge call's group cost; the fan-out id itself is the manifest's top-level parallel_id) into each imported lane's manifest -- the ONE writer (sessions compare stays stateless, the coordinator never compares its lanes) -- so every run view (sessions show, TUI/web run headers) shows where a lane placed and why, and the listings mark the winner with a . Nothing merges automatically. --max-usd is per lane (total spend up to --max-usd x lane count; the orchestrator prints the $X/lane x N = $Y total line before spawning). The web hub and TUI home new-run composers spawn the same fan-out from a /parallel [spec] <task> directive (parsed by agent6.directive); a multi-segment message spawns one detached run --parallel <spec> per segment (omitted spec = --parallel 1, one isolated lane). A malformed directive is refused before any spawn (all-or-nothing).
  • agent6 sessions compare <id> <id> ... (ui/cli/_compare.py, shared by both callers so the ranking/report logic exists once): the same ranked report over any >=2 existing runs, including different-task runs; degrades to the mechanical table without a reviewer model.
  • Coordinator dispatch: Workflow.lane_spawner: GroupLaneSpawner | None on the agent loop (workflows/loop.py) is the injection point that keeps workflows from importing ui (the Protocol lives in subrun.py, workflows never imports ui/cli/parallel.py directly; tach enforces it). run.py/resume.py build the real spawner (build_coordinator_spawner) and wire it in for a run-mode workflow only. A steer message starting with the exact /parallel token (Ctrl-C pause menu, or any steer surface) dispatches a sibling group: the loop blocks (no provider calls while lanes run), chain-commits a changed worktree first (lanes cut from the chain tip only), expands each segment into its lanes (spec -> one model per lane), then clones + spawns + awaits + imports every lane and merges each branch onto the run's chain sequentially in dispatch order (chain_merge, which also syncs the merged files into the worktree). Like the fan-out, each lane's session dir is symlinked into <origin_state>/sessions/runs/ while it runs, so coordinator lanes appear in the hubs like fan-out lanes and their approvals/asks are answered there. Each SEGMENT (task) gets one DAG node stamped passed (with the last joined sha; its note names every lane) or failed (all lanes failed or conflicted); a conflict aborts that one merge and tells the model to resolve it manually, and the run continues either way. Events loop.parallel.dispatched/joined/failed record the fan-out; the shared transcript fold (viewmodel/transcript.py) renders them as conversation markers on every surface (dispatched: the task count + tasks, truthfully -- lane ids do not exist yet; joined: each lane's id/branch/sha/ status; a dispatch failure: the error), so the coordinator's blocked wait is visible rather than silent. The injected [parallel] summary message is what carries the user-facing outcome to the model and continues the conversation.

Depth 1. Every spawned lane carries AGENT6_SUBRUN=1; both --parallel and build_coordinator_spawner refuse to wire a lane_spawner when it is set, so a lane's own run can never itself fan out or dispatch.

Enforcement layering

security.md details which guarantee each layer provides. As a diagram:

flowchart TD
    LLM[LLM choice of tool] --> Tools[tools/dispatch.py]
    Tools -->|apply_edit, apply_patch, read, list, outline| FS[(workspace fs)]
    Tools -->|run_verify_command, run_metric_command, run_command| Jail[agent6-jail]
    Jail --> NS[user/mount/pid/ipc/uts/net NS]
    Jail --> Pivot[pivot_root into minimal rootfs]
    Jail --> ROBinds[strict only: RO bind .git]
    Jail --> Land[Landlock FS rules]
    Jail --> Sec[seccomp filter]
    Jail --> Caps[NO_NEW_PRIVS]
    Land -.-> Child[child process]
    Sec -.-> Child
    ROBinds -.-> Child
    Caps -.-> Child
    Workflow[workflow git_ops.py] -->|outside jail| Git[(.git)]
    Workflow -. blocks .-> Push[push / --force / reset --hard]
  • git_ops.py runs outside the jail (the agent's own process), so the RO bind of .git does not stop the workflow from committing. It stops the worker.
  • protect_git is strict-only. On strict the jail read-only bind-remounts .git on top of the workspace mount. The hardened isolation (no mount namespace to carve with) grants blanket read-write on the repo cwd, so .git is writable by jailed commands there. Carving .git read-only on hardened would also deny new top-level entries and break toolchains like cargo/pytest that create target/ or .pytest_cache/. The writable .git on hardened is acceptable: it is gated by run_commands (default ask), recoverable (branch-per-run, commits go through git_ops), and the surrounding container is the blast radius.
  • Run state is safe from jailed commands because it lives out of the workspace (<state-dir>/<repo-id>/), unreachable from the repo cwd that jailed commands run on.

Curator

The task graph is owned by an in-process GraphCurator (graph/curator.py). The agent constructs one per run; the worker / planner / critic / alignment-guard roles are in-process in the one loop and mutate through it. The same process writes the rest of the run state (resume snapshot, event log, transcripts).

flowchart LR
    Agent[agent6 run<br/>main process] -->|in-process GraphCurator| Graph[(graph.jsonl, graph/*.md, cursor.json)]
    Agent -->|in-process| Rest[(loop_state.json, logs.jsonl, transcripts)]

Every mutation is validated against a pydantic schema before it applies, so the on-disk graph stays consistent. What keeps the whole session directory safe from jailed commands is its location: it lives out of the workspace (<state-dir>/<repo-id>/), unreachable from the repo cwd that jailed commands run on.

One curator per run is an invariant: two live curators on one session dir cache the graph independently, so a second one's write silently drops the first's parent→child links. agent6 run/resume/fork therefore take a run-level single-writer flock on <run-dir>/worker.lock (the analogue of machine.lock); a second process on the same run refuses. A crashed writer releases the lock on death, so resume-after-crash is never blocked. The curator additionally holds a per-mutation flock on the session dir, guarding the files against a concurrent operator-CLI read/write. A write-path fault (ENOSPC, a serialization error) after the in-memory update reloads the graph from disk before surfacing, so a later read never observes a node that was never persisted.

One live run-mode worker per CHECKOUT is the level above (sessions/lock.py, a repo-wide flock on <state-dir>/repo.lock): run-mode workers share one working tree, so a second one would interleave the two runs' edits into each other's chain commits. A second agent6 run refuses loudly and PARKS the submitted task verbatim in a new run's manifest (parked_task; listings show "parked"); the refusal prints the two follow-ups -- agent6 resume <id> once the checkout is free (resume runs the parked task as written), or a /parallel 1 <task> steer that hands it to the live run as an isolated lane. Plan/ask expose no edit tools and spawn freely; --parallel lanes work in isolated workdirs under the coordinator's one lock.

Session state on disk

Each session's directory <state-dir>/<repo-id>/sessions/<bucket>/<session-id>/ holds (the bucket is the mode plus s: runs/, plans/, asks/, machines/ for machine create authoring):

  • graph.jsonl: append-only journal of every task-graph mutation (curator-owned).
  • graph/*.md: one markdown file per task node, rewritten atomically (curator-owned).
  • logs.jsonl: the structured event stream (below), written by the main process.
  • loop_state.json: the latest resume snapshot that drives agent6 resume, written by the main process before each LLM call and at iteration end.
  • checkpoints/<NNNN>.json: append-only per-turn snapshots (NNNN = zero-padded next_iteration), each a byte-identical copy of loop_state.json at that turn (the snapshot payload already carries the workspace head_sha and curator graph_version). loop_state.json is the latest-pointer for resume; checkpoints/ is the per-turn history. agent6 fork --at-turn N rolls a run back to turn N by cloning the matching checkpoint into a new run. Kept in full (a run is dozens of turns); written by the main process alongside loop_state.json.
  • plan.md (plan sessions): the plan itself. finish_planning is its only writer, agent6 plan edit its only editor. The planner re-reads it before every turn and is shown it whenever it differs from what it last saw, so answers the operator wrote there survive the next finish_planning instead of being overwritten by the planner's own older copy. agent6 run --from-plan feeds it as a new run's task.
  • transcripts/: full provider request/response pairs for replay, written by the main process.

A fork (agent6 fork <src>) clones a source run's state, as of a checkpoint, into a NEW session dir with a new id: it copies the checkpoint as the new run's loop_state.json + seed checkpoints/0000.json, REBUILDS the curator DAG (graph/, graph.jsonl, cursor.json) at the checkpoint's graph_version, writes a manifest with parent_session_id / forked_from_turn / forked_from_sha, and cuts agent6/<new> at the turn's sha (additive git branch, the operator's checkout is untouched). The source run is never mutated. One fork edge per line lands in a per-repo lineage.jsonl at the state-dir root.

The rebuild (graph/replay.py) undoes every journal-stamped mutation newer than that version, so a fork's tasks, statuses, cursor, and journal match the turn its conversation came from -- no future subtasks, no passed for work its tree does not contain. Node content the journal never records (title, rationale, acceptance, paths) is immutable after creation and comes from the current nodes; notes and updated_at cannot be unwound and stay current (display-only). A checkpoint with graph_version: 0 (written before the stamp, or with the curator unreadable) has no version to rebuild at, so its fork copies the DAG verbatim.

The fork's tree is the repo as of that committed sha, nothing more. On a gated run (commits fire only on a green verify) an edit not yet committed at the forked turn is absent from the fork's tree even though the copied transcript mentions it, the same "head_sha is committed history only" posture resume documents (resume differs only in continuing on the live working tree). The forked run picks it back up by re-reading the real on-disk files. Modelling a fork as a commit plus the conversation up to that turn is the design choice: predictable and cheap, versus snapshotting uncommitted bytes into every checkpoint.

One headless core, four thin front-ends: the CLI, the Textual TUI (src/agent6/ui/tui/), the browser web UI (src/agent6/ui/web/, agent6 web), and the ACP agent an editor drives (src/agent6/ui/acp/, agent6 acp, see editor integration) all fold the same event stream and render their own way. Two shared layers sit under all four: the read side src/agent6/viewmodel/ (the SessionState/MachineState fold + its *_as_dict wire form, exactly what agent6 attach --json and the web JSON/SSE endpoints emit) and the textual-free write side: src/agent6/ui/spawn.py spawns the CLI detached, and src/agent6/sessions/ipc.py holds the approval / question / steer / compact-request file contract the workflow process polls. See the web UI.

The journal is durable by contract: an append failure on anything but the streaming deltas (role.*_delta, best-effort by design) stops the run loudly (EventWriteError) rather than letting it run on with an unrecordable outcome, and in-process listeners see an event only after its write landed.

The logs.jsonl vocabulary is small and stable: the data contract for any external viewer (the fold to render-ready state lives in src/agent6/viewmodel/state.py as a pure function, shared by the CLI, the TUI, and the web client):

Event Notable fields
session.start user_task
tool.call / .result name, args (preview), ok, summary; emitted as a pair for every dispatched tool, including ones a guard rejects (ok=false, trusted reason), so no call is unaccounted for. Execution tools (run_command/run_metric_command) also carry capped stdout_tail/stderr_tail like verify.end
verify.start / .end cmd, exit_code, duration_s, *_tail
loop.verify_inferred command (argv, [] if none), source (agents_md/manifest/llm/none); adopted_at (iteration) when a gateless run adopts one mid-run
role.call / .result role, model, tokens_in, tokens_out
role.text_delta streamed assistant text chunk
role.thinking_delta streamed reasoning chunk (TUI "thinking" pane)
session.steer_requested source ("sigint"): mid-run Ctrl-C
budget.update totals + caps for input/output tokens
approval.prompt/.answer id, prompt, standing (is an "allow all" on offer) / id, approved, source (frontend/stdin/session/away-deny/await-frontend/headless)
question.prompt/.answer id, question, options / id, answer, source: the ask_user tool and machine questioner states
loop.* agent progress: loop.auto_commit, loop.compact.*, loop.critic.*, loop.metric.*, loop.steer.*
loop.budget per-iteration usage heartbeat: iteration, input_tokens, output_tokens, cache_read_tokens, cost_usd (read by agent6 sessions show)
loop.review.* adversarial review panel: loop.review.start (trigger, seats), loop.review.seat (seat, model, verdict, findings), loop.review.panel (blocked, raw_blocked, decision, n_block, disarmed), loop.review.skipped
session.end reason, iterations, all_passed; one shape from every exit path (loop, machine-create, interrupt fallback)

A run_command approval is published as approval.prompt; the dashboard TUI shows an Allow/Deny modal and writes the operator's literal choice (yes, no, session, session-deny) to approvals/<id>.answer, which the workflow reads, then records approval.answer. What a choice GRANTS is the asking side's: each prompt names the SCOPE an "allow all" would cover (command, or one MCP server), a standing answer is recorded per scope, and a gate that offers none (fetch) sets standing: false so no front-end shows the button. The answer poll falls back headless (stdin prompt, or deny for a machine state) only after the front-end has stayed dead for 30 consecutive seconds, so a transient drop (a page reload, a phone locking its browser) does not convert a pending approval into a deny. The web UI drives the same answer-file contract (via src/agent6/sessions/ipc.py): while a browser watches a run it registers as the run's answer front-end, so approval / question / steer prompts bridge to the page. The task DAG is not in this stream; it is curator-owned and lives in graph.jsonl (read via agent6 sessions graph).

Where things live

Concern File / dir
Config schema src/agent6/config/model.py
Tool surface src/agent6/tools/schema.py
Tool dispatch src/agent6/tools/dispatch.py
agent loop src/agent6/workflows/loop.py
Prompt/template text src/agent6/prompts/ (pure strings; loop/review/judge/machine assemble them)
Review workflow src/agent6/workflows/review.py
Code-review call shape src/agent6/workflows/code_review.py
Jail launcher (Python wrapper) src/agent6/sandbox/jail.py
Jail launcher (Rust binary) src/agent6/jail/src/main.rs
Git policy src/agent6/git_ops.py
Subordinate-run primitive (clone/import/join) src/agent6/workflows/subrun.py
Run-dir single-writer lock src/agent6/sessions/lock.py (the worker.lock flock; see "Curator")
Compare judge (structured ranking) src/agent6/workflows/judge.py
Fan-out orchestrator (run --parallel, coordinator spawner) src/agent6/app/parallel.py (pipeline), src/agent6/ui/cli/parallel.py (CLI adapter)
Provider clients src/agent6/providers/
Knowledge graph (curator) src/agent6/graph/
Event log + view-model fold src/agent6/events.py (writer), src/agent6/viewmodel/ (SessionState/MachineState fold), src/agent6/ui/tui/ (textual render)
Front-end write bridge src/agent6/ui/spawn.py (spawn detached) + src/agent6/ui/notify.py (desktop notify), src/agent6/sessions/ipc.py (approval/question/steer/compact file contract); shared by CLI, TUI, web
Web UI (agent6 web) src/agent6/ui/web/ (stdlib HTTP server + one embedded page over the view-model + frontend)
Cross-run memory store src/agent6/memory.py (store), <state-dir>/<repo-id>/memories/ (data)
Notes scratchpad src/agent6/notes.py (store), <state-dir>/<repo-id>/notes.md (data)
Session state on disk <state-dir>/<repo-id>/sessions/<bucket>/<session-id>/ (out of the workspace)

Pre-1.0 stability

See AGENTS.md. Until 1.0 every public shape (config TOML, IPC frames, on-disk graph, CLI flags, transcript layout) is liquid; we break cleanly rather than carry shims.