Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,542 changes: 1,485 additions & 57 deletions Cargo.lock

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ serde = { version = "1", features = ["derive"] }
toml = "1"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "v7", "serde"] }
rig = { package = "rig-core", version = "0.38", features = ["derive"] }
rig = { package = "rig-core", version = "0.39", features = ["derive"] }
rig-bedrock = "0.39"
git2 = { version = "0.21", default-features = false, features = ["vendored-libgit2"] }
clap = { version = "4", features = ["derive"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
Expand All @@ -27,6 +28,13 @@ serde_json = "1"
base64 = "0.22"
jsonwebtoken = "9"
glob = "0.3"
keyring = "4"
aws-config = "1"
aws-credential-types = "1"
aws-sdk-bedrockruntime = "1"
aws-sdk-sso = "1"
aws-sdk-ssooidc = "1"
webbrowser = "1"

axum = { version = "0.8", features = ["macros"] }
ratatui = { version = "0.29", optional = true }
Expand Down
137 changes: 137 additions & 0 deletions docs/ADR/002-bootstrap-setup-or-use-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# ADR-002: Bootstrap Is a Backend-Owned "Setup or Use" Gate

**Status:** Accepted — implemented
**Date:** 2026-06-27

> **Implementation:** `crate::setup` (gate, `SetupState`, shared discover/
> activate), the daemon setup phase in `crate::daemon` (`run_setup` + socket
> commands `setup_state` / `setup_rescan` / `setup_select` streaming
> `SetupState`), `Config::fallback_provider` + read-and-report `Config::load`,
> the TUI thin renderer (`draw_setup` + the reconnecting `daemon_client` phase
> probe), and the fallback-brain recovery path in `Dispatcher::run_main_agent_turn`.

## Context

First-run discovery currently happens inside `Config::load`: on a missing
config file it synchronously runs `bootstrap::run()`, probes for local model
servers / SSO sessions, and writes a config with everything `enabled = false`
and no agents. Two problems follow from this:

1. **File existence is the wrong signal.** Discovery only runs when the config
file is absent. A user who starts gitzi before installing an LLM (or before
`aws sso login`) gets a config file written once; on every subsequent run
the file exists, so we never re-check, and the app loads into a board with
zero working agents — a dead app that silently does nothing.

2. **There is no good model we can ship inside the app.** So we cannot assume a
guaranteed local fallback agent exists to *conversationally* walk the user
through setup. The zero-state cannot be agent-driven (chicken-and-egg: the
agent that would run setup needs the provider that setup creates).

The earlier mental model — "the discovery scan is non-blocking, so the app can
never block loading" — conflated two independent properties: the *scan* should
be fast and time-boxed, but the *gate* ("do we have a working LLM?") should
absolutely stop the user from entering a useless board. Non-blocking scan does
not imply always-load.

## Decision

Treat the entire experience as a binary state owned by the backend (daemon):
**setup an LLM** or **use an LLM**. There is no degraded in-between. The logic
lives in the daemon; the TUI is a thin renderer so frontends stay portable.

### The gate is binary, evaluated every load

On startup the daemon checks whether config resolves to an enabled provider
that exists. If not, it enters **setup mode**: the dispatcher idles, no agents
come alive, and the frontend shows setup the entire time. The gate is not
gated on file existence — it is re-evaluated every run.

### Backend-owned setup state machine

The daemon owns a bootstrap state machine and publishes its state over the
existing event bus:

```
Loading → NeedsProvider { candidates } → Error { messages, can_rescan } → Ready
```

Inbound commands from the frontend: `SelectProvider`, `Rescan`. Discovery,
activation, and validation all run in the daemon, asynchronously, so the
splash reflects real progress instead of blocking the process.

- **Loading** — the time-boxed scan (LM Studio / Ollama / SSO) runs.
- **NeedsProvider** — discovered candidates are offered; the user picks one to
activate. This is the *only* human-facing setup step. Everything else (other
roles, pipeline agents) is deferred until after a valid provider exists.
- **Error** — if the scan finds nothing, or activation fails, every error
message is displayed with the ability to rescan. This is a real terminal
state of the gate, not a silent fall-through to the board.
- **Ready** — a valid provider exists; agents come alive and the chat
interface is presented.

### The fallback LLM is the control-plane brain

The provider activated at bootstrap is persisted as a *distinguished* fallback
provider, separate from whatever `main` is later pointed at (e.g. Bedrock). Its
job is narrow and specific:

1. Power the setup experience itself.
2. When `main`'s own provider is absent or not responding, run the "your main
provider isn't working — what do you want to do?" conversation.

It is **not** a transparent failover for the user's real work. We never quietly
answer a `main` prompt with the fallback. If there is no valid LLM at all, we
are not in "use" — we are back in setup. The recovery conversation is itself
LLM-driven *by the fallback brain*, not a fixed menu.

### `Config::load` stops discovering

`Config::load` becomes pure read-and-report. The scan/activate/validate logic
moves into the daemon's setup phase. Load reporting "nothing valid" is a normal
outcome that drives the state machine into setup mode rather than an error.

### One implementation, two entry points

`gitzi_rediscover_providers` / `gitzi_activate_provider` become thin wrappers
over the same backend setup logic. The pre-agent TUI path (during bootstrap)
and the post-bootstrap agent-driven path share a single implementation, so
there is one source of truth for "discover" and "activate".

## Consequences

- The TUI is a switch over `SetupState` (splash / picker / error+rescan / chat)
plus relaying `SelectProvider` / `Rescan`. No setup logic in the frontend —
it can be ported to other frontends by re-rendering the same backend state.
- The daemon must support running with no live agents (setup mode) as a
first-class state, not an error.
- A new distinguished "fallback provider" concept is added to config, with its
own activation and persistence, separate from per-agent `provider` fields.
- Discovery moving out of `Config::load` means load no longer has side effects
(no config rewrite to seed disabled providers); seeding happens in the setup
phase instead.
- Every startup pays a scan only when the gate is unsatisfied; once a valid
provider exists, startup goes straight to `Ready`.

## Alternatives Considered

1. **Transparent per-request failover to the fallback** — Rejected. It hides a
broken `main` from the user and silently changes which model does their
work. The binary setup/use model keeps the user in control: a broken `main`
triggers an explicit recovery conversation, not a silent swap.

2. **Ship a guaranteed local fallback model so setup can be conversational from
the zero-state** — Rejected. There is no model good enough to embed in the
app. Setup must therefore be backend-logic-driven (no LLM required) with the
TUI as a plain renderer.

3. **Keep setup orchestration in the TUI** — Rejected. "The human interacts
here" is not "the logic lives here." Putting discovery/activation/validation
in the frontend would have to be reimplemented for every future frontend.
The backend owns the state machine; the frontend renders it.

4. **Gate on config-file existence (status quo)** — Rejected. It only checks
once, so a user who sets up an LLM after first run is never re-discovered,
and a user whose provider later breaks is dropped into a dead board. The
gate must be re-evaluated every load against whether a provider actually
resolves.
34 changes: 29 additions & 5 deletions kb/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,42 @@ Per-column work-in-progress limit overrides. Columns not listed keep built-in de
- Type: table (column name → integer)
- Example: `coding = 2`

### `fallback_provider`
The distinguished "control-plane" provider chosen during bootstrap setup
(see ADR-002). It powers the setup experience and the recovery conversation
when the main agent's own provider is absent or not responding. Set
automatically on the first provider activation; names a key in `[providers]`.
- Type: string (optional)
- You normally never edit this by hand — it's written during setup.

### `[providers.<name>]`
Named LLM provider endpoints.
- `api_url`: OpenAI-compatible endpoint URL
- `api_key`: API key (plaintext — this file is never committed)
Named LLM provider endpoints. On first run gitzi has no valid provider and
enters **setup mode** (ADR-002): the daemon scans for LM Studio / Ollama / AWS
SSO sessions and the TUI shows a one-step picker to activate one. The chosen
provider is wired into the main agent and recorded as `fallback_provider`.
Providers discovered later are recorded here with `enabled = false` — use the
main agent's `gitzi_rediscover_providers`/`gitzi_activate_provider` chat tools to
see what's available and turn one on, rather than editing this file by hand.
- `kind`: `"openai-compatible"` (default) or `"bedrock"`
- `api_url`: OpenAI-compatible endpoint URL. Unused for `bedrock`.
- `api_key`: API key for OpenAI-compatible providers — plaintext or a
`keyring:<service>/<account>` pointer (gitzi migrates plaintext keys into the OS
keyring automatically on load).
- `region`: AWS region, for `bedrock` providers.
- `sso_start_url`, `sso_account_id`, `sso_role_name`: AWS SSO identifiers for `bedrock`
providers, filled in during activation. Credentials are handed to the AWS SDK
in-process via `crate::aws_sso::SsoCredentialsProvider` — gitzi never writes to
`~/.aws/config` or any other cloud CLI's config files.
- `model_id`: Bedrock model ID, e.g. `"anthropic.claude-sonnet-4-6-v1:0"`.
- `enabled`: Whether this provider is actually wired into any agent. Discovered
providers default to `false` until explicitly activated.

### `[[agents]]`
Agent definitions. Each entry defines a role with its model and behavior.
- `role`: One of: main, prioritizer, designer, coder, reviewer, tester, auditor, infrarian
- `role`: One of: main, prioritizer, designer, coder, reviewer, auditor, infrarian
- `model`: Model identifier passed to the API
- `api_url`: Direct endpoint (overrides provider)
- `provider`: Reference a named provider
- `system_prompt`: Override the built-in system prompt

### `[[repos]]`
Per-repository configuration overrides.
Expand Down
62 changes: 40 additions & 22 deletions plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -925,31 +925,43 @@ First-time experience when a user runs `gitzi` with no existing `~/.gitzi/`.
### First-run flow

```
loader/spinner (auto-discover LLM providers + repos)
→ generate config.toml
→ if multiple providers: onboarding selection UI
→ launch TUI (Status panel shows discovery results)
quick non-blocking scan (discover LLM providers + repos)
→ generate config.toml (providers recorded, none enabled/wired)
→ launch TUI immediately (Status panel shows discovery results)
→ main agent: "So what are we going to do next?"
```

Discovery never starts a server, loads a model, or opens a browser for SSO login —
onboarding must never block waiting on any of those. Every provider found is written to
`[providers.*]` with `enabled = false` and not wired into `[[agents]]`; every role falls
back to the local `claude` CLI subprocess until the user explicitly activates a provider
through the main agent's `gitzi_rediscover_providers`/`gitzi_activate_provider` tools
(see `src/bootstrap.rs`, `src/agent/main_agent.rs`).

### Provider discovery priority order

1. **CLI-based LLMs already running** (highest — zero friction)
2. **Installed but not running** — show guidance, do NOT auto-start
3. **Not installed** — skip
Priority only affects *display* sort on the Status panel — discovery never auto-starts,
auto-loads a model, or auto-activates a provider:

1. **Running with a model loaded** — zero friction to activate
2. **Running but no model loaded**
3. **Installed but not running**
4. **Not installed** — skipped, not recorded

| Provider | Binary | Detection | Start |
|----------|--------|-----------|-------|
| LM Studio | `~/.lmstudio/bin/lms` | `lms server status` | `lms server start` |
| Ollama | `ollama` | `ollama list` / port 11434 | `ollama serve` |
| Claude CLI | `claude` | `which claude` | N/A (API key) |
| OpenCode | `opencode` | `which opencode` | TBD |
| Goose | `goose` | `which goose` | TBD |
| Aider | `aider` | `which aider` | TBD |
| Provider | Detected via | Kind |
|----------|---------------|------|
| LM Studio | `~/.lmstudio/bin/lms` exists; port 1234 + `/v1/models` for running/model-loaded | `openai-compatible` |
| Ollama | `which ollama`; port 11434 + `/v1/models` for running/model-loaded | `openai-compatible` |
| AWS Bedrock | `[sso-session NAME]` blocks in `~/.aws/config` (one candidate per session), else a generic candidate if the `aws` CLI is installed | `bedrock` |

For "installed but not running": display guidance on Status panel ("X is installed but
not running. Please start it and load a model."). For "running but no model loaded":
use `/v1/models` HTTP endpoint to check, then force-load first downloaded text model.
Claude CLI/OpenCode/Goose/Aider are not auto-discovered providers — they remain
available as the implicit fallback (the local `claude` CLI subprocess) for any role not
wired to a provider.

For "installed but not running": Status panel shows "X is installed but not running.
Start it and load a model, then ask me to rediscover providers." For "running but no
model loaded": same guidance, using `/v1/models` to check what's loaded (more reliable
than the CLI) — never force-loads a model.

### Config three-layer architecture

Expand All @@ -972,11 +984,17 @@ Resolution: `Final = hardcoded ?? user_config ?? default`
- Invalid roles → silently stripped, config.toml rewritten
- Removed fields: `default_agent`, `test_command`, `system_prompt` in `[[agents]]`

### Selection logic
### Activation logic

Nothing is enabled or wired into `[[agents]]` by discovery — activation is always an
explicit, user-initiated step via chat:

- Single provider found → auto-use, no question
- Multiple in same category → ask user which to use (onboarding selection flow)
- Prefer: already-running > needs-start, local > API-key-required
- 1 or many providers found → all listed, none enabled; the user picks which (if any) to
activate.
- Activating an OpenAI-compatible provider (LM Studio, Ollama) is immediate.
- Activating Bedrock walks the user through AWS SSO device-authorization login, then
account and role selection, across multiple `gitzi_activate_provider` calls.
- A `gitzi` restart is required after activation for the rewired agent to take effect.

### Status panel states

Expand Down
73 changes: 73 additions & 0 deletions src/agent/bedrock_agent.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use aws_config::BehaviorVersion;
use rig::client::CompletionClient;
use rig::completion::Prompt;
use rig_bedrock::client::Client;
use crate::aws_sso::SsoCredentialsProvider;
use crate::error::{GitziError, Result};
use crate::model::Task;
use super::backend::{AgentBackend, AgentResult, RunContext};
use super::prompt::{build_task_content, DEFAULT_PREAMBLE};

/// Runs a pipeline agent against AWS Bedrock, authenticating via
/// [`SsoCredentialsProvider`] — credentials are handed to the AWS SDK
/// directly, in-process; gitzi never writes a profile to `~/.aws/config`.
pub struct BedrockAgent {
region: String,
sso_start_url: String,
sso_account_id: String,
sso_role_name: String,
model_id: String,
system_prompt: Option<String>,
}

impl BedrockAgent {
pub fn new(
region: impl Into<String>,
sso_start_url: impl Into<String>,
sso_account_id: impl Into<String>,
sso_role_name: impl Into<String>,
model_id: impl Into<String>,
system_prompt: Option<String>,
) -> Self {
Self {
region: region.into(),
sso_start_url: sso_start_url.into(),
sso_account_id: sso_account_id.into(),
sso_role_name: sso_role_name.into(),
model_id: model_id.into(),
system_prompt,
}
}
}

impl AgentBackend for BedrockAgent {
async fn run(&self, task: &Task, ctx: &RunContext) -> Result<AgentResult> {
let credentials_provider = SsoCredentialsProvider::new(
&self.region,
&self.sso_start_url,
&self.sso_account_id,
&self.sso_role_name,
);
let sdk_config = aws_config::defaults(BehaviorVersion::latest())
.region(aws_config::Region::new(self.region.clone()))
.credentials_provider(credentials_provider)
.load()
.await;
let aws_client = aws_sdk_bedrockruntime::Client::new(&sdk_config);
let client = Client::from(aws_client);

let agent = client
.agent(&self.model_id)
.preamble(self.system_prompt.as_deref().unwrap_or(DEFAULT_PREAMBLE))
.build();

let prompt = build_task_content(task, ctx.resume_summary.as_deref(), &ctx.answered_questions);

let response: String = agent
.prompt(prompt.as_str())
.await
.map_err(|e| GitziError::AgentFailed(e.to_string()))?;

Ok(AgentResult::Success { output: response })
}
}
Loading
Loading