1 //! siGit Code — local coding agent on Onde Inference.
2 //!
3 //! In TTY mode, all output (log crate, tracing, stray printlns) redirects to
4 //! `$TMPDIR/sigit.log`. Ratatui holds a separate fd to the real terminal so
5 //! the TUI stays clean.
6 //!
7 //! Two modes:
8 //! - ACP over stdio (editor integration, e.g. Zed)
9 //! - interactive terminal (direct TTY)
10 //!
11 //! Interactive mode is Unix-only — it needs fd redirection to keep logs out
12 //! of the TUI. Windows only gets ACP mode for now.
13 //!
14 //! On macOS the HF cache lives in the App Group container shared with the
15 //! siGit desktop app. See [`setup`].
16 //!
17 //! # Zed setup
18 //!
19 //! Add to `~/.config/zed/settings.json`:
20 //! ```json
21 //! {
22 //! "agent_servers": {
23 //! "siGit Code": {
24 //! "type": "custom",
25 //! "command": "/absolute/path/to/target/release/sigit"
26 //! }
27 //! }
28 //! }
29 //! ```
30
31 mod account;
32 mod backend;
33 mod browser_auth;
34 mod chat;
35 mod commands;
36 mod credentials;
37 mod frontmatter;
38 mod headless;
39 mod hooks;
40 mod inline_tool_calls;
41 mod instructions;
42 mod mcp;
43 mod models;
44 mod permissions;
45 mod provider;
46 mod session_store;
47 mod settings;
48 mod setup;
49 mod skills;
50 mod subagents;
51 mod tools;
52 mod workspace;
53
54 /// Serializes tests that mutate process-global state: env vars
55 /// (`SIGIT_CONFIG_DIR` etc.) and the current directory. `cargo test` runs
56 /// tests in parallel within a binary, so without this the credentials and
57 /// settings round-trip tests clobber each other's env, and the discovery
58 /// tests (skills/commands/subagents) yank the cwd out from under each other.
59 #[cfg(test)]
60 pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
61
62 use std::io::IsTerminal;
63 #[cfg(unix)]
64 use std::io::{BufWriter, Write};
65 use std::sync::Arc;
66
67 use onde::inference::SamplingConfig;
68
69 // `ProtocolVersion` is a version-agnostic type at the schema root; the rest of the
70 // schema types moved under `schema::v1` in agent-client-protocol 1.0.
71 use agent_client_protocol::schema::ProtocolVersion;
72 use agent_client_protocol::schema::v1::{
73 AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse,
74 AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, CancelNotification,
75 ConfigOptionUpdate, ContentBlock, ContentChunk, DeleteSessionRequest, DeleteSessionResponse,
76 EmbeddedResourceResource, ForkSessionRequest, ForkSessionResponse, Implementation,
77 InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse,
78 LoadSessionRequest, LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse,
79 PermissionOption, PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus,
80 PromptRequest, PromptResponse, RequestPermissionOutcome, RequestPermissionRequest,
81 SessionAdditionalDirectoriesCapabilities, SessionCapabilities, SessionConfigOption,
82 SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,
83 SessionDeleteCapabilities, SessionForkCapabilities, SessionId, SessionInfo,
84 SessionListCapabilities, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest,
85 SetSessionConfigOptionResponse, StopReason, ToolCall, ToolCallStatus, ToolCallUpdate,
86 ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
87 };
88 use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
89 use onde::inference::{ChatEngine, GgufModelConfig};
90
91 use crate::backend::{
92 InferenceBackend, LocalBackend, OpenAiBackend, ToolResult as BackendToolResult, ToolSpec,
93 TurnResult,
94 };
95 use std::path::PathBuf;
96 use std::sync::atomic::{AtomicBool, Ordering};
97 use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
98 use tracing_subscriber::{EnvFilter, fmt as tracing_fmt};
99
100 #[cfg(unix)]
101 use std::os::unix::io::{AsRawFd, FromRawFd};
102
103 const SYSTEM_PROMPT: &str = "\
104 Your name is siGit — lowercase 's', uppercase 'G', no spaces. \
105 Not 'SiGit', not 'Sigit'. Only say your name if the user asks who you are.
106
107 You are a strong general-purpose coding agent. smbCloud is your home turf, \
108 but you should still be useful in any codebase. When the project is clearly \
109 about smbCloud, use that context directly instead of falling back to vague \
110 cloud-platform advice.
111
112 smbCloud context you should know and use when it helps:
113 - smbCloud is a platform for deploying and managing projects
114 - the main CLI is a Rust workspace with focused crates rather than one giant crate
115 - common areas include auth, project management, deploy flows, networking, \
116 shared models, release tooling, and managed services
117 - deploy branches usually follow `release/service-{name}`
118 - Next.js SSR deploys on smbCloud are not the same as generic git-push deploys; \
119 they often use a local build plus rsync/PM2 style flow
120 - auth has a hard boundary between smbCloud platform users and tenant app users; \
121 platform flows use `/v1/users*`, tenant app flows use `/v1/client/*`, and \
122 you should not casually mix `User`, `TenantMembership`, `AuthApp`, and `AuthUser`
123 - smbCloud authorization is layered; do not flatten platform accounts, tenant \
124 memberships, auth-app collaborators, and tenant end users into one model
125 - `Project` is the umbrella workspace, while app-like resources such as \
126 `FrontendApp`, `AuthApp`, and GresIQ are the deployable units with their own \
127 ownership, sharing, and collaboration rules
128 - `FrontendApp` is many-per-project, while `AuthApp` is intentionally one-per-project; \
129 preserve those cardinality rules unless the code clearly changes them
130 - GresIQ is smbCloud's managed PostgreSQL offering; treat it as a platform \
131 service with its own credentials and boundaries, not as a generic local DB helper
132 - when debugging smbCloud Rails APIs, first classify the request: first-party \
133 smbCloud app or tenant app, then check which endpoint family and validator \
134 should be involved before changing code
135 - when working in smbCloud repos, prefer existing workspace patterns, existing \
136 crate boundaries, existing Rails conventions, and existing command flows over \
137 inventing new abstractions
138
139 CRITICAL RULE — never tell the user to run a command. You have tools. Use them. \
140 When the user asks you to clone a repo, run a build, check git status, or do \
141 anything that involves a shell command, you MUST call the run_command tool and \
142 execute it yourself. Do not print shell commands for the user to copy-paste. \
143 Do not give step-by-step instructions. Do not say \"you can run …\". Just do it. \
144 If a command fails, try to fix the problem and re-run it. If you cannot fix it \
145 after two attempts, explain what went wrong and what you tried.
146
147 Git operations — always use run_command:
148 - git clone: always pass the full absolute destination path as the last argument \
149 and set cwd to an existing writable parent directory. Example: \
150 run_command({\"command\": \"git clone https://github.com/org/repo /Users/me/Repositories/repo\", \
151 \"cwd\": \"/Users/me/Repositories\"})
152 - git init, add, commit, push, pull, fetch, checkout, branch, diff, log, status, \
153 stash, rebase, merge, tag — use run_command with an absolute cwd pointing to \
154 the repo root
155 - never run git clone without an explicit absolute destination path
156 - if a clone or init fails, check the error, fix the cause (wrong path, missing \
157 directory, permissions), and retry
158 - when you create a commit, always end the commit message with a blank line and \
159 then this trailer on its own line: Co-Authored-By: siGit Code <noreply@sigit.si> \
160 — GitHub reads that exact format and credits siGit as co-author. If a commit \
161 lands without it, siGit Code amends the trailer in automatically and the tool \
162 output says so; do not amend again yourself.
163
164 For repositories hosted on sigit.si, issue and pull request workflows go \
165 through the official MCP tools: mcp__sigit__list_issues, mcp__sigit__get_issue, \
166 mcp__sigit__create_issue, mcp__sigit__list_pull_requests, and \
167 mcp__sigit__get_pull_request. Prefer these tools over shelling out to git (or \
168 fetching web pages) for issue and PR queries on sigit.si repos.
169
170 Never introduce yourself unless asked. Jump straight into the answer. \
171 Keep answers short. Write idiomatic code. \
172 Fix root causes, not symptoms.
173
174 You have access to tools that let you read files, read websites directly from \
175 http and https URLs, create directories, list directories, search code, create \
176 new files, edit existing files, delete files, and run shell commands. You can \
177 also use git directly through shell commands, including `git init` and normal \
178 git workflows. Use them proactively. Read the code or website before answering. \
179 Prefer absolute paths when referring to files and directories, especially in \
180 protocol-facing output and tool arguments. Create directories when needed. Run \
181 builds, tests, and git commands after making changes. Ground your answers in \
182 the actual code or fetched page content, not in guesses.
183
184 CRITICAL — you CAN access websites. You are NOT a typical LLM without internet \
185 access. You have a read_website tool that fetches any http or https URL and \
186 returns the page text. When the user gives you a URL or asks you to read, \
187 summarize, or inspect a web page, you MUST call the read_website tool with that \
188 URL. Never say \"I cannot access websites\" or \"I cannot browse the internet\". \
189 You can. Use the tool.
190
191 CRITICAL — before every edit_file call, you MUST call read_file on the target \
192 file first (or the specific line range if one was given). Never rely on file \
193 content you saw in a previous turn — the user may have reverted, edited, or \
194 changed the file externally since then. Always re-read to get the current state \
195 before constructing old_text. \
196 When the user corrects a previous edit (e.g. \"don't remove X, append instead\"), \
197 treat it as a fresh task: re-read the file, identify the current content, and \
198 plan the edit from scratch. Do not assume the file still reflects your last edit.
199
200 Tool-use heuristics:
201 - when the user provides a URL or asks about a web page, ALWAYS call \
202 read_website — never refuse or claim you lack internet access
203 - prefer absolute paths over relative paths when you mention, return, or pass \
204 file and directory paths
205 - if a path does not exist yet, create the directory before creating files in it
206 - if the user asks to clone a repo, immediately call run_command with git clone \
207 and an absolute destination path — do not ask where to put it unless the \
208 request is ambiguous; default to the user's home Repositories directory
209 - if the user asks for a new repo, scaffold, or scratch project, create the \
210 directory, create the first files, and run `git init` without waiting unless \
211 the request says otherwise
212 - if the repo looks like smbCloud CLI code, respect workspace crate boundaries, \
213 shared models, and existing command handlers before adding new abstractions
214 - if the repo looks like smbCloud Rails code, check routes, controllers, \
215 validators, and model boundaries before changing business logic
216 - if the task touches smbCloud auth, first decide whether it is a platform-user \
217 flow or a tenant-app flow, then follow the right endpoint family and model layer
218 - if the task touches smbCloud deploy code, check whether it is the generic \
219 deploy path or the Next.js SSR path before proposing changes
220 - after edits, prefer running the smallest useful verification step first, then \
221 widen to broader checks if needed
222 - use git commands naturally for status checks, repo setup, diffs, and normal \
223 developer workflows when they help move the task forward
224 - if a tool call fails, read the error, try to fix it, and retry — do not \
225 fall back to telling the user what to type
226
227 When the repo is not about smbCloud, act like a normal coding agent and do not \
228 force smbCloud-specific advice into the answer. When it is about smbCloud, be \
229 specific and practical.
230
231 Be direct and brief. Write clean, idiomatic code. When debugging, go for the \
232 root cause, not the symptom. Correct beats clever.";
233
234 /// shorter prompt for models without tool calling (e.g. DeepSeek Coder v1).
235 /// the full [`SYSTEM_PROMPT`] wastes context and confuses them.
236 const SIMPLE_SYSTEM_PROMPT: &str = "\
237 Your name is siGit — a coding assistant. \
238 You are helpful, concise, and write clean, idiomatic code. \
239 Answer any question the user asks — programming, general knowledge, or casual chat. \
240 When debugging, address the root cause, not the symptom. \
241 Be direct and brief.";
242
243 pub(crate) fn system_prompt_for_model(tool_calling: bool) -> &'static str {
244 if tool_calling {
245 SYSTEM_PROMPT
246 } else {
247 SIMPLE_SYSTEM_PROMPT
248 }
249 }
250
251 /// cap tool-call loops so a confused model can't spin forever; auto-compaction
252 /// (see [`backend::DEFAULT_CONTEXT_TOKEN_BUDGET`]) keeps long runs inside the
253 /// context window, so the cap can afford to be generous
254 const MAX_TOOL_ROUNDS: usize = 24;
255
256 /// Separator between two stretches of assistant prose that a tool round split
257 /// apart. ACP clients concatenate consecutive agent-message chunks into a
258 /// single block, so a plain newline (or nothing at all) leaves the rounds
259 /// reading as one run-on paragraph.
260 const PARAGRAPH_BREAK: &str = "\n\n";
261
262 /// The assistant reply being streamed over the course of one prompt, tracked
263 /// across every tool round so `<think>` stripping stays correct and no text is
264 /// sent twice.
265 #[derive(Default)]
266 struct StreamedReply {
267 /// Raw text as it arrives, reasoning tags included: think-block stripping
268 /// needs the whole string, since a tag can span chunk boundaries.
269 assembled: String,
270 /// The visible text already on the wire.
271 sent: String,
272 /// Whether any chunk has been sent, i.e. the client already has a reply.
273 streamed_any: bool,
274 /// Whether the next visible text should open a new paragraph.
275 paragraph_pending: bool,
276 }
277
278 impl StreamedReply {
279 /// Record that a tool round has interrupted the prose, so the next chunk
280 /// starts a paragraph. A round that opened with tool calls and no text has
281 /// no sentence to break away from, hence the `streamed_any` guard.
282 fn interrupt(&mut self) {
283 self.paragraph_pending = self.streamed_any;
284 }
285
286 /// Fold one streamed fragment into the reply and return the visible text it
287 /// newly reveals, or `None` when it reveals nothing to show (it landed
288 /// inside a `<think>` block, say).
289 fn push(&mut self, piece: &str) -> Option<String> {
290 if self.paragraph_pending {
291 // A tool round interrupted the prose. Clients concatenate
292 // consecutive agent-message chunks into one block, so the new round
293 // has to open a paragraph or it runs onto the end of the previous
294 // sentence ("…parsed with unwrap_or_else.Let me broaden the
295 // search:"). Whatever whitespace the model puts at the seam gives
296 // way to the break.
297 let piece = piece.trim_start();
298 if piece.is_empty() {
299 return None;
300 }
301 self.assembled.push_str(PARAGRAPH_BREAK);
302 self.assembled.push_str(piece);
303 self.paragraph_pending = false;
304 } else {
305 self.assembled.push_str(piece);
306 }
307
308 let (_think, visible) = chat::strip_think_blocks(&self.assembled);
309 match visible.strip_prefix(self.sent.as_str()) {
310 Some(extra) if !extra.is_empty() => {
311 let extra = extra.to_string();
312 self.sent = visible;
313 self.streamed_any = true;
314 Some(extra)
315 }
316 // No new visible text, or the visible prefix changed retroactively
317 // (rare, e.g. a late-closing think tag): just resync without
318 // resending what's already on the wire.
319 _ => {
320 self.sent = visible;
321 None
322 }
323 }
324 }
325 }
326
327 /// Outcome of asking the client for permission to run one tool call.
328 enum PermissionVerdict {
329 /// Run the tool.
330 Approved,
331 /// Skip the tool; the string becomes its tool result so the model adapts.
332 Denied(String),
333 /// The client cancelled the turn while the request was pending; stop the
334 /// whole prompt with `StopReason::Cancelled` instead of burning rounds.
335 TurnCancelled,
336 }
337
338 /// Display kind for the permission dialog, so editors can show a fitting icon.
339 fn tool_kind_for(tool_name: &str) -> ToolKind {
340 match tool_name {
341 "edit_file" | "multi_edit" | "create_file" | "create_directory" | "remember" => {
342 ToolKind::Edit
343 }
344 "delete_file" => ToolKind::Delete,
345 "run_command" | "kill_command" => ToolKind::Execute,
346 "read_file" | "list_directory" | "command_output" => ToolKind::Read,
347 "search_files" | "glob" | "web_search" => ToolKind::Search,
348 "read_website" => ToolKind::Fetch,
349 "write_todos" => ToolKind::Think,
350 _ => ToolKind::Other,
351 }
352 }
353
354 /// The visible text of one saved history message. `content` is normally a
355 /// string, but an OpenAI-compatible endpoint may hand back the block form, and
356 /// tool-call-only assistant turns carry `null`.
357 fn history_message_text(message: &serde_json::Value) -> String {
358 match &message["content"] {
359 serde_json::Value::String(text) => text.clone(),
360 serde_json::Value::Array(blocks) => blocks
361 .iter()
362 .filter_map(|block| block["text"].as_str())
363 .collect::<Vec<_>>()
364 .join(""),
365 _ => String::new(),
366 }
367 }
368
369 /// First user message, trimmed to one short line — the thread label a client
370 /// shows in its history list.
371 fn title_from_history(history: &[serde_json::Value]) -> Option<String> {
372 let text = history
373 .iter()
374 .find(|message| message["role"] == "user")
375 .map(history_message_text)?;
376 let collapsed = text.split_whitespace().collect::<Vec<_>>().join(" ");
377 if collapsed.is_empty() {
378 return None;
379 }
380 const MAX_LEN: usize = 60;
381 if collapsed.chars().count() <= MAX_LEN {
382 return Some(collapsed);
383 }
384 let mut truncated: String = collapsed.chars().take(MAX_LEN).collect();
385 if let Some(last_space) = truncated.rfind(' ') {
386 truncated.truncate(last_space);
387 }
388 truncated.push('…');
389 Some(truncated)
390 }
391
392 /// RFC 3339 formatting for `SessionInfo.updated_at`, sourced from the
393 /// transcript file's mtime rather than a separately tracked timestamp.
394 fn rfc3339(time: std::time::SystemTime) -> String {
395 chrono::DateTime::<chrono::Utc>::from(time).to_rfc3339()
396 }
397
398 /// Rebuild the `session/update` notifications that re-draw a saved conversation
399 /// in the client.
400 ///
401 /// ACP's `session/load` is a replay: the client renders the reopened thread
402 /// purely from the updates the agent streams while the request is in flight.
403 /// Restoring history into the backend is what makes the *model* remember, but
404 /// it puts nothing on screen, so a reopened session came up blank and looked
405 /// like a brand-new one (issue #77).
406 fn history_replay_updates(history: &[serde_json::Value]) -> Vec<SessionUpdate> {
407 // Match results to their calls up front so each call replays as one
408 // finished tool call instead of a call followed by a loose result.
409 let mut outputs: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
410 for message in history {
411 if message["role"] == "tool"
412 && let (Some(id), Some(content)) = (
413 message["tool_call_id"].as_str(),
414 message["content"].as_str(),
415 )
416 {
417 outputs.insert(id, content);
418 }
419 }
420
421 let mut updates = Vec::new();
422 for message in history {
423 match message["role"].as_str().unwrap_or_default() {
424 // Seeded context and project instructions were never on screen.
425 "system" => {}
426 "user" => {
427 let text = history_message_text(message);
428 if !text.trim().is_empty() {
429 updates.push(SessionUpdate::UserMessageChunk(ContentChunk::new(
430 ContentBlock::from(text),
431 )));
432 }
433 }
434 "assistant" => {
435 let (_think, visible) = chat::strip_think_blocks(&history_message_text(message));
436 if !visible.trim().is_empty() {
437 updates.push(SessionUpdate::AgentMessageChunk(ContentChunk::new(
438 ContentBlock::from(visible),
439 )));
440 }
441 for call in message["tool_calls"].as_array().into_iter().flatten() {
442 let name = call["function"]["name"].as_str().unwrap_or("tool");
443 let arguments = call["function"]["arguments"].as_str().unwrap_or("");
444
445 // `write_todos` renders as a plan while it runs; replay it
446 // the same way, and let a later list replace an earlier one
447 // exactly as it did live.
448 if name == "write_todos"
449 && let Some(plan) = todos_arguments_to_plan(arguments)
450 {
451 updates.push(SessionUpdate::Plan(plan));
452 continue;
453 }
454
455 // A saved call is always finished — nothing is still
456 // running when the session file is written.
457 let id = call["id"].as_str().unwrap_or_default();
458 // A call saved without an id would otherwise collapse into
459 // one entry per replay; give each its own.
460 let replay_id = if id.is_empty() {
461 format!("replay-{}", uuid::Uuid::new_v4())
462 } else {
463 id.to_string()
464 };
465 let mut tool_call = ToolCall::new(replay_id, name)
466 .kind(tool_kind_for(name))
467 .status(ToolCallStatus::Completed)
468 .raw_input(serde_json::from_str::<serde_json::Value>(arguments).ok());
469 if let Some(output) = outputs.get(id) {
470 tool_call =
471 tool_call.raw_output(serde_json::Value::String((*output).to_string()));
472 }
473 updates.push(SessionUpdate::ToolCall(tool_call));
474 }
475 }
476 // Folded into the tool call above.
477 "tool" => {}
478 other => log::debug!("session replay: skipping unknown history role '{other}'"),
479 }
480 }
481 updates
482 }
483
484 fn todos_arguments_to_plan(arguments: &str) -> Option<Plan> {
485 let args: serde_json::Value = serde_json::from_str(arguments).ok()?;
486 let todos = args.get("todos")?.as_array()?;
487 if todos.is_empty() {
488 return None;
489 }
490
491 let mut entries = Vec::with_capacity(todos.len());
492 for todo in todos {
493 let content = todo.get("content")?.as_str()?.trim();
494 if content.is_empty() {
495 return None;
496 }
497 // Unknown and absent statuses both fall back to pending, matching how
498 // `exec_write_todos` renders them. The two have to agree: the executor
499 // reports success for an off-enum status, so rejecting the plan here
500 // would leave a stale list on screen while the model believes it
501 // reported progress.
502 let status = match todo.get("status").and_then(serde_json::Value::as_str) {
503 Some("completed") => PlanEntryStatus::Completed,
504 Some("in_progress") => PlanEntryStatus::InProgress,
505 _ => PlanEntryStatus::Pending,
506 };
507 entries.push(PlanEntry::new(content, PlanEntryPriority::Medium, status));
508 }
509
510 Some(Plan::new(entries))
511 }
512
513 /// Shown when a siGit Code Cloud tier is selected without a signed-in account.
514 const CLOUD_LOGIN_PROMPT: &str = "siGit Code Cloud needs an account. Sign in with \
515 `/login <email> <password>` (or the Authenticate button), then pick the tier again. \
516 Create an account at https://sigit.si.";
517
518 /// The per-session context system message: root-directory guidance plus any
519 /// project instruction files (`AGENTS.md` / `CLAUDE.md`) found for those roots.
520 /// Used by every session entry point so on-device and cloud backends get the
521 /// same always-on project context.
522 ///
523 /// `additional_roots` is empty for an ordinary project and non-empty for a
524 /// multi-root one, where the editor opened several directories at once (see
525 /// `workspace.rs`).
526 fn session_context_message(cwd: &std::path::Path, additional_roots: &[PathBuf]) -> String {
527 let mut message = if additional_roots.is_empty() {
528 format!(
529 "The user's project working directory is {}. \
530 Always use absolute paths under this directory for all file \
531 and directory operations. This is the root of the project \
532 the user has open in their editor.",
533 cwd.display()
534 )
535 } else {
536 let mut roots = format!("- {} (primary)", cwd.display());
537 for root in additional_roots {
538 roots.push_str(&format!("\n- {}", root.display()));
539 }
540 format!(
541 "The user has a multi-root project open in their editor, with these \
542 root directories:\n{roots}\n\
543 All of them are in scope: read, search and edit files in any of them. \
544 Always use absolute paths for file and directory operations, since a \
545 relative path resolves against the primary root only. When the user \
546 names a file without saying which root it is in, look for it in each \
547 root before asking."
548 )
549 };
550 if let Some(project_instructions) =
551 instructions::load_workspace_instructions(cwd, additional_roots)
552 {
553 message.push_str("\n\n");
554 message.push_str(&project_instructions);
555 }
556 message
557 }
558
559 fn agent_tools_as_specs() -> Vec<ToolSpec> {
560 let mut specs: Vec<ToolSpec> = tools::all_tools()
561 .into_iter()
562 .map(|t| ToolSpec {
563 name: t.name.to_string(),
564 description: t.description.to_string(),
565 parameters_schema: t.parameters_schema.to_string(),
566 })
567 .collect();
568
569 // Advertise the `skill` tool only when skills are present, so models without
570 // any skills installed don't see a dangling capability (Agent Skills format,
571 // https://agentskills.io). Discovery metadata lives in the tool description.
572 let discovered = skills::discover_skills();
573 if !discovered.is_empty() {
574 specs.push(ToolSpec {
575 name: skills::SKILL_TOOL_NAME.to_string(),
576 description: skills::skill_tool_description(&discovered),
577 parameters_schema: skills::skill_tool_schema().to_string(),
578 });
579 }
580
581 // Delegated research (`task`) is offered only when a subagent backend can
582 // actually be built — same conditional pattern as the `skill` tool above.
583 if tools::subagent_available() {
584 specs.push(tools::task_tool_spec());
585 }
586
587 // `web_search` is a native wrapper around the official MCP server's
588 // `web_search` tool (see the module doc in tools.rs), offered only when
589 // that MCP tool was actually discovered (i.e. the user is signed in to
590 // siGit Code Cloud).
591 let web_search_offered = tools::web_search_available();
592 if web_search_offered {
593 specs.push(tools::web_search_tool_spec());
594 }
595
596 // Tools discovered from configured MCP servers (incl. the official one).
597 // Exclude the raw web_search delegate when the native wrapper above is
598 // already offered, so the model sees one clean option, not two names for
599 // the same tool.
600 specs.extend(
601 mcp::tool_specs()
602 .into_iter()
603 .filter(|spec| !(web_search_offered && tools::is_web_search_delegate(&spec.name))),
604 );
605
606 specs
607 }
608
609 /// Register the `task` tool's subagent factory for an OpenAI-compatible
610 /// provider: each subagent run gets a FRESH `OpenAiBackend` against the same
611 /// endpoint (its own conversation history), seeded with whichever system
612 /// prompt the caller resolved (the default subagent prompt, or a configured
613 /// subagent type's prompt — see `tools::exec_task_with`). The provider config
614 /// is captured by clone. Called once at startup by whichever surface resolved
615 /// the provider; on-device paths register a factory that returns `None`
616 /// instead (onde has a single shared history, so a second concurrent context
617 /// is not possible yet).
618 fn register_subagent_factory_for(cfg: &provider::ProviderConfig) {
619 let cfg = cfg.clone();
620 tools::set_subagent_factory(Box::new(move |system_prompt: &str| {
621 Some(Arc::new(OpenAiBackend::new(
622 cfg.base_url.clone(),
623 cfg.api_key.clone(),
624 cfg.model.clone(),
625 Some(system_prompt.to_string()),
626 )) as Arc<dyn InferenceBackend>)
627 }));
628 }
629
630 fn initialize_meta() -> Meta {
631 let startup_selection = setup::startup_model_selection();
632
633 let active_model_name = startup_selection
634 .as_ref()
635 .map(|selection| selection.display_name.clone())
636 .unwrap_or_else(|| GgufModelConfig::qwen25_3b().display_name);
637
638 let active_model_id = startup_selection
639 .as_ref()
640 .and_then(|selection| selection.selected_model.as_ref())
641 .map(|selected| selected.model_id.clone())
642 .unwrap_or_else(|| GgufModelConfig::qwen25_3b().model_id);
643
644 let active_model_file = startup_selection
645 .as_ref()
646 .and_then(|selection| selection.selected_model.as_ref())
647 .map(|selected| selected.gguf_file.clone())
648 .unwrap_or_else(|| {
649 GgufModelConfig::qwen25_3b()
650 .files
651 .first()
652 .cloned()
653 .unwrap_or_default()
654 });
655
656 let mut model = serde_json::Map::new();
657 model.insert(
658 "display_name".to_string(),
659 serde_json::Value::String(active_model_name),
660 );
661 model.insert(
662 "model_id".to_string(),
663 serde_json::Value::String(active_model_id),
664 );
665 model.insert(
666 "gguf_file".to_string(),
667 serde_json::Value::String(active_model_file),
668 );
669
670 let mut sigit = serde_json::Map::new();
671 sigit.insert("active_model".to_string(), serde_json::Value::Object(model));
672
673 let mut meta = Meta::new();
674 meta.insert("sigit".to_string(), serde_json::Value::Object(sigit));
675 meta
676 }
677
678 struct SiGitAgent {
679 engine: Arc<ChatEngine>,
680 /// The active inference backend. `LocalBackend` by default; swapped to an
681 /// `OpenAiBackend` when the user selects a siGit Code Cloud tier in the panel.
682 backend: tokio::sync::Mutex<Arc<dyn InferenceBackend>>,
683 /// cwd from the editor — tool calls run here, not where the process started
684 session_cwd: std::sync::Mutex<Option<PathBuf>>,
685 current_model: std::sync::Mutex<GgufModelConfig>,
686 /// flipped once the startup model finishes (success or failure)
687 model_ready: Arc<AtomicBool>,
688 /// guards the one-time lazy startup load for ACP mode
689 startup_model_load_started: Arc<AtomicBool>,
690 /// set if the startup load failed
691 model_load_error: Arc<std::sync::Mutex<Option<String>>>,
692 /// true when the startup model isn't cached yet
693 startup_needs_download: bool,
694 /// Xcode launches custom agents with `--acp`; that mode should be ready to
695 /// answer the first prompt without requiring an editor slash command.
696 auto_load_local_model: bool,
697 /// for progress UI
698 startup_model_name: String,
699 /// for download-progress polling
700 startup_model_id: String,
701 /// Serializes turn-affecting handlers (prompt, session lifecycle, config
702 /// changes). They run in `cx.spawn`ed tasks so the JSON-RPC dispatch loop
703 /// stays free to route client responses (e.g. permission answers) mid-turn;
704 /// this lock reproduces the strict ordering the dispatch loop used to give
705 /// them for free.
706 turn_lock: Arc<tokio::sync::Mutex<()>>,
707 }
708
709 impl SiGitAgent {
710 fn new(
711 engine: Arc<ChatEngine>,
712 initial_model: GgufModelConfig,
713 model_ready: Arc<AtomicBool>,
714 startup_model_load_started: Arc<AtomicBool>,
715 model_load_error: Arc<std::sync::Mutex<Option<String>>>,
716 startup_needs_download: bool,
717 auto_load_local_model: bool,
718 ) -> Self {
719 let startup_model_name = initial_model.display_name.clone();
720 let startup_model_id = initial_model.model_id.clone();
721 let backend: Arc<dyn InferenceBackend> = Arc::new(LocalBackend::new(Arc::clone(&engine)));
722 Self {
723 engine,
724 backend: tokio::sync::Mutex::new(backend),
725 session_cwd: std::sync::Mutex::new(None),
726 current_model: std::sync::Mutex::new(initial_model),
727 model_ready,
728 startup_model_load_started,
729 model_load_error,
730 startup_needs_download,
731 auto_load_local_model,
732 startup_model_name,
733 startup_model_id,
734 turn_lock: Arc::new(tokio::sync::Mutex::new(())),
735 }
736 }
737
738 fn start_startup_model_load_if_needed(&self) {
739 if self
740 .startup_model_load_started
741 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
742 .is_err()
743 {
744 return;
745 }
746
747 self.model_ready.store(false, Ordering::Release);
748 if let Ok(mut guard) = self.model_load_error.lock() {
749 *guard = None;
750 }
751
752 let startup_config = self.current_model.lock().unwrap().clone();
753 let (max_tokens, tool_calling) = models::local_picker_items()
754 .into_iter()
755 .find(|item| {
756 item.config.model_id == startup_config.model_id
757 && item
758 .config
759 .files
760 .first()
761 .zip(startup_config.files.first())
762 .map(|(left, right)| left == right)
763 .unwrap_or(false)
764 })
765 .map(|item| (item.max_tokens, item.tool_calling))
766 .unwrap_or((4096, false));
767
768 let sampling = SamplingConfig {
769 max_tokens: Some(max_tokens),
770 ..SamplingConfig::default()
771 };
772
773 let loader_engine = Arc::clone(&self.engine);
774 let loader_system_prompt = system_prompt_for_model(tool_calling).to_string();
775 let model_ready = Arc::clone(&self.model_ready);
776 let model_load_error = Arc::clone(&self.model_load_error);
777
778 std::thread::spawn(move || {
779 let result = tokio::runtime::Runtime::new()
780 .map_err(|error| error.to_string())
781 .and_then(|rt| {
782 rt.block_on(loader_engine.load_gguf_model(
783 startup_config,
784 Some(loader_system_prompt),
785 Some(sampling),
786 ))
787 .map(|_| ())
788 .map_err(|error| error.to_string())
789 });
790
791 if let Ok(mut guard) = model_load_error.lock() {
792 *guard = result.err();
793 }
794 model_ready.store(true, Ordering::Release);
795 });
796 }
797
798 /// block until the startup model is ready, showing progress in the session.
799 async fn await_model_ready(
800 &self,
801 cx: &ConnectionTo<Client>,
802 session_id: &SessionId,
803 ) -> agent_client_protocol::Result<()> {
804 if self.model_ready.load(Ordering::Acquire) {
805 // already done — might be a stored error from earlier
806 if let Some(err) = self.model_load_error.lock().unwrap().as_ref() {
807 return Err(agent_client_protocol::Error::new(
808 -32603,
809 format!("model load failed: {err}"),
810 ));
811 }
812 return Ok(());
813 }
814
815 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
816
817 let tool_call_id = format!("startup-load-{}", uuid::Uuid::new_v4());
818 let title = if self.startup_needs_download {
819 format!("Downloading {}", self.startup_model_name)
820 } else {
821 format!("Loading {}", self.startup_model_name)
822 };
823
824 self.send_tool_call_update(
825 cx,
826 session_id.clone(),
827 SessionUpdate::ToolCall(
828 ToolCall::new(tool_call_id.clone(), &title)
829 .kind(ToolKind::Think)
830 .status(ToolCallStatus::InProgress)
831 .content(vec![format!("{}…", title).into()]),
832 ),
833 )
834 .ok();
835
836 let expected_bytes = if self.startup_needs_download {
837 onde::inference::models::SUPPORTED_MODEL_INFO
838 .iter()
839 .find(|m| m.id == self.startup_model_id)
840 .map(|m| m.expected_size_bytes)
841 .unwrap_or(0)
842 } else {
843 0
844 };
845
846 let load_start = std::time::Instant::now();
847 let mut tick: usize = 0;
848 let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
849 interval.tick().await;
850
851 loop {
852 interval.tick().await;
853 tick += 1;
854
855 if self.model_ready.load(Ordering::Acquire) {
856 break;
857 }
858
859 let frame = SPINNER[tick % SPINNER.len()];
860 let elapsed = load_start.elapsed();
861 let elapsed_str = if elapsed.as_secs() >= 60 {
862 format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
863 } else {
864 format!("{}s", elapsed.as_secs())
865 };
866
867 let (update_title, update_content) =
868 if self.startup_needs_download && expected_bytes > 0 {
869 let cache_path = onde::hf_cache::model_cache_path(&self.startup_model_id);
870 let downloaded = cache_path
871 .as_ref()
872 .filter(|p| p.exists())
873 .map(|p| dir_size_recursive(p))
874 .unwrap_or(0);
875 let pct = ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
876 let bar = progress_bar(pct, 20);
877 let size_hint = format!(" (~{})", format_size_human(expected_bytes));
878 (
879 format!(
880 "{frame} Downloading {}{size_hint} ({pct}%)",
881 self.startup_model_name
882 ),
883 format!(
884 "{} — {bar} {pct}% ({} / {})",
885 self.startup_model_name,
886 format_size_human(downloaded),
887 format_size_human(expected_bytes),
888 ),
889 )
890 } else if self.startup_needs_download {
891 let cache_path = onde::hf_cache::model_cache_path(&self.startup_model_id);
892 let downloaded = cache_path
893 .as_ref()
894 .filter(|p| p.exists())
895 .map(|p| dir_size_recursive(p))
896 .unwrap_or(0);
897 (
898 format!("{frame} Downloading {}", self.startup_model_name),
899 format!(
900 "{} — {} downloaded… ({elapsed_str})",
901 self.startup_model_name,
902 format_size_human(downloaded),
903 ),
904 )
905 } else {
906 (
907 format!("{frame} Loading {}", self.startup_model_name),
908 format!(
909 "{frame} Loading {}… ({elapsed_str})",
910 self.startup_model_name
911 ),
912 )
913 };
914
915 self.send_tool_call_update(
916 cx,
917 session_id.clone(),
918 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
919 tool_call_id.clone(),
920 ToolCallUpdateFields::new()
921 .title(update_title)
922 .status(ToolCallStatus::InProgress)
923 .content(vec![update_content.into()]),
924 )),
925 )
926 .ok();
927 }
928
929 // done — check if it blew up
930 let load_error = self.model_load_error.lock().unwrap().clone();
931 if let Some(err) = load_error {
932 self.send_tool_call_update(
933 cx,
934 session_id.clone(),
935 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
936 tool_call_id,
937 ToolCallUpdateFields::new()
938 .title("Model load failed".to_string())
939 .status(ToolCallStatus::Failed)
940 .content(vec![format!("error: {err}").into()]),
941 )),
942 )
943 .ok();
944
945 return Err(agent_client_protocol::Error::new(
946 -32603,
947 format!("model load failed: {err}"),
948 ));
949 }
950
951 let done_title = if self.startup_needs_download {
952 format!("✓ {} downloaded and loaded", self.startup_model_name)
953 } else {
954 format!("✓ {} loaded", self.startup_model_name)
955 };
956
957 self.send_tool_call_update(
958 cx,
959 session_id.clone(),
960 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
961 tool_call_id,
962 ToolCallUpdateFields::new()
963 .title(done_title)
964 .status(ToolCallStatus::Completed),
965 )),
966 )
967 .ok();
968
969 Ok(())
970 }
971
972 fn send_assistant_message(
973 &self,
974 cx: &ConnectionTo<Client>,
975 session_id: SessionId,
976 text: impl Into<String>,
977 ) -> agent_client_protocol::Result<()> {
978 cx.send_notification(SessionNotification::new(
979 session_id,
980 SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(text.into()))),
981 ))
982 }
983
984 fn send_system_status(
985 &self,
986 cx: &ConnectionTo<Client>,
987 session_id: SessionId,
988 title: impl Into<String>,
989 ) -> agent_client_protocol::Result<()> {
990 cx.send_notification(SessionNotification::new(
991 session_id,
992 SessionUpdate::ToolCall(
993 ToolCall::new(
994 format!("system-status-{}", uuid::Uuid::new_v4()),
995 title.into(),
996 )
997 .kind(ToolKind::Think)
998 .status(ToolCallStatus::Completed),
999 ),
1000 ))
1001 }
1002
1003 /// Run one inference turn (`fut`) while concurrently forwarding any streamed
1004 /// tokens to the editor. The sink receiver is drained as the future runs, so
1005 /// chunks reach the client live rather than all at once when it resolves.
1006 ///
1007 /// `reply` persists across the turns of a single prompt so reasoning is
1008 /// stripped consistently and we never re-send text.
1009 async fn drain_turn<F>(
1010 &self,
1011 cx: &ConnectionTo<Client>,
1012 session_id: &SessionId,
1013 fut: F,
1014 sink_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
1015 reply: &mut StreamedReply,
1016 ) -> Result<TurnResult, backend::BackendError>
1017 where
1018 F: std::future::Future<Output = Result<TurnResult, backend::BackendError>>,
1019 {
1020 tokio::pin!(fut);
1021 let result = loop {
1022 tokio::select! {
1023 done = &mut fut => break done,
1024 Some(piece) = sink_rx.recv() => {
1025 self.emit_visible_chunk(cx, session_id, &piece, reply);
1026 }
1027 }
1028 };
1029 // Flush tokens that landed between the last poll and the future resolving.
1030 while let Ok(piece) = sink_rx.try_recv() {
1031 self.emit_visible_chunk(cx, session_id, &piece, reply);
1032 }
1033 result
1034 }
1035
1036 /// Fold a streamed fragment into `reply` and send whatever it newly reveals
1037 /// as an agent-message chunk.
1038 fn emit_visible_chunk(
1039 &self,
1040 cx: &ConnectionTo<Client>,
1041 session_id: &SessionId,
1042 piece: &str,
1043 reply: &mut StreamedReply,
1044 ) {
1045 if let Some(extra) = reply.push(piece) {
1046 self.send_assistant_message(cx, session_id.clone(), extra)
1047 .ok();
1048 }
1049 }
1050
1051 fn send_tool_call_update(
1052 &self,
1053 cx: &ConnectionTo<Client>,
1054 session_id: SessionId,
1055 update: SessionUpdate,
1056 ) -> agent_client_protocol::Result<()> {
1057 cx.send_notification(SessionNotification::new(session_id, update))
1058 }
1059
1060 fn send_plan_update(
1061 &self,
1062 cx: &ConnectionTo<Client>,
1063 session_id: SessionId,
1064 plan: Plan,
1065 ) -> agent_client_protocol::Result<()> {
1066 cx.send_notification(SessionNotification::new(
1067 session_id,
1068 SessionUpdate::Plan(plan),
1069 ))
1070 }
1071
1072 /// Advertise siGit's slash commands to the client. Editors like Zed parse
1073 /// `/`-prefixed input and only forward commands they've been told about, so
1074 /// without this `/login`, `/models`, etc. are rejected client-side.
1075 fn advertise_commands(&self, cx: &ConnectionTo<Client>, session_id: SessionId) {
1076 let with_hint = |name: &str, desc: &str, hint: &str| {
1077 AvailableCommand::new(name, desc).input(AvailableCommandInput::Unstructured(
1078 UnstructuredCommandInput::new(hint),
1079 ))
1080 };
1081 let mut commands = vec![
1082 AvailableCommand::new("help", "Show available commands"),
1083 AvailableCommand::new("models", "List available models").input(
1084 AvailableCommandInput::Unstructured(UnstructuredCommandInput::new(
1085 "model number to switch to (optional)",
1086 )),
1087 ),
1088 with_hint(
1089 "local",
1090 "Toggle on-device inference mode",
1091 "on|off (optional)",
1092 ),
1093 AvailableCommand::new("skills", "List available Agent Skills"),
1094 AvailableCommand::new(
1095 "agents",
1096 "List configured subagent types (.sigit/agents/*.md) for task",
1097 ),
1098 AvailableCommand::new(
1099 "commands",
1100 "List user-defined commands (.sigit/commands/*.md)",
1101 ),
1102 AvailableCommand::new("mcp", "List MCP servers and their tools"),
1103 AvailableCommand::new("load", "Load the selected on-device model"),
1104 with_hint(
1105 "login",
1106 "Sign in to siGit Code Cloud (opens your browser)",
1107 "[email] [password]",
1108 ),
1109 AvailableCommand::new("logout", "Sign out of siGit Code Cloud"),
1110 AvailableCommand::new("whoami", "Show the signed-in account"),
1111 AvailableCommand::new("reload", "Re-sync sign-in and model state"),
1112 with_hint(
1113 "plan",
1114 "Plan mode: research only, no edits or commands",
1115 "on|off (optional)",
1116 ),
1117 AvailableCommand::new("permissions", "Show the tool permission policy"),
1118 AvailableCommand::new("compact", "Summarize and shrink the conversation history"),
1119 AvailableCommand::new("init", "Generate or improve the repo's AGENTS.md"),
1120 AvailableCommand::new("clear", "Wipe the conversation history"),
1121 AvailableCommand::new("status", "Show engine status"),
1122 ];
1123
1124 // User-defined commands (.sigit/commands/*.md etc.) are appended so
1125 // clients that only forward advertised commands (Zed) can actually
1126 // send them. A custom command sharing a name with a built-in is
1127 // unreachable — parse_slash always matches built-ins first — so skip
1128 // advertising the shadowed duplicate rather than confuse the client.
1129 let built_in_names: Vec<String> = commands.iter().map(|c| c.name.to_string()).collect();
1130 for command in commands::discover_commands() {
1131 if built_in_names.contains(&command.name) {
1132 log::warn!(
1133 "custom command \"{}\" at {} is shadowed by a built-in command of the same name",
1134 command.name,
1135 command.path.display()
1136 );
1137 continue;
1138 }
1139 let description = command
1140 .description
1141 .clone()
1142 .unwrap_or_else(|| "User-defined command".to_string());
1143 commands.push(match &command.argument_hint {
1144 Some(hint) => with_hint(&command.name, &description, hint),
1145 None => AvailableCommand::new(&command.name, &description),
1146 });
1147 }
1148
1149 self.send_tool_call_update(
1150 cx,
1151 session_id,
1152 SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)),
1153 )
1154 .ok();
1155 }
1156
1157 async fn switch_model_by_id(
1158 &self,
1159 model_id: &str,
1160 ) -> agent_client_protocol::Result<GgufModelConfig> {
1161 let (new_config, max_tokens, new_tool_calling) = resolve_model_config(model_id)
1162 .ok_or_else(|| {
1163 agent_client_protocol::Error::new(
1164 -32602,
1165 format!("unknown or unavailable model: {model_id}"),
1166 )
1167 })?;
1168
1169 log::info!(
1170 "switching model to {} (max_tokens={max_tokens})",
1171 new_config.display_name
1172 );
1173
1174 // Loading weights resets the engine's history, which would restart the
1175 // conversation under the new model. Keep the turns so far and replay
1176 // them below, once the new prompt and session context are seeded.
1177 let carried = self
1178 .engine
1179 .history()
1180 .await
1181 .into_iter()
1182 .filter(|message| message.role != onde::inference::ChatRole::System)
1183 .collect::<Vec<_>>();
1184
1185 let sampling = SamplingConfig {
1186 max_tokens: Some(max_tokens),
1187 ..SamplingConfig::default()
1188 };
1189
1190 // block_in_place inside spawn_local panics, so run the load on a
1191 // dedicated thread with its own runtime (same trick as startup)
1192 let (result_tx, result_rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
1193 let loader_engine = Arc::clone(&self.engine);
1194 let loader_config = new_config.clone();
1195 let loader_system_prompt = system_prompt_for_model(new_tool_calling).to_string();
1196 let loader_sampling = sampling;
1197
1198 std::thread::spawn(move || {
1199 let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
1200 let result = rt.block_on(async move {
1201 // load_gguf_model already unloads the old model internally;
1202 // calling unload first would leave a gap where prompts fail
1203 loader_engine
1204 .load_gguf_model(
1205 loader_config,
1206 Some(loader_system_prompt),
1207 Some(loader_sampling),
1208 )
1209 .await
1210 });
1211 let _ = result_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
1212 });
1213
1214 result_rx
1215 .await
1216 .map_err(|_| agent_client_protocol::Error::new(-32603, "model loader thread crashed"))?
1217 .map_err(|error| {
1218 log::error!("model switch failed: {error}");
1219 agent_client_protocol::Error::new(-32603, format!("model switch failed: {error}"))
1220 })?;
1221
1222 self.startup_model_load_started
1223 .store(true, Ordering::Release);
1224 self.model_ready.store(true, Ordering::Release);
1225 if let Ok(mut guard) = self.model_load_error.lock() {
1226 *guard = None;
1227 }
1228
1229 if let Some(item) = models::local_picker_items()
1230 .iter()
1231 .find(|item| item.config.model_id == new_config.model_id)
1232 && let Err(err) = setup::save_selected_model(&setup::SelectedModel {
1233 model_id: item.config.model_id.clone(),
1234 gguf_file: item.config.files.first().cloned().unwrap_or_default(),
1235 })
1236 {
1237 log::warn!("failed to persist model selection: {err}");
1238 }
1239
1240 {
1241 let mut guard = self.current_model.lock().unwrap();
1242 *guard = new_config.clone();
1243 }
1244
1245 if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
1246 self.engine
1247 .push_history(onde::inference::ChatMessage::system(
1248 session_context_message(&cwd, &workspace::additional_roots()),
1249 ))
1250 .await;
1251 }
1252
1253 for message in carried {
1254 self.engine.push_history(message).await;
1255 }
1256
1257 Ok(new_config)
1258 }
1259 }
1260
1261 // ── ACP handler implementations ───────────────────────────────────────────────
1262
1263 impl SiGitAgent {
1264 async fn handle_initialize(
1265 &self,
1266 _req: InitializeRequest,
1267 ) -> agent_client_protocol::Result<InitializeResponse> {
1268 log::info!("initialize");
1269
1270 // Agent-handled auth method. We don't use `AuthMethod::Terminal`: editors
1271 // like Zed advertise terminal-auth capability but don't actually spawn the
1272 // login terminal for *custom* ACP agents, so the button is a silent no-op.
1273 // With an Agent method, clicking calls `authenticate`, which opens the
1274 // browser and finishes the sign-in itself.
1275 let auth_methods = vec![AuthMethod::Agent(
1276 AuthMethodAgent::new("sigit", "Sign in to siGit Code")
1277 .description("Opens sigit.si in your browser to authorize this device."),
1278 )];
1279
1280 Ok(InitializeResponse::new(ProtocolVersion::V1)
1281 .agent_info(
1282 Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
1283 .title("siGit Code - AI Coding Agent"),
1284 )
1285 .auth_methods(auth_methods)
1286 .agent_capabilities(
1287 AgentCapabilities::default()
1288 .load_session(true)
1289 .session_capabilities(
1290 SessionCapabilities::new()
1291 .fork(SessionForkCapabilities::new())
1292 // Without this, a client that has several directories
1293 // open never sends the extra ones: Zed drops every root
1294 // but the first and tells the user the agent has no
1295 // multi-root support. See `workspace.rs`.
1296 .additional_directories(SessionAdditionalDirectoriesCapabilities::new())
1297 .list(SessionListCapabilities::new())
1298 .delete(SessionDeleteCapabilities::new()),
1299 ),
1300 )
1301 .meta(initialize_meta()))
1302 }
1303
1304 async fn handle_authenticate(
1305 &self,
1306 req: AuthenticateRequest,
1307 ) -> agent_client_protocol::Result<AuthenticateResponse> {
1308 log::info!("authenticate: method={}", req.method_id.0);
1309
1310 // An existing session clears the gate without sending anyone to a
1311 // browser they didn't ask for.
1312 if let Ok(email) = account::verify_session().await {
1313 log::info!("authenticate: verified existing session for {email}");
1314 return Ok(AuthenticateResponse::default());
1315 }
1316
1317 // Otherwise run the browser flow. There is no way to collect a password
1318 // here (the method has no UI of its own) and no way to show a code to
1319 // paste, so this is the loopback variant or nothing: the editor gets an
1320 // error naming the terminal command that does work without a listener.
1321 let flow = browser_auth::begin();
1322 if !flow.is_loopback() {
1323 return Err(agent_client_protocol::Error::new(
1324 -32000,
1325 "Could not open a local port to receive the sign-in. \
1326 Run `sigit login` in a terminal instead."
1327 .to_string(),
1328 ));
1329 }
1330
1331 log::info!("authenticate: opening browser at {}", flow.authorize_url());
1332 if !browser_auth::open_browser(flow.authorize_url()) {
1333 log::warn!("authenticate: no browser could be launched");
1334 }
1335
1336 match browser_auth::complete_loopback(flow).await {
1337 Ok(email) => {
1338 log::info!("authenticate: signed in as {email}");
1339 Ok(AuthenticateResponse::default())
1340 }
1341 Err(reason) => Err(agent_client_protocol::Error::new(
1342 -32000,
1343 format!(
1344 "Sign-in did not complete ({reason}). \
1345 Try again, or create an account at https://sigit.si."
1346 ),
1347 )),
1348 }
1349 }
1350
1351 /// Record the session's roots and move the process into the primary one.
1352 ///
1353 /// The editor sends `cwd` plus, for a multi-root project, the other
1354 /// directories it has open. Tool calls may use relative paths, so the
1355 /// process follows `cwd`; the extra roots go to `workspace`, which is where
1356 /// project-local discovery picks them up. Returns the roots that were kept.
1357 fn enter_session_roots(&self, cwd: &std::path::Path, additional: &[PathBuf]) -> Vec<PathBuf> {
1358 if let Ok(mut guard) = self.session_cwd.lock() {
1359 *guard = Some(cwd.to_path_buf());
1360 }
1361 let additional_roots = workspace::set_additional_roots(cwd, additional);
1362 if cwd.is_dir()
1363 && let Err(err) = std::env::set_current_dir(cwd)
1364 {
1365 log::warn!("could not set cwd to {}: {err}", cwd.display());
1366 }
1367 additional_roots
1368 }
1369
1370 /// Serve `session/list`: a pure filesystem read, no engine or backend
1371 /// access, so it stays answerable while a turn is in flight (registered
1372 /// inline, not in the `turn_lock` group).
1373 ///
1374 /// Sessions with no metadata sidecar are skipped — that covers transcripts
1375 /// saved before this existed and the singleton `tui`/`headless` entries,
1376 /// neither of which has a meaningful cwd for an editor's thread list.
1377 async fn handle_list_sessions(
1378 &self,
1379 args: ListSessionsRequest,
1380 ) -> agent_client_protocol::Result<ListSessionsResponse> {
1381 let sessions = session_store::list()
1382 .into_iter()
1383 .filter_map(|entry| {
1384 let meta = entry.meta?;
1385 if let Some(cwd) = &args.cwd
1386 && *cwd != meta.cwd
1387 {
1388 return None;
1389 }
1390 Some(
1391 SessionInfo::new(SessionId::new(entry.id), meta.cwd)
1392 .additional_directories(meta.additional_directories)
1393 .title(meta.title)
1394 .updated_at(rfc3339(entry.modified)),
1395 )
1396 })
1397 .collect();
1398
1399 Ok(ListSessionsResponse::new(sessions))
1400 }
1401
1402 /// Serve `session/delete`: removes the transcript and its metadata
1403 /// sidecar, and drops any permission grants so they don't outlive the
1404 /// thread. Registered in the `turn_lock` group — without the lock a
1405 /// delete could land between a turn's inference and its end-of-turn
1406 /// `session_store::save`, and the save would resurrect the file the user
1407 /// just removed.
1408 async fn handle_delete_session(
1409 &self,
1410 args: DeleteSessionRequest,
1411 ) -> agent_client_protocol::Result<DeleteSessionResponse> {
1412 log::info!("delete_session: id={}", args.session_id);
1413 session_store::delete(&args.session_id.to_string());
1414 permissions::reset_session(&args.session_id.to_string());
1415 Ok(DeleteSessionResponse::new())
1416 }
1417
1418 async fn handle_load_session(
1419 &self,
1420 cx: &ConnectionTo<Client>,
1421 args: LoadSessionRequest,
1422 ) -> agent_client_protocol::Result<LoadSessionResponse> {
1423 log::info!(
1424 "load_session: id={}, cwd={}, additional_directories={:?}",
1425 args.session_id,
1426 args.cwd.display(),
1427 args.additional_directories
1428 .iter()
1429 .map(|p| p.display().to_string())
1430 .collect::<Vec<_>>()
1431 );
1432
1433 let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1434
1435 // A session boundary: grants and plan mode from the previous life of
1436 // this session id must not carry over — and since one shared engine
1437 // means one live conversation, state for every other id is dead too.
1438 permissions::reset_all();
1439
1440 // start from a clean slate; a stored session (below) replaces it
1441 self.engine.clear_history().await;
1442
1443 self.engine
1444 .push_history(onde::inference::ChatMessage::system(
1445 session_context_message(&args.cwd, &additional_roots),
1446 ))
1447 .await;
1448
1449 // Honor the persisted Local Inference toggle (off + signed in → cloud).
1450 self.apply_startup_inference_mode().await;
1451
1452 // Durable sessions: when this session id was saved before, restore its
1453 // history into the active backend. The snapshot includes the system
1454 // messages that were live when it was saved, so restore replaces the
1455 // freshly seeded state wholesale.
1456 if let Some(history) = session_store::load(&args.session_id.to_string()) {
1457 let restored = history.len();
1458
1459 // Replay before restoring: the client draws the reopened thread
1460 // from these notifications alone, and `restore_history` consumes
1461 // the snapshot.
1462 let updates = history_replay_updates(&history);
1463 let replayed = updates.len();
1464 for update in updates {
1465 cx.send_notification(SessionNotification::new(args.session_id.clone(), update))
1466 .ok();
1467 }
1468
1469 let backend = self.backend.lock().await.clone();
1470 backend.restore_history(history).await;
1471 log::info!(
1472 "load_session: restored {restored} message(s), replayed {replayed} update(s) for {}",
1473 args.session_id
1474 );
1475 }
1476
1477 let config_options = {
1478 let guard = self.current_model.lock().unwrap();
1479 build_model_config_options(&guard)
1480 };
1481
1482 self.advertise_commands(cx, args.session_id.clone());
1483
1484 // Run SessionStart hooks
1485 let hook_settings = settings::load().hooks;
1486 if hook_settings.has_hooks() {
1487 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &args.session_id.to_string());
1488 }
1489
1490 Ok(LoadSessionResponse::new().config_options(config_options))
1491 }
1492
1493 async fn handle_fork_session(
1494 &self,
1495 cx: &ConnectionTo<Client>,
1496 args: ForkSessionRequest,
1497 ) -> agent_client_protocol::Result<ForkSessionResponse> {
1498 let new_id = SessionId::new(uuid::Uuid::new_v4().to_string());
1499 // Session boundary: permission grants and plan mode never cross it
1500 // (see handle_load_session), so a fork starts with a clean slate.
1501 permissions::reset_all();
1502 log::info!(
1503 "fork_session: from={} new={new_id}, cwd={}, additional_directories={:?}",
1504 args.session_id,
1505 args.cwd.display(),
1506 args.additional_directories
1507 .iter()
1508 .map(|p| p.display().to_string())
1509 .collect::<Vec<_>>()
1510 );
1511
1512 let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1513
1514 // no persistence, so fork == fresh session
1515 self.engine.clear_history().await;
1516
1517 self.engine
1518 .push_history(onde::inference::ChatMessage::system(
1519 session_context_message(&args.cwd, &additional_roots),
1520 ))
1521 .await;
1522
1523 // Honor the persisted Local Inference toggle (off + signed in → cloud).
1524 self.apply_startup_inference_mode().await;
1525
1526 let config_options = {
1527 let guard = self.current_model.lock().unwrap();
1528 build_model_config_options(&guard)
1529 };
1530
1531 self.advertise_commands(cx, new_id.clone());
1532
1533 // Run SessionStart hooks
1534 let hook_settings = settings::load().hooks;
1535 if hook_settings.has_hooks() {
1536 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &new_id.to_string());
1537 }
1538
1539 Ok(ForkSessionResponse::new(new_id).config_options(config_options))
1540 }
1541
1542 async fn handle_new_session(
1543 &self,
1544 cx: &ConnectionTo<Client>,
1545 args: NewSessionRequest,
1546 ) -> agent_client_protocol::Result<NewSessionResponse> {
1547 let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
1548 // Session boundary: permission grants and plan mode never cross it
1549 // (see handle_load_session), so stale ids stop accumulating state.
1550 permissions::reset_all();
1551 log::info!(
1552 "new_session: id={session_id}, cwd={}, additional_directories={:?}",
1553 args.cwd.display(),
1554 args.additional_directories
1555 .iter()
1556 .map(|p| p.display().to_string())
1557 .collect::<Vec<_>>()
1558 );
1559
1560 let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1561
1562 self.engine.clear_history().await;
1563
1564 self.engine
1565 .push_history(onde::inference::ChatMessage::system(
1566 session_context_message(&args.cwd, &additional_roots),
1567 ))
1568 .await;
1569
1570 // Honor the persisted Local Inference toggle (off + signed in → cloud).
1571 self.apply_startup_inference_mode().await;
1572
1573 let config_options = {
1574 let guard = self.current_model.lock().unwrap();
1575 build_model_config_options(&guard)
1576 };
1577
1578 self.advertise_commands(cx, session_id.clone());
1579
1580 // Run SessionStart hooks
1581 let hook_settings = settings::load().hooks;
1582 if hook_settings.has_hooks() {
1583 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &session_id.to_string());
1584 }
1585
1586 Ok(NewSessionResponse::new(session_id).config_options(config_options))
1587 }
1588
1589 async fn handle_prompt(
1590 &self,
1591 cx: &ConnectionTo<Client>,
1592 args: PromptRequest,
1593 ) -> agent_client_protocol::Result<PromptResponse> {
1594 let session_id = args.session_id.clone();
1595
1596 // log every block so we can debug @ references and file context
1597 for (i, block) in args.prompt.iter().enumerate() {
1598 match block {
1599 ContentBlock::Text(t) => {
1600 log::info!(
1601 "prompt({}) block[{}]: Text({} chars) = \"{}\"",
1602 session_id,
1603 i,
1604 t.text.len(),
1605 t.text.chars().take(200).collect::<String>()
1606 );
1607 }
1608 ContentBlock::Resource(embedded) => {
1609 log::info!(
1610 "prompt({}) block[{}]: EmbeddedResource = {:?}",
1611 session_id,
1612 i,
1613 match &embedded.resource {
1614 EmbeddedResourceResource::TextResourceContents(t) =>
1615 format!("TextResource(uri={}, {} chars)", t.uri, t.text.len()),
1616 EmbeddedResourceResource::BlobResourceContents(b) =>
1617 format!("BlobResource(uri={})", b.uri),
1618 _ => "Unknown".to_string(),
1619 }
1620 );
1621 }
1622 ContentBlock::ResourceLink(link) => {
1623 log::info!(
1624 "prompt({}) block[{}]: ResourceLink(name={}, uri={}, title={:?}, desc={:?})",
1625 session_id,
1626 i,
1627 link.name,
1628 link.uri,
1629 link.title,
1630 link.description
1631 );
1632 }
1633 other => {
1634 log::info!(
1635 "prompt({}) block[{}]: Other({:?})",
1636 session_id,
1637 i,
1638 std::mem::discriminant(other)
1639 );
1640 }
1641 }
1642 }
1643
1644 let mut parts: Vec<String> = Vec::new();
1645
1646 for block in &args.prompt {
1647 match block {
1648 ContentBlock::Text(t) => {
1649 parts.push(t.text.clone());
1650 }
1651 ContentBlock::Resource(embedded) => {
1652 // editor inlined the file content already
1653 match &embedded.resource {
1654 EmbeddedResourceResource::TextResourceContents(text_resource) => {
1655 parts.push(format!(
1656 "\n--- {} ---\n{}\n--- end {} ---",
1657 text_resource.uri, text_resource.text, text_resource.uri
1658 ));
1659 }
1660 EmbeddedResourceResource::BlobResourceContents(blob) => {
1661 parts.push(format!("[binary resource: {}]", blob.uri));
1662 }
1663 _ => {
1664 log::debug!("ignoring unsupported embedded resource variant");
1665 }
1666 }
1667 }
1668 ContentBlock::ResourceLink(link) => {
1669 // reference without content; read the file ourselves
1670 let label = link.name.clone();
1671
1672 if let Some(raw_path) = link.uri.strip_prefix("file://") {
1673 let (file_path, line_range) = if let Some(hash_pos) = raw_path.rfind('#') {
1674 let fragment = &raw_path[hash_pos + 1..];
1675 let path = &raw_path[..hash_pos];
1676 // Parse "L207:219" or "L207-219" → (207, 219)
1677 let range = fragment.strip_prefix('L').and_then(|rest| {
1678 let sep = if rest.contains(':') { ':' } else { '-' };
1679 let mut parts = rest.splitn(2, sep);
1680 let start = parts.next()?.parse::<usize>().ok()?;
1681 let end = parts.next()?.parse::<usize>().ok()?;
1682 Some((start, end))
1683 });
1684 (path, range)
1685 } else {
1686 (raw_path, None)
1687 };
1688
1689 match std::fs::read_to_string(file_path) {
1690 Ok(contents) => {
1691 let extracted = if let Some((start, end)) = line_range {
1692 let selected: Vec<&str> = contents
1693 .lines()
1694 .enumerate()
1695 .filter(|(i, _)| {
1696 let line_num = i + 1;
1697 line_num >= start && line_num <= end
1698 })
1699 .map(|(_, line)| line)
1700 .collect();
1701 format!(
1702 "\n--- {label} ({file_path} lines {start}-{end}) ---\n{}\n--- end {label} ---",
1703 selected.join("\n")
1704 )
1705 } else {
1706 format!(
1707 "\n--- {label} ({file_path}) ---\n{contents}\n--- end {label} ---"
1708 )
1709 };
1710 parts.push(extracted);
1711 }
1712 Err(err) => {
1713 log::warn!("could not read ResourceLink {}: {err}", link.uri);
1714 parts.push(format!("[referenced file: {label} ({file_path})]"));
1715 }
1716 }
1717 } else {
1718 parts.push(format!("[resource link: {label} ({})]", link.uri));
1719 }
1720 }
1721 _ => {
1722 log::debug!("ignoring unsupported content block type in prompt");
1723 }
1724 }
1725 }
1726
1727 let user_text = parts.join("\n");
1728
1729 if user_text.trim().is_empty() {
1730 return Ok(PromptResponse::new(StopReason::EndTurn));
1731 }
1732
1733 let user_text = match parse_slash(&user_text) {
1734 // `/init` is not a status command: it substitutes the canned
1735 // AGENTS.md-generation prompt and runs a normal agent turn, so the
1736 // exploration and file write go through the ordinary tools and
1737 // permission checks.
1738 Some(SlashCommand::Init) => instructions::INIT_PROMPT.to_string(),
1739 // A word `parse_slash` didn't recognize might be a user-defined
1740 // command (`.sigit/commands/*.md`) — same substitute-and-run-a-
1741 // real-turn treatment as `/init`. Only fall back to "unknown
1742 // command" once that lookup also misses.
1743 Some(SlashCommand::Unknown(command, argument)) => {
1744 match commands::resolve_command(&command) {
1745 Some(custom) => commands::render(&custom.body, argument.as_deref()),
1746 None => {
1747 return exec_slash_acp(
1748 self,
1749 cx,
1750 session_id,
1751 SlashCommand::Unknown(command, argument),
1752 )
1753 .await;
1754 }
1755 }
1756 }
1757 Some(command) => return exec_slash_acp(self, cx, session_id, command).await,
1758 None => user_text,
1759 };
1760
1761 log::info!(
1762 "prompt({}): \"{}\"",
1763 session_id,
1764 user_text.chars().take(80).collect::<String>()
1765 );
1766
1767 // The active backend drives the turn. Snapshot it once so a mid-turn
1768 // model switch doesn't split the conversation across backends.
1769 let backend = self.backend.lock().await.clone();
1770
1771 // Only on-device inference needs a local model in memory. Cloud tiers run
1772 // over the network, so they never need a local model. Xcode's explicit
1773 // `--acp` mode loads lazily on the first prompt because its custom-agent
1774 // UI has no reliable equivalent of siGit's `/load` command.
1775 if !backend.is_remote()
1776 && self.engine.info().await.status == onde::inference::EngineStatus::Unloaded
1777 {
1778 if self.auto_load_local_model {
1779 self.start_startup_model_load_if_needed();
1780 self.await_model_ready(cx, &session_id).await?;
1781 } else {
1782 self.send_assistant_message(
1783 cx,
1784 session_id,
1785 "No on-device model is loaded. Run `/load` to load the selected model, \
1786 or `/models` to choose one.",
1787 )
1788 .ok();
1789 return Ok(PromptResponse::new(StopReason::EndTurn));
1790 }
1791 }
1792
1793 // ── tool-calling loop ────────────────────────────────────────────
1794 // send message → execute any tool calls → feed results back
1795 // repeat up to MAX_TOOL_ROUNDS, then force a text reply
1796
1797 let tools = agent_tools_as_specs();
1798
1799 // Token sink: backends stream assistant text through this while a turn
1800 // runs. We forward the visible portion to the editor as agent-message
1801 // chunks live (see `drain_turn` / `emit_visible_chunk`). The sink stays
1802 // alive for the whole prompt so `recv()` only ends when a turn future
1803 // resolves, never because every sender was dropped.
1804 let (sink, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
1805 let mut reply = StreamedReply::default();
1806 let mut repeated_tool_calls = std::collections::HashMap::<String, usize>::new();
1807
1808 let mut result = self
1809 .drain_turn(
1810 cx,
1811 &session_id,
1812 backend.send_message_with_tools(&user_text, &tools, Some(&sink)),
1813 &mut sink_rx,
1814 &mut reply,
1815 )
1816 .await
1817 .map_err(|error| {
1818 log::error!("send_message_with_tools failed: {error}");
1819 // Verbatim, no prefix: the backend has already turned this into
1820 // something worth reading (see `describe_api_error`), and it is
1821 // what the editor puts in its error banner. The context a
1822 // prefix would add is in the log line above.
1823 agent_client_protocol::Error::new(-32603, error)
1824 })?;
1825
1826 let mut round = 0;
1827
1828 while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
1829 round += 1;
1830 log::info!(
1831 "prompt({}) tool round {} — {} call(s)",
1832 session_id,
1833 round,
1834 result.tool_calls.len()
1835 );
1836
1837 // Auto-compaction: long tool runs grow history fast; fold it into
1838 // a summary before the next round rather than blowing the window.
1839 let estimate = backend::estimate_tokens(&backend.history_snapshot().await);
1840 if estimate > backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
1841 log::info!(
1842 "prompt({}) history ≈{} tokens exceeds budget {} — compacting",
1843 session_id,
1844 estimate,
1845 backend::DEFAULT_CONTEXT_TOKEN_BUDGET
1846 );
1847 match backend.compact_history(backend::COMPACT_KEEP_LAST).await {
1848 Ok(()) => {
1849 let after = backend::estimate_tokens(&backend.history_snapshot().await);
1850 log::info!("prompt({}) compacted to ≈{} tokens", session_id, after);
1851 }
1852 Err(error) => {
1853 log::warn!("prompt({}) compaction failed: {error}", session_id);
1854 self.send_assistant_message(
1855 cx,
1856 session_id,
1857 format!(
1858 "This session is too large, and siGit Code could not compact it: \
1859 {error}. Start a new thread or run `/clear`, then retry."
1860 ),
1861 )
1862 .ok();
1863 return Ok(PromptResponse::new(StopReason::EndTurn));
1864 }
1865 }
1866 }
1867
1868 let mut tool_results = Vec::new();
1869 let mut force_text = false;
1870
1871 for (call_index, tc) in result.tool_calls.iter().enumerate() {
1872 log::info!(
1873 " → {}({})",
1874 tc.name,
1875 tc.arguments.chars().take(120).collect::<String>()
1876 );
1877
1878 // Only treat the call as a plan once its arguments have actually
1879 // produced one. Keying off the tool name alone means a
1880 // `write_todos` the converter rejects sends no plan *and* skips
1881 // the tool-call card below, so the call disappears from the
1882 // client while the model is told the list was updated.
1883 let plan = (tc.name == "write_todos")
1884 .then(|| todos_arguments_to_plan(&tc.arguments))
1885 .flatten();
1886 let render_as_plan = plan.is_some();
1887 if let Some(plan) = plan {
1888 self.send_plan_update(cx, session_id.clone(), plan).ok();
1889 } else {
1890 // Model tool calls are agent actions in ACP too, not merely an
1891 // implementation detail of the completion loop. Announce each
1892 // one before it runs and close it afterwards so clients such as
1893 // Zed can render activity while the next inference round is in
1894 // flight. Previously only model loading emitted ToolCall events,
1895 // leaving an otherwise working tool round visually indistinct
1896 // from a stalled response.
1897 let raw_input: serde_json::Value = serde_json::from_str(&tc.arguments)
1898 .unwrap_or_else(|e| {
1899 log::warn!("malformed JSON in tool arguments for '{}': {e}", tc.name);
1900 serde_json::Value::String(tc.arguments.clone())
1901 });
1902 self.send_tool_call_update(
1903 cx,
1904 session_id.clone(),
1905 SessionUpdate::ToolCall(
1906 ToolCall::new(tc.id.clone(), tc.name.clone())
1907 .kind(tool_kind_for(&tc.name))
1908 .status(ToolCallStatus::InProgress)
1909 .raw_input(raw_input),
1910 ),
1911 )
1912 .ok();
1913 }
1914
1915 let signature = format!("{}\n{}", tc.name, tc.arguments);
1916 let repeat_count = repeated_tool_calls
1917 .entry(signature)
1918 .and_modify(|count| *count += 1)
1919 .or_insert(1);
1920 let repeated = *repeat_count >= 3;
1921 if repeated {
1922 force_text = true;
1923 log::warn!(
1924 "prompt({}) stopping repeated tool call `{}` after {} attempts",
1925 session_id,
1926 tc.name,
1927 repeat_count
1928 );
1929 }
1930
1931 // Permission gate: read-only tools pass straight through; a
1932 // mutating tool consults policy and may ask the client.
1933 let output = if repeated {
1934 format!(
1935 "The tool `{}` was not executed again because the model repeated \
1936 the same call three times. Continue without this tool.",
1937 tc.name
1938 )
1939 } else {
1940 match permissions::decision_for(
1941 &session_id.to_string(),
1942 &tc.name,
1943 &tc.arguments,
1944 ) {
1945 permissions::Decision::Allow => {
1946 tools::execute_tool(&tc.name, &tc.arguments).await
1947 }
1948 permissions::Decision::Deny(reason) => {
1949 log::info!(" ✗ {} denied by policy", tc.name);
1950 reason
1951 }
1952 permissions::Decision::Ask => {
1953 match self
1954 .request_tool_permission(cx, &session_id, &tc.name, &tc.arguments)
1955 .await
1956 {
1957 PermissionVerdict::Approved => {
1958 tools::execute_tool(&tc.name, &tc.arguments).await
1959 }
1960 PermissionVerdict::Denied(reason) => {
1961 log::info!(" ✗ {} denied by user", tc.name);
1962 reason
1963 }
1964 PermissionVerdict::TurnCancelled => {
1965 log::info!(
1966 "prompt({}) cancelled at permission gate",
1967 session_id
1968 );
1969 // The assistant message carrying these tool
1970 // calls is already in the backend history;
1971 // leaving any of them unanswered makes strict
1972 // OpenAI-compatible endpoints reject every
1973 // later request in the session. Close out this
1974 // call and the ones this round never reached.
1975 for pending in &result.tool_calls[call_index..] {
1976 tool_results.push(BackendToolResult {
1977 tool_call_id: pending.id.clone(),
1978 content: format!(
1979 "`{}` was not executed: the user cancelled the turn \
1980 at the permission prompt.",
1981 pending.name
1982 ),
1983 });
1984 }
1985 self.send_tool_call_update(
1986 cx,
1987 session_id.clone(),
1988 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1989 tc.id.clone(),
1990 ToolCallUpdateFields::new()
1991 .status(ToolCallStatus::Failed)
1992 .raw_output(serde_json::Value::String(
1993 "Not executed: the user cancelled the turn at the permission prompt."
1994 .to_string(),
1995 )),
1996 )),
1997 )
1998 .ok();
1999 backend.record_cancelled_tool_results(tool_results).await;
2000 return Ok(PromptResponse::new(StopReason::Cancelled));
2001 }
2002 }
2003 }
2004 }
2005 };
2006
2007 log::info!(" ← {} chars", output.len());
2008
2009 if !render_as_plan {
2010 self.send_tool_call_update(
2011 cx,
2012 session_id.clone(),
2013 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
2014 tc.id.clone(),
2015 ToolCallUpdateFields::new()
2016 .status(ToolCallStatus::Completed)
2017 .raw_output(serde_json::Value::String(output.clone())),
2018 )),
2019 )
2020 .ok();
2021 }
2022
2023 tool_results.push(BackendToolResult {
2024 tool_call_id: tc.id.clone(),
2025 content: output,
2026 });
2027 }
2028
2029 let next_tools = if round < MAX_TOOL_ROUNDS && !force_text {
2030 Some(tools.as_slice())
2031 } else {
2032 None // last round: force text
2033 };
2034
2035 // Whatever this round says starts a new paragraph rather than
2036 // continuing the sentence the tool calls interrupted.
2037 reply.interrupt();
2038
2039 result = self
2040 .drain_turn(
2041 cx,
2042 &session_id,
2043 backend.send_tool_results(tool_results, next_tools, Some(&sink)),
2044 &mut sink_rx,
2045 &mut reply,
2046 )
2047 .await
2048 .map_err(|error| {
2049 log::error!("send_tool_results failed: {error}");
2050 agent_client_protocol::Error::new(-32603, error)
2051 })?;
2052 }
2053
2054 // ── Final text response ───────────────────────────────────────────
2055 // If anything streamed, the visible reply is already on the wire; only
2056 // send a trailing block for the non-streamed path (e.g. on-device direct
2057 // answers, which onde can't stream while tools are on offer).
2058 if !reply.streamed_any {
2059 let reply_text = result.text.trim().to_string();
2060 let final_text = if reply_text.is_empty() {
2061 if round > 0 {
2062 log::warn!(
2063 "prompt({}) — model returned empty reply after {} tool round(s)",
2064 session_id,
2065 round
2066 );
2067 "Something went wrong — the edits didn't go through. Try rephrasing what you need, or point me at the specific lines.".to_string()
2068 } else {
2069 log::warn!(
2070 "prompt({}) — model returned empty reply (no tool rounds)",
2071 session_id
2072 );
2073 String::new()
2074 }
2075 } else {
2076 // strip <think> blocks so reasoning tokens stay hidden
2077 let (_think, visible) = chat::strip_think_blocks(&reply_text);
2078 visible
2079 };
2080
2081 if !final_text.is_empty() {
2082 self.send_assistant_message(cx, session_id.clone(), final_text)
2083 .ok();
2084 }
2085 }
2086
2087 // Persist the completed turn so a restart (or session/load) can pick
2088 // the conversation back up.
2089 let snapshot = backend.history_snapshot().await;
2090 if let Err(error) = session_store::save(&session_id.to_string(), &snapshot) {
2091 log::warn!("prompt({}) session save failed: {error}", session_id);
2092 }
2093
2094 // Metadata sidecar for `session/list`. Written every turn — cheap, and
2095 // self-healing for sessions that predate this change.
2096 if let Some(cwd) = self.session_cwd.lock().ok().and_then(|guard| guard.clone()) {
2097 let meta = session_store::SessionMeta {
2098 cwd,
2099 additional_directories: workspace::additional_roots(),
2100 title: title_from_history(&snapshot),
2101 };
2102 if let Err(error) = session_store::save_meta(&session_id.to_string(), &meta) {
2103 log::warn!("prompt({}) session meta save failed: {error}", session_id);
2104 }
2105 }
2106
2107 log::info!("prompt({}) complete — {} tool round(s)", session_id, round);
2108 Ok(PromptResponse::new(StopReason::EndTurn))
2109 }
2110
2111 /// Ask the ACP client for permission to run one tool call. Presents
2112 /// allow-once / allow-for-session / deny; an "always allow" choice is
2113 /// recorded via [`permissions::grant_for_session`]. Only safe to call from
2114 /// a spawned task (see the handler registration in `run_acp_server`): the
2115 /// dispatch loop must be free to route the client's answer back to us.
2116 async fn request_tool_permission(
2117 &self,
2118 cx: &ConnectionTo<Client>,
2119 session_id: &SessionId,
2120 tool_name: &str,
2121 arguments: &str,
2122 ) -> PermissionVerdict {
2123 // The user decides from this dialog, so show the arguments with any
2124 // truncation flagged (a silently clipped command could hide its tail
2125 // from the person approving it). The full arguments also travel as
2126 // `raw_input` for clients that render it.
2127 let args_preview = permissions::approval_preview(arguments);
2128 let title = if args_preview.is_empty() {
2129 tool_name.to_string()
2130 } else {
2131 format!("{tool_name}({args_preview})")
2132 };
2133 let raw_input: serde_json::Value = serde_json::from_str(arguments).unwrap_or_else(|e| {
2134 log::warn!("malformed JSON in tool arguments for '{}': {e}", tool_name);
2135 serde_json::Value::String(arguments.to_string())
2136 });
2137
2138 let request = RequestPermissionRequest::new(
2139 session_id.clone(),
2140 ToolCallUpdate::new(
2141 format!("perm-{}", uuid::Uuid::new_v4()),
2142 ToolCallUpdateFields::new()
2143 .title(title)
2144 .kind(tool_kind_for(tool_name))
2145 .status(ToolCallStatus::Pending)
2146 .raw_input(raw_input),
2147 ),
2148 vec![
2149 PermissionOption::new("allow_once", "Allow once", PermissionOptionKind::AllowOnce),
2150 PermissionOption::new(
2151 "allow_session",
2152 "Allow for this session",
2153 PermissionOptionKind::AllowAlways,
2154 ),
2155 PermissionOption::new("reject_once", "Deny", PermissionOptionKind::RejectOnce),
2156 ],
2157 );
2158
2159 match cx.send_request(request).block_task().await {
2160 Ok(response) => match response.outcome {
2161 RequestPermissionOutcome::Selected(selected) => {
2162 match selected.option_id.0.as_ref() {
2163 "allow_once" => PermissionVerdict::Approved,
2164 "allow_session" => {
2165 permissions::grant_for_session(
2166 &session_id.to_string(),
2167 tool_name,
2168 arguments,
2169 );
2170 PermissionVerdict::Approved
2171 }
2172 _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
2173 }
2174 }
2175 RequestPermissionOutcome::Cancelled => PermissionVerdict::TurnCancelled,
2176 // The outcome enum is non_exhaustive; treat anything unknown as
2177 // a denial rather than running a mutating tool unapproved.
2178 _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
2179 },
2180 Err(error) => {
2181 log::warn!("permission request for `{tool_name}` failed: {error}");
2182 PermissionVerdict::Denied(format!(
2183 "`{tool_name}` was not executed: this client could not answer the \
2184 permission request ({error}). The user can pre-approve tools in \
2185 settings.toml under [permissions], or set SIGIT_PERMISSIONS=allow \
2186 for clients without permission support."
2187 ))
2188 }
2189 }
2190 }
2191
2192 async fn handle_cancel(&self, args: CancelNotification) -> agent_client_protocol::Result<()> {
2193 log::info!("cancel requested for session {}", args.session_id);
2194 Ok(())
2195 }
2196
2197 /// Swap the active backend to a siGit Code Cloud tier and reflect it as the
2198 /// current model so the picker shows it selected. Returns the tier's display
2199 /// name on success, or `None` when no account is signed in (caller prompts
2200 /// for login). Shared by the panel picker and the `/models` slash command.
2201 async fn switch_to_cloud_tier(&self, tier: &str) -> Option<String> {
2202 let cfg = crate::provider::cloud_tier_provider(tier)?;
2203 let mut system_prompt = system_prompt_for_model(true).to_string();
2204 // Mirror the cwd guidance and project instruction files the local engine
2205 // gets at session load, so the cloud model shares the same project context.
2206 if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
2207 system_prompt.push_str("\n\n");
2208 system_prompt.push_str(&session_context_message(
2209 &cwd,
2210 &workspace::additional_roots(),
2211 ));
2212 }
2213 let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
2214 cfg.base_url,
2215 cfg.api_key,
2216 cfg.model,
2217 Some(system_prompt),
2218 ));
2219 // Continue the thread on the new backend instead of starting over.
2220 let carried =
2221 backend::carryover_history(self.backend.lock().await.history_snapshot().await);
2222 backend::adopt_carryover(cloud_backend.as_ref(), carried).await;
2223 *self.backend.lock().await = cloud_backend;
2224
2225 let cloud_config = GgufModelConfig {
2226 model_id: format!("sigit-cloud:{tier}"),
2227 files: Vec::new(),
2228 tok_model_id: None,
2229 display_name: cfg.display_name.clone(),
2230 approx_memory: "Cloud".to_string(),
2231 chat_template: None,
2232 };
2233 {
2234 let mut guard = self.current_model.lock().unwrap();
2235 *guard = cloud_config;
2236 }
2237
2238 // Explicitly choosing a cloud tier puts us in cloud mode.
2239 let _ = settings::set_local_inference(false);
2240
2241 log::info!("switched to cloud tier {tier}");
2242 Some(cfg.display_name)
2243 }
2244
2245 /// Apply the persisted Local Inference mode at session start. When local
2246 /// inference is off and an account is signed in, route to a cloud tier so the
2247 /// on-device model is never loaded; otherwise leave the on-device backend in
2248 /// place. Call after the session cwd is set so the cloud system prompt picks
2249 /// it up. Does not flip the stored setting on the not-signed-in fallback.
2250 async fn apply_startup_inference_mode(&self) {
2251 if settings::local_inference_enabled() {
2252 return;
2253 }
2254 if self.switch_to_cloud_tier("balanced").await.is_some() {
2255 log::info!("startup: local inference off; routing inference to siGit Code Cloud");
2256 } else {
2257 log::warn!(
2258 "local inference is off but no account is signed in; staying on-device. \
2259 Run /login or set Local Inference on."
2260 );
2261 }
2262 }
2263
2264 /// Route inference back on-device. Used after leaving a cloud tier for a
2265 /// local model. The `LocalBackend` reads the live `engine`, so this repoints
2266 /// the active backend and moves the conversation onto the engine with it.
2267 async fn reset_to_local_backend(&self) {
2268 // Coming back from a cloud tier, the turns since the switch live in the
2269 // cloud backend's history, not the engine's — carry them over so the
2270 // on-device model picks the thread up where the cloud one left it. A
2271 // local-to-local switch needs nothing: `switch_model_by_id` has already
2272 // replayed the engine's own history past the reload.
2273 let carried = {
2274 let current = self.backend.lock().await;
2275 if current.is_remote() {
2276 backend::carryover_history(current.history_snapshot().await)
2277 } else {
2278 Vec::new()
2279 }
2280 };
2281
2282 let local_backend: Arc<dyn InferenceBackend> =
2283 Arc::new(LocalBackend::new(Arc::clone(&self.engine)));
2284 backend::adopt_carryover(local_backend.as_ref(), carried).await;
2285 *self.backend.lock().await = local_backend;
2286 }
2287
2288 /// Re-attempt the lazy startup model load if the previous attempt failed.
2289 /// Clears the one-shot guard so the next load runs; a healthy load is left
2290 /// untouched so `/reload` doesn't needlessly reload a working model.
2291 fn retry_startup_model_load_if_failed(&self) {
2292 let had_error = self
2293 .model_load_error
2294 .lock()
2295 .map(|guard| guard.is_some())
2296 .unwrap_or(false);
2297 if had_error {
2298 self.startup_model_load_started
2299 .store(false, Ordering::Release);
2300 self.start_startup_model_load_if_needed();
2301 }
2302 }
2303
2304 /// Re-sync session state in place — no new session needed. Re-applies the
2305 /// active backend from current credentials (so a fresh `/login` token is
2306 /// picked up), retries a failed model load, and pushes refreshed commands +
2307 /// picker so the editor's UI reflects the current state.
2308 async fn handle_reload(&self, cx: &ConnectionTo<Client>, session_id: SessionId) {
2309 let signed_in = account::status_line().await;
2310
2311 let on_cloud_tier = {
2312 let guard = self.current_model.lock().unwrap();
2313 guard
2314 .model_id
2315 .strip_prefix("sigit-cloud:")
2316 .map(str::to_string)
2317 };
2318
2319 let backend_note = match on_cloud_tier {
2320 Some(tier) => match self.switch_to_cloud_tier(&tier).await {
2321 Some(name) => format!("Active: {name}."),
2322 None => {
2323 self.reset_to_local_backend().await;
2324 "Signed out — back to on-device. Pick a model with /models.".to_string()
2325 }
2326 },
2327 None => {
2328 self.reset_to_local_backend().await;
2329 self.retry_startup_model_load_if_failed();
2330 let guard = self.current_model.lock().unwrap();
2331 format!("Active: {}.", guard.display_name)
2332 }
2333 };
2334
2335 // Push refreshed picker + commands so the editor reflects current state.
2336 let config_options = {
2337 let guard = self.current_model.lock().unwrap();
2338 build_model_config_options(&guard)
2339 };
2340 self.send_tool_call_update(
2341 cx,
2342 session_id.clone(),
2343 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
2344 )
2345 .ok();
2346 self.advertise_commands(cx, session_id.clone());
2347
2348 self.send_assistant_message(
2349 cx,
2350 session_id,
2351 format!("Reloaded. {signed_in} {backend_note}"),
2352 )
2353 .ok();
2354 }
2355
2356 async fn handle_set_session_config_option(
2357 &self,
2358 cx: &ConnectionTo<Client>,
2359 args: SetSessionConfigOptionRequest,
2360 ) -> agent_client_protocol::Result<SetSessionConfigOptionResponse> {
2361 log::info!(
2362 "set_session_config_option: config_id={}, value={:?}",
2363 args.config_id,
2364 args.value
2365 );
2366
2367 // ── Local Inference toggle ──────────────────────────────────────────
2368 if args.config_id.0.as_ref() == LOCAL_INFERENCE_CONFIG_ID {
2369 let enabled = match args.value.as_value_id().map(|v| v.0.as_ref()) {
2370 Some(LOCAL_INFERENCE_ON) => true,
2371 Some(LOCAL_INFERENCE_OFF) => false,
2372 other => {
2373 return Err(agent_client_protocol::Error::new(
2374 -32602,
2375 format!("unknown Local Inference value: {other:?}"),
2376 ));
2377 }
2378 };
2379 if let Err(error) = settings::set_local_inference(enabled) {
2380 return Err(agent_client_protocol::Error::new(
2381 -32603,
2382 format!("could not save Local Inference setting: {error}"),
2383 ));
2384 }
2385 let message = if enabled {
2386 "Local inference is on. On-device models are highlighted; pick one from Model."
2387 } else {
2388 "Local inference is off. siGit Code Cloud tiers are highlighted; pick one from Model."
2389 };
2390 self.send_system_status(cx, args.session_id.clone(), message)
2391 .ok();
2392 // Rebuild so the Model picker reflects the new emphasis/order.
2393 let current = self.current_model.lock().unwrap().clone();
2394 let config_options = build_model_config_options(&current);
2395 return Ok(SetSessionConfigOptionResponse::new(config_options));
2396 }
2397
2398 if args.config_id.0.as_ref() != MODEL_CONFIG_ID {
2399 return Err(agent_client_protocol::Error::new(
2400 -32602,
2401 format!("unknown config option: {}", args.config_id.0),
2402 ));
2403 }
2404
2405 let Some(model_id) = args.value.as_value_id().map(|v| v.0.as_ref()) else {
2406 return Err(agent_client_protocol::Error::new(
2407 -32602,
2408 format!(
2409 "expected a value-id for {MODEL_CONFIG_ID}, got {:?}",
2410 args.value
2411 ),
2412 ));
2413 };
2414
2415 // can't switch while the startup model is still loading — the old
2416 // weights are in GPU memory and the new load gets "does not fit"
2417 if self.startup_model_load_started.load(Ordering::Acquire)
2418 && !self.model_ready.load(Ordering::Acquire)
2419 {
2420 log::info!("set_session_config_option: waiting for startup model to finish loading");
2421 while !self.model_ready.load(Ordering::Acquire) {
2422 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2423 }
2424 }
2425
2426 // Zed re-fires the last selection when a thread opens. That re-fire must
2427 // not load anything: on-device models are loaded only on an explicit
2428 // request (`/load`, or actively picking a *different* model below), so a
2429 // re-fire of the already-current selection is a no-op. Otherwise opening a
2430 // new thread would silently load the local model — exactly what we avoid.
2431 {
2432 let current = self.current_model.lock().unwrap();
2433 if current.model_id == model_id {
2434 log::info!(
2435 "set_session_config_option: {} is already the active selection, skipping",
2436 current.display_name
2437 );
2438 let config_options = build_model_config_options(&current);
2439 return Ok(SetSessionConfigOptionResponse::new(config_options));
2440 }
2441 }
2442
2443 // ── siGit Code Cloud tier: no local load; sign-in gated ─────────────
2444 if let Some(tier) = model_id.strip_prefix("sigit-cloud:") {
2445 match self.switch_to_cloud_tier(tier).await {
2446 Some(display_name) => {
2447 self.send_system_status(
2448 cx,
2449 args.session_id.clone(),
2450 format!("Switched to {display_name}."),
2451 )
2452 .ok();
2453 }
2454 None => {
2455 // The picker response updates successful selections. When the
2456 // selection cannot apply because auth is missing, surface that
2457 // actionable state in the thread.
2458 self.send_assistant_message(
2459 cx,
2460 args.session_id.clone(),
2461 format!("\n\n{CLOUD_LOGIN_PROMPT}"),
2462 )
2463 .ok();
2464 }
2465 }
2466
2467 let current = self.current_model.lock().unwrap().clone();
2468 let config_options = build_model_config_options(&current);
2469 return Ok(SetSessionConfigOptionResponse::new(config_options));
2470 }
2471
2472 let needs_download = models::local_picker_items()
2473 .into_iter()
2474 .find(|item| item.config.model_id == model_id)
2475 .map(|item| item.cache_health == setup::ModelCacheHealth::NotDownloaded)
2476 .unwrap_or(false);
2477
2478 // tells the progress poller to stop
2479 let stop_flag = Arc::new(AtomicBool::new(false));
2480
2481 let tool_call_id = format!("model-switch-{}", uuid::Uuid::new_v4());
2482
2483 if needs_download {
2484 let model_id_owned = model_id.to_string();
2485 let expected_bytes = onde::inference::models::SUPPORTED_MODEL_INFO
2486 .iter()
2487 .find(|m| m.id == model_id_owned)
2488 .map(|m| m.expected_size_bytes)
2489 .unwrap_or(0);
2490
2491 let display_name = models::local_picker_items()
2492 .into_iter()
2493 .find(|item| item.config.model_id == model_id_owned)
2494 .map(|item| item.display_name.clone())
2495 .unwrap_or_else(|| model_id_owned.clone());
2496
2497 let size_hint = if expected_bytes > 0 {
2498 format!(" (~{})", format_size_human(expected_bytes))
2499 } else {
2500 String::new()
2501 };
2502
2503 self.send_tool_call_update(
2504 cx,
2505 args.session_id.clone(),
2506 SessionUpdate::ToolCall(
2507 ToolCall::new(
2508 tool_call_id.clone(),
2509 format!("⏬ Downloading {display_name}{size_hint}"),
2510 )
2511 .kind(ToolKind::Think)
2512 .status(ToolCallStatus::InProgress)
2513 .content(vec![
2514 format!(
2515 "Preparing download for {display_name}. This may take a few minutes."
2516 )
2517 .into(),
2518 ]),
2519 ),
2520 )
2521 .ok();
2522
2523 // poll download progress and update the spinner in Zed
2524 let cx_for_poller = cx.clone();
2525 let poller_session = args.session_id.clone();
2526 let poller_model_id = model_id_owned.clone();
2527 let poller_stop = Arc::clone(&stop_flag);
2528 let poller_tool_call_id = tool_call_id.clone();
2529
2530 cx.spawn(async move {
2531 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
2532 let cache_path = onde::hf_cache::model_cache_path(&poller_model_id);
2533 let mut tick: usize = 0;
2534 let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
2535 interval.tick().await; // consume the immediate first tick
2536
2537 while !poller_stop.load(Ordering::Relaxed) {
2538 interval.tick().await;
2539
2540 if poller_stop.load(Ordering::Relaxed) {
2541 break;
2542 }
2543
2544 let downloaded = cache_path
2545 .as_ref()
2546 .filter(|p| p.exists())
2547 .map(|p| dir_size_recursive(p))
2548 .unwrap_or(0);
2549
2550 let frame = SPINNER[tick % SPINNER.len()];
2551 tick += 1;
2552
2553 let title = if expected_bytes > 0 {
2554 let pct =
2555 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
2556 format!("{frame} Downloading {display_name}{size_hint} ({pct}%)")
2557 } else {
2558 format!("{frame} Downloading {display_name}{size_hint}")
2559 };
2560
2561 let msg = if expected_bytes > 0 {
2562 let pct =
2563 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
2564 let bar = progress_bar(pct, 20);
2565 format!(
2566 "{display_name} — {bar} {pct}% ({} / {})",
2567 format_size_human(downloaded),
2568 format_size_human(expected_bytes),
2569 )
2570 } else {
2571 format!(
2572 "{display_name} — {} downloaded…",
2573 format_size_human(downloaded)
2574 )
2575 };
2576
2577 let notification = SessionNotification::new(
2578 poller_session.clone(),
2579 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
2580 poller_tool_call_id.clone(),
2581 ToolCallUpdateFields::new()
2582 .title(title)
2583 .status(ToolCallStatus::InProgress)
2584 .content(vec![msg.into()]),
2585 )),
2586 );
2587 if cx_for_poller.send_notification(notification).is_err() {
2588 break;
2589 }
2590 }
2591 Ok(())
2592 })
2593 .ok();
2594 }
2595
2596 // cached models still take 10-30s to load weights; show a spinner
2597 if !needs_download {
2598 let cached_display_name = models::local_picker_items()
2599 .into_iter()
2600 .find(|item| item.config.model_id == model_id)
2601 .map(|item| item.display_name.clone())
2602 .unwrap_or_else(|| model_id.to_string());
2603
2604 self.send_tool_call_update(
2605 cx,
2606 args.session_id.clone(),
2607 SessionUpdate::ToolCall(
2608 ToolCall::new(
2609 tool_call_id.clone(),
2610 format!("Loading {cached_display_name}"),
2611 )
2612 .kind(ToolKind::Think)
2613 .status(ToolCallStatus::InProgress)
2614 .content(vec![format!("Loading {cached_display_name}…").into()]),
2615 ),
2616 )
2617 .ok();
2618
2619 // tick every 5s so the user knows we haven't frozen
2620 let cx_for_spinner = cx.clone();
2621 let spinner_session = args.session_id.clone();
2622 let spinner_name = cached_display_name.clone();
2623 let spinner_stop = Arc::clone(&stop_flag);
2624 let spinner_tool_call_id = tool_call_id.clone();
2625 let load_start = std::time::Instant::now();
2626
2627 cx.spawn(async move {
2628 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
2629 let mut tick: usize = 0;
2630 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
2631 interval.tick().await; // consume the immediate first tick
2632
2633 while !spinner_stop.load(Ordering::Relaxed) {
2634 interval.tick().await;
2635
2636 if spinner_stop.load(Ordering::Relaxed) {
2637 break;
2638 }
2639
2640 let elapsed = load_start.elapsed();
2641 let elapsed_str = if elapsed.as_secs() >= 60 {
2642 format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
2643 } else {
2644 format!("{}s", elapsed.as_secs())
2645 };
2646 let frame = SPINNER[tick % SPINNER.len()];
2647 tick += 1;
2648
2649 let msg = format!("{frame} Loading {spinner_name}… ({elapsed_str})");
2650 let notification = SessionNotification::new(
2651 spinner_session.clone(),
2652 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
2653 spinner_tool_call_id.clone(),
2654 ToolCallUpdateFields::new()
2655 .status(ToolCallStatus::InProgress)
2656 .content(vec![msg.into()]),
2657 )),
2658 );
2659 if cx_for_spinner.send_notification(notification).is_err() {
2660 break;
2661 }
2662 }
2663 Ok(())
2664 })
2665 .ok();
2666 }
2667
2668 let switch_result = self.switch_model_by_id(model_id).await;
2669
2670 stop_flag.store(true, Ordering::Relaxed);
2671
2672 match switch_result {
2673 Ok(new_config) => {
2674 // Route inference back on-device (in case we were on a cloud tier).
2675 self.reset_to_local_backend().await;
2676 // Selecting an on-device model puts us in local mode.
2677 let _ = settings::set_local_inference(true);
2678
2679 let completion_title = if needs_download {
2680 format!("✓ {} downloaded and loaded", new_config.display_name)
2681 } else {
2682 format!("✓ Switched to {}", new_config.display_name)
2683 };
2684 let completion_body = if needs_download {
2685 format!("✓ {} downloaded and loaded.", new_config.display_name)
2686 } else {
2687 format!("✓ Switched to {}.", new_config.display_name)
2688 };
2689
2690 self.send_tool_call_update(
2691 cx,
2692 args.session_id.clone(),
2693 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
2694 tool_call_id,
2695 ToolCallUpdateFields::new()
2696 .title(completion_title)
2697 .status(ToolCallStatus::Completed)
2698 .content(vec![completion_body.into()]),
2699 )),
2700 )
2701 .ok();
2702
2703 let config_options = {
2704 let guard = self.current_model.lock().unwrap();
2705 build_model_config_options(&guard)
2706 };
2707
2708 log::info!("model switch complete");
2709 Ok(SetSessionConfigOptionResponse::new(config_options))
2710 }
2711 Err(err) => {
2712 self.send_tool_call_update(
2713 cx,
2714 args.session_id.clone(),
2715 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
2716 tool_call_id,
2717 ToolCallUpdateFields::new()
2718 .title("Model switch failed".to_string())
2719 .status(ToolCallStatus::Failed)
2720 .content(vec![format!("error loading model: {}", err.message).into()]),
2721 )),
2722 )
2723 .ok();
2724
2725 Err(err)
2726 }
2727 }
2728 }
2729 }
2730
2731 // ── Config option helpers ─────────────────────────────────────────────────────
2732
2733 /// config option ID for the model picker in Zed's agent panel
2734 const MODEL_CONFIG_ID: &str = "sigit-model";
2735
2736 /// config option ID for the Local Inference on/off toggle. Surfaced as a
2737 /// two-option `select` so ACP clients without slash-command support (e.g. Xcode)
2738 /// can still flip the mode from the agent panel.
2739 const LOCAL_INFERENCE_CONFIG_ID: &str = "sigit-local-inference";
2740
2741 /// `select` value ids for the Local Inference toggle.
2742 const LOCAL_INFERENCE_ON: &str = "local-inference-on";
2743 const LOCAL_INFERENCE_OFF: &str = "local-inference-off";
2744
2745 /// Replace non-ASCII chars so a downstream byte-index truncation can't split a
2746 /// multi-byte char. Zed slices the model-picker label at a fixed byte offset
2747 /// (`agent_ui/src/config_options.rs`) and panics — crashing the whole editor —
2748 /// when the cut lands mid-glyph (e.g. inside `☁` or `·`). Mapping to `-` keeps
2749 /// separators readable; ASCII bytes are always char boundaries.
2750 fn ascii_safe(s: &str) -> String {
2751 s.chars()
2752 .map(|c| if c.is_ascii() { c } else { '-' })
2753 .collect()
2754 }
2755
2756 fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> {
2757 // The full list, including the siGit Code Cloud tiers, so the panel picker
2758 // mirrors the TUI `/models`. Cloud entries are sign-in gated at selection.
2759 let items = models::build_model_picker_items();
2760 let active_kind = models::active_inference_kind();
2761
2762 let options: Vec<SessionConfigSelectOption> = items
2763 .iter()
2764 .filter(|item| item.cache_health != setup::ModelCacheHealth::Incomplete)
2765 .map(|item| {
2766 let mut desc_parts = Vec::new();
2767 // Mark options in the inactive mode so the active group reads as the
2768 // recommended set (the list is already ordered active-group-first).
2769 if item.source.kind() != active_kind {
2770 desc_parts.push("inactive mode".to_string());
2771 }
2772 if item.tool_calling {
2773 desc_parts.push("tool calling".to_string());
2774 }
2775 desc_parts.push(format!(
2776 "{} context",
2777 models::format_context_window_short(item.context_window_tokens)
2778 ));
2779 desc_parts.push(item.description.clone());
2780 if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
2781 desc_parts.push("download on select".to_string());
2782 }
2783 // ASCII-only for the same reason as the name (see `ascii_safe`).
2784 let description = ascii_safe(&desc_parts.join(" - "));
2785 // Keep labels short for the Zed bottom bar. Source/routing detail
2786 // belongs in descriptions; the selected value is rendered without
2787 // the config-option title, so repeated badges become visual noise.
2788 let source_badge = if item.cloud_tier.is_some() {
2789 ""
2790 } else if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
2791 " [Onde]"
2792 } else {
2793 match item.source_label.as_str() {
2794 "Onde" => " [Onde]",
2795 "HuggingFace" => " [HuggingFace]",
2796 _ => "",
2797 }
2798 };
2799 // For cloud tiers use just the tier title (e.g. "Balanced"). The
2800 // display name can carry non-ASCII (the cloud tier label is
2801 // "siGit Code Cloud · Balanced"), so sanitize the whole label.
2802 let base_name = match &item.cloud_tier {
2803 Some(tier) => crate::provider::tier_title(tier),
2804 None => item.display_name.clone(),
2805 };
2806 let name = ascii_safe(&format!(
2807 "{base_name} - {}{source_badge}",
2808 models::format_context_window(item.context_window_tokens)
2809 ));
2810 SessionConfigSelectOption::new(
2811 SessionConfigValueId::new(item.config.model_id.as_str()),
2812 name,
2813 )
2814 .description(description)
2815 })
2816 .collect();
2817
2818 // Local Inference on/off toggle, modeled as a two-option select so panel-only
2819 // ACP clients (no slash commands) can flip the mode.
2820 let local_on = settings::local_inference_enabled();
2821 let local_current = SessionConfigValueId::new(if local_on {
2822 LOCAL_INFERENCE_ON
2823 } else {
2824 LOCAL_INFERENCE_OFF
2825 });
2826 let local_options = vec![
2827 SessionConfigSelectOption::new(
2828 SessionConfigValueId::new(LOCAL_INFERENCE_ON),
2829 "Local".to_string(),
2830 )
2831 .description("Run inference on-device; on-device models are highlighted".to_string()),
2832 SessionConfigSelectOption::new(
2833 SessionConfigValueId::new(LOCAL_INFERENCE_OFF),
2834 "Cloud".to_string(),
2835 )
2836 .description("Use siGit Code Cloud; cloud tiers are highlighted".to_string()),
2837 ];
2838 let local_option = SessionConfigOption::select(
2839 LOCAL_INFERENCE_CONFIG_ID,
2840 "Inference",
2841 local_current,
2842 local_options,
2843 )
2844 .description("Toggle on-device inference; changes which models are highlighted");
2845
2846 if options.is_empty() {
2847 return vec![local_option];
2848 }
2849
2850 let current_value = SessionConfigValueId::new(current_model.model_id.as_str());
2851
2852 vec![
2853 SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options)
2854 .category(SessionConfigOptionCategory::Model)
2855 .description("Select an on-device model or a siGit Code Cloud tier"),
2856 local_option,
2857 ]
2858 }
2859
2860 /// returns `(config, max_tokens, tool_calling)` for a picker model_id, or None
2861 fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> {
2862 let items = models::local_picker_items();
2863 items
2864 .into_iter()
2865 .find(|item| {
2866 item.config.model_id == model_id
2867 && item.cache_health != setup::ModelCacheHealth::Incomplete
2868 })
2869 .map(|item| (item.config, item.max_tokens, item.tool_calling))
2870 }
2871
2872 // ── Slash commands ────────────────────────────────────────────────────────────
2873
2874 #[derive(Debug, Clone)]
2875 enum SlashCommand {
2876 Help,
2877 Clear,
2878 Status,
2879 Models(Option<usize>),
2880 /// toggle on-device inference mode. `Some(true/false)` sets it, `None` flips it.
2881 Local(Option<bool>),
2882 /// List discovered Agent Skills.
2883 Skills,
2884 /// List configured subagent types (`.sigit/agents/*.md`) for `task`.
2885 Agents,
2886 /// List discovered user-defined commands (`.sigit/commands/*.md`).
2887 Commands,
2888 /// List configured MCP servers and their tools.
2889 Mcp,
2890 /// Explicitly load the selected (or default) on-device model.
2891 Load,
2892 /// `/login <email> <password>` — the raw argument, parsed when executed.
2893 Login(Option<String>),
2894 Logout,
2895 Whoami,
2896 /// Re-sync session state (auth, backend, picker) without a new session.
2897 Reload,
2898 /// Toggle plan mode (read-only research; mutating tools denied with a
2899 /// prompt to present a plan). `Some(true/false)` sets it, `None` flips it.
2900 Plan(Option<bool>),
2901 /// Show the effective permission policy for this session.
2902 Permissions,
2903 /// Summarize-and-shrink the conversation history on demand.
2904 Compact,
2905 /// Generate (or improve) the repo's AGENTS.md by running an agent turn
2906 /// with [`instructions::INIT_PROMPT`] as the user text.
2907 Init,
2908 Exit,
2909 /// An unrecognized command word, plus whatever followed it on the line
2910 /// (untouched — a caller may still resolve it as a user-defined command
2911 /// before falling back to "unknown command").
2912 Unknown(String, Option<String>),
2913 }
2914
2915 fn parse_slash(input: &str) -> Option<SlashCommand> {
2916 let trimmed = input.trim();
2917 if !trimmed.starts_with('/') {
2918 return None;
2919 }
2920 let mut parts = trimmed.splitn(2, char::is_whitespace);
2921 let command = parts.next().unwrap_or("");
2922 let argument = parts.next().map(str::trim);
2923 Some(match command {
2924 "/help" => SlashCommand::Help,
2925 "/clear" => SlashCommand::Clear,
2926 "/status" => SlashCommand::Status,
2927 "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())),
2928 "/local" => SlashCommand::Local(parse_on_off(argument)),
2929 "/skills" => SlashCommand::Skills,
2930 "/agents" => SlashCommand::Agents,
2931 "/commands" => SlashCommand::Commands,
2932 "/mcp" => SlashCommand::Mcp,
2933 "/load" => SlashCommand::Load,
2934 "/login" => SlashCommand::Login(argument.map(str::to_string)),
2935 "/logout" => SlashCommand::Logout,
2936 "/whoami" => SlashCommand::Whoami,
2937 "/reload" => SlashCommand::Reload,
2938 "/plan" => SlashCommand::Plan(parse_on_off(argument)),
2939 "/permissions" => SlashCommand::Permissions,
2940 "/compact" => SlashCommand::Compact,
2941 "/init" => SlashCommand::Init,
2942 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
2943 other => SlashCommand::Unknown(other.to_string(), argument.map(str::to_string)),
2944 })
2945 }
2946
2947 /// `on`/`off` (and synonyms) → `Some(bool)`; missing or unrecognized → `None`
2948 /// (meaning "toggle the current value").
2949 fn parse_on_off(arg: Option<&str>) -> Option<bool> {
2950 match arg.map(|s| s.trim().to_ascii_lowercase())?.as_str() {
2951 "on" | "true" | "1" | "yes" => Some(true),
2952 "off" | "false" | "0" | "no" => Some(false),
2953 _ => None,
2954 }
2955 }
2956
2957 fn format_models_list(current_model: &GgufModelConfig) -> String {
2958 let items = models::build_model_picker_items();
2959 if items.is_empty() {
2960 return "No local models found. siGit will use the platform default model.".to_string();
2961 }
2962
2963 let mut lines = vec!["Available models:".to_string()];
2964 let mut last_source: Option<&str> = None;
2965
2966 for (index, item) in items.iter().enumerate() {
2967 let source_key = match item.source_label.as_str() {
2968 "Onde" => "Onde",
2969 "HuggingFace" => "HuggingFace",
2970 "siGit Code Cloud" => "Cloud",
2971 _ => "Fallback",
2972 };
2973
2974 if last_source != Some(source_key) {
2975 if last_source.is_some() {
2976 lines.push(String::new());
2977 }
2978 let section = match source_key {
2979 "Onde" => "Onde Inference",
2980 "HuggingFace" => "Hugging Face cache",
2981 "Cloud" => "siGit Code Cloud",
2982 _ => "Fallback",
2983 };
2984 lines.push(section.to_string());
2985 // Blank line so the following "N." items render as an ordered list.
2986 // CommonMark only lets an ordered list interrupt a paragraph when it
2987 // starts at 1, so without this the cloud section (items 9+) would be
2988 // absorbed into the header paragraph.
2989 lines.push(String::new());
2990 last_source = Some(source_key);
2991 }
2992
2993 let number = index + 1;
2994 let current_badge = if item.config.model_id == current_model.model_id {
2995 " <- current"
2996 } else {
2997 ""
2998 };
2999 let tool_badge = if item.tool_calling {
3000 " tool calling"
3001 } else {
3002 ""
3003 };
3004 let health_badge = match item.cache_health {
3005 setup::ModelCacheHealth::Complete => "",
3006 setup::ModelCacheHealth::Incomplete => " ! incomplete cache",
3007 setup::ModelCacheHealth::NotDownloaded => " ↓ download on select",
3008 };
3009 let source = match source_key {
3010 "Onde" => " [Onde]",
3011 "HuggingFace" => " [HuggingFace]",
3012 "Cloud" => " [☁ Cloud]",
3013 _ => " [default]",
3014 };
3015
3016 lines.push(format!(
3017 "{number}. {} {}{}{}{}{}",
3018 item.display_name, item.description, tool_badge, health_badge, current_badge, source,
3019 ));
3020 }
3021
3022 lines.push(String::new());
3023 lines.push("Use /models N to switch models.".to_string());
3024 lines.join("\n")
3025 }
3026
3027 async fn exec_slash_acp(
3028 agent: &SiGitAgent,
3029 cx: &ConnectionTo<Client>,
3030 session_id: SessionId,
3031 command: SlashCommand,
3032 ) -> agent_client_protocol::Result<PromptResponse> {
3033 match command {
3034 SlashCommand::Help => {
3035 agent
3036 .send_assistant_message(
3037 cx,
3038 session_id,
3039 "/help - show this message\n\
3040 /models - list available models\n\
3041 /models N - switch to model N\n\
3042 /local [on|off]- toggle on-device inference mode\n\
3043 /skills - list available Agent Skills\n\
3044 /agents - list configured subagent types (.sigit/agents/*.md)\n\
3045 /commands - list user-defined commands (.sigit/commands/*.md)\n\
3046 /mcp - list MCP servers and their tools\n\
3047 /load - load the selected on-device model\n\
3048 /login E P - sign in to siGit Code Cloud\n\
3049 /logout - sign out\n\
3050 /whoami - show the signed-in account\n\
3051 /reload - re-sync sign-in and model state\n\
3052 /plan [on|off] - plan mode: research only, no edits or commands\n\
3053 /permissions - show the tool permission policy\n\
3054 /compact - summarize and shrink conversation history\n\
3055 /init - generate or improve the repo's AGENTS.md\n\
3056 /clear - wipe conversation history\n\
3057 /status - show engine status\n\
3058 /exit - end this turn",
3059 )
3060 .ok();
3061 }
3062 SlashCommand::Clear => {
3063 let cleared = agent.engine.clear_history().await;
3064 permissions::reset_session(&session_id.to_string());
3065 // The saved session must not resurrect what the user just wiped.
3066 session_store::delete(&session_id.to_string());
3067 agent
3068 .send_assistant_message(
3069 cx,
3070 session_id,
3071 format!("Cleared {cleared} turn(s). History is empty."),
3072 )
3073 .ok();
3074 }
3075 SlashCommand::Plan(value) => {
3076 let session_key = session_id.to_string();
3077 let enabled = value.unwrap_or_else(|| !permissions::plan_mode(&session_key));
3078 permissions::set_plan_mode(&session_key, enabled);
3079 let message = if enabled {
3080 "Plan mode ON — the agent researches with read-only tools and presents a \
3081 plan; edits and commands are blocked until /plan off."
3082 } else {
3083 "Plan mode OFF — the agent may execute tools again (subject to the \
3084 permission policy)."
3085 };
3086 agent.send_assistant_message(cx, session_id, message).ok();
3087 }
3088 SlashCommand::Permissions => {
3089 let summary = permissions::describe(&session_id.to_string());
3090 agent.send_assistant_message(cx, session_id, summary).ok();
3091 }
3092 SlashCommand::Compact => {
3093 let backend = agent.backend.lock().await.clone();
3094 let before = backend::estimate_tokens(&backend.history_snapshot().await);
3095 let message = match backend.compact_history(backend::COMPACT_KEEP_LAST).await {
3096 Ok(()) => {
3097 let snapshot = backend.history_snapshot().await;
3098 let after = backend::estimate_tokens(&snapshot);
3099 // Keep the saved session in step with the compacted state.
3100 if let Err(error) = session_store::save(&session_id.to_string(), &snapshot) {
3101 log::warn!("session save after /compact failed: {error}");
3102 }
3103 format!("Compacted history: ~{before} → ~{after} tokens (estimated).")
3104 }
3105 Err(error) => format!("Compaction failed: {error}"),
3106 };
3107 agent.send_assistant_message(cx, session_id, message).ok();
3108 }
3109 SlashCommand::Status => {
3110 let info = agent.engine.info().await;
3111 let model = info.model_name.as_deref().unwrap_or("(none)");
3112 let memory = info.approx_memory.as_deref().unwrap_or("unknown");
3113 let mut report = format!(
3114 "status: {:?} model: {} memory: {} history: {} turns",
3115 info.status, model, memory, info.history_length,
3116 );
3117 // Only worth a line when the project has more than one root —
3118 // otherwise it just repeats what the editor already shows.
3119 let extra_roots = workspace::additional_roots();
3120 if !extra_roots.is_empty() {
3121 let cwd = agent
3122 .session_cwd
3123 .lock()
3124 .ok()
3125 .and_then(|guard| guard.clone())
3126 .unwrap_or_else(|| PathBuf::from("."));
3127 report.push_str(&format!("\nproject roots: {} (primary)", cwd.display()));
3128 for root in &extra_roots {
3129 report.push_str(&format!(", {}", root.display()));
3130 }
3131 }
3132 agent.send_assistant_message(cx, session_id, report).ok();
3133 }
3134 SlashCommand::Models(None) => {
3135 let current_model = agent.current_model.lock().unwrap().clone();
3136 agent
3137 .send_assistant_message(cx, session_id, format_models_list(&current_model))
3138 .ok();
3139 }
3140 SlashCommand::Skills => {
3141 agent
3142 .send_assistant_message(cx, session_id, skills::format_skills_list())
3143 .ok();
3144 }
3145 SlashCommand::Agents => {
3146 agent
3147 .send_assistant_message(cx, session_id, subagents::format_agent_types_list())
3148 .ok();
3149 }
3150 SlashCommand::Commands => {
3151 agent
3152 .send_assistant_message(cx, session_id, commands::format_commands_list())
3153 .ok();
3154 }
3155 SlashCommand::Mcp => {
3156 agent
3157 .send_assistant_message(cx, session_id, mcp::status_summary())
3158 .ok();
3159 }
3160 SlashCommand::Models(Some(number)) => {
3161 let items = models::build_model_picker_items();
3162 let index = number.saturating_sub(1);
3163 match items.get(index).cloned() {
3164 None => {
3165 agent
3166 .send_assistant_message(
3167 cx,
3168 session_id,
3169 format!("error: no model #{number} - type /models to see the list."),
3170 )
3171 .ok();
3172 }
3173 Some(model) if model.cloud_tier.is_some() => {
3174 // siGit Code Cloud tier: swap backend, sign-in gated.
3175 let tier = model.cloud_tier.clone().unwrap_or_default();
3176 let message = match agent.switch_to_cloud_tier(&tier).await {
3177 Some(display_name) => format!("Switched to {display_name}."),
3178 None => CLOUD_LOGIN_PROMPT.to_string(),
3179 };
3180 agent.send_assistant_message(cx, session_id, message).ok();
3181 }
3182 Some(model) => {
3183 if model.cache_health == setup::ModelCacheHealth::Incomplete {
3184 agent
3185 .send_assistant_message(
3186 cx,
3187 session_id,
3188 format!(
3189 "error: {} has an incomplete local cache and cannot be selected yet.",
3190 model.display_name
3191 ),
3192 )
3193 .ok();
3194 } else if model.cache_health == setup::ModelCacheHealth::NotDownloaded {
3195 agent
3196 .send_assistant_message(
3197 cx,
3198 session_id.clone(),
3199 format!(
3200 "Downloading and loading {} ({})… this may take a few minutes.",
3201 model.display_name, model.description
3202 ),
3203 )
3204 .ok();
3205
3206 match agent.switch_model_by_id(&model.config.model_id).await {
3207 Ok(new_config) => {
3208 agent.reset_to_local_backend().await;
3209 let _ = settings::set_local_inference(true);
3210 agent
3211 .send_assistant_message(
3212 cx,
3213 session_id,
3214 format!(
3215 "✓ Downloaded and switched to {}",
3216 new_config.display_name
3217 ),
3218 )
3219 .ok();
3220 }
3221 Err(err) => {
3222 agent
3223 .send_assistant_message(
3224 cx,
3225 session_id,
3226 format!("error downloading model: {}", err.message),
3227 )
3228 .ok();
3229 }
3230 }
3231 } else {
3232 agent
3233 .send_assistant_message(
3234 cx,
3235 session_id.clone(),
3236 format!("Loading {}...", model.display_name),
3237 )
3238 .ok();
3239
3240 let switched = agent.switch_model_by_id(&model.config.model_id).await?;
3241 agent.reset_to_local_backend().await;
3242 let _ = settings::set_local_inference(true);
3243
3244 agent
3245 .send_assistant_message(
3246 cx,
3247 session_id,
3248 format!("Switched to {}.", switched.display_name),
3249 )
3250 .ok();
3251 }
3252 }
3253 }
3254 }
3255 SlashCommand::Local(value) => {
3256 let enabled = value.unwrap_or(!settings::local_inference_enabled());
3257 let message = match settings::set_local_inference(enabled) {
3258 Ok(()) if enabled => "Local inference is on. On-device models are highlighted; \
3259 pick one with /models."
3260 .to_string(),
3261 Ok(()) => "Local inference is off. siGit Code Cloud tiers are highlighted; \
3262 pick one with /models."
3263 .to_string(),
3264 Err(error) => format!("error: could not save local inference setting: {error}"),
3265 };
3266 agent
3267 .send_assistant_message(cx, session_id.clone(), message)
3268 .ok();
3269 // Refresh the panel so the Model picker reflects the new emphasis.
3270 let config_options = {
3271 let current = agent.current_model.lock().unwrap();
3272 build_model_config_options(&current)
3273 };
3274 agent
3275 .send_tool_call_update(
3276 cx,
3277 session_id,
3278 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
3279 )
3280 .ok();
3281 }
3282 SlashCommand::Load => {
3283 // Explicitly load the on-device model. This is the only path that
3284 // brings a local model into memory; prompts never do it implicitly.
3285 // If a cloud tier is active, fall back to a local default so we don't
3286 // try to load the (file-less) cloud config as GGUF.
3287 let on_cloud = {
3288 let guard = agent.current_model.lock().unwrap();
3289 guard.model_id.starts_with("sigit-cloud:")
3290 };
3291 if on_cloud {
3292 let default_config = default_local_model_config();
3293 *agent.current_model.lock().unwrap() = default_config;
3294 agent.reset_to_local_backend().await;
3295 }
3296 // Loading an on-device model puts us in local inference mode.
3297 let _ = settings::set_local_inference(true);
3298 // `await_model_ready` drives the download/load progress UI and reports
3299 // success or failure to the editor.
3300 agent.start_startup_model_load_if_needed();
3301 agent.await_model_ready(cx, &session_id).await?;
3302 }
3303 SlashCommand::Login(argument) => {
3304 let message = match argument.as_deref().and_then(account::parse_login_args) {
3305 Some((email, password)) => match account::authenticate(&email, &password).await {
3306 Ok(email) => format!(
3307 "Signed in as {email}. Pick a siGit Code Cloud tier in /models to use it."
3308 ),
3309 Err(error) => format!("Login failed: {error}"),
3310 },
3311 // A bare `/login` takes the browser route, same as the editor's
3312 // sign-in button.
3313 None => browser_login_message().await,
3314 };
3315 agent.send_assistant_message(cx, session_id, message).ok();
3316 }
3317 SlashCommand::Logout => {
3318 // If we're on a cloud tier, drop back to local — the token is gone.
3319 let on_cloud = {
3320 let guard = agent.current_model.lock().unwrap();
3321 guard.model_id.starts_with("sigit-cloud:")
3322 };
3323 let message = account::end_session().await;
3324 if on_cloud {
3325 agent.reset_to_local_backend().await;
3326 }
3327 agent.send_assistant_message(cx, session_id, message).ok();
3328 }
3329 SlashCommand::Whoami => {
3330 let message = account::status_line().await;
3331 agent.send_assistant_message(cx, session_id, message).ok();
3332 }
3333 SlashCommand::Reload => {
3334 agent.handle_reload(cx, session_id).await;
3335 }
3336 SlashCommand::Exit => {
3337 agent
3338 .send_assistant_message(
3339 cx,
3340 session_id,
3341 "Use the panel controls to close or switch threads.",
3342 )
3343 .ok();
3344 }
3345 SlashCommand::Init => {
3346 // Handled in the prompt path (it runs a real turn); reaching this
3347 // arm means a caller forgot that special case.
3348 agent
3349 .send_assistant_message(cx, session_id, "Send /init as a prompt to run it.")
3350 .ok();
3351 }
3352 SlashCommand::Unknown(command, _) => {
3353 agent
3354 .send_assistant_message(
3355 cx,
3356 session_id,
3357 format!(
3358 "unknown command: {command}. Type /commands to list user-defined commands."
3359 ),
3360 )
3361 .ok();
3362 }
3363 }
3364
3365 Ok(PromptResponse::new(StopReason::EndTurn))
3366 }
3367
3368 // ── Request dispatch helper ───────────────────────────────────────────────────
3369
3370 fn handle_response<T: agent_client_protocol::JsonRpcResponse>(
3371 responder: Responder<T>,
3372 result: agent_client_protocol::Result<T>,
3373 ) -> agent_client_protocol::Result<()> {
3374 match result {
3375 Ok(resp) => responder.respond(resp),
3376 Err(err) => responder.respond_with_error(err),
3377 }
3378 }
3379
3380 // ── Download progress helpers ─────────────────────────────────────────────────
3381
3382 /// total bytes on disk under `path`. needed because hf-hub uses staging
3383 /// names during download, so we can't just stat the final blobs.
3384 fn dir_size_recursive(path: &std::path::Path) -> u64 {
3385 let mut total: u64 = 0;
3386 let Ok(entries) = std::fs::read_dir(path) else {
3387 return 0;
3388 };
3389 for entry in entries.flatten() {
3390 let entry_path = entry.path();
3391 if entry_path.is_dir() {
3392 total += dir_size_recursive(&entry_path);
3393 } else if let Ok(meta) = entry_path.metadata() {
3394 total += meta.len();
3395 }
3396 }
3397 total
3398 }
3399
3400 fn format_size_human(bytes: u64) -> String {
3401 const GB: u64 = 1_073_741_824;
3402 const MB: u64 = 1_048_576;
3403 const KB: u64 = 1_024;
3404 if bytes >= GB {
3405 format!("{:.2} GB", bytes as f64 / GB as f64)
3406 } else if bytes >= MB {
3407 format!("{:.1} MB", bytes as f64 / MB as f64)
3408 } else if bytes >= KB {
3409 format!("{:.0} KB", bytes as f64 / KB as f64)
3410 } else {
3411 format!("{bytes} B")
3412 }
3413 }
3414
3415 fn progress_bar(pct: u8, width: usize) -> String {
3416 let filled = ((pct as usize) * width) / 100;
3417 let empty = width.saturating_sub(filled);
3418 format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
3419 }
3420
3421 // ── Output capture ────────────────────────────────────────────────────────────
3422
3423 /// redirect stdout+stderr to `$TMPDIR/sigit.log` at the fd level so
3424 /// mistralrs/tracing noise never hits the terminal. returns two dup'd
3425 /// fds to the real tty: one for ratatui, one for cleanup (ratatui 0.29
3426 /// doesn't expose `writer_mut()`).
3427 #[cfg(unix)]
3428 fn redirect_output_to_log() -> anyhow::Result<(std::fs::File, std::fs::File)> {
3429 let log_path = std::env::temp_dir().join("sigit.log");
3430 let log_file = std::fs::File::create(&log_path)?;
3431 let log_fd = log_file.as_raw_fd();
3432
3433 // two copies: ratatui needs one, cleanup needs another
3434 let saved_tui = unsafe { libc::dup(libc::STDOUT_FILENO) };
3435 anyhow::ensure!(
3436 saved_tui >= 0,
3437 "dup(stdout) for tui failed: {}",
3438 std::io::Error::last_os_error()
3439 );
3440 let saved_cleanup = unsafe { libc::dup(libc::STDOUT_FILENO) };
3441 anyhow::ensure!(
3442 saved_cleanup >= 0,
3443 "dup(stdout) for cleanup failed: {}",
3444 std::io::Error::last_os_error()
3445 );
3446
3447 unsafe {
3448 libc::dup2(log_fd, libc::STDOUT_FILENO);
3449 libc::dup2(log_fd, libc::STDERR_FILENO);
3450 }
3451
3452 // safe to drop log_file; dup2 keeps the fd alive via stdout/stderr
3453
3454 Ok((unsafe { std::fs::File::from_raw_fd(saved_tui) }, unsafe {
3455 std::fs::File::from_raw_fd(saved_cleanup)
3456 }))
3457 }
3458
3459 // ── Logging ───────────────────────────────────────────────────────────────────
3460
3461 /// in TUI mode stderr is the log file (redirected earlier);
3462 /// in ACP mode it's real stderr. either way, write there.
3463 fn init_logging(is_tty: bool) {
3464 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
3465 let _ = tracing_fmt::Subscriber::builder()
3466 .with_env_filter(filter)
3467 .with_writer(std::io::stderr)
3468 .with_ansi(!is_tty)
3469 .try_init();
3470 }
3471
3472 // ── Interactive TUI mode ──────────────────────────────────────────────────────
3473
3474 /// boot the TUI and load the model on a background thread.
3475 /// `tty` goes to ratatui; `cleanup_tty` is a separate fd for
3476 /// LeaveAlternateScreen (ratatui 0.29 hides `writer_mut()`).
3477 #[cfg(unix)]
3478 async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> anyhow::Result<()> {
3479 let engine = Arc::new(ChatEngine::new());
3480
3481 let startup_selection = setup::startup_model_selection();
3482 let startup_model_name = startup_selection
3483 .as_ref()
3484 .map(|selection| selection.display_name.clone())
3485 .unwrap_or_else(|| GgufModelConfig::qwen25_3b().display_name);
3486
3487 // Signals the loading phase to finish. On-device models are no longer loaded
3488 // at startup, so this resolves immediately for both backends; it stays a
3489 // channel so the loading-phase plumbing in `chat::run_with` is unchanged.
3490 let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>();
3491
3492 // Project instruction files (AGENTS.md / CLAUDE.md) for the launch directory,
3493 // injected into the system prompt so the TUI shares the same always-on
3494 // project context the ACP sessions get.
3495 let project_instructions = std::env::current_dir()
3496 .ok()
3497 .and_then(|cwd| instructions::load_project_instructions(&cwd));
3498 let with_instructions = |base: String| match &project_instructions {
3499 Some(extra) => format!("{base}\n\n{extra}"),
3500 None => base,
3501 };
3502
3503 // Pick the inference backend: a configured provider if present, else on-device.
3504 let (inference_backend, startup_model_name): (Arc<dyn InferenceBackend>, String) =
3505 match provider::active_provider() {
3506 Some(provider) => {
3507 log::info!(
3508 "inference: using {} (model {}) at {}",
3509 provider.display_name,
3510 provider.model,
3511 provider.base_url
3512 );
3513 // No local model to load; the endpoint is ready immediately.
3514 let _ = load_tx.send(Ok(()));
3515 let label = provider.display_name.clone();
3516 register_subagent_factory_for(&provider);
3517 let backend = Arc::new(OpenAiBackend::new(
3518 provider.base_url,
3519 provider.api_key,
3520 provider.model,
3521 Some(with_instructions(SYSTEM_PROMPT.to_string())),
3522 )) as Arc<dyn InferenceBackend>;
3523 (backend, label)
3524 }
3525 None => {
3526 // Honor the Local Inference toggle: when off and signed in, start
3527 // on a cloud tier. Otherwise bring up on-device WITHOUT loading a
3528 // model — the user loads it explicitly with /load (or /models), so
3529 // the UI comes up immediately. Project instructions are injected at
3530 // load time in `chat.rs`.
3531 let cloud_when_off = if settings::local_inference_enabled() {
3532 None
3533 } else {
3534 provider::cloud_tier_provider("balanced")
3535 };
3536
3537 match cloud_when_off {
3538 Some(provider) => {
3539 log::info!(
3540 "inference: local inference off; using {} (model {})",
3541 provider.display_name,
3542 provider.model
3543 );
3544 let _ = load_tx.send(Ok(()));
3545 let label = provider.display_name.clone();
3546 register_subagent_factory_for(&provider);
3547 let backend = Arc::new(OpenAiBackend::new(
3548 provider.base_url,
3549 provider.api_key,
3550 provider.model,
3551 Some(with_instructions(SYSTEM_PROMPT.to_string())),
3552 )) as Arc<dyn InferenceBackend>;
3553 (backend, label)
3554 }
3555 None => {
3556 if !settings::local_inference_enabled() {
3557 log::warn!(
3558 "local inference is off but no account is signed in; \
3559 bringing up on-device. Run /login or /local on."
3560 );
3561 }
3562 let _ = load_tx.send(Ok(()));
3563 // On-device inference has a single shared history; no
3564 // subagent context is possible yet.
3565 tools::set_subagent_factory(Box::new(|_system_prompt: &str| None));
3566 let backend = Arc::new(LocalBackend::new(Arc::clone(&engine)))
3567 as Arc<dyn InferenceBackend>;
3568 (backend, startup_model_name)
3569 }
3570 }
3571 }
3572 };
3573
3574 crossterm::terminal::enable_raw_mode()?;
3575 let mut tty = BufWriter::new(tty);
3576 crossterm::execute!(tty, crossterm::terminal::EnterAlternateScreen)?;
3577 let term_backend = ratatui::backend::CrosstermBackend::new(tty);
3578 let mut terminal = ratatui::Terminal::new(term_backend)?;
3579
3580 // polls load_rx with try_recv() each tick, no blocking
3581 let chat_result = chat::run_with(
3582 &mut terminal,
3583 engine,
3584 inference_backend,
3585 load_rx,
3586 startup_model_name,
3587 )
3588 .await;
3589
3590 // cleanup fd because backend's writer is private
3591 crossterm::execute!(cleanup_tty, crossterm::terminal::LeaveAlternateScreen)?;
3592 cleanup_tty.flush()?;
3593 crossterm::terminal::disable_raw_mode()?;
3594
3595 // restore real stdout/stderr for post-TUI error output
3596 #[cfg(unix)]
3597 {
3598 let cleanup_fd = cleanup_tty.as_raw_fd();
3599 unsafe {
3600 libc::dup2(cleanup_fd, libc::STDOUT_FILENO);
3601 libc::dup2(cleanup_fd, libc::STDERR_FILENO);
3602 }
3603 }
3604
3605 chat_result
3606 }
3607
3608 // ── ACP server mode ───────────────────────────────────────────────────────────
3609
3610 /// The on-device model `/load` should bring up by default: the persisted
3611 /// selection if it still resolves to a known local model, otherwise the built-in
3612 /// default (`qwen25_3b`).
3613 fn default_local_model_config() -> GgufModelConfig {
3614 setup::startup_model_selection()
3615 .as_ref()
3616 .and_then(|selection| {
3617 selection.selected_model.as_ref().and_then(|selected| {
3618 models::local_picker_items()
3619 .into_iter()
3620 .find(|item| {
3621 item.config.model_id == selected.model_id
3622 && item
3623 .config
3624 .files
3625 .iter()
3626 .any(|file| file == &selected.gguf_file)
3627 })
3628 .map(|item| item.config)
3629 })
3630 })
3631 .unwrap_or_else(GgufModelConfig::qwen25_3b)
3632 }
3633
3634 fn parse_explicit_acp(args: &[String]) -> Result<bool, String> {
3635 if !args.iter().any(|arg| arg == "--acp") {
3636 return Ok(false);
3637 }
3638 if args.iter().any(|arg| arg != "--acp") {
3639 return Err("--acp does not accept additional arguments".to_string());
3640 }
3641 Ok(true)
3642 }
3643
3644 async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
3645 log::info!("ACP mode — starting agent server");
3646
3647 let config = default_local_model_config();
3648
3649 let needs_download = models::local_picker_items()
3650 .iter()
3651 .find(|item| item.config.model_id == config.model_id)
3652 .map(|item| item.cache_health != setup::ModelCacheHealth::Complete)
3653 .unwrap_or(true);
3654
3655 log::info!(
3656 "ACP startup model selected: {} ({})",
3657 config.display_name,
3658 if needs_download {
3659 "needs download"
3660 } else {
3661 "cached"
3662 }
3663 );
3664
3665 let engine = Arc::new(ChatEngine::new());
3666
3667 // The on-device model is never loaded implicitly; the user loads it with
3668 // `/load` (or by picking one in `/models`). So initialize/session/new stay
3669 // lightweight and `model_ready` starts true (nothing is loading).
3670 let model_ready = Arc::new(AtomicBool::new(true));
3671 let startup_model_load_started = Arc::new(AtomicBool::new(false));
3672 let model_load_error: Arc<std::sync::Mutex<Option<String>>> =
3673 Arc::new(std::sync::Mutex::new(None));
3674
3675 let state = Arc::new(SiGitAgent::new(
3676 engine,
3677 config,
3678 model_ready,
3679 startup_model_load_started,
3680 model_load_error,
3681 needs_download,
3682 auto_load_local_model,
3683 ));
3684
3685 // Honor the explicit provider override (OPENAI_BASE_URL/OPENAI_API_KEY or
3686 // an active providers.toml profile) in ACP mode too — the interactive
3687 // client already does. Without this the override was silently ignored here
3688 // and prompts insisted on a local model. It is also what lets the ACP
3689 // integration test drive the agent against a scripted endpoint
3690 // (tests/acp_permissions.rs). The model picker still shows the local
3691 // selection; overrides are a power-user escape hatch, not a tier.
3692 if let Some(cfg) = provider::active_provider() {
3693 log::info!(
3694 "inference: using {} (model {}) at {}",
3695 cfg.display_name,
3696 cfg.model,
3697 cfg.base_url
3698 );
3699 register_subagent_factory_for(&cfg);
3700 let override_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
3701 cfg.base_url,
3702 cfg.api_key,
3703 cfg.model,
3704 Some(system_prompt_for_model(true).to_string()),
3705 ));
3706 *state.backend.lock().await = override_backend;
3707 } else {
3708 // On-device inference has a single shared conversation history, so a
3709 // second concurrent subagent context is not possible yet.
3710 tools::set_subagent_factory(Box::new(|_system_prompt: &str| None));
3711 }
3712
3713 let stdin = tokio::io::stdin().compat();
3714 let stdout = tokio::io::stdout().compat_write();
3715 let transport = ByteStreams::new(stdout, stdin);
3716
3717 Agent
3718 .builder()
3719 .on_receive_request(
3720 {
3721 let state = Arc::clone(&state);
3722 async move |req: InitializeRequest, responder, _cx: ConnectionTo<Client>| {
3723 handle_response(responder, state.handle_initialize(req).await)
3724 }
3725 },
3726 agent_client_protocol::on_receive_request!(),
3727 )
3728 .on_receive_request(
3729 {
3730 let state = Arc::clone(&state);
3731 async move |req: AuthenticateRequest, responder, _cx: ConnectionTo<Client>| {
3732 handle_response(responder, state.handle_authenticate(req).await)
3733 }
3734 },
3735 agent_client_protocol::on_receive_request!(),
3736 )
3737 .on_receive_request(
3738 {
3739 let state = Arc::clone(&state);
3740 async move |req: ListSessionsRequest, responder, _cx: ConnectionTo<Client>| {
3741 handle_response(responder, state.handle_list_sessions(req).await)
3742 }
3743 },
3744 agent_client_protocol::on_receive_request!(),
3745 )
3746 // Turn-affecting handlers below run in spawned tasks, serialized by
3747 // `turn_lock`, so the dispatch loop stays free to route client
3748 // responses (permission answers) while a turn is in flight. Awaiting a
3749 // client request from *inside* a handler would deadlock: the dispatch
3750 // loop can't read the response while the handler blocks it.
3751 .on_receive_request(
3752 {
3753 let state = Arc::clone(&state);
3754 async move |req: LoadSessionRequest, responder, cx: ConnectionTo<Client>| {
3755 let state = Arc::clone(&state);
3756 let task_cx = cx.clone();
3757 cx.spawn(async move {
3758 let _turn = state.turn_lock.lock().await;
3759 handle_response(responder, state.handle_load_session(&task_cx, req).await)
3760 })
3761 }
3762 },
3763 agent_client_protocol::on_receive_request!(),
3764 )
3765 .on_receive_request(
3766 {
3767 let state = Arc::clone(&state);
3768 async move |req: ForkSessionRequest, responder, cx: ConnectionTo<Client>| {
3769 let state = Arc::clone(&state);
3770 let task_cx = cx.clone();
3771 cx.spawn(async move {
3772 let _turn = state.turn_lock.lock().await;
3773 handle_response(responder, state.handle_fork_session(&task_cx, req).await)
3774 })
3775 }
3776 },
3777 agent_client_protocol::on_receive_request!(),
3778 )
3779 .on_receive_request(
3780 {
3781 let state = Arc::clone(&state);
3782 async move |req: NewSessionRequest, responder, cx: ConnectionTo<Client>| {
3783 let state = Arc::clone(&state);
3784 let task_cx = cx.clone();
3785 cx.spawn(async move {
3786 let _turn = state.turn_lock.lock().await;
3787 handle_response(responder, state.handle_new_session(&task_cx, req).await)
3788 })
3789 }
3790 },
3791 agent_client_protocol::on_receive_request!(),
3792 )
3793 .on_receive_request(
3794 {
3795 let state = Arc::clone(&state);
3796 async move |req: PromptRequest, responder, cx: ConnectionTo<Client>| {
3797 let state = Arc::clone(&state);
3798 let task_cx = cx.clone();
3799 cx.spawn(async move {
3800 let _turn = state.turn_lock.lock().await;
3801 handle_response(responder, state.handle_prompt(&task_cx, req).await)
3802 })
3803 }
3804 },
3805 agent_client_protocol::on_receive_request!(),
3806 )
3807 .on_receive_request(
3808 {
3809 let state = Arc::clone(&state);
3810 async move |req: SetSessionConfigOptionRequest,
3811 responder,
3812 cx: ConnectionTo<Client>| {
3813 let state = Arc::clone(&state);
3814 let task_cx = cx.clone();
3815 cx.spawn(async move {
3816 let _turn = state.turn_lock.lock().await;
3817 handle_response(
3818 responder,
3819 state.handle_set_session_config_option(&task_cx, req).await,
3820 )
3821 })
3822 }
3823 },
3824 agent_client_protocol::on_receive_request!(),
3825 )
3826 .on_receive_request(
3827 {
3828 let state = Arc::clone(&state);
3829 async move |req: DeleteSessionRequest, responder, cx: ConnectionTo<Client>| {
3830 let state = Arc::clone(&state);
3831 cx.spawn(async move {
3832 let _turn = state.turn_lock.lock().await;
3833 handle_response(responder, state.handle_delete_session(req).await)
3834 })
3835 }
3836 },
3837 agent_client_protocol::on_receive_request!(),
3838 )
3839 .on_receive_notification(
3840 {
3841 let state = Arc::clone(&state);
3842 async move |notif: CancelNotification, _cx: ConnectionTo<Client>| {
3843 state.handle_cancel(notif).await
3844 }
3845 },
3846 agent_client_protocol::on_receive_notification!(),
3847 )
3848 .connect_to(transport)
3849 .await
3850 .map_err(|e| anyhow::anyhow!("ACP connection error: {e}"))?;
3851
3852 log::info!("siGit shutting down");
3853 Ok(())
3854 }
3855
3856 /// Run the browser sign-in from a chat surface and describe how it went.
3857 ///
3858 /// Both the TUI and an ACP session reach this from a bare `/login`. Neither can
3859 /// prompt for a pasted code mid-command, so a box with no bindable port is told
3860 /// to use `sigit login`, which can.
3861 pub(crate) async fn browser_login_message() -> String {
3862 let flow = browser_auth::begin();
3863 if !flow.is_loopback() {
3864 return "Could not open a local port to receive the sign-in. \
3865 Run `sigit login` in a terminal instead."
3866 .to_string();
3867 }
3868
3869 browser_auth::open_browser(flow.authorize_url());
3870 let url = flow.authorize_url().to_string();
3871
3872 match browser_auth::complete_loopback(flow).await {
3873 Ok(email) => format!("Signed in as {email}. Pick a siGit Code Cloud tier in /models."),
3874 Err(error) => format!("Login failed: {error}\nYou can also open {url} by hand."),
3875 }
3876 }
3877
3878 /// `sigit login` from a shell: open the browser and wait for the code.
3879 ///
3880 /// Unlike the ACP button this has a terminal to fall back on, so when no local
3881 /// port can be bound the flow switches to the paste variant and reads the code
3882 /// from stdin. That is the path an ssh session takes.
3883 async fn terminal_browser_login() -> Result<String, String> {
3884 use std::io::{Write, stdin, stdout};
3885
3886 // `--paste` forces the code-in-hand variant. Binding a port succeeds on a
3887 // remote box, but nothing there can reach it if the browser is running on
3888 // the laptop in front of you, so the choice can't be made by probing.
3889 let paste = std::env::args().any(|arg| arg == "--paste");
3890 let flow = if paste {
3891 browser_auth::begin_paste()
3892 } else {
3893 browser_auth::begin()
3894 };
3895 let opened = browser_auth::open_browser(flow.authorize_url());
3896
3897 if flow.is_loopback() {
3898 if opened {
3899 println!("Opened your browser to finish signing in.");
3900 } else {
3901 println!("Open this URL to finish signing in:");
3902 }
3903 println!(" {}", flow.authorize_url());
3904 println!("Waiting...");
3905 return browser_auth::complete_loopback(flow).await;
3906 }
3907
3908 println!("Open this URL to finish signing in:");
3909 println!(" {}", flow.authorize_url());
3910 print!("Then paste the code shown there: ");
3911 stdout().flush().ok();
3912 let mut code = String::new();
3913 stdin()
3914 .read_line(&mut code)
3915 .map_err(|error| format!("could not read the code: {error}"))?;
3916
3917 browser_auth::complete_paste(&flow, &code).await
3918 }
3919
3920 // ── Entry point ──────────────────────────────────────────────────────────────
3921
3922 #[tokio::main]
3923 async fn main() -> anyhow::Result<()> {
3924 // Account subcommands. The editor launches `sigit login` in an embedded
3925 // terminal for ACP terminal-based authentication; the same verbs are handy
3926 // directly from a shell. These must be handled before the TTY/ACP split.
3927 if let Some(verb) = std::env::args().nth(1) {
3928 match verb.as_str() {
3929 "login" => {
3930 init_logging(true);
3931 // `--password` keeps the old email/password prompt for anyone
3932 // who wants it (scripts, a box with no browser at all).
3933 let by_password = std::env::args().any(|arg| arg == "--password");
3934 let result = if by_password {
3935 account::interactive_login().await
3936 } else {
3937 terminal_browser_login().await
3938 };
3939 match result {
3940 Ok(email) => {
3941 println!("Signed in to siGit Code Cloud as {email}.");
3942 return Ok(());
3943 }
3944 Err(error) => {
3945 eprintln!("Login failed: {error}");
3946 std::process::exit(1);
3947 }
3948 }
3949 }
3950 "logout" => {
3951 init_logging(true);
3952 println!("{}", account::end_session().await);
3953 return Ok(());
3954 }
3955 "whoami" => {
3956 init_logging(true);
3957 println!("{}", account::status_line().await);
3958 return Ok(());
3959 }
3960 _ => {}
3961 }
3962 }
3963
3964 // Headless programmatic mode: `sigit -p "<prompt>"` runs one prompt and
3965 // exits. Plain stdio and cross-platform (unlike the Unix-only TUI), so it
3966 // is dispatched here, before the TTY/ACP split, like the account
3967 // subcommands above.
3968 let cli_args: Vec<String> = std::env::args().skip(1).collect();
3969 // Xcode's custom-agent UI launches ACP agents with an explicit `--acp`
3970 // argument. Honor it even when Xcode gives the child a terminal-like stdin.
3971 match parse_explicit_acp(&cli_args) {
3972 Err(error) => {
3973 eprintln!("sigit: {error}");
3974 std::process::exit(2);
3975 }
3976 Ok(true) => {
3977 init_logging(false);
3978 setup::setup_shared_model_cache();
3979 mcp::init().await;
3980 log::info!(
3981 "siGit v{} starting (explicit ACP mode)",
3982 env!("CARGO_PKG_VERSION")
3983 );
3984 return run_acp_server(true).await;
3985 }
3986 Ok(false) => {}
3987 }
3988 match headless::parse_args(&cli_args) {
3989 Ok(None) => {}
3990 Ok(Some(config)) => {
3991 // Logs to stderr; stdout stays clean for the assistant's answer.
3992 init_logging(false);
3993 // Enter --cwd before anything loads, so instruction files and
3994 // project-local MCP config resolve from it (the headless
3995 // counterpart of the ACP session cwd handling).
3996 if let Some(dir) = &config.cwd
3997 && let Err(error) = std::env::set_current_dir(dir)
3998 {
3999 eprintln!("sigit: cannot use --cwd {}: {error}", dir.display());
4000 std::process::exit(2);
4001 }
4002 // --add-dir is the headless form of a multi-root project: extra
4003 // roots that project-local discovery scans alongside the cwd.
4004 if !config.add_dirs.is_empty() {
4005 for dir in &config.add_dirs {
4006 if !dir.is_dir() {
4007 eprintln!(
4008 "sigit: cannot use --add-dir {}: not a directory",
4009 dir.display()
4010 );
4011 std::process::exit(2);
4012 }
4013 }
4014 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
4015 workspace::set_additional_roots(&cwd, &config.add_dirs);
4016 }
4017 setup::setup_shared_model_cache();
4018 // Best-effort MCP discovery (incl. the official server), like the
4019 // other entry points, so MCP tools are offered to the model.
4020 mcp::init().await;
4021 log::info!(
4022 "siGit v{} starting (headless mode)",
4023 env!("CARGO_PKG_VERSION")
4024 );
4025 std::process::exit(headless::run(config).await);
4026 }
4027 Err(error) => {
4028 eprintln!("sigit: {error}\n{}", headless::USAGE);
4029 std::process::exit(2);
4030 }
4031 }
4032
4033 let is_tty = std::io::stdin().is_terminal();
4034
4035 if is_tty {
4036 // must redirect before any library code touches stdout
4037 #[cfg(unix)]
4038 {
4039 let (tty, cleanup_tty) = redirect_output_to_log()?;
4040 init_logging(true);
4041 setup::setup_shared_model_cache();
4042 // Best-effort: discover MCP servers (incl. the official one) before
4043 // the first turn so their tools are offered to the model.
4044 mcp::init().await;
4045 run_interactive(tty, cleanup_tty).await
4046 }
4047 #[cfg(not(unix))]
4048 {
4049 anyhow::bail!("interactive mode requires Unix (macOS / Linux)");
4050 }
4051 } else {
4052 // ACP mode: keep stdout untouched for protocol JSON only.
4053 // Logs already go to stderr via `init_logging(false)`.
4054 init_logging(false);
4055 setup::setup_shared_model_cache();
4056 // Best-effort MCP discovery (incl. the official server) before serving.
4057 mcp::init().await;
4058 log::info!("siGit v{} starting (ACP mode)", env!("CARGO_PKG_VERSION"));
4059 run_acp_server(false).await
4060 }
4061 }
4062
4063 #[cfg(test)]
4064 mod tests {
4065 use super::*;
4066
4067 #[test]
4068 fn session_context_message_lists_every_root_of_a_multi_root_project() {
4069 let primary = PathBuf::from("/tmp/primary");
4070 let secondary = PathBuf::from("/tmp/secondary");
4071
4072 let single = session_context_message(&primary, &[]);
4073 assert!(single.contains("/tmp/primary"));
4074 assert!(!single.contains("multi-root"));
4075
4076 let multi = session_context_message(&primary, &[secondary]);
4077 assert!(multi.contains("multi-root"));
4078 assert!(multi.contains("/tmp/primary (primary)"));
4079 assert!(multi.contains("/tmp/secondary"));
4080 // Relative paths only resolve against the primary root, so the model is
4081 // told to address the others absolutely.
4082 assert!(multi.contains("absolute paths"));
4083 }
4084
4085 #[test]
4086 fn title_from_history_picks_the_first_user_message() {
4087 let history = vec![
4088 serde_json::json!({ "role": "system", "content": "project context" }),
4089 serde_json::json!({ "role": "user", "content": " what do\nthe notes say? " }),
4090 serde_json::json!({ "role": "assistant", "content": "The notes say hello." }),
4091 serde_json::json!({ "role": "user", "content": "a later question" }),
4092 ];
4093 assert_eq!(
4094 title_from_history(&history),
4095 Some("what do the notes say?".to_string())
4096 );
4097
4098 assert_eq!(title_from_history(&[]), None);
4099
4100 let no_user = vec![serde_json::json!({ "role": "system", "content": "just context" })];
4101 assert_eq!(title_from_history(&no_user), None);
4102
4103 let blank = vec![serde_json::json!({ "role": "user", "content": " " })];
4104 assert_eq!(title_from_history(&blank), None);
4105 }
4106
4107 #[test]
4108 fn title_from_history_truncates_long_text_on_a_word_boundary() {
4109 let text = "a".repeat(50) + " " + &"b".repeat(50);
4110 let history = vec![serde_json::json!({ "role": "user", "content": text })];
4111 let title = title_from_history(&history).unwrap();
4112 assert!(title.ends_with('…'), "{title}");
4113 assert!(title.chars().count() <= 61, "{title}");
4114 assert!(
4115 title.starts_with(&"a".repeat(50)),
4116 "must truncate on a word boundary rather than mid-word: {title}"
4117 );
4118 }
4119
4120 #[test]
4121 fn rfc3339_formats_a_known_epoch_second() {
4122 let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
4123 assert_eq!(rfc3339(time), "2023-11-14T22:13:20+00:00");
4124 }
4125
4126 #[test]
4127 fn history_replay_redraws_the_conversation_for_the_client() {
4128 let history = vec![
4129 serde_json::json!({ "role": "system", "content": "project context" }),
4130 serde_json::json!({ "role": "user", "content": "read the changelog" }),
4131 serde_json::json!({
4132 "role": "assistant",
4133 "content": "<think>which file?</think>Reading it now.",
4134 "tool_calls": [{
4135 "id": "call_1", "type": "function",
4136 "function": { "name": "read_file", "arguments": "{\"path\":\"/tmp/CHANGELOG.md\"}" },
4137 }],
4138 }),
4139 serde_json::json!({ "role": "tool", "tool_call_id": "call_1", "content": "# Changelog" }),
4140 serde_json::json!({ "role": "assistant", "content": "It starts at v1.0." }),
4141 ];
4142
4143 let updates = history_replay_updates(&history);
4144
4145 // The system message seeded the model; it was never on screen.
4146 assert_eq!(updates.len(), 4, "{updates:#?}");
4147
4148 match &updates[0] {
4149 SessionUpdate::UserMessageChunk(chunk) => {
4150 assert!(format!("{:?}", chunk.content).contains("read the changelog"));
4151 }
4152 other => panic!("expected the user message first, got {other:?}"),
4153 }
4154 match &updates[1] {
4155 SessionUpdate::AgentMessageChunk(chunk) => {
4156 let rendered = format!("{:?}", chunk.content);
4157 assert!(rendered.contains("Reading it now."));
4158 // Reasoning stays hidden on replay, exactly as it did live.
4159 assert!(!rendered.contains("which file?"));
4160 }
4161 other => panic!("expected the assistant reply, got {other:?}"),
4162 }
4163 match &updates[2] {
4164 SessionUpdate::ToolCall(call) => {
4165 assert_eq!(call.title, "read_file");
4166 assert_eq!(call.kind, ToolKind::Read);
4167 // Nothing is still running in a saved session.
4168 assert_eq!(call.status, ToolCallStatus::Completed);
4169 assert_eq!(
4170 call.raw_input,
4171 Some(serde_json::json!({"path": "/tmp/CHANGELOG.md"}))
4172 );
4173 // The result is folded into its call rather than replayed loose.
4174 assert_eq!(
4175 call.raw_output,
4176 Some(serde_json::Value::String("# Changelog".to_string()))
4177 );
4178 }
4179 other => panic!("expected the tool call, got {other:?}"),
4180 }
4181 assert!(matches!(updates[3], SessionUpdate::AgentMessageChunk(_)));
4182 }
4183
4184 #[test]
4185 fn history_replay_renders_write_todos_as_a_plan() {
4186 let history = vec![serde_json::json!({
4187 "role": "assistant",
4188 "content": null,
4189 "tool_calls": [{
4190 "id": "call_1", "type": "function",
4191 "function": {
4192 "name": "write_todos",
4193 "arguments": "{\"todos\":[{\"content\":\"Ship it\",\"status\":\"completed\"}]}",
4194 },
4195 }],
4196 })];
4197
4198 match history_replay_updates(&history).as_slice() {
4199 [SessionUpdate::Plan(plan)] => {
4200 assert_eq!(plan.entries.len(), 1);
4201 assert_eq!(plan.entries[0].content, "Ship it");
4202 assert_eq!(plan.entries[0].status, PlanEntryStatus::Completed);
4203 }
4204 other => panic!("expected one plan update, got {other:?}"),
4205 }
4206 }
4207
4208 #[test]
4209 fn history_replay_skips_messages_with_nothing_to_show() {
4210 // A tool-call-only assistant turn (`content: null`), an empty reply and
4211 // a blank user message must not become empty bubbles in the client.
4212 let history = vec![
4213 serde_json::json!({ "role": "user", "content": " " }),
4214 serde_json::json!({ "role": "assistant", "content": null }),
4215 serde_json::json!({ "role": "assistant", "content": "" }),
4216 ];
4217 assert!(history_replay_updates(&history).is_empty());
4218 }
4219
4220 #[test]
4221 fn history_replay_reads_block_form_content() {
4222 // Some OpenAI-compatible endpoints hand back content blocks rather than
4223 // a plain string; both reach the session file.
4224 let history = vec![serde_json::json!({
4225 "role": "user",
4226 "content": [
4227 { "type": "text", "text": "check " },
4228 { "type": "text", "text": "this file" },
4229 ],
4230 })];
4231
4232 match history_replay_updates(&history).as_slice() {
4233 [SessionUpdate::UserMessageChunk(chunk)] => {
4234 assert!(format!("{:?}", chunk.content).contains("check this file"));
4235 }
4236 other => panic!("expected one user message, got {other:?}"),
4237 }
4238 }
4239
4240 #[test]
4241 fn streamed_reply_sends_each_fragment_as_it_is_revealed() {
4242 let mut reply = StreamedReply::default();
4243 assert_eq!(reply.push("Hello"), Some("Hello".to_string()));
4244 assert_eq!(reply.push(", world"), Some(", world".to_string()));
4245 assert!(reply.streamed_any);
4246 }
4247
4248 #[test]
4249 fn streamed_reply_opens_a_paragraph_after_a_tool_round() {
4250 let mut reply = StreamedReply::default();
4251 reply.push("I'll search for this pattern.");
4252
4253 // …tool calls run, then the next round starts talking again.
4254 reply.interrupt();
4255
4256 assert_eq!(
4257 reply.push("Let me broaden the search:"),
4258 Some("\n\nLet me broaden the search:".to_string()),
4259 "the new round must not run onto the end of the last sentence"
4260 );
4261 // Only the seam gets a break; the rest of the round streams as usual.
4262 assert_eq!(reply.push(" Found them."), Some(" Found them.".to_string()));
4263 }
4264
4265 #[test]
4266 fn streamed_reply_drops_the_models_own_whitespace_at_the_seam() {
4267 let mut reply = StreamedReply::default();
4268 reply.push("Checking that.");
4269 reply.interrupt();
4270
4271 // A fragment of pure whitespace reveals nothing and must not consume
4272 // the pending break.
4273 assert_eq!(reply.push("\n"), None);
4274 assert_eq!(
4275 reply.push(" Done."),
4276 Some("\n\nDone.".to_string()),
4277 "the break replaces the model's leading whitespace"
4278 );
4279 }
4280
4281 #[test]
4282 fn streamed_reply_does_not_open_with_a_blank_paragraph() {
4283 // First round was tool calls only, so nothing is on the wire yet and
4284 // there is no sentence to break away from.
4285 let mut reply = StreamedReply::default();
4286 reply.interrupt();
4287
4288 assert_eq!(reply.push("Found them."), Some("Found them.".to_string()));
4289 }
4290
4291 #[test]
4292 fn streamed_reply_hides_reasoning_and_still_breaks_the_paragraph() {
4293 let mut reply = StreamedReply::default();
4294 reply.push("Looking now.");
4295 reply.interrupt();
4296
4297 // A round that opens with reasoning reveals nothing until the visible
4298 // text arrives — and that text still starts the new paragraph.
4299 assert_eq!(reply.push("<think>weigh"), None);
4300 assert_eq!(reply.push(" options</think>"), None);
4301 assert_eq!(
4302 reply.push("Here it is."),
4303 Some("\n\nHere it is.".to_string()),
4304 "the break waits for the reasoning to end and rides out with the text"
4305 );
4306 }
4307
4308 #[test]
4309 fn explicit_acp_flag_is_accepted_without_other_arguments() {
4310 assert_eq!(parse_explicit_acp(&["--acp".to_string()]), Ok(true));
4311 assert_eq!(parse_explicit_acp(&[]), Ok(false));
4312 assert!(parse_explicit_acp(&["--acp".to_string(), "--quiet".to_string()]).is_err());
4313 }
4314
4315 #[test]
4316 fn system_prompt_advertises_the_commit_co_author_trailer() {
4317 // The prompt instructs the model with the exact trailer that
4318 // `tools::ensure_commit_co_author` enforces; if the two drift apart the
4319 // safety net would re-amend commits the model already attributed.
4320 assert!(
4321 SYSTEM_PROMPT.contains(tools::COMMIT_CO_AUTHOR_TRAILER),
4322 "SYSTEM_PROMPT must quote tools::COMMIT_CO_AUTHOR_TRAILER verbatim"
4323 );
4324 }
4325
4326 #[test]
4327 fn parse_slash_maps_init() {
4328 assert!(matches!(parse_slash("/init"), Some(SlashCommand::Init)));
4329 // Trailing whitespace comes from editors that submit the raw line.
4330 assert!(matches!(parse_slash(" /init "), Some(SlashCommand::Init)));
4331 }
4332
4333 #[test]
4334 fn system_prompt_points_issue_and_pr_work_at_the_official_mcp_tools() {
4335 // The guidance must reference the exact namespaced names the official
4336 // server's tools get (see mcp::official_tool_name), or the model will
4337 // call tools that don't exist.
4338 for tool in [
4339 "list_issues",
4340 "get_issue",
4341 "create_issue",
4342 "list_pull_requests",
4343 "get_pull_request",
4344 ] {
4345 assert!(
4346 SYSTEM_PROMPT.contains(&mcp::official_tool_name(tool)),
4347 "SYSTEM_PROMPT must mention mcp__sigit__{tool}"
4348 );
4349 }
4350 }
4351
4352 #[test]
4353 fn ascii_safe_replaces_multibyte_chars() {
4354 // The exact label that crashed Zed: the cloud tier name plus the old
4355 // "[☁ siGit Code Cloud]" badge. After sanitizing it must be pure ASCII so
4356 // Zed's fixed byte-offset truncation can never split a glyph.
4357 let crashing = "siGit Code Cloud · Balanced [☁ siGit Code Cloud]";
4358 let safe = ascii_safe(crashing);
4359 assert!(safe.is_ascii(), "sanitized label must be ASCII: {safe:?}");
4360 assert_eq!(safe, "siGit Code Cloud - Balanced [- siGit Code Cloud]");
4361 }
4362
4363 #[test]
4364 fn ascii_safe_leaves_ascii_untouched() {
4365 let plain = "Qwen 2.5 3B [Onde]";
4366 assert_eq!(ascii_safe(plain), plain);
4367 }
4368
4369 #[test]
4370 fn ascii_safe_output_has_only_char_boundaries() {
4371 // Every byte index in an ASCII string is a valid char boundary, so any
4372 // downstream truncation is panic-free regardless of where it cuts.
4373 let safe = ascii_safe("Onde · ◉ ↓ ☁ ○ test");
4374 for i in 0..=safe.len() {
4375 assert!(safe.is_char_boundary(i));
4376 }
4377 }
4378
4379 #[test]
4380 fn acp_config_option_labels_show_context_window() {
4381 let current = GgufModelConfig {
4382 model_id: "sigit-cloud:oke".to_string(),
4383 files: Vec::new(),
4384 tok_model_id: None,
4385 display_name: provider::cloud_tier_label("oke"),
4386 approx_memory: "Cloud".to_string(),
4387 chat_template: None,
4388 };
4389 let options = serde_json::to_value(build_model_config_options(&current)).unwrap();
4390 let all_options = options.as_array().expect("config options");
4391
4392 let model = all_options
4393 .iter()
4394 .find(|option| option["id"] == MODEL_CONFIG_ID)
4395 .expect("model config option");
4396 let model_names: Vec<&str> = model["options"]
4397 .as_array()
4398 .expect("model select options")
4399 .iter()
4400 .filter_map(|option| option["name"].as_str())
4401 .collect();
4402 assert!(model_names.contains(&"Oke - 200K ctx"));
4403 assert!(
4404 !model_names
4405 .iter()
4406 .any(|name| name.contains("siGit Code Cloud")),
4407 "cloud branding belongs in descriptions, not selected-value labels: {model_names:?}"
4408 );
4409 assert!(
4410 model_names.iter().all(|name| name.contains(" ctx")),
4411 "every model option should expose its context window: {model_names:?}"
4412 );
4413
4414 let inference = all_options
4415 .iter()
4416 .find(|option| option["id"] == LOCAL_INFERENCE_CONFIG_ID)
4417 .expect("inference config option");
4418 let inference_names: Vec<&str> = inference["options"]
4419 .as_array()
4420 .expect("inference select options")
4421 .iter()
4422 .filter_map(|option| option["name"].as_str())
4423 .collect();
4424 assert_eq!(inference_names, ["Local", "Cloud"]);
4425 }
4426 }