Runtime Flow

This guide documents Agentty's runtime data flows end to end: the foreground event loop, reducer/event buses, session-worker turn execution, merge/rebase/sync orchestration, and every background task with trigger points and side effects.

Architecture Goals🔗

Agentty runtime design is built around these constraints:

  • Keep domain logic independent from infrastructure and UI.
  • Keep long-running or external operations behind trait boundaries for testability.
  • Keep runtime event handling responsive by offloading background work to async tasks.
  • Keep AI-session changes isolated in git worktrees and reviewable as diffs.
  • Decouple agent transport (CLI subprocess vs app-server RPC) behind a unified channel abstraction.

Workspace Map🔗

| Path | Responsibility | |------|----------------| | crates/ag-forge/ | Shared forge review-request library crate for normalized review-request types, GitHub/GitLab remote detection, and gh/glab adapter orchestration. | | crates/agentty/ | Main TUI application crate (agentty) with runtime, app orchestration, domain, infrastructure, and UI modules. | | crates/ag-xtask/ | Workspace maintenance commands (migration checks, workspace-map generation, automation helpers). | | docs/site/content/docs/ | End-user and contributor documentation published at /docs/. |

Main Runtime Flow🔗

Primary foreground path from process start to one event-loop cycle:

flowchart TD
  main["main.rs"]
  db["Database::open()<br/>sqlite open + WAL + foreign keys + migrations"]
  app_new["App::new()"]
  scan["Startup-only home-directory project scan<br/>then project/session snapshot load"]
  fail_ops["Fail unfinished operations from previous run"]
  background["Spawn app background tasks"]
  runtime["runtime::run(&mut app)"]
  terminal["terminal::setup_terminal()"]
  event_reader["event::spawn_event_reader()<br/>dedicated OS thread"]
  main_loop["run_main_loop()"]
  drain["process_pending_app_events()<br/>reduce queued AppEvent values"]
  draw["ui::render::draw()"]
  process["event::process_events()"]
  key_events["Key events<br/>mode handlers -> app/session orchestration"]
  app_events["App events<br/>App::apply_app_events reducer"]
  tick["Tick<br/>refresh_sessions_if_needed safety poll"]

  main --> db
  main --> app_new
  app_new --> scan
  app_new --> fail_ops
  app_new --> background
  main --> runtime
  runtime --> terminal
  runtime --> event_reader
  runtime --> main_loop
  main_loop --> drain
  main_loop --> draw
  main_loop --> process
  process --> key_events
  process --> app_events
  process --> tick

Foreground loop details:

  • run_main_loop() drains queued app events before draw so touched sessions sync from their live handles without a full list-wide sweep every frame.
  • process_events() waits on terminal events, app events, or tick (tokio::select!).
  • After one event, it drains queued terminal events immediately to avoid one-key-per-frame lag.
  • Tick interval is 50ms; metadata-based session reload fallback is 5s (SESSION_REFRESH_INTERVAL).

Data Channels🔗

Agentty uses four primary runtime data channels:

Terminal Event channel (runtime/event.rs)🔗

  • Producer(s): Event-reader thread
  • Consumer(s): runtime::process_events()
  • Payload: crossterm::Event
  • Purpose: User input and terminal events.

App event bus (AppEvent)🔗

  • Producer(s): App background tasks, workers, and task helpers
  • Consumer(s): App::apply_app_events() reducer
  • Payload: AppEvent variants
  • Purpose: Safe cross-task app-state mutation.

Turn event stream (TurnEvent)🔗

  • Producer(s): AgentChannel implementations
  • Consumer(s): Session worker consume_turn_events()
  • Payload: Loader-thought and pid updates
  • Purpose: Transient loader updates and PID updates while final transcript output waits for the completed turn result.

Session handles (SessionHandles)🔗

  • Producer(s): Workers and session task helpers
  • Consumer(s): App::apply_app_events() via SessionUpdated-driven sync_session_from_handle() calls
  • Payload: Shared Arc<Mutex<...>> output, status, pid handles, and the in-memory queued_messages deque
  • Purpose: Fast targeted snapshot sync without a full DB reload. Handles are the single source of truth for output, status, and the chat queue; sync_session_with_handles() re-projects all three into the snapshot, so queue mutations (lifecycle enqueue, worker drain between turns, runtime LIFO pop) only need to touch the handle and emit SessionUpdated.

App Event Reducer Flow🔗

App::apply_app_events() is the single reducer path for async app events.

Flow:

  1. Drain queued events (first_event + try_recv loop).
  2. Reduce into AppEventBatch (coalesces refresh, git status, model, and loader-thinking updates).
  3. Apply side effects in stable order.

Reducer behaviors that matter for data flow:

  • RefreshSessions sets should_force_reload, which triggers refresh_sessions_now() and reload_projects().
  • reload_projects() now reloads only persisted project rows; the expensive home-directory repository discovery pass runs only during App::new().
  • BranchPublishActionCompleted swaps the session-view popup from loading to success or blocked/failure copy after the session-view p review-request publish flow finishes.
  • ReviewRequestStatusUpdated persists refreshed forge summaries for review-ready sessions and silently transitions externally merged sessions to Done or externally closed sessions to Canceled.
  • SessionUpdated marks touched sessions so reducer can call sync_session_from_handle() selectively.
  • SessionProgressUpdated refreshes transient loader text used by the session view.
  • AgentResponseReceived routes question-mode transitions for active view sessions and applies the worker's reducer-ready turn projection (summary, questions, token deltas) to the currently loaded session.
  • PublishedBranchSyncUpdated tracks detached post-turn auto-push state for already-published session branches so stale background completions do not overwrite newer sync attempts in the active session view.
  • After touched-session sync, terminal statuses (Done, Canceled) drop per-session worker senders so workers can shut down runtimes.

Session Chat Rendering Flow🔗

The session chat panel is rendered by crates/agentty/src/ui/page/session_chat.rs and crates/agentty/src/ui/component/session_output.rs. The page chooses which session is visible and which auxiliary view state applies; the component turns that state into the exact lines printed inside the bordered output panel.

Data Origins🔗

The printed session-chat data comes from these sources:

  • session.output Loaded with the session row in crates/agentty/src/app/session/workflow/load.rs, then kept hot from the per-session handle via crates/agentty/src/app/session_state.rs. Runtime workers append new transcript text through SessionTaskService::append_session_output() in crates/agentty/src/app/session/workflow/task.rs, which updates both the in-memory handle buffer and the persisted database row.
  • active_prompt_output Cached by SessionManager::set_active_prompt_output() in crates/agentty/src/app/session/core.rs when a start or reply prompt is submitted. This stores the exact prompt-shaped transcript block that was just appended to session.output so SessionOutput can split the transcript into completed-turn content and the currently active turn without reparsing generic prompt-looking lines from assistant output.
  • session.summary Persisted by the turn worker in crates/agentty/src/app/session/workflow/worker.rs as the raw protocol summary payload. App::apply_agent_response_received() in crates/agentty/src/app/core.rs now applies that same raw payload to the in-memory session snapshot immediately, and crates/agentty/src/ui/component/session_output.rs renders the synthetic summary block from session.summary instead of storing a second markdown copy inside session.output. For merged/done flows, merge helpers can rewrite the stored value into a display-oriented markdown form before the next reload.
  • session.questions Persisted by the worker alongside the latest turn metadata and applied immediately by the reducer, but these items do not render inside SessionOutput. Instead they drive AppMode::Question, where the bottom question panel renders the current question and its options/input while the transcript panel remains visible above it.
  • review_status_message and review_text Stored on AppMode::View and its restore-view variants, with successful focused review text also persisted on the session row as the last focused-review cache entry. Review mode is opened from crates/agentty/src/runtime/mode/session_view.rs, which either reuses a cached review, shows a loading message, or starts a review-assist task. That task emits ReviewPrepared / ReviewPreparationFailed, and App::apply_review_update() in crates/agentty/src/app/core/events.rs writes the resulting text or error/status message back into the active view mode while the reducer persists successful text for restart hydration.
  • active_progress Sourced from App::session_progress_message() in crates/agentty/src/app/core.rs. Session task helpers emit AppEvent::SessionProgressUpdated from crates/agentty/src/app/session/workflow/task.rs, the reducer batches those updates, and session view mode reads the latest message before rendering.

Output Assembly Diagram🔗

SessionOutput does not render one flat stored message list. Instead it assembles the panel from the persisted transcript plus synthetic metadata and transient view state:

flowchart TD
  render["SessionChatPage render_session()"]
  lines["SessionOutput output_lines()"]
  render --> lines

  base["Choose base body text"]
  lines --> base
  base --> draft["Draft preview<br/>(draft-status session)"]
  base --> transcript["session.output<br/>(all other statuses)"]

  split["Split transcript with active_prompt_output"]
  completed["Completed-turn text"]
  active["Active-turn text"]
  lines --> split
  split --> completed
  split --> active

  completed --> spacing["Normalize prompt spacing"]
  spacing --> footer_split["Peel trailing workflow notice block"]
  footer_split --> footer_kind["Commit, assist, or error notice"]
  footer_split --> completed_md["Render completed-turn markdown"]

  completed_md --> summary["Append synthetic session.summary block"]
  summary --> footer["Append trailing workflow notices"]
  footer --> active_prompt["Append active-turn prompt block"]
  active_prompt --> review["Append focused review markdown<br/>(review_text)"]
  review --> branch_sync["Append published-branch sync row<br/>(auto-push started, completed, or failed)"]
  branch_sync --> final_row["Append final status row<br/>or done continuation hint"]

This means session.output stays the durable transcript, while summary and focused review are layered on during render instead of being appended back into that transcript string.

App owns one shared MarkdownRenderCache plus one shared SessionOutputLayoutCache and threads them through RenderContext, the router, overlay restore state, and SessionChatPage. The markdown cache deduplicates rendered markdown blocks, while the layout cache deduplicates the fully assembled Line list and line count for matching session id/update version, width, active prompt, review text/status, progress text, and markdown style version. Changes in this area should keep caches bounded and avoid introducing separate layout-only render passes that bypass the shared cache.

Render Path🔗

The exact session-chat render path is:

  1. crates/agentty/src/runtime/mode/session_view.rs calculates the visible output height by calling SessionChatPage::rendered_output_line_count(...) with the selected Session, review_status_message, review_text, and the latest active_progress.
  2. crates/agentty/src/ui/page/session_chat.rs builds SessionOutput inside render_session(), forwarding the same render inputs plus scroll offset.
  3. SessionChatPage::render_session_header() prints the single-line session header above the bordered output region.
  4. SessionOutput::output_text() in crates/agentty/src/ui/component/session_output.rs selects the base text: staged draft preview for draft-status sessions, otherwise session.output.
  5. SessionOutput::output_lines() converts that source text into final panel lines: it optionally splits the transcript using active_prompt_output, normalizes prompt spacing, splits any trailing workflow notices, renders the completed-turn markdown, appends the synthetic summary block from session.summary when the current status and prompt state allow it, reattaches the trailing workflow notices, appends the active prompt block, appends focused review markdown from review_text, appends the published-branch sync row when a detached auto-push starts, completes, or fails, and finally adds the loader row or done continuation hint when the current status requires it.
  6. SessionOutput::render() writes the final Line list into a ratatui Paragraph, then applies a Tachyonfx pulse over the active loader glyph cells when the status row is visible in the session chat output area.

The session output panel shows different data at different lifecycle points:

flowchart TD
  submit["Start or reply submitted"]
  submit --> append_prompt["Append prompt block to session.output"]
  submit --> cache_prompt["Cache same block as active_prompt_output"]
  submit --> progress["Render loader row from active_progress"]

  turn_done["Turn completes"]
  append_prompt --> turn_done
  cache_prompt --> turn_done
  progress --> turn_done

  turn_done --> answer["Append assistant transcript chunk to session.output"]
  answer --> answer_kind["Protocol answer<br/>or joined clarification-question text"]
  turn_done --> metadata["Persist and apply latest-turn metadata"]
  metadata --> summary_meta["session.summary"]
  metadata --> question_meta["session.questions"]
  turn_done --> clear_active["Drop active_prompt_output"]
  turn_done --> next_status["Move session to Review or Question"]

  next_status --> review_run["Focused review runs"]
  review_run --> review_loader["Render AgentReview loader row<br/>from review_status_message"]
  review_run --> review_text["Append review_text<br/>after transcript content"]

  question_meta --> clarifications["Clarification answers submitted"]
  clarifications --> clarification_prompt["Runtime builds one normal reply prompt<br/>starting with Clarifications:"]
  question_meta --> end_turn["Esc ends clarification turn<br/>and restores Review without a reply"]

What Prints, When It Prints, and When It Stops Showing🔗

Use the artifact-by-artifact reference below instead of a wide comparison table. Each item keeps the same four questions grouped vertically so the page stays readable on narrow screens.

User prompt blocks🔗

  • Comes from: session.output and active_prompt_output
  • Prints: immediately after start or reply submission. The same block stays in the transcript after the turn finishes.
  • Hidden or removed: the transcript entry is durable. Only the transient active_prompt_output cache is removed after turn metadata is applied or when the session is no longer active.

Assistant answer🔗

  • Comes from: session.output
  • Prints: after a successful turn when protocol answer is non-empty.
  • Hidden or removed: durable transcript entry; not removed by SessionOutput.

Clarification question text from the assistant🔗

  • Comes from: session.output fallback when no answer exists
  • Prints: after a successful turn with questions but without top-level answer text.
  • Hidden or removed: durable transcript entry once appended. Pending structured session.questions continue separately in question mode until answered.

Structured clarification questions🔗

  • Comes from: session.questions
  • Prints: in AppMode::Question, inside the bottom question panel, not inside SessionOutput.
  • Hidden or removed: cleared when the resumed turn starts. The output panel never renders these as synthetic transcript rows.

Clarification answers🔗

  • Comes from: a new reply prompt built by runtime and appended into session.output
  • Prints: when the user finishes all questions and submits the generated Clarifications: reply turn.
  • Hidden or removed: durable transcript entry. SessionOutput only adjusts spacing between numbered question groups for readability. If the user ends question mode with Esc, no clarification reply is built and the session returns to Review.

Summary block🔗

  • Comes from: session.summary
  • Prints: appended after the completed agent-turn transcript and before later trailing workflow notices for most statuses.
  • Hidden or removed: hidden for Canceled sessions and while a newer prompt is active, so stale change metadata disappears as soon as the user posts the next prompt.

Trailing workflow notices🔗

  • Comes from: trailing paragraphs in session.output that begin with known workflow labels such as [Commit], [Commit Error], [Commit Assist], [Rebase Assist], [Rebase Error], or other bracketed workflow errors. Producers and the renderer share these labels through TranscriptNotice in crates/agentty/src/domain/transcript_notice.rs.
  • Prints: reattached after the synthetic summary so the summary stays at the completed agent-turn boundary and later workflow notices remain below it in transcript order.
  • Hidden or removed: durable transcript content; only moved later in render order.

Focused review loader🔗

  • Comes from: review_status_message with Status::AgentReview
  • Prints: while review assist is running or when the latest review attempt failed.
  • Hidden or removed: removed when a review result arrives, when session view leaves review state, or when a new reply clears the review cache.

Focused review text🔗

  • Comes from: review_text from review cache or view state; successful focused reviews are also saved as the session's persisted focused-review cache entry and hydrated into App.review_cache on startup.
  • Prints: appended after transcript content once review assist succeeds.
  • Hidden or removed: cleared from memory and persistence when a new reply starts, when the session returns to InProgress, when a replacement review starts, or when a later review result fails and replaces it with an error status message.

In-progress loader🔗

  • Comes from: active_progress
  • Prints: while a turn is running in InProgress; the loader glyph is painted as stable text and animated by a Tachyonfx buffer effect after paragraph rendering.
  • Related loaders: shared spinner call sites render the same ▌▌▌ glyph and apply the reusable Tachyon loader effect where their glyph area is known.
  • Hidden or removed: removed when the turn finishes or the session leaves an active status.

Mode Rules🔗

  • Status::Done Keeps transcript output as the primary body, appends the synthetic summary section, and ends with the continuation hint.
  • Status::Question Keeps the transcript panel visible above, while the question/options/input UI is rendered in the bottom panel rather than inside SessionOutput.
  • Status::Canceled Uses raw transcript only. Synthetic summary rendering is intentionally suppressed so interrupted sessions do not show finalized metadata they never completed.

Session Turn Data Flow🔗

From prompt submit to persisted result:

  1. Prompt mode converts a submit key into an app-layer prompt intent.
  2. App::handle_prompt_submit_intent() drains normal prompt submissions or dispatches slash-command selections through app-owned intent handlers such as /apply, /qe:check, and /stats.
  3. start_session() for first prompt (AgentRequestKind::SessionStart) or reply() for follow-up (AgentRequestKind::SessionResume).
  4. Shared prompt-composer helpers in crates/agentty/src/domain/composer.rs derive slash-menu options and attachment-aware deletion ranges. App-layer prompt intent handlers drain the final prompt submission payload. The transcript keeps raw @path lookups, while the later agent-facing prompt text quotes the repository-relative path without adding transport sentinels.
  5. Session command is persisted in session_operation before enqueue.
  6. SessionWorkerService lazily creates or reuses a per-session worker queue.
  7. Worker marks operation running, checks cancel flags, then runs channel turn.
  8. Worker creates TurnRequest (reasoning level, model, prompt, request_kind, replay output, provider conversation id).
  9. Worker spawns consume_turn_events().
  10. AgentChannel::run_turn() streams TurnEvent values and returns TurnResult.
  11. Worker applies final result:
  12. Append final assistant transcript output when no assistant chunks were already streamed (answer text, fallback question text).
  13. TurnPersistence::apply(...) transactionally stores the canonical summary payload, question payload, token-usage deltas, and provider conversation markers, then returns TurnAppliedState.
  14. Emit AppEvent::AgentResponseReceived with that reducer projection so the active session updates without a forced reload.
  15. If canonical metadata persistence fails, append a recovery error to the transcript, trigger RefreshSessions, and skip reducer projection emission so the UI falls back to durable state on reload.
  16. Run auto-commit assistance path, which preserves a single evolving commit on the session branch: the first successful file-changing turn creates the commit, later turns regenerate the message from the cumulative diff with the active project's Default Fast Model, an empty-amend result drops the reverted session commit and reports a no-change notice, auto-commit recovery prompts use that same fast-model selection, and the session title is synced from the rewritten commit after success while the structured response summary payload remains unchanged.
  17. When the auto-commit produces a new commit, the session already tracks a published upstream branch, and no inline chat messages are queued behind the completed turn, start the detached auto-push task. Queued follow-up turns suppress this launch until the queue drains and the latest queued turn completes. After that push succeeds, linked open review requests load the current remote title/body metadata through ReviewRequestClient, update the PR/MR only if the latest commit message differs, and persist the refreshed review-request summary. If the push fails, the linked review request is left unchanged so its stored summary still matches the remote branch state.
  18. Refresh persisted session size.
  19. Update final status (Review or Question; on failure -> Review).

Operation Lifecycle and Recovery🔗

Turn execution is durable and restart-safe:

  • Before enqueue: insert session_operation row (queued).
  • Worker transitions: queued -> running -> done/failed/canceled.
  • Cancel requests are persisted and checked before command execution.
  • On startup, unfinished operations are failed with reason Interrupted by app restart, and impacted sessions are reset to Review.

Status Transition Rules🔗

Runtime status transitions enforced by Status::can_transition_to() or explicit cancellation paths:

  • Draft -> InProgress (first prompt)
  • Draft session in Draft status -> Canceled (list-mode cancel before first turn)
  • Review/Question -> InProgress (reply)
  • Review -> Queued -> Merging -> Done (merge queue path)
  • Review -> Rebasing -> Review/Question (rebase path)
  • Review/Question -> Canceled
  • InProgress -> Review (user stops the current turn)
  • InProgress -> Canceled (list-mode cancel stops the running turn)
  • InProgress/Rebasing -> Review/Question (post-turn or post-rebase)

Agent Channel Architecture🔗

Session workers are transport-agnostic through AgentChannel:

flowchart TD
  worker["app/session/workflow/worker.rs"]
  factory["create_agent_channel(kind, override)"]
  provider["Provider registry<br/>infra/agent/provider.rs"]
  cli_mode["transport_mode() -> Cli"]
  cli_channel["CliAgentChannel<br/>Antigravity/Claude; subprocess per turn"]
  app_server_mode["transport_mode() -> AppServer"]
  app_server_client["create_app_server_client()"]
  app_server_channel["AppServerAgentChannel<br/>Codex/Gemini; persistent runtime per session"]
  client_trait["AppServerClient"]
  codex_client["RealCodexAppServerClient"]
  gemini_client["RealGeminiAcpClient"]

  worker --> factory
  factory --> provider
  provider --> cli_mode
  cli_mode --> cli_channel
  provider --> app_server_mode
  app_server_mode --> app_server_client
  app_server_mode --> app_server_channel
  app_server_channel --> client_trait
  client_trait --> codex_client
  client_trait --> gemini_client

Key types (infra/channel/contract.rs, re-exported by infra/channel.rs, with prompt payloads owned by domain/turn_prompt.rs):

| Type | Purpose | |------|---------| | TurnRequest | Input payload: reasoning_level, folder, live_session_output, model, request_kind, prompt, and provider_conversation_id. | | TurnEvent | Incremental stream events: ThoughtDelta, Completed, Failed, PidUpdate. | | TurnResult | Normalized output: assistant_message, token counts, provider_conversation_id. | | AgentRequestKind | SessionStart, SessionResume (with optional session output replay), or UtilityPrompt. |

Provider conversation id flow:

  • App-server providers return provider_conversation_id in TurnResult.
  • Worker persists it to DB (update_session_provider_conversation_id).
  • Worker also persists the matching instruction-bootstrap marker so app-server follow-up turns know whether the active provider context already received Agentty's full prompt contract.
  • Future TurnRequest loads and forwards both values so runtime restarts can resume native provider context and decide between a full bootstrap and a compact reminder.

Session isolation guards:

  • Before every worker-dispatched turn, worker.rs calls workflow/isolation.rs to verify the session folder exists, is checked out on the expected wt/<session-id-prefix> branch, and resolves to a linked worktree with a distinct main checkout.
  • The worker captures git status --porcelain=v1 --untracked-files=no for that main checkout before provider execution and compares it again after a successful turn. A changed tracked-file snapshot appends a [Main Checkout Warning] transcript notice while allowing the session turn to persist normally.
  • Merge and sync main workflows perform clean-check preflights on the target checkout before changing base-branch state, so dirty main-checkout state blocks only the workflows that actually depend on it.
  • Codex app-server permissions are scoped in app_server/codex/policy.rs: turns use the non-interactive never approval policy plus a workspace-write sandbox. If Codex still emits pre-action requests, command approvals are accepted under that sandbox and file-change approvals are accepted only for paths inside the session folder.
  • Gemini ACP permission requests prefer explicit one-shot allow options in app_server/gemini/policy.rs when Gemini offers them, allowing file-changing tools to proceed inside the session worktree without granting durable session-wide approval. Antigravity and Claude subprocess turns run from the session worktree process directory and rely on the main-checkout tracked-file guard for isolation.
  • Antigravity command construction passes the session worktree as the first agy --add-dir root, because Antigravity print mode uses ordered explicit workspace roots for file tools instead of treating the process directory as an editable workspace.
  • Antigravity setup and command construction add .antigravitycli/ and cache/projects.json to the repository-local git exclude file before agy starts, keeping Antigravity's project configuration state out of session diffs without changing tracked ignore files.

Agent Interaction Protocol Flow🔗

Provider output is normalized to one structured response protocol:

  1. Prompt builders choose among BootstrapFull, DeltaOnly, and BootstrapWithReplay. CLI turns still use the full shared protocol preamble each turn, while persistent app-server turns reuse a compact reminder when the active provider context already matches the stored instruction bootstrap marker. crates/agentty/src/infra/agent/template/protocol_instruction_prompt.md owns the normal request wrapper, crates/agentty/src/infra/agent/template/protocol_refresh_prompt.md owns the compact reminder wrapper, the sibling profile-specific markdown templates supply the request-family instruction text, crates/agentty/src/infra/agent/prompt.rs owns shared prompt preparation, and crates/agentty/src/infra/agent/protocol.rs routes to the authoritative protocol model/schema/parse submodules.
  2. BootstrapFull and BootstrapWithReplay still prepend the same self-descriptive schemars document, so every provider sees the same answer/questions/optional-summary schema and transport-enforced outputSchema paths can normalize that same contract separately.
  3. The caller selects one canonical AgentRequestKind before transport handoff, and the transport derives the matching ProtocolRequestProfile from it. Session turns use SessionStart or SessionResume, while isolated utility prompts use UtilityPrompt.
  4. Session discussion turns typically populate summary.turn and summary.session, while one-shot prompts may leave summary unused.
  5. Channels emit transient loader updates as TurnEvent::ThoughtDelta values when providers surface thought or tool-status text during the turn.
  6. Final output is parsed to protocol answer, questions, and the optional structured summary. The final assistant payload itself must match the shared protocol JSON object, while direct deserialization into the shared wire type still accepts summary-only or otherwise defaulted top-level fields. If a provider prepends prose before one final schema object, parsing now recovers that trailing payload as long as nothing except whitespace follows it. Rejected payloads now surface parse diagnostics including response sizing, JSON parser location/category, and discovered top-level keys.
  7. Worker persists final display text, then TurnPersistence::apply(...) commits the canonical turn metadata and emits AgentResponseReceived with the matching reducer projection. If that transaction fails, the worker requests RefreshSessions and does not emit the projection.

Streaming behavior differs by transport/provider:

  • CLI channel (CliAgentChannel): parses stdout lines into loader-only ThoughtDelta updates for non-response progress/thought text and keeps raw output for final parse. Claude uses its documented stream-json output path here so compaction/tool-use progress can surface without waiting for a single final JSON payload. Antigravity uses agy --print and waits for the complete stdout payload because it does not currently expose a documented streaming output format.
  • CLI prompt submission can stream the fully rendered prompt through stdin for providers that would otherwise exceed argv limits on large diffs or one-shot utility prompts.
  • Shared CLI subprocess helpers under crates/agentty/src/infra/agent/cli/ now own stdin piping and provider-aware exit guidance so session turns and one-shot prompts use the same subprocess behavior.
  • App-server channel (AppServerAgentChannel): routes provider thought phases and progress updates to transient loader text, while withholding assistant transcript chunks until the completed turn result is parsed.
  • One-shot prompt submission asks the concrete backend for its transport path, so app-server providers (Codex and Gemini) resolve their own runtime client while Antigravity and Claude stay on direct CLI subprocess execution.
  • Provider capabilities in crates/agentty/src/infra/agent/provider.rs centralize strict final protocol validation, CLI stream classification, app-server thought-phase handling, and provider app-server client construction.
  • Gemini ACP still accumulates streamed assistant chunks internally for its final turn result, but the runtime now prefers the completed session/prompt payload whenever that payload parses as protocol JSON and the streamed accumulation does not.
  • Worker persistence behavior: ThoughtDelta updates refresh the loader only, while assistant transcript output is appended once from the final parsed turn result.

Final-output validation:

  • Claude, Antigravity, Gemini, and Codex use strict protocol parsing and return an error immediately when invalid.
  • One-shot agent submissions still surface schema errors directly to the caller whenever the shared parser rejects the final output, including plain text, blank utility responses, non-utility prompts that miss the schema, or any output that leaves trailing non-whitespace text after the recovered protocol payload. Those surfaced errors now also include the shared protocol parser's debug report.
  • App-server restart retries preserve the original protocol profile and now compare the persisted instruction state against the runtime's actual provider_conversation_id before choosing the prompt-delivery mode.

Clarification Question Loop🔗

Question-mode loop:

  1. Worker receives final parsed response containing clarification questions in questions.
  2. Worker persists question list and sets session status Question.
  3. Reducer switches active view to AppMode::Question when that session is focused.
  4. User answers each question. Submitting a blank free-text answer stores no answer.
  5. Runtime builds one follow-up prompt:
Clarifications:
1. Q: <question 1>
   A: <response 1>
2. Q: <question 2>
   A: <response 2>
  1. Runtime submits this as a normal reply turn; flow returns to standard worker path.

Pressing Esc instead ends question mode immediately, restores the session to Review, and does not send the generated clarification reply.

Background Task Catalog🔗

Detached/background execution paths and their trigger conditions:

Terminal event reader thread🔗

  • Trigger: Runtime startup
  • Spawn site: runtime/event::spawn_event_reader
  • Emits or writes: Terminal Event channel
  • What it does: Polls crossterm and forwards terminal events into the runtime loop.

Git status poller loop🔗

  • Trigger: App startup when the project has a git branch, project switch, and session refreshes that change active session branches
  • Spawn site: TaskService::spawn_git_status_task
  • Emits or writes: AppEvent::GitStatusUpdated
  • What it does: Runs a periodic fetch plus one repo-level upstream snapshot for the active project branch, then combines each active session branch's base-branch comparison with any tracked-remote snapshot before emitting one combined update about every 30s.

Version check one-shot🔗

  • Trigger: App startup
  • Spawn site: TaskService::spawn_version_check_task
  • Emits or writes: AppEvent::VersionAvailabilityUpdated
  • What it does: Checks the latest npm version tag and reports update availability.

Per-session worker loop🔗

  • Trigger: First command enqueue for a session
  • Spawn site: SessionWorkerService::spawn_session_worker
  • Emits or writes: DB session_operation updates plus app or session updates
  • What it does: Serializes all turn commands per session and manages channel lifecycle.

Per-turn turn-event consumer🔗

  • Trigger: Every queued turn execution
  • Spawn site: run_channel_turn
  • Emits or writes: Loader updates and pid slot updates
  • What it does: Consumes the TurnEvent stream and applies immediate side effects.

CLI stdout and stderr readers🔗

  • Trigger: Every CLI-backed turn
  • Spawn site: CliAgentChannel::run_turn
  • Emits or writes: TurnEvent stream plus raw output buffers
  • What it does: Reads subprocess streams and emits transient loader updates while buffering final output.

App-server stream bridge🔗

  • Trigger: Every app-server-backed turn
  • Spawn site: AppServerAgentChannel::run_turn
  • Emits or writes: TurnEvent stream
  • What it does: Bridges AppServerStreamEvent values into the unified turn event stream.

Clipboard image persistence🔗

  • Trigger: Prompt input Ctrl+V, Ctrl+Shift+V, or Alt+V
  • Spawn site: runtime/mode/prompt::handle_prompt_image_paste
  • Emits or writes: Temporary PNG files under AGENTTY_ROOT/tmp/<session-id>/images/ plus prompt attachment state
  • What it does: Reads a copied PNG file, clipboard image, or PNG path via spawn_blocking from X11 or Wayland data-control clipboard backends, persists it, and inserts an inline [Image #n] placeholder.

Session title generation🔗

  • Trigger: First Start turn, before main turn execution
  • Spawn site: spawn_start_turn_title_generation
  • Emits or writes: Database title update plus AppEvent::RefreshSessions
  • What it does: Runs a one-shot title prompt in the background and persists the generated title when it is a concise title rather than overlong prose, first-person narration, or provider progress/status text.

At-mention file indexing🔗

  • Trigger: Prompt input or question free-text input activates @ mention mode
  • Spawn site: runtime/mode/prompt::activate_at_mention and runtime/mode/question::activate_question_at_mention
  • Emits or writes: AppEvent::AtMentionEntriesLoaded
  • What it does: Lists session files via spawn_blocking, falling back to the active project working directory when an unstarted draft session has not yet materialized its worktree, and updates mention picker entries for the active composer.

Background session-size refresh🔗

  • Trigger: Enter on a session in list mode
  • Spawn site: App::refresh_session_size_in_background
  • Emits or writes: Database size update plus AppEvent::RefreshSessions
  • What it does: Computes the diff-size bucket without blocking the key-handling path.

Session-view branch-publish action🔗

  • Trigger: Session view p in Review, then publish popup Enter
  • Spawn site: App::start_publish_branch_action
  • Emits or writes: AppEvent::BranchPublishActionCompleted
  • What it does: Collects an optional remote branch name before first publish, locks to the existing upstream after publish, then runs git push --force-with-lease for the session branch in the background and updates the session-view popup with success or recovery guidance.

Deferred session cleanup🔗

  • Trigger: Delete with the deferred cleanup path
  • Spawn site: delete_selected_session_deferred_cleanup
  • Emits or writes: Filesystem and git side effects
  • What it does: Removes the worktree folder and branch asynchronously after database deletion.

Focused review assist🔗

  • Trigger: View mode focused-review open when the diff is reviewable
  • Spawn site: TaskService::spawn_review_assist_task
  • Emits or writes: ReviewPrepared or ReviewPreparationFailed
  • What it does: Runs the model review prompt and stores the final review text or error.

Sync-main workflow task🔗

  • Trigger: List-mode sync action s
  • Spawn site: TokioSyncMainRunner::start_sync_main
  • Emits or writes: AppEvent::SyncMainCompleted
  • What it does: Pulls, rebases, and pushes the selected project branch with the assisted conflict flow when needed.

Session merge task🔗

  • Trigger: Merge confirmation accepted
  • Spawn site: SessionMergeService::merge_session
  • Emits or writes: Output append, status updates, and session metadata updates
  • What it does: Runs rebase, reuses the single evolving session-branch HEAD commit message for squash merge, then cleans up the worktree in the background.

Session rebase task🔗

  • Trigger: Rebase action in view mode
  • Spawn site: SessionMergeService::rebase_session
  • Emits or writes: Output append and status updates
  • What it does: Runs the assisted rebase flow and returns the session to Review or Question. Published sessions fetch first and rebase onto the remote base ref from the published upstream's remote; unpublished sessions use the local base branch.

Sync, Merge, and Rebase Flows🔗

Project and session git workflows use shared boundaries (GitClient, FsClient, assist helpers) but have distinct orchestration paths:

  • sync main: selected project branch pull/rebase/push, optional assisted conflict resolution, popup result summary.
  • session merge: queue-aware workflow, assisted rebase first, reuse the single evolving session-branch HEAD commit message for the squash commit into the base branch, then clean up the worktree and set status Done.
  • session rebase: assisted rebase of session branch onto the local base branch for unpublished sessions or onto the published upstream's remote base ref for published sessions, returns to Review after completion/failure reporting.
  • session review-request publish: review-ready sessions push the session branch through GitClient with --force-with-lease, then create or refresh the forge review request through ReviewRequestClient. Unlinked sessions only reuse an open same-branch review request; terminal same-branch requests are ignored so branch names can be reused after merge or close.
  • background review-request sync: review-ready sessions with a published branch or linked review request are polled through ReviewRequestClient; merged requests move the session to Done, and closed requests move it to Canceled.

Persistence and Recovery Boundaries🔗

Persistence invariants that shape runtime flow:

  • DB opens with SQLite WAL and foreign_keys = ON, then embedded migrations run at startup.
  • Session snapshots in memory are authoritative for rendering; DB is authoritative for restart recovery.
  • Shared session handles (output, status, child_pid) provide low-latency updates between DB reloads.
  • Event-driven refresh is primary (RefreshSessions); metadata polling is fallback safety only.
  • External integrations (GitClient, ReviewRequestClient, AppServerClient, AgentChannel, EventSource, FsClient, TmuxClient) isolate side effects and enable deterministic tests.