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