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