From 48d44387e289041d24bf053a83440b8e21197b48 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:53:55 -0500 Subject: [PATCH] feat(tui): register interactive sessions in the Coven ledger (opt-in) When `daemonLedger` is enabled and a Coven daemon socket is present, the interactive TUI registers itself via POST /api/v1/sessions/external on start and POST /api/v1/sessions//complete on exit. Best-effort: every failure is swallowed so a dead or absent daemon never affects the TUI. Opt-in (default off). --- src-rust/crates/cli/src/main.rs | 25 ++++++++ src-rust/crates/core/src/coven_daemon.rs | 73 ++++++++++++++++++++++++ src-rust/crates/core/src/coven_ledger.rs | 40 +++++++++++++ src-rust/crates/core/src/lib.rs | 7 +++ 4 files changed, 145 insertions(+) create mode 100644 src-rust/crates/core/src/coven_ledger.rs diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index 4152464..3ba1d6a 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -2063,6 +2063,23 @@ async fn run_interactive( app.completion_toast_enabled = settings.completion_toast_enabled(); app.bell_on_complete = settings.bell_on_complete; + // Register this session in the Coven daemon ledger (best-effort, opt-in). + // The id is captured here so that a mid-run /resume (which rebinds session.id) + // does not leave this registration uncompleted — the exit hook uses this local + // rather than session.id, which may have changed to a different session by then. + let ledger_session_id: Option = if settings.daemon_ledger { + let id = session.id.clone(); + let root = tool_ctx.working_dir.clone(); + let title = session.title.clone().unwrap_or_default(); + let id_for_task = id.clone(); + tokio::task::spawn_blocking(move || { + claurst_core::coven_ledger::notify_session_start(&id_for_task, &root, &title); + }); + Some(id) + } else { + None + }; + // Background: refresh the model registry from models.dev. // The fetched JSON is saved as a cache file; the App will reload it from // disk whenever the /model picker opens. @@ -4548,6 +4565,14 @@ async fn run_interactive( if let Some(runtime) = bridge_runtime.take() { runtime.cancel.cancel(); } + + // Mark the session complete in the Coven daemon ledger (best-effort, opt-in). + // Use the id captured at registration time, not session.id, which may have been + // rebound by a mid-run /resume to a completely different session. + if let Some(id) = &ledger_session_id { + claurst_core::coven_ledger::notify_session_complete(id, Some(0)); + } + restore_terminal(&mut terminal)?; Ok(()) } diff --git a/src-rust/crates/core/src/coven_daemon.rs b/src-rust/crates/core/src/coven_daemon.rs index 28231f1..6eef4f6 100644 --- a/src-rust/crates/core/src/coven_daemon.rs +++ b/src-rust/crates/core/src/coven_daemon.rs @@ -97,6 +97,17 @@ pub struct CreateSessionRequest { pub initial_message: String, } +/// Payload for registering an externally-owned running session in the daemon. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisterExternalSession { + pub id: String, + pub project_root: String, + pub harness: String, + pub title: String, + pub transcript_path: Option, +} + // --------------------------------------------------------------------------- // Raw JSON shapes (private — only used for deserialization) // --------------------------------------------------------------------------- @@ -744,6 +755,29 @@ impl DaemonClient { .map(|id| id.to_string()) .ok_or(DaemonError::MissingField("id")) } + + /// Register an already-running session in the daemon ledger. Best-effort. + pub fn register_external_session( + &self, + req: &RegisterExternalSession, + ) -> Result<(), DaemonError> { + let body = serde_json::to_string(req) + .map_err(|e| DaemonError::MalformedResponse(format!("encode request: {e}")))?; + self.request("POST", "/api/v1/sessions/external", Some(&body)) + .map(|_| ()) + } + + /// Mark an externally-owned session finished. Best-effort. + pub fn complete_session( + &self, + session_id: &str, + exit_code: Option, + ) -> Result<(), DaemonError> { + let body = serde_json::to_string(&serde_json::json!({ "exitCode": exit_code })) + .map_err(|e| DaemonError::MalformedResponse(format!("encode request: {e}")))?; + let path = format!("/api/v1/sessions/{}/complete", url_quote(session_id)); + self.request("POST", &path, Some(&body)).map(|_| ()) + } } /// Minimal percent-encoder for path segments — escapes anything outside the @@ -1097,4 +1131,43 @@ mod tests { let result = client.check_reachability(std::time::Duration::from_secs(2)); assert_eq!(result, DaemonReachability::Online); } + + #[test] + fn register_external_session_serializes_camel_case() { + let req = RegisterExternalSession { + id: "sess-1".to_string(), + project_root: "/home/user/repo".to_string(), + harness: "coven-code".to_string(), + title: "My session".to_string(), + transcript_path: Some("/home/user/.coven-code/sessions/sess-1.jsonl".to_string()), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!( + json.contains("\"projectRoot\""), + "expected camelCase projectRoot, got: {json}" + ); + assert!( + json.contains("\"transcriptPath\""), + "expected camelCase transcriptPath, got: {json}" + ); + assert!( + !json.contains("\"project_root\""), + "snake_case leaked: {json}" + ); + assert!( + !json.contains("\"transcript_path\""), + "snake_case leaked: {json}" + ); + } + + #[test] + fn complete_session_body_serializes_exit_code() { + // Verify the exitCode key (camelCase) is what the daemon expects. + let body = serde_json::to_string(&serde_json::json!({ "exitCode": 0i32 })).unwrap(); + assert!( + body.contains("\"exitCode\""), + "expected camelCase exitCode, got: {body}" + ); + assert!(!body.contains("\"exit_code\""), "snake_case leaked: {body}"); + } } diff --git a/src-rust/crates/core/src/coven_ledger.rs b/src-rust/crates/core/src/coven_ledger.rs new file mode 100644 index 0000000..5958419 --- /dev/null +++ b/src-rust/crates/core/src/coven_ledger.rs @@ -0,0 +1,40 @@ +//! Best-effort registration of interactive sessions in the Coven daemon ledger. +//! +//! Every failure is swallowed to a debug log — a dead or absent daemon must +//! never affect the TUI. + +#[cfg(unix)] +pub fn notify_session_start(id: &str, project_root: &std::path::Path, title: &str) { + let Some(client) = crate::coven_daemon::DaemonClient::new() else { + return; + }; + let transcript_path = crate::session_storage::transcript_path(project_root, id) + .ok() + .map(|p| p.to_string_lossy().into_owned()); + let req = crate::coven_daemon::RegisterExternalSession { + id: id.to_string(), + project_root: project_root.to_string_lossy().into_owned(), + harness: "coven-code".to_string(), + title: title.to_string(), + transcript_path, + }; + if let Err(e) = client.register_external_session(&req) { + tracing::debug!("coven ledger register failed (ignored): {e}"); + } +} + +#[cfg(unix)] +pub fn notify_session_complete(id: &str, exit_code: Option) { + let Some(client) = crate::coven_daemon::DaemonClient::new() else { + return; + }; + if let Err(e) = client.complete_session(id, exit_code) { + tracing::debug!("coven ledger complete failed (ignored): {e}"); + } +} + +#[cfg(not(unix))] +pub fn notify_session_start(_id: &str, _project_root: &std::path::Path, _title: &str) {} + +#[cfg(not(unix))] +pub fn notify_session_complete(_id: &str, _exit_code: Option) {} diff --git a/src-rust/crates/core/src/lib.rs b/src-rust/crates/core/src/lib.rs index a1671fd..765c31d 100644 --- a/src-rust/crates/core/src/lib.rs +++ b/src-rust/crates/core/src/lib.rs @@ -113,6 +113,8 @@ pub use skill_discovery::{ pub mod coven_shared; // Tier B IPC — blocking HTTP-over-Unix-socket client for the live daemon. pub mod coven_daemon; +// Best-effort session lifecycle notifications to the Coven daemon ledger. +pub mod coven_ledger; pub mod roster_reset; pub use cost::CostTracker; pub use feature_flags::FeatureFlagManager; @@ -1180,6 +1182,10 @@ pub mod config { /// Ring the terminal bell (\x07) when a background bash task or assistant turn finishes. Defaults to false. #[serde(default, rename = "bellOnComplete")] pub bell_on_complete: bool, + /// Register interactive sessions in the Coven daemon ledger (best-effort). + /// Opt-in for now; only acts when a Coven daemon socket is present. + #[serde(default, rename = "daemonLedger")] + pub daemon_ledger: bool, } impl Settings { @@ -1898,6 +1904,7 @@ pub mod config { }, completion_toast: over.completion_toast.or(base.completion_toast), bell_on_complete: over.bell_on_complete || base.bell_on_complete, + daemon_ledger: over.daemon_ledger || base.daemon_ledger, } } }