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 or prompt handlers, streaming notifications, or editor integration.
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
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.10.4"` (latest as of 2025)
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]
24
-agent-client-protocol = "0.10.4"
25
-async-trait = "0.1"
26
-tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "io-std", "io-util", "sync"] }
27
-tokio-util = { version = "0.7", features = ["compat"] }
28
-futures = "0.3"
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
33
-## The `Agent` trait
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
35
-Declared `#[async_trait::async_trait(?Send)]` — futures are `!Send`.
36
-Your impl needs the same annotation:
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
39
-#[async_trait::async_trait(?Send)]
40
-impl Agent for MyAgent {
41
- async fn initialize(&self, args: InitializeRequest) -> Result<InitializeResponse> { ... }
42
- async fn authenticate(&self, args: AuthenticateRequest) -> Result<AuthenticateResponse> { ... }
43
- async fn new_session(&self, args: NewSessionRequest) -> Result<NewSessionResponse> { ... }
44
- async fn prompt(&self, args: PromptRequest) -> Result<PromptResponse> { ... }
45
- async fn cancel(&self, args: CancelNotification) -> Result<()> { ... }
46
- // All other methods have default impls that return Error::method_not_found()
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
50
-You must implement `initialize`, `authenticate`, `new_session`, `prompt`, and `cancel`.
51
-Everything else (`load_session`, `set_session_mode`, etc.) defaults to `Err(method_not_found)`.
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
57
-All `#[non_exhaustive]` structs require builder methods — struct literal syntax won't compile.
209
+All `#[non_exhaustive]` structs require builder methods — struct-literal syntax
210
+won't compile.
211
59
-### `InitializeRequest` / `InitializeResponse`
212
+### `InitializeResponse`
213
214
```rust
62
-// Response builder — use ProtocolVersion::V1, NOT args.protocol_version:
63
-InitializeResponse::new(ProtocolVersion::V1)
215
+Ok(InitializeResponse::new(ProtocolVersion::V1) // use V1, not args.protocol_version
216
.agent_info(
65
- Implementation::new("my-agent", env!("CARGO_PKG_VERSION"))
66
- .title("My Agent"),
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
)
68
- .auth_methods(vec![AuthMethod::Agent(AuthMethodAgent::new(
69
- "my-agent", "My Agent",
70
- ))])
71
- .agent_capabilities(AgentCapabilities::default())
232
+ .meta(initialize_meta())) // free-form Meta (see below)
233
```
234
74
-`auth_methods` must include at least one `AuthMethod::Agent` or Zed hangs on
75
-"Loading…" forever. Import `AuthMethod`, `AuthMethodAgent`, and `ProtocolVersion`
76
-from the crate.
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
81
-Ok(AuthenticateResponse::default()) // No auth = just return default
255
+Ok(AuthenticateResponse::default()) // success
256
+// failure: return an Error — siGit uses -32000 "not signed in …"
257
```
258
84
-### `NewSessionResponse`
259
+### `NewSessionResponse` / `LoadSessionResponse` / `ForkSessionResponse`
260
261
```rust
262
let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
88
-Ok(NewSessionResponse::new(session_id))
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>`,
92
-and `AsRef<str>`. Store it as-is (not as `String`) so `==` works directly.
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
94
-### `PromptRequest`
277
+### `PromptRequest` / blocks
278
279
```rust
97
-args.session_id // type: SessionId
98
-args.prompt // type: Vec<ContentBlock>
280
+args.session_id // SessionId
281
+args.prompt // Vec<ContentBlock>
282
```
283
101
-Extract user text from the prompt:
284
+Editors send several block kinds — handle the three siGit cares about:
285
+
286
```rust
103
-let user_text: String = args.prompt.iter()
104
- .filter_map(|block| match block {
105
- ContentBlock::Text(t) => Some(t.text.as_str()),
106
- _ => None,
107
- })
108
- .collect::<Vec<_>>()
109
- .join("\n");
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))
116
-// Other reasons: MaxTokens, Cancelled, MaxTurnRequests, Refusal
310
+// other reasons: MaxTokens, Cancelled, MaxTurnRequests, Refusal
311
```
312
119
-### `ContentBlock`
313
+### Streaming: `ContentChunk` + `SessionUpdate` + `SessionNotification`
314
315
```rust
122
-// Text block — use the From impl:
123
-ContentBlock::from("some text") // impl From<T: Into<String>> for ContentBlock
124
-
125
-// Pattern-match incoming blocks:
126
-match block {
127
- ContentBlock::Text(t) => t.text.as_str(),
128
- ContentBlock::ResourceLink(_) => ...,
129
- ContentBlock::Resource(_) => ...,
130
- _ => ..., // non_exhaustive — always need a wildcard
131
-}
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
134
-### `ContentChunk` + `SessionUpdate` — streaming
321
+`SessionUpdate` variants siGit uses:
322
136
-```rust
137
-let chunk = ContentChunk::new(ContentBlock::from(delta_text));
138
-let update = SessionUpdate::AgentMessageChunk(chunk);
139
-// Other variants: UserMessageChunk, AgentThoughtChunk, ToolCall, Plan, ...
140
-```
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
142
-### `SessionNotification` — send streaming content to client
333
+siGit reuses tool-call cards as a generic progress UI (model loading/download):
334
335
```rust
145
-let notification = SessionNotification::new(session_id.clone(), update);
146
-// Deliver via AgentSideConnection::session_notification()
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
152
-// There is NO Error::internal(msg) method — use:
153
-agent_client_protocol::Error::new(-32603, "your message here")
154
-
155
-// For invalid params:
156
-agent_client_protocol::Error::invalid_params()
157
-
158
-// For method not found (already the trait default):
159
-agent_client_protocol::Error::method_not_found()
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
164
-## Running the agent — `AgentSideConnection`
365
+## Config options — the editor model picker
366
166
-Wraps stdin/stdout with JSON-RPC machinery.
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
169
-use futures::future::LocalBoxFuture;
170
-use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
171
-
172
-// Adapt tokio I/O to futures AsyncRead/AsyncWrite (the SDK expects these)
173
-let stdin = tokio::io::stdin().compat();
174
-let stdout = tokio::io::stdout().compat_write();
175
-
176
-// Must run inside a LocalSet — the spawn fn takes LocalBoxFuture (!Send)
177
-let local = tokio::task::LocalSet::new();
178
-local.run_until(async move {
179
- let (conn, io_task) = AgentSideConnection::new(
180
- agent,
181
- stdout,
182
- stdin,
183
- |fut: LocalBoxFuture<'static, ()>| {
184
- tokio::task::spawn_local(fut); // requires LocalSet context
185
- },
186
- );
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
188
- // ... set up forwarder task using conn ...
387
+Return these from new/load/fork session via `.config_options(config_options)`.
388
+When the user picks one, the client sends `SetSessionConfigOptionRequest`:
389
190
- io_task.await // drives JSON-RPC until client disconnects
191
-}).await;
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
194
-`AgentSideConnection::new` returns `(conn, io_task)` — you need both. `io_task`
195
-drives the actual IO; `conn` sends notifications. The spawn closure gets
196
-`LocalBoxFuture<'static, ()>` (not Send), so use `tokio::task::spawn_local`,
197
-not `tokio::spawn`. Everything must sit inside
198
-`tokio::task::LocalSet::new().run_until(...)`.
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
202
-## Streaming — circular dependency pattern
410
+## Slash commands
411
204
-`Agent::prompt()` needs to send `SessionNotification` through the connection,
205
-but the connection is built *from* the agent. Break the cycle with an mpsc channel:
412
+Advertise them so the editor forwards `/`-prefixed input (Zed rejects unknown
413
+slash commands client-side):
414
415
```rust
208
-// 1. Create channel BEFORE the agent
209
-let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256);
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
211
-// 2. Pass sender into agent
212
-let agent = MyAgent { notification_tx, ... };
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
214
-// 3. Create connection
215
-let (conn, io_task) = AgentSideConnection::new(agent, stdout, stdin, |fut| {
216
- tokio::task::spawn_local(fut);
217
-});
433
+---
434
219
-// 4. Spawn forwarder that holds `conn`
220
-tokio::task::spawn_local(async move {
221
- while let Some(notification) = notification_rx.recv().await {
222
- conn.session_notification(notification).await.ok();
223
- }
224
-});
435
+## Concurrency: the `block_in_place` trap (still real)
436
226
-// 5. Run IO
227
-io_task.await;
228
-```
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
230
-Inside `prompt()`, push chunks through the channel:
447
```rust
232
-self.notification_tx.send(SessionNotification::new(
233
- session_id.clone(),
234
- SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(delta))),
235
-)).await.ok(); // ignore send errors (channel closed = client gone)
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
242
-Log to **stderr** — stdout is the ACP JSON-RPC wire:
462
+stdout is the ACP JSON-RPC wire — **log only to stderr.** siGit uses
463
+`tracing_subscriber` to stderr:
464
465
```rust
245
-env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
246
- .target(env_logger::Target::Stderr)
247
- .init();
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
```
255
-Editor Agent
256
- │ │
257
- │── initialize ────────────────►│ (negotiate version + capabilities)
258
- │◄─ InitializeResponse ─────────│
259
- │ │
260
- │── authenticate ──────────────►│ (method_id from authMethods)
261
- │◄─ AuthenticateResponse ───────│
262
- │ │
263
- │── session/new ───────────────►│ (create session, load model)
264
- │◄─ NewSessionResponse ─────────│
265
- │ │
266
- │── session/prompt ────────────►│ (user message)
267
- │◄─ session/update (N times) ───│ (streaming tokens via notification)
268
- │◄─ PromptResponse ─────────────│ (stop_reason = EndTurn when done)
269
- │ │
270
- │── session/cancel (optional) ──►│
271
- │ │
272
- │── [disconnect] ───────────────►│ (io_task future resolves → shutdown)
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
---
515
```json
516
{
517
"agent_servers": {
282
- "MyAgent": {
518
+ "siGit Code": {
519
"type": "custom",
284
- "command": "/path/to/binary"
520
+ "command": "/absolute/path/to/target/release/sigit"
521
}
522
}
523
}
527
528
## Gotchas
529
294
-1. **`Error::internal()` doesn't exist** — use `Error::new(-32603, msg)`.
295
-2. **All protocol structs are `#[non_exhaustive]`** — use builder methods,
296
- never struct literals. Add `_ => ...` wildcards when matching.
297
-3. **`LocalBoxFuture` is `!Send`** — `tokio::spawn` won't work; use
298
- `tokio::task::spawn_local` inside a `LocalSet`.
299
-4. **`tokio::task::spawn_local` panics outside a `LocalSet`** — wrap with
300
- `LocalSet::new().run_until(async { ... }).await`.
301
-5. **Store `SessionId` as `SessionId`**, not `String` — otherwise `==`
302
- comparisons get annoying.
303
-6. **One session per connection is fine for MVP** — reuse the model with
304
- `clear_history()` instead of reloading.
305
-7. **`AgentCapabilities::default()` exists** — all capabilities None/false.
306
-8. **`block_in_place` panics inside `spawn_local`** — dependencies that call
307
- `tokio::task::block_in_place` internally (e.g. `mistralrs`) will blow up
308
- with "can call blocking only when running on the multi-threaded runtime"
309
- from a `spawn_local` task. Fix: do the blocking work *before* entering
310
- the `LocalSet`, while you're still on a normal multi-thread worker, then
311
- pass the result into your agent struct.
312
-9. **Empty `authMethods` hangs Zed** — `InitializeResponse` with an empty
313
- `auth_methods` vec makes Zed show "Loading…" forever. Always include at
314
- least one `AuthMethod::Agent(AuthMethodAgent::new("id", "Name"))`.
315
- Import `AuthMethod`, `AuthMethodAgent`, and `ProtocolVersion` from the crate.
316
-10. **Never write to stdout except JSON-RPC** — any library that prints to
317
- stdout (`mistralrs` model metadata, stray `println!`, whatever) will
318
- corrupt the wire. Redirect diagnostics to stderr. If a dependency writes
319
- to stdout internally, fix it or suppress it before shipping.
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`.