docs(agents): sync skills with current code and add CLAUDE.md
Update the .agents skill files to match the current codebase:
- agent-client-protocol: rewrite for agent-client-protocol 0.13 — the
builder API (Agent.builder().on_receive_request(...)) replaces the old
Agent trait impl, ConnectionTo<Client> replaces the mpsc forwarder, plus
session fork, config options (model picker), and slash commands.
- tool-calling: tool calling now covers the Qwen 3 family and Qwen 2.5
Coder 7B (not Qwen 3 only); document the InferenceBackend layer
(LocalBackend/OpenAiBackend cloud tiers); models live in src/models.rs;
onde is a crates.io dep; fix max_tokens and default-model claims.
- ai-assisted-coding: onde is published on crates.io; replace the mpsc
streaming example with cx.send_notification; fix the resource variant
name and the block_in_place guidance.
- sigit-code-release: add the release-crates.yml and release-homebrew.yml
workflows.
Add CLAUDE.md with repo guidance for Claude Code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
paydii committedJun 24, 2026 at 20:45 UTC3de9ecec76bfc2b7eb537a3c02a36b97deb33a76
5 files changed+681-238
.agents/skills/agent-client-protocol/SKILL.md
+437-180
index 2e9dd5e..efed935 100644--- a/.agents/skills/agent-client-protocol/SKILL.md+++ b/.agents/skills/agent-client-protocol/SKILL.md@@ -1,6 +1,6 @@ --- name: agent-client-protocol-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.+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. --- # Skill: Agent Client Protocol (ACP) — Rust Implementation@@ -11,265 +11,501 @@ ACP is a JSON-RPC 2.0 protocol over **stdio** for integrating AI coding agents with editors (Zed, JetBrains, Neovim, etc.). The agent runs as a subprocess; the editor is the client. Communication is newline-delimited JSON on stdin/stdout.-Crate: `agent-client-protocol = "0.10.4"` (latest as of 2025)+Crate: `agent-client-protocol = "0.13"` (siGit pins 0.13.0 in `Cargo.lock`) Docs: https://docs.rs/agent-client-protocol Spec: https://agentclientprotocol.com+> **Big change since 0.10:** the crate moved from a `#[async_trait(?Send)] impl Agent`+> model to a **builder** model. You no longer implement a trait. You build an+> `Agent` with per-message handler closures and `.connect_to(transport)`. Each+> handler receives a `ConnectionTo<Client>` (`cx`) you use to send notifications+> and spawn tasks — so the old mpsc "circular dependency" pattern is gone.++siGit's entire ACP server lives in `src/main.rs` (`run_acp_server`, the+`SiGitAgent` struct, and its `handle_*` methods). Read it alongside this skill.+ --- ## Dependency setup ```toml [dependencies]-agent-client-protocol = "0.10.4"-async-trait = "0.1"-tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "io-std", "io-util", "sync"] }-tokio-util = { version = "0.7", features = ["compat"] }-futures = "0.3"+agent-client-protocol = { version = "0.13", features = [+ "unstable_session_fork", # session/fork support+ "unstable_session_additional_directories", # additional_directories on session requests+ "unstable_auth_methods", # AuthMethod::Agent etc.+] }+async-trait = "0.1"+tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "io-std", "io-util", "sync", "time"] }+tokio-util = { version = "0.7", features = ["compat"] }+futures = "0.3"+uuid = { version = "1", features = ["v4"] } ```+The `unstable_*` features gate real types/methods (`ForkSessionRequest`,+`additional_directories`, `AuthMethod::Agent`). Without them the corresponding+APIs don't exist and you'll get "no variant/method" errors.+ ----## The `Agent` trait+## Imports++Protocol message/data types live under `agent_client_protocol::schema::*`.+Connection/runtime types live at the crate root.++```rust+use agent_client_protocol::schema::{+ AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse,+ AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, CancelNotification,+ ConfigOptionUpdate, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest,+ ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest,+ LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PromptRequest,+ PromptResponse, ProtocolVersion, SessionCapabilities, SessionConfigOption,+ SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,+ SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,+ SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,+ ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,+};+use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};+```++---++## Wiring up the server — the builder++You do **not** implement a trait. You hold your state in an `Arc<MyState>`, then+register one closure per incoming message type on `Agent.builder()`, and finish+with `.connect_to(transport).await`. The builder owns the JSON-RPC loop and runs+until the client disconnects.-Declared `#[async_trait::async_trait(?Send)]` — futures are `!Send`.-Your impl needs the same annotation:+```rust+use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};++async fn run_acp_server() -> anyhow::Result<()> {+ let state = Arc::new(SiGitAgent::new(/* … */));++ // Adapt tokio stdio to the futures AsyncRead/AsyncWrite the SDK expects.+ let stdin = tokio::io::stdin().compat();+ let stdout = tokio::io::stdout().compat_write();+ let transport = ByteStreams::new(stdout, stdin); // note: (writer, reader)++ Agent+ .builder()+ .on_receive_request(+ {+ let state = Arc::clone(&state);+ async move |req: InitializeRequest, responder, _cx: ConnectionTo<Client>| {+ handle_response(responder, state.handle_initialize(req).await)+ }+ },+ agent_client_protocol::on_receive_request!(),+ )+ .on_receive_request(+ {+ let state = Arc::clone(&state);+ async move |req: PromptRequest, responder, cx: ConnectionTo<Client>| {+ handle_response(responder, state.handle_prompt(&cx, req).await)+ }+ },+ agent_client_protocol::on_receive_request!(),+ )+ // … one .on_receive_request(…) per request type you support …+ .on_receive_notification(+ {+ let state = Arc::clone(&state);+ async move |notif: CancelNotification, _cx: ConnectionTo<Client>| {+ state.handle_cancel(notif).await+ }+ },+ agent_client_protocol::on_receive_notification!(),+ )+ .connect_to(transport)+ .await+ .map_err(|e| anyhow::anyhow!("ACP connection error: {e}"))?;++ Ok(())+}+```++Key points:++- **`Arc::clone(&state)` per closure.** Each handler closure is `move` and owns+ its own `Arc` clone of shared state.+- **The macro is required.** Each handler is paired with+ `agent_client_protocol::on_receive_request!()` (or `on_receive_notification!()`).+ It wires the closure's concrete message type into the dispatcher. Don't omit it.+- **Closure signature for requests:** `async move |req: T, responder, cx: ConnectionTo<Client>|`.+ Use `_cx` when a handler doesn't send notifications (e.g. `initialize`, `authenticate`).+- **Closure signature for notifications:** `async move |notif: T, cx: ConnectionTo<Client>|`+ returning `agent_client_protocol::Result<()>` — no responder (notifications get no reply).+- **Unmatched messages fall to the SDK default** — you only register what you support.++### The `Responder` + `handle_response` helper++Requests reply through a `Responder<T>`. siGit funnels every handler's+`Result` through one helper: ```rust-#[async_trait::async_trait(?Send)]-impl Agent for MyAgent {- async fn initialize(&self, args: InitializeRequest) -> Result<InitializeResponse> { ... }- async fn authenticate(&self, args: AuthenticateRequest) -> Result<AuthenticateResponse> { ... }- async fn new_session(&self, args: NewSessionRequest) -> Result<NewSessionResponse> { ... }- async fn prompt(&self, args: PromptRequest) -> Result<PromptResponse> { ... }- async fn cancel(&self, args: CancelNotification) -> Result<()> { ... }- // All other methods have default impls that return Error::method_not_found()+fn handle_response<T: agent_client_protocol::JsonRpcResponse>(+ responder: Responder<T>,+ result: agent_client_protocol::Result<T>,+) -> agent_client_protocol::Result<()> {+ match result {+ Ok(resp) => responder.respond(resp),+ Err(err) => responder.respond_with_error(err),+ } } ```-You must implement `initialize`, `authenticate`, `new_session`, `prompt`, and `cancel`.-Everything else (`load_session`, `set_session_mode`, etc.) defaults to `Err(method_not_found)`.+So each `handle_*` method just returns `Result<SomeResponse>` and stays free of+protocol plumbing.++---++## `ConnectionTo<Client>` — the `cx`++The per-handler `cx: ConnectionTo<Client>` replaces the old mpsc-channel forwarder.+It is `Clone`. Two things you do with it:++```rust+// 1. Send a server→client notification (streaming chunks, tool-call updates, …)+cx.send_notification(SessionNotification::new(session_id.clone(), update))?;++// 2. Spawn a background task that keeps using cx (e.g. a progress spinner poller).+let cx_for_poller = cx.clone();+cx.spawn(async move {+ loop {+ // … cx_for_poller.send_notification(progress_update) …+ # break;+ }+ Ok(())+}).ok();+```++Because `cx` is handed to you directly, there is **no circular dependency** between+the connection and the agent anymore. Don't reintroduce the mpsc forwarder pattern.++---++## The handlers siGit implements++| Message | Method | Notes |+|---------|--------|-------|+| `InitializeRequest` | `handle_initialize` | capabilities, auth methods, agent info, `meta` |+| `AuthenticateRequest` | `handle_authenticate` | verifies stored siGit Code Cloud session |+| `NewSessionRequest` | `handle_new_session` | sets cwd, resets history, advertises commands + config options |+| `LoadSessionRequest` | `handle_load_session` | like new_session; gated by `load_session(true)` capability |+| `ForkSessionRequest` | `handle_fork_session` | gated by `unstable_session_fork` + `SessionForkCapabilities` |+| `PromptRequest` | `handle_prompt` | the turn: parse blocks → slash commands or tool-calling loop |+| `SetSessionConfigOptionRequest` | `handle_set_session_config_option` | the Zed model picker — switches/downloads models |+| `CancelNotification` | `handle_cancel` | notification, no response |++Everything else is left to the SDK default (method not found). --- ## Types and their builders-All `#[non_exhaustive]` structs require builder methods — struct literal syntax won't compile.+All `#[non_exhaustive]` structs require builder methods — struct-literal syntax+won't compile.-### `InitializeRequest` / `InitializeResponse`+### `InitializeResponse` ```rust-// Response builder — use ProtocolVersion::V1, NOT args.protocol_version:-InitializeResponse::new(ProtocolVersion::V1)+Ok(InitializeResponse::new(ProtocolVersion::V1) // use V1, not args.protocol_version .agent_info(- Implementation::new("my-agent", env!("CARGO_PKG_VERSION"))- .title("My Agent"),+ Implementation::new("sigit", env!("CARGO_PKG_VERSION"))+ .title("siGit Code - AI Coding Agent"),+ )+ .auth_methods(vec![AuthMethod::Agent(+ AuthMethodAgent::new("sigit", "Sign in to siGit Code")+ .description("Sign in with `/login <email> <password>` in the message box."),+ )])+ .agent_capabilities(+ AgentCapabilities::default()+ .load_session(true) // enables LoadSessionRequest+ .session_capabilities(+ SessionCapabilities::new()+ .fork(SessionForkCapabilities::new()), // enables ForkSessionRequest+ ), )- .auth_methods(vec![AuthMethod::Agent(AuthMethodAgent::new(- "my-agent", "My Agent",- ))])- .agent_capabilities(AgentCapabilities::default())+ .meta(initialize_meta())) // free-form Meta (see below) ```-`auth_methods` must include at least one `AuthMethod::Agent` or Zed hangs on-"Loading…" forever. Import `AuthMethod`, `AuthMethodAgent`, and `ProtocolVersion`-from the crate.+`auth_methods` must include at least one `AuthMethod::Agent` or **Zed hangs on+"Loading…" forever.** siGit uses `Agent` (not `Terminal`) because Zed advertises+terminal-auth for custom agents but never actually spawns the login terminal, so+the button would be a silent no-op. With `Agent`, clicking calls `authenticate`.++### `Meta` — free-form server metadata++`Meta` is a string-keyed JSON map you can attach to `InitializeResponse` (siGit+publishes the active model there so the editor can show it):++```rust+let mut meta = Meta::new();+meta.insert("sigit".to_string(), serde_json::json!({+ "active_model": { "display_name": "...", "model_id": "...", "gguf_file": "..." }+}));+``` ### `AuthenticateResponse` ```rust-Ok(AuthenticateResponse::default()) // No auth = just return default+Ok(AuthenticateResponse::default()) // success+// failure: return an Error — siGit uses -32000 "not signed in …" ```-### `NewSessionResponse`+### `NewSessionResponse` / `LoadSessionResponse` / `ForkSessionResponse` ```rust let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());-Ok(NewSessionResponse::new(session_id))++Ok(NewSessionResponse::new(session_id).config_options(config_options))+Ok(LoadSessionResponse::new().config_options(config_options)) // no id arg — it's in the request+Ok(ForkSessionResponse::new(new_id).config_options(config_options)) ``` `SessionId` is a newtype with `Clone`, `PartialEq`, `Display`, `Into<String>`,-and `AsRef<str>`. Store it as-is (not as `String`) so `==` works directly.+`AsRef<str>`. Store it as-is so `==` works. `config_options` powers the editor's+per-session picker (see Config options below).++The session requests carry `cwd: PathBuf` and (with the feature)+`additional_directories: Vec<PathBuf>`. siGit stashes `cwd`, `set_current_dir`s+to it, and pushes a system message telling the model to use absolute paths under it.-### `PromptRequest`+### `PromptRequest` / blocks ```rust-args.session_id // type: SessionId-args.prompt // type: Vec<ContentBlock>+args.session_id // SessionId+args.prompt // Vec<ContentBlock> ```-Extract user text from the prompt:+Editors send several block kinds — handle the three siGit cares about:+ ```rust-let user_text: String = args.prompt.iter()- .filter_map(|block| match block {- ContentBlock::Text(t) => Some(t.text.as_str()),- _ => None,- })- .collect::<Vec<_>>()- .join("\n");+for block in &args.prompt {+ match block {+ ContentBlock::Text(t) => { /* t.text */ }+ ContentBlock::Resource(embedded) => match &embedded.resource {+ // editor already inlined file content+ EmbeddedResourceResource::TextResourceContents(tr) => { /* tr.uri, tr.text */ }+ EmbeddedResourceResource::BlobResourceContents(b) => { /* b.uri */ }+ _ => {}+ },+ ContentBlock::ResourceLink(link) => {+ // a reference (e.g. `@file`); read it yourself.+ // link.uri is "file:///abs/path#L207:219" (or #L207-219). Strip "file://",+ // split the "#L<start>:<end>" fragment, read & slice the lines.+ }+ _ => {} // non_exhaustive — always a wildcard+ }+} ``` ### `PromptResponse` ```rust Ok(PromptResponse::new(StopReason::EndTurn))-// Other reasons: MaxTokens, Cancelled, MaxTurnRequests, Refusal+// other reasons: MaxTokens, Cancelled, MaxTurnRequests, Refusal ```-### `ContentBlock`+### Streaming: `ContentChunk` + `SessionUpdate` + `SessionNotification` ```rust-// Text block — use the From impl:-ContentBlock::from("some text") // impl From<T: Into<String>> for ContentBlock--// Pattern-match incoming blocks:-match block {- ContentBlock::Text(t) => t.text.as_str(),- ContentBlock::ResourceLink(_) => ...,- ContentBlock::Resource(_) => ...,- _ => ..., // non_exhaustive — always need a wildcard-}+let chunk = ContentChunk::new(ContentBlock::from(delta_text)); // From<Into<String>>+let update = SessionUpdate::AgentMessageChunk(chunk);+cx.send_notification(SessionNotification::new(session_id.clone(), update))?; ```-### `ContentChunk` + `SessionUpdate` — streaming+`SessionUpdate` variants siGit uses:-```rust-let chunk = ContentChunk::new(ContentBlock::from(delta_text));-let update = SessionUpdate::AgentMessageChunk(chunk);-// Other variants: UserMessageChunk, AgentThoughtChunk, ToolCall, Plan, ...-```+- `AgentMessageChunk(ContentChunk)` — assistant text.+- `ToolCall(ToolCall)` — start a tool-call card (used for model load/download progress).+- `ToolCallUpdate(ToolCallUpdate)` — update that card's title/status/content.+- `AvailableCommandsUpdate(AvailableCommandsUpdate)` — advertise slash commands.+- `ConfigOptionUpdate(ConfigOptionUpdate)` — refresh the picker mid-session.++(Other variants exist: `UserMessageChunk`, `AgentThoughtChunk`, `Plan`, …)++### `ToolCall` / `ToolCallUpdate` — progress cards-### `SessionNotification` — send streaming content to client+siGit reuses tool-call cards as a generic progress UI (model loading/download): ```rust-let notification = SessionNotification::new(session_id.clone(), update);-// Deliver via AgentSideConnection::session_notification()+// open the card+SessionUpdate::ToolCall(+ ToolCall::new(tool_call_id.clone(), "Loading Qwen 2.5 3B")+ .kind(ToolKind::Think)+ .status(ToolCallStatus::InProgress)+ .content(vec!["Loading…".into()]),+)+// update it (only the fields you set)+SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(+ tool_call_id.clone(),+ ToolCallUpdateFields::new()+ .title("✓ Qwen 2.5 3B loaded")+ .status(ToolCallStatus::Completed),+)) ```+`ToolCallStatus`: `InProgress`, `Completed`, `Failed`. `ToolKind::Think` is the+"thinking/util" kind.+ ### `Error` ```rust-// There is NO Error::internal(msg) method — use:-agent_client_protocol::Error::new(-32603, "your message here")--// For invalid params:-agent_client_protocol::Error::invalid_params()--// For method not found (already the trait default):-agent_client_protocol::Error::method_not_found()+agent_client_protocol::Error::new(-32603, "internal error message") // there is NO Error::internal()+agent_client_protocol::Error::new(-32602, "invalid params: …") // or Error::invalid_params()+agent_client_protocol::Error::new(-32000, "not signed in …") // app-defined ``` ----## Running the agent — `AgentSideConnection`+## Config options — the editor model picker-Wraps stdin/stdout with JSON-RPC machinery.+ACP lets the agent expose per-session config controls; Zed renders them in the+agent panel. siGit uses one `select` option as a model picker. ```rust-use futures::future::LocalBoxFuture;-use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};--// Adapt tokio I/O to futures AsyncRead/AsyncWrite (the SDK expects these)-let stdin = tokio::io::stdin().compat();-let stdout = tokio::io::stdout().compat_write();--// Must run inside a LocalSet — the spawn fn takes LocalBoxFuture (!Send)-let local = tokio::task::LocalSet::new();-local.run_until(async move {- let (conn, io_task) = AgentSideConnection::new(- agent,- stdout,- stdin,- |fut: LocalBoxFuture<'static, ()>| {- tokio::task::spawn_local(fut); // requires LocalSet context- },- );+const MODEL_CONFIG_ID: &str = "sigit-model";++let options: Vec<SessionConfigSelectOption> = models.iter().map(|m| {+ SessionConfigSelectOption::new(+ SessionConfigValueId::new(m.model_id.as_str()),+ format!("{} {badge}", m.display_name),+ ).description(desc)+}).collect();++let config_options = vec![+ SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options)+ .category(SessionConfigOptionCategory::Model)+ .description("Select an on-device model or a siGit Code Cloud tier"),+];+```- // ... set up forwarder task using conn ...+Return these from new/load/fork session via `.config_options(config_options)`.+When the user picks one, the client sends `SetSessionConfigOptionRequest`:- io_task.await // drives JSON-RPC until client disconnects-}).await;+```rust+async fn handle_set_session_config_option(&self, cx: &ConnectionTo<Client>,+ args: SetSessionConfigOptionRequest) -> Result<SetSessionConfigOptionResponse> {+ if args.config_id.0.as_ref() != MODEL_CONFIG_ID { return Err(Error::new(-32602, "…")); }+ let model_id = args.value.0.as_ref();+ // … switch model, streaming ToolCall progress via cx …+ Ok(SetSessionConfigOptionResponse::new(rebuilt_config_options))+} ```-`AgentSideConnection::new` returns `(conn, io_task)` — you need both. `io_task`-drives the actual IO; `conn` sends notifications. The spawn closure gets-`LocalBoxFuture<'static, ()>` (not Send), so use `tokio::task::spawn_local`,-not `tokio::spawn`. Everything must sit inside-`tokio::task::LocalSet::new().run_until(...)`.+To refresh the picker mid-session (e.g. after `/reload`), push+`SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options))`.++**Gotcha:** Zed re-fires the last selection on (re)connect. Guard against a no-op+re-select of the already-active model, and don't try to load a new model while a+startup load is still in flight (the old weights still hold GPU memory → the new+load fails with "does not fit"). siGit waits for `model_ready` first. ----## Streaming — circular dependency pattern+## Slash commands-`Agent::prompt()` needs to send `SessionNotification` through the connection,-but the connection is built *from* the agent. Break the cycle with an mpsc channel:+Advertise them so the editor forwards `/`-prefixed input (Zed rejects unknown+slash commands client-side): ```rust-// 1. Create channel BEFORE the agent-let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256);+let commands = vec![+ AvailableCommand::new("help", "Show available commands"),+ AvailableCommand::new("models", "List available models").input(+ AvailableCommandInput::Unstructured(UnstructuredCommandInput::new(+ "model number to switch to (optional)"))),+ // … login/logout/whoami/reload/clear/status …+];+cx.send_notification(SessionNotification::new(+ session_id,+ SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)),+))?;+```-// 2. Pass sender into agent-let agent = MyAgent { notification_tx, ... };+siGit parses slash text out of the prompt itself (`parse_slash`) and dispatches in+`exec_slash_acp` before falling through to inference. The command turn still ends+with `Ok(PromptResponse::new(StopReason::EndTurn))`.-// 3. Create connection-let (conn, io_task) = AgentSideConnection::new(agent, stdout, stdin, |fut| {- tokio::task::spawn_local(fut);-});+----// 4. Spawn forwarder that holds `conn`-tokio::task::spawn_local(async move {- while let Some(notification) = notification_rx.recv().await {- conn.session_notification(notification).await.ok();- }-});+## Concurrency: the `block_in_place` trap (still real)-// 5. Run IO-io_task.await;-```+`mistralrs` model loading calls `tokio::task::block_in_place` internally, which+**panics off a multi-threaded runtime worker** ("can call blocking only when+running on the multi-threaded runtime"). The builder's task context and+`cx.spawn` tasks are not safe for this.++siGit's fix: **do the blocking model load on a dedicated `std::thread` with its+own fresh `tokio::runtime::Runtime`**, and signal completion back via an+`AtomicBool` / `oneshot` channel. Never call `load_gguf_model` directly inside a+prompt handler or a `cx.spawn` task.-Inside `prompt()`, push chunks through the channel: ```rust-self.notification_tx.send(SessionNotification::new(- session_id.clone(),- SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(delta))),-)).await.ok(); // ignore send errors (channel closed = client gone)+std::thread::spawn(move || {+ let rt = tokio::runtime::Runtime::new().unwrap();+ let result = rt.block_on(loader_engine.load_gguf_model(cfg, prompt, sampling));+ // store result, flip an AtomicBool / send on a oneshot+}); ```+The prompt handler then `await`s readiness (siGit polls `model_ready` on a 1s+`tokio::time::interval`, streaming a spinner via `cx.send_notification`).+ --- ## Logging-Log to **stderr** — stdout is the ACP JSON-RPC wire:+stdout is the ACP JSON-RPC wire — **log only to stderr.** siGit uses+`tracing_subscriber` to stderr: ```rust-env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))- .target(env_logger::Target::Stderr)- .init();+tracing_subscriber::fmt::Subscriber::builder()+ .with_env_filter(EnvFilter::try_from_default_env()+ .unwrap_or_else(|_| EnvFilter::new("info")))+ .with_writer(std::io::stderr)+ .try_init(); ```+In siGit's interactive TTY mode (not ACP), it goes further and redirects the+stdout/stderr **fds** to `$TMPDIR/sigit.log` so mistralrs/native noise can't+corrupt the ratatui screen. ACP mode keeps stdout pristine for protocol JSON.++---++## TTY vs ACP split++`main()` decides mode from `std::io::stdin().is_terminal()`:++- **TTY** → interactive ratatui chat (`run_interactive`, Unix-only — needs fd+ redirection).+- **non-TTY** → `run_acp_server()` (editor launched it over a pipe).++Account verbs (`sigit login` / `logout` / `whoami`) are handled before the split,+since the editor launches `sigit login` in an embedded terminal.+ --- ## Protocol flow ```-Editor Agent- │ │- │── initialize ────────────────►│ (negotiate version + capabilities)- │◄─ InitializeResponse ─────────│- │ │- │── authenticate ──────────────►│ (method_id from authMethods)- │◄─ AuthenticateResponse ───────│- │ │- │── session/new ───────────────►│ (create session, load model)- │◄─ NewSessionResponse ─────────│- │ │- │── session/prompt ────────────►│ (user message)- │◄─ session/update (N times) ───│ (streaming tokens via notification)- │◄─ PromptResponse ─────────────│ (stop_reason = EndTurn when done)- │ │- │── session/cancel (optional) ──►│- │ │- │── [disconnect] ───────────────►│ (io_task future resolves → shutdown)+Editor Agent+ │── initialize ──────────────────────►│ capabilities + auth methods + meta+ │◄─ InitializeResponse ───────────────│+ │── authenticate ────────────────────►│ (button → verify stored session)+ │◄─ AuthenticateResponse ─────────────│+ │── session/new (or load / fork) ───►│ cwd, reset history+ │◄─ …Response(config_options) ────────│+ │◄─ session/update AvailableCommands ─│ advertise slash commands+ │── session/setConfigOption ─────────►│ (model picker) → ToolCall progress+ │── session/prompt ──────────────────►│ user message (text + resources)+ │◄─ session/update (N×) ──────────────│ streaming chunks / tool-call cards+ │◄─ PromptResponse(EndTurn) ──────────│+ │── session/cancel (notification) ───►│+ │── [disconnect] ─────────────────────►│ connect_to future resolves → shutdown ``` ---@@ -279,9 +515,9 @@ Editor Agent ```json { "agent_servers": {- "MyAgent": {+ "siGit Code": { "type": "custom",- "command": "/path/to/binary"+ "command": "/absolute/path/to/target/release/sigit" } } }@@ -291,29 +527,50 @@ Editor Agent ## Gotchas-1. **`Error::internal()` doesn't exist** — use `Error::new(-32603, msg)`.-2. **All protocol structs are `#[non_exhaustive]`** — use builder methods,- never struct literals. Add `_ => ...` wildcards when matching.-3. **`LocalBoxFuture` is `!Send`** — `tokio::spawn` won't work; use- `tokio::task::spawn_local` inside a `LocalSet`.-4. **`tokio::task::spawn_local` panics outside a `LocalSet`** — wrap with- `LocalSet::new().run_until(async { ... }).await`.-5. **Store `SessionId` as `SessionId`**, not `String` — otherwise `==`- comparisons get annoying.-6. **One session per connection is fine for MVP** — reuse the model with- `clear_history()` instead of reloading.-7. **`AgentCapabilities::default()` exists** — all capabilities None/false.-8. **`block_in_place` panics inside `spawn_local`** — dependencies that call- `tokio::task::block_in_place` internally (e.g. `mistralrs`) will blow up- with "can call blocking only when running on the multi-threaded runtime"- from a `spawn_local` task. Fix: do the blocking work *before* entering- the `LocalSet`, while you're still on a normal multi-thread worker, then- pass the result into your agent struct.-9. **Empty `authMethods` hangs Zed** — `InitializeResponse` with an empty- `auth_methods` vec makes Zed show "Loading…" forever. Always include at- least one `AuthMethod::Agent(AuthMethodAgent::new("id", "Name"))`.- Import `AuthMethod`, `AuthMethodAgent`, and `ProtocolVersion` from the crate.-10. **Never write to stdout except JSON-RPC** — any library that prints to- stdout (`mistralrs` model metadata, stray `println!`, whatever) will- corrupt the wire. Redirect diagnostics to stderr. If a dependency writes- to stdout internally, fix it or suppress it before shipping.+1. **No `Agent` trait to implement** — it's a builder. Register handler closures+ with `.on_receive_request(closure, on_receive_request!())` and finish with+ `.connect_to(transport)`. The `on_receive_request!()` / `on_receive_notification!()`+ macro is mandatory per handler.+2. **`cx: ConnectionTo<Client>` replaces the mpsc forwarder** — send notifications+ with `cx.send_notification(...)` and background tasks with `cx.spawn(...)`.+ Don't reintroduce the old channel-based circular-dependency pattern.+3. **`Error::internal()` doesn't exist** — use `Error::new(-32603, msg)`.+4. **Everything in `agent_client_protocol::schema` is `#[non_exhaustive]`** — use+ builder methods, never struct literals; add `_ => …` wildcards when matching.+5. **`ByteStreams::new(stdout, stdin)`** — writer first, reader second. Adapt+ tokio stdio with `.compat()` / `.compat_write()` (tokio-util).+6. **`block_in_place` panics in handler/`cx.spawn` tasks** — run mistralrs model+ loads on a dedicated `std::thread` + its own `Runtime`; signal back via+ `AtomicBool`/`oneshot`. Never load inside a prompt handler directly.+7. **Empty `authMethods` hangs Zed** — always include at least one+ `AuthMethod::Agent(AuthMethodAgent::new("id", "Name"))`. Prefer `Agent` over+ `Terminal` for custom agents (Zed never spawns the terminal for them).+8. **Never write to stdout except JSON-RPC** — log to stderr; in TTY mode siGit+ redirects fds to `$TMPDIR/sigit.log`. Any stray `println!` or native library+ stdout write corrupts the wire.+9. **Unstable features gate real types** — `unstable_session_fork`,+ `unstable_session_additional_directories`, `unstable_auth_methods` must be on+ in `Cargo.toml` or `ForkSessionRequest`, `additional_directories`, and+ `AuthMethod::Agent` won't exist.+10. **Zed re-fires the last config selection on connect** — make+ `setConfigOption` a no-op when the requested model is already active, and+ never start a model switch while a startup load is still in flight (GPU OOM).+11. **Store `SessionId` as `SessionId`**, not `String`, so `==` is clean.+12. **`SetSessionConfigOptionResponse::new(config_options)`** — the response+ carries the *rebuilt* options so the picker reflects the new current value.++---++## Where to look in the code++Everything ACP lives in `src/main.rs`:++- `run_acp_server` — builder wiring + transport.+- `SiGitAgent` + `handle_*` — the handlers.+- `build_model_config_options` / `resolve_model_config` — picker.+- `parse_slash` / `exec_slash_acp` — slash commands.+- `handle_response` — the `Responder` helper.++`src/backend.rs` holds the `InferenceBackend` trait (`LocalBackend` /+`OpenAiBackend`) used by `handle_prompt`'s tool-calling loop; `src/tools.rs`+defines the agent tools and `execute_tool`.
.agents/skills/ai-assisted-coding/SKILL.md
+41-17
index c488cfb..fedac16 100644--- a/.agents/skills/ai-assisted-coding/SKILL.md+++ b/.agents/skills/ai-assisted-coding/SKILL.md@@ -11,7 +11,7 @@ Building a local AI coding agent in Rust using Onde Inference as the LLM backend Onde wraps mistral.rs with a clean API for model loading, history management, and streaming inference across macOS (Metal), iOS, Android, Linux, and Windows.-Crate: `onde = { path = "../onde" }` or from crates.io when published+Crate: `onde = "1.1.2"` (published on crates.io; siGit pins it in `Cargo.toml`) Repo: https://github.com/ondeinference/onde Docs: https://ondeinference.com@@ -165,15 +165,26 @@ GgufModelConfig::qwen25_1_5b() // force 1.5B GgufModelConfig::qwen25_3b() // force 3B GgufModelConfig::qwen25_coder_1_5b() // coder variant 1.5B GgufModelConfig::qwen25_coder_3b() // coder variant 3B+GgufModelConfig::qwen25_coder_7b() // coder variant 7B (tool calling)+GgufModelConfig::qwen3_1_7b() // Qwen 3 1.7B (tool calling)+GgufModelConfig::qwen3_4b() // Qwen 3 4B (tool calling)+GgufModelConfig::qwen3_8b() // Qwen 3 8B (tool calling)+GgufModelConfig::qwen3_14b() // Qwen 3 14B (tool calling) ```+Only the Qwen 3 family and Qwen 2.5 Coder 7B support tool calling — see the+`tool-calling` skill. The on-device default is the saved selection, falling back+to `platform_default()` (Qwen 2.5 3B on macOS).+ --- ## Adding onde as a Rust library dependency ```toml-# In your crate's Cargo.toml — onde is a path dep since it's not on crates.io yet-onde = { path = "../onde" }+# In your crate's Cargo.toml — onde is published on crates.io+onde = "1.1.2"+# For local SDK development against a checkout, swap to a path dep:+# onde = { path = "../onde" } ``` **Important:** `onde` declares `crate-type = ["lib", "cdylib", "staticlib"]`.@@ -262,20 +273,19 @@ Key principles: ### Streaming tokens to ACP (connecting onde → ACP) ```rust-// In Agent::prompt():+// In the prompt handler — cx: &ConnectionTo<Client> is passed in by the builder+// (agent-client-protocol 0.13). No mpsc forwarder; send through cx directly. let mut rx = self.engine.stream_message(user_text).await .map_err(|e| Error::new(-32603, e.to_string()))?; while let Some(chunk) = rx.recv().await { if !chunk.delta.is_empty() {- self.notification_tx.send(- SessionNotification::new(- session_id.clone(),- SessionUpdate::AgentMessageChunk(- ContentChunk::new(ContentBlock::from(chunk.delta)),- ),- )- ).await.ok(); // .ok() — ignore if forwarder is gone+ cx.send_notification(SessionNotification::new(+ session_id.clone(),+ SessionUpdate::AgentMessageChunk(+ ContentChunk::new(ContentBlock::from(chunk.delta)),+ ),+ )).ok(); // .ok() — ignore if the client is gone } if chunk.done { break; } }@@ -285,7 +295,13 @@ Ok(PromptResponse::new(StopReason::EndTurn)) The `PromptResponse` is returned AFTER the stream finishes. The client receives streaming tokens via `session/update` notifications while blocking on the-`session/prompt` response.+`session/prompt` response. See the `agent-client-protocol` skill for the `cx`+(`ConnectionTo<Client>`) model that replaced the old mpsc-channel forwarder.++> **Note:** siGit's actual `handle_prompt` does *not* stream token-by-token — it+> runs a tool-calling loop through an `InferenceBackend` and sends the final text+> in one `AgentMessageChunk`. The streaming pattern above still applies if you+> want incremental output. See the `tool-calling` skill for the backend loop. ---@@ -305,13 +321,18 @@ let user_text: String = args.prompt.iter() .join("\n"); ```-For future resource context (e.g. open files provided by Zed):+For resource context (e.g. open files provided by Zed) — note the variant is+`TextResourceContents`, not `Text`: ```rust ContentBlock::Resource(r) => match &r.resource {- EmbeddedResourceResource::Text(t) => Some(t.text.as_str()),+ EmbeddedResourceResource::TextResourceContents(t) => Some(t.text.as_str()),+ EmbeddedResourceResource::BlobResourceContents(_) => None, _ => None, }, ```+siGit also handles `ContentBlock::ResourceLink` (a `file://` reference it reads+from disk, including `#L<start>:<end>` line-range fragments). See the+`tool-calling` skill. ---@@ -321,8 +342,11 @@ ContentBlock::Resource(r) => match &r.resource { - Safe to wrap in `Arc<ChatEngine>` and share across tasks. - `stream_message()` spawns a `tokio::spawn` background task internally — the mistralrs model must be `Send`, which it is on all supported platforms.-- Calling `stream_message()` from a `!Send` future (e.g. inside a `LocalSet`) is- fine — the future itself doesn't hold a `!Send` value across `.await`.+- **`block_in_place` trap:** `load_gguf_model` calls `tokio::task::block_in_place`+ internally, which panics unless it's on a multi-threaded runtime worker. Run+ model loads on a dedicated `std::thread` with its own `tokio::runtime::Runtime`+ and signal back via `AtomicBool`/`oneshot`. siGit does exactly this in both ACP+ and TUI modes — see the `agent-client-protocol` and `tool-calling` skills. ---
.agents/skills/sigit-code-release/SKILL.md
+3-2
index 83ce33c..7dbd758 100644--- a/.agents/skills/sigit-code-release/SKILL.md+++ b/.agents/skills/sigit-code-release/SKILL.md@@ -29,7 +29,8 @@ Use this skill when preparing a release for this repository. - Add or update the top changelog entry in `CHANGELOG.md` for the release being cut. - Do not treat `npm/sigit/package.json` `0.0.0-dev` as a bug by default. The npm release workflow rewrites it at publish time using `npm/scripts/render-main-package.cjs` and the release tag. - Do not add a hardcoded version to `pypi/pyproject.toml` for normal releases. PyPI uses `maturin` with `dynamic = ["version"]` and derives the published package version from `Cargo.toml`.-- Release workflows are tag-driven. `release-github.yml`, `release-npm.yml`, and `release-pypi.yml` all derive `RELEASE_VERSION` from a `v*.*.*` tag or a manually supplied tag input.+- Release workflows are tag-driven. `release-github.yml`, `release-npm.yml`, `release-pypi.yml`, `release-crates.yml`, and `release-homebrew.yml` all derive `RELEASE_VERSION` from a `v*.*.*` tag or a manually supplied tag input.+- The crate is published to crates.io (`release-crates.yml`) and the Homebrew tap is updated (`release-homebrew.yml`) as part of the tag-driven flow. Per the siGit release flow, Homebrew is auto-triggered — do not dispatch it manually. ## Typical files to inspect@@ -41,7 +42,7 @@ Use this skill when preparing a release for this repository. - `npm/scripts/render-main-package.cjs` - `npm/` - `pypi/`-- `.github/workflows/`+- `.github/workflows/` (`release-github.yml`, `release-npm.yml`, `release-pypi.yml`, `release-crates.yml`, `release-homebrew.yml`) ## Release checklist
.agents/skills/tool-calling/SKILL.md
+95-39
index b1ed924..4ed8c0c 100644--- a/.agents/skills/tool-calling/SKILL.md+++ b/.agents/skills/tool-calling/SKILL.md@@ -9,33 +9,61 @@ description: Implement or debug tool calling in siGit Code across the app, Onde siGit Code supports **agentic tool calling** — the LLM invokes tools (read/write files, run commands, read websites) to operate on the user's codebase. This works in both **interactive TUI mode** and **ACP server mode** (Zed editor).-Tool calling spans three layers:+Tool calling spans these layers: ``` siGit (agent loop + tool execution)- → onde (ChatEngine with tool-aware API)- → mistral.rs (model inference + tool call parsing)+ → InferenceBackend (src/backend.rs — LocalBackend or OpenAiBackend/cloud)+ → onde ChatEngine (tool-aware API) ── for LocalBackend+ → mistral.rs (model inference + tool call parsing)+ └ OpenAI-compatible HTTP endpoint ── for OpenAiBackend (siGit Code Cloud) ```+The agent loop talks to an `InferenceBackend` trait object, not the engine+directly. `LocalBackend` wraps the on-device `ChatEngine`; `OpenAiBackend` calls+a remote OpenAI-compatible endpoint (the siGit Code Cloud tiers). Both implement+`send_message_with_tools` / `send_tool_results`, so the loop is identical.+ --- ## Model Requirement-**Only Qwen 3 supports tool calling.** Qwen 2.5 does NOT — mistral.rs only has a parser for Qwen 3's `<tool_call>...</tool_call>` XML format.+Tool calling needs a model mistral.rs has a tool-call parser for. The supported+set is the **Qwen 3 family** (`<tool_call>...</tool_call>` XML) plus **Qwen 2.5+Coder 7B**. Plain Qwen 2.5 and the smaller Qwen 2.5 Coder variants do NOT support+tool calling. The authoritative list is `is_tool_calling()` in `src/models.rs`.++| Model | Constructor | Size | Tool calling |+|-------|-----------|------|:---:|+| Qwen 3 14B (Q4_K_M) | `GgufModelConfig::qwen3_14b()` | ~9 GB | ✅ |+| Qwen 3 8B (Q4_K_M) | `GgufModelConfig::qwen3_8b()` | ~5 GB | ✅ |+| Qwen 3 4B (Q4_K_M) | `GgufModelConfig::qwen3_4b()` | ~2.7 GB | ✅ |+| Qwen 3 1.7B (Q4_K_M) | `GgufModelConfig::qwen3_1_7b()` | ~1.3 GB | ✅ |+| Qwen 2.5 Coder 7B | `GgufModelConfig::qwen25_coder_7b()` | ~5 GB | ✅ |+| Qwen 2.5 Coder 3B | `GgufModelConfig::qwen25_coder_3b()` | ~1.93 GB | ❌ |+| Qwen 2.5 Coder 1.5B | `GgufModelConfig::qwen25_coder_1_5b()` | ~941 MB | ❌ |+| Qwen 2.5 3B / 1.5B | `qwen25_3b()` / `qwen25_1_5b()` | ~1.93 GB / ~941 MB | ❌ |++### Default model++There is **no hardcoded default model.** Startup uses the saved selection+(`setup::startup_model_selection`), then the first complete locally-cached model,+falling back to `GgufModelConfig::platform_default()` (Qwen 2.5 3B on macOS) when+nothing is cached. The TUI/ACP code in `main.rs` uses `qwen25_3b()` as that final+fallback. Users pick a tool-calling model via the `/models` picker.-| Model | Constructor | Size | Tool calling | Default |-|-------|-----------|------|:---:|:---:|-| Qwen 3 8B (Q4_K_M) | `GgufModelConfig::qwen3_8b()` | ~5 GB | ✅ | ✅ **default** |-| Qwen 3 4B (Q4_K_M) | `GgufModelConfig::qwen3_4b()` | ~2.7 GB | ✅ | |-| Qwen 3 1.7B (Q4_K_M) | `GgufModelConfig::qwen3_1_7b()` | ~1.3 GB | ✅ | |-| Qwen 2.5 Coder 3B | `GgufModelConfig::qwen25_coder_3b()` | ~1.93 GB | ❌ | |-| Qwen 2.5 Coder 1.5B | `GgufModelConfig::qwen25_coder_1_5b()` | ~941 MB | ❌ | |+### max_tokens-siGit uses **Qwen 3 8B** by default with `max_tokens: 8192` (set in `main.rs` for both TUI and ACP modes).+`max_tokens_for()` in `src/models.rs` gives tool-calling models **4096** tokens+and non-tool models **512** (tool models need headroom because `<think>` blocks+eat the budget). The TUI startup load in `run_interactive` overrides this to+**8192**. Don't assume a single value.-### Why 8B over 4B+### Why prefer 8B+ over 4B for editing-4B can't do `edit_file` reliably. It reads a file, then fails to reproduce the exact `old_text` it just saw. This spirals into 7+ retry rounds that burn through `max_tokens` on `<think>` blocks and return nothing. 8B is the smallest model that actually lands edits.+4B struggles with `edit_file`: it reads a file, then fails to reproduce the exact+`old_text` it just saw, spiralling into retry rounds that burn `max_tokens` on+`<think>` blocks and return nothing. 8B (or larger) lands edits far more reliably. ### bartowski GGUF naming convention@@ -76,7 +104,9 @@ Defined in `sigit/src/tools.rs` via `all_tools()`: ### Tool gating by model-In TUI mode, `run_inference_task()` takes a `tools_enabled: bool` parameter. When the model's `ModelOption.tool_calling` is `false` (Qwen 2.5), an empty tool list is passed so the model doesn't receive tool schemas it can't use.+In TUI mode, `run_inference_task()` takes a `tools_enabled: bool` parameter. When the picker item's `tool_calling` (from `models::is_tool_calling`) is `false`, an empty tool list is passed so the model doesn't receive tool schemas it can't use.++In ACP mode, `handle_prompt` currently always passes the full tool set (`agent_tools_as_specs()`) regardless of the active model — there is no per-model gate on the ACP path. ---@@ -109,6 +139,24 @@ In TUI mode, `run_inference_task()` takes a `tools_enabled: bool` parameter. Whe | `send_message_with_tools(msg, &[ToolDefinition])` | Returns `ToolAwareResult` with possible tool calls | | `send_tool_results(Vec<ToolResult>, Option<&[ToolDefinition]>)` | Feed results back; `None` forces text response |+#### Layer 2.5: the `InferenceBackend` abstraction (`src/backend.rs`)++siGit doesn't call the engine directly from the agent loop — it goes through the+`InferenceBackend` trait so on-device and cloud inference share one code path:++| Item | Purpose |+|------|---------|+| `trait InferenceBackend` | `send_message_with_tools` / `send_tool_results` / `is_remote` |+| `LocalBackend` | wraps `Arc<ChatEngine>` — on-device inference |+| `OpenAiBackend` | OpenAI-compatible HTTP client — siGit Code Cloud tiers |+| `ToolSpec` | backend-level tool definition (`name`, `description`, `parameters_schema`) |+| `ToolCall` / `ToolResult` / `TurnResult` | backend-level request/result types |++`handle_prompt` snapshots `self.backend.lock().await.clone()` once per turn so a+mid-turn model/tier switch can't split the conversation across backends. When+`backend.is_remote()` it skips the local model load + readiness wait. Cloud tiers+(`fast`, `balanced`, `large`) come from `src/provider.rs` and are sign-in gated.+ #### Internal details - `attach_tools()` converts `ToolDefinition` → mistral.rs `Tool`, sets `ToolChoice::Auto` and `strict: Some(true)`@@ -148,10 +196,11 @@ siGit parses this into path `/path/to/index.html` + lines 207–219. ## The Agentic Loop-Both ACP mode (`SiGitAgent::prompt()`) and TUI mode (`run_inference_task()`) implement:+Both ACP mode (`SiGitAgent::handle_prompt()`) and TUI mode (`run_inference_task()`)+implement the same loop, driven through the active `InferenceBackend`: ```-1. engine.send_message_with_tools(user_text, &tools) → ToolAwareResult+1. backend.send_message_with_tools(user_text, &tools) → TurnResult 2. while result.tool_calls is non-empty AND round < MAX_TOOL_ROUNDS (10): a. For each tool_call: - Log: → tool_name(arguments)@@ -161,22 +210,29 @@ Both ACP mode (`SiGitAgent::prompt()`) and TUI mode (`run_inference_task()`) imp b. Decide next_tools: - round < MAX_TOOL_ROUNDS → Some(&tools) (allow more calls) - else → None (force text response)- c. engine.send_tool_results(results, next_tools) → ToolAwareResult-3. Send final result.text to user+ c. backend.send_tool_results(results, next_tools) → TurnResult+3. Strip <think> blocks (chat::strip_think_blocks), send final text to user - Empty reply after tool rounds → log warning (ACP) or show error (TUI) ```+In ACP mode the final text is sent as one `AgentMessageChunk`; the tool-calling+loop is not streamed token-by-token.+ --- ## System Prompt-The `SYSTEM_PROMPT` in `main.rs` (~122 lines) includes critical instructions:+`main.rs` defines **two** prompts, picked by `system_prompt_for_model(tool_calling)`:-- **Never tell the user to run commands** — use `run_command` tool instead-- **Can access websites** — use `read_website` tool (overrides RLHF refusal training)-- **Prefer absolute paths** in all tool arguments-- **Git operations** — always use `run_command` with absolute cwd-- **smbCloud domain knowledge** — auth boundaries, deploy flows, project structure+- **`SYSTEM_PROMPT`** (~120 lines) — the full agentic prompt for tool-calling models:+ - **Never tell the user to run commands** — use `run_command` tool instead+ - **Can access websites** — use `read_website` tool (overrides RLHF refusal training)+ - **Prefer absolute paths** in all tool arguments+ - **Git operations** — always use `run_command` with absolute cwd+ - **Always re-read a file before `edit_file`** — don't trust stale content+ - **smbCloud domain knowledge** — auth boundaries, deploy flows, project structure+- **`SIMPLE_SYSTEM_PROMPT`** — a short prompt for non-tool models; the full one+ wastes context and confuses them. The session `cwd` is injected as a separate system message at session creation time (not part of the static prompt).@@ -209,8 +265,8 @@ No changes needed in onde or mistral.rs — tool definitions are passed dynamica 1. **`onde/src/inference/models.rs`** — add `pub const` for repo ID and GGUF filename, add to `SUPPORTED_MODELS` array and `SUPPORTED_MODEL_INFO` 2. **`onde/src/inference/engine.rs`** — add `pub fn model_name() -> Self` constructor to `impl GgufModelConfig`-3. **`sigit/src/chat.rs`** — add `ModelOption` entry to `SIGIT_MODELS` with `tool_calling: true/false`-4. **`sigit/src/main.rs`** — update `run_interactive()` and `run_acp_server()` if changing the default+3. **`sigit/src/models.rs`** — add a match arm to `model_id_to_config()` mapping the repo ID to the new constructor; if it supports tool calling, add the repo ID to `is_tool_calling()` (which also drives `max_tokens_for()`). The picker (`build_model_picker_items`) then surfaces it automatically.+4. **`sigit/src/main.rs`** — only if you're changing the fallback default (`qwen25_3b()`) ---@@ -266,19 +322,16 @@ could not read ResourceLink file:///path/to/index.html#L207:219: No such file or ## Cargo Dependency Note-For local development, `sigit/Cargo.toml` must use the path dependency:--```toml-onde = { path = "../onde" }-```--For CI/release, switch to the git dependency (after pushing Onde changes):+`onde` is published on crates.io; `sigit/Cargo.toml` pins it: ```toml-onde = { git = "https://github.com/ondeinference/onde", branch = "development" }+onde = "1.1.2" ```-The `qwen3_8b()` constructor only exists in the local Onde SDK until it's pushed to the `development` branch.+The Qwen 3 / Coder-7B constructors (`qwen3_8b()`, etc.) ship in that release. For+local SDK development against an `onde` checkout, swap to a path dep+(`onde = { path = "../onde" }`) — but the committed form must stay the crates.io+version so CI/release builds resolve. ---@@ -287,9 +340,12 @@ The `qwen3_8b()` constructor only exists in the local Onde SDK until it's pushed | File | What it does | |------|-------------| | `sigit/src/tools.rs` | 9 tool schemas (`all_tools()`), `execute_tool()` dispatch, all `exec_*` implementations |-| `sigit/src/main.rs` | `SYSTEM_PROMPT`, `SiGitAgent` struct with `session_cwd`, ACP session handlers (cwd + push_history), `prompt()` with content block parsing, model selection (`qwen3_8b`), `MAX_TOOL_ROUNDS` |-| `sigit/src/chat.rs` | `SIGIT_MODELS` array (4 models), `run_inference_task()` with `tools_enabled` gate, TUI tool loop |-| `sigit/src/setup.rs` | HF cache setup pointing to shared App Group container |+| `sigit/src/main.rs` | `SYSTEM_PROMPT`, `SiGitAgent` struct with `session_cwd` + `backend`, ACP handlers (cwd + push_history), `handle_prompt()` content-block parsing + tool loop, `MAX_TOOL_ROUNDS`, ACP builder wiring |+| `sigit/src/backend.rs` | `InferenceBackend` trait, `LocalBackend`, `OpenAiBackend`, `ToolSpec`/`ToolCall`/`ToolResult`/`TurnResult` |+| `sigit/src/models.rs` | `ModelPickerItem`, `model_id_to_config()`, `is_tool_calling()`, `max_tokens_for()`, `build_model_picker_items()` / `local_picker_items()` |+| `sigit/src/provider.rs` | `CLOUD_TIERS`, `cloud_tier_provider()`, cloud endpoint config |+| `sigit/src/chat.rs` | TUI app, model picker UI (uses `build_model_picker_items`), `run_inference_task()` with `tools_enabled` gate, TUI tool loop |+| `sigit/src/setup.rs` | HF cache setup (shared App Group container), `startup_model_selection()` | | `onde/src/inference/types.rs` | `ToolDefinition`, `ToolCallRequest`, `ToolResult`, `ToolAwareResult` | | `onde/src/inference/engine.rs` | `send_message_with_tools()`, `send_tool_results()`, `attach_tools()`, `parse_tool_calls()`, `replay_history_with_tools()`, `GgufModelConfig::qwen3_8b()` | | `onde/src/inference/models.rs` | Model constants and `SUPPORTED_MODELS` array |
CLAUDE.md
+105
new file mode 100644index 0000000..6f6c995--- /dev/null+++ b/CLAUDE.md@@ -0,0 +1,105 @@+# CLAUDE.md++This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.++## What this is++`sigit` ("siGit Code") is a single Rust binary: a local-first AI coding agent that runs LLM+inference on-device (via the `onde` crate / GGUF models) or against a hosted/OpenAI-compatible+endpoint. It exposes itself two ways from the *same* binary, chosen at startup by whether stdin+is a TTY:++- **ACP mode** (stdin not a TTY): speaks the Agent Client Protocol over stdio for editor+ integration (Zed, VS Code ACP Client). Cross-platform.+- **Interactive terminal mode** (stdin is a TTY): a full-screen ratatui chat UI. **Unix-only** —+ it relies on fd redirection to keep logs out of the TUI, so Windows gets ACP mode only.++Before the TTY/ACP split, `main` also dispatches the account subcommands `sigit login`,+`sigit logout`, `sigit whoami` (see `src/main.rs` `main()`).++## Build / test / lint++```sh+cargo build # debug build+cargo build --release # release binary at target/release/sigit+cargo run # launches interactive TUI (stdin is a TTY)+cargo test # CI runs: cargo test --locked --target <target>+cargo clippy --tests -- -D warnings # CI gate: clippy is -D warnings on all 4 targets+cargo fmt -- --check # CI gate (edition 2024)+```++CI (`.github/workflows/ci.yml`) runs fmt + clippy + test across four targets:+`aarch64-apple-darwin`, `x86_64-apple-darwin`, `x86_64-unknown-linux-gnu`,+`x86_64-pc-windows-msvc`. Clippy is `-D warnings`, so warnings fail the build.++Run a single test: `cargo test <test_name>`.++## Critical platform constraint: `#[cfg(unix)]` dead code++The interactive client, the `InferenceBackend` seam (`backend.rs`), and provider resolution+(`provider.rs`) are wired up **only** through `#[cfg(unix)]` code paths. On Windows the binary+runs ACP-only and drives `onde` directly, so much of `backend.rs` and `provider.rs` is+legitimately unused there and the dead-code lint is suppressed *on non-Unix targets only*.++Consequence: code can pass clippy on macOS/Linux but fail on the Windows target (or vice versa).+When touching `backend.rs`, `provider.rs`, or the interactive path, keep the `cfg` gates intact —+don't "fix" an unused-warning by deleting code that's live on Unix.++## Architecture++The agent loop is backend-agnostic. The flow: a turn (messages + tool specs) goes to an+`InferenceBackend`, which returns assistant text and/or tool calls; the loop executes tools and+feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete backend.++- **`src/main.rs`** — entry point, mode dispatch, the full ACP `Agent` impl (session lifecycle:+ new/load/fork/prompt/cancel, config options, slash-command advertisement), and the `SYSTEM_PROMPT`+ (note: it bakes in smbCloud-specific context the agent should use when the repo is clearly+ smbCloud, and stay general otherwise).+- **`src/backend.rs`** — the `InferenceBackend` trait and neutral types (`ToolSpec`, `ToolCall`,+ `ToolResult`, `TurnResult`). Two impls: `LocalBackend` (on-device via `onde::ChatEngine`) and+ `OpenAiBackend` (any OpenAI-compatible HTTP endpoint).+- **`src/provider.rs`** — decides *which* backend serves inference. Resolution order, first match+ wins: (1) override via `OPENAI_BASE_URL`+`OPENAI_API_KEY` or active profile in+ `~/.config/sigit/providers.toml`; (2) siGit Code Cloud when logged in; (3) on-device.+- **`src/tools.rs`** — agent tool schemas + execution: `read_file`, `create_directory`,+ `list_directory`, `search_files`, `read_website`, `create_file`, `edit_file`, `delete_file`,+ `run_command`. Add a tool in both the spec list and the execute `match`.+- **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses+ `tokio::select!` to multiplex terminal events with streaming tokens.+- **`src/setup.rs`** — model cache location, local model discovery, selected-model persistence.+ Must run (`setup_shared_model_cache`) *before* anything touches `ChatEngine`/`hf-hub`, since+ those read env vars once at init.+- **`src/account.rs`** — siGit Code Cloud auth (`/login`, `/logout`, `/whoami`); authenticates+ against the account API and stores a session token. Performs no console I/O.+- **`src/credentials.rs`** — local session-token store (TOML, `0600` on Unix).+- **`src/models.rs`** — model-picker types shared across platforms.++Slash commands (`/help`, `/models`, `/login`, `/logout`, `/whoami`, `/reload`, `/clear`,+`/status`) are advertised via `advertise_commands` in `main.rs` and handled in both the TUI and+ACP sessions.++## Model cache (macOS)++On macOS the HF model cache lives in an App Group container shared with the siGit desktop app:+`~/Library/Group Containers/group.com.ondeinference.apps/models/`. Other platforms fall back to+`~/.cache/huggingface/`. The CLI reuses a model the desktop app already downloaded. First run+downloads a GGUF model (~1–2 GB) from Hugging Face.++## Logging++In TTY (interactive) mode, *all* output — `log`, `tracing`, stray `println!` — is redirected to+`$TMPDIR/sigit.log` so the ratatui surface stays clean; the TUI holds a separate fd to the real+terminal. In ACP mode, stdout is reserved for protocol JSON and logs go to stderr. Control+verbosity with `RUST_LOG`.++## Relevant env vars++`OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,+default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),+`SIGIT_MODEL`, `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`.++## Releasing++Version lives in `Cargo.toml`. The binary is published to five registries via separate workflows+(`release-crates`, `release-github`, `release-homebrew`, `release-npm`, `release-pypi`); the+`npm/` and `pypi/` dirs hold the wrapper-package templates. Update `CHANGELOG.md` for releases.