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