@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
@@ -111,7 +111,17 @@ verbosity with `RUST_LOG`.
111
112 `OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
113 default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
114 -`SIGIT_MODEL`, `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`.
114 +`SIGIT_MODEL`, `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`, `SIGIT_DISABLE_APP_GROUP` (macOS: skip the
115 +shared Onde App Group model cache so the cross-app data privacy prompt never fires; falls back to
116 +`~/.cache/huggingface`).
117 +
118 +## Session persistence (ACP)
119 +
120 +ACP chat transcripts persist per `SessionId` under `$SIGIT_CONFIG_DIR/sessions/<id>.json` (see
121 +`src/sessions.rs`). Each completed user/assistant turn is appended in `handle_prompt`; on
122 +`session/load` (and `session/fork`) the transcript is replayed to the editor and pushed back into
123 +the active backend via `InferenceBackend::restore_history`, so reopening a thread in Zed resumes it
124 +instead of starting blank. `/clear` wipes both the engine history and the stored file.
125
126 ## Releasing
127
src/backend.rs
+49
@@ -59,6 +59,21 @@ pub struct TurnResult {
59 pub tool_calls: Vec<ToolCall>,
60 }
61
62 +/// Author of a turn replayed into a backend when a persisted session reopens.
63 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64 +pub enum HistoryRole {
65 + User,
66 + Assistant,
67 +}
68 +
69 +/// A prior user/assistant turn restored into a backend's context when a
70 +/// persisted session is reopened (see [`InferenceBackend::restore_history`]).
71 +#[derive(Debug, Clone)]
72 +pub struct HistoryMessage {
73 + pub role: HistoryRole,
74 + pub content: String,
75 +}
76 +
77 /// Backend errors are plain strings. Callers map them to ACP errors.
78 pub type BackendError = String;
79
@@ -102,6 +117,12 @@ pub trait InferenceBackend: Send + Sync {
117 /// than on-device. Drives UI labelling so the displayed model can't claim a
118 /// local model while requests actually go to the cloud.
119 fn is_remote(&self) -> bool;
120 +
121 + /// Replay prior user/assistant turns into the backend's context after a
122 + /// persisted session is reopened, so the model continues with its history
123 + /// rather than starting blank. Called once on session load, before any new
124 + /// prompt. The default does nothing.
125 + async fn restore_history(&self, _messages: &[HistoryMessage]) {}
126 }
127
128 // ── Local backend (onde ChatEngine) ──────────────────────────────────────────────
@@ -198,6 +219,21 @@ impl InferenceBackend for LocalBackend {
219 fn is_remote(&self) -> bool {
220 false
221 }
222 +
223 + async fn restore_history(&self, messages: &[HistoryMessage]) {
224 + // Push the saved turns straight into onde's conversation history so the
225 + // next prompt sees them. The system context is re-pushed separately by
226 + // the caller, so only user/assistant turns flow through here.
227 + for message in messages {
228 + let chat_message = match message.role {
229 + HistoryRole::User => onde::inference::ChatMessage::user(message.content.clone()),
230 + HistoryRole::Assistant => {
231 + onde::inference::ChatMessage::assistant(message.content.clone())
232 + }
233 + };
234 + self.engine.push_history(chat_message).await;
235 + }
236 + }
237 }
238
239 /// Drain an onde streaming receiver, forwarding each token to `sink` and
@@ -565,6 +601,19 @@ impl InferenceBackend for OpenAiBackend {
601 fn is_remote(&self) -> bool {
602 true
603 }
604 +
605 + async fn restore_history(&self, messages: &[HistoryMessage]) {
606 + // Splice the saved turns in after the seeded system prompt and before
607 + // any new request, matching the OpenAI chat history shape.
608 + let mut history = self.history.lock().await;
609 + for message in messages {
610 + let role = match message.role {
611 + HistoryRole::User => "user",
612 + HistoryRole::Assistant => "assistant",
613 + };
614 + history.push(serde_json::json!({ "role": role, "content": message.content }));
615 + }
616 + }
617 }
618
619 // ── OpenAI response shapes ────────────────────────────────────────────────────────
src/main.rs
+86 -10
@@ -35,6 +35,7 @@ mod credentials;
35 mod instructions;
36 mod models;
37 mod provider;
38 +mod sessions;
39 mod settings;
40 mod setup;
41 mod skills;
@@ -72,8 +73,8 @@ use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder}
73 use onde::inference::{ChatEngine, GgufModelConfig};
74
75 use crate::backend::{
75 - InferenceBackend, LocalBackend, OpenAiBackend, ToolResult as BackendToolResult, ToolSpec,
76 - TurnResult,
76 + HistoryMessage, HistoryRole, InferenceBackend, LocalBackend, OpenAiBackend,
77 + ToolResult as BackendToolResult, ToolSpec, TurnResult,
78 };
79 use std::path::PathBuf;
80 use std::sync::atomic::{AtomicBool, Ordering};
@@ -883,6 +884,51 @@ impl SiGitAgent {
884 }
885 }
886
887 + /// Replay a persisted transcript into a freshly-loaded session: restore the
888 + /// model's context on whatever backend is now active, then re-emit each turn
889 + /// to the editor so a reopened thread shows its history instead of a blank
890 + /// panel. Call after the session cwd and backend are settled.
891 + async fn replay_stored_session(
892 + &self,
893 + cx: &ConnectionTo<Client>,
894 + session_id: &SessionId,
895 + stored: &sessions::StoredSession,
896 + ) {
897 + if stored.messages.is_empty() {
898 + return;
899 + }
900 +
901 + // Give the model its prior context back on the active backend.
902 + let history: Vec<HistoryMessage> = stored
903 + .messages
904 + .iter()
905 + .map(|message| HistoryMessage {
906 + role: match message.role {
907 + sessions::Role::User => HistoryRole::User,
908 + sessions::Role::Assistant => HistoryRole::Assistant,
909 + },
910 + content: message.text.clone(),
911 + })
912 + .collect();
913 + self.backend.lock().await.restore_history(&history).await;
914 +
915 + // Replay the visible transcript so the editor re-renders the thread.
916 + for message in &stored.messages {
917 + let chunk = ContentChunk::new(ContentBlock::from(message.text.clone()));
918 + let update = match message.role {
919 + sessions::Role::User => SessionUpdate::UserMessageChunk(chunk),
920 + sessions::Role::Assistant => SessionUpdate::AgentMessageChunk(chunk),
921 + };
922 + self.send_tool_call_update(cx, session_id.clone(), update)
923 + .ok();
924 + }
925 +
926 + log::info!(
927 + "replayed {} stored message(s) for session {session_id}",
928 + stored.messages.len()
929 + );
930 + }
931 +
932 async fn handle_load_session(
933 &self,
934 cx: &ConnectionTo<Client>,
@@ -909,7 +955,7 @@ impl SiGitAgent {
955 log::warn!("could not set cwd to {}: {err}", args.cwd.display());
956 }
957
912 - // no session persistence, so "load" just resets
958 + // Start from a clean engine, then rebuild the per-session context.
959 self.engine.clear_history().await;
960
961 self.engine
@@ -919,8 +965,15 @@ impl SiGitAgent {
965 .await;
966
967 // Honor the persisted Local Inference toggle (off + signed in → cloud).
968 + // Do this before replaying so the saved turns land on the active backend.
969 self.apply_startup_inference_mode().await;
970
971 + // Replay the persisted transcript so a reopened thread resumes instead
972 + // of starting blank.
973 + let stored = sessions::load(args.session_id.0.as_ref());
974 + self.replay_stored_session(cx, &args.session_id, &stored)
975 + .await;
976 +
977 let config_options = {
978 let guard = self.current_model.lock().unwrap();
979 build_model_config_options(&guard)
@@ -956,7 +1009,7 @@ impl SiGitAgent {
1009 log::warn!("could not set cwd to {}: {err}", args.cwd.display());
1010 }
1011
959 - // no persistence, so fork == fresh session
1012 + // Start from a clean engine, then rebuild the per-session context.
1013 self.engine.clear_history().await;
1014
1015 self.engine
@@ -968,6 +1021,12 @@ impl SiGitAgent {
1021 // Honor the persisted Local Inference toggle (off + signed in → cloud).
1022 self.apply_startup_inference_mode().await;
1023
1024 + // Carry the parent thread's transcript into the fork so it opens with
1025 + // the same history rather than blank, then replay it like a load.
1026 + sessions::fork(args.session_id.0.as_ref(), new_id.0.as_ref());
1027 + let stored = sessions::load(new_id.0.as_ref());
1028 + self.replay_stored_session(cx, &new_id, &stored).await;
1029 +
1030 let config_options = {
1031 let guard = self.current_model.lock().unwrap();
1032 build_model_config_options(&guard)
@@ -1282,10 +1341,14 @@ impl SiGitAgent {
1341 }
1342
1343 // ── Final text response ───────────────────────────────────────────
1285 - // If anything streamed, the visible reply is already on the wire; only
1286 - // send a trailing block for the non-streamed path (e.g. on-device direct
1287 - // answers, which onde can't stream while tools are on offer).
1288 - if !streamed_any {
1344 + // If anything streamed, the visible reply is already on the wire and
1345 + // `sent` holds exactly what reached the editor. Otherwise send the
1346 + // buffered final text now (e.g. on-device direct answers, which onde
1347 + // can't stream while tools are on offer). Either way, keep the visible
1348 + // reply so we can persist the turn below.
1349 + let assistant_reply = if streamed_any {
1350 + sent.clone()
1351 + } else {
1352 let reply_text = result.text.trim().to_string();
1353 let final_text = if reply_text.is_empty() {
1354 if round > 0 {
@@ -1309,10 +1372,21 @@ impl SiGitAgent {
1372 };
1373
1374 if !final_text.is_empty() {
1312 - self.send_assistant_message(cx, session_id.clone(), final_text)
1375 + self.send_assistant_message(cx, session_id.clone(), final_text.clone())
1376 .ok();
1377 }
1315 - }
1378 + final_text
1379 + };
1380 +
1381 + // Persist the completed turn so reopening this thread in the editor
1382 + // resumes it instead of starting from a blank panel.
1383 + let cwd = self.session_cwd.lock().ok().and_then(|guard| guard.clone());
1384 + sessions::append_turn(
1385 + session_id.0.as_ref(),
1386 + cwd.as_deref(),
1387 + &user_text,
1388 + &assistant_reply,
1389 + );
1390
1391 log::info!("prompt({}) complete — {} tool round(s)", session_id, round);
1392 Ok(PromptResponse::new(StopReason::EndTurn))
@@ -2109,6 +2183,8 @@ async fn exec_slash_acp(
2183 }
2184 SlashCommand::Clear => {
2185 let cleared = agent.engine.clear_history().await;
2186 + // Also forget the persisted transcript so the wipe survives a reload.
2187 + sessions::clear(session_id.0.as_ref());
2188 agent
2189 .send_assistant_message(
2190 cx,
src/sessions.rs new
+217
@@ -0,0 +1,217 @@
1 +//! Local persistence of ACP chat conversations.
2 +//!
3 +//! Editors such as Zed remember a thread's `SessionId` across restarts and call
4 +//! `session/load` to reopen it. Before this module siGit cleared history on
5 +//! load, so every reopened thread started blank. Here we store a compact
6 +//! transcript — the user prompts and the assistant's visible replies — per
7 +//! session under `$SIGIT_CONFIG_DIR/sessions/<id>.json`. On reload the
8 +//! transcript is replayed to the editor and pushed back into the active backend
9 +//! so the model keeps its context.
10 +//!
11 +//! Only finished user/assistant turns are stored: no tool-call plumbing and no
12 +//! system context (that is rebuilt fresh from the cwd on every load). This keeps
13 +//! the format backend-agnostic — the same file restores whether the session
14 +//! resumes on-device or on a siGit Code Cloud tier.
15 +
16 +use std::path::{Path, PathBuf};
17 +
18 +use serde::{Deserialize, Serialize};
19 +
20 +/// Author of a stored message.
21 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22 +#[serde(rename_all = "lowercase")]
23 +pub enum Role {
24 + User,
25 + Assistant,
26 +}
27 +
28 +/// One persisted turn in a conversation.
29 +#[derive(Debug, Clone, Serialize, Deserialize)]
30 +pub struct StoredMessage {
31 + pub role: Role,
32 + pub text: String,
33 +}
34 +
35 +/// A persisted conversation, keyed on disk by its ACP `SessionId`.
36 +#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37 +pub struct StoredSession {
38 + /// The session's working directory, kept for reference/debugging.
39 + #[serde(default)]
40 + pub cwd: Option<String>,
41 + #[serde(default)]
42 + pub messages: Vec<StoredMessage>,
43 +}
44 +
45 +impl StoredSession {
46 + pub fn is_empty(&self) -> bool {
47 + self.messages.is_empty()
48 + }
49 +}
50 +
51 +/// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`. Mirrors
52 +/// [`crate::settings`] and [`crate::credentials`].
53 +fn config_dir() -> Option<PathBuf> {
54 + if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
55 + return Some(PathBuf::from(dir));
56 + }
57 + let home = std::env::var("HOME").ok()?;
58 + Some(PathBuf::from(home).join(".config/sigit"))
59 +}
60 +
61 +fn sessions_dir() -> Option<PathBuf> {
62 + config_dir().map(|dir| dir.join("sessions"))
63 +}
64 +
65 +/// Reduce a `SessionId` to a single, traversal-safe file-name stem. Editors
66 +/// pick the id (usually a UUID); keep `[A-Za-z0-9._-]` and map anything else to
67 +/// `_` so it can never escape the sessions directory.
68 +fn sanitize_id(id: &str) -> String {
69 + id.chars()
70 + .map(|c| {
71 + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
72 + c
73 + } else {
74 + '_'
75 + }
76 + })
77 + .collect()
78 +}
79 +
80 +/// Path to the transcript file for `id`, or `None` if the id can't form a valid
81 +/// file name (empty, or only dots/separators after sanitizing).
82 +fn session_path(id: &str) -> Option<PathBuf> {
83 + let stem = sanitize_id(id);
84 + if stem.trim_matches(['.', '_', '-']).is_empty() {
85 + return None;
86 + }
87 + sessions_dir().map(|dir| dir.join(format!("{stem}.json")))
88 +}
89 +
90 +/// Load the stored transcript for `id`, or an empty session if none exists or
91 +/// the file can't be read or parsed.
92 +pub fn load(id: &str) -> StoredSession {
93 + let Some(path) = session_path(id) else {
94 + return StoredSession::default();
95 + };
96 + match std::fs::read_to_string(&path) {
97 + Ok(contents) => serde_json::from_str(&contents).unwrap_or_else(|error| {
98 + log::warn!("sessions: ignoring unreadable {}: {error}", path.display());
99 + StoredSession::default()
100 + }),
101 + Err(_) => StoredSession::default(),
102 + }
103 +}
104 +
105 +/// Persist `session` for `id`, creating the sessions directory if needed.
106 +pub fn save(id: &str, session: &StoredSession) -> Result<(), String> {
107 + let path = session_path(id).ok_or_else(|| format!("invalid session id: {id:?}"))?;
108 + if let Some(parent) = path.parent() {
109 + std::fs::create_dir_all(parent).map_err(|error| format!("create {parent:?}: {error}"))?;
110 + }
111 + let body =
112 + serde_json::to_string_pretty(session).map_err(|error| format!("serialize: {error}"))?;
113 + std::fs::write(&path, body).map_err(|error| format!("write {path:?}: {error}"))
114 +}
115 +
116 +/// Append a completed turn to `id`'s transcript. The user text is always
117 +/// recorded; the assistant reply only when non-empty. Best-effort: a write
118 +/// failure is logged, never surfaced, so persistence can't break a live turn.
119 +pub fn append_turn(id: &str, cwd: Option<&Path>, user_text: &str, assistant_text: &str) {
120 + let mut session = load(id);
121 + if session.cwd.is_none() {
122 + session.cwd = cwd.map(|path| path.display().to_string());
123 + }
124 + session.messages.push(StoredMessage {
125 + role: Role::User,
126 + text: user_text.to_string(),
127 + });
128 + let assistant = assistant_text.trim();
129 + if !assistant.is_empty() {
130 + session.messages.push(StoredMessage {
131 + role: Role::Assistant,
132 + text: assistant.to_string(),
133 + });
134 + }
135 + if let Err(error) = save(id, &session) {
136 + log::warn!("sessions: could not persist turn for {id}: {error}");
137 + }
138 +}
139 +
140 +/// Forget `id`'s transcript (used by `/clear`). A missing file is not an error.
141 +pub fn clear(id: &str) {
142 + if let Some(path) = session_path(id)
143 + && let Err(error) = std::fs::remove_file(&path)
144 + && error.kind() != std::io::ErrorKind::NotFound
145 + {
146 + log::warn!("sessions: could not clear {}: {error}", path.display());
147 + }
148 +}
149 +
150 +/// Copy `from`'s transcript onto `to` when a session is forked, so the fork
151 +/// opens with the parent's history instead of blank. Best-effort.
152 +pub fn fork(from: &str, to: &str) {
153 + let session = load(from);
154 + if session.is_empty() {
155 + return;
156 + }
157 + if let Err(error) = save(to, &session) {
158 + log::warn!("sessions: could not fork {from} -> {to}: {error}");
159 + }
160 +}
161 +
162 +#[cfg(test)]
163 +mod tests {
164 + use super::*;
165 +
166 + #[test]
167 + fn append_load_clear_round_trip() {
168 + let _guard = crate::ENV_TEST_LOCK
169 + .lock()
170 + .unwrap_or_else(|poisoned| poisoned.into_inner());
171 + let dir = std::env::temp_dir().join(format!("sigit_sessions_{}", std::process::id()));
172 + let _ = std::fs::remove_dir_all(&dir);
173 + // SAFETY: single-threaded test guarded by ENV_TEST_LOCK; restored below.
174 + unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
175 +
176 + let id = "11111111-2222-3333-4444-555555555555";
177 + assert!(load(id).is_empty(), "unknown session starts empty");
178 +
179 + append_turn(id, Some(Path::new("/tmp/project")), "hello", "hi there");
180 + append_turn(id, None, "second", "");
181 +
182 + let session = load(id);
183 + assert_eq!(session.cwd.as_deref(), Some("/tmp/project"));
184 + // user, assistant, user — the empty assistant reply is dropped.
185 + assert_eq!(session.messages.len(), 3);
186 + assert_eq!(session.messages[0].role, Role::User);
187 + assert_eq!(session.messages[0].text, "hello");
188 + assert_eq!(session.messages[1].role, Role::Assistant);
189 + assert_eq!(session.messages[1].text, "hi there");
190 + assert_eq!(session.messages[2].role, Role::User);
191 + assert_eq!(session.messages[2].text, "second");
192 +
193 + let forked = "99999999-2222-3333-4444-555555555555";
194 + fork(id, forked);
195 + assert_eq!(load(forked).messages.len(), 3, "fork copies the transcript");
196 +
197 + clear(id);
198 + assert!(load(id).is_empty(), "clear forgets the transcript");
199 + clear(id); // clearing a missing session is a no-op
200 +
201 + let _ = std::fs::remove_dir_all(&dir);
202 + // SAFETY: single-threaded test guarded by ENV_TEST_LOCK.
203 + unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
204 + }
205 +
206 + #[test]
207 + fn sanitize_id_blocks_path_traversal() {
208 + // Dots are kept, but every path separator becomes `_`, so the result is
209 + // always a single, non-traversing path component.
210 + assert_eq!(sanitize_id("../../etc/passwd"), ".._.._etc_passwd");
211 + assert_eq!(sanitize_id("a/b\\c"), "a_b_c");
212 + assert_eq!(sanitize_id("uuid-1234_AB.cd"), "uuid-1234_AB.cd");
213 + // Ids that sanitize to nothing usable yield no path.
214 + assert!(session_path("..").is_none());
215 + assert!(session_path("/").is_none());
216 + }
217 +}
src/setup.rs
+29
@@ -13,6 +13,17 @@ use std::path::{Path, PathBuf};
13 #[cfg(target_os = "macos")]
14 const APP_GROUP_IDENTIFIER: &str = "group.com.ondeinference.apps";
15
16 +/// Opt out of the shared App Group cache. Set truthy to keep siGit out of the
17 +/// Onde App Group container entirely. On macOS Sequoia a process that touches
18 +/// another app's Group Container triggers a "would like to access data from
19 +/// other apps" privacy prompt; when siGit runs as an editor's ACP subprocess
20 +/// (e.g. Zed) that prompt is attributed to the editor and recurs on every
21 +/// launch because the unsigned CLI binary can't hold a stable TCC grant.
22 +/// Setting this routes model discovery and caching to the default
23 +/// `~/.cache/huggingface` location instead, so the prompt never appears.
24 +#[cfg(target_os = "macos")]
25 +const DISABLE_APP_GROUP_ENV: &str = "SIGIT_DISABLE_APP_GROUP";
26 +
27 /// point `HF_HOME` / `HF_HUB_CACHE` at the shared container. no-ops if
28 /// the user already set them.
29 pub fn setup_shared_model_cache() {
@@ -404,6 +415,24 @@ fn selected_model_file_path() -> Option<PathBuf> {
415 /// so it won't exist until the user has launched siGit desktop or another Onde app.
416 #[cfg(target_os = "macos")]
417 fn resolve_shared_container() -> Option<PathBuf> {
418 + // Honor the opt-out before touching the container at all — the access
419 + // itself is what triggers the macOS cross-app data privacy prompt.
420 + if std::env::var(DISABLE_APP_GROUP_ENV)
421 + .ok()
422 + .map(|value| {
423 + matches!(
424 + value.trim().to_ascii_lowercase().as_str(),
425 + "1" | "true" | "on" | "yes"
426 + )
427 + })
428 + .unwrap_or(false)
429 + {
430 + log::info!(
431 + "{DISABLE_APP_GROUP_ENV} set — skipping Onde App Group container, using default HF cache"
432 + );
433 + return None;
434 + }
435 +
436 let home = std::env::var("HOME").ok()?;
437 let container = PathBuf::from(home)
438 .join("Library")