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(), hostname: "naps-laptop".into(),
version: "0.1.0".into(), version: "0.1.0".into(),
daemon_url: "ws://10.0.0.1:7070".into(), daemon_url: "ws://10.0.0.1:7070".into(),
home_dir: String::new(),
}, },
DaemonInfo { DaemonInfo {
daemon_id: "vm1".into(), daemon_id: "vm1".into(),
hostname: "vm1.lan".into(), hostname: "vm1.lan".into(),
version: "0.1.0".into(), version: "0.1.0".into(),
daemon_url: "ws://10.0.0.2:7070".into(), daemon_url: "ws://10.0.0.2:7070".into(),
home_dir: String::new(),
}, },
]; ];
let mut buf = Vec::new(); let mut buf = Vec::new();
@@ -222,6 +224,7 @@ mod tests {
hostname: "naps".into(), hostname: "naps".into(),
version: "0.1.0".into(), version: "0.1.0".into(),
daemon_url: "ws://x".into(), daemon_url: "ws://x".into(),
home_dir: String::new(),
}]; }];
let mut buf = Vec::new(); let mut buf = Vec::new();
super::print_session_table(&mut buf, &[], &daemons); super::print_session_table(&mut buf, &[], &daemons);
@@ -131,6 +131,7 @@ mod tests {
hostname: format!("{daemon_id}-host"), hostname: format!("{daemon_id}-host"),
version: "0.1.0".into(), version: "0.1.0".into(),
daemon_url: format!("ws://{daemon_id}:7070"), 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.daemon_id.clone(),
config.hostname.clone(), config.hostname.clone(),
config.daemon_url.clone(), config.daemon_url.clone(),
config.home_dir.clone(),
config.token.clone(), config.token.clone(),
manager.clone(), manager.clone(),
Some(events.clone()), Some(events.clone()),
@@ -138,6 +139,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
manager.clone(), manager.clone(),
config.hostname.clone(), config.hostname.clone(),
config.daemon_url.clone(), config.daemon_url.clone(),
config.home_dir.clone(),
catalog.clone(), catalog.clone(),
events, events,
client.broker(), client.broker(),
+8
View File
@@ -72,6 +72,10 @@ pub enum ConfigError {
pub struct DaemonConfig { pub struct DaemonConfig {
pub daemon_id: String, pub daemon_id: String,
pub hostname: 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` /// Registry WS URL the daemon dials. Derived from `[shared].registry_url`
/// via [`maestro_config::derive_daemon_ws_url`]. /// via [`maestro_config::derive_daemon_ws_url`].
pub registry_url: String, pub registry_url: String,
@@ -189,6 +193,9 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
let daemon = file.daemon; let daemon = file.daemon;
let hostname = daemon.hostname.unwrap_or_else(system_hostname); 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 let daemon_id = env
.daemon_id .daemon_id
.or(daemon.daemon_id) .or(daemon.daemon_id)
@@ -248,6 +255,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
Ok(DaemonConfig { Ok(DaemonConfig {
daemon_id, daemon_id,
hostname, hostname,
home_dir,
registry_url, registry_url,
bind_addr, bind_addr,
daemon_url, daemon_url,
+7
View File
@@ -54,6 +54,9 @@ pub struct LocalApiState {
pub manager: SessionManager, pub manager: SessionManager,
pub hostname: Arc<String>, pub hostname: Arc<String>,
pub daemon_url: 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 /// Peer-only mirror of the global catalog. Empty until the daemon's
/// registry client connects and receives the snapshot. /// registry client connects and receives the snapshot.
pub catalog: CatalogMirror, pub catalog: CatalogMirror,
@@ -87,6 +90,7 @@ async fn list_daemons(State(s): State<LocalApiState>) -> Json<Vec<DaemonInfo>> {
hostname: (*s.hostname).clone(), hostname: (*s.hostname).clone(),
version: PROTOCOL_VERSION.into(), version: PROTOCOL_VERSION.into(),
daemon_url: (*s.daemon_url).clone(), daemon_url: (*s.daemon_url).clone(),
home_dir: (*s.home_dir).clone(),
}; };
let mut out = vec![self_info]; let mut out = vec![self_info];
out.extend(s.catalog.daemons().await); out.extend(s.catalog.daemons().await);
@@ -206,6 +210,7 @@ pub fn state_for(
manager: SessionManager, manager: SessionManager,
hostname: impl Into<String>, hostname: impl Into<String>,
daemon_url: impl Into<String>, daemon_url: impl Into<String>,
home_dir: impl Into<String>,
catalog: CatalogMirror, catalog: CatalogMirror,
events: LocalEventBus, events: LocalEventBus,
broker: BrokerHandle, broker: BrokerHandle,
@@ -214,6 +219,7 @@ pub fn state_for(
manager, manager,
hostname: Arc::new(hostname.into()), hostname: Arc::new(hostname.into()),
daemon_url: Arc::new(daemon_url.into()), daemon_url: Arc::new(daemon_url.into()),
home_dir: Arc::new(home_dir.into()),
catalog, catalog,
events, events,
broker, broker,
@@ -254,6 +260,7 @@ async fn pump_events(
hostname: (*s.hostname).clone(), hostname: (*s.hostname).clone(),
version: PROTOCOL_VERSION.into(), version: PROTOCOL_VERSION.into(),
daemon_url: (*s.daemon_url).clone(), daemon_url: (*s.daemon_url).clone(),
home_dir: (*s.home_dir).clone(),
}; };
let mut daemons = vec![self_info]; let mut daemons = vec![self_info];
daemons.extend(s.catalog.daemons().await); daemons.extend(s.catalog.daemons().await);
@@ -48,6 +48,7 @@ pub struct RegistryClient {
daemon_id: String, daemon_id: String,
hostname: String, hostname: String,
daemon_url: String, daemon_url: String,
home_dir: String,
token: String, token: String,
/// Operator-set perimeter (id + reachable address) advertised in heartbeats /// Operator-set perimeter (id + reachable address) advertised in heartbeats
/// so peer daemons can dial this one over T2 when they share the same /// so peer daemons can dial this one over T2 when they share the same
@@ -68,11 +69,13 @@ pub struct RegistryClient {
} }
impl RegistryClient { impl RegistryClient {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
registry_url: impl Into<String>, registry_url: impl Into<String>,
daemon_id: impl Into<String>, daemon_id: impl Into<String>,
hostname: impl Into<String>, hostname: impl Into<String>,
daemon_url: impl Into<String>, daemon_url: impl Into<String>,
home_dir: impl Into<String>,
token: impl Into<String>, token: impl Into<String>,
manager: SessionManager, manager: SessionManager,
) -> Self { ) -> Self {
@@ -81,6 +84,7 @@ impl RegistryClient {
daemon_id, daemon_id,
hostname, hostname,
daemon_url, daemon_url,
home_dir,
token, token,
manager, manager,
None, None,
@@ -89,11 +93,13 @@ impl RegistryClient {
/// Variant that wires a [`LocalEventBus`](crate::LocalEventBus) into the /// Variant that wires a [`LocalEventBus`](crate::LocalEventBus) into the
/// catalog mirror so peer deltas reach `WS /api/events` (NAP-248). /// catalog mirror so peer deltas reach `WS /api/events` (NAP-248).
#[allow(clippy::too_many_arguments)]
pub fn new_with_bus( pub fn new_with_bus(
registry_url: impl Into<String>, registry_url: impl Into<String>,
daemon_id: impl Into<String>, daemon_id: impl Into<String>,
hostname: impl Into<String>, hostname: impl Into<String>,
daemon_url: impl Into<String>, daemon_url: impl Into<String>,
home_dir: impl Into<String>,
token: impl Into<String>, token: impl Into<String>,
manager: SessionManager, manager: SessionManager,
bus: Option<crate::LocalEventBus>, bus: Option<crate::LocalEventBus>,
@@ -108,6 +114,7 @@ impl RegistryClient {
daemon_id: daemon_id.into(), daemon_id: daemon_id.into(),
hostname: hostname.into(), hostname: hostname.into(),
daemon_url: daemon_url.into(), daemon_url: daemon_url.into(),
home_dir: home_dir.into(),
token: token.into(), token: token.into(),
perimeter: None, perimeter: None,
manager, manager,
@@ -171,6 +178,7 @@ impl RegistryClient {
version: PROTOCOL_VERSION.into(), version: PROTOCOL_VERSION.into(),
token: self.token.clone(), token: self.token.clone(),
daemon_url: self.daemon_url.clone(), daemon_url: self.daemon_url.clone(),
home_dir: self.home_dir.clone(),
}; };
sink.send(Message::Text(serde_json::to_string(&hello)?)) sink.send(Message::Text(serde_json::to_string(&hello)?))
.await?; .await?;
+1
View File
@@ -117,6 +117,7 @@ async fn list_daemons_includes_self_and_mirrored_peers() {
hostname: "konishi-host".into(), hostname: "konishi-host".into(),
version: "0.1.0".into(), version: "0.1.0".into(),
daemon_url: "ws://10.0.0.2:7070".into(), daemon_url: "ws://10.0.0.2:7070".into(),
home_dir: String::new(),
}, },
}) })
.await; .await;
@@ -58,6 +58,7 @@ async fn daemon_registers_and_streams_snapshot() {
"laptop", "laptop",
"naps-laptop", "naps-laptop",
"ws://127.0.0.1:8081", "ws://127.0.0.1:8081",
"/home/naps62",
TOKEN, TOKEN,
fx.manager.clone(), fx.manager.clone(),
); );
@@ -115,6 +116,7 @@ async fn daemon_reconnects_after_registry_drop() {
"laptop", "laptop",
"naps-laptop", "naps-laptop",
"ws://127.0.0.1:8081", "ws://127.0.0.1:8081",
"/home/naps62",
TOKEN, TOKEN,
fx.manager.clone(), fx.manager.clone(),
); );
@@ -156,6 +158,7 @@ async fn registry_create_session_round_trips() {
"laptop", "laptop",
"naps-laptop", "naps-laptop",
"ws://127.0.0.1:8081", "ws://127.0.0.1:8081",
"/home/naps62",
TOKEN, TOKEN,
fx.manager.clone(), 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(&"alpha"));
assert!(ids.contains(&"beta")); 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"); 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 /// in `GET /api/sessions/:id/connect-info` so the client can connect
/// directly. Example: `ws://10.0.0.1:8081`. /// directly. Example: `ws://10.0.0.1:8081`.
daemon_url: String, 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 { SessionsSnapshot {
sessions: Vec<Session>, sessions: Vec<Session>,
@@ -234,6 +239,11 @@ pub struct DaemonInfo {
pub hostname: String, pub hostname: String,
pub version: String, pub version: String,
pub daemon_url: 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`. /// Response body for `GET /api/sessions/:id/connect-info`.
@@ -528,6 +538,7 @@ mod tests {
version: PROTOCOL_VERSION.into(), version: PROTOCOL_VERSION.into(),
token: "shhh".into(), token: "shhh".into(),
daemon_url: "ws://10.0.0.1:8081".into(), daemon_url: "ws://10.0.0.1:8081".into(),
home_dir: String::new(),
}; };
let v = serde_json::to_value(&hello).unwrap(); let v = serde_json::to_value(&hello).unwrap();
assert_eq!(v["type"], "Hello"); assert_eq!(v["type"], "Hello");
@@ -656,10 +667,25 @@ mod tests {
hostname: "naps-laptop".into(), hostname: "naps-laptop".into(),
version: "0.1.0".into(), version: "0.1.0".into(),
daemon_url: "ws://10.0.0.1:8081".into(), daemon_url: "ws://10.0.0.1:8081".into(),
home_dir: "/home/naps62".into(),
}; };
assert_eq!(d, round_trip(&d)); 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] #[test]
fn connect_info_round_trips() { fn connect_info_round_trips() {
let c = ConnectInfo { let c = ConnectInfo {
@@ -676,6 +702,7 @@ mod tests {
hostname: "naps-laptop".into(), hostname: "naps-laptop".into(),
version: "0.1.0".into(), version: "0.1.0".into(),
daemon_url: "ws://10.0.0.1:8081".into(), daemon_url: "ws://10.0.0.1:8081".into(),
home_dir: String::new(),
}; };
let session = sample_session(); let session = sample_session();
let variants = vec![ let variants = vec![
@@ -882,12 +909,14 @@ mod tests {
hostname: "naps-laptop".into(), hostname: "naps-laptop".into(),
version: "0.1.0".into(), version: "0.1.0".into(),
daemon_url: "ws://10.0.0.1:7070".into(), daemon_url: "ws://10.0.0.1:7070".into(),
home_dir: String::new(),
}, },
DaemonInfo { DaemonInfo {
daemon_id: "konishi".into(), daemon_id: "konishi".into(),
hostname: "konishi".into(), hostname: "konishi".into(),
version: "0.1.0".into(), version: "0.1.0".into(),
daemon_url: "ws://10.0.0.2:7070".into(), daemon_url: "ws://10.0.0.2:7070".into(),
home_dir: String::new(),
}, },
], ],
sessions: vec![mine.clone(), peer_session.clone()], sessions: vec![mine.clone(), peer_session.clone()],
@@ -929,6 +958,7 @@ mod tests {
hostname: "x".into(), hostname: "x".into(),
version: "y".into(), version: "y".into(),
daemon_url: "z".into(), daemon_url: "z".into(),
home_dir: String::new(),
} }
} }
.for_peer("laptop") .for_peer("laptop")
@@ -966,6 +996,7 @@ mod tests {
hostname: "peer-host".into(), hostname: "peer-host".into(),
version: "0.1.0".into(), version: "0.1.0".into(),
daemon_url: "ws://peer:7070".into(), daemon_url: "ws://peer:7070".into(),
home_dir: String::new(),
}], }],
sessions: vec![sample_session()], sessions: vec![sample_session()],
}, },
+4 -2
View File
@@ -38,14 +38,15 @@ pub async fn handle_socket(
None => return None, 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 { DaemonOutbound::Hello {
daemon_id, daemon_id,
hostname, hostname,
version, version,
token, token,
daemon_url, 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; let _ = send_error(&mut sink, "expected_hello", "first message must be Hello").await;
return None; return None;
@@ -66,6 +67,7 @@ pub async fn handle_socket(
hostname, hostname,
version, version,
daemon_url, daemon_url,
home_dir,
out_tx.clone(), out_tx.clone(),
) )
.await; .await;
+15 -2
View File
@@ -83,6 +83,7 @@ pub struct DaemonView {
pub hostname: String, pub hostname: String,
pub version: String, pub version: String,
pub daemon_url: String, pub daemon_url: String,
pub home_dir: String,
} }
impl From<DaemonView> for DaemonInfo { impl From<DaemonView> for DaemonInfo {
@@ -92,6 +93,7 @@ impl From<DaemonView> for DaemonInfo {
hostname: v.hostname, hostname: v.hostname,
version: v.version, version: v.version,
daemon_url: v.daemon_url, daemon_url: v.daemon_url,
home_dir: v.home_dir,
} }
} }
} }
@@ -120,6 +122,7 @@ struct DaemonEntry {
hostname: String, hostname: String,
version: String, version: String,
daemon_url: String, daemon_url: String,
home_dir: String,
outbound: mpsc::Sender<DaemonInbound>, outbound: mpsc::Sender<DaemonInbound>,
/// Latest perimeter advertised by this daemon in its heartbeats (id + addr /// Latest perimeter advertised by this daemon in its heartbeats (id + addr
/// together, or absent). `None` until the daemon advertises one (NAP-247 T2 /// together, or absent). `None` until the daemon advertises one (NAP-247 T2
@@ -139,6 +142,7 @@ impl DaemonEntry {
hostname: self.hostname.clone(), hostname: self.hostname.clone(),
version: self.version.clone(), version: self.version.clone(),
daemon_url: self.daemon_url.clone(), daemon_url: self.daemon_url.clone(),
home_dir: self.home_dir.clone(),
} }
} }
} }
@@ -193,6 +197,7 @@ impl Registry {
hostname: String, hostname: String,
version: String, version: String,
daemon_url: String, daemon_url: String,
home_dir: String,
outbound: mpsc::Sender<DaemonInbound>, outbound: mpsc::Sender<DaemonInbound>,
) -> Uuid { ) -> Uuid {
let connection_token = Uuid::new_v4(); let connection_token = Uuid::new_v4();
@@ -204,6 +209,7 @@ impl Registry {
hostname: hostname.clone(), hostname: hostname.clone(),
version: version.clone(), version: version.clone(),
daemon_url: daemon_url.clone(), daemon_url: daemon_url.clone(),
home_dir: home_dir.clone(),
outbound, outbound,
perimeter: None, perimeter: None,
connection_token, connection_token,
@@ -222,6 +228,7 @@ impl Registry {
hostname, hostname,
version, version,
daemon_url, daemon_url,
home_dir,
}, },
}); });
connection_token connection_token
@@ -874,7 +881,10 @@ mod tests {
// The home daemon gets the relayed result. // The home daemon gets the relayed result.
match home_rx.recv().await.expect("home got ForwardCreateResult") { 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); assert_eq!(got, reply_to);
match result { match result {
RpcResult::Ok { value } => { RpcResult::Ok { value } => {
@@ -924,7 +934,10 @@ mod tests {
let id = Uuid::new_v4(); let id = Uuid::new_v4();
let r = reg.clone(); 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") { let owner_reply_to = match target_rx.recv().await.expect("owner got KillSession") {
DaemonInbound::KillSession { reply_to, id: got } => { DaemonInbound::KillSession { reply_to, id: got } => {
@@ -89,6 +89,7 @@ fn hello(token: &str) -> DaemonOutbound {
version: "0.1.0".into(), version: "0.1.0".into(),
token: token.into(), token: token.into(),
daemon_url: "ws://127.0.0.1:8081".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(), version: "0.1.0".into(),
token: TOKEN.into(), token: TOKEN.into(),
daemon_url: self.daemon_url.clone(), daemon_url: self.daemon_url.clone(),
home_dir: String::new(),
}; };
ws.send(WsMessage::Text(serde_json::to_string(&hello).unwrap())) ws.send(WsMessage::Text(serde_json::to_string(&hello).unwrap()))
.await .await
+2
View File
@@ -514,6 +514,7 @@ mod tests {
hostname: "konishi.lan".into(), hostname: "konishi.lan".into(),
version: "test".into(), version: "test".into(),
daemon_url: "ws://k:7070".into(), daemon_url: "ws://k:7070".into(),
home_dir: String::new(),
}; };
app.catalog.apply_event(RegistryEvent::Snapshot { app.catalog.apply_event(RegistryEvent::Snapshot {
daemons: vec![d.clone()], daemons: vec![d.clone()],
@@ -537,6 +538,7 @@ mod tests {
hostname: "k".into(), hostname: "k".into(),
version: "t".into(), version: "t".into(),
daemon_url: "ws://k".into(), daemon_url: "ws://k".into(),
home_dir: String::new(),
}], }],
sessions: vec![Session { sessions: vec![Session {
id: sid, id: sid,
+1
View File
@@ -205,6 +205,7 @@ mod tests {
hostname: format!("{id}.lan"), hostname: format!("{id}.lan"),
version: "test".into(), version: "test".into(),
daemon_url: format!("ws://{id}.lan:7070"), daemon_url: format!("ws://{id}.lan:7070"),
home_dir: String::new(),
} }
} }
+1
View File
@@ -312,6 +312,7 @@ mod tests {
hostname: "konishi".into(), hostname: "konishi".into(),
version: "0".into(), version: "0".into(),
daemon_url: "ws://x".into(), daemon_url: "ws://x".into(),
home_dir: String::new(),
}], }],
daemon_idx: 0, daemon_idx: 0,
cwd: "/x".into(), cwd: "/x".into(),
+1
View File
@@ -93,6 +93,7 @@ mod tests {
hostname: id.into(), hostname: id.into(),
version: "test".into(), version: "test".into(),
daemon_url: format!("ws://{id}:7070"), 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"), hostname: format!("{id}.lan"),
version: "test".into(), version: "test".into(),
daemon_url: format!("ws://{id}.lan:7070"), daemon_url: format!("ws://{id}.lan:7070"),
home_dir: String::new(),
} }
} }
+16 -1
View File
@@ -25,7 +25,7 @@ export function openSpawnModal(daemons: Map<string, DaemonInfo>): void {
</label> </label>
<label> <label>
Command Command
<input name="command" type="text" required placeholder="bash" autocomplete="off" /> <input name="command" type="text" required value="claude" placeholder="bash" autocomplete="off" />
</label> </label>
<label> <label>
Name (optional) Name (optional)
@@ -82,6 +82,21 @@ export function openSpawnModal(daemons: Map<string, DaemonInfo>): void {
} }
} }
// Prefill the working directory with the selected daemon's home dir, and keep
// it in sync when the daemon changes — unless the user has typed their own.
const cwdInput = host.querySelector("input[name=cwd]") as HTMLInputElement;
let cwdEdited = false;
cwdInput.addEventListener("input", () => {
cwdEdited = true;
});
const applyHomeDir = () => {
if (cwdEdited) return;
const home = daemons.get(select.value)?.home_dir;
if (home) cwdInput.value = home;
};
select.addEventListener("change", applyHomeDir);
applyHomeDir();
const close = () => host.remove(); const close = () => host.remove();
host.querySelector(".close")?.addEventListener("click", close); host.querySelector(".close")?.addEventListener("click", close);
host.querySelector(".cancel")?.addEventListener("click", close); host.querySelector(".cancel")?.addEventListener("click", close);
+3
View File
@@ -22,6 +22,9 @@ export interface DaemonInfo {
hostname: string; hostname: string;
version: string; version: string;
daemon_url: string; daemon_url: string;
/** Home dir of the user the daemon runs as; used to prefill the spawn form's
* working directory. Empty/absent for older daemons. */
home_dir?: string;
} }
export interface WorktreeSpec { export interface WorktreeSpec {