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