notedeck

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

commit c4a97163b79d39f39cdf1a0dd6245b1898eda52b
parent b2b14fb6c7b3116e7ab3222e7f72204215f8f8ad
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 30 Jan 2026 12:59:21 -0800

feat(dave): add notedeck-spawn CLI for spawning agents externally

Adds IPC support via Unix domain sockets to allow spawning new agent
sessions from the terminal. The running notedeck instance listens on
$XDG_RUNTIME_DIR/notedeck/spawn.sock for spawn requests.

Usage:
  notedeck-spawn              # spawn with current directory
  notedeck-spawn /path/to/dir # spawn with specific directory

Also enables default-run in chrome crate so `cargo run` still works.

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

Diffstat:
Mcrates/notedeck_chrome/Cargo.toml | 2+-
Mcrates/notedeck_dave/Cargo.toml | 4++++
Acrates/notedeck_dave/src/bin/notedeck-spawn.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/ipc.rs | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 343 insertions(+), 1 deletion(-)

diff --git a/crates/notedeck_chrome/Cargo.toml b/crates/notedeck_chrome/Cargo.toml @@ -3,7 +3,7 @@ name = "notedeck_chrome" version = { workspace = true } authors = ["William Casarin <jb55@jb55.com>", "kernelkind <kernelkind@gmail.com>"] edition = "2021" -#default-run = "notedeck" +default-run = "notedeck" #rust-version = "1.60" license = "GPLv3" description = "The nostr browser" diff --git a/crates/notedeck_dave/Cargo.toml b/crates/notedeck_dave/Cargo.toml @@ -33,3 +33,7 @@ dirs = "5" [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] } + +[[bin]] +name = "notedeck-spawn" +path = "src/bin/notedeck-spawn.rs" diff --git a/crates/notedeck_dave/src/bin/notedeck-spawn.rs b/crates/notedeck_dave/src/bin/notedeck-spawn.rs @@ -0,0 +1,96 @@ +//! CLI tool to spawn a new agent in a running notedeck instance. +//! +//! Usage: +//! notedeck-spawn # spawn with current directory +//! notedeck-spawn /path/to/dir # spawn with specific directory + +#[cfg(unix)] +fn main() { + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixStream; + use std::path::PathBuf; + + // Parse arguments + let cwd = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().expect("Failed to get current directory")); + + // Canonicalize path + let cwd = match cwd.canonicalize() { + Ok(p) => p, + Err(e) => { + eprintln!("Error: Invalid path '{}': {}", cwd.display(), e); + std::process::exit(1); + } + }; + + // Validate it's a directory + if !cwd.is_dir() { + eprintln!("Error: '{}' is not a directory", cwd.display()); + std::process::exit(1); + } + + let socket_path = notedeck_dave::ipc::socket_path(); + + // Connect to the running notedeck instance + let mut stream = match UnixStream::connect(&socket_path) { + Ok(s) => s, + Err(e) => { + eprintln!("Could not connect to notedeck at {}", socket_path.display()); + eprintln!("Error: {}", e); + eprintln!(); + eprintln!("Is notedeck running? Start it first with `notedeck`"); + std::process::exit(1); + } + }; + + // Send spawn request + let request = serde_json::json!({ + "type": "spawn_agent", + "cwd": cwd + }); + + if let Err(e) = writeln!(stream, "{}", request) { + eprintln!("Failed to send request: {}", e); + std::process::exit(1); + } + + // Read response + let mut reader = BufReader::new(&stream); + let mut response = String::new(); + if let Err(e) = reader.read_line(&mut response) { + eprintln!("Failed to read response: {}", e); + std::process::exit(1); + } + + // Parse and display response + match serde_json::from_str::<serde_json::Value>(&response) { + Ok(json) => { + if json.get("status").and_then(|s| s.as_str()) == Some("ok") { + if let Some(id) = json.get("session_id") { + println!("Agent spawned (session {})", id); + } else { + println!("Agent spawned"); + } + } else if let Some(msg) = json.get("message").and_then(|m| m.as_str()) { + eprintln!("Error: {}", msg); + std::process::exit(1); + } else { + eprintln!("Unknown response: {}", response); + std::process::exit(1); + } + } + Err(e) => { + eprintln!("Invalid response: {}", e); + eprintln!("Raw: {}", response); + std::process::exit(1); + } + } +} + +#[cfg(not(unix))] +fn main() { + eprintln!("notedeck-spawn is only supported on Unix systems (Linux, macOS)"); + std::process::exit(1); +} diff --git a/crates/notedeck_dave/src/ipc.rs b/crates/notedeck_dave/src/ipc.rs @@ -0,0 +1,172 @@ +//! IPC module for external spawn-agent commands via Unix domain sockets. +//! +//! This allows external tools (like `notedeck-spawn`) to create new agent +//! sessions in a running notedeck instance. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Request to spawn a new agent +#[derive(Debug, Serialize, Deserialize)] +pub struct SpawnRequest { + #[serde(rename = "type")] + pub request_type: String, + pub cwd: PathBuf, +} + +/// Response to a spawn request +#[derive(Debug, Serialize, Deserialize)] +pub struct SpawnResponse { + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option<u32>, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option<String>, +} + +impl SpawnResponse { + pub fn ok(session_id: u32) -> Self { + Self { + status: "ok".to_string(), + session_id: Some(session_id), + message: None, + } + } + + pub fn error(message: impl Into<String>) -> Self { + Self { + status: "error".to_string(), + session_id: None, + message: Some(message.into()), + } + } +} + +/// Returns the path to the IPC socket. +/// +/// Uses XDG_RUNTIME_DIR on Linux (e.g., /run/user/1000/notedeck/spawn.sock) +/// or falls back to a user-local directory. +pub fn socket_path() -> PathBuf { + // Try XDG_RUNTIME_DIR first (Linux) + if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { + return PathBuf::from(runtime_dir) + .join("notedeck") + .join("spawn.sock"); + } + + // macOS: use Application Support + #[cfg(target_os = "macos")] + if let Some(home) = dirs::home_dir() { + return home + .join("Library") + .join("Application Support") + .join("notedeck") + .join("spawn.sock"); + } + + // Fallback: ~/.local/share/notedeck + dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("notedeck") + .join("spawn.sock") +} + +#[cfg(unix)] +pub use unix::*; + +#[cfg(unix)] +mod unix { + use super::*; + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixListener; + + /// Creates a non-blocking Unix domain socket listener. + /// + /// Returns None if the socket cannot be created (e.g., permission issues). + /// The socket file is removed if it already exists (stale from crash). + pub fn create_listener() -> Option<UnixListener> { + let path = socket_path(); + + // Ensure parent directory exists + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + tracing::warn!("Failed to create IPC socket directory: {}", e); + return None; + } + } + + // Remove stale socket if it exists + if path.exists() { + if let Err(e) = std::fs::remove_file(&path) { + tracing::warn!("Failed to remove stale socket: {}", e); + return None; + } + } + + // Create and bind the listener + match UnixListener::bind(&path) { + Ok(listener) => { + // Set non-blocking for polling in event loop + if let Err(e) = listener.set_nonblocking(true) { + tracing::warn!("Failed to set socket non-blocking: {}", e); + return None; + } + tracing::info!("IPC listener started at {}", path.display()); + Some(listener) + } + Err(e) => { + tracing::warn!("Failed to create IPC listener: {}", e); + None + } + } + } + + /// Handles a single IPC connection, returning the cwd if valid spawn request. + pub fn handle_connection( + stream: &mut std::os::unix::net::UnixStream, + ) -> Result<PathBuf, String> { + // Read the request line + let mut reader = BufReader::new(stream.try_clone().map_err(|e| e.to_string())?); + let mut line = String::new(); + reader.read_line(&mut line).map_err(|e| e.to_string())?; + + // Parse JSON request + let request: SpawnRequest = + serde_json::from_str(&line).map_err(|e| format!("Invalid JSON: {}", e))?; + + // Validate request type + if request.request_type != "spawn_agent" { + return Err(format!("Unknown request type: {}", request.request_type)); + } + + // Validate path exists and is a directory + if !request.cwd.exists() { + return Err(format!("Path does not exist: {}", request.cwd.display())); + } + if !request.cwd.is_dir() { + return Err(format!( + "Path is not a directory: {}", + request.cwd.display() + )); + } + + Ok(request.cwd) + } + + /// Sends a response back to the client + pub fn send_response( + stream: &mut std::os::unix::net::UnixStream, + response: &SpawnResponse, + ) -> std::io::Result<()> { + let json = serde_json::to_string(response)?; + writeln!(stream, "{}", json)?; + stream.flush() + } +} + +// Stub for non-Unix platforms (Windows) +#[cfg(not(unix))] +pub fn create_listener() -> Option<()> { + tracing::info!("IPC spawn-agent not supported on this platform"); + None +} diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -4,6 +4,7 @@ mod backend; mod config; pub mod file_update; mod focus_queue; +pub mod ipc; pub(crate) mod mesh; mod messages; mod quaternion; @@ -87,6 +88,9 @@ pub struct Dave { directory_picker: DirectoryPicker, /// Current overlay taking over the UI (if any) active_overlay: DaveOverlay, + /// IPC listener for external spawn-agent commands (Unix only) + #[cfg(unix)] + ipc_listener: Option<std::os::unix::net::UnixListener>, } /// Calculate an anonymous user_id from a keypair @@ -161,6 +165,10 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let directory_picker = DirectoryPicker::new(); + // Create IPC listener for external spawn-agent commands (Unix only) + #[cfg(unix)] + let ipc_listener = ipc::create_listener(); + Dave { backend, avatar, @@ -179,6 +187,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr directory_picker, // Auto-show directory picker on startup since there are no sessions active_overlay: DaveOverlay::DirectoryPicker, + #[cfg(unix)] + ipc_listener, } } @@ -761,6 +771,62 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + /// Poll for IPC spawn-agent commands from external tools (Unix only) + #[cfg(unix)] + fn poll_ipc_commands(&mut self) { + use std::io::ErrorKind; + + let Some(listener) = self.ipc_listener.as_ref() else { + return; + }; + + // Non-blocking accept - check for incoming connections + match listener.accept() { + Ok((mut stream, _)) => { + // Handle the connection + match ipc::handle_connection(&mut stream) { + Ok(cwd) => { + // Create the session and get its ID + let id = self.session_manager.new_session(cwd.clone()); + self.directory_picker.add_recent(cwd); + + // Focus on new session + if let Some(session) = self.session_manager.get_mut(id) { + session.focus_requested = true; + if self.show_scene { + self.scene.select(id); + self.scene.focus_on(session.scene_position); + } + } + + // Close directory picker if open + if self.active_overlay == DaveOverlay::DirectoryPicker { + self.active_overlay = DaveOverlay::None; + } + + // Send success response + let response = ipc::SpawnResponse::ok(id); + let _ = ipc::send_response(&mut stream, &response); + + tracing::info!("Spawned agent via IPC (session {})", id); + } + Err(e) => { + // Send error response + let response = ipc::SpawnResponse::error(&e); + let _ = ipc::send_response(&mut stream, &response); + tracing::warn!("IPC spawn-agent failed: {}", e); + } + } + } + Err(ref e) if e.kind() == ErrorKind::WouldBlock => { + // No pending connections, this is normal + } + Err(e) => { + tracing::warn!("IPC accept error: {}", e); + } + } + } + /// Delete a session and clean up backend resources fn delete_session(&mut self, id: SessionId) { // Remove from focus queue first @@ -1442,6 +1508,10 @@ impl notedeck::App for Dave { } } + // Poll for external spawn-agent commands via IPC (Unix only) + #[cfg(unix)] + self.poll_ipc_commands(); + // Handle global keybindings (when no text input has focus) let has_pending_permission = self.first_pending_permission().is_some(); let has_pending_question = self.has_pending_question();