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#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Project {
17 pub id: String,
19 pub path: String,
21 pub sessions: Vec<String>,
23 pub created_at: u64,
25 pub most_recent_session: Option<u64>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Session {
32 pub id: String,
34 pub project_id: String,
36 pub project_path: String,
38 pub todo_data: Option<serde_json::Value>,
40 pub created_at: u64,
42 pub first_message: Option<String>,
44 pub message_timestamp: Option<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ClaudeSettings {
51 #[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#[derive(Debug, Deserialize)]
70pub struct CreateProjectRequest {
71 pub path: String,
73}
74
75#[derive(Debug, Deserialize)]
77pub struct SaveSettingsRequest {
78 pub settings: serde_json::Value,
80}
81
82#[derive(Debug, Deserialize)]
84pub struct SaveSystemPromptRequest {
85 pub content: String,
87}
88
89#[derive(Debug, Deserialize)]
91pub struct ExecuteRequest {
92 pub project_path: String,
94 pub prompt: String,
96 pub session_id: Option<String>,
98 pub anthropic_base_url: Option<String>,
103 pub json_schema: Option<String>,
105 pub dangerously_skip_permissions: Option<bool>,
107 pub include_partial_messages: Option<bool>,
109}
110
111#[derive(Debug, Deserialize)]
113pub struct CancelRequest {
114 pub session_id: String,
116}
117
118fn 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 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
148pub 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
230pub 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 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 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
309pub 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
392pub 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
433pub 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
484pub 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
520pub 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
565pub async fn list_running_claude_sessions() -> Result<HttpResponse, AppError> {
584 Ok(HttpResponse::Ok().json(Vec::<serde_json::Value>::new()))
586}
587
588pub 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
600pub 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 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
677pub 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 let client_session_id = req
730 .session_id
731 .clone()
732 .unwrap_or_else(|| Uuid::new_v4().to_string());
733
734 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 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 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 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 {
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
826pub 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 {
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 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 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 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
917pub 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
975pub 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}