Agent computer: terminal (tmux + drives + tabs), Obsidian vault, UI polish

Terminal app (xterm ⇄ WebSocket ⇄ per-agent themed container):
- zsh + oh-my-zsh + powerlevel10k image (agent-terminal), runs as uid 65532 to
  share read-write ownership of the file-drive volume with the server.
- Interactive PTY in cm-sandbox (bollard exec tty/attach + resize) + a
  TerminalManager; ticket-authed WS bridge routed straight to the backend via a
  Traefik PathRegexp(/ws) rule. MOTD greets the user by name.
- tmux resumable sessions; multi-tab (one tmux session per tab, same container),
  drag-to-reorder, rename, and a Save that persists named tabs to the server
  (terminal_tabs, migration 0014) so they survive logout / a new device.
- Files drives mounted per-agent (subpath) at ~/drives/{documents,received,
  shared}; a reconciler keeps the Files app's index in sync with terminal writes.
  Storage moved to a shared `filedata` volume (CLAWMATES_STORAGE__DATA_DIR).

Obsidian vault (a markdown "second brain" per agent):
- New `vault` FileDrive (migration 0015) mounted into the terminal at ~/obsidian;
  a file-content read route; a purple Obsidian tile + a vault viewer app.

Computer UI:
- Draggable computer-panel width (min = phone preset) keeping the size presets.
- Green Terminal glyph, "Claw Chat" → "Chat", colored gradient-outline app icons.
- Agent page: avatar↔activity-grid spacing + larger, uniform section fonts with
  colored section-tinted tag chips.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-23 16:52:35 -07:00
co-authored by Claude Opus 4.8
parent 671df7c622
commit e61724ff82
46 changed files with 2247 additions and 95 deletions
+38 -5
View File
@@ -146,7 +146,7 @@ async fn run() -> Result<(), String> {
}; };
// Environment tools need a container engine; absence is tolerated // Environment tools need a container engine; absence is tolerated
// (shell.exec reports it per-call) so the API still serves. // (shell.exec reports it per-call) so the API still serves.
let (sandboxes, browser) = if config.sandbox.enabled { let (sandboxes, browser, terminals) = if config.sandbox.enabled {
match cm_sandbox::DockerDriver::connect() { match cm_sandbox::DockerDriver::connect() {
Ok(driver) => { Ok(driver) => {
let driver: std::sync::Arc<dyn cm_sandbox::SandboxDriver> = let driver: std::sync::Arc<dyn cm_sandbox::SandboxDriver> =
@@ -156,14 +156,32 @@ async fn run() -> Result<(), String> {
&config.sandbox.image, &config.sandbox.image,
)); ));
let browser = std::sync::Arc::new( let browser = std::sync::Arc::new(
cm_runtime::SandboxManager::new(driver, &config.sandbox.browser_image) cm_runtime::SandboxManager::new(driver.clone(), &config.sandbox.browser_image)
.with_egress(), .with_egress(),
); );
// Themed interactive terminal containers (zsh + oh-my-zsh + p10k)
// for the Terminal computer app. On the Local storage backend the
// file-drive volume is mounted (per-agent subpath) at ~/drives.
let drives = if config.storage.backend == cm_config::StorageBackend::Local {
Some(cm_runtime::DriveConfig {
volume: config.sandbox.terminal_drive_volume.clone(),
data_dir: PathBuf::from(&config.storage.data_dir),
})
} else {
None
};
let terminals = std::sync::Arc::new(cm_runtime::TerminalManager::new(
driver,
&config.sandbox.terminal_image,
config.sandbox.terminal_egress,
drives,
));
// Boot reconciliation: any sandbox the engine still holds is an // Boot reconciliation: any sandbox the engine still holds is an
// orphan from a dead process (we track none yet) — remove them // orphan from a dead process (we track none yet) — remove them
// before warming so a crash/redeploy can't leak containers. // before warming so a crash/redeploy can't leak containers.
agents.reconcile_orphans(std::time::Duration::ZERO).await; agents.reconcile_orphans(std::time::Duration::ZERO).await;
browser.reconcile_orphans(std::time::Duration::ZERO).await; browser.reconcile_orphans(std::time::Duration::ZERO).await;
terminals.reconcile_orphans().await;
let agents = if config.sandbox.warm_pool > 0 { let agents = if config.sandbox.warm_pool > 0 {
agents.warm(config.sandbox.warm_pool) agents.warm(config.sandbox.warm_pool)
} else { } else {
@@ -179,20 +197,27 @@ async fn run() -> Result<(), String> {
std::time::Duration::from_secs(300), std::time::Duration::from_secs(300),
std::time::Duration::from_secs(600), std::time::Duration::from_secs(600),
); );
(Some(agents), Some(browser)) // Terminals: reap idle (no live session for ~2 h, so a resumable
// tmux session survives normal navigation gaps) + orphans.
terminals.clone().spawn_reaper(
std::time::Duration::from_secs(300),
std::time::Duration::from_secs(7200),
);
(Some(agents), Some(browser), Some(terminals))
} }
Err(error) => { Err(error) => {
eprintln!("clawmates-server: sandbox engine unavailable: {error}"); eprintln!("clawmates-server: sandbox engine unavailable: {error}");
(None, None) (None, None, None)
} }
} }
} else { } else {
(None, None) (None, None, None)
}; };
// Keep handles for the graceful-shutdown drain (SIGTERM) — the managers // Keep handles for the graceful-shutdown drain (SIGTERM) — the managers
// themselves are moved into the runtime config below. // themselves are moved into the runtime config below.
let drain_agents = sandboxes.clone(); let drain_agents = sandboxes.clone();
let drain_browser = browser.clone(); let drain_browser = browser.clone();
let drain_terminals = terminals.clone();
let runtime = Runtime::with_blob_store( let runtime = Runtime::with_blob_store(
pool.clone(), pool.clone(),
provider, provider,
@@ -203,6 +228,7 @@ async fn run() -> Result<(), String> {
slack_base_url: config.slack.base_url.clone(), slack_base_url: config.slack.base_url.clone(),
sandboxes, sandboxes,
browser, browser,
terminals,
providers: provider_registry, providers: provider_registry,
}, },
blob, blob,
@@ -246,6 +272,10 @@ async fn run() -> Result<(), String> {
.with_broker(PathBuf::from(&config.broker.socket_path)) .with_broker(PathBuf::from(&config.broker.socket_path))
.with_oauth(config.oauth.clone()) .with_oauth(config.oauth.clone())
.with_billing(config.billing.clone()) .with_billing(config.billing.clone())
.with_file_root(
(config.storage.backend == cm_config::StorageBackend::Local)
.then(|| PathBuf::from(&config.storage.data_dir)),
)
.pipe_auth_verifier(auth_verifier), .pipe_auth_verifier(auth_verifier),
); );
if e2e::enabled() { if e2e::enabled() {
@@ -277,5 +307,8 @@ async fn run() -> Result<(), String> {
if let Some(browser) = drain_browser { if let Some(browser) = drain_browser {
browser.shutdown().await; browser.shutdown().await;
} }
if let Some(terminals) = drain_terminals {
terminals.shutdown().await;
}
Ok(()) Ok(())
} }
+1 -1
View File
@@ -11,7 +11,7 @@ hex = "0.4"
hmac = "0.12" hmac = "0.12"
sha2 = "0.10" sha2 = "0.10"
async-stream = "0.3" async-stream = "0.3"
axum = "0.8" axum = { version = "0.8", features = ["ws"] }
futures = "0.3" futures = "0.3"
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
+25
View File
@@ -29,6 +29,11 @@ pub struct AppState {
pub broker_socket: Option<std::path::PathBuf>, pub broker_socket: Option<std::path::PathBuf>,
pub oauth: cm_config::OAuthConfig, pub oauth: cm_config::OAuthConfig,
pub billing: cm_config::BillingConfig, pub billing: cm_config::BillingConfig,
/// Short-lived single-use tickets for the Terminal WebSocket.
pub terminal_tickets: routes::terminal::TerminalTickets,
/// Local blob-store root (Some on the Local backend) so the Files app can
/// reconcile its index with files the Terminal wrote into the drives.
pub file_root: Option<std::path::PathBuf>,
} }
impl AppState { impl AppState {
@@ -41,9 +46,16 @@ impl AppState {
broker_socket: None, broker_socket: None,
oauth: cm_config::OAuthConfig::default(), oauth: cm_config::OAuthConfig::default(),
billing: cm_config::BillingConfig::default(), billing: cm_config::BillingConfig::default(),
terminal_tickets: routes::terminal::TerminalTickets::default(),
file_root: None,
} }
} }
pub fn with_file_root(mut self, root: Option<std::path::PathBuf>) -> AppState {
self.file_root = root;
self
}
pub fn with_oauth(mut self, oauth: cm_config::OAuthConfig) -> AppState { pub fn with_oauth(mut self, oauth: cm_config::OAuthConfig) -> AppState {
self.oauth = oauth; self.oauth = oauth;
self self
@@ -139,6 +151,15 @@ pub fn router(state: AppState) -> Router {
"/api/claws/{id}/brain/apply", "/api/claws/{id}/brain/apply",
axum::routing::post(routes::claws::apply_brain), axum::routing::post(routes::claws::apply_brain),
) )
.route(
"/api/terminal/{id}/ticket",
post(routes::terminal::ticket),
)
.route(
"/api/terminal/{id}/tabs",
get(routes::terminal::get_tabs).put(routes::terminal::save_tabs),
)
.route("/api/terminal/{id}/ws", get(routes::terminal::ws))
.route("/api/claw-chat/threads", get(routes::claw_chat::threads)) .route("/api/claw-chat/threads", get(routes::claw_chat::threads))
.route("/api/claw-chat/messages", get(routes::claw_chat::messages)) .route("/api/claw-chat/messages", get(routes::claw_chat::messages))
.route( .route(
@@ -156,6 +177,10 @@ pub fn router(state: AppState) -> Router {
.route("/api/skills/install", post(routes::skills::install)) .route("/api/skills/install", post(routes::skills::install))
.route("/api/skills/uninstall", post(routes::skills::uninstall)) .route("/api/skills/uninstall", post(routes::skills::uninstall))
.route("/api/openclaw/files", get(routes::files::openclaw_files)) .route("/api/openclaw/files", get(routes::files::openclaw_files))
.route(
"/api/openclaw/files/content",
get(routes::files::file_content),
)
.route("/api/shared-drive/files", get(routes::files::shared_files)) .route("/api/shared-drive/files", get(routes::files::shared_files))
.route("/api/slack/events", post(routes::slack::events)) .route("/api/slack/events", post(routes::slack::events))
.route( .route(
+127 -1
View File
@@ -1,11 +1,93 @@
use std::collections::HashMap;
use std::path::Path;
use axum::extract::{Query, State}; use axum::extract::{Query, State};
use axum::Json; use axum::Json;
use cm_domain::{AgentId, FileDrive, FileNode}; use cm_domain::{AgentId, FileDrive, FileNode, WorkspaceId};
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool;
use crate::routes::claws::workspace_agent; use crate::routes::claws::workspace_agent;
use crate::{ApiError, AppState, Authed}; use crate::{ApiError, AppState, Authed};
/// Recursively collect files under `dir` as (path-relative-to-dir → size).
fn walk_files<'a>(
dir: std::path::PathBuf,
base: std::path::PathBuf,
out: &'a mut HashMap<String, u64>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
Box::pin(async move {
let mut rd = match tokio::fs::read_dir(&dir).await {
Ok(rd) => rd,
Err(_) => return,
};
while let Ok(Some(entry)) = rd.next_entry().await {
let path = entry.path();
let Ok(ft) = entry.file_type().await else { continue };
if ft.is_dir() {
walk_files(path, base.clone(), out).await;
} else if ft.is_file() {
if let Ok(rel) = path.strip_prefix(&base) {
let rel = rel.to_string_lossy().replace('\\', "/");
let size = entry.metadata().await.map(|m| m.len()).unwrap_or(0);
out.insert(rel, size);
}
}
}
})
}
/// Reconcile `file_nodes` with what's actually in the drive's blob directory, so
/// files the Terminal created/edited/removed are reflected in the Files app
/// (Local backend only; the blob layout is `{root}/{ws}/{drive}/{scope}/{path}`).
async fn reconcile_drive(
pool: &PgPool,
root: &Path,
ws: WorkspaceId,
drive: FileDrive,
agent: AgentId,
) {
let scope = if drive.is_agent_scoped() {
agent.to_string()
} else {
"shared".to_string()
};
let dir = root.join(format!("{ws}/{}/{scope}", drive.as_str()));
let mut disk = HashMap::new();
walk_files(dir, root.join(format!("{ws}/{}/{scope}", drive.as_str())), &mut disk).await;
let db = match cm_db::repo::files::list(pool, ws, drive, agent).await {
Ok(d) => d,
Err(_) => return,
};
// Upsert files present on disk but new/changed in the index.
for (path, size) in &disk {
let existing = db.iter().find(|n| &n.path == path);
if existing.map(|n| n.size as u64) == Some(*size) {
continue;
}
let node = FileNode {
id: existing.map(|n| n.id).unwrap_or_else(uuid::Uuid::now_v7),
workspace_id: ws,
agent_id: drive.is_agent_scoped().then_some(agent),
drive,
path: path.clone(),
size: *size as i64,
blob_ref: format!("{ws}/{}/{scope}/{path}", drive.as_str()),
};
let _ = cm_db::repo::files::upsert(pool, &node).await;
}
// Drop index rows whose blob no longer exists on disk.
for n in &db {
if n.blob_ref.is_empty() {
continue;
}
if !root.join(&n.blob_ref).exists() {
let _ = cm_db::repo::files::delete(pool, n.id).await;
}
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct FilesQuery { pub struct FilesQuery {
#[serde(rename = "clawId")] #[serde(rename = "clawId")]
@@ -30,6 +112,9 @@ pub async fn openclaw_files(
if !drive.is_agent_scoped() { if !drive.is_agent_scoped() {
return Err(ApiError::NotFound); // shared drive has its own route return Err(ApiError::NotFound); // shared drive has its own route
} }
if let Some(root) = &state.file_root {
reconcile_drive(&state.pool, root, user.workspace_id, drive, agent.id).await;
}
let nodes = cm_db::repo::files::list(&state.pool, user.workspace_id, drive, agent.id).await?; let nodes = cm_db::repo::files::list(&state.pool, user.workspace_id, drive, agent.id).await?;
Ok(Json(nodes)) Ok(Json(nodes))
} }
@@ -40,6 +125,44 @@ pub struct SharedQuery {
claw_id: AgentId, claw_id: AgentId,
} }
#[derive(Deserialize)]
pub struct ContentQuery {
#[serde(rename = "clawId")]
claw_id: AgentId,
drive: String,
path: String,
}
/// GET /api/openclaw/files/content?clawId=&drive=&path= — a file's text content
/// from a drive (used by the Obsidian vault viewer to render a note).
pub async fn file_content(
State(state): State<AppState>,
Authed(user): Authed,
Query(query): Query<ContentQuery>,
) -> Result<String, ApiError> {
let agent = workspace_agent(&state, &user, query.claw_id).await?;
let drive: FileDrive = query.drive.parse().map_err(|_| ApiError::NotFound)?;
let scope = if drive.is_agent_scoped() {
agent.id.to_string()
} else {
"shared".to_string()
};
let key = format!(
"{}/{}/{}/{}",
user.workspace_id,
drive.as_str(),
scope,
query.path
);
let bytes = state
.runtime
.blob()
.get(&key)
.await
.map_err(|_| ApiError::NotFound)?;
Ok(String::from_utf8_lossy(&bytes).to_string())
}
/// GET /api/shared-drive/files?clawId= — the team-wide ClawDrive (§7.4). /// GET /api/shared-drive/files?clawId= — the team-wide ClawDrive (§7.4).
pub async fn shared_files( pub async fn shared_files(
State(state): State<AppState>, State(state): State<AppState>,
@@ -47,6 +170,9 @@ pub async fn shared_files(
Query(query): Query<SharedQuery>, Query(query): Query<SharedQuery>,
) -> Result<Json<Vec<FileNode>>, ApiError> { ) -> Result<Json<Vec<FileNode>>, ApiError> {
let agent = workspace_agent(&state, &user, query.claw_id).await?; let agent = workspace_agent(&state, &user, query.claw_id).await?;
if let Some(root) = &state.file_root {
reconcile_drive(&state.pool, root, user.workspace_id, FileDrive::Shared, agent.id).await;
}
let nodes = let nodes =
cm_db::repo::files::list(&state.pool, user.workspace_id, FileDrive::Shared, agent.id) cm_db::repo::files::list(&state.pool, user.workspace_id, FileDrive::Shared, agent.id)
.await?; .await?;
+1
View File
@@ -21,4 +21,5 @@ pub mod slack;
pub mod structure; pub mod structure;
pub mod team; pub mod team;
pub mod teams; pub mod teams;
pub mod terminal;
pub mod topology; pub mod topology;
+302
View File
@@ -0,0 +1,302 @@
//! The Terminal computer app: an interactive PTY (`zsh -l`) in the agent's
//! themed terminal container, bridged to the browser's xterm.js over a
//! WebSocket. Because a browser WS handshake can't carry the bearer header, the
//! client first POSTs (through the authed proxy) for a short-lived single-use
//! ticket, then opens the WS with `?ticket=`. The WS itself is routed straight
//! to this server by the edge (Traefik), bypassing the HTTP-only Next proxy.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use cm_db::repo::audit::Actor;
use cm_domain::{AgentId, WorkspaceId};
use futures::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::io::AsyncWriteExt;
use crate::routes::claws::workspace_agent;
use crate::{ApiError, AppState, Authed};
const TICKET_TTL: Duration = Duration::from_secs(30);
/// In-memory single-use WS tickets (token → agent + greeting label + expiry).
/// Tiny + ephemeral; a ticket is consumed on redeem, expired ones swept lazily.
struct Ticket {
agent_id: AgentId,
workspace_id: WorkspaceId,
/// Display name to greet the user with in the terminal MOTD.
label: String,
expires: Instant,
}
#[derive(Clone, Default)]
pub struct TerminalTickets {
inner: Arc<Mutex<HashMap<String, Ticket>>>,
}
impl TerminalTickets {
pub fn issue(&self, agent_id: AgentId, workspace_id: WorkspaceId, label: String) -> String {
let token = uuid::Uuid::new_v4().simple().to_string();
let mut g = self.inner.lock().expect("tickets lock");
let now = Instant::now();
g.retain(|_, t| t.expires > now);
g.insert(
token.clone(),
Ticket {
agent_id,
workspace_id,
label,
expires: now + TICKET_TTL,
},
);
token
}
/// Consume a ticket; returns its (workspace, greeting label) iff valid for this agent.
pub fn redeem(&self, token: &str, agent_id: AgentId) -> Option<(WorkspaceId, String)> {
let now = Instant::now();
let mut g = self.inner.lock().expect("tickets lock");
g.retain(|_, t| t.expires > now);
match g.remove(token) {
Some(t) if t.agent_id == agent_id && t.expires > now => Some((t.workspace_id, t.label)),
_ => None,
}
}
}
#[derive(Serialize)]
pub struct TicketResponse {
pub ticket: String,
}
/// `POST /api/terminal/{id}/ticket` — owner-gated; mints a short-lived ticket.
pub async fn ticket(
State(state): State<AppState>,
Authed(user): Authed,
Path(agent_id): Path<AgentId>,
) -> Result<Json<TicketResponse>, ApiError> {
// Tenant isolation: a foreign agent looks non-existent.
workspace_agent(&state, &user, agent_id).await?;
// Greet by display name, falling back to the email local-part.
let label = match cm_db::repo::users::get(&state.pool, user.user_id).await {
Ok(u) if !u.display_name.trim().is_empty() => u.display_name.trim().to_string(),
Ok(u) => u
.email
.split('@')
.next()
.filter(|s| !s.is_empty())
.unwrap_or("there")
.to_string(),
Err(_) => "there".to_string(),
};
let ticket = state
.terminal_tickets
.issue(agent_id, user.workspace_id, label);
cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
Actor::User(user.user_id),
"terminal.ticket_issued",
"agent",
&agent_id.to_string(),
json!({}),
)
.await?;
Ok(Json(TicketResponse { ticket }))
}
/// GET /api/terminal/{id}/tabs — the user's saved tab layout (or null).
pub async fn get_tabs(
State(state): State<AppState>,
Authed(user): Authed,
Path(agent_id): Path<AgentId>,
) -> Result<Json<serde_json::Value>, ApiError> {
workspace_agent(&state, &user, agent_id).await?;
let layout = cm_db::repo::terminal_tabs::get(&state.pool, user.user_id, agent_id).await?;
Ok(Json(layout.unwrap_or(serde_json::Value::Null)))
}
#[derive(Deserialize)]
pub struct SaveTabsRequest {
layout: serde_json::Value,
}
/// PUT /api/terminal/{id}/tabs — save (replace) the user's tab layout, so the
/// named tabs + their tmux sessions survive logout / a new device.
pub async fn save_tabs(
State(state): State<AppState>,
Authed(user): Authed,
Path(agent_id): Path<AgentId>,
Json(body): Json<SaveTabsRequest>,
) -> Result<StatusCode, ApiError> {
workspace_agent(&state, &user, agent_id).await?;
cm_db::repo::terminal_tabs::upsert(
&state.pool,
user.workspace_id,
user.user_id,
agent_id,
&body.layout,
)
.await?;
cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
Actor::User(user.user_id),
"terminal.tabs_saved",
"agent",
&agent_id.to_string(),
json!({}),
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
pub struct WsQuery {
ticket: String,
/// tmux session name → one per terminal tab (same container, shared drives).
#[serde(default)]
session: Option<String>,
}
/// Keep tmux-session names safe ([A-Za-z0-9_-], <=24) — they all share the
/// agent's one container, so different names = independent tabs.
fn sanitize_session(raw: Option<&str>) -> String {
let s: String = raw
.unwrap_or("main")
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.take(24)
.collect();
if s.is_empty() {
"main".to_string()
} else {
s
}
}
#[derive(Deserialize)]
struct ControlMsg {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
cols: u16,
#[serde(default)]
rows: u16,
}
/// `GET /api/terminal/{id}/ws?ticket=…` — upgrades to a WebSocket bridged to an
/// interactive PTY. Ticket-authed (no bearer header on a browser WS handshake).
pub async fn ws(
State(state): State<AppState>,
Path(agent_id): Path<AgentId>,
Query(q): Query<WsQuery>,
upgrade: WebSocketUpgrade,
) -> Response {
let (workspace_id, label) = match state.terminal_tickets.redeem(&q.ticket, agent_id) {
Some(v) => v,
None => return (StatusCode::UNAUTHORIZED, "invalid or expired ticket").into_response(),
};
let tm = match state.runtime.terminals() {
Some(tm) => tm,
None => {
return (
StatusCode::SERVICE_UNAVAILABLE,
"terminal engine unavailable",
)
.into_response()
}
};
// MOTD greeting injected into the PTY session (read by the container .zshrc).
let env = vec![format!("CLAWMATES_USER={label}")];
let session = sanitize_session(q.session.as_deref());
upgrade.on_upgrade(move |socket| bridge(socket, tm, workspace_id, agent_id, env, session))
}
/// Pumps bytes both ways between the WebSocket and the container PTY. Binary
/// frames are stdin; text frames are control JSON ({"type":"resize",cols,rows}).
async fn bridge(
socket: WebSocket,
tm: Arc<cm_runtime::TerminalManager>,
workspace_id: WorkspaceId,
agent_id: AgentId,
env: Vec<String>,
tmux_session: String,
) {
let (mut ws_tx, mut ws_rx) = socket.split();
let mut session = match tm
.attach(workspace_id, agent_id, 80, 24, &env, &tmux_session)
.await
{
Ok(s) => s,
Err(e) => {
let _ = ws_tx
.send(Message::Text(
format!("\r\n\x1b[31mterminal error: {e}\x1b[0m\r\n").into(),
))
.await;
tm.detach(agent_id).await;
return;
}
};
let exec_id = session.exec_id.clone();
// PTY → WS: stream the shell's combined TTY output as binary frames.
let pty_to_ws = async {
while let Some(chunk) = session.output.next().await {
match chunk {
Ok(bytes) => {
if ws_tx.send(Message::Binary(bytes.into())).await.is_err() {
break;
}
}
Err(_) => break,
}
}
};
// WS → PTY: binary = keystrokes; text = control (resize).
let ws_to_pty = async {
while let Some(msg) = ws_rx.next().await {
let msg = match msg {
Ok(m) => m,
Err(_) => break,
};
match msg {
Message::Binary(b) => {
if session.input.write_all(&b).await.is_err() {
break;
}
let _ = session.input.flush().await;
}
Message::Text(t) => {
if let Ok(ctrl) = serde_json::from_str::<ControlMsg>(t.as_str()) {
if ctrl.kind == "resize" && ctrl.cols > 0 && ctrl.rows > 0 {
let _ = tm.resize(&exec_id, ctrl.cols, ctrl.rows).await;
continue;
}
}
if session.input.write_all(t.as_bytes()).await.is_err() {
break;
}
let _ = session.input.flush().await;
}
Message::Close(_) => break,
_ => {}
}
}
};
tokio::select! {
_ = pty_to_ws => {}
_ = ws_to_pty => {}
}
tm.detach(agent_id).await;
}
+1
View File
@@ -67,6 +67,7 @@ async fn mention_round_trip_verifies_runs_and_gates_the_reply() {
max_tokens: 1024, max_tokens: 1024,
sandboxes: None, sandboxes: None,
browser: None, browser: None,
terminals: None,
broker_socket: Some(socket.clone()), broker_socket: Some(socket.clone()),
slack_base_url: "http://127.0.0.1:1".into(), // never reached here slack_base_url: "http://127.0.0.1:1".into(), // never reached here
providers: Default::default(), providers: Default::default(),
+23
View File
@@ -174,6 +174,26 @@ pub struct SandboxConfig {
pub enabled: bool, pub enabled: bool,
/// Pre-provisioned sandboxes kept ready (0 = provision on demand). /// Pre-provisioned sandboxes kept ready (0 = provision on demand).
pub warm_pool: usize, pub warm_pool: usize,
/// Themed image for the interactive Terminal computer app
/// (zsh + oh-my-zsh + powerlevel10k).
#[serde(default = "default_terminal_image")]
pub terminal_image: String,
/// Give terminal containers network egress (a networked dev shell). Off by
/// default — the safe, isolated posture.
#[serde(default)]
pub terminal_egress: bool,
/// The named Docker volume holding the file-drive blobs, mounted (per-agent
/// subpath) into the Terminal at ~/drives. Must match the compose volume.
#[serde(default = "default_terminal_drive_volume")]
pub terminal_drive_volume: String,
}
fn default_terminal_image() -> String {
"clawmates/agent-terminal:dev".into()
}
fn default_terminal_drive_volume() -> String {
"clawmates_filedata".into()
} }
impl Default for SandboxConfig { impl Default for SandboxConfig {
@@ -183,6 +203,9 @@ impl Default for SandboxConfig {
browser_image: "clawmates/agent-browser:dev".into(), browser_image: "clawmates/agent-browser:dev".into(),
enabled: true, enabled: true,
warm_pool: 0, warm_pool: 0,
terminal_image: default_terminal_image(),
terminal_egress: false,
terminal_drive_volume: default_terminal_drive_volume(),
} }
} }
} }
+1
View File
@@ -16,6 +16,7 @@ pub mod sessions;
pub mod skills; pub mod skills;
pub mod steps; pub mod steps;
pub mod teams; pub mod teams;
pub mod terminal_tabs;
pub mod threads; pub mod threads;
pub mod topology_runs; pub mod topology_runs;
pub mod users; pub mod users;
+44
View File
@@ -0,0 +1,44 @@
//! Per-user saved terminal tab layout for an agent (named tabs + their tmux
//! sessions), so a user can restore their terminal after logging back in.
use cm_domain::{AgentId, UserId, WorkspaceId};
use sqlx::{PgPool, Row};
use crate::DbError;
/// The saved layout JSON for `(user, agent)`, or None if nothing saved.
pub async fn get(
pool: &PgPool,
user_id: UserId,
agent_id: AgentId,
) -> Result<Option<serde_json::Value>, DbError> {
let row = sqlx::query("SELECT layout FROM terminal_tabs WHERE user_id = $1 AND agent_id = $2")
.bind(user_id.as_uuid())
.bind(agent_id.as_uuid())
.fetch_optional(pool)
.await?;
Ok(row.map(|r| r.get::<serde_json::Value, _>("layout")))
}
/// Save (replace) the layout for `(user, agent)`.
pub async fn upsert(
pool: &PgPool,
workspace_id: WorkspaceId,
user_id: UserId,
agent_id: AgentId,
layout: &serde_json::Value,
) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO terminal_tabs (user_id, agent_id, workspace_id, layout, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (user_id, agent_id)
DO UPDATE SET layout = $4, workspace_id = $3, updated_at = now()",
)
.bind(user_id.as_uuid())
.bind(agent_id.as_uuid())
.bind(workspace_id.as_uuid())
.bind(layout)
.execute(pool)
.await?;
Ok(())
}
+5
View File
@@ -64,6 +64,9 @@ pub enum FileDrive {
Documents, Documents,
Received, Received,
Shared, Shared,
/// The agent's Obsidian-style markdown vault — a "second brain" the agent
/// writes notes/references into (per-agent scoped).
Vault,
} }
impl FileDrive { impl FileDrive {
@@ -72,6 +75,7 @@ impl FileDrive {
FileDrive::Documents => "documents", FileDrive::Documents => "documents",
FileDrive::Received => "received", FileDrive::Received => "received",
FileDrive::Shared => "shared", FileDrive::Shared => "shared",
FileDrive::Vault => "vault",
} }
} }
@@ -89,6 +93,7 @@ impl std::str::FromStr for FileDrive {
"documents" => Ok(FileDrive::Documents), "documents" => Ok(FileDrive::Documents),
"received" => Ok(FileDrive::Received), "received" => Ok(FileDrive::Received),
"shared" => Ok(FileDrive::Shared), "shared" => Ok(FileDrive::Shared),
"vault" => Ok(FileDrive::Vault),
other => Err(format!("unknown drive: {other}")), other => Err(format!("unknown drive: {other}")),
} }
} }
+2
View File
@@ -8,6 +8,7 @@ pub mod outbox;
mod runtime; mod runtime;
mod sandboxes; mod sandboxes;
pub mod scheduling; pub mod scheduling;
mod terminals;
mod tools; mod tools;
pub use events::{RunEventBody, RunEventEnvelope}; pub use events::{RunEventBody, RunEventEnvelope};
@@ -16,4 +17,5 @@ pub use runtime::{
judge_model, ProviderRegistry, Runtime, RuntimeConfig, RuntimeError, StartedRun, judge_model, ProviderRegistry, Runtime, RuntimeConfig, RuntimeError, StartedRun,
}; };
pub use sandboxes::SandboxManager; pub use sandboxes::SandboxManager;
pub use terminals::{DriveConfig, TerminalManager};
pub use tools::{ClockNow, EmailSend, Tool, ToolContext, ToolRegistry}; pub use tools::{ClockNow, EmailSend, Tool, ToolContext, ToolRegistry};
+11
View File
@@ -60,6 +60,8 @@ pub struct RuntimeConfig {
pub sandboxes: Option<std::sync::Arc<crate::SandboxManager>>, pub sandboxes: Option<std::sync::Arc<crate::SandboxManager>>,
/// Egress-enabled browser containers for browser.goto. /// Egress-enabled browser containers for browser.goto.
pub browser: Option<std::sync::Arc<crate::SandboxManager>>, pub browser: Option<std::sync::Arc<crate::SandboxManager>>,
/// Themed interactive terminal containers for the Terminal computer app.
pub terminals: Option<std::sync::Arc<crate::TerminalManager>>,
/// Extra named providers (GLM/Kimi/…) for judges and topology nodes. /// Extra named providers (GLM/Kimi/…) for judges and topology nodes.
pub providers: ProviderRegistry, pub providers: ProviderRegistry,
} }
@@ -87,6 +89,7 @@ impl RuntimeConfig {
slack_base_url: "https://slack.com/api".into(), slack_base_url: "https://slack.com/api".into(),
sandboxes: None, sandboxes: None,
browser: None, browser: None,
terminals: None,
providers: ProviderRegistry::default(), providers: ProviderRegistry::default(),
} }
} }
@@ -236,9 +239,17 @@ impl Runtime {
if let Some(br) = &self.inner.config.browser { if let Some(br) = &self.inner.config.browser {
any |= br.release_agent(agent_id).await; any |= br.release_agent(agent_id).await;
} }
if let Some(tm) = &self.inner.config.terminals {
any |= tm.release_agent(agent_id).await;
}
any any
} }
/// The themed-terminal manager, if a sandbox engine is configured.
pub fn terminals(&self) -> Option<std::sync::Arc<crate::TerminalManager>> {
self.inner.config.terminals.clone()
}
/// The configured per-call max output tokens. /// The configured per-call max output tokens.
pub fn max_tokens(&self) -> u32 { pub fn max_tokens(&self) -> u32 {
self.inner.config.max_tokens self.inner.config.max_tokens
+6
View File
@@ -82,6 +82,12 @@ impl SandboxManager {
nano_cpus: 1_000_000_000, nano_cpus: 1_000_000_000,
pids_limit: 256, pids_limit: 256,
egress: self.egress, egress: self.egress,
kind: if self.egress {
cm_sandbox::SandboxKind::Browser
} else {
cm_sandbox::SandboxKind::Agent
},
mounts: Vec::new(),
}; };
self.driver self.driver
.provision(&spec) .provision(&spec)
+296
View File
@@ -0,0 +1,296 @@
//! Interactive terminal containers — one themed `agent-terminal` (zsh +
//! oh-my-zsh + powerlevel10k) container per agent, provisioned lazily on first
//! WebSocket connect and reused across reconnects. Unlike the agent tool
//! sandboxes, these are interactive PTYs (`exec -it zsh`), so a small idle
//! sweeper reaps containers that no live session has touched for a while.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use cm_domain::{AgentId, WorkspaceId};
use cm_sandbox::{DriveMount, PtySession, SandboxDriver, SandboxHandle, SandboxKind, SandboxSpec};
use tokio::sync::Mutex;
/// Where the agent's Files drives live, so the Terminal can mount them.
#[derive(Clone)]
pub struct DriveConfig {
/// The engine's named volume holding all file-drive blobs (e.g. `clawmates_filedata`).
pub volume: String,
/// The server's view of the blob root, to create per-agent subdirs before
/// the (subpath) mount — Docker errors on a missing subpath.
pub data_dir: PathBuf,
}
pub struct TerminalManager {
driver: Arc<dyn SandboxDriver>,
image: String,
egress: bool,
/// Drive mounts; None disables the ~/drives mapping.
drives: Option<DriveConfig>,
handles: Mutex<HashMap<AgentId, SandboxHandle>>,
/// Live WebSocket sessions per agent + when each agent was last touched —
/// an agent with zero sessions, idle past the TTL, gets its container reaped.
active: Mutex<HashMap<AgentId, usize>>,
last_seen: Mutex<HashMap<AgentId, Instant>>,
}
impl std::fmt::Debug for TerminalManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TerminalManager")
.field("image", &self.image)
.finish_non_exhaustive()
}
}
impl TerminalManager {
pub fn new(
driver: Arc<dyn SandboxDriver>,
image: &str,
egress: bool,
drives: Option<DriveConfig>,
) -> TerminalManager {
TerminalManager {
driver,
image: image.to_owned(),
egress,
drives,
handles: Mutex::new(HashMap::new()),
active: Mutex::new(HashMap::new()),
last_seen: Mutex::new(HashMap::new()),
}
}
/// The three Files drives mounted read-write at `~/drives/*`, each a per-agent
/// subpath of the shared volume (subpath = isolation). Creates the subdirs
/// first so the mount doesn't fail on an empty drive.
async fn drive_mounts(&self, workspace_id: WorkspaceId, agent_id: AgentId) -> Vec<DriveMount> {
let Some(cfg) = &self.drives else {
return Vec::new();
};
let ws = workspace_id.to_string();
let agent = agent_id.to_string();
// (drive, scope, mount target) per `files::blob_key`: documents/received +
// the Obsidian vault are agent-scoped; the shared ClawDrive is team-wide.
// The vault lands at ~/obsidian (the agent's second-brain markdown vault).
let drives: [(&str, String, &str); 4] = [
("documents", agent.clone(), "/home/agent/drives/documents"),
("received", agent.clone(), "/home/agent/drives/received"),
("shared", "shared".to_string(), "/home/agent/drives/shared"),
("vault", agent.clone(), "/home/agent/obsidian"),
];
let mut out = Vec::with_capacity(drives.len());
for (drive, scope, target) in drives {
let subpath = format!("{ws}/{drive}/{scope}");
let _ = tokio::fs::create_dir_all(cfg.data_dir.join(&subpath)).await;
out.push(DriveMount {
volume: cfg.volume.clone(),
subpath,
target: target.to_string(),
read_only: false,
});
}
out
}
async fn provision_one(
&self,
workspace_id: WorkspaceId,
agent_id: AgentId,
) -> Result<SandboxHandle, String> {
let short = uuid::Uuid::now_v7().simple().to_string();
let spec = SandboxSpec {
name: format!("tc-term-{}", &short[short.len() - 12..]),
image: self.image.clone(),
// A dev shell wants more headroom than a tool sandbox.
memory_bytes: 1024 * 1024 * 1024,
nano_cpus: 2_000_000_000,
pids_limit: 512,
egress: self.egress,
kind: SandboxKind::Terminal,
mounts: self.drive_mounts(workspace_id, agent_id).await,
};
self.driver
.provision(&spec)
.await
.map_err(|e| format!("terminal provision failed: {e}"))
}
/// The agent's terminal container, provisioned on first use; a dead one is
/// replaced transparently.
async fn ensure(
&self,
workspace_id: WorkspaceId,
agent_id: AgentId,
) -> Result<SandboxHandle, String> {
let mut handles = self.handles.lock().await;
let alive = match handles.get(&agent_id) {
Some(handle) => self.driver.health(handle).await.unwrap_or(false),
None => false,
};
if !alive {
if let Some(stale) = handles.remove(&agent_id) {
let _ = self.driver.destroy(&stale).await;
}
let handle = self.provision_one(workspace_id, agent_id).await?;
handles.insert(agent_id, handle);
}
Ok(handles.get(&agent_id).expect("just ensured").clone())
}
/// Open an interactive login zsh in the agent's terminal container. `env`
/// adds session vars (e.g. a `CLAWMATES_USER` MOTD greeting). Counts a live
/// session (paired with [`detach`]).
pub async fn attach(
&self,
workspace_id: WorkspaceId,
agent_id: AgentId,
cols: u16,
rows: u16,
env: &[String],
tmux_session: &str,
) -> Result<PtySession, String> {
let handle = self.ensure(workspace_id, agent_id).await?;
// tmux attach-or-create: the named session + its processes survive a WS
// disconnect and resume on reconnect. Different session names are
// independent tabs sharing the agent's one container (+ its ~/drives).
let session = self
.driver
.attach_pty(
&handle,
// -A attach-or-create; -D detaches any stale client on reattach so
// the resumed session redraws cleanly at the new client's size.
&["tmux", "new-session", "-A", "-D", "-s", tmux_session],
cols,
rows,
env,
)
.await
.map_err(|e| format!("terminal attach failed: {e}"))?;
*self.active.lock().await.entry(agent_id).or_insert(0) += 1;
self.last_seen.lock().await.insert(agent_id, Instant::now());
Ok(session)
}
pub async fn resize(&self, exec_id: &str, cols: u16, rows: u16) -> Result<(), String> {
self.driver
.resize_pty(exec_id, cols, rows)
.await
.map_err(|e| format!("terminal resize failed: {e}"))
}
/// A session ended; the container stays warm for reconnects but becomes a
/// candidate for the idle sweeper once no session remains.
pub async fn detach(&self, agent_id: AgentId) {
if let Some(n) = self.active.lock().await.get_mut(&agent_id) {
*n = n.saturating_sub(1);
}
self.last_seen.lock().await.insert(agent_id, Instant::now());
}
/// Tear down a single agent's terminal container (on agent deletion / idle).
/// Returns whether one existed.
pub async fn release_agent(&self, agent_id: AgentId) -> bool {
self.active.lock().await.remove(&agent_id);
self.last_seen.lock().await.remove(&agent_id);
let handle = { self.handles.lock().await.remove(&agent_id) };
match handle {
Some(h) => {
if let Err(e) = self.driver.destroy(&h).await {
eprintln!("terminal release: failed to remove {}: {e}", h.id);
}
true
}
None => false,
}
}
/// Destroy every terminal container on graceful shutdown (SIGTERM).
pub async fn shutdown(&self) {
let mut handles = self.handles.lock().await;
for (_, handle) in handles.drain() {
if let Err(e) = self.driver.destroy(&handle).await {
eprintln!("terminal shutdown: failed to remove {}: {e}", handle.id);
}
}
}
/// Reap idle terminals (no live session, untouched past `idle_ttl`) and
/// orphan containers the engine still holds that no live handle owns.
async fn sweep(&self, idle_ttl: Duration) -> usize {
// 1. Idle, session-less terminals we still track.
let now = Instant::now();
let idle: Vec<AgentId> = {
let active = self.active.lock().await;
let last = self.last_seen.lock().await;
last.iter()
.filter(|(id, seen)| {
active.get(*id).copied().unwrap_or(0) == 0
&& now.duration_since(**seen) > idle_ttl
})
.map(|(id, _)| *id)
.collect()
};
let mut reaped = 0;
for id in idle {
if self.release_agent(id).await {
reaped += 1;
}
}
// 2. Orphans from a crashed process (label-filtered, no live handle).
let managed = match self.driver.list_managed(SandboxKind::Terminal.label()).await {
Ok(m) => m,
Err(e) => {
eprintln!("terminal reaper: list failed: {e}");
return reaped;
}
};
let live: std::collections::HashSet<String> = {
let handles = self.handles.lock().await;
handles.values().map(|h| h.id.clone()).collect()
};
let now_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let min_age = idle_ttl.as_secs() as i64;
for m in managed {
if live.contains(&m.id) {
continue;
}
if min_age > 0 && now_unix - m.created_unix < min_age {
continue;
}
let handle = SandboxHandle {
id: m.id.clone(),
name: m.id.clone(),
};
match self.driver.destroy(&handle).await {
Ok(()) => reaped += 1,
Err(e) => eprintln!("terminal reaper: failed to remove {}: {e}", m.id),
}
}
reaped
}
/// Boot reconciliation: remove every terminal container the engine still
/// holds (all are orphans from a dead process — we track none yet).
pub async fn reconcile_orphans(&self) -> usize {
self.sweep(Duration::ZERO).await
}
/// Background reaper: every `interval`, reap idle (`idle_ttl`) + orphan terminals.
pub fn spawn_reaper(self: Arc<Self>, interval: Duration, idle_ttl: Duration) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(interval);
loop {
tick.tick().await;
let n = self.sweep(idle_ttl).await;
if n > 0 {
eprintln!("terminal reaper: removed {n} terminal container(s)");
}
}
});
}
}
+1
View File
@@ -142,6 +142,7 @@ async fn slack_post_blocks_then_the_broker_executes_exactly_once() {
max_tokens: 1024, max_tokens: 1024,
sandboxes: None, sandboxes: None,
browser: None, browser: None,
terminals: None,
broker_socket: Some(socket), broker_socket: Some(socket),
slack_base_url: sink_url, slack_base_url: sink_url,
providers: Default::default(), providers: Default::default(),
+122 -15
View File
@@ -2,15 +2,17 @@
//! development and the air-gapped compose target; podman works through the //! development and the air-gapped compose target; podman works through the
//! same API via `DOCKER_HOST`. //! same API via `DOCKER_HOST`.
use bollard::exec::{CreateExecOptions, StartExecResults}; use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
use bollard::models::{ContainerCreateBody, HostConfig}; use bollard::models::{
ContainerCreateBody, HostConfig, Mount, MountTypeEnum, MountVolumeOptions,
};
use bollard::query_parameters::{ use bollard::query_parameters::{
CreateContainerOptions, InspectContainerOptions, RemoveContainerOptions, StartContainerOptions, CreateContainerOptions, InspectContainerOptions, RemoveContainerOptions, StartContainerOptions,
}; };
use bollard::Docker; use bollard::Docker;
use futures::StreamExt; use futures::StreamExt;
use crate::spec::{ExecResult, ManagedSandbox, SandboxHandle, SandboxSpec}; use crate::spec::{ExecResult, ManagedSandbox, PtySession, SandboxHandle, SandboxSpec};
use crate::{SandboxDriver, SandboxError}; use crate::{SandboxDriver, SandboxError};
/// The seccomp deny profile, embedded so the Docker path needs no file /// The seccomp deny profile, embedded so the Docker path needs no file
@@ -59,15 +61,15 @@ impl DockerDriver {
#[async_trait::async_trait] #[async_trait::async_trait]
impl SandboxDriver for DockerDriver { impl SandboxDriver for DockerDriver {
async fn provision(&self, spec: &SandboxSpec) -> Result<SandboxHandle, SandboxError> { async fn provision(&self, spec: &SandboxSpec) -> Result<SandboxHandle, SandboxError> {
// The §15 controls, enforced unconditionally: // Hardened tool sandboxes are read-only with a tmpfs home; the Terminal
let host_config = HostConfig { // flavour keeps a writable rootfs + home so its baked zsh/oh-my-zsh/p10k
cap_drop: Some(vec!["ALL".into()]), // config and shell history survive the session. Everything else (non-root,
security_opt: Some(vec![ // cap-drop ALL, seccomp, no-new-privileges, limits) stays identical.
"no-new-privileges:true".into(), let writable = spec.kind.writable_home();
format!("seccomp={SECCOMP_PROFILE}"), let tmpfs = if writable {
]), None
readonly_rootfs: Some(true), } else {
tmpfs: Some( Some(
[ [
( (
"/tmp".to_owned(), "/tmp".to_owned(),
@@ -81,7 +83,39 @@ impl SandboxDriver for DockerDriver {
] ]
.into_iter() .into_iter()
.collect(), .collect(),
), )
};
// Per-agent volume-subpath mounts (the Terminal's Files drives).
let mounts: Option<Vec<Mount>> = if spec.mounts.is_empty() {
None
} else {
Some(
spec.mounts
.iter()
.map(|m| Mount {
target: Some(m.target.clone()),
source: Some(m.volume.clone()),
typ: Some(MountTypeEnum::VOLUME),
read_only: Some(m.read_only),
volume_options: Some(MountVolumeOptions {
subpath: Some(m.subpath.clone()),
..Default::default()
}),
..Default::default()
})
.collect(),
)
};
// The §15 controls, enforced unconditionally:
let host_config = HostConfig {
cap_drop: Some(vec!["ALL".into()]),
security_opt: Some(vec![
"no-new-privileges:true".into(),
format!("seccomp={SECCOMP_PROFILE}"),
]),
readonly_rootfs: Some(!writable),
tmpfs,
mounts,
network_mode: Some(if spec.egress { "bridge" } else { "none" }.into()), network_mode: Some(if spec.egress { "bridge" } else { "none" }.into()),
// Lets browser containers reach host-published test pages on // Lets browser containers reach host-published test pages on
// Linux engines; Docker Desktop resolves this name natively. // Linux engines; Docker Desktop resolves this name natively.
@@ -97,13 +131,13 @@ impl SandboxDriver for DockerDriver {
}; };
let body = ContainerCreateBody { let body = ContainerCreateBody {
image: Some(spec.image.clone()), image: Some(spec.image.clone()),
user: Some("10001:10001".into()), user: Some(spec.kind.run_user().into()),
cmd: Some(vec!["sleep".into(), "infinity".into()]), cmd: Some(vec!["sleep".into(), "infinity".into()]),
host_config: Some(host_config), host_config: Some(host_config),
// Mark every sandbox so the reaper can find orphans after a crash. // Mark every sandbox so the reaper can find orphans after a crash.
labels: Some(std::collections::HashMap::from([( labels: Some(std::collections::HashMap::from([(
crate::SANDBOX_LABEL.to_string(), crate::SANDBOX_LABEL.to_string(),
crate::sandbox_kind(spec.egress).to_string(), spec.kind.label().to_string(),
)])), )])),
..Default::default() ..Default::default()
}; };
@@ -180,6 +214,79 @@ impl SandboxDriver for DockerDriver {
}) })
} }
async fn attach_pty(
&self,
handle: &SandboxHandle,
cmd: &[&str],
cols: u16,
rows: u16,
env: &[String],
) -> Result<PtySession, SandboxError> {
let exec = self
.docker
.create_exec(
&handle.id,
CreateExecOptions {
cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
env: if env.is_empty() { None } else { Some(env.to_vec()) },
attach_stdin: Some(true),
attach_stdout: Some(true),
attach_stderr: Some(true),
tty: Some(true),
..Default::default()
},
)
.await
.map_err(|e| SandboxError::Engine(e.to_string()))?;
match self
.docker
.start_exec(&exec.id, None)
.await
.map_err(|e| SandboxError::Engine(e.to_string()))?
{
StartExecResults::Attached { output, input } => {
// Best-effort initial window size; resize_pty handles later changes.
let _ = self
.docker
.resize_exec(
&exec.id,
ResizeExecOptions {
height: rows,
width: cols,
},
)
.await;
let bytes = output.map(|chunk| {
chunk
.map(|log| log.into_bytes().to_vec())
.map_err(|e| SandboxError::Engine(e.to_string()))
});
Ok(PtySession {
exec_id: exec.id,
output: Box::pin(bytes),
input,
})
}
StartExecResults::Detached => {
Err(SandboxError::Engine("pty exec detached unexpectedly".into()))
}
}
}
async fn resize_pty(&self, exec_id: &str, cols: u16, rows: u16) -> Result<(), SandboxError> {
self.docker
.resize_exec(
exec_id,
ResizeExecOptions {
height: rows,
width: cols,
},
)
.await
.map_err(|e| SandboxError::Engine(e.to_string()))
}
async fn destroy(&self, handle: &SandboxHandle) -> Result<(), SandboxError> { async fn destroy(&self, handle: &SandboxHandle) -> Result<(), SandboxError> {
self.destroy_by_name(&handle.id).await self.destroy_by_name(&handle.id).await
} }
+21
View File
@@ -263,6 +263,27 @@ impl SandboxDriver for K8sDriver {
}) })
} }
async fn attach_pty(
&self,
_handle: &SandboxHandle,
_cmd: &[&str],
_cols: u16,
_rows: u16,
_env: &[String],
) -> Result<crate::PtySession, SandboxError> {
// The interactive Terminal runs on the Docker target today; the k8s
// attach (kube exec with tty+stdin streams) is a later addition.
Err(SandboxError::Engine(
"interactive PTY is not yet supported on the Kubernetes driver".into(),
))
}
async fn resize_pty(&self, _exec_id: &str, _cols: u16, _rows: u16) -> Result<(), SandboxError> {
Err(SandboxError::Engine(
"interactive PTY is not yet supported on the Kubernetes driver".into(),
))
}
async fn destroy(&self, handle: &SandboxHandle) -> Result<(), SandboxError> { async fn destroy(&self, handle: &SandboxHandle) -> Result<(), SandboxError> {
self.pods() self.pods()
.delete(&handle.name, &DeleteParams::default().grace_period(0)) .delete(&handle.name, &DeleteParams::default().grace_period(0))
+17 -1
View File
@@ -12,7 +12,9 @@ mod spec;
pub use docker::DockerDriver; pub use docker::DockerDriver;
#[cfg(feature = "k8s")] #[cfg(feature = "k8s")]
pub use k8s::K8sDriver; pub use k8s::K8sDriver;
pub use spec::{ExecResult, ManagedSandbox, SandboxHandle, SandboxSpec}; pub use spec::{
DriveMount, ExecResult, ManagedSandbox, PtySession, SandboxHandle, SandboxKind, SandboxSpec,
};
/// Label every Clawmates sandbox carries, so orphans can be found + reaped /// Label every Clawmates sandbox carries, so orphans can be found + reaped
/// after a crash/restart. Value is the kind: `agent` (no egress) or `browser`. /// after a crash/restart. Value is the kind: `agent` (no egress) or `browser`.
@@ -42,6 +44,20 @@ pub trait SandboxDriver: Send + Sync {
/// Runs a command inside the sandbox (orchestrator-initiated only; the /// Runs a command inside the sandbox (orchestrator-initiated only; the
/// sandbox can initiate nothing outbound). /// sandbox can initiate nothing outbound).
async fn exec(&self, handle: &SandboxHandle, cmd: &[&str]) -> Result<ExecResult, SandboxError>; async fn exec(&self, handle: &SandboxHandle, cmd: &[&str]) -> Result<ExecResult, SandboxError>;
/// Starts an interactive PTY (`exec -it`) inside the sandbox: a TTY-backed
/// `cmd` (e.g. `["zsh","-l"]`) whose combined output streams back and whose
/// stdin accepts keystrokes. `env` adds `KEY=VALUE` vars to the session (e.g.
/// a MOTD greeting). Used by the Terminal app, not agent tools.
async fn attach_pty(
&self,
handle: &SandboxHandle,
cmd: &[&str],
cols: u16,
rows: u16,
env: &[String],
) -> Result<crate::PtySession, SandboxError>;
/// Resizes a running PTY exec's window (cols × rows).
async fn resize_pty(&self, exec_id: &str, cols: u16, rows: u16) -> Result<(), SandboxError>;
/// Stops and removes the sandbox. /// Stops and removes the sandbox.
async fn destroy(&self, handle: &SandboxHandle) -> Result<(), SandboxError>; async fn destroy(&self, handle: &SandboxHandle) -> Result<(), SandboxError>;
/// Whether the sandbox container is currently running. /// Whether the sandbox container is currently running.
+67
View File
@@ -15,6 +15,73 @@ pub struct SandboxSpec {
/// hold no credentials and have no broker route; their output is /// hold no credentials and have no broker route; their output is
/// tainted `web`. Everything else runs with no network at all. /// tainted `web`. Everything else runs with no network at all.
pub egress: bool, pub egress: bool,
/// What this sandbox is for — drives the reaper label and whether the
/// rootfs/home is writable.
pub kind: SandboxKind,
/// Named-volume subpath mounts (Terminal drives). Empty for tool sandboxes.
pub mounts: Vec<DriveMount>,
}
/// A read-write mount of a per-agent subpath of a named Docker volume into the
/// container — used to expose the Files drives inside the Terminal.
#[derive(Debug, Clone)]
pub struct DriveMount {
/// The engine's named volume (e.g. `clawmates_filedata`).
pub volume: String,
/// Subpath within the volume (per-agent isolation), e.g. `{ws}/documents/{agent}`.
pub subpath: String,
/// Mount target inside the container, e.g. `/home/agent/drives/documents`.
pub target: String,
pub read_only: bool,
}
/// The flavour of a sandbox container. Agent + Browser are hardened tool
/// sandboxes; Terminal is the interactive themed dev shell.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxKind {
/// Hardened, no-egress agent tool sandbox (read-only rootfs, tmpfs home).
Agent,
/// Egress-enabled browser sandbox (no credentials; output tainted `web`).
Browser,
/// Interactive themed terminal: a writable home so the baked zsh /
/// oh-my-zsh / powerlevel10k config + history work. Still non-root,
/// cap-drop ALL, seccomp, no-new-privileges and resource-limited.
Terminal,
}
impl SandboxKind {
/// The [`crate::SANDBOX_LABEL`] value, so each manager reaps only its own.
pub fn label(self) -> &'static str {
match self {
SandboxKind::Agent => "agent",
SandboxKind::Browser => "browser",
SandboxKind::Terminal => "terminal",
}
}
/// Terminal keeps a writable rootfs + home (baked dotfiles + history);
/// the hardened tool sandboxes stay read-only with a tmpfs home.
pub fn writable_home(self) -> bool {
matches!(self, SandboxKind::Terminal)
}
/// The uid:gid the container runs as. Terminal matches the server's nonroot
/// uid (65532) so it shares read-write ownership of the file-drive volume;
/// the hardened tool sandboxes run as 10001.
pub fn run_user(self) -> &'static str {
match self {
SandboxKind::Terminal => "65532:65532",
_ => "10001:10001",
}
}
}
/// An attached interactive PTY exec (a `docker exec -it` session): combined
/// TTY output as byte chunks + a writer for keystrokes, plus the exec id so
/// the window size can be resized.
pub struct PtySession {
pub exec_id: String,
pub output: std::pin::Pin<Box<dyn futures::Stream<Item = Result<Vec<u8>, crate::SandboxError>> + Send>>,
pub input: std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send>>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
+6
View File
@@ -64,6 +64,8 @@ async fn spawn(suffix: &str) -> (K8sDriver, cm_sandbox::SandboxHandle) {
nano_cpus: 1_000_000_000, nano_cpus: 1_000_000_000,
pids_limit: 128, pids_limit: 128,
egress: false, egress: false,
kind: cm_sandbox::SandboxKind::Agent,
mounts: Vec::new(),
}; };
let handle = driver.provision(&spec).await.expect("pod provisions"); let handle = driver.provision(&spec).await.expect("pod provisions");
(driver, handle) (driver, handle)
@@ -200,6 +202,8 @@ async fn localhost_seccomp_profile_denies_unshare_inside_pods() {
nano_cpus: 1_000_000_000, nano_cpus: 1_000_000_000,
pids_limit: 128, pids_limit: 128,
egress: false, egress: false,
kind: cm_sandbox::SandboxKind::Agent,
mounts: Vec::new(),
}; };
let handle = driver.provision(&spec).await.expect("pod provisions"); let handle = driver.provision(&spec).await.expect("pod provisions");
@@ -249,6 +253,8 @@ async fn calico_enforces_the_default_deny_egress() {
nano_cpus: 1_000_000_000, nano_cpus: 1_000_000_000,
pids_limit: 128, pids_limit: 128,
egress: false, egress: false,
kind: cm_sandbox::SandboxKind::Agent,
mounts: Vec::new(),
}; };
let handle = driver.provision(&spec).await.expect("pod provisions"); let handle = driver.provision(&spec).await.expect("pod provisions");
+2
View File
@@ -44,6 +44,8 @@ async fn spawn(name_suffix: &str) -> (DockerDriver, cm_sandbox::SandboxHandle) {
nano_cpus: 1_000_000_000, nano_cpus: 1_000_000_000,
pids_limit: 128, pids_limit: 128,
egress: false, egress: false,
kind: cm_sandbox::SandboxKind::Agent,
mounts: Vec::new(),
}; };
// Clean leftovers from interrupted runs, then provision fresh. // Clean leftovers from interrupted runs, then provision fresh.
let _ = driver.destroy_by_name(&spec.name).await; let _ = driver.destroy_by_name(&spec.name).await;
+2
View File
@@ -105,6 +105,8 @@ async fn the_sandbox_lifecycle_works_through_the_allowlisted_proxy() {
nano_cpus: 500_000_000, nano_cpus: 500_000_000,
pids_limit: 64, pids_limit: 64,
egress: false, egress: false,
kind: cm_sandbox::SandboxKind::Agent,
mounts: Vec::new(),
}; };
for _ in 0..20 { for _ in 0..20 {
match driver.provision(&spec).await { match driver.provision(&spec).await {
+17
View File
@@ -10,6 +10,8 @@
"dependencies": { "dependencies": {
"@clerk/nextjs": "^7.5.0", "@clerk/nextjs": "^7.5.0",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.11.0", "@xyflow/react": "^12.11.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -3029,6 +3031,21 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
},
"node_modules/@xyflow/react": { "node_modules/@xyflow/react": {
"version": "12.11.0", "version": "12.11.0",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz",
+2
View File
@@ -13,6 +13,8 @@
"dependencies": { "dependencies": {
"@clerk/nextjs": "^7.5.0", "@clerk/nextjs": "^7.5.0",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.11.0", "@xyflow/react": "^12.11.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+31
View File
@@ -1,6 +1,37 @@
@import "tailwindcss"; @import "tailwindcss";
@import "../styles/motion.css"; @import "../styles/motion.css";
/* MesloLGS NF — the powerlevel10k-recommended Nerd Font, so the agent
Terminal app's p10k prompt renders its glyphs (icons/powerline) in xterm. */
@font-face {
font-family: "MesloLGS NF";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/MesloLGS-NF-Regular.ttf") format("truetype");
}
@font-face {
font-family: "MesloLGS NF";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/MesloLGS-NF-Bold.ttf") format("truetype");
}
@font-face {
font-family: "MesloLGS NF";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("/fonts/MesloLGS-NF-Italic.ttf") format("truetype");
}
@font-face {
font-family: "MesloLGS NF";
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("/fonts/MesloLGS-NF-Bold-Italic.ttf") format("truetype");
}
/* shadcn/ui "dark" class variant (the app runs under .dark; marketing is a /* shadcn/ui "dark" class variant (the app runs under .dark; marketing is a
separate explicit-light scope). */ separate explicit-light scope). */
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -14,6 +14,8 @@ const SettingsApp = dynamic(() => import("./apps/SettingsApp"));
const SlackApp = dynamic(() => import("./apps/SlackApp")); const SlackApp = dynamic(() => import("./apps/SlackApp"));
const AddAppsApp = dynamic(() => import("./apps/AddAppsApp")); const AddAppsApp = dynamic(() => import("./apps/AddAppsApp"));
const BrowserApp = dynamic(() => import("./apps/BrowserApp")); const BrowserApp = dynamic(() => import("./apps/BrowserApp"));
const TerminalApp = dynamic(() => import("./apps/TerminalApp"));
const ObsidianApp = dynamic(() => import("./apps/ObsidianApp"));
export function appTitle(app: AppId): string { export function appTitle(app: AppId): string {
switch (resolveAppView(app)) { switch (resolveAppView(app)) {
@@ -21,6 +23,10 @@ export function appTitle(app: AppId): string {
return "Browser"; return "Browser";
case "slack": case "slack":
return "Slack"; return "Slack";
case "terminal":
return "Terminal";
case "obsidian":
return "Obsidian Vault";
case "chat": case "chat":
return "Agent Chat"; return "Agent Chat";
case "skills": case "skills":
@@ -45,6 +51,10 @@ export function AppRouter({ app, agent }: { app: AppId; agent: Agent }) {
return <BrowserApp agent={agent} />; return <BrowserApp agent={agent} />;
case "slack": case "slack":
return <SlackApp agent={agent} />; return <SlackApp agent={agent} />;
case "terminal":
return <TerminalApp agent={agent} />;
case "obsidian":
return <ObsidianApp agent={agent} />;
case "chat": case "chat":
return <ClawChatApp agent={agent} />; return <ClawChatApp agent={agent} />;
case "skills": case "skills":
@@ -141,6 +141,7 @@ export function DevicePanel({ agent }: { agent: Agent }) {
</span> </span>
</button> </button>
<div className="flex-1" /> <div className="flex-1" />
{subHeader.right}
</> </>
) : ( ) : (
<> <>
@@ -35,6 +35,32 @@ function tileSurface(app: AppId) {
</div> </div>
); );
} }
if (app === "obsidian") {
// A purple gem tile for the agent's Obsidian-style vault (a faceted-crystal
// glyph on the brand-purple squircle).
return (
<div
className="relative flex size-14 items-center justify-center rounded-2xl select-none"
style={{
background: "linear-gradient(150deg,#a78bfa 0%,#7c3aed 55%,#4c1d95 100%)",
boxShadow: TILE_SHADOW,
}}
>
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M12 2.6 L18.4 8.2 L14.8 21 L9.2 21 L5.6 8.2 Z"
fill="rgba(255,255,255,0.95)"
/>
<path
d="M12 2.6 L12 21 M5.6 8.2 L18.4 8.2 M12 2.6 L9.2 21 M12 2.6 L14.8 21"
stroke="#6d28d9"
strokeWidth="0.7"
opacity="0.5"
/>
</svg>
</div>
);
}
if (app === "apps") { if (app === "apps") {
// Plain plus in the dashed tile (currentColor = white/65), not the coral // Plain plus in the dashed tile (currentColor = white/65), not the coral
// gradient glyph — matches WorkClaw's Add Apps tile. // gradient glyph — matches WorkClaw's Add Apps tile.
@@ -45,9 +71,17 @@ function tileSurface(app: AppId) {
</div> </div>
); );
} }
// The Terminal glyph reads bright green (vs the default coral) so it stands out.
// Glyphs are the colored gradient OUTLINE style (not solid-filled).
const green = app === "terminal";
return ( return (
<div className="relative flex size-14 items-center justify-center rounded-2xl bg-[#1f1f1f] shadow-dock-tile select-none"> <div className="relative flex size-14 items-center justify-center rounded-2xl bg-[#1f1f1f] shadow-dock-tile select-none">
<GradientGlyph icon={APP_ICON[app]} size={34} filled /> <GradientGlyph
icon={APP_ICON[app]}
size={34}
from={green ? "#86efac" : undefined}
to={green ? "#22c55e" : undefined}
/>
</div> </div>
); );
} }
@@ -1,11 +1,13 @@
import { import {
Bell, Bell,
Folder, Folder,
Gem,
Globe, Globe,
MessageCircle, MessageCircle,
Plus, Plus,
Settings, Settings,
Hash, Hash,
Terminal,
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
@@ -18,6 +20,8 @@ import type { AppId } from "@/lib/url/panel-params";
export const APP_ICON: Record<string, LucideIcon> = { export const APP_ICON: Record<string, LucideIcon> = {
browser: Globe, browser: Globe,
slack: Hash, slack: Hash,
terminal: Terminal,
obsidian: Gem,
chat: MessageCircle, chat: MessageCircle,
apps: Plus, apps: Plus,
skills: Zap, skills: Zap,
@@ -30,7 +34,9 @@ export const APP_ICON: Record<string, LucideIcon> = {
export const HOME_GRID: { app: AppId; label: string }[] = [ export const HOME_GRID: { app: AppId; label: string }[] = [
{ app: "browser", label: "Browser" }, { app: "browser", label: "Browser" },
{ app: "slack", label: "Slack" }, { app: "slack", label: "Slack" },
{ app: "chat", label: "Claw Chat" }, { app: "terminal", label: "Terminal" },
{ app: "obsidian", label: "Obsidian" },
{ app: "chat", label: "Chat" },
{ app: "apps", label: "Add Apps" }, { app: "apps", label: "Add Apps" },
]; ];
@@ -1,23 +1,36 @@
"use client"; "use client";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { createContext, useContext, useEffect, useRef } from "react"; import {
createContext,
useContext,
useEffect,
useRef,
type ReactNode,
} from "react";
import { GradientGlyph } from "@/components/ui/GradientGlyph"; import { GradientGlyph } from "@/components/ui/GradientGlyph";
/* When an app drills into a sub-screen it registers a back-header here; the /* When an app drills into a sub-screen it registers a back-header here; the
panel chrome (DevicePanel) then morphs its single header from "app name + panel chrome (DevicePanel) then morphs its single header from "app name +
close-X" to "back-chevron + sub-title" — one header, like the reference. */ close-X" to "back-chevron + sub-title" — one header, like the reference.
export type SubHeader = { title: string; onBack: () => void }; `right` lets an app add controls to the header's right edge (e.g. the
Terminal's "+ tab" button). */
export type SubHeader = {
title: string;
onBack: () => void;
right?: ReactNode;
};
export const SubHeaderContext = createContext<(h: SubHeader | null) => void>( export const SubHeaderContext = createContext<(h: SubHeader | null) => void>(
() => {}, () => {},
); );
/** Registers `{title, onBack}` with the panel header while `active`. */ /** Registers `{title, onBack, right}` with the panel header while `active`. */
export function useSubHeader( export function useSubHeader(
active: boolean, active: boolean,
title: string, title: string,
onBack: () => void, onBack: () => void,
right?: ReactNode,
) { ) {
const setSub = useContext(SubHeaderContext); const setSub = useContext(SubHeaderContext);
const backRef = useRef(onBack); const backRef = useRef(onBack);
@@ -27,9 +40,9 @@ export function useSubHeader(
}); });
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return;
setSub({ title, onBack: () => backRef.current() }); setSub({ title, onBack: () => backRef.current(), right });
return () => setSub(null); return () => setSub(null);
}, [active, title, setSub]); }, [active, title, right, setSub]);
} }
/* The shared empty / error state (measured): muted lucide glyph, 16px/600 /* The shared empty / error state (measured): muted lucide glyph, 16px/600
@@ -0,0 +1,236 @@
"use client";
// The agent's Obsidian-style "vault": a markdown second brain stored under the
// agent's `vault` drive (mounted into the terminal at ~/obsidian). This viewer
// lists the notes and renders a selected one; the agent writes notes/references
// here over time.
import { useState, type ReactNode } from "react";
import { FileText, Gem } from "lucide-react";
import type { Agent } from "@/lib/api/schemas";
import { useFetchJson } from "@/lib/api/use-fetch";
import { GradientGlyph } from "@/components/ui/GradientGlyph";
import { PanelEmptyState, useSubHeader } from "./AppShell";
interface VaultNote {
path: string;
size: number;
}
const noteTitle = (path: string) =>
path.replace(/\.(md|markdown)$/i, "").split("/").pop() || path;
export default function ObsidianApp({ agent }: { agent: Agent }) {
const { data, loading } = useFetchJson<VaultNote[]>(
`/api/openclaw/files?clawId=${agent.id}&drive=vault`,
);
const [open, setOpen] = useState<VaultNote | null>(null);
const [content, setContent] = useState<string | null>(null);
const [loadingNote, setLoadingNote] = useState(false);
useSubHeader(open !== null, open ? noteTitle(open.path) : "", () => {
setOpen(null);
setContent(null);
});
const openNote = async (note: VaultNote) => {
setOpen(note);
setContent(null);
setLoadingNote(true);
try {
const res = await fetch(
`/api/openclaw/files/content?clawId=${agent.id}&drive=vault&path=${encodeURIComponent(note.path)}`,
);
setContent(res.ok ? await res.text() : "*(could not load this note)*");
} catch {
setContent("*(could not load this note)*");
} finally {
setLoadingNote(false);
}
};
if (open) {
return (
<div className="px-4 py-3">
{loadingNote ? (
<p className="text-xs text-muted-foreground">Loading…</p>
) : (
<Markdown text={content ?? ""} />
)}
</div>
);
}
return (
<div className="p-2">
{loading ? (
<p className="px-2 py-2 text-xs text-muted-foreground">Loading vault…</p>
) : data && data.length > 0 ? (
<>
<p className="px-2 pb-2 pt-1 text-[11px] text-muted-foreground">
{agent.name}&apos;s second-brain vault — {data.length} note
{data.length === 1 ? "" : "s"}
</p>
<ul aria-label="Vault notes">
{data.map((note) => (
<li key={note.path}>
<button
type="button"
onClick={() => openNote(note)}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-neutral-800/40"
>
<GradientGlyph icon={FileText} size={17} from="#a78bfa" to="#7c3aed" />
<span className="truncate">{noteTitle(note.path)}</span>
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground">
{note.size} B
</span>
</button>
</li>
))}
</ul>
</>
) : (
<PanelEmptyState
icon={Gem}
title="Your vault is empty"
subtitle={`${agent.name} writes notes & references into ~/obsidian as a second brain — they'll show up here.`}
/>
)}
</div>
);
}
/* A compact markdown renderer (headings, bold/code/links, bullet lists, fenced
code) — enough to read vault notes; a richer Obsidian view can come later. */
function inline(text: string, kp: string): ReactNode[] {
return text
.split(/(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g)
.filter(Boolean)
.map((p, i) => {
if (p.startsWith("**") && p.endsWith("**"))
return (
<strong key={`${kp}-${i}`} className="font-semibold text-white">
{p.slice(2, -2)}
</strong>
);
if (p.startsWith("`") && p.endsWith("`"))
return (
<code
key={`${kp}-${i}`}
className="rounded bg-white/10 px-1 py-0.5 font-mono text-[0.85em]"
>
{p.slice(1, -1)}
</code>
);
const link = p.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
if (link)
return (
<a
key={`${kp}-${i}`}
href={link[2]}
target="_blank"
rel="noreferrer"
className="text-violet-400 underline"
>
{link[1]}
</a>
);
return <span key={`${kp}-${i}`}>{p}</span>;
});
}
function Markdown({ text }: { text: string }) {
const lines = text.replace(/\r\n/g, "\n").split("\n");
const blocks: ReactNode[] = [];
let para: string[] = [];
let list: string[] = [];
let code: string[] | null = null;
const flushPara = () => {
if (para.length) {
const k = `p${blocks.length}`;
blocks.push(
<p key={k} className="mb-2 text-sm leading-relaxed text-neutral-200">
{inline(para.join(" "), k)}
</p>,
);
para = [];
}
};
const flushList = () => {
if (list.length) {
const k = `u${blocks.length}`;
blocks.push(
<ul key={k} className="mb-2 list-disc pl-5 text-sm text-neutral-200">
{list.map((li, i) => (
<li key={i} className="leading-relaxed">
{inline(li, `${k}-${i}`)}
</li>
))}
</ul>,
);
list = [];
}
};
for (const raw of lines) {
const line = raw.replace(/\s+$/, "");
if (line.startsWith("```")) {
if (code) {
blocks.push(
<pre
key={`c${blocks.length}`}
className="mb-2 overflow-x-auto rounded-lg bg-black/40 p-3 font-mono text-xs text-neutral-200"
>
{code.join("\n")}
</pre>,
);
code = null;
} else {
flushPara();
flushList();
code = [];
}
continue;
}
if (code) {
code.push(raw);
continue;
}
const h = line.match(/^(#{1,6})\s+(.*)$/);
const li = line.match(/^\s*[-*]\s+(.*)$/);
if (h) {
flushPara();
flushList();
const lvl = h[1].length;
const size = lvl <= 1 ? "text-lg" : lvl === 2 ? "text-base" : "text-sm";
blocks.push(
<div key={`h${blocks.length}`} className={`mb-1.5 mt-3 font-semibold text-white ${size}`}>
{inline(h[2], `h${blocks.length}`)}
</div>,
);
} else if (li) {
flushPara();
list.push(li[1]);
} else if (!line.trim()) {
flushPara();
flushList();
} else {
flushList();
para.push(line);
}
}
flushPara();
flushList();
if (code && code.length) {
blocks.push(
<pre
key={`c${blocks.length}`}
className="mb-2 overflow-x-auto rounded-lg bg-black/40 p-3 font-mono text-xs text-neutral-200"
>
{code.join("\n")}
</pre>,
);
}
return <div>{blocks}</div>;
}
@@ -0,0 +1,434 @@
"use client";
// Multi-tab interactive terminal in the agent computer. Each tab is its own
// xterm.js ⇄ WebSocket ⇄ a distinct tmux session, but every tab shares the SAME
// per-agent container (so the same ~/drives are mounted + shared between them).
// The header gets a back arrow (→ the computer home) and a "+" to add tabs (max 5).
import { useEffect, useMemo, useRef, useState } from "react";
import { Check, Plus, Save, X } from "lucide-react";
import { useQueryStates } from "nuqs";
import type { Agent } from "@/lib/api/schemas";
import { panelParsers } from "@/lib/url/panel-params";
import { useSubHeader } from "./AppShell";
import "@xterm/xterm/css/xterm.css";
const MAX_TABS = 5;
type Tab = { id: number; session: string; name?: string };
type TabState = { tabs: Tab[]; activeId: number; nextId: number };
// Open tabs persist in localStorage (keyed by agent) so they — and their tmux
// sessions — are restored when the app is reopened, including after a full page
// reload. (The sessions live in the per-agent container; a reaped one just comes
// back as a fresh shell when the tab reattaches.)
const TABS_KEY = "cm.terminal.tabs";
function loadTabState(agentId: string): TabState | null {
if (typeof window === "undefined") return null;
try {
const all = JSON.parse(window.localStorage.getItem(TABS_KEY) || "{}");
const v = all?.[agentId];
if (v && Array.isArray(v.tabs) && v.tabs.length) return v as TabState;
} catch {
/* ignore */
}
return null;
}
function saveTabState(agentId: string, v: TabState) {
if (typeof window === "undefined") return;
try {
const all = JSON.parse(window.localStorage.getItem(TABS_KEY) || "{}");
all[agentId] = v;
window.localStorage.setItem(TABS_KEY, JSON.stringify(all));
} catch {
/* ignore */
}
}
export default function TerminalApp({ agent }: { agent: Agent }) {
const [, setParams] = useQueryStates(panelParsers, { shallow: true });
// Tab 1 attaches to the resumable "main" session; extra tabs get their own.
const cached = useMemo(() => loadTabState(agent.id), [agent.id]);
const [tabs, setTabs] = useState<Tab[]>(
() => cached?.tabs ?? [{ id: 1, session: "main" }],
);
const [activeId, setActiveId] = useState(() => cached?.activeId ?? 1);
const nextId = useRef(cached?.nextId ?? 2);
const [editingId, setEditingId] = useState<number | null>(null);
const [editValue, setEditValue] = useState("");
const [saved, setSaved] = useState(false);
const [dragId, setDragId] = useState<number | null>(null);
// Persist the tab set to localStorage (survives reload in this browser).
useEffect(() => {
saveTabState(agent.id, { tabs, activeId, nextId: nextId.current });
}, [tabs, activeId, agent.id]);
// If nothing's in this browser, restore the user's server-saved layout (so a
// new device / cleared storage / a logout-login still brings the tabs back).
useEffect(() => {
if (cached) return;
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/terminal/${agent.id}/tabs`);
if (!res.ok) return;
const data = (await res.json()) as { tabs?: { name?: string; session?: string }[] } | null;
if (cancelled || !data || !Array.isArray(data.tabs) || data.tabs.length === 0) return;
const restored: Tab[] = data.tabs.slice(0, MAX_TABS).map((t, i) => ({
id: i + 1,
session: t.session || (i === 0 ? "main" : `tab${i + 1}`),
name: t.name || undefined,
}));
setTabs(restored);
setActiveId(restored[0].id);
nextId.current = restored.length + 1;
} catch {
/* ignore */
}
})();
return () => {
cancelled = true;
};
}, [agent.id, cached]);
const addTab = () => {
if (tabs.length >= MAX_TABS) return;
const id = nextId.current++;
setTabs((cur) => [...cur, { id, session: `tab${id}` }]);
setActiveId(id);
};
const closeTab = (id: number) => {
if (tabs.length <= 1) return;
const next = tabs.filter((t) => t.id !== id);
setTabs(next);
if (id === activeId) setActiveId(next[next.length - 1].id);
};
const commitRename = (id: number, name: string) => {
setTabs((cur) =>
cur.map((t) => (t.id === id ? { ...t, name: name.trim() || undefined } : t)),
);
setEditingId(null);
};
// Drag a tab onto another to reorder (the session mapping stays with the tab).
const reorder = (fromId: number, toId: number) => {
if (fromId === toId) return;
setTabs((cur) => {
const from = cur.findIndex((t) => t.id === fromId);
const to = cur.findIndex((t) => t.id === toId);
if (from < 0 || to < 0) return cur;
const next = [...cur];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
};
// Save the named tabs + their sessions to the server (account-tied, durable).
const saveLayout = async () => {
const layout = {
tabs: tabs.map((t) => ({ name: t.name ?? null, session: t.session })),
};
try {
await fetch(`/api/terminal/${agent.id}/tabs`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ layout }),
});
setSaved(true);
setTimeout(() => setSaved(false), 1800);
} catch {
/* ignore */
}
};
// Save + "new tab" buttons at the right edge of the panel header.
const headerRight = useMemo(
() => (
<div className="flex items-center gap-0.5">
<button
type="button"
aria-label="Save tabs"
title="Save these tabs (restored after you log back in)"
onClick={saveLayout}
className={`flex size-7 items-center justify-center rounded-full transition-colors hover:bg-neutral-800 ${
saved ? "text-emerald-400" : "text-neutral-300"
}`}
>
{saved ? <Check aria-hidden size={17} /> : <Save aria-hidden size={16} />}
</button>
<button
type="button"
aria-label="New terminal tab"
title={tabs.length >= MAX_TABS ? "Tab limit reached" : "New tab"}
disabled={tabs.length >= MAX_TABS}
onClick={addTab}
className="flex size-7 items-center justify-center rounded-full text-neutral-300 transition-colors hover:bg-neutral-800 disabled:opacity-40"
>
<Plus aria-hidden size={17} />
</button>
</div>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tabs, saved],
);
// Back arrow (→ computer home) + the "+" on the right, in the panel header.
useSubHeader(true, "Terminal", () => setParams({ app: "home" }), headerRight);
return (
<div
className="flex h-full w-full flex-col bg-[#0b0b0e]"
style={{ minHeight: "70vh" }}
>
<div className="flex shrink-0 items-center gap-1.5 overflow-x-auto border-b border-white/10 px-2.5 py-2">
{tabs.map((t, i) => {
const label = t.name?.trim() || `Tab ${i + 1}`;
const isActive = t.id === activeId;
return (
<div
key={t.id}
draggable={editingId !== t.id}
onDragStart={() => setDragId(t.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => {
if (dragId != null) reorder(dragId, t.id);
setDragId(null);
}}
onDragEnd={() => setDragId(null)}
className={`group flex shrink-0 cursor-grab items-center gap-2 rounded-lg pl-3 pr-2 py-1.5 text-sm transition-colors active:cursor-grabbing ${
dragId === t.id ? "opacity-50" : ""
} ${
isActive
? "bg-white/[0.14] text-white shadow-sm"
: "text-neutral-400 hover:bg-white/5"
}`}
>
{editingId === t.id ? (
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => commitRename(t.id, editValue)}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename(t.id, editValue);
if (e.key === "Escape") setEditingId(null);
}}
maxLength={24}
className="w-28 border-b border-white/40 bg-transparent text-sm text-white outline-none"
/>
) : (
<button
type="button"
onClick={() => setActiveId(t.id)}
onDoubleClick={() => {
setEditingId(t.id);
setEditValue(label);
}}
title="Double-click to rename"
className="max-w-[180px] truncate font-medium"
>
{label}
</button>
)}
{tabs.length > 1 && (
<button
type="button"
aria-label={`Close ${label}`}
onClick={() => closeTab(t.id)}
className="flex size-5 items-center justify-center rounded opacity-50 transition hover:bg-white/10 hover:opacity-100"
>
<X aria-hidden size={13} />
</button>
)}
</div>
);
})}
</div>
<div className="relative min-h-0 flex-1">
{tabs.map((t) => (
<TerminalTab
key={t.id}
agent={agent}
session={t.session}
active={t.id === activeId}
/>
))}
</div>
</div>
);
}
/** One tab: an xterm bridged to a tmux session over a WebSocket. Kept mounted
* (hidden when inactive) so its session + processes keep running. */
function TerminalTab({
agent,
session,
active,
}: {
agent: Agent;
session: string;
active: boolean;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const termRef = useRef<import("@xterm/xterm").Terminal | null>(null);
const fitRef = useRef<import("@xterm/addon-fit").FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
// Latest `active` for callbacks without re-running the connect effect.
const activeRef = useRef(active);
useEffect(() => {
activeRef.current = active;
});
useEffect(() => {
let disposed = false;
let ro: ResizeObserver | null = null;
let retry: ReturnType<typeof setTimeout> | null = null;
(async () => {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
]);
if (disposed || !hostRef.current) return;
const term = new Terminal({
fontFamily: '"MesloLGS NF", "JetBrains Mono", ui-monospace, monospace',
fontSize: 13,
cursorBlink: true,
allowProposedApi: true,
scrollback: 5000,
theme: {
background: "#0b0b0e",
foreground: "#e6e6ea",
cursor: "#ff8a7a",
cursorAccent: "#0b0b0e",
selectionBackground: "rgba(255,138,122,.28)",
black: "#1c1c22",
red: "#ff6f61",
green: "#5fd08a",
yellow: "#e8b465",
blue: "#5ec8d8",
magenta: "#c98af0",
cyan: "#6fd0c0",
white: "#cfcfd5",
brightBlack: "#5a5a62",
},
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(hostRef.current);
termRef.current = term;
fitRef.current = fit;
// Only fit when actually visible (a hidden tab has zero size).
if (hostRef.current.clientWidth > 0) {
try {
fit.fit();
} catch {
/* not laid out */
}
}
const encoder = new TextEncoder();
term.onData((d) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) ws.send(encoder.encode(d));
});
const sendResize = () => {
if (!hostRef.current || hostRef.current.clientWidth === 0) return;
try {
fit.fit();
} catch {
/* ignore */
}
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}
};
ro = new ResizeObserver(() => sendResize());
ro.observe(hostRef.current);
const connect = async () => {
if (disposed) return;
try {
const res = await fetch(`/api/terminal/${agent.id}/ticket`, { method: "POST" });
if (!res.ok) throw new Error(`ticket request failed (${res.status})`);
const { ticket } = (await res.json()) as { ticket: string };
if (disposed) return;
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(
`${proto}//${location.host}/api/terminal/${agent.id}/ws?ticket=${encodeURIComponent(
ticket,
)}&session=${encodeURIComponent(session)}`,
);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
ws.onopen = () => {
sendResize();
if (activeRef.current) term.focus();
};
ws.onmessage = (ev) => {
if (typeof ev.data === "string") term.write(ev.data);
else term.write(new Uint8Array(ev.data as ArrayBuffer));
};
ws.onerror = () => ws.close();
ws.onclose = () => {
wsRef.current = null;
if (disposed) return;
term.write("\r\n\x1b[90m[disconnected — reconnecting…]\x1b[0m\r\n");
retry = setTimeout(connect, 1500);
};
} catch (e) {
if (disposed) return;
term.write(`\r\n\x1b[31m[terminal: ${String(e)}]\x1b[0m\r\n`);
retry = setTimeout(connect, 2500);
}
};
void connect();
})();
return () => {
disposed = true;
if (retry) clearTimeout(retry);
ro?.disconnect();
wsRef.current?.close();
termRef.current?.dispose();
termRef.current = null;
fitRef.current = null;
wsRef.current = null;
};
}, [agent.id, session]);
// Re-fit + focus when this tab becomes visible (it may have had zero size).
useEffect(() => {
if (!active) return;
const id = setTimeout(() => {
const term = termRef.current;
const fit = fitRef.current;
const ws = wsRef.current;
if (!hostRef.current || hostRef.current.clientWidth === 0) return;
try {
fit?.fit();
} catch {
/* ignore */
}
if (term && ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}
term?.focus();
}, 40);
return () => clearTimeout(id);
}, [active]);
return (
<div
ref={hostRef}
className="absolute inset-0 px-3 py-2"
style={{ display: active ? "block" : "none" }}
/>
);
}
+49 -39
View File
@@ -276,6 +276,11 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
// Computer panel state (size + open app), shared with DevicePanel via nuqs. // Computer panel state (size + open app), shared with DevicePanel via nuqs.
const [{ app, device }, setParams] = useQueryStates(panelParsers, { shallow: true }); const [{ app, device }, setParams] = useQueryStates(panelParsers, { shallow: true });
// Custom computer width (px) from dragging the panel's left edge; null = use the
// preset (phone/tablet/full). A size-toggle click clears it back to the preset.
const [customWidth, setCustomWidth] = useState<number | null>(null);
const [resizing, setResizing] = useState(false);
const canvasRef = useRef<HTMLDivElement>(null);
// Claw-page UI preferences persist across navigation AND reloads (localStorage): // Claw-page UI preferences persist across navigation AND reloads (localStorage):
// the chat's collapsed state and the computer's open/size. The dashboard stays // the chat's collapsed state and the computer's open/size. The dashboard stays
// mounted while you move between tiers, so in-memory state already survives // mounted while you move between tiers, so in-memory state already survives
@@ -561,30 +566,12 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
) : null} ) : null}
{/* CANVAS */} {/* CANVAS */}
<div style={{ flex: 1, position: "relative", minWidth: 0, overflow: "hidden", ...(isClaw ? { ["--computer-width" as string]: computerOpen ? COMPUTER_WIDTH[device] : "0px" } : isWorld ? { ["--world-width" as string]: worldPanelOpen ? COMPUTER_WIDTH[worldSize] : "0px" } : {}) }}> <div ref={canvasRef} style={{ flex: 1, position: "relative", minWidth: 0, overflow: "hidden", ...(resizing ? { ["--duration-normal" as string]: "0ms" } : {}), ...(isClaw ? { ["--computer-width" as string]: computerOpen ? (customWidth != null ? `${customWidth}px` : COMPUTER_WIDTH[device]) : "0px" } : isWorld ? { ["--world-width" as string]: worldPanelOpen ? COMPUTER_WIDTH[worldSize] : "0px" } : {}) }}>
{isWorld ? ( {isWorld ? (
<> <>
{/* Graph stage — condensed by the right slide-out's width. */} {/* Graph stage — condensed by the right slide-out's width. */}
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: "var(--world-width, 0px)", background: "radial-gradient(120% 90% at 55% 38%, #0e0e13 0%, #08080a 70%)", transition: "right var(--duration-normal) var(--ease-app)" }}> <div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: "var(--world-width, 0px)", background: "radial-gradient(120% 90% at 55% 38%, #0e0e13 0%, #08080a 70%)", transition: "right var(--duration-normal) var(--ease-app)" }}>
<WorldFlow roots={worldRoots} expanded={expanded} selectedId={worldSel} onToggleExpand={toggleExpand} onSelect={onWorldSelect} /> <WorldFlow roots={worldRoots} expanded={expanded} selectedId={worldSel} onToggleExpand={toggleExpand} onSelect={onWorldSelect} onOpenRuns={() => setRunsOpen(true)} />
{(() => {
const sel = allNodes.find((n) => n.id === worldSel);
// Agents get the rich side-panel summary instead of this inspector.
if (!sel || sel.level === "claw") return null;
const btn = (color: string): CSSProperties => ({ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, width: "100%", padding: "8px 0", borderRadius: 9, border: `1px solid ${color}55`, background: `${color}14`, color, fontSize: 12.5, fontWeight: 600, cursor: "pointer" });
return (
<div style={{ position: "absolute", top: 16, left: 16, width: 248, zIndex: 6, borderRadius: 14, background: "rgba(11,11,14,.94)", backdropFilter: "blur(8px)", border: "1px solid rgba(255,255,255,.1)", boxShadow: "0 18px 50px rgba(0,0,0,.55)", padding: 14 }}>
<div style={{ fontFamily: mono, fontSize: 9, letterSpacing: ".1em", color: "#8a8a92", marginBottom: 5 }}>{sel.level.toUpperCase()}</div>
<div style={{ fontSize: 17, fontWeight: 700, color: "#f3f3f5", overflow: "hidden", textOverflow: "ellipsis" }}>{sel.label}</div>
<div style={{ display: "flex", flexDirection: "column", gap: 7, marginTop: 12 }}>
<button type="button" onClick={() => toggleExpand(sel.id)} style={btn("#9a9aa2")}>{expanded.has(sel.id) ? "Collapse" : "Expand"}</button>
{sel.level === "team" ? (
<button type="button" onClick={() => setRunsOpen(true)} style={btn("#5ec8d8")}>▶ Runs</button>
) : null}
</div>
</div>
);
})()}
{/* Open the slide-out (top-right) when it's closed. */} {/* Open the slide-out (top-right) when it's closed. */}
{!worldPanelOpen ? ( {!worldPanelOpen ? (
<button type="button" aria-label="Open panel" title="Panel" onClick={() => setWorldPanelOpen(true)} style={{ position: "absolute", top: 14, right: 16, zIndex: 50, width: 38, height: 38, borderRadius: "50%", border: "1px solid rgba(255,111,97,.4)", background: "rgba(255,111,97,.08)", color: "#ff6f61", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><PanelRight aria-hidden size={19} /></button> <button type="button" aria-label="Open panel" title="Panel" onClick={() => setWorldPanelOpen(true)} style={{ position: "absolute", top: 14, right: 16, zIndex: 50, width: 38, height: 38, borderRadius: "50%", border: "1px solid rgba(255,111,97,.4)", background: "rgba(255,111,97,.08)", color: "#ff6f61", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><PanelRight aria-hidden size={19} /></button>
@@ -686,6 +673,27 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
</div> </div>
{/* Right: the original device computer (phone / tablet / desktop). */} {/* Right: the original device computer (phone / tablet / desktop). */}
<DevicePanel agent={clawAgent} /> <DevicePanel agent={clawAgent} />
{/* Drag the panel's left edge to a custom width (the size toggle
above snaps back to the phone/tablet/full presets). */}
{computerOpen ? (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize computer width"
onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); setResizing(true); }}
onPointerMove={(e) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
// Don't allow narrower than the phone preset (448px).
setCustomWidth(Math.round(Math.max(448, Math.min(rect.width, rect.right - e.clientX))));
}}
onPointerUp={(e) => { e.currentTarget.releasePointerCapture(e.pointerId); setResizing(false); }}
style={{ position: "absolute", top: 0, bottom: 0, right: "var(--computer-width)", width: 10, marginRight: -5, zIndex: 41, cursor: "col-resize", touchAction: "none" }}
>
<div style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%,-50%)", width: 4, height: 46, borderRadius: 3, background: resizing ? "#ff8a7a" : "rgba(255,255,255,.22)" }} />
</div>
) : null}
{/* Top-right launchers: chat (left) + computer (right), each a coral {/* Top-right launchers: chat (left) + computer (right), each a coral
icon when collapsed; controls show when the computer is open. */} icon when collapsed; controls show when the computer is open. */}
<div style={{ position: "absolute", top: 14, right: 16, zIndex: 50, display: "flex", alignItems: "center", gap: 8 }}> <div style={{ position: "absolute", top: 14, right: 16, zIndex: 50, display: "flex", alignItems: "center", gap: 8 }}>
@@ -694,7 +702,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
) : null} ) : null}
{computerOpen ? ( {computerOpen ? (
<> <>
<DeviceSizeToggle value={device} onChange={(d) => setParams({ device: d })} /> <DeviceSizeToggle value={device} onChange={(d) => { setParams({ device: d }); setCustomWidth(null); }} />
<span style={{ width: 1, height: 18, background: "rgba(255,255,255,.14)" }} /> <span style={{ width: 1, height: 18, background: "rgba(255,255,255,.14)" }} />
<button type="button" aria-label="Close computer" onClick={() => setParams({ app: null })} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "rgba(8,8,10,.6)", color: "#cfcfd5", cursor: "pointer" }}><X aria-hidden size={16} /></button> <button type="button" aria-label="Close computer" onClick={() => setParams({ app: null })} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "rgba(8,8,10,.6)", color: "#cfcfd5", cursor: "pointer" }}><X aria-hidden size={16} /></button>
</> </>
@@ -828,7 +836,7 @@ function CollapseTick({ open, color, onClick }: { open: boolean; color: string;
// Personality is often stored as JSON (traits/tone/…). Render it as labeled // Personality is often stored as JSON (traits/tone/…). Render it as labeled
// sections instead of dumping raw JSON; fall back to tags / text otherwise. // sections instead of dumping raw JSON; fall back to tags / text otherwise.
function PersonalityBody({ raw, fallback }: { raw: string | null | undefined; fallback: string[] }) { function PersonalityBody({ raw, fallback }: { raw: string | null | undefined; fallback: string[] }) {
const tags = (arr: string[]) => <div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>{arr.map((p, i) => <span key={i} style={tag()}>{p}</span>)}</div>; const tags = (arr: string[]) => <div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>{arr.map((p, i) => <span key={i} style={tag("#c98af0")}>{p}</span>)}</div>;
const trimmed = (raw ?? "").trim(); const trimmed = (raw ?? "").trim();
let parsed: unknown; let parsed: unknown;
if (trimmed.startsWith("{") || trimmed.startsWith("[")) { if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
@@ -840,20 +848,20 @@ function PersonalityBody({ raw, fallback }: { raw: string | null | undefined; fa
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}> <div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
{Object.entries(parsed as Record<string, unknown>).map(([k, v]) => ( {Object.entries(parsed as Record<string, unknown>).map(([k, v]) => (
<div key={k}> <div key={k}>
<div style={{ fontFamily: mono, fontSize: 9, letterSpacing: ".08em", color: "#b9a6e0", marginBottom: 4, textTransform: "uppercase" }}>{k.replace(/_/g, " ")}</div> <div style={{ fontFamily: mono, fontSize: 10.5, letterSpacing: ".08em", color: "#b9a6e0", marginBottom: 5, textTransform: "uppercase" }}>{k.replace(/_/g, " ")}</div>
{Array.isArray(v) {Array.isArray(v)
? tags(v.map((x) => String(x))) ? tags(v.map((x) => String(x)))
: v && typeof v === "object" : v && typeof v === "object"
? <div style={{ display: "flex", flexDirection: "column", gap: 2, fontSize: 11.5, color: "#cfcfd5", lineHeight: 1.5 }}>{Object.entries(v as Record<string, unknown>).map(([kk, vv]) => <div key={kk}><span style={{ color: "#8a8a92" }}>{kk}:</span> {String(vv)}</div>)}</div> ? <div style={{ display: "flex", flexDirection: "column", gap: 3, fontSize: 13, color: "#cfcfd5", lineHeight: 1.55 }}>{Object.entries(v as Record<string, unknown>).map(([kk, vv]) => <div key={kk}><span style={{ color: "#8a8a92" }}>{kk}:</span> {String(vv)}</div>)}</div>
: <div style={{ fontSize: 12.5, color: "#d6d6dc", lineHeight: 1.5 }}>{String(v)}</div>} : <div style={{ fontSize: 13.5, color: "#d6d6dc", lineHeight: 1.55 }}>{String(v)}</div>}
</div> </div>
))} ))}
</div> </div>
); );
} }
if (fallback.length) return tags(fallback); if (fallback.length) return tags(fallback);
if (trimmed) return <div style={{ fontSize: 12.5, color: "#d6d6dc", lineHeight: 1.5 }}>{trimmed}</div>; if (trimmed) return <div style={{ fontSize: 13.5, color: "#d6d6dc", lineHeight: 1.6 }}>{trimmed}</div>;
return <span style={{ fontSize: 11, color: "#6a6a72" }}>—</span>; return <span style={{ fontSize: 12, color: "#6a6a72" }}>—</span>;
} }
function AnatomyCard({ tint, label, count, icon, collapsible, children }: { tint: string; label: string; count?: string; icon?: React.ReactNode; collapsible?: boolean; children: React.ReactNode }) { function AnatomyCard({ tint, label, count, icon, collapsible, children }: { tint: string; label: string; count?: string; icon?: React.ReactNode; collapsible?: boolean; children: React.ReactNode }) {
@@ -874,7 +882,9 @@ function AnatomyCard({ tint, label, count, icon, collapsible, children }: { tint
</div> </div>
); );
} }
const tag = (dim?: boolean): CSSProperties => ({ fontFamily: mono, fontSize: 10, color: dim ? "#8a8a92" : "#cfcfd5", padding: "3px 7px", borderRadius: 5, background: dim ? "rgba(255,255,255,.03)" : "rgba(255,255,255,.05)" }); // A pill-shaped tag, tinted to its section's accent so it reads clearly as a
// tag. `dim` is the muted "+N more" variant.
const tag = (color = "#9a9aa2", dim = false): CSSProperties => ({ fontFamily: mono, fontSize: 12, color: dim ? "#8a8a92" : color, padding: "4px 10px", borderRadius: 7, background: dim ? "rgba(255,255,255,.04)" : `${color}1f`, border: `1px solid ${dim ? "rgba(255,255,255,.08)" : `${color}45`}` });
function ClawAnatomyCanvas({ agent, teamName, avatarUrl, onToolsChanged, brain }: { agent: DemoAgent; teamName: string; avatarUrl?: string; onToolsChanged?: () => void; brain?: RawBrain }) { function ClawAnatomyCanvas({ agent, teamName, avatarUrl, onToolsChanged, brain }: { agent: DemoAgent; teamName: string; avatarUrl?: string; onToolsChanged?: () => void; brain?: RawBrain }) {
const c = agent.compartments; const c = agent.compartments;
@@ -896,7 +906,7 @@ function ClawAnatomyCanvas({ agent, teamName, avatarUrl, onToolsChanged, brain }
<div style={{ position: "relative", flex: 1, minHeight: 0, overflow: "auto", padding: "22px 20px 28px" }}> <div style={{ position: "relative", flex: 1, minHeight: 0, overflow: "auto", padding: "22px 20px 28px" }}>
<div style={{ maxWidth: 820, margin: "0 auto", display: "flex", flexDirection: "column", alignItems: "center", gap: 20 }}> <div style={{ maxWidth: 820, margin: "0 auto", display: "flex", flexDirection: "column", alignItems: "center", gap: 20 }}>
{/* Name (left) · avatar (center) · title (right), above the activity card. */} {/* Name (left) · avatar (center) · title (right), above the activity card. */}
<div style={{ width: "100%", display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ width: "100%", display: "flex", flexDirection: "column", gap: 18 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, width: "100%", padding: "0 2px" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, width: "100%", padding: "0 2px" }}>
<div style={{ borderRadius: 13, background: "#0f0f13", border: "1px solid rgba(255,255,255,.1)", padding: "11px 22px", boxShadow: "0 8px 22px rgba(0,0,0,.4)" }}> <div style={{ borderRadius: 13, background: "#0f0f13", border: "1px solid rgba(255,255,255,.1)", padding: "11px 22px", boxShadow: "0 8px 22px rgba(0,0,0,.4)" }}>
<div style={{ fontSize: 24, fontWeight: 800, color: "#f3f3f5", letterSpacing: "-.02em" }}>{agent.name}</div> <div style={{ fontSize: 24, fontWeight: 800, color: "#f3f3f5", letterSpacing: "-.02em" }}>{agent.name}</div>
@@ -931,42 +941,42 @@ function ClawAnatomyCanvas({ agent, teamName, avatarUrl, onToolsChanged, brain }
then the full-width live log. */} then the full-width live log. */}
<div style={{ width: "100%", display: "flex", flexDirection: "column", gap: 14 }}> <div style={{ width: "100%", display: "flex", flexDirection: "column", gap: 14 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 14, alignItems: "stretch" }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 14, alignItems: "stretch" }}>
<AnatomyCard tint="#ff6f61" label="SKILLS" count={String(c.skills.length)} icon={<Zap size={15} />}><div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>{skillsShown.map((s) => (<span key={s} style={tag()}>{s}</span>))}{skillsRest > 0 ? <span style={tag(true)}>+{skillsRest}</span> : null}</div></AnatomyCard> <AnatomyCard tint="#ff6f61" label="SKILLS" count={String(c.skills.length)} icon={<Zap size={15} />}><div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>{skillsShown.map((s) => (<span key={s} style={tag("#ff6f61")}>{s}</span>))}{skillsRest > 0 ? <span style={tag("#9a9aa2", true)}>+{skillsRest}</span> : null}</div></AnatomyCard>
<AnatomyCard tint="#5ec8d8" label="TOOLS · DOORS" icon={<Wrench size={15} />}> <AnatomyCard tint="#5ec8d8" label="TOOLS · DOORS" icon={<Wrench size={15} />}>
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}> <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 4, fontFamily: mono, fontSize: 10 }}>{c.tools.map((t) => row(t.name, t.state === "gated" ? "gated ✓" : "blocked ⨯", t.state === "gated" ? "#5fd08a" : "#e8b465"))}</div> <div style={{ display: "flex", flexDirection: "column", gap: 6, fontFamily: mono, fontSize: 12.5 }}>{c.tools.map((t) => row(t.name, t.state === "gated" ? "gated ✓" : "blocked ⨯", t.state === "gated" ? "#5fd08a" : "#e8b465"))}</div>
<button type="button" onClick={() => setAddToolOpen(true)} style={{ marginTop: "auto", width: "100%", padding: "7px 0", borderRadius: 7, border: "1px dashed rgba(94,200,216,.4)", background: "transparent", color: "#5ec8d8", fontSize: 11, fontFamily: mono, cursor: "pointer" }}>+ Add tool</button> <button type="button" onClick={() => setAddToolOpen(true)} style={{ marginTop: "auto", width: "100%", padding: "8px 0", borderRadius: 7, border: "1px dashed rgba(94,200,216,.4)", background: "transparent", color: "#5ec8d8", fontSize: 12.5, fontFamily: mono, cursor: "pointer" }}>+ Add tool</button>
</div> </div>
</AnatomyCard> </AnatomyCard>
<AnatomyCard tint="#5fd08a" label="MEMORY" count={brain ? String(brain.stats.memories) : undefined} icon={<Database size={15} />}> <AnatomyCard tint="#5fd08a" label="MEMORY" count={brain ? String(brain.stats.memories) : undefined} icon={<Database size={15} />}>
{brain && brain.memory.length ? ( {brain && brain.memory.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 4, fontFamily: mono, fontSize: 10, color: "#9a9aa2", maxHeight: 124, overflowY: "auto" }}> <div style={{ display: "flex", flexDirection: "column", gap: 5, fontFamily: mono, fontSize: 12.5, color: "#9a9aa2", maxHeight: 150, overflowY: "auto" }}>
{brain.memory.slice(0, 6).map((m, i) => (<div key={i} title={m} style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m}</div>))} {brain.memory.slice(0, 6).map((m, i) => (<div key={i} title={m} style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m}</div>))}
</div> </div>
) : ( ) : (
<div style={{ display: "flex", flexDirection: "column", gap: 5, fontFamily: mono, fontSize: 10, color: "#9a9aa2" }}>{brain ? <span style={{ color: "#6a6a72" }}>no memories yet — chat with this agent and they appear here.</span> : (<>{row("Long-term", c.memory.long)}{row("Recent ctx", c.memory.recent)}</>)}</div> <div style={{ display: "flex", flexDirection: "column", gap: 6, fontFamily: mono, fontSize: 12.5, color: "#9a9aa2" }}>{brain ? <span style={{ color: "#6a6a72" }}>no memories yet — chat with this agent and they appear here.</span> : (<>{row("Long-term", c.memory.long)}{row("Recent ctx", c.memory.recent)}</>)}</div>
)} )}
</AnatomyCard> </AnatomyCard>
</div> </div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 14, alignItems: "stretch" }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 14, alignItems: "stretch" }}>
<AnatomyCard tint="#e8b465" label="CAPABILITIES" icon={<Cpu size={15} />}><div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>{c.capabilities.map((p) => (<span key={p} style={tag()}>{p}</span>))}</div></AnatomyCard> <AnatomyCard tint="#e8b465" label="CAPABILITIES" icon={<Cpu size={15} />}><div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>{c.capabilities.map((p) => (<span key={p} style={tag("#e8b465")}>{p}</span>))}</div></AnatomyCard>
<AnatomyCard tint="#6fd0c0" label="SAFETY · §15" icon={<ShieldCheck size={15} />}><div style={{ display: "flex", flexDirection: "column", gap: 5, fontFamily: mono, fontSize: 10, color: "#9a9aa2" }}>{row("Sandbox", c.safety.sandbox, "#6fd0c0")}{row("Network", c.safety.network, "#6fd0c0")}</div></AnatomyCard> <AnatomyCard tint="#6fd0c0" label="SAFETY · §15" icon={<ShieldCheck size={15} />}><div style={{ display: "flex", flexDirection: "column", gap: 6, fontFamily: mono, fontSize: 12.5, color: "#9a9aa2" }}>{row("Sandbox", c.safety.sandbox, "#6fd0c0")}{row("Network", c.safety.network, "#6fd0c0")}</div></AnatomyCard>
</div> </div>
{/* Full-width live work log. */} {/* Full-width live work log. */}
<div> <div>
<AnatomyCard tint="#5ec8d8" label="NOW RUNNING · LIVE" count={agent.nowRunning.length ? `${agent.nowRunning.length} active` : "idle"} icon={<Activity size={15} />}> <AnatomyCard tint="#5ec8d8" label="NOW RUNNING · LIVE" count={agent.nowRunning.length ? `${agent.nowRunning.length} active` : "idle"} icon={<Activity size={15} />}>
<div style={{ minHeight: 200, maxHeight: 340, overflowY: "auto" }}> <div style={{ minHeight: 200, maxHeight: 340, overflowY: "auto" }}>
{agent.nowRunning.length === 0 ? ( {agent.nowRunning.length === 0 ? (
<span style={{ fontFamily: mono, fontSize: 10.5, color: "#6a6a72" }}>idle — no active tasks. The agent&apos;s live work log will stream here as it runs.</span> <span style={{ fontFamily: mono, fontSize: 12.5, color: "#6a6a72" }}>idle — no active tasks. The agent&apos;s live work log will stream here as it runs.</span>
) : ( ) : (
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}> <div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
{agent.nowRunning.map((t, i) => ( {agent.nowRunning.map((t, i) => (
<div key={i}> <div key={i}>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: t.progress != null ? 5 : 0 }}> <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: t.progress != null ? 5 : 0 }}>
<span className={t.kind === "loop" ? "cm-blink" : ""} style={{ width: 6, height: 6, borderRadius: "50%", background: t.kind === "loop" ? "#5ec8d8" : "#e8b465" }} /> <span className={t.kind === "loop" ? "cm-blink" : ""} style={{ width: 6, height: 6, borderRadius: "50%", background: t.kind === "loop" ? "#5ec8d8" : "#e8b465" }} />
<span style={{ fontSize: 12, fontWeight: 600, color: "#e6e6ea" }}>{t.name}</span> <span style={{ fontSize: 13.5, fontWeight: 600, color: "#e6e6ea" }}>{t.name}</span>
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
<span style={{ fontFamily: mono, fontSize: 9, color: t.kind === "loop" ? "#5ec8d8" : "#e8b465" }}>{t.detail}</span> <span style={{ fontFamily: mono, fontSize: 11, color: t.kind === "loop" ? "#5ec8d8" : "#e8b465" }}>{t.detail}</span>
</div> </div>
{t.progress != null ? ( {t.progress != null ? (
<div style={{ height: 4, borderRadius: 2, background: "rgba(255,255,255,.08)", overflow: "hidden" }}><div style={{ width: `${t.progress}%`, height: "100%", background: "linear-gradient(90deg,#5ec8d8,#4aa3b8)" }} /></div> <div style={{ height: 4, borderRadius: 2, background: "rgba(255,255,255,.08)", overflow: "hidden" }}><div style={{ width: `${t.progress}%`, height: "100%", background: "linear-gradient(90deg,#5ec8d8,#4aa3b8)" }} /></div>
@@ -10,16 +10,20 @@ import "@xyflow/react/dist/style.css";
import { memo, useEffect, useMemo, useState } from "react"; import { memo, useEffect, useMemo, useState } from "react";
import { import {
ReactFlow, ReactFlow,
ReactFlowProvider,
Background, Background,
Controls, Controls,
Handle, Handle,
MiniMap, MiniMap,
Position, Position,
useNodesState, useNodesState,
useReactFlow,
type Edge, type Edge,
type Node, type Node,
type NodeProps, type NodeProps,
type Viewport,
} from "@xyflow/react"; } from "@xyflow/react";
import { Check, Maximize2, Play, RotateCcw, Save } from "lucide-react";
const mono = "'JetBrains Mono', ui-monospace, monospace"; const mono = "'JetBrains Mono', ui-monospace, monospace";
@@ -124,41 +128,72 @@ function layoutWorld(roots: WorldItem[], expanded: Set<string>): Map<string, Pos
return pos; return pos;
} }
// Persisted manual positions (localStorage) so a layout the user arranged // Layout persistence. `sessionPos` / `sessionViewport` survive tier navigation
// survives expand/collapse and navigating away. Keyed by node id. // (module-level, so an arranged layout isn't lost just by leaving the page); the
const POS_KEY = "cm.world.pos"; // explicit Save tool writes a durable snapshot (localStorage) that's restored on
function loadSavedPos(): Record<string, Pos> { // a full reload. Keyed by node id.
if (typeof window === "undefined") return {}; const sessionPos: Record<string, Pos> = {};
try { return JSON.parse(window.localStorage.getItem(POS_KEY) || "{}") as Record<string, Pos>; } catch { return {}; } let sessionViewport: Viewport | null = null;
} const SNAP_KEY = "cm.world.snapshot";
function persistPos(p: Record<string, Pos>) { type Snapshot = { positions: Record<string, Pos>; viewport?: Viewport };
try { window.localStorage.setItem(POS_KEY, JSON.stringify(p)); } catch { /* ignore */ } function loadSnapshot(): Snapshot | null {
if (typeof window === "undefined") return null;
try { const r = window.localStorage.getItem(SNAP_KEY); return r ? (JSON.parse(r) as Snapshot) : null; } catch { return null; }
} }
function writeSnapshot(s: Snapshot) { try { window.localStorage.setItem(SNAP_KEY, JSON.stringify(s)); } catch { /* ignore */ } }
function clearSnapshot() { try { window.localStorage.removeItem(SNAP_KEY); } catch { /* ignore */ } }
export function WorldFlow({ roots, expanded, selectedId, onToggleExpand, onSelect }: { type WorldFlowProps = {
roots: WorldItem[]; roots: WorldItem[];
expanded: Set<string>; expanded: Set<string>;
selectedId: string | null; selectedId: string | null;
onToggleExpand: (id: string) => void; onToggleExpand: (id: string) => void;
onSelect: (id: string) => void; onSelect: (id: string) => void;
}) { onOpenRuns?: () => void;
};
export function WorldFlow(props: WorldFlowProps) {
// A provider so the inner graph can read/set the viewport (for Save / Fit).
return (
<ReactFlowProvider>
<WorldFlowInner {...props} />
</ReactFlowProvider>
);
}
function WorldFlowInner({ roots, expanded, selectedId, onToggleExpand, onSelect, onOpenRuns }: WorldFlowProps) {
const rf = useReactFlow();
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]); const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const visible = useMemo(() => flattenVisible(roots, expanded), [roots, expanded]); const visible = useMemo(() => flattenVisible(roots, expanded), [roots, expanded]);
const pos = useMemo(() => layoutWorld(roots, expanded), [roots, expanded]); const pos = useMemo(() => layoutWorld(roots, expanded), [roots, expanded]);
// Manual positions the user dragged, loaded once from localStorage. A node the // Positions the user has arranged. Seeded from the session cache (so a layout
// user moved keeps its saved position across re-layouts; everything else // survives leaving + returning to the page); a node the user moved keeps its
// auto-arranges into the tidy tree (so newly-revealed children get placed). // position across re-layouts, everything else auto-arranges into the tidy tree.
const [savedPos, setSavedPos] = useState<Record<string, Pos> | null>(null); const [savedPos, setSavedPos] = useState<Record<string, Pos>>(() => ({ ...sessionPos }));
const [loaded, setLoaded] = useState(false);
const [savedFlash, setSavedFlash] = useState(false);
// On (re)mount: if there's no session layout yet, restore the saved snapshot;
// restore the viewport so the camera comes back exactly where it was.
useEffect(() => { useEffect(() => {
let vp = sessionViewport;
if (Object.keys(sessionPos).length === 0) {
const snap = loadSnapshot();
if (snap?.positions) {
Object.assign(sessionPos, snap.positions);
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setSavedPos(loadSavedPos()); setSavedPos({ ...sessionPos });
}, []); if (snap.viewport && !vp) vp = snap.viewport;
const saved = savedPos ?? {}; }
}
if (vp) { const v = vp; requestAnimationFrame(() => rf.setViewport(v)); }
setLoaded(true);
}, [rf]);
// Rebuild when the visible set / selection changes (or saved positions load). // Rebuild when the visible set / selection changes (or saved positions load).
// Surviving nodes keep their measurements so they don't flash back to hidden. // Surviving nodes keep their measurements so they don't flash back to hidden.
const layoutKey = `${visible.map((v) => v.id).join(",")}|${selectedId}|${savedPos ? "L" : "U"}`; const layoutKey = `${visible.map((v) => v.id).join(",")}|${selectedId}|${loaded ? "L" : "U"}`;
const [seen, setSeen] = useState(""); const [seen, setSeen] = useState("");
if (seen !== layoutKey) { if (seen !== layoutKey) {
setSeen(layoutKey); setSeen(layoutKey);
@@ -170,7 +205,7 @@ export function WorldFlow({ roots, expanded, selectedId, onToggleExpand, onSelec
return { return {
id: it.id, id: it.id,
type: "world", type: "world",
position: saved[it.id] ?? pos.get(it.id) ?? { x: 0, y: 0 }, position: savedPos[it.id] ?? pos.get(it.id) ?? { x: 0, y: 0 },
data: { data: {
level: it.level, level: it.level,
label: it.label, label: it.label,
@@ -199,6 +234,36 @@ export function WorldFlow({ roots, expanded, selectedId, onToggleExpand, onSelec
return es; return es;
}, [visible, expanded, selectedId]); }, [visible, expanded, selectedId]);
// The selected node's level — drives the context-aware Runs tool.
const selLevel = useMemo(() => {
if (!selectedId) return null;
let lv: string | null = null;
const walk = (n: WorldItem) => { if (n.id === selectedId) lv = n.level; else (n.children ?? []).forEach(walk); };
roots.forEach(walk);
return lv;
}, [roots, selectedId]);
// ── Graph tools ──
const saveLayout = () => {
const positions: Record<string, Pos> = {};
nodes.forEach((n) => { positions[n.id] = { x: n.position.x, y: n.position.y }; });
Object.assign(sessionPos, positions);
sessionViewport = rf.getViewport();
setSavedPos({ ...positions });
writeSnapshot({ positions, viewport: sessionViewport });
setSavedFlash(true);
setTimeout(() => setSavedFlash(false), 1700);
};
const resetLayout = () => {
for (const k of Object.keys(sessionPos)) delete sessionPos[k];
sessionViewport = null;
clearSnapshot();
setSavedPos({});
setSeen(""); // force a rebuild back to the tidy auto-layout
requestAnimationFrame(() => rf.fitView({ padding: 0.24, duration: 420 }));
};
const fit = () => rf.fitView({ padding: 0.24, duration: 420 });
return ( return (
<div style={{ position: "absolute", inset: 0 }}> <div style={{ position: "absolute", inset: 0 }}>
<ReactFlow <ReactFlow
@@ -209,11 +274,11 @@ export function WorldFlow({ roots, expanded, selectedId, onToggleExpand, onSelec
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onNodeDragStop={(_, node, dragged) => { onNodeDragStop={(_, node, dragged) => {
const moved = dragged && dragged.length ? dragged : [node]; const moved = dragged && dragged.length ? dragged : [node];
const next = { ...(savedPos ?? {}) }; const next = { ...savedPos };
moved.forEach((n) => { if (n) next[n.id] = { x: n.position.x, y: n.position.y }; }); moved.forEach((n) => { if (n) { const p = { x: n.position.x, y: n.position.y }; next[n.id] = p; sessionPos[n.id] = p; } });
setSavedPos(next); setSavedPos(next);
persistPos(next);
}} }}
onMoveEnd={(_, vp) => { sessionViewport = vp; }}
onNodeClick={(_, n) => { onNodeClick={(_, n) => {
const item = visible.find((v) => v.id === n.id); const item = visible.find((v) => v.id === n.id);
if (!item) return; if (!item) return;
@@ -221,7 +286,7 @@ export function WorldFlow({ roots, expanded, selectedId, onToggleExpand, onSelec
onSelect(item.id); onSelect(item.id);
if (item.level !== "claw" && (item.children?.length ?? 0) > 0) onToggleExpand(item.id); if (item.level !== "claw" && (item.children?.length ?? 0) > 0) onToggleExpand(item.id);
}} }}
fitView fitView={!sessionViewport}
fitViewOptions={{ padding: 0.24 }} fitViewOptions={{ padding: 0.24 }}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
nodesConnectable={false} nodesConnectable={false}
@@ -234,6 +299,31 @@ export function WorldFlow({ roots, expanded, selectedId, onToggleExpand, onSelec
<Controls showInteractive={false} /> <Controls showInteractive={false} />
<MiniMap pannable zoomable nodeColor={(n) => LEVEL[(n.data as WorldNodeData)?.level]?.color ?? "#ff6f61"} maskColor="rgba(8,8,10,.6)" style={{ background: "#0b0b0e" }} /> <MiniMap pannable zoomable nodeColor={(n) => LEVEL[(n.data as WorldNodeData)?.level]?.color ?? "#ff6f61"} maskColor="rgba(8,8,10,.6)" style={{ background: "#0b0b0e" }} />
</ReactFlow> </ReactFlow>
<GraphTools onSave={saveLayout} onFit={fit} onReset={resetLayout} saved={savedFlash} runs={selLevel === "team" && onOpenRuns ? onOpenRuns : null} />
</div>
);
}
// Floating graph-tools list (top-left). Starts with Save / Fit / Reset, plus a
// context-aware Runs entry when a team is selected; more tools slot in here.
function GraphTools({ onSave, onFit, onReset, saved, runs }: { onSave: () => void; onFit: () => void; onReset: () => void; saved: boolean; runs: (() => void) | null }) {
const tool = (icon: React.ReactNode, label: string, onClick: () => void, accent = "#cfcfd5") => (
<button type="button" onClick={onClick}
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255,255,255,.06)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "#121216")}
style={{ display: "flex", alignItems: "center", gap: 9, width: "100%", padding: "8px 11px", borderRadius: 9, border: "1px solid rgba(255,255,255,.08)", background: "#121216", color: accent, fontSize: 12, fontWeight: 600, cursor: "pointer", textAlign: "left" }}>
{icon}<span style={{ flex: 1 }}>{label}</span>
</button>
);
return (
<div style={{ position: "absolute", top: 14, left: 14, zIndex: 6, width: 174, borderRadius: 13, background: "rgba(11,11,14,.94)", backdropFilter: "blur(8px)", border: "1px solid rgba(255,255,255,.1)", boxShadow: "0 18px 50px rgba(0,0,0,.55)", padding: 9 }}>
<div style={{ fontFamily: mono, fontSize: 9, letterSpacing: ".12em", color: "#5a5a62", padding: "3px 6px 8px" }}>GRAPH TOOLS</div>
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
{tool(saved ? <Check aria-hidden size={14} /> : <Save aria-hidden size={14} />, saved ? "Saved ✓" : "Save layout", onSave, saved ? "#5fd08a" : "#ff8a7a")}
{tool(<Maximize2 aria-hidden size={14} />, "Fit to view", onFit, "#5ec8d8")}
{tool(<RotateCcw aria-hidden size={14} />, "Reset layout", onReset, "#9a9aa2")}
{runs ? tool(<Play aria-hidden size={14} />, "Team runs", runs, "#5ec8d8") : null}
</div>
</div> </div>
); );
} }
+2
View File
@@ -24,6 +24,8 @@ export const APP_IDS = [
"home", "home",
"browser", "browser",
"slack", "slack",
"terminal",
"obsidian",
"chat", "chat",
"skills", "skills",
"files", "files",
+41
View File
@@ -0,0 +1,41 @@
# The themed interactive "computer terminal" for the agent's Terminal app:
# zsh + oh-my-zsh + powerlevel10k. Runs as uid 65532 — matching the server's
# nonroot uid — so the terminal and the server share read-write ownership of the
# file-drive volume (~/drives). cap-drop ALL, seccomp deny profile,
# no-new-privileges. Unlike the hardened tool sandboxes it keeps a writable home
# so the baked p10k config, completion cache and shell history work.
FROM debian:bookworm-slim
ENV LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
TERM=xterm-256color
# A small but useful dev toolbelt; no setuid binaries survive (no priv-esc).
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
zsh tmux git curl ca-certificates less nano procps coreutils \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --uid 65532 --user-group --create-home --shell /usr/bin/zsh agent \
&& find / -xdev -perm /6000 -type f -delete
USER 65532:65532
WORKDIR /home/agent
ENV HOME=/home/agent \
ZSH=/home/agent/.oh-my-zsh
# oh-my-zsh + powerlevel10k + quality-of-life plugins (built with egress; the
# running container's egress is a separate, default-off config knob).
RUN git clone --depth=1 https://github.com/ohmyzsh/ohmyzsh.git "$ZSH" \
&& git clone --depth=1 https://github.com/romkatv/powerlevel10k.git \
"$ZSH/custom/themes/powerlevel10k" \
&& git clone --depth=1 https://github.com/zsh-users/zsh-autosuggestions \
"$ZSH/custom/plugins/zsh-autosuggestions" \
&& git clone --depth=1 https://github.com/zsh-users/zsh-syntax-highlighting \
"$ZSH/custom/plugins/zsh-syntax-highlighting"
COPY --chown=65532:65532 zdotdir/.zshrc /home/agent/.zshrc
COPY --chown=65532:65532 zdotdir/.p10k.zsh /home/agent/.p10k.zsh
COPY --chown=65532:65532 zdotdir/.tmux.conf /home/agent/.tmux.conf
# Idle keep-alive; the server execs an interactive tmux/zsh into the container.
CMD ["sleep", "infinity"]
+55
View File
@@ -0,0 +1,55 @@
# Compact, recognizable powerlevel10k config — the signature two-line prompt
# (os icon · dir · git on the left; status · run-time · clock on the right;
# a ❯ prompt char on its own line) without the 1700-line wizard output.
# Requires a Nerd Font in the terminal (the app loads MesloLGS NF).
'builtin' 'local' '-a' 'p10k_config_opts'
[[ ! -o 'aliases' ]] || p10k_config_opts+=('aliases')
[[ ! -o 'sh_glob' ]] || p10k_config_opts+=('sh_glob')
[[ ! -o 'no_brace_expand' ]] || p10k_config_opts+=('no_brace_expand')
'builtin' 'setopt' 'no_aliases' 'no_sh_glob' 'brace_expand'
() {
emulate -L zsh -o extended_glob
unset -m '(POWERLEVEL9K_*|DEFAULT_USER)~POWERLEVEL9K_GITSTATUS_DIR'
typeset -g POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD=true
typeset -g POWERLEVEL9K_INSTANT_PROMPT=off
typeset -g POWERLEVEL9K_MODE=nerdfont-complete
typeset -g POWERLEVEL9K_ICON_PADDING=moderate
typeset -g POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=(os_icon dir vcs newline prompt_char)
typeset -g POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS=(status command_execution_time time)
typeset -g POWERLEVEL9K_PROMPT_ADD_NEWLINE=true
typeset -g POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_CHAR=' '
# Prompt char: green ❯ on success, red on error.
typeset -g POWERLEVEL9K_PROMPT_CHAR_OK_{VIINS,VICMD,VIVIS,VIOWR}_FOREGROUND=76
typeset -g POWERLEVEL9K_PROMPT_CHAR_ERROR_{VIINS,VICMD,VIVIS,VIOWR}_FOREGROUND=196
typeset -g POWERLEVEL9K_PROMPT_CHAR_{OK,ERROR}_VIINS_CONTENT_EXPANSION='❯'
typeset -g POWERLEVEL9K_PROMPT_CHAR_{OK,ERROR}_VICMD_CONTENT_EXPANSION='❮'
typeset -g POWERLEVEL9K_PROMPT_CHAR_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL=''
typeset -g POWERLEVEL9K_OS_ICON_FOREGROUND=255
typeset -g POWERLEVEL9K_DIR_FOREGROUND=39
typeset -g POWERLEVEL9K_DIR_SHORTENED_FOREGROUND=103
typeset -g POWERLEVEL9K_DIR_ANCHOR_FOREGROUND=39
typeset -g POWERLEVEL9K_VCS_CLEAN_FOREGROUND=76
typeset -g POWERLEVEL9K_VCS_UNTRACKED_FOREGROUND=178
typeset -g POWERLEVEL9K_VCS_MODIFIED_FOREGROUND=178
typeset -g POWERLEVEL9K_STATUS_OK=false
typeset -g POWERLEVEL9K_STATUS_ERROR=true
typeset -g POWERLEVEL9K_STATUS_ERROR_FOREGROUND=196
typeset -g POWERLEVEL9K_COMMAND_EXECUTION_TIME_THRESHOLD=2
typeset -g POWERLEVEL9K_COMMAND_EXECUTION_TIME_FOREGROUND=101
typeset -g POWERLEVEL9K_TIME_FOREGROUND=66
typeset -g POWERLEVEL9K_TIME_FORMAT='%D{%H:%M:%S}'
typeset -g POWERLEVEL9K_TRANSIENT_PROMPT=off
}
(( ${#p10k_config_opts} )) && 'builtin' 'setopt' "${p10k_config_opts[@]}"
'builtin' 'unset' 'p10k_config_opts'
+20
View File
@@ -0,0 +1,20 @@
# tmux config for resumable terminal sessions in the agent computer.
set -g default-shell /usr/bin/zsh
set -g default-command /usr/bin/zsh
set -g default-terminal "tmux-256color"
set -ga terminal-overrides ",*256col*:Tc"
set -g mouse on
set -g history-limit 50000
set -g base-index 1
setw -g pane-base-index 1
set -g renumber-windows on
set -sg escape-time 10
set -g focus-events on
# A small clawmates-styled status line.
set -g status-style "bg=#0b0b0e fg=#5ec8d8"
set -g status-left " #[bold]clawmates#[default] "
set -g status-left-length 24
set -g status-right "#[fg=#9a9aa2]#S · %H:%M "
set -g window-status-current-style "fg=#ff8a7a bold"
+33
View File
@@ -0,0 +1,33 @@
# oh-my-zsh + powerlevel10k for the agent Terminal app.
export ZSH="$HOME/.oh-my-zsh"
ZSH_THEME="powerlevel10k/powerlevel10k"
plugins=(git zsh-autosuggestions zsh-syntax-highlighting)
source "$ZSH/oh-my-zsh.sh"
# powerlevel10k prompt config (baked, no interactive wizard).
[[ ! -f ~/.p10k.zsh ]] || source ~/.p10k.zsh
# Friendly defaults.
export EDITOR=nano
export CLICOLOR=1
alias ll='ls -alFh --color=auto'
alias la='ls -A --color=auto'
alias l='ls -CF --color=auto'
alias gs='git status'
# Runtime MOTD. CLAWMATES_USER is injected per session by the WS bridge, so the
# greeting is personalised at connect time without rebuilding the image.
if [[ -n "$CLAWMATES_USER" ]]; then
print -P ""
print -P " %F{45}╭─%f %BWelcome %F{208}${CLAWMATES_USER}%f%b, to your %F{45}clawmates%f terminal"
print -P " %F{45}│%f %F{244}themed zsh · oh-my-zsh · powerlevel10k · tmux (resumable)%f"
if [[ -d ~/drives ]]; then
print -P " %F{45}╰─%f %F{244}your Files drives are in %F{45}~/drives%f %F{244}(documents · received · shared)%f"
else
print -P " %F{45}╰─%f %F{244}isolated container%f"
fi
print -P ""
else
print -P "%F{45}clawmates%f terminal — %F{244}themed zsh in an isolated container%f"
fi
+13
View File
@@ -0,0 +1,13 @@
-- Per-user saved terminal tab layout for an agent: the named tabs and the tmux
-- session each maps to, so a user can restore "their" terminal — the same tabs
-- and sessions — after logging out and back in (even on another device). The
-- live shell state resumes only while the agent's terminal container is alive;
-- this preserves the tab/session mapping regardless.
CREATE TABLE terminal_tabs (
user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
agent_id UUID NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
layout JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, agent_id)
);
+6
View File
@@ -0,0 +1,6 @@
-- Allow the per-agent Obsidian-style "vault" drive (a markdown second brain)
-- alongside documents / received / shared.
ALTER TABLE file_nodes DROP CONSTRAINT IF EXISTS file_nodes_drive_check;
ALTER TABLE file_nodes
ADD CONSTRAINT file_nodes_drive_check
CHECK (drive IN ('documents', 'received', 'shared', 'vault'));