Skip to content

Commit 9060913

Browse files
authored
feat(tui): register interactive sessions in the Coven ledger (opt-in) (Phase 3, Track B) (#152)
Opt-in daemonLedger setting: the interactive TUI registers itself in the Coven daemon ledger on start and completes on clean exit, so sessions appear in coven sessions. Best-effort notifier is cfg(unix)-gated; every failure is swallowed to a debug log. Registered id is captured so /resume cannot leave a registration uncompleted.
1 parent f24ed29 commit 9060913

4 files changed

Lines changed: 145 additions & 0 deletions

File tree

src-rust/crates/cli/src/main.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2063,6 +2063,23 @@ async fn run_interactive(
20632063
app.completion_toast_enabled = settings.completion_toast_enabled();
20642064
app.bell_on_complete = settings.bell_on_complete;
20652065

2066+
// Register this session in the Coven daemon ledger (best-effort, opt-in).
2067+
// The id is captured here so that a mid-run /resume (which rebinds session.id)
2068+
// does not leave this registration uncompleted — the exit hook uses this local
2069+
// rather than session.id, which may have changed to a different session by then.
2070+
let ledger_session_id: Option<String> = if settings.daemon_ledger {
2071+
let id = session.id.clone();
2072+
let root = tool_ctx.working_dir.clone();
2073+
let title = session.title.clone().unwrap_or_default();
2074+
let id_for_task = id.clone();
2075+
tokio::task::spawn_blocking(move || {
2076+
claurst_core::coven_ledger::notify_session_start(&id_for_task, &root, &title);
2077+
});
2078+
Some(id)
2079+
} else {
2080+
None
2081+
};
2082+
20662083
// Background: refresh the model registry from models.dev.
20672084
// The fetched JSON is saved as a cache file; the App will reload it from
20682085
// disk whenever the /model picker opens.
@@ -4548,6 +4565,14 @@ async fn run_interactive(
45484565
if let Some(runtime) = bridge_runtime.take() {
45494566
runtime.cancel.cancel();
45504567
}
4568+
4569+
// Mark the session complete in the Coven daemon ledger (best-effort, opt-in).
4570+
// Use the id captured at registration time, not session.id, which may have been
4571+
// rebound by a mid-run /resume to a completely different session.
4572+
if let Some(id) = &ledger_session_id {
4573+
claurst_core::coven_ledger::notify_session_complete(id, Some(0));
4574+
}
4575+
45514576
restore_terminal(&mut terminal)?;
45524577
Ok(())
45534578
}

src-rust/crates/core/src/coven_daemon.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,17 @@ pub struct CreateSessionRequest {
9797
pub initial_message: String,
9898
}
9999

100+
/// Payload for registering an externally-owned running session in the daemon.
101+
#[derive(Debug, Clone, Serialize)]
102+
#[serde(rename_all = "camelCase")]
103+
pub struct RegisterExternalSession {
104+
pub id: String,
105+
pub project_root: String,
106+
pub harness: String,
107+
pub title: String,
108+
pub transcript_path: Option<String>,
109+
}
110+
100111
// ---------------------------------------------------------------------------
101112
// Raw JSON shapes (private — only used for deserialization)
102113
// ---------------------------------------------------------------------------
@@ -744,6 +755,29 @@ impl DaemonClient {
744755
.map(|id| id.to_string())
745756
.ok_or(DaemonError::MissingField("id"))
746757
}
758+
759+
/// Register an already-running session in the daemon ledger. Best-effort.
760+
pub fn register_external_session(
761+
&self,
762+
req: &RegisterExternalSession,
763+
) -> Result<(), DaemonError> {
764+
let body = serde_json::to_string(req)
765+
.map_err(|e| DaemonError::MalformedResponse(format!("encode request: {e}")))?;
766+
self.request("POST", "/api/v1/sessions/external", Some(&body))
767+
.map(|_| ())
768+
}
769+
770+
/// Mark an externally-owned session finished. Best-effort.
771+
pub fn complete_session(
772+
&self,
773+
session_id: &str,
774+
exit_code: Option<i32>,
775+
) -> Result<(), DaemonError> {
776+
let body = serde_json::to_string(&serde_json::json!({ "exitCode": exit_code }))
777+
.map_err(|e| DaemonError::MalformedResponse(format!("encode request: {e}")))?;
778+
let path = format!("/api/v1/sessions/{}/complete", url_quote(session_id));
779+
self.request("POST", &path, Some(&body)).map(|_| ())
780+
}
747781
}
748782

749783
/// Minimal percent-encoder for path segments — escapes anything outside the
@@ -1097,4 +1131,43 @@ mod tests {
10971131
let result = client.check_reachability(std::time::Duration::from_secs(2));
10981132
assert_eq!(result, DaemonReachability::Online);
10991133
}
1134+
1135+
#[test]
1136+
fn register_external_session_serializes_camel_case() {
1137+
let req = RegisterExternalSession {
1138+
id: "sess-1".to_string(),
1139+
project_root: "/home/user/repo".to_string(),
1140+
harness: "coven-code".to_string(),
1141+
title: "My session".to_string(),
1142+
transcript_path: Some("/home/user/.coven-code/sessions/sess-1.jsonl".to_string()),
1143+
};
1144+
let json = serde_json::to_string(&req).unwrap();
1145+
assert!(
1146+
json.contains("\"projectRoot\""),
1147+
"expected camelCase projectRoot, got: {json}"
1148+
);
1149+
assert!(
1150+
json.contains("\"transcriptPath\""),
1151+
"expected camelCase transcriptPath, got: {json}"
1152+
);
1153+
assert!(
1154+
!json.contains("\"project_root\""),
1155+
"snake_case leaked: {json}"
1156+
);
1157+
assert!(
1158+
!json.contains("\"transcript_path\""),
1159+
"snake_case leaked: {json}"
1160+
);
1161+
}
1162+
1163+
#[test]
1164+
fn complete_session_body_serializes_exit_code() {
1165+
// Verify the exitCode key (camelCase) is what the daemon expects.
1166+
let body = serde_json::to_string(&serde_json::json!({ "exitCode": 0i32 })).unwrap();
1167+
assert!(
1168+
body.contains("\"exitCode\""),
1169+
"expected camelCase exitCode, got: {body}"
1170+
);
1171+
assert!(!body.contains("\"exit_code\""), "snake_case leaked: {body}");
1172+
}
11001173
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//! Best-effort registration of interactive sessions in the Coven daemon ledger.
2+
//!
3+
//! Every failure is swallowed to a debug log — a dead or absent daemon must
4+
//! never affect the TUI.
5+
6+
#[cfg(unix)]
7+
pub fn notify_session_start(id: &str, project_root: &std::path::Path, title: &str) {
8+
let Some(client) = crate::coven_daemon::DaemonClient::new() else {
9+
return;
10+
};
11+
let transcript_path = crate::session_storage::transcript_path(project_root, id)
12+
.ok()
13+
.map(|p| p.to_string_lossy().into_owned());
14+
let req = crate::coven_daemon::RegisterExternalSession {
15+
id: id.to_string(),
16+
project_root: project_root.to_string_lossy().into_owned(),
17+
harness: "coven-code".to_string(),
18+
title: title.to_string(),
19+
transcript_path,
20+
};
21+
if let Err(e) = client.register_external_session(&req) {
22+
tracing::debug!("coven ledger register failed (ignored): {e}");
23+
}
24+
}
25+
26+
#[cfg(unix)]
27+
pub fn notify_session_complete(id: &str, exit_code: Option<i32>) {
28+
let Some(client) = crate::coven_daemon::DaemonClient::new() else {
29+
return;
30+
};
31+
if let Err(e) = client.complete_session(id, exit_code) {
32+
tracing::debug!("coven ledger complete failed (ignored): {e}");
33+
}
34+
}
35+
36+
#[cfg(not(unix))]
37+
pub fn notify_session_start(_id: &str, _project_root: &std::path::Path, _title: &str) {}
38+
39+
#[cfg(not(unix))]
40+
pub fn notify_session_complete(_id: &str, _exit_code: Option<i32>) {}

src-rust/crates/core/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ pub use skill_discovery::{
113113
pub mod coven_shared;
114114
// Tier B IPC — blocking HTTP-over-Unix-socket client for the live daemon.
115115
pub mod coven_daemon;
116+
// Best-effort session lifecycle notifications to the Coven daemon ledger.
117+
pub mod coven_ledger;
116118
pub mod roster_reset;
117119
pub use cost::CostTracker;
118120
pub use feature_flags::FeatureFlagManager;
@@ -1180,6 +1182,10 @@ pub mod config {
11801182
/// Ring the terminal bell (\x07) when a background bash task or assistant turn finishes. Defaults to false.
11811183
#[serde(default, rename = "bellOnComplete")]
11821184
pub bell_on_complete: bool,
1185+
/// Register interactive sessions in the Coven daemon ledger (best-effort).
1186+
/// Opt-in for now; only acts when a Coven daemon socket is present.
1187+
#[serde(default, rename = "daemonLedger")]
1188+
pub daemon_ledger: bool,
11831189
}
11841190

11851191
impl Settings {
@@ -1898,6 +1904,7 @@ pub mod config {
18981904
},
18991905
completion_toast: over.completion_toast.or(base.completion_toast),
19001906
bell_on_complete: over.bell_on_complete || base.bell_on_complete,
1907+
daemon_ledger: over.daemon_ledger || base.daemon_ledger,
19011908
}
19021909
}
19031910
}

0 commit comments

Comments
 (0)