herdr phase 2: Live Pane tab (xterm.js → node's herdr TUI)
The killer UX feature: click a mission's Live Pane tab and watch the
actual Herdr TUI on the target node in the browser — cursor, colors,
tool output, all live. WebRTC DataChannel direct where the browser
can reach the node peer-to-peer, WS-relayed fallback otherwise
(same auto-negotiation the INFRA node terminal already uses).
Zero new deployment infra — reuses the existing terminal_ticket +
terminal_ws + PTY-over-control-channel machinery. The one primitive
we grew: PtyTarget::Command variant so the node can spawn an
arbitrary program (\`herdr\`) in the PTY instead of the login shell.
Node daemon (clawmates-node):
- PtyTarget grows a Command { argv } variant
- spawn_command_pty resolves bare names against user + system bin
dirs (matches how tool_update finds claude/kimi)
- PtyTarget::from_frame reads the `command` array from the pty_open
frame; precedence Command > Container > Host
cm-api:
- NodeHub::open_pty grows an optional command argv; when set, the
frame carries it and the daemon spawns the program directly.
- routes::nodes::TermCtrl gains a `command: Vec<String>`; the
fallback branch threads it through.
Frontend:
- core.ts::webrtcConnector takes an optional commandOverride
that ships inside the fallback frame
- nodeHerdrConnector(nodeId) — mints the standard ticket + WS URL
but overrides command to ["herdr"]
- MissionCanvas grows a "pane" tab, visible only when
runtime_kind='local_herdr'. LivePane subcomponent uses xterm.js
(already a workspace dep) via useResilientTerminal, shows a
connecting/relayed/direct pill in the corner.
To watch a mission live: pick "On a fleet node (Herdr)" + target
node in the wizard, launch, click Pane tab → node's Herdr TUI
appears. Navigate to the mission workspace in the Herdr sidebar
(mouse or prefix+w) to zoom into the mission's pane.
Focus-a-specific-pane-directly is a later enhancement — Herdr has
no CLI arg for it yet, so operator navigates the sidebar for now.
Verified: cargo check --workspace + tsc --noEmit both green.
This commit is contained in:
@@ -672,12 +672,25 @@ fn spawn_container_pty(
|
||||
pub(crate) enum PtyTarget {
|
||||
Host,
|
||||
Container { container: String, session: String },
|
||||
/// Custom argv (Herdr Live Pane uses this to spawn `herdr` directly
|
||||
/// so the browser xterm attaches straight into the node's Herdr TUI
|
||||
/// instead of a login shell).
|
||||
Command { argv: Vec<String> },
|
||||
}
|
||||
|
||||
impl PtyTarget {
|
||||
/// Parse from a control frame: a non-empty `container` field selects the
|
||||
/// container path (with an optional `session`, default "main").
|
||||
/// Parse from a control frame. Precedence: explicit `command` (non-
|
||||
/// empty array) → Command; else `container` → Container; else Host.
|
||||
pub(crate) fn from_frame(v: &Value) -> Self {
|
||||
if let Some(argv) = v.get("command").and_then(Value::as_array) {
|
||||
let parts: Vec<String> = argv
|
||||
.iter()
|
||||
.filter_map(|x| x.as_str().map(str::to_owned))
|
||||
.collect();
|
||||
if !parts.is_empty() {
|
||||
return PtyTarget::Command { argv: parts };
|
||||
}
|
||||
}
|
||||
match v.get("container").and_then(Value::as_str) {
|
||||
Some(c) if !c.is_empty() => PtyTarget::Container {
|
||||
container: c.to_owned(),
|
||||
@@ -697,10 +710,42 @@ impl PtyTarget {
|
||||
PtyTarget::Container { container, session } => {
|
||||
spawn_container_pty(container, session, cols, rows)
|
||||
}
|
||||
PtyTarget::Command { argv } => spawn_command_pty(argv, cols, rows),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn an arbitrary command in a PTY. Argv[0] must be the program;
|
||||
/// if it's a bare name (no slash), it's resolved via the process PATH.
|
||||
/// Missing binary returns a clean error the client sees as a banner.
|
||||
fn spawn_command_pty(argv: &[String], cols: u16, rows: u16) -> Result<PtyParts, String> {
|
||||
let program = argv
|
||||
.first()
|
||||
.ok_or_else(|| "command argv is empty".to_string())?;
|
||||
// Resolve bare names against common bin dirs so a headless daemon
|
||||
// (no login shell / no PATH set for herdr install dir) still finds it.
|
||||
let resolved = if program.contains('/') {
|
||||
program.clone()
|
||||
} else {
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let candidates = [
|
||||
format!("{home}/.local/bin/{program}"),
|
||||
format!("/opt/homebrew/bin/{program}"),
|
||||
format!("/usr/local/bin/{program}"),
|
||||
format!("/usr/bin/{program}"),
|
||||
];
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|p| std::path::Path::new(p).exists())
|
||||
.unwrap_or_else(|| program.clone())
|
||||
};
|
||||
let mut c = CommandBuilder::new(&resolved);
|
||||
for a in argv.iter().skip(1) {
|
||||
c.arg(a);
|
||||
}
|
||||
spawn_pty(c, cols, rows)
|
||||
}
|
||||
|
||||
/// Spawn a host login shell in a PTY; stream its output back as pty_out frames.
|
||||
async fn open_pty(
|
||||
sid: u64,
|
||||
@@ -725,6 +770,7 @@ async fn open_pty(
|
||||
if has_tmux() { "tmux" } else { "login shell" }
|
||||
),
|
||||
PtyTarget::Container { container, .. } => format!("container {container}"),
|
||||
PtyTarget::Command { argv } => format!("command {}", argv.join(" ")),
|
||||
};
|
||||
eprintln!("[pty] open sid={sid} cols={cols} rows={rows} target={label}");
|
||||
// Immediate banner over the channel: if the browser shows this but no shell,
|
||||
|
||||
Reference in New Issue
Block a user