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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-23 16:52:35 -07:00
co-authored by Claude Opus 4.8
parent 671df7c622
commit e61724ff82
46 changed files with 2247 additions and 95 deletions
+127 -1
View File
@@ -1,11 +1,93 @@
use std::collections::HashMap;
use std::path::Path;
use axum::extract::{Query, State};
use axum::Json;
use cm_domain::{AgentId, FileDrive, FileNode};
use cm_domain::{AgentId, FileDrive, FileNode, WorkspaceId};
use serde::Deserialize;
use sqlx::PgPool;
use crate::routes::claws::workspace_agent;
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)]
pub struct FilesQuery {
#[serde(rename = "clawId")]
@@ -30,6 +112,9 @@ pub async fn openclaw_files(
if !drive.is_agent_scoped() {
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?;
Ok(Json(nodes))
}
@@ -40,6 +125,44 @@ pub struct SharedQuery {
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).
pub async fn shared_files(
State(state): State<AppState>,
@@ -47,6 +170,9 @@ pub async fn shared_files(
Query(query): Query<SharedQuery>,
) -> Result<Json<Vec<FileNode>>, ApiError> {
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 =
cm_db::repo::files::list(&state.pool, user.workspace_id, FileDrive::Shared, agent.id)
.await?;