Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src-rust/crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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.
Expand Down Expand Up @@ -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(())
Comment on lines +4572 to 4577
}
Expand Down
73 changes: 73 additions & 0 deletions src-rust/crates/core/src/coven_daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

// ---------------------------------------------------------------------------
// Raw JSON shapes (private — only used for deserialization)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<i32>,
) -> 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
Expand Down Expand Up @@ -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}");
}
}
40 changes: 40 additions & 0 deletions src-rust/crates/core/src/coven_ledger.rs
Original file line number Diff line number Diff line change
@@ -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<i32>) {
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<i32>) {}
7 changes: 7 additions & 0 deletions src-rust/crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
}
}
}
Expand Down