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:
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user