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