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:
co-authored by
Claude Opus 4.8
parent
671df7c622
commit
e61724ff82
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Generated
+17
@@ -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",
|
||||||
|
|||||||
@@ -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.
@@ -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}'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" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export const APP_IDS = [
|
|||||||
"home",
|
"home",
|
||||||
"browser",
|
"browser",
|
||||||
"slack",
|
"slack",
|
||||||
|
"terminal",
|
||||||
|
"obsidian",
|
||||||
"chat",
|
"chat",
|
||||||
"skills",
|
"skills",
|
||||||
"files",
|
"files",
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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'
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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'));
|
||||||
Reference in New Issue
Block a user