notedeck

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

commit 513c18f5b7b06d541bd5fb024f1d57225f3df5da
parent 950b43012f42f25f00feeac218e35a43f22505a0
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 17 Feb 2026 12:31:02 -0800

publish deleted state event when session is removed

When a session was deleted, the kind 31988 replaceable event persisted
in ndb and on relays, causing deleted sessions to reappear on restart.

Now we publish a replacement 31988 event with status "deleted" which
overwrites the old state. Session loading and live polling both filter
out deleted sessions.

Also fixes: PNS ingest uses process_event with relay format so ndb
triggers PNS unwrapping, and Claude stream errors from unknown message
types (e.g. rate_limit_event) are now non-fatal warnings instead of
killing the session. Resumed sessions always send just the latest
message since Claude Code already has context via --resume.

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

Diffstat:
Mcrates/notedeck_dave/src/backend/claude.rs | 36++++++++++++++++++++----------------
Mcrates/notedeck_dave/src/lib.rs | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/notedeck_dave/src/session_loader.rs | 10++++++++--
3 files changed, 134 insertions(+), 23 deletions(-)

diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -555,9 +555,9 @@ async fn session_actor( } } Some(Err(err)) => { - tracing::error!("Claude stream error: {}", err); - let _ = response_tx.send(DaveApiResponse::Failed(err.to_string())); - stream_done = true; + // Non-fatal: unknown message types (e.g. rate_limit_event) + // cause deserialization errors but the stream continues. + tracing::warn!("Claude stream message skipped: {}", err); } None => { stream_done = true; @@ -620,24 +620,28 @@ impl AiBackend for ClaudeBackend { ) { let (response_tx, response_rx) = mpsc::channel(); - // Determine if this is the first message in the session - let is_first_message = messages - .iter() - .filter(|m| matches!(m, Message::User(_))) - .count() - == 1; - - // For first message, send full prompt; for continuation, just the latest message - let prompt = if is_first_message { - Self::messages_to_prompt(&messages) - } else { + // For resumed sessions, always send just the latest message since + // Claude Code already has the full conversation context via --resume. + // For new sessions, send full prompt on the first message. + let prompt = if resume_session_id.is_some() { Self::get_latest_user_message(&messages) + } else { + let is_first_message = messages + .iter() + .filter(|m| matches!(m, Message::User(_))) + .count() + == 1; + if is_first_message { + Self::messages_to_prompt(&messages) + } else { + Self::get_latest_user_message(&messages) + } }; tracing::debug!( - "Sending request to Claude Code: session={}, is_first={}, prompt length: {}, preview: {:?}", + "Sending request to Claude Code: session={}, resumed={}, prompt length: {}, preview: {:?}", session_id, - is_first_message, + resume_session_id.is_some(), prompt.len(), &prompt[..prompt.len().min(100)] ); diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -129,6 +129,9 @@ pub struct Dave { /// Permission responses queued for relay publishing (from remote sessions). /// Built and published in the update loop where AppContext is available. pending_perm_responses: Vec<PendingPermResponse>, + /// Sessions pending deletion state event publication. + /// Populated in delete_session(), drained in the update loop where AppContext is available. + pending_deletions: Vec<DeletedSessionInfo>, } /// A permission response queued for relay publishing. @@ -138,6 +141,13 @@ struct PendingPermResponse { message: Option<String>, } +/// Info captured from a session before deletion, for publishing a "deleted" state event. +struct DeletedSessionInfo { + claude_session_id: String, + title: String, + cwd: String, +} + /// Subscription waiting for ndb to index 1988 conversation events. struct PendingMessageLoad { /// ndb subscription for kind-1988 events matching the session @@ -160,10 +170,10 @@ fn pns_ingest( let pns_keys = enostr::pns::derive_pns_keys(secret_key); match session_events::wrap_pns(event_json, &pns_keys) { Ok(pns_json) => { - // wrap_pns returns bare {…} JSON, but process_client_event - // expects ["EVENT", {…}] format - let wrapped = format!("[\"EVENT\", {}]", pns_json); - if let Err(e) = ndb.process_client_event(&wrapped) { + // wrap_pns returns bare {…} JSON; use relay format + // ["EVENT", "subid", {…}] so ndb triggers PNS unwrapping + let wrapped = format!("[\"EVENT\", \"_pns\", {}]", pns_json); + if let Err(e) = ndb.process_event(&wrapped) { tracing::warn!("failed to ingest PNS event: {:?}", e); } } @@ -327,6 +337,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr pns_relay_sub: None, session_state_sub: None, pending_perm_responses: Vec::new(), + pending_deletions: Vec::new(), } } @@ -1112,6 +1123,51 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + /// Publish "deleted" state events for sessions that were deleted. + /// Called in the update loop where AppContext is available. + fn publish_pending_deletions(&mut self, ctx: &mut AppContext<'_>) { + if self.pending_deletions.is_empty() { + return; + } + + 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 info in std::mem::take(&mut self.pending_deletions) { + match session_events::build_session_state_event( + &info.claude_session_id, + &info.title, + &info.cwd, + "deleted", + &sk, + ) { + Ok(evt) => { + tracing::info!( + "publishing deleted session state: {}", + info.claude_session_id, + ); + pns_ingest(ctx.ndb, &evt.note_json, &sk); + self.pending_relay_events.push(evt); + } + Err(e) => { + tracing::error!("failed to build deleted session state event: {}", e); + } + } + } + } + /// Build and queue permission response events from remote sessions. /// Called in the update loop where AppContext is available. fn publish_pending_perm_responses(&mut self, ctx: &AppContext<'_>) { @@ -1322,9 +1378,38 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr continue; }; + let status_str = json["status"].as_str().unwrap_or("idle"); + + // Skip deleted sessions entirely — don't create or keep them + if status_str == "deleted" { + // If we have this session locally, remove it + if existing_ids.contains(claude_sid) { + let to_delete: Vec<SessionId> = self + .session_manager + .iter() + .filter(|s| { + s.agentic + .as_ref() + .and_then(|a| a.event_session_id()) + == Some(claude_sid) + }) + .map(|s| s.id) + .collect(); + for id in to_delete { + update::delete_session( + &mut self.session_manager, + &mut self.focus_queue, + self.backend.as_ref(), + &mut self.directory_picker, + id, + ); + } + } + continue; + } + // Update remote_status for existing remote sessions if existing_ids.contains(claude_sid) { - let status_str = json["status"].as_str().unwrap_or("idle"); let new_status = AgentStatus::from_status_str(status_str); for session in self.session_manager.iter_mut() { if session.is_remote() { @@ -1582,6 +1667,19 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr /// Delete a session and clean up backend resources fn delete_session(&mut self, id: SessionId) { + // Capture session info before deletion so we can publish a "deleted" state event + if let Some(session) = self.session_manager.get(id) { + if let Some(agentic) = &session.agentic { + if let Some(claude_sid) = agentic.event_session_id() { + self.pending_deletions.push(DeletedSessionInfo { + claude_session_id: claude_sid.to_string(), + title: session.title.clone(), + cwd: agentic.cwd.to_string_lossy().to_string(), + }); + } + } + } + update::delete_session( &mut self.session_manager, &mut self.focus_queue, @@ -2085,6 +2183,9 @@ impl notedeck::App for Dave { // Publish kind-31988 state events for sessions whose status changed self.publish_dirty_session_states(ctx); + // Publish "deleted" state events for recently deleted sessions + self.publish_pending_deletions(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_loader.rs b/crates/notedeck_dave/src/session_loader.rs @@ -245,15 +245,21 @@ pub fn load_session_states(ndb: &Ndb, txn: &Transaction) -> Vec<SessionState> { let Some(claude_session_id) = json["claude_session_id"].as_str() else { continue; }; + let status = json["status"].as_str().unwrap_or("idle"); + + // Skip sessions that have been deleted + if status == "deleted" { + 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, + status: status.to_string(), }); }