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