notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 972bb3551ab8a2d0feddac0d4b30c469c8837507
parent c0d75293dde6fb1eaa47243313af2aed7ec3089e
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 16 Feb 2026 16:45:27 -0800

persist active sessions via kind-31988 replaceable nostr notes

Publish a parameterized replaceable event (NIP-33) on every agent
status transition and title change. On startup, query ndb for these
events to restore sessions and skip the directory picker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/agent_status.rs | 11+++++++++++
Mcrates/notedeck_dave/src/lib.rs | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/session.rs | 18+++++++++++++++---
Mcrates/notedeck_dave/src/session_events.rs | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/session_loader.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 311 insertions(+), 3 deletions(-)

diff --git a/crates/notedeck_dave/src/agent_status.rs b/crates/notedeck_dave/src/agent_status.rs @@ -36,4 +36,15 @@ impl AgentStatus { AgentStatus::Done => "Done", } } + + /// Get the status as a lowercase string for serialization (nostr events). + pub fn as_str(&self) -> &'static str { + match self { + AgentStatus::Idle => "idle", + AgentStatus::Working => "working", + AgentStatus::NeedsInput => "needs_input", + AgentStatus::Error => "error", + AgentStatus::Done => "done", + } + } } diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -113,6 +113,8 @@ pub struct Dave { pending_message_load: Option<PendingMessageLoad>, /// Events waiting to be published to relays (queued from non-pool contexts). pending_relay_events: Vec<session_events::BuiltEvent>, + /// Whether sessions have been restored from ndb on startup. + sessions_restored: bool, } /// Subscription waiting for ndb to index 1988 conversation events. @@ -279,6 +281,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr pending_archive_convert: None, pending_message_load: None, pending_relay_events: Vec::new(), + sessions_restored: false, } } @@ -528,6 +531,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } agentic.session_info = Some(info); } + // Persist initial session state now that we know the claude_session_id + session.state_dirty = true; } DaveApiResponse::SubagentSpawned(subagent) => { @@ -1004,6 +1009,122 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + /// Publish kind-31988 state events for sessions whose status changed. + fn publish_dirty_session_states(&mut self, ctx: &mut AppContext<'_>) { + let secret_key: Option<[u8; 32]> = ctx + .accounts + .get_selected_account() + .keypair() + .secret_key + .map(|sk| { + sk.as_secret_bytes() + .try_into() + .expect("secret key is 32 bytes") + }); + + let Some(sk) = secret_key else { + return; + }; + + for session in self.session_manager.iter_mut() { + if !session.state_dirty { + continue; + } + + let Some(agentic) = &session.agentic else { + continue; + }; + + let Some(claude_sid) = agentic.event_session_id() else { + continue; + }; + let claude_sid = claude_sid.to_string(); + + let cwd = agentic.cwd.to_string_lossy(); + let status = session.status().as_str(); + + match session_events::build_session_state_event( + &claude_sid, + &session.title, + &cwd, + status, + &sk, + ) { + Ok(evt) => { + tracing::info!( + "publishing session state: {} -> {}", + claude_sid, + status, + ); + let _ = ctx + .ndb + .process_event(&evt.note_json); + } + Err(e) => { + tracing::error!("failed to build session state event: {}", e); + } + } + + session.state_dirty = false; + } + } + + /// Restore sessions from kind-31988 state events in ndb. + /// Called once on first `update()`. + fn restore_sessions_from_ndb(&mut self, ctx: &mut AppContext<'_>) { + let txn = match Transaction::new(ctx.ndb) { + Ok(t) => t, + Err(e) => { + tracing::error!("failed to open txn for session restore: {:?}", e); + return; + } + }; + + let states = session_loader::load_session_states(ctx.ndb, &txn); + if states.is_empty() { + return; + } + + tracing::info!("restoring {} sessions from ndb", states.len()); + + for state in &states { + let cwd = std::path::PathBuf::from(&state.cwd); + let dave_sid = self.session_manager.new_resumed_session( + cwd, + state.claude_session_id.clone(), + state.title.clone(), + AiMode::Agentic, + ); + + // Load conversation history from kind-1988 events + let loaded = session_loader::load_session_messages( + ctx.ndb, + &txn, + &state.claude_session_id, + ); + + if let Some(session) = self.session_manager.get_mut(dave_sid) { + tracing::info!( + "restored session '{}': {} messages", + state.title, + loaded.messages.len(), + ); + session.chat = loaded.messages; + + if let (Some(root), Some(last)) = + (loaded.root_note_id, loaded.last_note_id) + { + if let Some(agentic) = &mut session.agentic { + agentic.live_threading.seed(root, last, loaded.event_count); + } + } + } + } + + // Skip the directory picker since we restored sessions + self.active_overlay = DaveOverlay::None; + } + /// Delete a session and clean up backend resources fn delete_session(&mut self, id: SessionId) { update::delete_session( @@ -1203,6 +1324,12 @@ impl notedeck::App for Dave { // Poll for external spawn-agent commands via IPC self.poll_ipc_commands(); + // Restore sessions from kind-31988 events on first update + if !self.sessions_restored && self.ai_mode == AiMode::Agentic { + self.sessions_restored = true; + self.restore_sessions_from_ndb(ctx); + } + // Poll for external editor completion update::poll_editor_job(&mut self.session_manager); @@ -1378,6 +1505,9 @@ impl notedeck::App for Dave { // Update all session statuses after processing events self.session_manager.update_all_statuses(); + // Publish kind-31988 state events for sessions whose status changed + self.publish_dirty_session_states(ctx); + // Update focus queue based on status changes let status_iter = self.session_manager.iter().map(|s| (s.id, s.status())); self.focus_queue.update_from_statuses(status_iter); diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -148,6 +148,8 @@ pub struct ChatSession { pub task_handle: Option<tokio::task::JoinHandle<()>>, /// Cached status for the agent (derived from session state) cached_status: AgentStatus, + /// Set when cached_status changes, cleared after publishing state event + pub state_dirty: bool, /// Whether this session's input should be focused on the next frame pub focus_requested: bool, /// AI interaction mode for this session (Chat vs Agentic) @@ -179,6 +181,7 @@ impl ChatSession { incoming_tokens: None, task_handle: None, cached_status: AgentStatus::Idle, + state_dirty: false, focus_requested: false, ai_mode, agentic, @@ -265,11 +268,15 @@ impl ChatSession { }; // Use first ~30 chars of last message as title let title: String = text.chars().take(30).collect(); - self.title = if text.len() > 30 { + let new_title = if text.len() > 30 { format!("{}...", title) } else { title }; + if new_title != self.title { + self.title = new_title; + self.state_dirty = true; + } break; } } @@ -279,9 +286,14 @@ impl ChatSession { self.cached_status } - /// Update the cached status based on current session state + /// Update the cached status based on current session state. + /// Sets `state_dirty` when the status actually changes. pub fn update_status(&mut self) { - self.cached_status = self.derive_status(); + let new_status = self.derive_status(); + if new_status != self.cached_status { + self.cached_status = new_status; + self.state_dirty = true; + } } /// Derive status from the current session state diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs @@ -16,6 +16,11 @@ pub const AI_CONVERSATION_KIND: u32 = 1988; /// corresponding 1988 event via an `e` tag. pub const AI_SOURCE_DATA_KIND: u32 = 1989; +/// Nostr event kind for AI session state (parameterized replaceable, NIP-33). +/// One event per session, auto-replaced by nostrdb on update. +/// `d` tag = claude_session_id. +pub const AI_SESSION_STATE_KIND: u32 = 31988; + /// Extract the value of a named tag from a note. pub fn get_tag_value<'a>(note: &'a nostrdb::Note<'a>, tag_name: &str) -> Option<&'a str> { for tag in note.tags() { @@ -662,6 +667,72 @@ pub fn build_permission_response_event( }) } +/// Build a kind-31988 session state event (parameterized replaceable). +/// +/// Published on every status change so remote clients and startup restore +/// can discover active sessions. nostrdb auto-replaces older versions +/// with same (kind, pubkey, d-tag). +pub fn build_session_state_event( + claude_session_id: &str, + title: &str, + cwd: &str, + status: &str, + secret_key: &[u8; 32], +) -> Result<BuiltEvent, EventBuildError> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let content = serde_json::json!({ + "claude_session_id": claude_session_id, + "title": title, + "cwd": cwd, + "status": status, + "last_active": now, + }) + .to_string(); + + let mut builder = NoteBuilder::new() + .kind(AI_SESSION_STATE_KIND) + .content(&content) + .options(NoteBuildOptions::default()) + .created_at(now); + + // Session identity (makes this a parameterized replaceable event) + builder = builder.start_tag().tag_str("d").tag_str(claude_session_id); + + // Discoverability + builder = builder + .start_tag() + .tag_str("t") + .tag_str("ai-session-state"); + builder = builder + .start_tag() + .tag_str("t") + .tag_str("ai-conversation"); + builder = builder + .start_tag() + .tag_str("source") + .tag_str("notedeck-dave"); + + let note = builder + .sign(secret_key) + .build() + .ok_or_else(|| EventBuildError::Build("NoteBuilder::build returned None".to_string()))?; + + let note_id: [u8; 32] = *note.id(); + let note_json = note + .json() + .map_err(|e| EventBuildError::Serialize(format!("{:?}", e)))?; + + Ok(BuiltEvent { + note_json, + note_id, + kind: AI_SESSION_STATE_KIND, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1091,4 +1162,33 @@ mod tests { assert!(json.contains("deny")); assert!(json.contains("too dangerous")); } + + #[test] + fn test_build_session_state_event() { + let sk = test_secret_key(); + + let event = build_session_state_event( + "sess-state-test", + "Fix the login bug", + "/tmp/project", + "working", + &sk, + ) + .unwrap(); + + assert_eq!(event.kind, AI_SESSION_STATE_KIND); + + let json = &event.note_json; + // Kind 31988 (parameterized replaceable) + assert!(json.contains("31988")); + // Has d tag for replacement + assert!(json.contains(r#""d","sess-state-test"#)); + // Has discoverability tags + assert!(json.contains(r#""t","ai-session-state"#)); + assert!(json.contains(r#""t","ai-conversation"#)); + // Content has state fields + assert!(json.contains("Fix the login bug")); + assert!(json.contains("working")); + assert!(json.contains("/tmp/project")); + } } diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs @@ -107,6 +107,61 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> } } +/// A persisted session state from a kind-31988 event. +pub struct SessionState { + pub claude_session_id: String, + pub title: String, + pub cwd: String, + pub status: String, +} + +/// Load all session states from kind-31988 events in ndb. +/// +/// Returns one `SessionState` per unique session. Since these are +/// parameterized replaceable events, nostrdb keeps only the latest +/// version for each (kind, pubkey, d-tag) tuple. +pub fn load_session_states(ndb: &Ndb, txn: &Transaction) -> Vec<SessionState> { + use crate::session_events::AI_SESSION_STATE_KIND; + + let filter = Filter::new() + .kinds([AI_SESSION_STATE_KIND as u64]) + .tags(["ai-session-state"], 't') + .build(); + + let results = match ndb.query(txn, &[filter], 100) { + Ok(r) => r, + Err(_) => return vec![], + }; + + let mut states = Vec::new(); + for qr in &results { + let Ok(note) = ndb.get_note_by_key(txn, qr.note_key) else { + continue; + }; + + let content = note.content(); + let Ok(json) = serde_json::from_str::<serde_json::Value>(content) else { + continue; + }; + + let Some(claude_session_id) = json["claude_session_id"].as_str() else { + continue; + }; + let title = json["title"].as_str().unwrap_or("Untitled").to_string(); + let cwd = json["cwd"].as_str().unwrap_or("").to_string(); + let status = json["status"].as_str().unwrap_or("idle").to_string(); + + states.push(SessionState { + claude_session_id: claude_session_id.to_string(), + title, + cwd, + status, + }); + } + + states +} + fn truncate(s: &str, max_chars: usize) -> String { if s.chars().count() <= max_chars { s.to_string()