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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-23 16:52:35 -07:00
co-authored by Claude Opus 4.8
parent 671df7c622
commit e61724ff82
46 changed files with 2247 additions and 95 deletions
+38 -5
View File
@@ -146,7 +146,7 @@ async fn run() -> Result<(), String> {
};
// Environment tools need a container engine; absence is tolerated
// (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() {
Ok(driver) => {
let driver: std::sync::Arc<dyn cm_sandbox::SandboxDriver> =
@@ -156,14 +156,32 @@ async fn run() -> Result<(), String> {
&config.sandbox.image,
));
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(),
);
// 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
// orphan from a dead process (we track none yet) — remove them
// before warming so a crash/redeploy can't leak containers.
agents.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 {
agents.warm(config.sandbox.warm_pool)
} else {
@@ -179,20 +197,27 @@ async fn run() -> Result<(), String> {
std::time::Duration::from_secs(300),
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) => {
eprintln!("clawmates-server: sandbox engine unavailable: {error}");
(None, None)
(None, None, None)
}
}
} else {
(None, None)
(None, None, None)
};
// Keep handles for the graceful-shutdown drain (SIGTERM) — the managers
// themselves are moved into the runtime config below.
let drain_agents = sandboxes.clone();
let drain_browser = browser.clone();
let drain_terminals = terminals.clone();
let runtime = Runtime::with_blob_store(
pool.clone(),
provider,
@@ -203,6 +228,7 @@ async fn run() -> Result<(), String> {
slack_base_url: config.slack.base_url.clone(),
sandboxes,
browser,
terminals,
providers: provider_registry,
},
blob,
@@ -246,6 +272,10 @@ async fn run() -> Result<(), String> {
.with_broker(PathBuf::from(&config.broker.socket_path))
.with_oauth(config.oauth.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),
);
if e2e::enabled() {
@@ -277,5 +307,8 @@ async fn run() -> Result<(), String> {
if let Some(browser) = drain_browser {
browser.shutdown().await;
}
if let Some(terminals) = drain_terminals {
terminals.shutdown().await;
}
Ok(())
}