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