commit 1fcf756160dd5cdee02ba52f0c9146209b4b30a0
parent 0cc918a0bb5eccdbc2364a33ff6fb590407545ad
Author: William Casarin <jb55@jb55.com>
Date: Mon, 16 Feb 2026 17:44:13 -0800
subscribe to PNS events on relays for remote session discovery
Subscribe for kind-1080 (PNS) events authored by our derived PNS pubkey
so that session state events from other devices arrive via relay pool.
Add local ndb subscription for kind-31988 session state events and poll
each frame to detect newly-unwrapped sessions, creating them in the UI
with conversation history and threading state.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
1 file changed, 147 insertions(+), 0 deletions(-)
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -115,6 +115,12 @@ pub struct Dave {
pending_relay_events: Vec<session_events::BuiltEvent>,
/// Whether sessions have been restored from ndb on startup.
sessions_restored: bool,
+ /// Remote relay subscription ID for PNS events (kind-1080).
+ /// Used to discover session state events from other devices.
+ pns_relay_sub: Option<String>,
+ /// Local ndb subscription for kind-31988 session state events.
+ /// Fires when new session states are unwrapped from PNS events.
+ session_state_sub: Option<nostrdb::Subscription>,
}
/// Subscription waiting for ndb to index 1988 conversation events.
@@ -282,6 +288,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
pending_message_load: None,
pending_relay_events: Vec::new(),
sessions_restored: false,
+ pns_relay_sub: None,
+ session_state_sub: None,
}
}
@@ -1125,6 +1133,104 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
self.active_overlay = DaveOverlay::None;
}
+ /// Poll for new kind-31988 session state events from the ndb subscription.
+ ///
+ /// When PNS events arrive from relays and get unwrapped, new session state
+ /// events may appear. This detects them and creates sessions we don't already have.
+ fn poll_session_state_events(&mut self, ctx: &mut AppContext<'_>) {
+ let Some(sub) = self.session_state_sub else {
+ return;
+ };
+
+ let note_keys = ctx.ndb.poll_for_notes(sub, 32);
+ if note_keys.is_empty() {
+ return;
+ }
+
+ let txn = match Transaction::new(ctx.ndb) {
+ Ok(t) => t,
+ Err(_) => return,
+ };
+
+ // Collect existing claude session IDs to avoid duplicates
+ let existing_ids: std::collections::HashSet<String> = self
+ .session_manager
+ .iter()
+ .filter_map(|s| {
+ s.agentic
+ .as_ref()
+ .and_then(|a| a.event_session_id().map(|id| id.to_string()))
+ })
+ .collect();
+
+ for key in note_keys {
+ let Ok(note) = ctx.ndb.get_note_by_key(&txn, key) else {
+ continue;
+ };
+
+ let content = note.content();
+ let Ok(json) = serde_json::from_str::<serde_json::Value>(content) else {
+ continue;
+ };
+
+ let Some(claude_sid) = json["claude_session_id"].as_str() else {
+ continue;
+ };
+
+ // Skip sessions we already know about
+ if existing_ids.contains(claude_sid) {
+ continue;
+ }
+
+ let title = json["title"].as_str().unwrap_or("Untitled").to_string();
+ let cwd_str = json["cwd"].as_str().unwrap_or("");
+ let cwd = std::path::PathBuf::from(cwd_str);
+
+ tracing::info!(
+ "discovered new session from relay: '{}' ({})",
+ title,
+ claude_sid
+ );
+
+ let dave_sid = self.session_manager.new_resumed_session(
+ cwd,
+ claude_sid.to_string(),
+ title,
+ AiMode::Agentic,
+ );
+
+ // Load any conversation history that arrived with it
+ let loaded = session_loader::load_session_messages(
+ ctx.ndb,
+ &txn,
+ claude_sid,
+ );
+
+ if let Some(session) = self.session_manager.get_mut(dave_sid) {
+ if !loaded.messages.is_empty() {
+ tracing::info!(
+ "loaded {} messages for discovered session",
+ 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);
+ }
+ }
+ }
+
+ // If we were showing the directory picker, switch to showing sessions
+ if matches!(self.active_overlay, DaveOverlay::DirectoryPicker) {
+ self.active_overlay = DaveOverlay::None;
+ }
+ }
+ }
+
/// Delete a session and clean up backend resources
fn delete_session(&mut self, id: SessionId) {
update::delete_session(
@@ -1341,11 +1447,52 @@ impl notedeck::App for Dave {
}
self.restore_sessions_from_ndb(ctx);
+
+ // Subscribe to PNS events on relays for session discovery from other devices.
+ // Also subscribe locally in ndb for kind-31988 session state events
+ // so we detect new sessions appearing after PNS unwrapping.
+ if let Some(sk) = ctx
+ .accounts
+ .get_selected_account()
+ .keypair()
+ .secret_key
+ {
+ let pns_keys =
+ enostr::pns::derive_pns_keys(&sk.secret_bytes());
+
+ // Remote: subscribe on relays for kind-1080 authored by our PNS pubkey
+ let pns_filter = nostrdb::Filter::new()
+ .kinds([enostr::pns::PNS_KIND as u64])
+ .authors([pns_keys.keypair.pubkey.bytes()])
+ .build();
+ let sub_id = uuid::Uuid::new_v4().to_string();
+ ctx.pool.subscribe(sub_id.clone(), vec![pns_filter]);
+ self.pns_relay_sub = Some(sub_id);
+ tracing::info!("subscribed for PNS events on relays");
+
+ // Local: subscribe in ndb for kind-31988 session state events
+ let state_filter = nostrdb::Filter::new()
+ .kinds([session_events::AI_SESSION_STATE_KIND as u64])
+ .tags(["ai-session-state"], 't')
+ .build();
+ match ctx.ndb.subscribe(&[state_filter]) {
+ Ok(sub) => {
+ self.session_state_sub = Some(sub);
+ tracing::info!("subscribed for session state events in ndb");
+ }
+ Err(e) => {
+ tracing::warn!("failed to subscribe for session state events: {:?}", e);
+ }
+ }
+ }
}
// Poll for external editor completion
update::poll_editor_job(&mut self.session_manager);
+ // Poll for new session states from PNS-unwrapped relay events
+ self.poll_session_state_events(ctx);
+
// Process pending archive conversion (JSONL → nostr events)
if let Some((file_path, dave_sid, claude_sid)) = self.pending_archive_convert.take() {
// Check if events already exist for this session in ndb