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