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:
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()