feat(maestro): spawn form defaults command to claude + prefills cwd to daemon home dir (NAP-328)

Two web spawn QoL improvements plus the wire support they need:

- Command field now prefills `claude` instead of an empty box.
- Working directory prefills the selected daemon's home dir, updating on
  daemon change unless the user has typed their own value.

The daemon resolves its `$HOME` via `dirs::home_dir()` at startup and
advertises it end-to-end: a new `home_dir` field on `DaemonOutbound::Hello`
flows through the registry's `register` into `DaemonInfo` (served on
`/api/daemons` and `/api/events`). The field is `#[serde(default)]`, so older
daemons that omit it deserialize to an empty string — backward compatible, and
the UI simply skips prefill when it's empty.

Hierarchical `/`-separated labels in the web sidebar already worked
(web/src/catalog.ts); no change there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 18:07:53 +00:00
co-authored by Claude Opus 4.8
parent 906538c776
commit 6d7cd0b4c8
21 changed files with 119 additions and 5 deletions
+3
View File
@@ -188,12 +188,14 @@ mod tests {
hostname: "naps-laptop".into(),
version: "0.1.0".into(),
daemon_url: "ws://10.0.0.1:7070".into(),
home_dir: String::new(),
},
DaemonInfo {
daemon_id: "vm1".into(),
hostname: "vm1.lan".into(),
version: "0.1.0".into(),
daemon_url: "ws://10.0.0.2:7070".into(),
home_dir: String::new(),
},
];
let mut buf = Vec::new();
@@ -222,6 +224,7 @@ mod tests {
hostname: "naps".into(),
version: "0.1.0".into(),
daemon_url: "ws://x".into(),
home_dir: String::new(),
}];
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[], &daemons);
@@ -131,6 +131,7 @@ mod tests {
hostname: format!("{daemon_id}-host"),
version: "0.1.0".into(),
daemon_url: format!("ws://{daemon_id}:7070"),
home_dir: String::new(),
}
}
+2
View File
@@ -127,6 +127,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
config.daemon_id.clone(),
config.hostname.clone(),
config.daemon_url.clone(),
config.home_dir.clone(),
config.token.clone(),
manager.clone(),
Some(events.clone()),
@@ -138,6 +139,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
manager.clone(),
config.hostname.clone(),
config.daemon_url.clone(),
config.home_dir.clone(),
catalog.clone(),
events,
client.broker(),
+8
View File
@@ -72,6 +72,10 @@ pub enum ConfigError {
pub struct DaemonConfig {
pub daemon_id: String,
pub hostname: String,
/// Home directory of the OS user the daemon runs as, advertised to clients
/// so they can prefill a spawn form's working directory. Empty when `$HOME`
/// can't be resolved (NAP-328).
pub home_dir: String,
/// Registry WS URL the daemon dials. Derived from `[shared].registry_url`
/// via [`maestro_config::derive_daemon_ws_url`].
pub registry_url: String,
@@ -189,6 +193,9 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
let daemon = file.daemon;
let hostname = daemon.hostname.unwrap_or_else(system_hostname);
let home_dir = dirs::home_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let daemon_id = env
.daemon_id
.or(daemon.daemon_id)
@@ -248,6 +255,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
Ok(DaemonConfig {
daemon_id,
hostname,
home_dir,
registry_url,
bind_addr,
daemon_url,
+7
View File
@@ -54,6 +54,9 @@ pub struct LocalApiState {
pub manager: SessionManager,
pub hostname: Arc<String>,
pub daemon_url: Arc<String>,
/// Home directory of the OS user this daemon runs as, advertised so clients
/// can prefill a spawn form's working directory. Empty if unresolved.
pub home_dir: Arc<String>,
/// Peer-only mirror of the global catalog. Empty until the daemon's
/// registry client connects and receives the snapshot.
pub catalog: CatalogMirror,
@@ -87,6 +90,7 @@ async fn list_daemons(State(s): State<LocalApiState>) -> Json<Vec<DaemonInfo>> {
hostname: (*s.hostname).clone(),
version: PROTOCOL_VERSION.into(),
daemon_url: (*s.daemon_url).clone(),
home_dir: (*s.home_dir).clone(),
};
let mut out = vec![self_info];
out.extend(s.catalog.daemons().await);
@@ -206,6 +210,7 @@ pub fn state_for(
manager: SessionManager,
hostname: impl Into<String>,
daemon_url: impl Into<String>,
home_dir: impl Into<String>,
catalog: CatalogMirror,
events: LocalEventBus,
broker: BrokerHandle,
@@ -214,6 +219,7 @@ pub fn state_for(
manager,
hostname: Arc::new(hostname.into()),
daemon_url: Arc::new(daemon_url.into()),
home_dir: Arc::new(home_dir.into()),
catalog,
events,
broker,
@@ -254,6 +260,7 @@ async fn pump_events(
hostname: (*s.hostname).clone(),
version: PROTOCOL_VERSION.into(),
daemon_url: (*s.daemon_url).clone(),
home_dir: (*s.home_dir).clone(),
};
let mut daemons = vec![self_info];
daemons.extend(s.catalog.daemons().await);
@@ -48,6 +48,7 @@ pub struct RegistryClient {
daemon_id: String,
hostname: String,
daemon_url: String,
home_dir: String,
token: String,
/// Operator-set perimeter (id + reachable address) advertised in heartbeats
/// so peer daemons can dial this one over T2 when they share the same
@@ -68,11 +69,13 @@ pub struct RegistryClient {
}
impl RegistryClient {
#[allow(clippy::too_many_arguments)]
pub fn new(
registry_url: impl Into<String>,
daemon_id: impl Into<String>,
hostname: impl Into<String>,
daemon_url: impl Into<String>,
home_dir: impl Into<String>,
token: impl Into<String>,
manager: SessionManager,
) -> Self {
@@ -81,6 +84,7 @@ impl RegistryClient {
daemon_id,
hostname,
daemon_url,
home_dir,
token,
manager,
None,
@@ -89,11 +93,13 @@ impl RegistryClient {
/// Variant that wires a [`LocalEventBus`](crate::LocalEventBus) into the
/// catalog mirror so peer deltas reach `WS /api/events` (NAP-248).
#[allow(clippy::too_many_arguments)]
pub fn new_with_bus(
registry_url: impl Into<String>,
daemon_id: impl Into<String>,
hostname: impl Into<String>,
daemon_url: impl Into<String>,
home_dir: impl Into<String>,
token: impl Into<String>,
manager: SessionManager,
bus: Option<crate::LocalEventBus>,
@@ -108,6 +114,7 @@ impl RegistryClient {
daemon_id: daemon_id.into(),
hostname: hostname.into(),
daemon_url: daemon_url.into(),
home_dir: home_dir.into(),
token: token.into(),
perimeter: None,
manager,
@@ -171,6 +178,7 @@ impl RegistryClient {
version: PROTOCOL_VERSION.into(),
token: self.token.clone(),
daemon_url: self.daemon_url.clone(),
home_dir: self.home_dir.clone(),
};
sink.send(Message::Text(serde_json::to_string(&hello)?))
.await?;
+1
View File
@@ -117,6 +117,7 @@ async fn list_daemons_includes_self_and_mirrored_peers() {
hostname: "konishi-host".into(),
version: "0.1.0".into(),
daemon_url: "ws://10.0.0.2:7070".into(),
home_dir: String::new(),
},
})
.await;
@@ -58,6 +58,7 @@ async fn daemon_registers_and_streams_snapshot() {
"laptop",
"naps-laptop",
"ws://127.0.0.1:8081",
"/home/naps62",
TOKEN,
fx.manager.clone(),
);
@@ -115,6 +116,7 @@ async fn daemon_reconnects_after_registry_drop() {
"laptop",
"naps-laptop",
"ws://127.0.0.1:8081",
"/home/naps62",
TOKEN,
fx.manager.clone(),
);
@@ -156,6 +158,7 @@ async fn registry_create_session_round_trips() {
"laptop",
"naps-laptop",
"ws://127.0.0.1:8081",
"/home/naps62",
TOKEN,
fx.manager.clone(),
);
+9
View File
@@ -159,6 +159,15 @@ async fn daemon_local_api_mirrors_peer_catalog() {
assert!(ids.contains(&"alpha"));
assert!(ids.contains(&"beta"));
// NAP-328: home_dir is advertised end-to-end — for a peer relayed through
// the registry (beta, via Hello → register → catalog mirror), so the web
// spawn form can prefill the working directory.
let beta = list.iter().find(|d| d.daemon_id == "beta").unwrap();
assert!(
!beta.home_dir.is_empty(),
"peer home_dir should propagate through the registry: {beta:?}"
);
client.kill_session(b.id).await.expect("kill b");
}
+31
View File
@@ -100,6 +100,11 @@ pub enum DaemonOutbound {
/// in `GET /api/sessions/:id/connect-info` so the client can connect
/// directly. Example: `ws://10.0.0.1:8081`.
daemon_url: String,
/// Absolute home directory of the OS user this daemon runs as, used by
/// clients to prefill a spawn form's working directory. Empty when the
/// daemon could not resolve `$HOME`. (NAP-328)
#[serde(default)]
home_dir: String,
},
SessionsSnapshot {
sessions: Vec<Session>,
@@ -234,6 +239,11 @@ pub struct DaemonInfo {
pub hostname: String,
pub version: String,
pub daemon_url: String,
/// Absolute home directory of the OS user the daemon runs as. Clients use
/// it to prefill the spawn form's working directory. Empty when unknown
/// (older daemon, or `$HOME` unresolved). (NAP-328)
#[serde(default)]
pub home_dir: String,
}
/// Response body for `GET /api/sessions/:id/connect-info`.
@@ -528,6 +538,7 @@ mod tests {
version: PROTOCOL_VERSION.into(),
token: "shhh".into(),
daemon_url: "ws://10.0.0.1:8081".into(),
home_dir: String::new(),
};
let v = serde_json::to_value(&hello).unwrap();
assert_eq!(v["type"], "Hello");
@@ -656,10 +667,25 @@ mod tests {
hostname: "naps-laptop".into(),
version: "0.1.0".into(),
daemon_url: "ws://10.0.0.1:8081".into(),
home_dir: "/home/naps62".into(),
};
assert_eq!(d, round_trip(&d));
}
#[test]
fn daemon_info_home_dir_defaults_when_absent() {
// Older daemons / pre-NAP-328 payloads omit `home_dir`; it must
// deserialize to an empty string rather than failing.
let json = r#"{
"daemon_id": "laptop",
"hostname": "naps-laptop",
"version": "0.1.0",
"daemon_url": "ws://10.0.0.1:8081"
}"#;
let d: DaemonInfo = serde_json::from_str(json).unwrap();
assert_eq!(d.home_dir, "");
}
#[test]
fn connect_info_round_trips() {
let c = ConnectInfo {
@@ -676,6 +702,7 @@ mod tests {
hostname: "naps-laptop".into(),
version: "0.1.0".into(),
daemon_url: "ws://10.0.0.1:8081".into(),
home_dir: String::new(),
};
let session = sample_session();
let variants = vec![
@@ -882,12 +909,14 @@ mod tests {
hostname: "naps-laptop".into(),
version: "0.1.0".into(),
daemon_url: "ws://10.0.0.1:7070".into(),
home_dir: String::new(),
},
DaemonInfo {
daemon_id: "konishi".into(),
hostname: "konishi".into(),
version: "0.1.0".into(),
daemon_url: "ws://10.0.0.2:7070".into(),
home_dir: String::new(),
},
],
sessions: vec![mine.clone(), peer_session.clone()],
@@ -929,6 +958,7 @@ mod tests {
hostname: "x".into(),
version: "y".into(),
daemon_url: "z".into(),
home_dir: String::new(),
}
}
.for_peer("laptop")
@@ -966,6 +996,7 @@ mod tests {
hostname: "peer-host".into(),
version: "0.1.0".into(),
daemon_url: "ws://peer:7070".into(),
home_dir: String::new(),
}],
sessions: vec![sample_session()],
},
+4 -2
View File
@@ -38,14 +38,15 @@ pub async fn handle_socket(
None => return None,
};
let (daemon_id, hostname, version, token, daemon_url) = match hello {
let (daemon_id, hostname, version, token, daemon_url, home_dir) = match hello {
DaemonOutbound::Hello {
daemon_id,
hostname,
version,
token,
daemon_url,
} => (daemon_id, hostname, version, token, daemon_url),
home_dir,
} => (daemon_id, hostname, version, token, daemon_url, home_dir),
_ => {
let _ = send_error(&mut sink, "expected_hello", "first message must be Hello").await;
return None;
@@ -66,6 +67,7 @@ pub async fn handle_socket(
hostname,
version,
daemon_url,
home_dir,
out_tx.clone(),
)
.await;
+15 -2
View File
@@ -83,6 +83,7 @@ pub struct DaemonView {
pub hostname: String,
pub version: String,
pub daemon_url: String,
pub home_dir: String,
}
impl From<DaemonView> for DaemonInfo {
@@ -92,6 +93,7 @@ impl From<DaemonView> for DaemonInfo {
hostname: v.hostname,
version: v.version,
daemon_url: v.daemon_url,
home_dir: v.home_dir,
}
}
}
@@ -120,6 +122,7 @@ struct DaemonEntry {
hostname: String,
version: String,
daemon_url: String,
home_dir: String,
outbound: mpsc::Sender<DaemonInbound>,
/// Latest perimeter advertised by this daemon in its heartbeats (id + addr
/// together, or absent). `None` until the daemon advertises one (NAP-247 T2
@@ -139,6 +142,7 @@ impl DaemonEntry {
hostname: self.hostname.clone(),
version: self.version.clone(),
daemon_url: self.daemon_url.clone(),
home_dir: self.home_dir.clone(),
}
}
}
@@ -193,6 +197,7 @@ impl Registry {
hostname: String,
version: String,
daemon_url: String,
home_dir: String,
outbound: mpsc::Sender<DaemonInbound>,
) -> Uuid {
let connection_token = Uuid::new_v4();
@@ -204,6 +209,7 @@ impl Registry {
hostname: hostname.clone(),
version: version.clone(),
daemon_url: daemon_url.clone(),
home_dir: home_dir.clone(),
outbound,
perimeter: None,
connection_token,
@@ -222,6 +228,7 @@ impl Registry {
hostname,
version,
daemon_url,
home_dir,
},
});
connection_token
@@ -874,7 +881,10 @@ mod tests {
// The home daemon gets the relayed result.
match home_rx.recv().await.expect("home got ForwardCreateResult") {
DaemonInbound::ForwardCreateResult { reply_to: got, result } => {
DaemonInbound::ForwardCreateResult {
reply_to: got,
result,
} => {
assert_eq!(got, reply_to);
match result {
RpcResult::Ok { value } => {
@@ -924,7 +934,10 @@ mod tests {
let id = Uuid::new_v4();
let r = reg.clone();
let task = tokio::spawn(async move { r.forward_kill("laptop", Uuid::new_v4(), "konishi", id).await });
let task = tokio::spawn(async move {
r.forward_kill("laptop", Uuid::new_v4(), "konishi", id)
.await
});
let owner_reply_to = match target_rx.recv().await.expect("owner got KillSession") {
DaemonInbound::KillSession { reply_to, id: got } => {
@@ -89,6 +89,7 @@ fn hello(token: &str) -> DaemonOutbound {
version: "0.1.0".into(),
token: token.into(),
daemon_url: "ws://127.0.0.1:8081".into(),
home_dir: String::new(),
}
}
@@ -118,6 +118,7 @@ impl MockDaemon {
version: "0.1.0".into(),
token: TOKEN.into(),
daemon_url: self.daemon_url.clone(),
home_dir: String::new(),
};
ws.send(WsMessage::Text(serde_json::to_string(&hello).unwrap()))
.await
+2
View File
@@ -514,6 +514,7 @@ mod tests {
hostname: "konishi.lan".into(),
version: "test".into(),
daemon_url: "ws://k:7070".into(),
home_dir: String::new(),
};
app.catalog.apply_event(RegistryEvent::Snapshot {
daemons: vec![d.clone()],
@@ -537,6 +538,7 @@ mod tests {
hostname: "k".into(),
version: "t".into(),
daemon_url: "ws://k".into(),
home_dir: String::new(),
}],
sessions: vec![Session {
id: sid,
+1
View File
@@ -205,6 +205,7 @@ mod tests {
hostname: format!("{id}.lan"),
version: "test".into(),
daemon_url: format!("ws://{id}.lan:7070"),
home_dir: String::new(),
}
}
+1
View File
@@ -312,6 +312,7 @@ mod tests {
hostname: "konishi".into(),
version: "0".into(),
daemon_url: "ws://x".into(),
home_dir: String::new(),
}],
daemon_idx: 0,
cwd: "/x".into(),
+1
View File
@@ -93,6 +93,7 @@ mod tests {
hostname: id.into(),
version: "test".into(),
daemon_url: format!("ws://{id}:7070"),
home_dir: String::new(),
}
}
+1
View File
@@ -19,6 +19,7 @@ fn daemon(id: &str) -> DaemonInfo {
hostname: format!("{id}.lan"),
version: "test".into(),
daemon_url: format!("ws://{id}.lan:7070"),
home_dir: String::new(),
}
}