notedeck

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

commit 94a7f9c4b40b07b11b07518802a27ef87aa232c1
parent 56bc19042226fa3fb73d524200d0c683f817d108
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 18 Feb 2026 10:30:00 -0800

refactor: consolidate permission state into PermissionTracker struct

Unifies three scattered permission fields (pending_permissions,
perm_request_note_ids, responded_perm_ids) into a single
PermissionTracker struct with a merge_loaded() helper for the
common session-restore pattern.

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

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 36+++++++++++++++++-------------------
Mcrates/notedeck_dave/src/session.rs | 58++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/notedeck_dave/src/session_loader.rs | 24+++++++++---------------
Mcrates/notedeck_dave/src/update.rs | 14+++++++-------
4 files changed, 79 insertions(+), 53 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -523,7 +523,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Store note_id for linking responses if let Some(agentic) = &mut session.agentic { agentic - .perm_request_note_ids + .permissions + .request_note_ids .insert(pending.request.id, evt.note_id); } events_to_publish.push(evt); @@ -541,7 +542,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Store the response sender for later (agentic only) if let Some(agentic) = &mut session.agentic { agentic - .pending_permissions + .permissions + .pending .insert(pending.request.id, pending.response_tx); } @@ -1083,7 +1085,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr }; // Route through the existing oneshot channel (first-response-wins) - if let Some(sender) = agentic.pending_permissions.remove(&perm_id) { + if let Some(sender) = agentic.permissions.pending.remove(&perm_id) { let response = if allowed { PermissionResponse::Allow { message } } else { @@ -1227,7 +1229,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr }; for resp in pending { - let request_note_id = match agentic.perm_request_note_ids.get(&resp.perm_id) { + let request_note_id = match agentic.permissions.request_note_ids.get(&resp.perm_id) { Some(id) => id, None => { tracing::warn!("no request note_id for perm_id {}", resp.perm_id); @@ -1315,10 +1317,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr agentic.live_threading.seed(root, last, loaded.event_count); } // Load permission state and dedup set from events - agentic.responded_perm_ids = loaded.responded_perm_ids; - agentic - .perm_request_note_ids - .extend(loaded.perm_request_note_ids); + agentic.permissions.merge_loaded(loaded.permissions.responded, loaded.permissions.request_note_ids); agentic.seen_note_ids = loaded.note_ids; // Set remote status from state event agentic.remote_status = AgentStatus::from_status_str(&state.status); @@ -1485,10 +1484,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr agentic.live_threading.seed(root, last, loaded.event_count); } // Load permission state and dedup set - agentic.responded_perm_ids = loaded.responded_perm_ids; - agentic - .perm_request_note_ids - .extend(loaded.perm_request_note_ids); + agentic.permissions.merge_loaded(loaded.permissions.responded, loaded.permissions.request_note_ids); agentic.seen_note_ids = loaded.note_ids; // Set remote status agentic.remote_status = AgentStatus::from_status_str(status_str); @@ -1644,14 +1640,14 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .unwrap_or_else(uuid::Uuid::new_v4); // Check if we already responded - let response = if agentic.responded_perm_ids.contains(&perm_id) { + let response = if agentic.permissions.responded.contains(&perm_id) { Some(crate::messages::PermissionResponseType::Allowed) } else { None }; // Store the note ID for linking responses - agentic.perm_request_note_ids.insert(perm_id, *note.id()); + agentic.permissions.request_note_ids.insert(perm_id, *note.id()); session.chat.push(Message::PermissionRequest( crate::messages::PermissionRequest { @@ -1669,7 +1665,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Track that this permission was responded to if let Some(perm_id_str) = session_events::get_tag_value(note, "perm-id") { if let Ok(perm_id) = uuid::Uuid::parse_str(perm_id_str) { - agentic.responded_perm_ids.insert(perm_id); + agentic.permissions.responded.insert(perm_id); // Update the matching PermissionRequest in chat for msg in session.chat.iter_mut() { if let Message::PermissionRequest(req) = msg { @@ -2053,8 +2049,9 @@ impl notedeck::App for Dave { agentic.live_threading.seed(root, last, loaded.event_count); } agentic - .perm_request_note_ids - .extend(loaded.perm_request_note_ids); + .permissions + .request_note_ids + .extend(loaded.permissions.request_note_ids); } } } else if let Some(secret_bytes) = @@ -2121,8 +2118,9 @@ impl notedeck::App for Dave { agentic.live_threading.seed(root, last, loaded.event_count); } agentic - .perm_request_note_ids - .extend(loaded.perm_request_note_ids); + .permissions + .request_note_ids + .extend(loaded.permissions.request_note_ids); } } self.pending_message_load = None; diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -37,10 +37,51 @@ pub enum PermissionMessageState { TentativeDeny, } +/// Consolidated permission tracking for a session. +/// +/// Bundles the local oneshot channels (for local sessions), the note-ID +/// mapping (for linking relay responses), and the already-responded set +/// (for remote sessions) into a single struct. +pub struct PermissionTracker { + /// Local oneshot senders waiting for the user to allow/deny. + pub pending: HashMap<Uuid, oneshot::Sender<PermissionResponse>>, + /// Maps permission-request UUID → nostr note ID of the published request. + pub request_note_ids: HashMap<Uuid, [u8; 32]>, + /// Permission UUIDs that have already been responded to. + pub responded: HashSet<Uuid>, +} + +impl PermissionTracker { + pub fn new() -> Self { + Self { + pending: HashMap::new(), + request_note_ids: HashMap::new(), + responded: HashSet::new(), + } + } + + /// Whether there are unresolved local permission requests. + pub fn has_pending(&self) -> bool { + !self.pending.is_empty() + } + + /// Merge loaded permission state from restored events. + pub fn merge_loaded(&mut self, responded: HashSet<Uuid>, request_note_ids: HashMap<Uuid, [u8; 32]>) { + self.responded = responded; + self.request_note_ids.extend(request_note_ids); + } +} + +impl Default for PermissionTracker { + fn default() -> Self { + Self::new() + } +} + /// Agentic-mode specific session data (Claude backend only) pub struct AgenticSessionData { - /// Pending permission requests waiting for user response - pub pending_permissions: HashMap<Uuid, oneshot::Sender<PermissionResponse>>, + /// Permission state (pending channels, note IDs, responded set) + pub permissions: PermissionTracker, /// Position in the RTS scene (in scene coordinates) pub scene_position: egui::Vec2, /// Permission mode for Claude (Default or Plan) @@ -68,9 +109,6 @@ pub struct AgenticSessionData { pub git_status: GitStatusCache, /// Threading state for live kind-1988 event generation. pub live_threading: ThreadingState, - /// Maps permission request UUID → note ID of the published request event. - /// Used to link permission response events back to their requests. - pub perm_request_note_ids: HashMap<Uuid, [u8; 32]>, /// Subscription for remote permission response events (kind-1988, t=ai-permission). /// Set up once when the session's claude_session_id becomes known. pub perm_response_sub: Option<nostrdb::Subscription>, @@ -80,8 +118,6 @@ pub struct AgenticSessionData { /// Subscription for live kind-1988 conversation events from relays. /// Used by remote sessions to receive new messages in real-time. pub live_conversation_sub: Option<nostrdb::Subscription>, - /// Set of perm-id UUIDs that we (the remote/phone) have already responded to. - pub responded_perm_ids: HashSet<Uuid>, /// Note IDs we've already processed from live conversation polling. /// Prevents duplicate messages when events are loaded during restore /// and then appear again via the subscription. @@ -99,7 +135,7 @@ impl AgenticSessionData { let git_status = GitStatusCache::new(cwd.clone()); AgenticSessionData { - pending_permissions: HashMap::new(), + permissions: PermissionTracker::new(), scene_position: egui::Vec2::new(x, y), permission_mode: PermissionMode::Default, permission_message_state: PermissionMessageState::None, @@ -113,11 +149,9 @@ impl AgenticSessionData { resume_session_id: None, git_status, live_threading: ThreadingState::new(), - perm_request_note_ids: HashMap::new(), perm_response_sub: None, remote_status: None, live_conversation_sub: None, - responded_perm_ids: HashSet::new(), seen_note_ids: HashSet::new(), } } @@ -266,7 +300,7 @@ impl ChatSession { pub fn has_pending_permissions(&self) -> bool { if self.is_remote() { // Remote: check for unresponded PermissionRequest messages in chat - let responded = self.agentic.as_ref().map(|a| &a.responded_perm_ids); + let responded = self.agentic.as_ref().map(|a| &a.permissions.responded); return self.chat.iter().any(|msg| { if let Message::PermissionRequest(req) = msg { req.response.is_none() && responded.is_none_or(|ids| !ids.contains(&req.id)) @@ -278,7 +312,7 @@ impl ChatSession { // Local: check oneshot senders self.agentic .as_ref() - .is_some_and(|a| !a.pending_permissions.is_empty()) + .is_some_and(|a| a.permissions.has_pending()) } /// Check if session is in plan mode diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs @@ -5,6 +5,7 @@ //! for populating the chat UI. use crate::messages::{AssistantMessage, PermissionRequest, PermissionResponseType, ToolResult}; +use crate::session::PermissionTracker; use crate::session_events::{get_tag_value, is_conversation_role, AI_CONVERSATION_KIND}; use crate::Message; use nostrdb::{Filter, Ndb, NoteKey, Transaction}; @@ -77,12 +78,8 @@ pub struct LoadedSession { pub root_note_id: Option<[u8; 32]>, pub last_note_id: Option<[u8; 32]>, pub event_count: u32, - /// Set of perm-id UUIDs that have already been responded to. - /// Used by remote sessions to know which permission requests are already handled. - pub responded_perm_ids: HashSet<uuid::Uuid>, - /// Map of perm_id -> note_id for permission request events. - /// Used by remote sessions to link responses back to requests. - pub perm_request_note_ids: std::collections::HashMap<uuid::Uuid, [u8; 32]>, + /// Permission state loaded from events (responded set + request note IDs). + pub permissions: PermissionTracker, /// All note IDs found, for seeding dedup in live polling. pub note_ids: HashSet<[u8; 32]>, } @@ -105,8 +102,7 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> root_note_id: None, last_note_id: None, event_count: 0, - responded_perm_ids: HashSet::new(), - perm_request_note_ids: std::collections::HashMap::new(), + permissions: PermissionTracker::new(), note_ids: HashSet::new(), } } @@ -137,20 +133,19 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> let last_note_id = notes.last().map(|n| *n.id()); // First pass: collect responded permission IDs and perm request note IDs - let mut responded_perm_ids = HashSet::new(); - let mut perm_request_note_ids = std::collections::HashMap::new(); + let mut permissions = PermissionTracker::new(); for note in &notes { let role = get_tag_value(note, "role"); if role == Some("permission_response") { if let Some(perm_id_str) = get_tag_value(note, "perm-id") { if let Ok(perm_id) = uuid::Uuid::parse_str(perm_id_str) { - responded_perm_ids.insert(perm_id); + permissions.responded.insert(perm_id); } } } else if role == Some("permission_request") { if let Some(perm_id_str) = get_tag_value(note, "perm-id") { if let Ok(perm_id) = uuid::Uuid::parse_str(perm_id_str) { - perm_request_note_ids.insert(perm_id, *note.id()); + permissions.request_note_ids.insert(perm_id, *note.id()); } } } @@ -190,7 +185,7 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> .and_then(|s| uuid::Uuid::parse_str(s).ok()) .unwrap_or_else(uuid::Uuid::new_v4); - let response = if responded_perm_ids.contains(&perm_id) { + let response = if permissions.responded.contains(&perm_id) { Some(PermissionResponseType::Allowed) } else { None @@ -222,8 +217,7 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> root_note_id, last_note_id, event_count, - responded_perm_ids, - perm_request_note_ids, + permissions, note_ids, } } diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs @@ -72,7 +72,7 @@ pub fn execute_interrupt( backend.interrupt_session(session_id, ctx.clone()); session.incoming_tokens = None; if let Some(agentic) = &mut session.agentic { - agentic.pending_permissions.clear(); + agentic.permissions.pending.clear(); } tracing::debug!("Interrupted session {}", session.id); } @@ -141,7 +141,7 @@ pub fn first_pending_permission(session_manager: &SessionManager) -> Option<uuid let session = session_manager.get_active()?; if session.is_remote() { // Remote: find first unresponded PermissionRequest in chat - let responded = session.agentic.as_ref().map(|a| &a.responded_perm_ids); + let responded = session.agentic.as_ref().map(|a| &a.permissions.responded); for msg in &session.chat { if let Message::PermissionRequest(req) = msg { if req.response.is_none() && responded.is_none_or(|ids| !ids.contains(&req.id)) { @@ -155,7 +155,7 @@ pub fn first_pending_permission(session_manager: &SessionManager) -> Option<uuid session .agentic .as_ref() - .and_then(|a| a.pending_permissions.keys().next().copied()) + .and_then(|a| a.permissions.pending.keys().next().copied()) } } @@ -243,10 +243,10 @@ pub fn handle_permission_response( if let Some(agentic) = &mut session.agentic { if is_remote { // Remote: mark as responded, signal relay publish needed - agentic.responded_perm_ids.insert(request_id); + agentic.permissions.responded.insert(request_id); } else { // Local: send through oneshot channel to Claude process - if let Some(sender) = agentic.pending_permissions.remove(&request_id) { + if let Some(sender) = agentic.permissions.pending.remove(&request_id) { if sender.send(response).is_err() { tracing::error!( "Failed to send permission response for request {}", @@ -377,10 +377,10 @@ pub fn handle_question_response( if is_remote { // Remote: mark as responded, signal relay publish needed - agentic.responded_perm_ids.insert(request_id); + agentic.permissions.responded.insert(request_id); } else { // Local: send through oneshot channel to Claude process - if let Some(sender) = agentic.pending_permissions.remove(&request_id) { + if let Some(sender) = agentic.permissions.pending.remove(&request_id) { let response = PermissionResponse::Allow { message: Some(formatted_response.clone()), };