claude_code_acp/session/
session.rs

1//! Session state and management
2//!
3//! Each session represents an active Claude conversation with its own
4//! ClaudeClient instance, usage tracking, and permission state.
5
6use std::collections::HashMap;
7use std::error::Error;
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::sync::OnceLock;
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::time::Instant;
13use tokio::sync::broadcast;
14
15use claude_code_agent_sdk::types::config::PermissionMode as SdkPermissionMode;
16use claude_code_agent_sdk::types::mcp::McpSdkServerConfig;
17use claude_code_agent_sdk::{
18    ClaudeAgentOptions, ClaudeClient, HookEvent, HookMatcher, McpServerConfig, McpServers,
19    SystemPrompt, SystemPromptPreset,
20};
21use sacp::JrConnectionCx;
22use sacp::link::AgentToClient;
23use sacp::schema::McpServer;
24use tokio::sync::RwLock;
25use tracing::instrument;
26
27use crate::converter::NotificationConverter;
28use crate::hooks::{HookCallbackRegistry, create_post_tool_use_hook, create_pre_tool_use_hook};
29use crate::mcp::AcpMcpServer;
30use crate::permissions::create_can_use_tool_callback;
31use crate::settings::{PermissionChecker, SettingsManager};
32use crate::terminal::TerminalClient;
33use crate::types::{AgentConfig, AgentError, NewSessionMeta, Result};
34
35use super::BackgroundProcessManager;
36use super::permission::{PermissionHandler, PermissionMode};
37use super::usage::UsageTracker;
38
39/// Get the list of tools that should be replaced by ACP MCP server tools.
40///
41/// Only tools that interact with the terminal or filesystem should be replaced:
42/// - Terminal tools: Bash, BashOutput, KillShell
43/// - File tools: Read, Write, Edit
44///
45/// Other tools like Glob, Grep, Task, etc. should remain as CLI built-in tools.
46fn get_acp_replacement_tools() -> Vec<&'static str> {
47    vec![
48        // Terminal tools - must be replaced to use ACP Terminal API
49        "Bash",
50        "BashOutput",
51        "KillShell",
52        // File tools - must be replaced for ACP file synchronization
53        "Read",
54        "Write",
55        "Edit",
56    ]
57}
58
59/// An active Claude session
60///
61/// Each session holds its own ClaudeClient instance and maintains
62/// independent state for usage tracking, permissions, and message conversion.
63pub struct Session {
64    /// Unique session identifier
65    pub session_id: String,
66    /// Working directory for this session
67    pub cwd: PathBuf,
68    /// The Claude client for this session
69    client: RwLock<ClaudeClient>,
70    /// Whether this session has been cancelled
71    cancelled: Arc<AtomicBool>,
72    /// Permission handler for tool execution (wrapped in Arc for can_use_tool callback)
73    permission: Arc<RwLock<PermissionHandler>>,
74    /// Token usage tracker
75    usage_tracker: UsageTracker,
76    /// Notification converter with tool use cache
77    converter: NotificationConverter,
78    /// Whether the client is connected
79    connected: AtomicBool,
80    /// Hook callback registry for PostToolUse callbacks
81    hook_callback_registry: Arc<HookCallbackRegistry>,
82    /// Permission checker for hooks
83    permission_checker: Arc<RwLock<PermissionChecker>>,
84    /// Shared permission mode (synced with permission handler and hooks)
85    permission_mode: Arc<RwLock<PermissionMode>>,
86    /// Current model ID for this session (set once during initialization)
87    current_model: OnceLock<String>,
88    /// ACP MCP server for tool execution with notifications
89    acp_mcp_server: Arc<AcpMcpServer>,
90    /// Background process manager
91    background_processes: Arc<BackgroundProcessManager>,
92    /// External MCP servers to connect (from client request)
93    /// Set once during session initialization via set_external_mcp_servers()
94    external_mcp_servers: OnceLock<Vec<McpServer>>,
95    /// Whether external MCP servers have been connected
96    external_mcp_connected: AtomicBool,
97    /// Connection context OnceLock for ACP requests (shared with hooks)
98    /// Used by pre_tool_use_hook for permission requests
99    connection_cx_lock: Arc<OnceLock<JrConnectionCx<AgentToClient>>>,
100    /// Cancel signal sender - used to notify when MCP cancellation is received
101    cancel_sender: broadcast::Sender<()>,
102}
103
104impl Session {
105    /// Create a new session and wrap in Arc
106    ///
107    /// Returns Arc<Self> because the can_use_tool callback needs Arc<Session>.
108    /// We use OnceLock to break the circular dependency between Session and callback.
109    ///
110    /// # Arguments
111    ///
112    /// * `session_id` - Unique identifier for this session
113    /// * `cwd` - Working directory
114    /// * `config` - Agent configuration from environment
115    /// * `meta` - Session metadata from the new session request
116    #[instrument(
117        name = "session_create",
118        skip(config, meta),
119        fields(
120            session_id = %session_id,
121            cwd = ?cwd,
122            has_meta = meta.is_some(),
123        )
124    )]
125    pub fn new(
126        session_id: String,
127        cwd: PathBuf,
128        config: &AgentConfig,
129        meta: Option<&NewSessionMeta>,
130    ) -> Result<Arc<Self>> {
131        let start_time = Instant::now();
132
133        tracing::info!(
134            session_id = %session_id,
135            cwd = ?cwd,
136            "Creating new session"
137        );
138
139        // Create hook callback registry
140        let hook_callback_registry = Arc::new(HookCallbackRegistry::new());
141
142        // Create permission checker for hooks
143        // Load settings from ~/.claude/settings.json, .claude/settings.json, etc.
144        let settings_manager = SettingsManager::new(&cwd)
145            .unwrap_or_else(|e| {
146                tracing::warn!("Failed to load settings manager from cwd: {}. Using default settings.", e);
147                // Fallback: try to load settings from home directory
148                match dirs::home_dir() {
149                    Some(home) => {
150                        tracing::info!("Attempting to load settings from home directory");
151                        SettingsManager::new(&home).unwrap_or_else(|e2| {
152                            tracing::error!("Failed to load settings from home directory: {}. Using minimal default settings.", e2);
153                            // Last resort: create a manager with minimal settings
154                            SettingsManager::new_with_settings(crate::settings::Settings::default(), "/")
155                        })
156                    }
157                    None => {
158                        tracing::error!("No home directory found. Using minimal default settings.");
159                        SettingsManager::new_with_settings(crate::settings::Settings::default(), "/")
160                    }
161                }
162            });
163        // Create shared permission checker that will be used by both hook and permission handler
164        // This ensures that runtime rule changes (e.g., "Always Allow") are reflected in both places
165        let permission_checker = Arc::new(RwLock::new(PermissionChecker::new(
166            settings_manager.settings().clone(),
167            &cwd,
168        )));
169
170        // Create shared permission mode that can be updated at runtime
171        // This is shared between the hook and the permission handler
172        // Default to BypassPermissions to allow all tools without permission checks
173        let permission_mode = Arc::new(RwLock::new(PermissionMode::BypassPermissions));
174
175        // Create shared connection_cx_lock for hook permission requests
176        let connection_cx_lock: Arc<OnceLock<JrConnectionCx<AgentToClient>>> =
177            Arc::new(OnceLock::new());
178
179        // Create hooks with shared permission checker and mode
180        let pre_tool_use_hook = create_pre_tool_use_hook(
181            connection_cx_lock.clone(),
182            session_id.clone(),
183            Some(permission_checker.clone()),
184            permission_mode.clone(),
185        );
186        let post_tool_use_hook = create_post_tool_use_hook(hook_callback_registry.clone());
187
188        // Build hooks map
189        let mut hooks_map: HashMap<HookEvent, Vec<HookMatcher>> = HashMap::new();
190        hooks_map.insert(
191            HookEvent::PreToolUse,
192            vec![
193                HookMatcher::builder()
194                    .hooks(vec![pre_tool_use_hook])
195                    .build(),
196            ],
197        );
198        hooks_map.insert(
199            HookEvent::PostToolUse,
200            vec![
201                HookMatcher::builder()
202                    .hooks(vec![post_tool_use_hook])
203                    .build(),
204            ],
205        );
206
207        tracing::info!(
208            session_id = %session_id,
209            hooks_count = 2,
210            "Hooks configured: PreToolUse, PostToolUse"
211        );
212
213        // Create PermissionHandler with shared PermissionChecker
214        // This ensures both pre_tool_use_hook and can_use_tool callback use the same rules
215        // PermissionHandler defaults to BypassPermissions mode (allow all tools)
216        let permission_handler = Arc::new(RwLock::new(PermissionHandler::with_checker(
217            permission_checker.clone(),
218        )));
219
220        // Create OnceLock for storing Arc<Session> (needed for callback)
221        let session_lock: Arc<OnceLock<Arc<Session>>> = Arc::new(OnceLock::new());
222
223        // Create ACP MCP server
224        let acp_mcp_server = Arc::new(AcpMcpServer::new("acp", env!("CARGO_PKG_VERSION")));
225
226        // Create background process manager
227        let background_processes = Arc::new(BackgroundProcessManager::new());
228
229        // Build MCP servers with our ACP server
230        let mut mcp_servers_dict = HashMap::new();
231        mcp_servers_dict.insert(
232            "acp".to_string(),
233            McpServerConfig::Sdk(McpSdkServerConfig {
234                name: "acp".to_string(),
235                instance: acp_mcp_server.clone(),
236            }),
237        );
238
239        tracing::info!(
240            session_id = %session_id,
241            mcp_server_count = mcp_servers_dict.len(),
242            "MCP servers configured"
243        );
244
245        // Create can_use_tool callback with OnceLock<Session>
246        let can_use_tool_callback = create_can_use_tool_callback(session_lock.clone());
247
248        // Build ClaudeAgentOptions
249        let mut options = ClaudeAgentOptions::builder()
250            .cwd(cwd.clone())
251            .hooks(hooks_map)
252            .mcp_servers(McpServers::Dict(mcp_servers_dict))
253            .can_use_tool(can_use_tool_callback)
254            .permission_mode(SdkPermissionMode::BypassPermissions)
255            .build();
256
257        // Debug: Verify can_use_tool is set
258        tracing::info!(
259            session_id = %session_id,
260            has_can_use_tool = options.can_use_tool.is_some(),
261            has_hooks = options.hooks.is_some(),
262            "Options configured after build"
263        );
264
265        // Verify mcp_servers is set correctly
266        match &options.mcp_servers {
267            McpServers::Dict(dict) => {
268                tracing::debug!(
269                    session_id = %session_id,
270                    servers = ?dict.keys().collect::<Vec<_>>(),
271                    "MCP servers registered"
272                );
273            }
274            McpServers::Empty => {
275                tracing::warn!(
276                    session_id = %session_id,
277                    "MCP servers is Empty - this is unexpected!"
278                );
279            }
280            McpServers::Path(p) => {
281                tracing::warn!(
282                    session_id = %session_id,
283                    path = ?p,
284                    "MCP servers is Path - this is unexpected!"
285                );
286            }
287        }
288
289        // Configure ACP tools to replace CLI built-in tools
290        // This disables CLI's built-in tools and enables our MCP tools with mcp__acp__ prefix
291        let acp_tools = get_acp_replacement_tools();
292        options.use_acp_tools(&acp_tools);
293
294        // Enable streaming to receive incremental content updates
295        // This allows SDK to send StreamEvent messages with content_block_delta
296        options.include_partial_messages = true;
297
298        tracing::debug!(
299            session_id = %session_id,
300            acp_tools = ?acp_tools,
301            disallowed_tools = ?options.disallowed_tools,
302            allowed_tools = ?options.allowed_tools,
303            "ACP tools configured"
304        );
305
306        // Apply config from environment
307        config.apply_to_options(&mut options);
308
309        tracing::debug!(
310            session_id = %session_id,
311            model = ?options.model,
312            fallback_model = ?options.fallback_model,
313            max_thinking_tokens = ?options.max_thinking_tokens,
314            base_url = ?config.base_url,
315            api_key = ?config.masked_api_key(),
316            env_vars_count = options.env.len(),
317            "Agent config applied"
318        );
319
320        // Apply meta options if provided
321        if let Some(meta) = meta {
322            // Set system prompt: replace takes priority over append
323            if let Some(replace) = meta.get_system_prompt_replace() {
324                // Complete replacement of system prompt
325                options.system_prompt = Some(SystemPrompt::Text(replace.to_string()));
326                tracing::info!(
327                    session_id = %session_id,
328                    prompt_len = replace.len(),
329                    "Using custom system prompt from meta (replace)"
330                );
331            } else if let Some(append) = meta.get_system_prompt_append() {
332                // Append to default claude_code preset
333                let preset = SystemPromptPreset::with_append("claude_code", append);
334                options.system_prompt = Some(SystemPrompt::Preset(preset));
335                tracing::info!(
336                    session_id = %session_id,
337                    append_len = append.len(),
338                    "Appending to system prompt from meta"
339                );
340            }
341
342            // Set resume session if provided
343            if let Some(resume_id) = meta.get_resume_session_id() {
344                options.resume = Some(resume_id.to_string());
345                tracing::info!(
346                    session_id = %session_id,
347                    resume_session_id = %resume_id,
348                    "Resuming from previous session"
349                );
350            }
351
352            // Set max thinking tokens if provided (enables extended thinking mode)
353            if let Some(tokens) = meta.get_max_thinking_tokens() {
354                options.max_thinking_tokens = Some(tokens);
355                tracing::info!(
356                    session_id = %session_id,
357                    max_thinking_tokens = tokens,
358                    "Extended thinking mode enabled via meta"
359                );
360            }
361        }
362
363        // Create the client
364        let client = ClaudeClient::new(options);
365
366        let elapsed = start_time.elapsed();
367        tracing::info!(
368            session_id = %session_id,
369            elapsed_ms = elapsed.as_millis(),
370            "Session created successfully"
371        );
372
373        // Clone cwd for converter before moving cwd into the struct
374        let cwd_for_converter = cwd.clone();
375
376        // Build the Session struct
377        let session = Self {
378            session_id,
379            cwd,
380            client: RwLock::new(client),
381            cancelled: Arc::new(AtomicBool::new(false)),
382            permission: permission_handler,
383            usage_tracker: UsageTracker::new(),
384            converter: NotificationConverter::with_cwd(cwd_for_converter),
385            connected: AtomicBool::new(false),
386            hook_callback_registry,
387            permission_checker,
388            permission_mode,
389            current_model: OnceLock::new(),
390            acp_mcp_server,
391            background_processes,
392            external_mcp_servers: OnceLock::new(),
393            external_mcp_connected: AtomicBool::new(false),
394            connection_cx_lock,
395            cancel_sender: broadcast::channel(1).0,
396        };
397
398        // Wrap in Arc
399        let session_arc = Arc::new(session);
400
401        // Set the OnceLock so the callback can access the Session
402        drop(session_lock.set(session_arc.clone()));
403
404        Ok(session_arc)
405    }
406
407    /// Set external MCP servers to connect
408    ///
409    /// # Arguments
410    ///
411    /// * `servers` - List of MCP servers from the client request
412    pub fn set_external_mcp_servers(&self, servers: Vec<McpServer>) {
413        if !servers.is_empty() {
414            tracing::info!(
415                session_id = %self.session_id,
416                server_count = servers.len(),
417                "Storing external MCP servers for later connection"
418            );
419
420            for server in &servers {
421                match server {
422                    McpServer::Stdio(s) => {
423                        tracing::debug!(
424                            session_id = %self.session_id,
425                            server_name = %s.name,
426                            command = ?s.command,
427                            args = ?s.args,
428                            "External MCP server (stdio)"
429                        );
430                    }
431                    McpServer::Http(s) => {
432                        tracing::debug!(
433                            session_id = %self.session_id,
434                            server_name = %s.name,
435                            url = %s.url,
436                            "External MCP server (http)"
437                        );
438                    }
439                    McpServer::Sse(s) => {
440                        tracing::debug!(
441                            session_id = %self.session_id,
442                            server_name = %s.name,
443                            url = %s.url,
444                            "External MCP server (sse)"
445                        );
446                    }
447                    _ => {
448                        tracing::debug!(
449                            session_id = %self.session_id,
450                            "External MCP server (unknown type)"
451                        );
452                    }
453                }
454            }
455        }
456
457        // Only set if not already set (configure_acp_server may be called multiple times)
458        if self.external_mcp_servers.get().is_none() {
459            drop(self.external_mcp_servers.set(servers));
460        }
461    }
462
463    /// Set the connection context for ACP requests
464    ///
465    /// This is called once during handle_prompt to enable permission requests.
466    /// The OnceLock ensures it's only set once even if called multiple times.
467    pub fn set_connection_cx(&self, cx: JrConnectionCx<AgentToClient>) {
468        if self.connection_cx_lock.get().is_none() {
469            drop(self.connection_cx_lock.set(cx));
470        }
471    }
472
473    /// Get the connection context if available
474    ///
475    /// Returns None if called before handle_prompt sets the connection.
476    pub fn get_connection_cx(&self) -> Option<&JrConnectionCx<AgentToClient>> {
477        self.connection_cx_lock.get()
478    }
479
480    /// Connect to external MCP servers
481    ///
482    /// This should be called before the first prompt to ensure all
483    /// external MCP tools are available.
484    #[instrument(
485        name = "connect_external_mcp_servers",
486        skip(self),
487        fields(session_id = %self.session_id)
488    )]
489    pub async fn connect_external_mcp_servers(&self) -> Result<()> {
490        // Only connect once
491        if self.external_mcp_connected.load(Ordering::SeqCst) {
492            tracing::debug!(
493                session_id = %self.session_id,
494                "External MCP servers already connected"
495            );
496            return Ok(());
497        }
498
499        // Get servers (no lock needed with OnceLock)
500        let servers = match self.external_mcp_servers.get() {
501            Some(s) => s,
502            None => {
503                tracing::debug!(
504                    session_id = %self.session_id,
505                    "No external MCP servers to connect"
506                );
507                self.external_mcp_connected.store(true, Ordering::SeqCst);
508                return Ok(());
509            }
510        };
511
512        // Clone server list to avoid holding reference
513        let servers_vec: Vec<_> = servers.iter().cloned().collect();
514
515        let server_count = servers_vec.len();
516        let start_time = Instant::now();
517
518        tracing::info!(
519            session_id = %self.session_id,
520            server_count = server_count,
521            "Connecting to external MCP servers"
522        );
523
524        let external_manager = self.acp_mcp_server.mcp_server().external_manager();
525
526        let mut success_count = 0;
527        let mut error_count = 0;
528
529        for server in servers_vec.iter() {
530            match server {
531                McpServer::Stdio(s) => {
532                    let server_start = Instant::now();
533
534                    tracing::info!(
535                        session_id = %self.session_id,
536                        server_name = %s.name,
537                        command = ?s.command,
538                        args = ?s.args,
539                        "Connecting to external MCP server (stdio)"
540                    );
541
542                    // Convert env variables
543                    let env: Option<HashMap<String, String>> = if s.env.is_empty() {
544                        None
545                    } else {
546                        Some(
547                            s.env
548                                .iter()
549                                .map(|e| (e.name.clone(), e.value.clone()))
550                                .collect(),
551                        )
552                    };
553
554                    match external_manager
555                        .connect(
556                            s.name.clone(),
557                            s.command.to_string_lossy().as_ref(),
558                            &s.args,
559                            env.as_ref(),
560                            Some(self.cwd.as_path()),
561                        )
562                        .await
563                    {
564                        Ok(_) => {
565                            success_count += 1;
566                            let elapsed = server_start.elapsed();
567                            tracing::info!(
568                                session_id = %self.session_id,
569                                server_name = %s.name,
570                                elapsed_ms = elapsed.as_millis(),
571                                "Successfully connected to external MCP server"
572                            );
573                        }
574                        Err(e) => {
575                            error_count += 1;
576                            let elapsed = server_start.elapsed();
577                            tracing::error!(
578                                session_id = %self.session_id,
579                                server_name = %s.name,
580                                error = %e,
581                                elapsed_ms = elapsed.as_millis(),
582                                "Failed to connect to external MCP server"
583                            );
584                        }
585                    }
586                }
587                McpServer::Http(s) => {
588                    tracing::warn!(
589                        session_id = %self.session_id,
590                        server_name = %s.name,
591                        url = %s.url,
592                        "HTTP MCP servers not yet supported"
593                    );
594                }
595                McpServer::Sse(s) => {
596                    tracing::warn!(
597                        session_id = %self.session_id,
598                        server_name = %s.name,
599                        url = %s.url,
600                        "SSE MCP servers not yet supported"
601                    );
602                }
603                _ => {
604                    tracing::warn!(
605                        session_id = %self.session_id,
606                        "Unknown MCP server type - not supported"
607                    );
608                }
609            }
610        }
611
612        let total_elapsed = start_time.elapsed();
613        tracing::info!(
614            session_id = %self.session_id,
615            total_servers = server_count,
616            success_count = success_count,
617            error_count = error_count,
618            total_elapsed_ms = total_elapsed.as_millis(),
619            "Finished connecting external MCP servers"
620        );
621
622        self.external_mcp_connected.store(true, Ordering::SeqCst);
623        Ok(())
624    }
625
626    /// Connect to Claude CLI
627    ///
628    /// This spawns the Claude CLI process and establishes JSON-RPC communication.
629    #[instrument(
630        name = "session_connect",
631        skip(self),
632        fields(session_id = %self.session_id)
633    )]
634    pub async fn connect(&self) -> Result<()> {
635        if self.connected.load(Ordering::SeqCst) {
636            tracing::debug!(
637                session_id = %self.session_id,
638                "Already connected to Claude CLI"
639            );
640            return Ok(());
641        }
642
643        let start_time = Instant::now();
644        tracing::info!(
645            session_id = %self.session_id,
646            cwd = ?self.cwd,
647            "Connecting to Claude CLI..."
648        );
649
650        let mut client = self.client.write().await;
651        client.connect().await.map_err(|e| {
652            let agent_error = AgentError::from(e);
653            tracing::error!(
654                session_id = %self.session_id,
655                error = %agent_error,
656                error_code = ?agent_error.error_code(),
657                is_retryable = %agent_error.is_retryable(),
658                error_chain = ?agent_error.source(),
659                "Failed to connect to Claude CLI"
660            );
661            agent_error
662        })?;
663
664        self.connected.store(true, Ordering::SeqCst);
665
666        let elapsed = start_time.elapsed();
667        tracing::info!(
668            session_id = %self.session_id,
669            elapsed_ms = elapsed.as_millis(),
670            "Successfully connected to Claude CLI"
671        );
672
673        Ok(())
674    }
675
676    /// Disconnect from Claude CLI
677    #[instrument(
678        name = "session_disconnect",
679        skip(self),
680        fields(session_id = %self.session_id)
681    )]
682    pub async fn disconnect(&self) -> Result<()> {
683        if !self.connected.load(Ordering::SeqCst) {
684            tracing::debug!(
685                session_id = %self.session_id,
686                "Already disconnected from Claude CLI"
687            );
688            return Ok(());
689        }
690
691        let start_time = Instant::now();
692        tracing::info!(
693            session_id = %self.session_id,
694            "Disconnecting from Claude CLI..."
695        );
696
697        let mut client = self.client.write().await;
698        client.disconnect().await.map_err(|e| {
699            let agent_error = AgentError::from(e);
700            tracing::error!(
701                session_id = %self.session_id,
702                error = %agent_error,
703                error_code = ?agent_error.error_code(),
704                is_retryable = %agent_error.is_retryable(),
705                error_chain = ?agent_error.source(),
706                "Failed to disconnect from Claude CLI"
707            );
708            agent_error
709        })?;
710
711        self.connected.store(false, Ordering::SeqCst);
712
713        let elapsed = start_time.elapsed();
714        tracing::info!(
715            session_id = %self.session_id,
716            elapsed_ms = elapsed.as_millis(),
717            "Disconnected from Claude CLI"
718        );
719
720        Ok(())
721    }
722
723    /// Check if the session is connected
724    pub fn is_connected(&self) -> bool {
725        self.connected.load(Ordering::SeqCst)
726    }
727
728    /// Get read access to the client
729    pub async fn client(&self) -> tokio::sync::RwLockReadGuard<'_, ClaudeClient> {
730        self.client.read().await
731    }
732
733    /// Get write access to the client
734    pub async fn client_mut(&self) -> tokio::sync::RwLockWriteGuard<'_, ClaudeClient> {
735        self.client.write().await
736    }
737
738    /// Get a receiver for cancel signals
739    ///
740    /// This can be used to listen for MCP cancellation notifications.
741    /// When a cancel notification is received, a signal is sent through the channel.
742    pub fn cancel_receiver(&self) -> broadcast::Receiver<()> {
743        self.cancel_sender.subscribe()
744    }
745
746    /// Check if the session has been cancelled
747    pub fn is_cancelled(&self) -> bool {
748        self.cancelled.load(Ordering::SeqCst)
749    }
750
751    /// Cancel this session and interrupt the Claude CLI
752    #[instrument(
753        name = "session_cancel",
754        skip(self),
755        fields(session_id = %self.session_id)
756    )]
757    pub async fn cancel(&self) {
758        tracing::info!(
759            session_id = %self.session_id,
760            "Cancelling session and sending interrupt signal"
761        );
762
763        self.cancelled.store(true, Ordering::SeqCst);
764
765        // Send interrupt signal to Claude CLI to stop current operation
766        if let Ok(client) = self.client.try_read() {
767            if let Err(e) = client.interrupt().await {
768                tracing::warn!(
769                    session_id = %self.session_id,
770                    error = %e,
771                    "Failed to send interrupt signal to Claude CLI"
772                );
773            } else {
774                tracing::info!(
775                    session_id = %self.session_id,
776                    "Interrupt signal sent to Claude CLI"
777                );
778            }
779        } else {
780            tracing::warn!(
781                session_id = %self.session_id,
782                "Could not acquire client lock for interrupt"
783            );
784        }
785    }
786
787    /// Reset the cancelled flag
788    pub fn reset_cancelled(&self) {
789        self.cancelled.store(false, Ordering::SeqCst);
790    }
791
792    /// Get the permission handler
793    pub async fn permission(&self) -> tokio::sync::RwLockReadGuard<'_, PermissionHandler> {
794        self.permission.read().await
795    }
796
797    /// Get the current permission mode
798    pub async fn permission_mode(&self) -> PermissionMode {
799        self.permission.read().await.mode()
800    }
801
802    /// Set the permission mode
803    ///
804    /// Updates both the PermissionHandler and the shared permission mode
805    /// used by the pre_tool_use_hook.
806    pub async fn set_permission_mode(&self, mode: PermissionMode) {
807        // Update the permission handler
808        self.permission.write().await.set_mode(mode);
809        // Also update the shared permission mode used by the hook
810        *self.permission_mode.write().await = mode;
811
812        tracing::info!(
813            session_id = %self.session_id,
814            mode = mode.as_str(),
815            "Permission mode updated"
816        );
817    }
818
819    /// Add an allow rule for a tool
820    ///
821    /// This is called when user selects "Always Allow" in permission prompt.
822    pub async fn add_permission_allow_rule(&self, tool_name: &str) {
823        self.permission.read().await.add_allow_rule(tool_name).await;
824    }
825
826    /// Get the current model ID
827    ///
828    /// Note: Not yet used because sacp SDK does not support SetSessionModel.
829    #[allow(dead_code)]
830    pub fn current_model(&self) -> Option<String> {
831        self.current_model.get().cloned()
832    }
833
834    /// Set the model for this session
835    ///
836    /// Note: Not yet used because sacp SDK does not support SetSessionModel.
837    #[allow(dead_code)]
838    pub fn set_model(&self, model_id: String) {
839        // Only set if not already set (may be called multiple times)
840        if self.current_model.get().is_none() {
841            drop(self.current_model.set(model_id));
842        }
843    }
844
845    /// Get the usage tracker
846    pub fn usage_tracker(&self) -> &UsageTracker {
847        &self.usage_tracker
848    }
849
850    /// Get the notification converter
851    pub fn converter(&self) -> &NotificationConverter {
852        &self.converter
853    }
854
855    /// Get the hook callback registry
856    pub fn hook_callback_registry(&self) -> &Arc<HookCallbackRegistry> {
857        &self.hook_callback_registry
858    }
859
860    /// Get the permission checker
861    pub fn permission_checker(&self) -> &Arc<RwLock<PermissionChecker>> {
862        &self.permission_checker
863    }
864
865    /// Register a PostToolUse callback for a tool use
866    pub fn register_post_tool_use_callback(
867        &self,
868        tool_use_id: String,
869        callback: crate::hooks::PostToolUseCallback,
870    ) {
871        self.hook_callback_registry
872            .register_post_tool_use(tool_use_id, callback);
873    }
874
875    /// Get the ACP MCP server
876    pub fn acp_mcp_server(&self) -> &Arc<AcpMcpServer> {
877        &self.acp_mcp_server
878    }
879
880    /// Get the background process manager
881    pub fn background_processes(&self) -> &Arc<BackgroundProcessManager> {
882        &self.background_processes
883    }
884
885    /// Configure the ACP MCP server with connection and terminal client
886    ///
887    /// This should be called after creating the session to enable Terminal API
888    /// integration for Bash commands.
889    pub async fn configure_acp_server(
890        &self,
891        connection_cx: JrConnectionCx<AgentToClient>,
892        terminal_client: Option<Arc<TerminalClient>>,
893    ) {
894        self.acp_mcp_server.set_session_id(&self.session_id);
895        self.acp_mcp_server.set_connection(connection_cx);
896        self.acp_mcp_server.set_cwd(self.cwd.clone()).await;
897        self.acp_mcp_server
898            .set_background_processes(self.background_processes.clone());
899        self.acp_mcp_server
900            .set_permission_checker(self.permission_checker.clone());
901
902        if let Some(client) = terminal_client {
903            self.acp_mcp_server.set_terminal_client(client);
904        }
905
906        // Set up cancel callback to interrupt Claude CLI when MCP cancellation is received
907        let cancelled_flag = self.cancelled.clone();
908        let session_id = self.session_id.clone();
909        let cancel_sender = self.cancel_sender.clone();
910
911        self.acp_mcp_server
912            .set_cancel_callback(move || {
913                tracing::info!(
914                    session_id = %session_id,
915                    "MCP cancel callback invoked, sending cancel signal"
916                );
917                cancelled_flag.store(true, Ordering::SeqCst);
918                // Send cancel signal through the channel
919                let _ = cancel_sender.send(());
920            })
921            .await;
922    }
923}
924
925#[allow(clippy::missing_fields_in_debug)]
926impl std::fmt::Debug for Session {
927    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
928        f.debug_struct("Session")
929            .field("session_id", &self.session_id)
930            .field("cwd", &self.cwd)
931            .field("cancelled", &self.cancelled.load(Ordering::Relaxed))
932            .field("connected", &self.connected.load(Ordering::Relaxed))
933            .finish()
934    }
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940
941    fn test_config() -> AgentConfig {
942        AgentConfig {
943            base_url: None,
944            api_key: None,
945            model: None,
946            small_fast_model: None,
947            max_thinking_tokens: None,
948        }
949    }
950
951    #[test]
952    fn test_session_new() {
953        let session = Session::new(
954            "test-session-1".to_string(),
955            PathBuf::from("/tmp"),
956            &test_config(),
957            None,
958        )
959        .unwrap();
960
961        assert_eq!(session.session_id, "test-session-1");
962        assert_eq!(session.cwd, PathBuf::from("/tmp"));
963        assert!(!session.is_cancelled());
964        assert!(!session.is_connected());
965    }
966
967    #[tokio::test]
968    async fn test_session_cancel() {
969        let session = Session::new(
970            "test-session-2".to_string(),
971            PathBuf::from("/tmp"),
972            &test_config(),
973            None,
974        )
975        .unwrap();
976
977        assert!(!session.is_cancelled());
978        session.cancel().await;
979        assert!(session.is_cancelled());
980        session.reset_cancelled();
981        assert!(!session.is_cancelled());
982    }
983
984    #[tokio::test]
985    async fn test_session_permission_mode() {
986        let session = Session::new(
987            "test-session-3".to_string(),
988            PathBuf::from("/tmp"),
989            &test_config(),
990            None,
991        )
992        .unwrap();
993
994        assert_eq!(session.permission_mode().await, PermissionMode::BypassPermissions);
995        session
996            .set_permission_mode(PermissionMode::AcceptEdits)
997            .await;
998        assert_eq!(session.permission_mode().await, PermissionMode::AcceptEdits);
999    }
1000}