@setoelkahfi / sigit / commits / e0edcc8

Persist ACP chat sessions and add App Group opt-out

Store ACP conversations locally so reopening a thread in Zed resumes it instead of starting blank. - Add `src/sessions.rs`: per-`SessionId` transcripts under `$SIGIT_CONFIG_DIR/sessions/<id>.json`, recording finished user/assistant turns (traversal-safe id sanitizing, best-effort writes). - Append each completed turn in `handle_prompt`; on `session/load` and `session/fork` replay the transcript to the editor and restore the model's context via the new `InferenceBackend::restore_history` (implemented for both the on-device and OpenAI-compatible backends). `/clear` now wipes the stored file too. - Add `SIGIT_DISABLE_APP_GROUP` (macOS): skip the shared Onde App Group model cache so the "would like to access data from other apps" privacy prompt — which recurs on every Zed launch because the unsigned CLI can't hold a stable TCC grant — never fires; falls back to `~/.cache/huggingface`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ke8A29yTronhKd5EbWeVX

Claude committed Jun 30, 2026 at 15:00 UTC e0edcc8c9d4d8208870bef949110de70d70389cb
5 files changed +392 -11
CLAUDE.md
+11 -1
index 5fc3327..3ada51b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,7 +111,17 @@ verbosity with `RUST_LOG`. `OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base, default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`), -`SIGIT_MODEL`, `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`. +`SIGIT_MODEL`, `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`, `SIGIT_DISABLE_APP_GROUP` (macOS: skip the +shared Onde App Group model cache so the cross-app data privacy prompt never fires; falls back to +`~/.cache/huggingface`). + +## Session persistence (ACP) + +ACP chat transcripts persist per `SessionId` under `$SIGIT_CONFIG_DIR/sessions/<id>.json` (see +`src/sessions.rs`). Each completed user/assistant turn is appended in `handle_prompt`; on +`session/load` (and `session/fork`) the transcript is replayed to the editor and pushed back into +the active backend via `InferenceBackend::restore_history`, so reopening a thread in Zed resumes it +instead of starting blank. `/clear` wipes both the engine history and the stored file. ## Releasing
src/backend.rs
+49
index c99e90d..d851f53 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -59,6 +59,21 @@ pub struct TurnResult { pub tool_calls: Vec<ToolCall>, } +/// Author of a turn replayed into a backend when a persisted session reopens. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HistoryRole { + User, + Assistant, +} + +/// A prior user/assistant turn restored into a backend's context when a +/// persisted session is reopened (see [`InferenceBackend::restore_history`]). +#[derive(Debug, Clone)] +pub struct HistoryMessage { + pub role: HistoryRole, + pub content: String, +} + /// Backend errors are plain strings. Callers map them to ACP errors. pub type BackendError = String; @@ -102,6 +117,12 @@ pub trait InferenceBackend: Send + Sync { /// than on-device. Drives UI labelling so the displayed model can't claim a /// local model while requests actually go to the cloud. fn is_remote(&self) -> bool; + + /// Replay prior user/assistant turns into the backend's context after a + /// persisted session is reopened, so the model continues with its history + /// rather than starting blank. Called once on session load, before any new + /// prompt. The default does nothing. + async fn restore_history(&self, _messages: &[HistoryMessage]) {} } // ── Local backend (onde ChatEngine) ────────────────────────────────────────────── @@ -198,6 +219,21 @@ impl InferenceBackend for LocalBackend { fn is_remote(&self) -> bool { false } + + async fn restore_history(&self, messages: &[HistoryMessage]) { + // Push the saved turns straight into onde's conversation history so the + // next prompt sees them. The system context is re-pushed separately by + // the caller, so only user/assistant turns flow through here. + for message in messages { + let chat_message = match message.role { + HistoryRole::User => onde::inference::ChatMessage::user(message.content.clone()), + HistoryRole::Assistant => { + onde::inference::ChatMessage::assistant(message.content.clone()) + } + }; + self.engine.push_history(chat_message).await; + } + } } /// Drain an onde streaming receiver, forwarding each token to `sink` and @@ -565,6 +601,19 @@ impl InferenceBackend for OpenAiBackend { fn is_remote(&self) -> bool { true } + + async fn restore_history(&self, messages: &[HistoryMessage]) { + // Splice the saved turns in after the seeded system prompt and before + // any new request, matching the OpenAI chat history shape. + let mut history = self.history.lock().await; + for message in messages { + let role = match message.role { + HistoryRole::User => "user", + HistoryRole::Assistant => "assistant", + }; + history.push(serde_json::json!({ "role": role, "content": message.content })); + } + } } // ── OpenAI response shapes ────────────────────────────────────────────────────────
src/main.rs
+86 -10
index 79c041d..f0961a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,7 @@ mod credentials; mod instructions; mod models; mod provider; +mod sessions; mod settings; mod setup; mod skills; @@ -72,8 +73,8 @@ use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder} use onde::inference::{ChatEngine, GgufModelConfig}; use crate::backend::{ - InferenceBackend, LocalBackend, OpenAiBackend, ToolResult as BackendToolResult, ToolSpec, - TurnResult, + HistoryMessage, HistoryRole, InferenceBackend, LocalBackend, OpenAiBackend, + ToolResult as BackendToolResult, ToolSpec, TurnResult, }; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; @@ -883,6 +884,51 @@ impl SiGitAgent { } } + /// Replay a persisted transcript into a freshly-loaded session: restore the + /// model's context on whatever backend is now active, then re-emit each turn + /// to the editor so a reopened thread shows its history instead of a blank + /// panel. Call after the session cwd and backend are settled. + async fn replay_stored_session( + &self, + cx: &ConnectionTo<Client>, + session_id: &SessionId, + stored: &sessions::StoredSession, + ) { + if stored.messages.is_empty() { + return; + } + + // Give the model its prior context back on the active backend. + let history: Vec<HistoryMessage> = stored + .messages + .iter() + .map(|message| HistoryMessage { + role: match message.role { + sessions::Role::User => HistoryRole::User, + sessions::Role::Assistant => HistoryRole::Assistant, + }, + content: message.text.clone(), + }) + .collect(); + self.backend.lock().await.restore_history(&history).await; + + // Replay the visible transcript so the editor re-renders the thread. + for message in &stored.messages { + let chunk = ContentChunk::new(ContentBlock::from(message.text.clone())); + let update = match message.role { + sessions::Role::User => SessionUpdate::UserMessageChunk(chunk), + sessions::Role::Assistant => SessionUpdate::AgentMessageChunk(chunk), + }; + self.send_tool_call_update(cx, session_id.clone(), update) + .ok(); + } + + log::info!( + "replayed {} stored message(s) for session {session_id}", + stored.messages.len() + ); + } + async fn handle_load_session( &self, cx: &ConnectionTo<Client>, @@ -909,7 +955,7 @@ impl SiGitAgent { log::warn!("could not set cwd to {}: {err}", args.cwd.display()); } - // no session persistence, so "load" just resets + // Start from a clean engine, then rebuild the per-session context. self.engine.clear_history().await; self.engine @@ -919,8 +965,15 @@ impl SiGitAgent { .await; // Honor the persisted Local Inference toggle (off + signed in → cloud). + // Do this before replaying so the saved turns land on the active backend. self.apply_startup_inference_mode().await; + // Replay the persisted transcript so a reopened thread resumes instead + // of starting blank. + let stored = sessions::load(args.session_id.0.as_ref()); + self.replay_stored_session(cx, &args.session_id, &stored) + .await; + let config_options = { let guard = self.current_model.lock().unwrap(); build_model_config_options(&guard) @@ -956,7 +1009,7 @@ impl SiGitAgent { log::warn!("could not set cwd to {}: {err}", args.cwd.display()); } - // no persistence, so fork == fresh session + // Start from a clean engine, then rebuild the per-session context. self.engine.clear_history().await; self.engine @@ -968,6 +1021,12 @@ impl SiGitAgent { // Honor the persisted Local Inference toggle (off + signed in → cloud). self.apply_startup_inference_mode().await; + // Carry the parent thread's transcript into the fork so it opens with + // the same history rather than blank, then replay it like a load. + sessions::fork(args.session_id.0.as_ref(), new_id.0.as_ref()); + let stored = sessions::load(new_id.0.as_ref()); + self.replay_stored_session(cx, &new_id, &stored).await; + let config_options = { let guard = self.current_model.lock().unwrap(); build_model_config_options(&guard) @@ -1282,10 +1341,14 @@ impl SiGitAgent { } // ── Final text response ─────────────────────────────────────────── - // If anything streamed, the visible reply is already on the wire; only - // send a trailing block for the non-streamed path (e.g. on-device direct - // answers, which onde can't stream while tools are on offer). - if !streamed_any { + // If anything streamed, the visible reply is already on the wire and + // `sent` holds exactly what reached the editor. Otherwise send the + // buffered final text now (e.g. on-device direct answers, which onde + // can't stream while tools are on offer). Either way, keep the visible + // reply so we can persist the turn below. + let assistant_reply = if streamed_any { + sent.clone() + } else { let reply_text = result.text.trim().to_string(); let final_text = if reply_text.is_empty() { if round > 0 { @@ -1309,10 +1372,21 @@ impl SiGitAgent { }; if !final_text.is_empty() { - self.send_assistant_message(cx, session_id.clone(), final_text) + self.send_assistant_message(cx, session_id.clone(), final_text.clone()) .ok(); } - } + final_text + }; + + // Persist the completed turn so reopening this thread in the editor + // resumes it instead of starting from a blank panel. + let cwd = self.session_cwd.lock().ok().and_then(|guard| guard.clone()); + sessions::append_turn( + session_id.0.as_ref(), + cwd.as_deref(), + &user_text, + &assistant_reply, + ); log::info!("prompt({}) complete — {} tool round(s)", session_id, round); Ok(PromptResponse::new(StopReason::EndTurn)) @@ -2109,6 +2183,8 @@ async fn exec_slash_acp( } SlashCommand::Clear => { let cleared = agent.engine.clear_history().await; + // Also forget the persisted transcript so the wipe survives a reload. + sessions::clear(session_id.0.as_ref()); agent .send_assistant_message( cx,
src/sessions.rs
+217
new file mode 100644 index 0000000..ae79662 --- /dev/null +++ b/src/sessions.rs @@ -0,0 +1,217 @@ +//! Local persistence of ACP chat conversations. +//! +//! Editors such as Zed remember a thread's `SessionId` across restarts and call +//! `session/load` to reopen it. Before this module siGit cleared history on +//! load, so every reopened thread started blank. Here we store a compact +//! transcript — the user prompts and the assistant's visible replies — per +//! session under `$SIGIT_CONFIG_DIR/sessions/<id>.json`. On reload the +//! transcript is replayed to the editor and pushed back into the active backend +//! so the model keeps its context. +//! +//! Only finished user/assistant turns are stored: no tool-call plumbing and no +//! system context (that is rebuilt fresh from the cwd on every load). This keeps +//! the format backend-agnostic — the same file restores whether the session +//! resumes on-device or on a siGit Code Cloud tier. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// Author of a stored message. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + User, + Assistant, +} + +/// One persisted turn in a conversation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredMessage { + pub role: Role, + pub text: String, +} + +/// A persisted conversation, keyed on disk by its ACP `SessionId`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct StoredSession { + /// The session's working directory, kept for reference/debugging. + #[serde(default)] + pub cwd: Option<String>, + #[serde(default)] + pub messages: Vec<StoredMessage>, +} + +impl StoredSession { + pub fn is_empty(&self) -> bool { + self.messages.is_empty() + } +} + +/// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`. Mirrors +/// [`crate::settings`] and [`crate::credentials`]. +fn config_dir() -> Option<PathBuf> { + if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") { + return Some(PathBuf::from(dir)); + } + let home = std::env::var("HOME").ok()?; + Some(PathBuf::from(home).join(".config/sigit")) +} + +fn sessions_dir() -> Option<PathBuf> { + config_dir().map(|dir| dir.join("sessions")) +} + +/// Reduce a `SessionId` to a single, traversal-safe file-name stem. Editors +/// pick the id (usually a UUID); keep `[A-Za-z0-9._-]` and map anything else to +/// `_` so it can never escape the sessions directory. +fn sanitize_id(id: &str) -> String { + id.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + }) + .collect() +} + +/// Path to the transcript file for `id`, or `None` if the id can't form a valid +/// file name (empty, or only dots/separators after sanitizing). +fn session_path(id: &str) -> Option<PathBuf> { + let stem = sanitize_id(id); + if stem.trim_matches(['.', '_', '-']).is_empty() { + return None; + } + sessions_dir().map(|dir| dir.join(format!("{stem}.json"))) +} + +/// Load the stored transcript for `id`, or an empty session if none exists or +/// the file can't be read or parsed. +pub fn load(id: &str) -> StoredSession { + let Some(path) = session_path(id) else { + return StoredSession::default(); + }; + match std::fs::read_to_string(&path) { + Ok(contents) => serde_json::from_str(&contents).unwrap_or_else(|error| { + log::warn!("sessions: ignoring unreadable {}: {error}", path.display()); + StoredSession::default() + }), + Err(_) => StoredSession::default(), + } +} + +/// Persist `session` for `id`, creating the sessions directory if needed. +pub fn save(id: &str, session: &StoredSession) -> Result<(), String> { + let path = session_path(id).ok_or_else(|| format!("invalid session id: {id:?}"))?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| format!("create {parent:?}: {error}"))?; + } + let body = + serde_json::to_string_pretty(session).map_err(|error| format!("serialize: {error}"))?; + std::fs::write(&path, body).map_err(|error| format!("write {path:?}: {error}")) +} + +/// Append a completed turn to `id`'s transcript. The user text is always +/// recorded; the assistant reply only when non-empty. Best-effort: a write +/// failure is logged, never surfaced, so persistence can't break a live turn. +pub fn append_turn(id: &str, cwd: Option<&Path>, user_text: &str, assistant_text: &str) { + let mut session = load(id); + if session.cwd.is_none() { + session.cwd = cwd.map(|path| path.display().to_string()); + } + session.messages.push(StoredMessage { + role: Role::User, + text: user_text.to_string(), + }); + let assistant = assistant_text.trim(); + if !assistant.is_empty() { + session.messages.push(StoredMessage { + role: Role::Assistant, + text: assistant.to_string(), + }); + } + if let Err(error) = save(id, &session) { + log::warn!("sessions: could not persist turn for {id}: {error}"); + } +} + +/// Forget `id`'s transcript (used by `/clear`). A missing file is not an error. +pub fn clear(id: &str) { + if let Some(path) = session_path(id) + && let Err(error) = std::fs::remove_file(&path) + && error.kind() != std::io::ErrorKind::NotFound + { + log::warn!("sessions: could not clear {}: {error}", path.display()); + } +} + +/// Copy `from`'s transcript onto `to` when a session is forked, so the fork +/// opens with the parent's history instead of blank. Best-effort. +pub fn fork(from: &str, to: &str) { + let session = load(from); + if session.is_empty() { + return; + } + if let Err(error) = save(to, &session) { + log::warn!("sessions: could not fork {from} -> {to}: {error}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn append_load_clear_round_trip() { + let _guard = crate::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let dir = std::env::temp_dir().join(format!("sigit_sessions_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + // SAFETY: single-threaded test guarded by ENV_TEST_LOCK; restored below. + unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) }; + + let id = "11111111-2222-3333-4444-555555555555"; + assert!(load(id).is_empty(), "unknown session starts empty"); + + append_turn(id, Some(Path::new("/tmp/project")), "hello", "hi there"); + append_turn(id, None, "second", ""); + + let session = load(id); + assert_eq!(session.cwd.as_deref(), Some("/tmp/project")); + // user, assistant, user — the empty assistant reply is dropped. + assert_eq!(session.messages.len(), 3); + assert_eq!(session.messages[0].role, Role::User); + assert_eq!(session.messages[0].text, "hello"); + assert_eq!(session.messages[1].role, Role::Assistant); + assert_eq!(session.messages[1].text, "hi there"); + assert_eq!(session.messages[2].role, Role::User); + assert_eq!(session.messages[2].text, "second"); + + let forked = "99999999-2222-3333-4444-555555555555"; + fork(id, forked); + assert_eq!(load(forked).messages.len(), 3, "fork copies the transcript"); + + clear(id); + assert!(load(id).is_empty(), "clear forgets the transcript"); + clear(id); // clearing a missing session is a no-op + + let _ = std::fs::remove_dir_all(&dir); + // SAFETY: single-threaded test guarded by ENV_TEST_LOCK. + unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") }; + } + + #[test] + fn sanitize_id_blocks_path_traversal() { + // Dots are kept, but every path separator becomes `_`, so the result is + // always a single, non-traversing path component. + assert_eq!(sanitize_id("../../etc/passwd"), ".._.._etc_passwd"); + assert_eq!(sanitize_id("a/b\\c"), "a_b_c"); + assert_eq!(sanitize_id("uuid-1234_AB.cd"), "uuid-1234_AB.cd"); + // Ids that sanitize to nothing usable yield no path. + assert!(session_path("..").is_none()); + assert!(session_path("/").is_none()); + } +}
src/setup.rs
+29
index f57c798..8c7310a 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -13,6 +13,17 @@ use std::path::{Path, PathBuf}; #[cfg(target_os = "macos")] const APP_GROUP_IDENTIFIER: &str = "group.com.ondeinference.apps"; +/// Opt out of the shared App Group cache. Set truthy to keep siGit out of the +/// Onde App Group container entirely. On macOS Sequoia a process that touches +/// another app's Group Container triggers a "would like to access data from +/// other apps" privacy prompt; when siGit runs as an editor's ACP subprocess +/// (e.g. Zed) that prompt is attributed to the editor and recurs on every +/// launch because the unsigned CLI binary can't hold a stable TCC grant. +/// Setting this routes model discovery and caching to the default +/// `~/.cache/huggingface` location instead, so the prompt never appears. +#[cfg(target_os = "macos")] +const DISABLE_APP_GROUP_ENV: &str = "SIGIT_DISABLE_APP_GROUP"; + /// point `HF_HOME` / `HF_HUB_CACHE` at the shared container. no-ops if /// the user already set them. pub fn setup_shared_model_cache() { @@ -404,6 +415,24 @@ fn selected_model_file_path() -> Option<PathBuf> { /// so it won't exist until the user has launched siGit desktop or another Onde app. #[cfg(target_os = "macos")] fn resolve_shared_container() -> Option<PathBuf> { + // Honor the opt-out before touching the container at all — the access + // itself is what triggers the macOS cross-app data privacy prompt. + if std::env::var(DISABLE_APP_GROUP_ENV) + .ok() + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "on" | "yes" + ) + }) + .unwrap_or(false) + { + log::info!( + "{DISABLE_APP_GROUP_ENV} set — skipping Onde App Group container, using default HF cache" + ); + return None; + } + let home = std::env::var("HOME").ok()?; let container = PathBuf::from(home) .join("Library")