main
claude/code-feature-parity-q003hm
claude/elegant-carson-l1menh
claude/sigit-acp-local-chat-cx6380
claude/sigit-cloud-agent-expansion-reox0a
claude/tool-permission-system
claude/zen-feynman-0u78dk
development
feature/agent-tools-multiedit-glob-todos-remember
feature/background-commands
feature/commit-coauthor-attribution
feature/headless-mode
feature/init-command
feature/load-local-model-explicitly
feature/session-persistence-compaction
feature/sigit-code-cloud
feature/subagent-tool
feature/tool-permission-system
feature/tui-repo-tabs
feature/tui-tabs
main
release/v1.3.1
| 1 | --- |
| 2 | name: agent-client-protocol |
| 3 | description: Implement or debug Agent Client Protocol (ACP) support in Rust for siGit Code. Use when working on ACP JSON-RPC over stdio, the agent-client-protocol crate, session/prompt/fork handlers, config options (model picker), slash commands, streaming notifications, or editor integration. |
| 4 | --- |
| 5 | |
| 6 | # Skill: Agent Client Protocol (ACP) — Rust Implementation |
| 7 | |
| 8 | ## Overview |
| 9 | |
| 10 | ACP is a JSON-RPC 2.0 protocol over **stdio** for integrating AI coding agents |
| 11 | with editors (Zed, JetBrains, Neovim, etc.). The agent runs as a subprocess; |
| 12 | the editor is the client. Communication is newline-delimited JSON on stdin/stdout. |
| 13 | |
| 14 | Crate: `agent-client-protocol = "0.13"` (siGit pins 0.13.0 in `Cargo.lock`) |
| 15 | Docs: https://docs.rs/agent-client-protocol |
| 16 | Spec: https://agentclientprotocol.com |
| 17 | |
| 18 | > **Big change since 0.10:** the crate moved from a `#[async_trait(?Send)] impl Agent` |
| 19 | > model to a **builder** model. You no longer implement a trait. You build an |
| 20 | > `Agent` with per-message handler closures and `.connect_to(transport)`. Each |
| 21 | > handler receives a `ConnectionTo<Client>` (`cx`) you use to send notifications |
| 22 | > and spawn tasks — so the old mpsc "circular dependency" pattern is gone. |
| 23 | |
| 24 | siGit's entire ACP server lives in `src/main.rs` (`run_acp_server`, the |
| 25 | `SiGitAgent` struct, and its `handle_*` methods). Read it alongside this skill. |
| 26 | |
| 27 | --- |
| 28 | |
| 29 | ## Dependency setup |
| 30 | |
| 31 | ```toml |
| 32 | [dependencies] |
| 33 | agent-client-protocol = { version = "0.13", features = [ |
| 34 | "unstable_session_fork", # session/fork support |
| 35 | "unstable_session_additional_directories", # additional_directories on session requests |
| 36 | "unstable_auth_methods", # AuthMethod::Agent etc. |
| 37 | ] } |
| 38 | async-trait = "0.1" |
| 39 | tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "io-std", "io-util", "sync", "time"] } |
| 40 | tokio-util = { version = "0.7", features = ["compat"] } |
| 41 | futures = "0.3" |
| 42 | uuid = { version = "1", features = ["v4"] } |
| 43 | ``` |
| 44 | |
| 45 | The `unstable_*` features gate real types/methods (`ForkSessionRequest`, |
| 46 | `additional_directories`, `AuthMethod::Agent`). Without them the corresponding |
| 47 | APIs don't exist and you'll get "no variant/method" errors. |
| 48 | |
| 49 | --- |
| 50 | |
| 51 | ## Imports |
| 52 | |
| 53 | Protocol message/data types live under `agent_client_protocol::schema::*`. |
| 54 | Connection/runtime types live at the crate root. |
| 55 | |
| 56 | ```rust |
| 57 | use agent_client_protocol::schema::{ |
| 58 | AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse, |
| 59 | AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, CancelNotification, |
| 60 | ConfigOptionUpdate, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest, |
| 61 | ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest, |
| 62 | LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PromptRequest, |
| 63 | PromptResponse, ProtocolVersion, SessionCapabilities, SessionConfigOption, |
| 64 | SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId, |
| 65 | SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate, |
| 66 | SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall, |
| 67 | ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput, |
| 68 | }; |
| 69 | use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder}; |
| 70 | ``` |
| 71 | |
| 72 | --- |
| 73 | |
| 74 | ## Wiring up the server — the builder |
| 75 | |
| 76 | You do **not** implement a trait. You hold your state in an `Arc<MyState>`, then |
| 77 | register one closure per incoming message type on `Agent.builder()`, and finish |
| 78 | with `.connect_to(transport).await`. The builder owns the JSON-RPC loop and runs |
| 79 | until the client disconnects. |
| 80 | |
| 81 | ```rust |
| 82 | use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; |
| 83 | |
| 84 | async fn run_acp_server() -> anyhow::Result<()> { |
| 85 | let state = Arc::new(SiGitAgent::new(/* … */)); |
| 86 | |
| 87 | // Adapt tokio stdio to the futures AsyncRead/AsyncWrite the SDK expects. |
| 88 | let stdin = tokio::io::stdin().compat(); |
| 89 | let stdout = tokio::io::stdout().compat_write(); |
| 90 | let transport = ByteStreams::new(stdout, stdin); // note: (writer, reader) |
| 91 | |
| 92 | Agent |
| 93 | .builder() |
| 94 | .on_receive_request( |
| 95 | { |
| 96 | let state = Arc::clone(&state); |
| 97 | async move |req: InitializeRequest, responder, _cx: ConnectionTo<Client>| { |
| 98 | handle_response(responder, state.handle_initialize(req).await) |
| 99 | } |
| 100 | }, |
| 101 | agent_client_protocol::on_receive_request!(), |
| 102 | ) |
| 103 | .on_receive_request( |
| 104 | { |
| 105 | let state = Arc::clone(&state); |
| 106 | async move |req: PromptRequest, responder, cx: ConnectionTo<Client>| { |
| 107 | handle_response(responder, state.handle_prompt(&cx, req).await) |
| 108 | } |
| 109 | }, |
| 110 | agent_client_protocol::on_receive_request!(), |
| 111 | ) |
| 112 | // … one .on_receive_request(…) per request type you support … |
| 113 | .on_receive_notification( |
| 114 | { |
| 115 | let state = Arc::clone(&state); |
| 116 | async move |notif: CancelNotification, _cx: ConnectionTo<Client>| { |
| 117 | state.handle_cancel(notif).await |
| 118 | } |
| 119 | }, |
| 120 | agent_client_protocol::on_receive_notification!(), |
| 121 | ) |
| 122 | .connect_to(transport) |
| 123 | .await |
| 124 | .map_err(|e| anyhow::anyhow!("ACP connection error: {e}"))?; |
| 125 | |
| 126 | Ok(()) |
| 127 | } |
| 128 | ``` |
| 129 | |
| 130 | Key points: |
| 131 | |
| 132 | - **`Arc::clone(&state)` per closure.** Each handler closure is `move` and owns |
| 133 | its own `Arc` clone of shared state. |
| 134 | - **The macro is required.** Each handler is paired with |
| 135 | `agent_client_protocol::on_receive_request!()` (or `on_receive_notification!()`). |
| 136 | It wires the closure's concrete message type into the dispatcher. Don't omit it. |
| 137 | - **Closure signature for requests:** `async move |req: T, responder, cx: ConnectionTo<Client>|`. |
| 138 | Use `_cx` when a handler doesn't send notifications (e.g. `initialize`, `authenticate`). |
| 139 | - **Closure signature for notifications:** `async move |notif: T, cx: ConnectionTo<Client>|` |
| 140 | returning `agent_client_protocol::Result<()>` — no responder (notifications get no reply). |
| 141 | - **Unmatched messages fall to the SDK default** — you only register what you support. |
| 142 | |
| 143 | ### The `Responder` + `handle_response` helper |
| 144 | |
| 145 | Requests reply through a `Responder<T>`. siGit funnels every handler's |
| 146 | `Result` through one helper: |
| 147 | |
| 148 | ```rust |
| 149 | fn handle_response<T: agent_client_protocol::JsonRpcResponse>( |
| 150 | responder: Responder<T>, |
| 151 | result: agent_client_protocol::Result<T>, |
| 152 | ) -> agent_client_protocol::Result<()> { |
| 153 | match result { |
| 154 | Ok(resp) => responder.respond(resp), |
| 155 | Err(err) => responder.respond_with_error(err), |
| 156 | } |
| 157 | } |
| 158 | ``` |
| 159 | |
| 160 | So each `handle_*` method just returns `Result<SomeResponse>` and stays free of |
| 161 | protocol plumbing. |
| 162 | |
| 163 | --- |
| 164 | |
| 165 | ## `ConnectionTo<Client>` — the `cx` |
| 166 | |
| 167 | The per-handler `cx: ConnectionTo<Client>` replaces the old mpsc-channel forwarder. |
| 168 | It is `Clone`. Two things you do with it: |
| 169 | |
| 170 | ```rust |
| 171 | // 1. Send a server→client notification (streaming chunks, tool-call updates, …) |
| 172 | cx.send_notification(SessionNotification::new(session_id.clone(), update))?; |
| 173 | |
| 174 | // 2. Spawn a background task that keeps using cx (e.g. a progress spinner poller). |
| 175 | let cx_for_poller = cx.clone(); |
| 176 | cx.spawn(async move { |
| 177 | loop { |
| 178 | // … cx_for_poller.send_notification(progress_update) … |
| 179 | # break; |
| 180 | } |
| 181 | Ok(()) |
| 182 | }).ok(); |
| 183 | ``` |
| 184 | |
| 185 | Because `cx` is handed to you directly, there is **no circular dependency** between |
| 186 | the connection and the agent anymore. Don't reintroduce the mpsc forwarder pattern. |
| 187 | |
| 188 | --- |
| 189 | |
| 190 | ## The handlers siGit implements |
| 191 | |
| 192 | | Message | Method | Notes | |
| 193 | |---------|--------|-------| |
| 194 | | `InitializeRequest` | `handle_initialize` | capabilities, auth methods, agent info, `meta` | |
| 195 | | `AuthenticateRequest` | `handle_authenticate` | verifies stored siGit Code Cloud session | |
| 196 | | `NewSessionRequest` | `handle_new_session` | sets cwd, resets history, advertises commands + config options | |
| 197 | | `LoadSessionRequest` | `handle_load_session` | like new_session; gated by `load_session(true)` capability | |
| 198 | | `ForkSessionRequest` | `handle_fork_session` | gated by `unstable_session_fork` + `SessionForkCapabilities` | |
| 199 | | `PromptRequest` | `handle_prompt` | the turn: parse blocks → slash commands or tool-calling loop | |
| 200 | | `SetSessionConfigOptionRequest` | `handle_set_session_config_option` | the Zed model picker — switches/downloads models | |
| 201 | | `CancelNotification` | `handle_cancel` | notification, no response | |
| 202 | |
| 203 | Everything else is left to the SDK default (method not found). |
| 204 | |
| 205 | --- |
| 206 | |
| 207 | ## Types and their builders |
| 208 | |
| 209 | All `#[non_exhaustive]` structs require builder methods — struct-literal syntax |
| 210 | won't compile. |
| 211 | |
| 212 | ### `InitializeResponse` |
| 213 | |
| 214 | ```rust |
| 215 | Ok(InitializeResponse::new(ProtocolVersion::V1) // use V1, not args.protocol_version |
| 216 | .agent_info( |
| 217 | Implementation::new("sigit", env!("CARGO_PKG_VERSION")) |
| 218 | .title("siGit Code - AI Coding Agent"), |
| 219 | ) |
| 220 | .auth_methods(vec![AuthMethod::Agent( |
| 221 | AuthMethodAgent::new("sigit", "Sign in to siGit Code") |
| 222 | .description("Sign in with `/login <email> <password>` in the message box."), |
| 223 | )]) |
| 224 | .agent_capabilities( |
| 225 | AgentCapabilities::default() |
| 226 | .load_session(true) // enables LoadSessionRequest |
| 227 | .session_capabilities( |
| 228 | SessionCapabilities::new() |
| 229 | .fork(SessionForkCapabilities::new()), // enables ForkSessionRequest |
| 230 | ), |
| 231 | ) |
| 232 | .meta(initialize_meta())) // free-form Meta (see below) |
| 233 | ``` |
| 234 | |
| 235 | `auth_methods` must include at least one `AuthMethod::Agent` or **Zed hangs on |
| 236 | "Loading…" forever.** siGit uses `Agent` (not `Terminal`) because Zed advertises |
| 237 | terminal-auth for custom agents but never actually spawns the login terminal, so |
| 238 | the button would be a silent no-op. With `Agent`, clicking calls `authenticate`. |
| 239 | |
| 240 | ### `Meta` — free-form server metadata |
| 241 | |
| 242 | `Meta` is a string-keyed JSON map you can attach to `InitializeResponse` (siGit |
| 243 | publishes the active model there so the editor can show it): |
| 244 | |
| 245 | ```rust |
| 246 | let mut meta = Meta::new(); |
| 247 | meta.insert("sigit".to_string(), serde_json::json!({ |
| 248 | "active_model": { "display_name": "...", "model_id": "...", "gguf_file": "..." } |
| 249 | })); |
| 250 | ``` |
| 251 | |
| 252 | ### `AuthenticateResponse` |
| 253 | |
| 254 | ```rust |
| 255 | Ok(AuthenticateResponse::default()) // success |
| 256 | // failure: return an Error — siGit uses -32000 "not signed in …" |
| 257 | ``` |
| 258 | |
| 259 | ### `NewSessionResponse` / `LoadSessionResponse` / `ForkSessionResponse` |
| 260 | |
| 261 | ```rust |
| 262 | let session_id = SessionId::new(uuid::Uuid::new_v4().to_string()); |
| 263 | |
| 264 | Ok(NewSessionResponse::new(session_id).config_options(config_options)) |
| 265 | Ok(LoadSessionResponse::new().config_options(config_options)) // no id arg — it's in the request |
| 266 | Ok(ForkSessionResponse::new(new_id).config_options(config_options)) |
| 267 | ``` |
| 268 | |
| 269 | `SessionId` is a newtype with `Clone`, `PartialEq`, `Display`, `Into<String>`, |
| 270 | `AsRef<str>`. Store it as-is so `==` works. `config_options` powers the editor's |
| 271 | per-session picker (see Config options below). |
| 272 | |
| 273 | The session requests carry `cwd: PathBuf` and (with the feature) |
| 274 | `additional_directories: Vec<PathBuf>`. siGit stashes `cwd`, `set_current_dir`s |
| 275 | to it, and pushes a system message telling the model to use absolute paths under it. |
| 276 | |
| 277 | ### `PromptRequest` / blocks |
| 278 | |
| 279 | ```rust |
| 280 | args.session_id // SessionId |
| 281 | args.prompt // Vec<ContentBlock> |
| 282 | ``` |
| 283 | |
| 284 | Editors send several block kinds — handle the three siGit cares about: |
| 285 | |
| 286 | ```rust |
| 287 | for block in &args.prompt { |
| 288 | match block { |
| 289 | ContentBlock::Text(t) => { /* t.text */ } |
| 290 | ContentBlock::Resource(embedded) => match &embedded.resource { |
| 291 | // editor already inlined file content |
| 292 | EmbeddedResourceResource::TextResourceContents(tr) => { /* tr.uri, tr.text */ } |
| 293 | EmbeddedResourceResource::BlobResourceContents(b) => { /* b.uri */ } |
| 294 | _ => {} |
| 295 | }, |
| 296 | ContentBlock::ResourceLink(link) => { |
| 297 | // a reference (e.g. `@file`); read it yourself. |
| 298 | // link.uri is "file:///abs/path#L207:219" (or #L207-219). Strip "file://", |
| 299 | // split the "#L<start>:<end>" fragment, read & slice the lines. |
| 300 | } |
| 301 | _ => {} // non_exhaustive — always a wildcard |
| 302 | } |
| 303 | } |
| 304 | ``` |
| 305 | |
| 306 | ### `PromptResponse` |
| 307 | |
| 308 | ```rust |
| 309 | Ok(PromptResponse::new(StopReason::EndTurn)) |
| 310 | // other reasons: MaxTokens, Cancelled, MaxTurnRequests, Refusal |
| 311 | ``` |
| 312 | |
| 313 | ### Streaming: `ContentChunk` + `SessionUpdate` + `SessionNotification` |
| 314 | |
| 315 | ```rust |
| 316 | let chunk = ContentChunk::new(ContentBlock::from(delta_text)); // From<Into<String>> |
| 317 | let update = SessionUpdate::AgentMessageChunk(chunk); |
| 318 | cx.send_notification(SessionNotification::new(session_id.clone(), update))?; |
| 319 | ``` |
| 320 | |
| 321 | `SessionUpdate` variants siGit uses: |
| 322 | |
| 323 | - `AgentMessageChunk(ContentChunk)` — assistant text. |
| 324 | - `ToolCall(ToolCall)` — start a tool-call card (used for model load/download progress). |
| 325 | - `ToolCallUpdate(ToolCallUpdate)` — update that card's title/status/content. |
| 326 | - `AvailableCommandsUpdate(AvailableCommandsUpdate)` — advertise slash commands. |
| 327 | - `ConfigOptionUpdate(ConfigOptionUpdate)` — refresh the picker mid-session. |
| 328 | |
| 329 | (Other variants exist: `UserMessageChunk`, `AgentThoughtChunk`, `Plan`, …) |
| 330 | |
| 331 | ### `ToolCall` / `ToolCallUpdate` — progress cards |
| 332 | |
| 333 | siGit reuses tool-call cards as a generic progress UI (model loading/download): |
| 334 | |
| 335 | ```rust |
| 336 | // open the card |
| 337 | SessionUpdate::ToolCall( |
| 338 | ToolCall::new(tool_call_id.clone(), "Loading Qwen 2.5 3B") |
| 339 | .kind(ToolKind::Think) |
| 340 | .status(ToolCallStatus::InProgress) |
| 341 | .content(vec!["Loading…".into()]), |
| 342 | ) |
| 343 | // update it (only the fields you set) |
| 344 | SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( |
| 345 | tool_call_id.clone(), |
| 346 | ToolCallUpdateFields::new() |
| 347 | .title("✓ Qwen 2.5 3B loaded") |
| 348 | .status(ToolCallStatus::Completed), |
| 349 | )) |
| 350 | ``` |
| 351 | |
| 352 | `ToolCallStatus`: `InProgress`, `Completed`, `Failed`. `ToolKind::Think` is the |
| 353 | "thinking/util" kind. |
| 354 | |
| 355 | ### `Error` |
| 356 | |
| 357 | ```rust |
| 358 | agent_client_protocol::Error::new(-32603, "internal error message") // there is NO Error::internal() |
| 359 | agent_client_protocol::Error::new(-32602, "invalid params: …") // or Error::invalid_params() |
| 360 | agent_client_protocol::Error::new(-32000, "not signed in …") // app-defined |
| 361 | ``` |
| 362 | |
| 363 | --- |
| 364 | |
| 365 | ## Config options — the editor model picker |
| 366 | |
| 367 | ACP lets the agent expose per-session config controls; Zed renders them in the |
| 368 | agent panel. siGit uses one `select` option as a model picker. |
| 369 | |
| 370 | ```rust |
| 371 | const MODEL_CONFIG_ID: &str = "sigit-model"; |
| 372 | |
| 373 | let options: Vec<SessionConfigSelectOption> = models.iter().map(|m| { |
| 374 | SessionConfigSelectOption::new( |
| 375 | SessionConfigValueId::new(m.model_id.as_str()), |
| 376 | format!("{} {badge}", m.display_name), |
| 377 | ).description(desc) |
| 378 | }).collect(); |
| 379 | |
| 380 | let config_options = vec![ |
| 381 | SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options) |
| 382 | .category(SessionConfigOptionCategory::Model) |
| 383 | .description("Select an on-device model or a siGit Code Cloud tier"), |
| 384 | ]; |
| 385 | ``` |
| 386 | |
| 387 | Return these from new/load/fork session via `.config_options(config_options)`. |
| 388 | When the user picks one, the client sends `SetSessionConfigOptionRequest`: |
| 389 | |
| 390 | ```rust |
| 391 | async fn handle_set_session_config_option(&self, cx: &ConnectionTo<Client>, |
| 392 | args: SetSessionConfigOptionRequest) -> Result<SetSessionConfigOptionResponse> { |
| 393 | if args.config_id.0.as_ref() != MODEL_CONFIG_ID { return Err(Error::new(-32602, "…")); } |
| 394 | let model_id = args.value.0.as_ref(); |
| 395 | // … switch model, streaming ToolCall progress via cx … |
| 396 | Ok(SetSessionConfigOptionResponse::new(rebuilt_config_options)) |
| 397 | } |
| 398 | ``` |
| 399 | |
| 400 | To refresh the picker mid-session (e.g. after `/reload`), push |
| 401 | `SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options))`. |
| 402 | |
| 403 | **Gotcha:** Zed re-fires the last selection on (re)connect. Guard against a no-op |
| 404 | re-select of the already-active model, and don't try to load a new model while a |
| 405 | startup load is still in flight (the old weights still hold GPU memory → the new |
| 406 | load fails with "does not fit"). siGit waits for `model_ready` first. |
| 407 | |
| 408 | --- |
| 409 | |
| 410 | ## Slash commands |
| 411 | |
| 412 | Advertise them so the editor forwards `/`-prefixed input (Zed rejects unknown |
| 413 | slash commands client-side): |
| 414 | |
| 415 | ```rust |
| 416 | let commands = vec![ |
| 417 | AvailableCommand::new("help", "Show available commands"), |
| 418 | AvailableCommand::new("models", "List available models").input( |
| 419 | AvailableCommandInput::Unstructured(UnstructuredCommandInput::new( |
| 420 | "model number to switch to (optional)"))), |
| 421 | // … login/logout/whoami/reload/clear/status … |
| 422 | ]; |
| 423 | cx.send_notification(SessionNotification::new( |
| 424 | session_id, |
| 425 | SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)), |
| 426 | ))?; |
| 427 | ``` |
| 428 | |
| 429 | siGit parses slash text out of the prompt itself (`parse_slash`) and dispatches in |
| 430 | `exec_slash_acp` before falling through to inference. The command turn still ends |
| 431 | with `Ok(PromptResponse::new(StopReason::EndTurn))`. |
| 432 | |
| 433 | --- |
| 434 | |
| 435 | ## Concurrency: the `block_in_place` trap (still real) |
| 436 | |
| 437 | `mistralrs` model loading calls `tokio::task::block_in_place` internally, which |
| 438 | **panics off a multi-threaded runtime worker** ("can call blocking only when |
| 439 | running on the multi-threaded runtime"). The builder's task context and |
| 440 | `cx.spawn` tasks are not safe for this. |
| 441 | |
| 442 | siGit's fix: **do the blocking model load on a dedicated `std::thread` with its |
| 443 | own fresh `tokio::runtime::Runtime`**, and signal completion back via an |
| 444 | `AtomicBool` / `oneshot` channel. Never call `load_gguf_model` directly inside a |
| 445 | prompt handler or a `cx.spawn` task. |
| 446 | |
| 447 | ```rust |
| 448 | std::thread::spawn(move || { |
| 449 | let rt = tokio::runtime::Runtime::new().unwrap(); |
| 450 | let result = rt.block_on(loader_engine.load_gguf_model(cfg, prompt, sampling)); |
| 451 | // store result, flip an AtomicBool / send on a oneshot |
| 452 | }); |
| 453 | ``` |
| 454 | |
| 455 | The prompt handler then `await`s readiness (siGit polls `model_ready` on a 1s |
| 456 | `tokio::time::interval`, streaming a spinner via `cx.send_notification`). |
| 457 | |
| 458 | --- |
| 459 | |
| 460 | ## Logging |
| 461 | |
| 462 | stdout is the ACP JSON-RPC wire — **log only to stderr.** siGit uses |
| 463 | `tracing_subscriber` to stderr: |
| 464 | |
| 465 | ```rust |
| 466 | tracing_subscriber::fmt::Subscriber::builder() |
| 467 | .with_env_filter(EnvFilter::try_from_default_env() |
| 468 | .unwrap_or_else(|_| EnvFilter::new("info"))) |
| 469 | .with_writer(std::io::stderr) |
| 470 | .try_init(); |
| 471 | ``` |
| 472 | |
| 473 | In siGit's interactive TTY mode (not ACP), it goes further and redirects the |
| 474 | stdout/stderr **fds** to `$TMPDIR/sigit.log` so mistralrs/native noise can't |
| 475 | corrupt the ratatui screen. ACP mode keeps stdout pristine for protocol JSON. |
| 476 | |
| 477 | --- |
| 478 | |
| 479 | ## TTY vs ACP split |
| 480 | |
| 481 | `main()` decides mode from `std::io::stdin().is_terminal()`: |
| 482 | |
| 483 | - **TTY** → interactive ratatui chat (`run_interactive`, Unix-only — needs fd |
| 484 | redirection). |
| 485 | - **non-TTY** → `run_acp_server()` (editor launched it over a pipe). |
| 486 | |
| 487 | Account verbs (`sigit login` / `logout` / `whoami`) are handled before the split, |
| 488 | since the editor launches `sigit login` in an embedded terminal. |
| 489 | |
| 490 | --- |
| 491 | |
| 492 | ## Protocol flow |
| 493 | |
| 494 | ``` |
| 495 | Editor Agent |
| 496 | │── initialize ──────────────────────►│ capabilities + auth methods + meta |
| 497 | │◄─ InitializeResponse ───────────────│ |
| 498 | │── authenticate ────────────────────►│ (button → verify stored session) |
| 499 | │◄─ AuthenticateResponse ─────────────│ |
| 500 | │── session/new (or load / fork) ───►│ cwd, reset history |
| 501 | │◄─ …Response(config_options) ────────│ |
| 502 | │◄─ session/update AvailableCommands ─│ advertise slash commands |
| 503 | │── session/setConfigOption ─────────►│ (model picker) → ToolCall progress |
| 504 | │── session/prompt ──────────────────►│ user message (text + resources) |
| 505 | │◄─ session/update (N×) ──────────────│ streaming chunks / tool-call cards |
| 506 | │◄─ PromptResponse(EndTurn) ──────────│ |
| 507 | │── session/cancel (notification) ───►│ |
| 508 | │── [disconnect] ─────────────────────►│ connect_to future resolves → shutdown |
| 509 | ``` |
| 510 | |
| 511 | --- |
| 512 | |
| 513 | ## Zed configuration |
| 514 | |
| 515 | ```json |
| 516 | { |
| 517 | "agent_servers": { |
| 518 | "siGit Code": { |
| 519 | "type": "custom", |
| 520 | "command": "/absolute/path/to/target/release/sigit" |
| 521 | } |
| 522 | } |
| 523 | } |
| 524 | ``` |
| 525 | |
| 526 | --- |
| 527 | |
| 528 | ## Gotchas |
| 529 | |
| 530 | 1. **No `Agent` trait to implement** — it's a builder. Register handler closures |
| 531 | with `.on_receive_request(closure, on_receive_request!())` and finish with |
| 532 | `.connect_to(transport)`. The `on_receive_request!()` / `on_receive_notification!()` |
| 533 | macro is mandatory per handler. |
| 534 | 2. **`cx: ConnectionTo<Client>` replaces the mpsc forwarder** — send notifications |
| 535 | with `cx.send_notification(...)` and background tasks with `cx.spawn(...)`. |
| 536 | Don't reintroduce the old channel-based circular-dependency pattern. |
| 537 | 3. **`Error::internal()` doesn't exist** — use `Error::new(-32603, msg)`. |
| 538 | 4. **Everything in `agent_client_protocol::schema` is `#[non_exhaustive]`** — use |
| 539 | builder methods, never struct literals; add `_ => …` wildcards when matching. |
| 540 | 5. **`ByteStreams::new(stdout, stdin)`** — writer first, reader second. Adapt |
| 541 | tokio stdio with `.compat()` / `.compat_write()` (tokio-util). |
| 542 | 6. **`block_in_place` panics in handler/`cx.spawn` tasks** — run mistralrs model |
| 543 | loads on a dedicated `std::thread` + its own `Runtime`; signal back via |
| 544 | `AtomicBool`/`oneshot`. Never load inside a prompt handler directly. |
| 545 | 7. **Empty `authMethods` hangs Zed** — always include at least one |
| 546 | `AuthMethod::Agent(AuthMethodAgent::new("id", "Name"))`. Prefer `Agent` over |
| 547 | `Terminal` for custom agents (Zed never spawns the terminal for them). |
| 548 | 8. **Never write to stdout except JSON-RPC** — log to stderr; in TTY mode siGit |
| 549 | redirects fds to `$TMPDIR/sigit.log`. Any stray `println!` or native library |
| 550 | stdout write corrupts the wire. |
| 551 | 9. **Unstable features gate real types** — `unstable_session_fork`, |
| 552 | `unstable_session_additional_directories`, `unstable_auth_methods` must be on |
| 553 | in `Cargo.toml` or `ForkSessionRequest`, `additional_directories`, and |
| 554 | `AuthMethod::Agent` won't exist. |
| 555 | 10. **Zed re-fires the last config selection on connect** — make |
| 556 | `setConfigOption` a no-op when the requested model is already active, and |
| 557 | never start a model switch while a startup load is still in flight (GPU OOM). |
| 558 | 11. **Store `SessionId` as `SessionId`**, not `String`, so `==` is clean. |
| 559 | 12. **`SetSessionConfigOptionResponse::new(config_options)`** — the response |
| 560 | carries the *rebuilt* options so the picker reflects the new current value. |
| 561 | |
| 562 | --- |
| 563 | |
| 564 | ## Where to look in the code |
| 565 | |
| 566 | Everything ACP lives in `src/main.rs`: |
| 567 | |
| 568 | - `run_acp_server` — builder wiring + transport. |
| 569 | - `SiGitAgent` + `handle_*` — the handlers. |
| 570 | - `build_model_config_options` / `resolve_model_config` — picker. |
| 571 | - `parse_slash` / `exec_slash_acp` — slash commands. |
| 572 | - `handle_response` — the `Responder` helper. |
| 573 | |
| 574 | `src/backend.rs` holds the `InferenceBackend` trait (`LocalBackend` / |
| 575 | `OpenAiBackend`) used by `handle_prompt`'s tool-calling loop; `src/tools.rs` |
| 576 | defines the agent tools and `execute_tool`. |