commit e5c350351903b6bc3850a328192de62b62731bf2
parent 7ff4dc68999584811cd4ac35acf61dde3c47e55b
Author: William Casarin <jb55@jb55.com>
Date: Fri, 27 Feb 2026 11:42:47 -0800
fix: separate Nostr event identity from CLI session ID for remote spawn
Every AgenticSessionData now gets a permanent `event_id` (UUID) at
creation for use as the Nostr d-tag. This is independent of the Claude
CLI session ID used for --resume. A new `cli_session` tag in kind-31988
state events persists the real CLI session ID separately, enabling
spawn-created sessions to publish state events immediately (before the
backend starts) so remote clients can discover them.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
5 files changed, 128 insertions(+), 54 deletions(-)
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -290,7 +290,7 @@ fn ingest_live_event(
tool_name: Option<&str>,
) -> Option<session_events::BuiltEvent> {
let agentic = session.agentic.as_mut()?;
- let session_id = agentic.event_session_id().map(|s| s.to_string())?;
+ let session_id = agentic.event_session_id().to_string();
let cwd = agentic.cwd.to_str();
match session_events::build_live_event(
@@ -1289,19 +1289,16 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
continue;
};
- let Some(claude_sid) = agentic.event_session_id() else {
- continue;
- };
- let claude_sid = claude_sid.to_string();
-
+ let event_sid = agentic.event_session_id().to_string();
let cwd = agentic.cwd.to_string_lossy();
let status = session.status().as_str();
let indicator = session.indicator.as_ref().map(|i| i.as_str());
let perm_mode = crate::session::permission_mode_to_str(agentic.permission_mode);
+ let cli_sid = agentic.cli_resume_id().map(|s| s.to_string());
queue_built_event(
session_events::build_session_state_event(
- &claude_sid,
+ &event_sid,
&session.details.title,
session.details.custom_title.as_deref(),
&cwd,
@@ -1311,9 +1308,10 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
&session.details.home_dir,
session.backend_type.as_str(),
perm_mode,
+ cli_sid.as_deref(),
&sk,
),
- &format!("publishing session state: {} -> {}", claude_sid, status),
+ &format!("publishing session state: {} -> {}", event_sid, status),
ctx.ndb,
&sk,
&mut self.pending_relay_events,
@@ -1347,6 +1345,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
&info.home_dir,
info.backend.as_str(),
"default",
+ None,
&sk,
),
&format!(
@@ -1384,10 +1383,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
Some(a) => a,
None => return,
};
- let session_id = match agentic.event_session_id() {
- Some(id) => id.to_string(),
- None => return,
- };
+ let session_id = agentic.event_session_id().to_string();
for resp in pending {
let request_note_id = match agentic.permissions.request_note_ids.get(&resp.perm_id) {
@@ -1471,9 +1467,26 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.and_then(BackendType::from_tag_str)
.unwrap_or(BackendType::Claude);
let cwd = std::path::PathBuf::from(&state.cwd);
+
+ // The d-tag is the event_id (Nostr identity). The cli_session
+ // tag holds the real CLI session ID for --resume. If there's
+ // no cli_session tag, this is a legacy event where d-tag was
+ // the CLI session ID.
+ let resume_id = match state.cli_session_id {
+ Some(ref cli) if !cli.is_empty() => cli.clone(),
+ Some(_) => {
+ // Empty cli_session — backend never started, nothing to resume
+ String::new()
+ }
+ None => {
+ // Legacy: d-tag IS the CLI session ID
+ state.claude_session_id.clone()
+ }
+ };
+
let dave_sid = self.session_manager.new_resumed_session(
cwd,
- state.claude_session_id.clone(),
+ resume_id,
state.title.clone(),
AiMode::Agentic,
backend,
@@ -1517,6 +1530,17 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
}
if let Some(agentic) = &mut session.agentic {
+ // Restore the event_id from the d-tag so published
+ // state events keep using the same Nostr identity.
+ agentic.event_id = state.claude_session_id.clone();
+
+ // If cli_session was empty the backend never ran —
+ // clear resume_session_id so we don't try --resume
+ // with the event UUID.
+ if state.cli_session_id.as_ref().is_some_and(|s| s.is_empty()) {
+ agentic.resume_session_id = None;
+ }
+
if let (Some(root), Some(last)) = (loaded.root_note_id, loaded.last_note_id) {
agentic.live_threading.seed(root, last, loaded.event_count);
}
@@ -1572,11 +1596,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
let mut 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()))
- })
+ .filter_map(|s| s.agentic.as_ref().map(|a| a.event_session_id().to_string()))
.collect();
for key in note_keys {
@@ -1603,7 +1623,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.iter()
.filter(|s| {
s.agentic.as_ref().is_some_and(|a| {
- a.event_session_id() == Some(claude_sid) && ts > a.remote_status_ts
+ a.event_session_id() == claude_sid && ts > a.remote_status_ts
})
})
.map(|s| s.id)
@@ -1639,8 +1659,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
for session in self.session_manager.iter_mut() {
let is_remote = session.is_remote();
if let Some(agentic) = &mut session.agentic {
- if agentic.event_session_id() == Some(claude_sid)
- && ts > agentic.remote_status_ts
+ if agentic.event_session_id() == claude_sid && ts > agentic.remote_status_ts
{
agentic.remote_status_ts = ts;
// custom_title syncs for both local and remote
@@ -1705,9 +1724,17 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.and_then(BackendType::from_tag_str)
.unwrap_or(BackendType::Claude);
let cwd = std::path::PathBuf::from(&state.cwd);
+
+ // Same event_id / cli_session logic as restore_sessions_from_ndb
+ let resume_id = match state.cli_session_id {
+ Some(ref cli) if !cli.is_empty() => cli.clone(),
+ Some(_) => String::new(), // backend never started
+ None => claude_sid.to_string(), // legacy
+ };
+
let dave_sid = self.session_manager.new_resumed_session(
cwd,
- claude_sid.to_string(),
+ resume_id,
state.title.clone(),
AiMode::Agentic,
backend,
@@ -1739,6 +1766,16 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
}
if let Some(agentic) = &mut session.agentic {
+ // Restore the event_id from the d-tag
+ agentic.event_id = claude_sid.to_string();
+
+ // If cli_session was empty the backend never ran —
+ // clear resume_session_id so we don't try --resume
+ // with the event UUID.
+ if state.cli_session_id.as_ref().is_some_and(|s| s.is_empty()) {
+ agentic.resume_session_id = None;
+ }
+
if let (Some(root), Some(last)) = (loaded.root_note_id, loaded.last_note_id) {
agentic.live_threading.seed(root, last, loaded.event_count);
}
@@ -1825,7 +1862,16 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
);
self.processed_commands.insert(command_id.to_string());
- self.create_session_with_cwd(PathBuf::from(cwd), backend);
+ update::create_session_with_cwd(
+ &mut self.session_manager,
+ &mut self.directory_picker,
+ &mut self.scene,
+ self.show_scene,
+ self.ai_mode,
+ PathBuf::from(cwd),
+ &self.hostname,
+ backend,
+ );
}
}
@@ -2029,15 +2075,13 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
// 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.details.title.clone(),
- cwd: agentic.cwd.to_string_lossy().to_string(),
- home_dir: session.details.home_dir.clone(),
- backend: session.backend_type,
- });
- }
+ self.pending_deletions.push(DeletedSessionInfo {
+ claude_session_id: agentic.event_session_id().to_string(),
+ title: session.details.title.clone(),
+ cwd: agentic.cwd.to_string_lossy().to_string(),
+ home_dir: session.details.home_dir.clone(),
+ backend: session.backend_type,
+ });
}
}
@@ -2377,7 +2421,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
let resume_session_id = session
.agentic
.as_ref()
- .and_then(|a| a.resume_session_id.clone());
+ .and_then(|a| a.cli_resume_id().map(|s| s.to_string()));
let backend_type = session.backend_type;
let tools = self.tools.clone();
let model_name = if backend_type == self.model_config.backend {
@@ -2890,7 +2934,7 @@ impl notedeck::App for Dave {
///
/// Subscribes to kind-1988 events tagged with the session's claude ID so we
/// receive messages from remote clients (phone) even before the local backend starts.
-fn setup_conversation_subscription(
+pub(crate) fn setup_conversation_subscription(
agentic: &mut session::AgenticSessionData,
claude_session_id: &str,
ndb: &nostrdb::Ndb,
@@ -3011,7 +3055,7 @@ fn handle_permission_request(
let event_session_id = session
.agentic
.as_ref()
- .and_then(|a| a.event_session_id().map(|s| s.to_string()));
+ .map(|a| a.event_session_id().to_string());
if let Some(sid) = event_session_id {
match session_events::build_permission_request_event(
@@ -3091,17 +3135,16 @@ fn handle_remote_permission_request(
);
agentic.permissions.responded.insert(perm_id);
if let Some(sk) = secret_key {
- if let Some(sid) = agentic.event_session_id().map(|s| s.to_string()) {
- if let Ok(evt) = session_events::build_permission_response_event(
- &perm_id,
- note.id(),
- true,
- None,
- &sid,
- sk,
- ) {
- events_to_publish.push(evt);
- }
+ let sid = agentic.event_session_id();
+ if let Ok(evt) = session_events::build_permission_response_event(
+ &perm_id,
+ note.id(),
+ true,
+ None,
+ sid,
+ sk,
+ ) {
+ events_to_publish.push(evt);
}
}
chat.push(Message::PermissionRequest(
diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs
@@ -239,6 +239,10 @@ pub struct AgenticSessionData {
/// For Bash: stores binary names (first word of command).
/// For other tools: stores the tool name.
pub runtime_allows: HashSet<String>,
+ /// Stable Nostr event identity for this session (d-tag for kind-31988
+ /// and kind-1988 events). Generated at creation, never changes.
+ /// Separate from the Claude CLI session ID used for `--resume`.
+ pub event_id: String,
}
impl AgenticSessionData {
@@ -274,6 +278,7 @@ impl AgenticSessionData {
compact_and_proceed: CompactAndProceedState::Idle,
usage: Default::default(),
runtime_allows: HashSet::new(),
+ event_id: uuid::Uuid::new_v4().to_string(),
}
}
@@ -313,10 +318,19 @@ impl AgenticSessionData {
Some(key)
}
- /// Get the session ID to use for live kind-1988 events.
+ /// Stable Nostr event identity (d-tag for kind-1988 / kind-31988).
///
- /// Prefers claude_session_id from SessionInfo, falls back to resume_session_id.
- pub fn event_session_id(&self) -> Option<&str> {
+ /// This is always available — every session gets a UUID at creation.
+ /// It is independent of the Claude CLI session ID.
+ pub fn event_session_id(&self) -> &str {
+ &self.event_id
+ }
+
+ /// Get the CLI session ID for backend `--resume`.
+ ///
+ /// Returns the real Claude CLI session ID. `None` means the backend
+ /// hasn't started yet (no session to resume).
+ pub fn cli_resume_id(&self) -> Option<&str> {
self.session_info
.as_ref()
.and_then(|i| i.claude_session_id.as_deref())
@@ -481,7 +495,7 @@ impl ChatSession {
task_handle: None,
dispatch_state: DispatchState::Idle,
cached_status: AgentStatus::Idle,
- state_dirty: false,
+ state_dirty: true,
focus_requested: false,
ai_mode,
agentic,
@@ -512,7 +526,9 @@ impl ChatSession {
) -> Self {
let mut session = Self::new(id, cwd, ai_mode, backend_type);
if let Some(ref mut agentic) = session.agentic {
- agentic.resume_session_id = Some(resume_session_id);
+ if !resume_session_id.is_empty() {
+ agentic.resume_session_id = Some(resume_session_id);
+ }
}
session.details.title = title;
session
diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs
@@ -735,7 +735,7 @@ pub fn build_permission_response_event(
/// with same (kind, pubkey, d-tag).
#[allow(clippy::too_many_arguments)]
pub fn build_session_state_event(
- claude_session_id: &str,
+ event_session_id: &str,
title: &str,
custom_title: Option<&str>,
cwd: &str,
@@ -745,12 +745,13 @@ pub fn build_session_state_event(
home_dir: &str,
backend: &str,
permission_mode: &str,
+ cli_session_id: Option<&str>,
secret_key: &[u8; 32],
) -> Result<BuiltEvent, EventBuildError> {
let mut builder = init_note_builder(AI_SESSION_STATE_KIND, "", Some(now_secs()));
// Session identity (makes this a parameterized replaceable event)
- builder = builder.start_tag().tag_str("d").tag_str(claude_session_id);
+ builder = builder.start_tag().tag_str("d").tag_str(event_session_id);
// Session metadata as tags
builder = builder.start_tag().tag_str("title").tag_str(title);
@@ -770,6 +771,14 @@ pub fn build_session_state_event(
.tag_str("permission-mode")
.tag_str(permission_mode);
+ // Real Claude CLI session ID for backend --resume.
+ // Empty string means the backend hasn't started yet.
+ // Absent (old events) means the d-tag itself is the CLI ID.
+ builder = builder
+ .start_tag()
+ .tag_str("cli_session")
+ .tag_str(cli_session_id.unwrap_or(""));
+
// Discoverability
builder = builder.start_tag().tag_str("t").tag_str("ai-session-state");
builder = builder.start_tag().tag_str("t").tag_str("ai-conversation");
@@ -1346,6 +1355,7 @@ mod tests {
"/home/testuser",
"claude",
"plan",
+ None,
&sk,
)
.unwrap();
diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs
@@ -256,6 +256,10 @@ pub struct SessionState {
pub backend: Option<String>,
pub permission_mode: Option<String>,
pub created_at: u64,
+ /// Real CLI session ID when the d-tag is a provisional UUID.
+ /// Present only for sessions created via spawn commands.
+ /// Empty string means the backend hasn't started yet.
+ pub cli_session_id: Option<String>,
}
impl SessionState {
@@ -281,6 +285,7 @@ impl SessionState {
backend: get_tag_value(note, "backend").map(|s| s.to_string()),
permission_mode: get_tag_value(note, "permission-mode").map(|s| s.to_string()),
created_at: note.created_at(),
+ cli_session_id: get_tag_value(note, "cli_session").map(|s| s.to_string()),
})
}
}
diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs
@@ -141,7 +141,7 @@ pub fn cycle_permission_mode(
let result = if is_remote {
// Remote session: return info for caller to publish command event
- let event_sid = agentic.event_session_id()?.to_string();
+ let event_sid = agentic.event_session_id().to_string();
Some(ModeCommandPublish {
session_id: event_sid,
mode: mode_str,