Skip to main content

bamboo_agent/server/handlers/
agent_api.rs

1use crate::agent::core::AgentEvent;
2use crate::server::app_state::{AgentStatus, AppState};
3use crate::server::error::AppError;
4use actix_web::http::header;
5use actix_web::{web, HttpRequest, HttpResponse, Responder};
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8use uuid::Uuid;
9
10// ============================================================================
11// Data Types
12// ============================================================================
13
14/// Represents a Claude Code project with its metadata and sessions
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Project {
17    /// Unique project identifier
18    pub id: String,
19    /// File system path to the project
20    pub path: String,
21    /// List of session IDs associated with this project
22    pub sessions: Vec<String>,
23    /// Unix timestamp of project creation
24    pub created_at: u64,
25    /// Unix timestamp of most recent session (if any)
26    pub most_recent_session: Option<u64>,
27}
28
29/// Represents a Claude Code conversation session
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Session {
32    /// Unique session identifier
33    pub id: String,
34    /// ID of the parent project
35    pub project_id: String,
36    /// File system path to the project
37    pub project_path: String,
38    /// Optional TODO data for the session
39    pub todo_data: Option<serde_json::Value>,
40    /// Unix timestamp of session creation
41    pub created_at: u64,
42    /// First message content (for preview)
43    pub first_message: Option<String>,
44    /// ISO timestamp of first message
45    pub message_timestamp: Option<String>,
46}
47
48/// Claude settings configuration wrapper
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ClaudeSettings {
51    /// Settings data as JSON
52    #[serde(flatten)]
53    pub data: serde_json::Value,
54}
55
56impl Default for ClaudeSettings {
57    fn default() -> Self {
58        Self {
59            data: serde_json::json!({}),
60        }
61    }
62}
63
64// ============================================================================
65// Request Types
66// ============================================================================
67
68/// Request body for creating a new project
69#[derive(Debug, Deserialize)]
70pub struct CreateProjectRequest {
71    /// File system path to the project directory
72    pub path: String,
73}
74
75/// Request body for saving Claude settings
76#[derive(Debug, Deserialize)]
77pub struct SaveSettingsRequest {
78    /// Settings data as JSON
79    pub settings: serde_json::Value,
80}
81
82/// Request body for saving system prompt
83#[derive(Debug, Deserialize)]
84pub struct SaveSystemPromptRequest {
85    /// System prompt content (markdown)
86    pub content: String,
87}
88
89/// Request body for executing Claude code
90#[derive(Debug, Deserialize)]
91pub struct ExecuteRequest {
92    /// Project directory path
93    pub project_path: String,
94    /// User prompt to execute
95    pub prompt: String,
96    /// Optional session ID to resume
97    pub session_id: Option<String>,
98    /// Optional override for Claude's Anthropic base URL.
99    ///
100    /// If omitted, Bamboo defaults to `http://127.0.0.1:{port}/anthropic` so the
101    /// Claude Code CLI talks to Bamboo's embedded Anthropic-compatible API.
102    pub anthropic_base_url: Option<String>,
103    /// Optional JSON schema for structured output (passed to `claude --json-schema`).
104    pub json_schema: Option<String>,
105    /// If omitted, defaults to `true` (skip Claude's user confirmation prompts).
106    pub dangerously_skip_permissions: Option<bool>,
107    /// If omitted, defaults to `true` (better streaming UX).
108    pub include_partial_messages: Option<bool>,
109}
110
111/// Request body for canceling execution
112#[derive(Debug, Deserialize)]
113pub struct CancelRequest {
114    /// Session ID to cancel
115    pub session_id: String,
116}
117
118// ============================================================================
119// Helper Functions
120// ============================================================================
121
122/// Gets the Claude configuration directory (~/.claude)
123///
124/// Creates the directory if it doesn't exist.
125fn get_claude_dir() -> Result<PathBuf, AppError> {
126    let dir = dirs::home_dir()
127        .ok_or_else(|| AppError::InternalError(anyhow::anyhow!("Could not find home directory")))?
128        .join(".claude");
129
130    // Create directory if it doesn't exist
131    if !dir.exists() {
132        std::fs::create_dir_all(&dir).map_err(|e| {
133            AppError::InternalError(anyhow::anyhow!(
134                "Could not create ~/.claude directory: {}",
135                e
136            ))
137        })?;
138    }
139
140    dir.canonicalize().map_err(|e| {
141        AppError::InternalError(anyhow::anyhow!(
142            "Could not canonicalize ~/.claude directory: {}",
143            e
144        ))
145    })
146}
147
148// ============================================================================
149// HTTP Handlers
150// ============================================================================
151
152/// Lists all Claude Code projects
153///
154/// # HTTP Route
155/// `GET /agent/projects`
156///
157/// # Response Format
158/// Returns an array of [`Project`] objects:
159/// ```json
160/// [
161///   {
162///     "id": "-Users-me-projects-myproject",
163///     "path": "/Users/me/projects/myproject",
164///     "sessions": ["session-1", "session-2"],
165///     "created_at": 1234567890,
166///     "most_recent_session": 1234567890
167///   }
168/// ]
169/// ```
170///
171/// # Response Status
172/// - `200 OK`: Successfully retrieved project list
173///
174/// # Example
175/// ```bash
176/// curl http://localhost:3000/agent/projects
177/// ```
178pub async fn list_projects() -> Result<HttpResponse, AppError> {
179    let claude_dir = get_claude_dir()?;
180    let mut projects = Vec::new();
181
182    if let Ok(entries) = std::fs::read_dir(&claude_dir) {
183        for entry in entries.flatten() {
184            let path = entry.path();
185            if path.is_dir() && path.join(".project_path").exists() {
186                let project_id = path
187                    .file_name()
188                    .and_then(|n| n.to_str())
189                    .unwrap_or("")
190                    .to_string();
191
192                let project_path = std::fs::read_to_string(path.join(".project_path"))
193                    .unwrap_or_default()
194                    .trim()
195                    .to_string();
196
197                let sessions = std::fs::read_dir(&path)
198                    .map(|entries| {
199                        entries
200                            .flatten()
201                            .filter(|e| {
202                                e.path().extension().and_then(|ext| ext.to_str()) == Some("jsonl")
203                            })
204                            .filter_map(|e| e.file_name().into_string().ok())
205                            .collect()
206                    })
207                    .unwrap_or_default();
208
209                let metadata = std::fs::metadata(&path)
210                    .ok()
211                    .and_then(|m| m.created().ok())
212                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
213                    .map(|d| d.as_secs())
214                    .unwrap_or(0);
215
216                projects.push(Project {
217                    id: project_id,
218                    path: project_path,
219                    sessions,
220                    created_at: metadata,
221                    most_recent_session: None,
222                });
223            }
224        }
225    }
226
227    Ok(HttpResponse::Ok().json(projects))
228}
229
230/// Creates a new Claude Code project
231///
232/// # HTTP Route
233/// `POST /agent/projects`
234///
235/// # Request Body
236/// ```json
237/// {
238///   "path": "/Users/me/projects/myproject"
239/// }
240/// ```
241///
242/// # Response Format
243/// Returns the created [`Project`] object:
244/// ```json
245/// {
246///   "id": "-Users-me-projects-myproject",
247///   "path": "/Users/me/projects/myproject",
248///   "sessions": [],
249///   "created_at": 1234567890,
250///   "most_recent_session": null
251/// }
252/// ```
253///
254/// # Response Status
255/// - `200 OK`: Project created successfully
256/// - `500 Internal Server Error`: Path doesn't exist or creation failed
257///
258/// # Example
259/// ```bash
260/// curl -X POST http://localhost:3000/agent/projects \
261///   -H "Content-Type: application/json" \
262///   -d '{"path": "/Users/me/projects/myproject"}'
263/// ```
264pub async fn create_project(
265    req: web::Json<CreateProjectRequest>,
266) -> Result<HttpResponse, AppError> {
267    let claude_dir = get_claude_dir()?;
268    let path = PathBuf::from(&req.path);
269
270    if !path.exists() || !path.is_dir() {
271        return Err(AppError::InternalError(anyhow::anyhow!(
272            "Path does not exist or is not a directory: {}",
273            req.path
274        )));
275    }
276
277    // Create project ID from path
278    let canonical = path.canonicalize().map_err(|e| {
279        AppError::InternalError(anyhow::anyhow!("Failed to canonicalize path: {}", e))
280    })?;
281    let project_id = canonical.to_string_lossy().replace(['/', '\\'], "-");
282
283    let project_dir = claude_dir.join(&project_id);
284    std::fs::create_dir_all(&project_dir).map_err(|e| {
285        AppError::InternalError(anyhow::anyhow!("Failed to create project dir: {}", e))
286    })?;
287
288    // Write project path file
289    std::fs::write(
290        project_dir.join(".project_path"),
291        canonical.to_string_lossy().as_bytes(),
292    )
293    .map_err(|e| AppError::InternalError(anyhow::anyhow!("Failed to write project path: {}", e)))?;
294
295    let project = Project {
296        id: project_id,
297        path: req.path.clone(),
298        sessions: Vec::new(),
299        created_at: std::time::SystemTime::now()
300            .duration_since(std::time::UNIX_EPOCH)
301            .unwrap_or_default()
302            .as_secs(),
303        most_recent_session: None,
304    };
305
306    Ok(HttpResponse::Ok().json(project))
307}
308
309/// Gets all sessions for a specific project
310///
311/// # HTTP Route
312/// `GET /agent/projects/{project_id}/sessions`
313///
314/// # Path Parameters
315/// - `project_id`: Unique project identifier
316///
317/// # Response Format
318/// Returns an array of [`Session`] objects:
319/// ```json
320/// [
321///   {
322///     "id": "session-123",
323///     "project_id": "-Users-me-projects-myproject",
324///     "project_path": "/Users/me/projects/myproject",
325///     "todo_data": null,
326///     "created_at": 1234567890,
327///     "first_message": null,
328///     "message_timestamp": null
329///   }
330/// ]
331/// ```
332///
333/// # Response Status
334/// - `200 OK`: Successfully retrieved sessions
335/// - `500 Internal Server Error`: Project not found
336///
337/// # Example
338/// ```bash
339/// curl http://localhost:3000/agent/projects/-Users-me-projects-myproject/sessions
340/// ```
341pub async fn get_project_sessions(path: web::Path<String>) -> Result<HttpResponse, AppError> {
342    let claude_dir = get_claude_dir()?;
343    let project_id = path.into_inner();
344    let project_dir = claude_dir.join(&project_id);
345
346    if !project_dir.exists() {
347        return Err(AppError::InternalError(anyhow::anyhow!(
348            "Project not found"
349        )));
350    }
351
352    let project_path = std::fs::read_to_string(project_dir.join(".project_path"))
353        .unwrap_or_default()
354        .trim()
355        .to_string();
356
357    let mut sessions = Vec::new();
358
359    if let Ok(entries) = std::fs::read_dir(&project_dir) {
360        for entry in entries.flatten() {
361            let path = entry.path();
362            if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
363                let session_id = path
364                    .file_stem()
365                    .and_then(|n| n.to_str())
366                    .unwrap_or("")
367                    .to_string();
368
369                let metadata = std::fs::metadata(&path)
370                    .ok()
371                    .and_then(|m| m.created().ok())
372                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
373                    .map(|d| d.as_secs())
374                    .unwrap_or(0);
375
376                sessions.push(Session {
377                    id: session_id,
378                    project_id: project_id.clone(),
379                    project_path: project_path.clone(),
380                    todo_data: None,
381                    created_at: metadata,
382                    first_message: None,
383                    message_timestamp: None,
384                });
385            }
386        }
387    }
388
389    Ok(HttpResponse::Ok().json(sessions))
390}
391
392/// Gets Claude Code settings
393///
394/// # HTTP Route
395/// `GET /agent/settings`
396///
397/// # Response Format
398/// Returns [`ClaudeSettings`] object:
399/// ```json
400/// {
401///   "apiKey": "...",
402///   "model": "claude-3-5-sonnet-20241022",
403///   ...
404/// }
405/// ```
406///
407/// # Response Status
408/// - `200 OK`: Settings retrieved (or default empty settings if not configured)
409///
410/// # Example
411/// ```bash
412/// curl http://localhost:3000/agent/settings
413/// ```
414pub async fn get_claude_settings() -> Result<HttpResponse, AppError> {
415    let settings_path = dirs::home_dir()
416        .ok_or_else(|| AppError::InternalError(anyhow::anyhow!("Home directory not found")))?
417        .join(".claude")
418        .join("settings.json");
419
420    if settings_path.exists() {
421        let content = std::fs::read_to_string(&settings_path).map_err(|e| {
422            AppError::InternalError(anyhow::anyhow!("Failed to read settings: {}", e))
423        })?;
424        let data: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
425            AppError::InternalError(anyhow::anyhow!("Failed to parse settings: {}", e))
426        })?;
427        Ok(HttpResponse::Ok().json(ClaudeSettings { data }))
428    } else {
429        Ok(HttpResponse::Ok().json(ClaudeSettings::default()))
430    }
431}
432
433/// Saves Claude Code settings
434///
435/// # HTTP Route
436/// `POST /agent/settings`
437///
438/// # Request Body
439/// ```json
440/// {
441///   "settings": {
442///     "apiKey": "...",
443///     "model": "claude-3-5-sonnet-20241022"
444///   }
445/// }
446/// ```
447///
448/// # Response Format
449/// ```json
450/// {
451///   "success": true,
452///   "path": "/Users/me/.claude/settings.json"
453/// }
454/// ```
455///
456/// # Response Status
457/// - `200 OK`: Settings saved successfully
458/// - `500 Internal Server Error`: Failed to save settings
459///
460/// # Example
461/// ```bash
462/// curl -X POST http://localhost:3000/agent/settings \
463///   -H "Content-Type: application/json" \
464///   -d '{"settings": {"model": "claude-3-5-sonnet-20241022"}}'
465/// ```
466pub async fn save_claude_settings(
467    req: web::Json<SaveSettingsRequest>,
468) -> Result<HttpResponse, AppError> {
469    let settings_path = dirs::home_dir()
470        .ok_or_else(|| AppError::InternalError(anyhow::anyhow!("Home directory not found")))?
471        .join(".claude")
472        .join("settings.json");
473
474    let content = serde_json::to_string_pretty(&req.settings).map_err(|e| {
475        AppError::InternalError(anyhow::anyhow!("Failed to serialize settings: {}", e))
476    })?;
477
478    std::fs::write(&settings_path, content)
479        .map_err(|e| AppError::InternalError(anyhow::anyhow!("Failed to write settings: {}", e)))?;
480
481    Ok(HttpResponse::Ok().json(serde_json::json!({"success": true, "path": settings_path})))
482}
483
484/// Gets the custom system prompt
485///
486/// # HTTP Route
487/// `GET /agent/system-prompt`
488///
489/// # Response Format
490/// ```json
491/// {
492///   "content": "# Custom System Prompt\n...",
493///   "path": "/Users/me/.claude/system-prompt.md"
494/// }
495/// ```
496///
497/// # Response Status
498/// - `200 OK`: System prompt retrieved (empty content if not set)
499///
500/// # Example
501/// ```bash
502/// curl http://localhost:3000/agent/system-prompt
503/// ```
504pub async fn get_system_prompt() -> Result<HttpResponse, AppError> {
505    let prompt_path = dirs::home_dir()
506        .ok_or_else(|| AppError::InternalError(anyhow::anyhow!("Home directory not found")))?
507        .join(".claude")
508        .join("system-prompt.md");
509
510    if prompt_path.exists() {
511        let content = std::fs::read_to_string(&prompt_path).map_err(|e| {
512            AppError::InternalError(anyhow::anyhow!("Failed to read system prompt: {}", e))
513        })?;
514        Ok(HttpResponse::Ok().json(serde_json::json!({ "content": content, "path": prompt_path })))
515    } else {
516        Ok(HttpResponse::Ok().json(serde_json::json!({ "content": "", "path": prompt_path })))
517    }
518}
519
520/// Saves the custom system prompt
521///
522/// # HTTP Route
523/// `POST /agent/system-prompt`
524///
525/// # Request Body
526/// ```json
527/// {
528///   "content": "# Custom System Prompt\n\nYou are a helpful assistant..."
529/// }
530/// ```
531///
532/// # Response Format
533/// ```json
534/// {
535///   "success": true,
536///   "path": "/Users/me/.claude/system-prompt.md"
537/// }
538/// ```
539///
540/// # Response Status
541/// - `200 OK`: System prompt saved successfully
542/// - `500 Internal Server Error`: Failed to save prompt
543///
544/// # Example
545/// ```bash
546/// curl -X POST http://localhost:3000/agent/system-prompt \
547///   -H "Content-Type: application/json" \
548///   -d '{"content": "# My Prompt"}'
549/// ```
550pub async fn save_system_prompt(
551    req: web::Json<SaveSystemPromptRequest>,
552) -> Result<HttpResponse, AppError> {
553    let prompt_path = dirs::home_dir()
554        .ok_or_else(|| AppError::InternalError(anyhow::anyhow!("Home directory not found")))?
555        .join(".claude")
556        .join("system-prompt.md");
557
558    std::fs::write(&prompt_path, &req.content).map_err(|e| {
559        AppError::InternalError(anyhow::anyhow!("Failed to write system prompt: {}", e))
560    })?;
561
562    Ok(HttpResponse::Ok().json(serde_json::json!({ "success": true, "path": prompt_path })))
563}
564
565/// Lists currently running Claude Code sessions
566///
567/// # HTTP Route
568/// `GET /agent/sessions/running`
569///
570/// # Response Format
571/// Returns an array of running session metadata (currently returns empty array):
572/// ```json
573/// []
574/// ```
575///
576/// # Response Status
577/// - `200 OK`: Always returns successfully
578///
579/// # Example
580/// ```bash
581/// curl http://localhost:3000/agent/sessions/running
582/// ```
583pub async fn list_running_claude_sessions() -> Result<HttpResponse, AppError> {
584    // Kept for backward compatibility (legacy signature). Prefer the stateful variant below.
585    Ok(HttpResponse::Ok().json(Vec::<serde_json::Value>::new()))
586}
587
588/// Lists currently running Claude Code sessions (stateful).
589pub async fn list_running_claude_sessions_stateful(
590    state: web::Data<AppState>,
591) -> Result<HttpResponse, AppError> {
592    let sessions = state
593        .process_registry
594        .get_running_claude_sessions()
595        .await
596        .map_err(|e| AppError::InternalError(anyhow::anyhow!(e)))?;
597    Ok(HttpResponse::Ok().json(sessions))
598}
599
600/// Subscribe to Claude Code streaming events via SSE.
601///
602/// `GET /agent/sessions/{session_id}/events`
603pub async fn claude_events(
604    state: web::Data<AppState>,
605    path: web::Path<String>,
606    _req: HttpRequest,
607) -> impl Responder {
608    let session_id = path.into_inner();
609
610    let (event_receiver, runner_status) = {
611        let runners = state.claude_runners.read().await;
612        match runners.get(&session_id) {
613            Some(runner) => (
614                Some(runner.event_sender.subscribe()),
615                Some(runner.status.clone()),
616            ),
617            None => (None, None),
618        }
619    };
620
621    match event_receiver {
622        Some(mut receiver) => {
623            // If already terminal, send immediate event and close.
624            match runner_status {
625                Some(AgentStatus::Completed) => {
626                    return HttpResponse::Ok()
627                        .append_header((header::CONTENT_TYPE, "text/event-stream"))
628                        .append_header((header::CACHE_CONTROL, "no-cache"))
629                        .streaming(async_stream::stream! {
630                            let event = AgentEvent::Complete {
631                                usage: crate::agent::core::TokenUsage { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
632                            };
633                            let event_json = serde_json::to_string(&event).unwrap();
634                            yield Ok::<_, actix_web::Error>(actix_web::web::Bytes::from(format!("data: {event_json}\n\n")));
635                        });
636                }
637                Some(AgentStatus::Error(err)) => {
638                    return HttpResponse::Ok()
639                        .append_header((header::CONTENT_TYPE, "text/event-stream"))
640                        .append_header((header::CACHE_CONTROL, "no-cache"))
641                        .streaming(async_stream::stream! {
642                            let event = AgentEvent::Error { message: err.clone() };
643                            let event_json = serde_json::to_string(&event).unwrap();
644                            yield Ok::<_, actix_web::Error>(actix_web::web::Bytes::from(format!("data: {event_json}\n\n")));
645                        });
646                }
647                _ => {}
648            }
649
650            HttpResponse::Ok()
651                .append_header((header::CONTENT_TYPE, "text/event-stream"))
652                .append_header((header::CACHE_CONTROL, "no-cache"))
653                .append_header((header::CONNECTION, "keep-alive"))
654                .streaming(async_stream::stream! {
655                    while let Ok(event) = receiver.recv().await {
656                        let event_json = match serde_json::to_string(&event) {
657                            Ok(json) => json,
658                            Err(_) => continue,
659                        };
660
661                        yield Ok::<_, actix_web::Error>(actix_web::web::Bytes::from(format!("data: {event_json}\n\n")));
662
663                        match &event {
664                            AgentEvent::Complete { .. } | AgentEvent::Error { .. } => break,
665                            _ => {}
666                        }
667                    }
668                })
669        }
670        None => HttpResponse::NotFound().json(serde_json::json!({
671            "error": "Claude session not running",
672            "session_id": session_id
673        })),
674    }
675}
676
677/// Executes Claude Code in a project directory
678///
679/// # HTTP Route
680/// `POST /agent/sessions/execute`
681///
682/// # Request Body
683/// ```json
684/// {
685///   "project_path": "/Users/me/projects/myproject",
686///   "prompt": "Help me debug this code",
687///   "session_id": "optional-session-id"
688/// }
689/// ```
690///
691/// # Response Format
692/// ```json
693/// {
694///   "success": true,
695///   "message": "Execution started - streaming not yet implemented"
696/// }
697/// ```
698///
699/// # Response Status
700/// - `200 OK`: Execution started (placeholder implementation)
701///
702/// # Example
703/// ```bash
704/// curl -X POST http://localhost:3000/agent/sessions/execute \
705///   -H "Content-Type: application/json" \
706///   -d '{"project_path": "/tmp", "prompt": "Hello"}'
707/// ```
708pub async fn execute_claude_code(
709    state: web::Data<AppState>,
710    req: web::Json<ExecuteRequest>,
711) -> Result<HttpResponse, AppError> {
712    let Some(claude_path) = state.claude_cli_path.clone() else {
713        log::warn!("Claude Code CLI not available; refusing to execute");
714        return Ok(HttpResponse::Ok().json(serde_json::json!({
715            "success": false,
716            "message": "Claude Code CLI not found; integration disabled"
717        })));
718    };
719
720    let project_path = PathBuf::from(req.project_path.trim());
721    if !project_path.is_dir() {
722        return Err(AppError::BadRequest(format!(
723            "project_path is not a directory: {}",
724            project_path.display()
725        )));
726    }
727
728    // Client-visible session id (can be an alias).
729    let client_session_id = req
730        .session_id
731        .clone()
732        .unwrap_or_else(|| Uuid::new_v4().to_string());
733
734    // Claude Code requires UUID session ids; if the client provides a non-UUID,
735    // accept it as an alias and generate a UUID for Claude.
736    let (claude_session_id, alias_used) = match Uuid::parse_str(&client_session_id) {
737        Ok(_) => (client_session_id.clone(), false),
738        Err(_) => (Uuid::new_v4().to_string(), true),
739    };
740
741    if alias_used {
742        log::warn!(
743            "Non-UUID session_id provided ({}); using generated Claude session UUID ({})",
744            client_session_id,
745            claude_session_id
746        );
747        let mut aliases = state.claude_session_aliases.write().await;
748        aliases.insert(client_session_id.clone(), claude_session_id.clone());
749    }
750
751    let include_partial_messages = req.include_partial_messages.unwrap_or(true);
752    let dangerously_skip_permissions = req.dangerously_skip_permissions.unwrap_or(true);
753
754    // Default Anthropic base URL points back to Bamboo itself.
755    let port = state.config.read().await.server.port;
756    let anthropic_base_url = req
757        .anthropic_base_url
758        .clone()
759        .unwrap_or_else(|| format!("http://127.0.0.1:{}/anthropic", port));
760
761    // Create and register a runner for SSE streaming.
762    let mut runner = crate::server::app_state::AgentRunner::new();
763    runner.status = AgentStatus::Running;
764
765    let event_sender = runner.event_sender.clone();
766    let cancel_token = runner.cancel_token.clone();
767
768    {
769        let mut runners = state.claude_runners.write().await;
770        runners.insert(client_session_id.clone(), runner.clone());
771    }
772
773    // Spawn Claude process + streaming conversion.
774    let run_id = crate::claude::spawn_claude_code_cli(
775        state.process_registry.clone(),
776        event_sender.clone(),
777        cancel_token.clone(),
778        crate::claude::ClaudeCodeCliConfig {
779            claude_path,
780            project_path: project_path.clone(),
781            prompt: req.prompt.clone(),
782            session_id: claude_session_id.clone(),
783            anthropic_base_url,
784            json_schema: req.json_schema.clone(),
785            skip_permissions: dangerously_skip_permissions,
786            include_partial_messages,
787        },
788    )
789    .await
790    .map_err(|e| AppError::InternalError(anyhow::anyhow!(e)))?;
791
792    // Update runner status on terminal events.
793    {
794        let runners = state.claude_runners.clone();
795        let session_id_clone = client_session_id.clone();
796        let mut rx = event_sender.subscribe();
797        tokio::spawn(async move {
798            while let Ok(event) = rx.recv().await {
799                let terminal = match &event {
800                    AgentEvent::Complete { .. } => Some(AgentStatus::Completed),
801                    AgentEvent::Error { message } => Some(AgentStatus::Error(message.clone())),
802                    _ => None,
803                };
804                if let Some(status) = terminal {
805                    let mut guard = runners.write().await;
806                    if let Some(runner) = guard.get_mut(&session_id_clone) {
807                        runner.status = status;
808                        runner.completed_at = Some(chrono::Utc::now());
809                    }
810                    break;
811                }
812            }
813        });
814    }
815
816    Ok(HttpResponse::Ok().json(serde_json::json!({
817        "success": true,
818        "session_id": client_session_id,
819        "claude_session_id": claude_session_id,
820        "run_id": run_id,
821        "events_url": format!("/v1/agent/sessions/{}/events", client_session_id),
822        "message": "Claude Code execution started"
823    })))
824}
825
826/// Cancels a running Claude Code execution
827///
828/// # HTTP Route
829/// `POST /agent/sessions/cancel`
830///
831/// # Request Body
832/// ```json
833/// {
834///   "session_id": "session-123"
835/// }
836/// ```
837///
838/// # Response Format
839/// ```json
840/// {
841///   "success": true,
842///   "message": "Cancellation request sent"
843/// }
844/// ```
845///
846/// # Response Status
847/// - `200 OK`: Cancellation request sent
848///
849/// # Example
850/// ```bash
851/// curl -X POST http://localhost:3000/agent/sessions/cancel \
852///   -H "Content-Type: application/json" \
853///   -d '{"session_id": "session-123"}'
854/// ```
855pub async fn cancel_claude_execution(
856    state: web::Data<AppState>,
857    req: web::Json<CancelRequest>,
858) -> Result<HttpResponse, AppError> {
859    let session_id = req.session_id.trim().to_string();
860    if session_id.is_empty() {
861        return Err(AppError::BadRequest("session_id is required".to_string()));
862    }
863
864    // Signal cancellation to any active runner (best-effort).
865    {
866        let runners = state.claude_runners.read().await;
867        if let Some(runner) = runners.get(&session_id) {
868            runner.cancel_token.cancel();
869        }
870    }
871
872    // Resolve aliases to Claude UUID session ids.
873    let claude_session_id = match Uuid::parse_str(&session_id) {
874        Ok(_) => Some(session_id.clone()),
875        Err(_) => {
876            let aliases = state.claude_session_aliases.read().await;
877            aliases.get(&session_id).cloned()
878        }
879    };
880
881    // Kill the process if it's tracked.
882    let run_id = if let Some(ref claude_session_id) = claude_session_id {
883        state
884            .process_registry
885            .get_claude_session_by_id(claude_session_id)
886            .await
887            .map_err(|e| AppError::InternalError(anyhow::anyhow!(e)))?
888            .map(|info| info.run_id)
889    } else {
890        None
891    };
892
893    if let Some(run_id) = run_id {
894        let _ = state
895            .process_registry
896            .kill_process(run_id)
897            .await
898            .map_err(|e| AppError::InternalError(anyhow::anyhow!(e)))?;
899        Ok(HttpResponse::Ok().json(serde_json::json!({
900            "success": true,
901            "message": "Cancellation request sent",
902            "session_id": session_id,
903            "claude_session_id": claude_session_id,
904            "run_id": run_id
905        })))
906    } else {
907        // Treat "not running" as an accepted cancellation (no-op) to keep API ergonomic.
908        Ok(HttpResponse::Ok().json(serde_json::json!({
909            "success": true,
910            "message": "Session not found or not running",
911            "session_id": session_id,
912            "claude_session_id": claude_session_id
913        })))
914    }
915}
916
917/// Gets session JSONL content (conversation history)
918///
919/// # HTTP Route
920/// `GET /agent/sessions/{session_id}/jsonl?project_id={project_id}`
921///
922/// # Path Parameters
923/// - `session_id`: Session identifier
924///
925/// # Query Parameters
926/// - `project_id`: (Required) Project identifier
927///
928/// # Response Format
929/// Returns an array of JSON objects representing conversation messages:
930/// ```json
931/// [
932///   {"role": "user", "content": "Hello"},
933///   {"role": "assistant", "content": "Hi!"}
934/// ]
935/// ```
936///
937/// # Response Status
938/// - `200 OK`: Session content retrieved successfully
939/// - `500 Internal Server Error`: Session not found or read error
940///
941/// # Example
942/// ```bash
943/// curl "http://localhost:3000/agent/sessions/session-123/jsonl?project_id=my-project"
944/// ```
945pub async fn get_session_jsonl(
946    path: web::Path<String>,
947    query: web::Query<std::collections::HashMap<String, String>>,
948) -> Result<HttpResponse, AppError> {
949    let claude_dir = get_claude_dir()?;
950    let session_id = path.into_inner();
951    let project_id = query.get("project_id").ok_or_else(|| {
952        AppError::InternalError(anyhow::anyhow!("project_id query parameter required"))
953    })?;
954
955    let project_dir = claude_dir.join(project_id);
956    let session_path = project_dir.join(format!("{}.jsonl", session_id));
957
958    if !session_path.exists() {
959        return Err(AppError::InternalError(anyhow::anyhow!(
960            "Session not found"
961        )));
962    }
963
964    let content = std::fs::read_to_string(&session_path)
965        .map_err(|e| AppError::InternalError(anyhow::anyhow!("Failed to read session: {}", e)))?;
966
967    let lines: Vec<serde_json::Value> = content
968        .lines()
969        .filter_map(|line| serde_json::from_str(line).ok())
970        .collect();
971
972    Ok(HttpResponse::Ok().json(lines))
973}
974
975/// Configures agent API routes
976///
977/// # Routes
978/// - `GET /agent/projects` - List all projects
979/// - `POST /agent/projects` - Create a new project
980/// - `GET /agent/projects/{project_id}/sessions` - Get project sessions
981/// - `GET /agent/settings` - Get Claude settings
982/// - `POST /agent/settings` - Save Claude settings
983/// - `GET /agent/system-prompt` - Get system prompt
984/// - `POST /agent/system-prompt` - Save system prompt
985/// - `GET /agent/sessions/running` - List running sessions
986/// - `POST /agent/sessions/execute` - Execute Claude code
987/// - `POST /agent/sessions/cancel` - Cancel execution
988/// - `GET /agent/sessions/{session_id}/jsonl` - Get session content
989pub fn config(cfg: &mut web::ServiceConfig) {
990    cfg.service(
991        web::scope("/agent")
992            .route("/projects", web::get().to(list_projects))
993            .route("/projects", web::post().to(create_project))
994            .route(
995                "/projects/{project_id}/sessions",
996                web::get().to(get_project_sessions),
997            )
998            .route("/settings", web::get().to(get_claude_settings))
999            .route("/settings", web::post().to(save_claude_settings))
1000            .route("/system-prompt", web::get().to(get_system_prompt))
1001            .route("/system-prompt", web::post().to(save_system_prompt))
1002            .route(
1003                "/sessions/running",
1004                web::get().to(list_running_claude_sessions),
1005            )
1006            .route("/sessions/execute", web::post().to(execute_claude_code))
1007            .route("/sessions/cancel", web::post().to(cancel_claude_execution))
1008            .route(
1009                "/sessions/{session_id}/jsonl",
1010                web::get().to(get_session_jsonl),
1011            ),
1012    );
1013}