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:
Omar Sobh
2026-07-20 10:58:45 -07:00
parent 2b3ec27757
commit a5588b0289
6 changed files with 227 additions and 14 deletions
+48 -2
View File
@@ -672,12 +672,25 @@ fn spawn_container_pty(
pub(crate) enum PtyTarget { pub(crate) enum PtyTarget {
Host, Host,
Container { container: String, session: String }, 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 { impl PtyTarget {
/// Parse from a control frame: a non-empty `container` field selects the /// Parse from a control frame. Precedence: explicit `command` (non-
/// container path (with an optional `session`, default "main"). /// empty array) → Command; else `container` → Container; else Host.
pub(crate) fn from_frame(v: &Value) -> Self { 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) { match v.get("container").and_then(Value::as_str) {
Some(c) if !c.is_empty() => PtyTarget::Container { Some(c) if !c.is_empty() => PtyTarget::Container {
container: c.to_owned(), container: c.to_owned(),
@@ -697,10 +710,42 @@ impl PtyTarget {
PtyTarget::Container { container, session } => { PtyTarget::Container { container, session } => {
spawn_container_pty(container, session, cols, rows) 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. /// Spawn a host login shell in a PTY; stream its output back as pty_out frames.
async fn open_pty( async fn open_pty(
sid: u64, sid: u64,
@@ -725,6 +770,7 @@ async fn open_pty(
if has_tmux() { "tmux" } else { "login shell" } if has_tmux() { "tmux" } else { "login shell" }
), ),
PtyTarget::Container { container, .. } => format!("container {container}"), PtyTarget::Container { container, .. } => format!("container {container}"),
PtyTarget::Command { argv } => format!("command {}", argv.join(" ")),
}; };
eprintln!("[pty] open sid={sid} cols={cols} rows={rows} target={label}"); eprintln!("[pty] open sid={sid} cols={cols} rows={rows} target={label}");
// Immediate banner over the channel: if the browser shows this but no shell, // Immediate banner over the channel: if the browser shows this but no shell,
+13 -5
View File
@@ -205,6 +205,9 @@ impl NodeHub {
/// Open the WS-relay PTY for an allocated session (the fallback path). /// Open the WS-relay PTY for an allocated session (the fallback path).
/// `container` (+ `session`) targets `docker exec` into an agent container on /// `container` (+ `session`) targets `docker exec` into an agent container on
/// the node (the node-placed agent terminal); both `None` ⇒ the host shell. /// the node (the node-placed agent terminal); both `None` ⇒ the host shell.
/// `command`, when set to a non-empty argv, wins over both — spawns
/// the program directly (used by the Herdr Live Pane to attach xterm.js
/// straight to `herdr`).
pub async fn open_pty( pub async fn open_pty(
&self, &self,
id: NodeId, id: NodeId,
@@ -213,14 +216,19 @@ impl NodeHub {
rows: u16, rows: u16,
container: Option<&str>, container: Option<&str>,
session: Option<&str>, session: Option<&str>,
command: Option<&[String]>,
) { ) {
if let Some(conn) = self.get(id).await { if let Some(conn) = self.get(id).await {
let mut frame = json!({ "t": "pty_open", "sid": sid, "cols": cols, "rows": rows }); let mut frame = json!({ "t": "pty_open", "sid": sid, "cols": cols, "rows": rows });
if let Some(c) = container { if let Some(cmd) = command.filter(|c| !c.is_empty()) {
frame["container"] = json!(c); frame["command"] = json!(cmd);
} } else {
if let Some(s) = session { if let Some(c) = container {
frame["session"] = json!(s); frame["container"] = json!(c);
}
if let Some(s) = session {
frame["session"] = json!(s);
}
} }
let _ = conn.tx.send(frame.to_string()); let _ = conn.tx.send(frame.to_string());
} }
+13 -2
View File
@@ -293,6 +293,10 @@ struct TermCtrl {
candidate: Option<String>, candidate: Option<String>,
sdp_mid: Option<String>, sdp_mid: Option<String>,
sdp_mline_index: Option<u16>, sdp_mline_index: Option<u16>,
/// When present on `fallback`, spawns this argv in the PTY instead of
/// the login shell (used by the Herdr Live Pane).
#[serde(default)]
command: Vec<String>,
} }
/// The terminal WS is BOTH the WebRTC signaling channel and the fallback data /// The terminal WS is BOTH the WebRTC signaling channel and the fallback data
@@ -335,8 +339,15 @@ async fn bridge_terminal(hub: Arc<NodeHub>, node_id: NodeId, socket: WebSocket)
let rows = c.rows.unwrap_or(24); let rows = c.rows.unwrap_or(24);
match c.kind.as_str() { match c.kind.as_str() {
"resize" => hub.terminal_resize(node_id, sid, cols, rows).await, "resize" => hub.terminal_resize(node_id, sid, cols, rows).await,
// Host shell (no container) — the Infra node terminal. // Host shell by default; `command` override wins.
"fallback" => hub.open_pty(node_id, sid, cols, rows, None, None).await, "fallback" => {
let cmd = if c.command.is_empty() {
None
} else {
Some(c.command.as_slice())
};
hub.open_pty(node_id, sid, cols, rows, None, None, cmd).await
}
"webrtc_offer" => { "webrtc_offer" => {
hub.webrtc_offer( hub.webrtc_offer(
node_id, node_id,
+1 -1
View File
@@ -402,7 +402,7 @@ async fn bridge_node(
match c.kind.as_str() { match c.kind.as_str() {
"resize" => hub.terminal_resize(node_id, sid, cols, rows).await, "resize" => hub.terminal_resize(node_id, sid, cols, rows).await,
"fallback" => { "fallback" => {
hub.open_pty(node_id, sid, cols, rows, Some(&container), Some(&session)) hub.open_pty(node_id, sid, cols, rows, Some(&container), Some(&session), None)
.await .await
} }
"webrtc_offer" => { "webrtc_offer" => {
@@ -219,7 +219,14 @@ export type TermMode = "connecting" | "direct" | "relayed" | "local";
* ticket + builds the wss URL): direct DataChannel browser↔node, with the * ticket + builds the wss URL): direct DataChannel browser↔node, with the
* gateway WS relay as automatic fallback. Endpoint-agnostic — works for the node * gateway WS relay as automatic fallback. Endpoint-agnostic — works for the node
* host shell and the node-placed agent container alike. */ * host shell and the node-placed agent container alike. */
export function webrtcConnector(getUrl: () => Promise<string | null>, onMode?: (m: TermMode) => void): TermConnector { export function webrtcConnector(
getUrl: () => Promise<string | null>,
onMode?: (m: TermMode) => void,
/** When set, the fallback frame carries this argv so the node spawns
* the given command in the PTY instead of the login shell. Used by
* the Herdr Live Pane to attach directly to `herdr`. */
commandOverride?: string[],
): TermConnector {
return ({ term, onClosed }) => return ({ term, onClosed }) =>
new Promise<TermTransport | null>((resolve) => { new Promise<TermTransport | null>((resolve) => {
let settled = false; let settled = false;
@@ -284,7 +291,16 @@ export function webrtcConnector(getUrl: () => Promise<string | null>, onMode?: (
} }
dc = null; dc = null;
pc = null; pc = null;
ws?.send(JSON.stringify({ type: "fallback", cols: term.cols, rows: term.rows })); ws?.send(
JSON.stringify({
type: "fallback",
cols: term.cols,
rows: term.rows,
...(commandOverride && commandOverride.length > 0
? { command: commandOverride }
: {}),
}),
);
flush(); flush();
}; };
const startWebrtc = () => { const startWebrtc = () => {
@@ -388,6 +404,27 @@ export function nodeWebrtcConnector(nodeId: string, onMode?: (m: TermMode) => vo
}, onMode); }, onMode);
} }
/** Attach xterm to `herdr` running on the node — bypasses the login
* shell, drops the browser straight into the node's Herdr TUI so the
* operator sees every mission pane on that node. */
export function nodeHerdrConnector(
nodeId: string,
onMode?: (m: TermMode) => void,
): TermConnector {
return webrtcConnector(
async () => {
const res = await fetch(`/api/nodes/${nodeId}/terminal/ticket`, { method: "POST" });
if (!res.ok) return null;
const { ticket } = (await res.json()) as { ticket?: string };
if (!ticket) return null;
const proto = location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${location.host}/api/nodes/${nodeId}/terminal/ws?token=${encodeURIComponent(ticket)}`;
},
onMode,
["herdr"],
);
}
/** The agent terminal: mint the ticket, then pick the transport from the /** The agent terminal: mint the ticket, then pick the transport from the
* response — a node-placed container → WebRTC (LAN speed, signaling over the * response — a node-placed container → WebRTC (LAN speed, signaling over the
* agent WS); a gateway-local container → plain WS (today's path). */ * agent WS); a gateway-local container → plain WS (today's path). */
@@ -30,6 +30,12 @@ import {
} from "@/lib/api/missions"; } from "@/lib/api/missions";
import { MarkdownBlock } from "./MarkdownBlock"; import { MarkdownBlock } from "./MarkdownBlock";
import { MissionWizard } from "./MissionWizard"; import { MissionWizard } from "./MissionWizard";
import {
nodeHerdrConnector,
useResilientTerminal,
type TermMode,
} from "@/components/computer/apps/terminal/core";
import "@xterm/xterm/css/xterm.css";
const mono = const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -70,7 +76,7 @@ const TEMPLATE_LABEL: Record<TemplateKind, string> = {
custom: "Custom", custom: "Custom",
}; };
type Tab = "overview" | "phases" | "tasks" | "artifacts" | "benchmarks"; type Tab = "overview" | "phases" | "tasks" | "artifacts" | "benchmarks" | "pane";
export function MissionCanvas({ export function MissionCanvas({
selectedId, selectedId,
@@ -438,7 +444,16 @@ export function MissionCanvas({
/> />
)} )}
<div style={{ display: "flex", gap: 4, marginTop: 4 }}> <div style={{ display: "flex", gap: 4, marginTop: 4 }}>
{(["overview", "phases", "tasks", "artifacts", "benchmarks"] as Tab[]).map((t) => { {(
[
"overview",
"phases",
"tasks",
"artifacts",
"benchmarks",
...(mission.runtime_kind === "local_herdr" ? (["pane"] as const) : []),
] as Tab[]
).map((t) => {
const active = tab === t; const active = tab === t;
const badge = const badge =
t === "tasks" t === "tasks"
@@ -918,6 +933,13 @@ export function MissionCanvas({
)} )}
</div> </div>
)} )}
{tab === "pane" && mission.runtime_kind === "local_herdr" && (
<LivePane
nodeId={mission.target_node_id}
visible={tab === "pane"}
/>
)}
</div> </div>
</div> </div>
); );
@@ -970,6 +992,95 @@ function Empty({ label }: { label: string }) {
); );
} }
function LivePane({
nodeId,
visible,
}: {
nodeId: string | null;
visible: boolean;
}) {
if (!nodeId) {
return (
<div
style={{
padding: 40,
textAlign: "center",
color: "#8a8a92",
fontSize: 13,
}}
>
This mission has no target node.
</div>
);
}
return <LivePaneInner nodeId={nodeId} visible={visible} />;
}
function LivePaneInner({
nodeId,
visible,
}: {
nodeId: string;
visible: boolean;
}) {
const [mode, setMode] = useState<TermMode>("connecting");
const { hostRef, refit } = useResilientTerminal(
{
connect: nodeHerdrConnector(nodeId, setMode),
autoFocus: false,
visible: () => visible,
},
[nodeId],
);
useEffect(() => {
if (visible) refit();
}, [visible, refit]);
return (
<div
style={{
position: "relative",
height: "70vh",
minHeight: 480,
background: "#0a0a0d",
borderRadius: 10,
border: "1px solid rgba(255,255,255,.06)",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
top: 8,
right: 8,
zIndex: 5,
padding: "2px 8px",
borderRadius: 6,
fontFamily: mono,
fontSize: 10,
letterSpacing: ".1em",
textTransform: "uppercase",
color:
mode === "direct"
? "#5fd08a"
: mode === "relayed"
? "#8a8a92"
: "#e8b465",
background:
mode === "direct"
? "rgba(95,208,138,.12)"
: "rgba(255,255,255,.05)",
border: `1px solid ${
mode === "direct" ? "rgba(95,208,138,.3)" : "rgba(255,255,255,.1)"
}`,
}}
>
{mode === "direct" ? "direct" : mode === "relayed" ? "relayed" : "connecting…"}
</div>
<div ref={hostRef} style={{ position: "absolute", inset: 0, padding: 8 }} />
</div>
);
}
function EditMissionModal({ function EditMissionModal({
mission, mission,
onClose, onClose,