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:
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(),
});
}