Merge: Large World graph, agent platform, brain stack & dashboard rebuild
ci / gates (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / sandbox-k8s (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / e2e (push) Has been cancelled

This commit is contained in:
Omar Sobh
2026-06-22 23:22:06 -07:00
123 changed files with 9591 additions and 1098 deletions
Generated
+1017 -44
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -18,6 +18,7 @@ members = [
"crates/cm-telemetry", "crates/cm-telemetry",
"crates/cm-testkit", "crates/cm-testkit",
"crates/cm-auth", "crates/cm-auth",
"crates/cm-brain",
"crates/cm-api", "crates/cm-api",
"crates/bins/clawmates-server", "crates/bins/clawmates-server",
"crates/bins/clawmates-broker", "crates/bins/clawmates-broker",
@@ -59,3 +60,16 @@ unsafe_code = "deny"
todo = "deny" todo = "deny"
unimplemented = "deny" unimplemented = "deny"
dbg_macro = "deny" dbg_macro = "deny"
# ClawSync (claw-brain `sync` feature) pulls clawhdf5-onion/clawsync-onion/
# clawsync-agent from the clawsync repo, whose crates internally path-dep on a
# sibling ../clawhdf5 (absent in a git checkout). clawverse patches this for its
# own build, but `[patch]` only applies from the root workspace — so we mirror it
# here, redirecting clawsync's clawhdf5 view to the same quantumclaw rev cm-brain
# already uses (one clawhdf5 in the graph, types unify).
[patch."https://git.redclaw.dev/redclaw/clawsync.git"]
clawhdf5 = { git = "https://git.redclaw.dev/quantumclaw/clawhdf5.git", rev = "8534c7d204959c6f8959f3983f3edf6475ba25b9" }
clawhdf5-format = { git = "https://git.redclaw.dev/quantumclaw/clawhdf5.git", rev = "8534c7d204959c6f8959f3983f3edf6475ba25b9" }
clawhdf5-io = { git = "https://git.redclaw.dev/quantumclaw/clawhdf5.git", rev = "8534c7d204959c6f8959f3983f3edf6475ba25b9" }
clawhdf5-filters = { git = "https://git.redclaw.dev/quantumclaw/clawhdf5.git", rev = "8534c7d204959c6f8959f3983f3edf6475ba25b9" }
clawhdf5-agent = { git = "https://git.redclaw.dev/quantumclaw/clawhdf5.git", rev = "8534c7d204959c6f8959f3983f3edf6475ba25b9" }
+1 -1
View File
@@ -216,7 +216,7 @@ async fn run() -> Result<(), String> {
// Durable topology run jobs: claim queued runs, drive + checkpoint per step, // Durable topology run jobs: claim queued runs, drive + checkpoint per step,
// resume stale ones after a crash. Long-horizon topologies run here, not in // resume stale ones after a crash. Long-horizon topologies run here, not in
// the HTTP request. // the HTTP request.
cm_api::topology_worker::spawn(pool.clone(), std::time::Duration::from_secs(3)); cm_api::topology_worker::spawn(pool.clone(), runtime.clone(), std::time::Duration::from_secs(3));
// Outbound-email delivery: drains the §15-gated `outbox` over SMTP. Inert // Outbound-email delivery: drains the §15-gated `outbox` over SMTP. Inert
// until CLAWMATES_SMTP_* is set, so it ships safely before credentials exist. // until CLAWMATES_SMTP_* is set, so it ships safely before credentials exist.
cm_runtime::spawn_drainer(pool.clone(), std::time::Duration::from_secs(10)); cm_runtime::spawn_drainer(pool.clone(), std::time::Duration::from_secs(10));
+1
View File
@@ -19,6 +19,7 @@ sqlx = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
cm-auth = { path = "../cm-auth" } cm-auth = { path = "../cm-auth" }
cm-billing = { path = "../cm-billing" } cm-billing = { path = "../cm-billing" }
cm-brain = { path = "../cm-brain" }
cm-config = { path = "../cm-config" } cm-config = { path = "../cm-config" }
cm-db = { path = "../cm-db" } cm-db = { path = "../cm-db" }
cm-domain = { path = "../cm-domain" } cm-domain = { path = "../cm-domain" }
+53 -3
View File
@@ -7,6 +7,7 @@ mod mcp_door;
mod recursive_exec; mod recursive_exec;
mod routes; mod routes;
mod runtime_provision; mod runtime_provision;
pub mod swarm;
mod topology_exec; mod topology_exec;
pub mod topology_worker; pub mod topology_worker;
@@ -88,6 +89,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/auth/logout", post(routes::auth::logout)) .route("/api/auth/logout", post(routes::auth::logout))
.route("/api/user/me", get(routes::identity::me)) .route("/api/user/me", get(routes::identity::me))
.route("/api/claws", post(routes::claws::create)) .route("/api/claws", post(routes::claws::create))
.route("/api/claws/batch-delete", post(routes::claws::batch_delete))
.route("/api/claws/{id}", patch(routes::claws::patch)) .route("/api/claws/{id}", patch(routes::claws::patch))
.route("/api/claws/{id}", delete(routes::claws::delete)) .route("/api/claws/{id}", delete(routes::claws::delete))
.route( .route(
@@ -102,6 +104,41 @@ pub fn router(state: AppState) -> Router {
"/api/claws/{id}/compartments", "/api/claws/{id}/compartments",
get(routes::claws::compartments), get(routes::claws::compartments),
) )
.route("/api/claws/{id}/brain", get(routes::claws::brain))
.route(
"/api/claws/{id}/brain/push",
axum::routing::post(routes::claws::push_brain),
)
.route(
"/api/brainhub/pull",
axum::routing::post(routes::claws::pull_brain),
)
.route("/api/brainhub/search", get(routes::claws::brainhub_search))
.route("/api/brainhub/preview", get(routes::claws::brainhub_preview))
.route(
"/api/brainhub/enhance",
axum::routing::post(routes::claws::enhance_brain),
)
.route(
"/api/claws/{id}/brain/revisions",
axum::routing::get(routes::claws::brain_revisions),
)
.route(
"/api/claws/{id}/brain/rollback",
axum::routing::post(routes::claws::brain_rollback),
)
.route(
"/api/planner/chat",
axum::routing::post(routes::planner::planner_chat),
)
.route(
"/api/planner/scaffold",
axum::routing::post(routes::planner::planner_scaffold),
)
.route(
"/api/claws/{id}/brain/apply",
axum::routing::post(routes::claws::apply_brain),
)
.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(
@@ -162,17 +199,30 @@ pub fn router(state: AppState) -> Router {
post(routes::topology::compare_topologies), post(routes::topology::compare_topologies),
) )
.route("/api/topologies/run", post(routes::topology::run_topology)) .route("/api/topologies/run", post(routes::topology::run_topology))
.route("/api/swarm/run", post(routes::topology::run_swarm))
.route("/api/hooks/{token}", post(routes::webhooks::trigger_hook))
.route(
"/api/teams/{id}/webhooks",
get(routes::webhooks::list_webhooks).post(routes::webhooks::create_webhook),
)
.route( .route(
"/api/teams", "/api/teams",
get(routes::teams::list_teams).post(routes::teams::create_team), get(routes::teams::list_teams).post(routes::teams::create_team),
) )
.route("/api/teams/{id}", get(routes::teams::get_team)) .route("/api/teams/from-claws", post(routes::teams::create_team_from_claws))
.route(
"/api/teams/{id}",
get(routes::teams::get_team).patch(routes::teams::patch_team).delete(routes::teams::delete_team),
)
.route("/api/teams/{id}/run", post(routes::teams::run_team)) .route("/api/teams/{id}/run", post(routes::teams::run_team))
.route( .route(
"/api/companies", "/api/companies",
get(routes::companies::list_companies).post(routes::companies::create_company), get(routes::companies::list_companies).post(routes::companies::create_company),
) )
.route("/api/companies/{id}", get(routes::companies::get_company)) .route(
"/api/companies/{id}",
get(routes::companies::get_company).patch(routes::companies::patch_company).delete(routes::companies::delete_company),
)
.route( .route(
"/api/companies/{id}/run", "/api/companies/{id}/run",
post(routes::companies::run_company), post(routes::companies::run_company),
@@ -181,7 +231,7 @@ pub fn router(state: AppState) -> Router {
"/api/orgs", "/api/orgs",
get(routes::orgs::list_orgs).post(routes::orgs::create_org), get(routes::orgs::list_orgs).post(routes::orgs::create_org),
) )
.route("/api/orgs/{id}", get(routes::orgs::get_org)) .route("/api/orgs/{id}", get(routes::orgs::get_org).delete(routes::orgs::delete_org))
.route("/api/orgs/{id}/run", post(routes::orgs::run_org)) .route("/api/orgs/{id}/run", post(routes::orgs::run_org))
.route("/api/structure/stats", get(routes::structure::stats)) .route("/api/structure/stats", get(routes::structure::stats))
.route("/api/structure/{level}/{id}", get(routes::structure::node)) .route("/api/structure/{level}/{id}", get(routes::structure::node))
+664
View File
@@ -1,6 +1,8 @@
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::Json; use axum::Json;
use std::convert::Infallible;
use cm_db::repo::audit::Actor; use cm_db::repo::audit::Actor;
use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus}; use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -121,6 +123,605 @@ pub async fn compartments(
Ok(Json(out)) Ok(Json(out))
} }
/// `GET /api/claws/{id}/brain` — the claw's `.brain` (cm-brain / ClawhDF5)
/// rendered for the anatomy cards: its six sections + recent memory + stats.
/// Best-effort: if the brain can't be opened, returns an empty (`exists:false`)
/// payload so the UI falls back to its other data sources.
#[derive(Serialize)]
pub struct BrainSkill {
pub name: String,
pub body: String,
}
#[derive(Serialize)]
pub struct BrainTool {
pub name: String,
pub state: String,
}
#[derive(Serialize, Default)]
pub struct BrainStats {
pub skills: usize,
pub tools: usize,
pub memories: usize,
}
#[derive(Serialize, Default)]
pub struct ClawBrainResponse {
/// Whether a `.brain` file already existed before this request.
pub exists: bool,
pub system_prompt: Option<String>,
pub personality: Option<String>,
pub skills: Vec<BrainSkill>,
pub tools: Vec<BrainTool>,
/// Recent conversational memory chunks, newest first.
pub memory: Vec<String>,
pub runtime: Option<Value>,
pub provenance: Option<Value>,
pub stats: BrainStats,
}
pub(crate) fn brain_dir() -> std::path::PathBuf {
std::env::var("CLAWMATES_BRAIN_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir().join("clawmates-brains"))
}
/// Open (or first-create) the claw's brain and read it into a response. Seeds
/// the definition from Postgres on a fresh brain — mirrors the runtime's
/// first-touch seeding so the cards always have real data. Pure/sync.
fn load_brain(agent: &Agent, skills: &[(String, String)]) -> ClawBrainResponse {
use cm_brain::ClawBrain;
let path = brain_dir().join(format!("claw_{}.h5", agent.id));
let existed = path.exists();
let mut brain = match ClawBrain::open_or_create(&path, &agent.id.to_string()) {
Ok(b) => b,
Err(_) => return ClawBrainResponse::default(),
};
if brain.system_prompt().is_none() && !agent.system_prompt.trim().is_empty() {
let _ = brain.set_system_prompt(&agent.system_prompt);
for (name, body) in skills {
let _ = brain.set_skill(name, body);
}
}
let parse = |s: Option<String>| s.and_then(|t| serde_json::from_str::<Value>(&t).ok());
let skills_v: Vec<BrainSkill> = brain
.skills()
.into_iter()
.map(|(name, body)| BrainSkill { name, body })
.collect();
let tools_v: Vec<BrainTool> = brain
.tools()
.into_iter()
.map(|(name, state)| BrainTool { name, state })
.collect();
let memory: Vec<String> = brain
.recent_memory(12)
.into_iter()
.map(|(_, text)| text)
.collect();
let stats = BrainStats {
skills: skills_v.len(),
tools: tools_v.len(),
memories: brain.memory_count(),
};
ClawBrainResponse {
exists: existed,
system_prompt: brain.system_prompt(),
personality: brain.personality(),
skills: skills_v,
tools: tools_v,
memory,
runtime: parse(brain.runtime()),
provenance: parse(brain.provenance()),
stats,
}
}
pub async fn brain(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
) -> Result<Json<ClawBrainResponse>, ApiError> {
let agent = workspace_agent(&state, &user, id).await?;
let skills: Vec<(String, String)> = cm_db::repo::skills::installed(&state.pool, agent.id)
.await
.unwrap_or_default()
.into_iter()
.map(|s| (s.title, s.body))
.collect();
Ok(Json(load_brain(&agent, &skills)))
}
/// `POST /api/brainhub/enhance` — Opus-4.8 reviews a brain (prompt
/// effectiveness, exploitability, personality, tools/access), rewrites its
/// files, and commits a new version to ClawBrainHub. Streams progress (SSE).
#[derive(Deserialize)]
pub struct EnhanceRequest {
reference: String,
}
/// Extract a JSON object from an LLM response, tolerating prose wrappers, ```json
/// fences, and trailing commentary (balanced-brace scan from the first `{`).
pub(crate) fn extract_json(s: &str) -> Option<Value> {
let t = s.trim();
let t = t.strip_prefix("```json").or_else(|| t.strip_prefix("```")).unwrap_or(t);
let t = t.strip_suffix("```").unwrap_or(t).trim();
if let Ok(v) = serde_json::from_str::<Value>(t) {
return Some(v);
}
let bytes = t.as_bytes();
let start = t.find('{')?;
let (mut depth, mut in_str, mut esc) = (0i32, false, false);
for i in start..bytes.len() {
let c = bytes[i] as char;
if in_str {
if esc {
esc = false;
} else if c == '\\' {
esc = true;
} else if c == '"' {
in_str = false;
}
} else {
match c {
'"' => in_str = true,
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return serde_json::from_str(&t[start..=i]).ok();
}
}
_ => {}
}
}
}
None
}
pub(crate) const ENHANCE_SYSTEM: &str = "You are a rigorous AI-agent brain reviewer. You are given an agent's brain — \
its SYSTEM PROMPT, AGENTS.md (operating rules), PERSONA, and SKILLS. Audit it on four axes: \
(1) effectiveness — is the role clear, actionable, unambiguous; \
(2) exploitability — resistance to prompt injection / jailbreaks / data exfiltration / over-broad authority; \
(3) personality — consistency, tone, and an appropriate intensity/scale; \
(4) tools & access — are capabilities scoped and least-privilege. \
Then REWRITE each file to fix weaknesses and conform to production best practices (clear role, explicit \
guardrails and refusal boundaries, disciplined tool use, consistent persona). Preserve the agent's domain \
and intent; improve, don't replace its purpose. If a section is empty, create an appropriate one. \
USE WEB SEARCH to verify current real-world facts before writing — especially the LATEST STABLE versions \
of the languages, runtimes, toolchains, and key libraries this agent uses (your training data is stale; \
do not guess version numbers). Reflect the accurate current versions and any recent best-practice changes \
in the rewritten files. \
Respond with STRICT JSON ONLY, no prose or markdown, exactly this shape: \
{\"analysis\":{\"effectiveness\":{\"score\":0,\"notes\":\"\"},\"exploitability\":{\"score\":0,\"notes\":\"\"},\
\"personality\":{\"score\":0,\"notes\":\"\"},\"tools_access\":{\"score\":0,\"notes\":\"\"},\"summary\":\"\"},\
\"enhanced\":{\"system_prompt\":\"\",\"agent_md\":\"\",\"persona\":\"\",\"skills_md\":\"\"}} \
where scores are 0-10 and each enhanced file is the complete, ready-to-use replacement text.";
pub(crate) fn bump_version(v: &str) -> String {
let p: Vec<&str> = v.split('.').collect();
if p.len() == 3 {
if let Ok(patch) = p[2].parse::<u64>() {
return format!("{}.{}.{}", p[0], p[1], patch + 1);
}
}
format!("{v}-enhanced")
}
fn sse(v: Value) -> Result<Event, Infallible> {
Ok(Event::default().data(v.to_string()))
}
pub async fn enhance_brain(
State(state): State<AppState>,
Authed(_user): Authed,
Json(body): Json<EnhanceRequest>,
) -> impl axum::response::IntoResponse {
let reference = body.reference.trim().to_string();
let runtime = state.runtime.clone();
let stream = async_stream::stream! {
if reference.is_empty() || !reference.contains('/') {
yield sse(json!({"stage":"error","pct":100,"label":"Bad brain reference"}));
return;
}
yield sse(json!({"stage":"pull","pct":8,"label":"Pulling brain…"}));
let safe: String = reference.chars().map(|c| if c == '/' || c == ':' { '_' } else { c }).collect();
let path = brain_dir().join(format!("enhance_{safe}.h5"));
let _ = std::fs::remove_file(&path);
let pulled = match cm_brain::hub::pull(&reference, &path).await {
Ok(p) => p,
Err(e) => { yield sse(json!({"stage":"error","pct":100,"label":format!("Pull failed: {e}")})); return; }
};
let (sp, agent_md, persona, skills) = match cm_brain::ClawBrain::open_or_create(&path, &reference) {
Ok(b) => (
b.system_prompt().unwrap_or_default(),
b.agent_md().unwrap_or_default(),
b.personality().unwrap_or_default(),
b.skills().into_iter().map(|(n, body)| format!("## {n}\n{body}")).collect::<Vec<_>>().join("\n\n"),
),
Err(e) => { yield sse(json!({"stage":"error","pct":100,"label":format!("Open failed: {e}")})); return; }
};
yield sse(json!({"stage":"analyze","pct":28,"label":"Auditing with Claude Opus 4.8…"}));
let user_prompt = format!(
"BRAIN: {reference}\n\n=== SYSTEM PROMPT ===\n{sp}\n\n=== AGENTS.md ===\n{agent_md}\n\n=== PERSONA ===\n{persona}\n\n=== SKILLS ===\n{skills}"
);
let raw = match runtime.complete(ENHANCE_SYSTEM, &user_prompt, "claude-opus-4-8", 16000, true).await {
Ok(t) => t,
Err(e) => { yield sse(json!({"stage":"error","pct":100,"label":format!("Opus error: {e}")})); return; }
};
let v = match extract_json(&raw) {
Some(v) => v,
None => {
let head: String = raw.chars().take(220).collect();
let tail: String = { let n = raw.chars().count(); raw.chars().skip(n.saturating_sub(220)).collect() };
eprintln!("cm-api: enhance unparseable for {reference} (len={}): head={head:?} tail={tail:?}", raw.len());
yield sse(json!({"stage":"error","pct":100,"label":"Opus returned unparseable output"}));
return;
}
};
let analysis = v.get("analysis").cloned().unwrap_or(Value::Null);
let enh = v.get("enhanced").cloned().unwrap_or(Value::Null);
let field = |k: &str| enh.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
let (e_sp, e_agent, e_persona, e_skills) =
(field("system_prompt"), field("agent_md"), field("persona"), field("skills_md"));
yield sse(json!({"stage":"write","pct":74,"label":"Applying enhancements…"}));
match cm_brain::ClawBrain::open_or_create(&path, &reference) {
Ok(mut b) => {
if !e_sp.trim().is_empty() { let _ = b.set_system_prompt(&e_sp); }
if !e_agent.trim().is_empty() { let _ = b.set_agent_md(&e_agent); }
if !e_persona.trim().is_empty() { let _ = b.set_personality(&e_persona); }
if !e_skills.trim().is_empty() { let _ = b.set_skills_md(&e_skills); }
}
Err(e) => { yield sse(json!({"stage":"error","pct":100,"label":format!("Write failed: {e}")})); return; }
}
yield sse(json!({"stage":"push","pct":90,"label":"Committing new version to ClawBrainHub…"}));
let owner = cm_brain::hub::whoami().await.unwrap_or_else(|_| "me".to_string());
let on = pulled.meta.reference.rsplit_once(':').map(|(o, _)| o).unwrap_or(&pulled.meta.reference);
let name = on.rsplit_once('/').map(|(_, n)| n.to_string()).unwrap_or_else(|| on.to_string());
let cur_ver = pulled.meta.reference.rsplit_once(':').map(|(_, v)| v.to_string()).unwrap_or_else(|| "1.0.0".to_string());
let new_ref = format!("{owner}/{name}:{}", bump_version(&cur_ver));
match cm_brain::hub::push(&new_ref, &path, "Enhanced by Claude Opus 4.8", &[]).await {
Ok(()) => yield sse(json!({"stage":"done","pct":100,"label":"Committed new version","new_reference":new_ref,"analysis":analysis})),
Err(e) => yield sse(json!({"stage":"done","pct":100,"label":format!("Enhanced — push skipped ({e})"),"new_reference":Value::Null,"analysis":analysis})),
}
let _ = std::fs::remove_file(&path);
};
Sse::new(Box::pin(stream)).keep_alive(KeepAlive::new())
}
/// Pull a registry brain, refine it once with Opus 4.8 (web-grounded, role-aware),
/// and publish a new version. Returns the new reference (or the original on push
/// failure). Used by the Master Planner scaffold.
pub(crate) async fn enhance_and_publish(
runtime: &cm_runtime::Runtime,
reference: &str,
role_context: &str,
) -> Result<String, String> {
let safe: String = reference.chars().map(|c| if c == '/' || c == ':' { '_' } else { c }).collect();
let path = brain_dir().join(format!("scaffold_{safe}.h5"));
let _ = std::fs::remove_file(&path);
let pulled = cm_brain::hub::pull(reference, &path).await.map_err(|e| e.to_string())?;
let (sp, agent_md, persona, skills) = {
let b = cm_brain::ClawBrain::open_or_create(&path, reference).map_err(|e| e.to_string())?;
(
b.system_prompt().unwrap_or_default(),
b.agent_md().unwrap_or_default(),
b.personality().unwrap_or_default(),
b.skills().into_iter().map(|(n, bd)| format!("## {n}\n{bd}")).collect::<Vec<_>>().join("\n\n"),
)
};
let user_prompt = format!(
"ROLE CONTEXT: {role_context}\n\nBRAIN: {reference}\n\n=== SYSTEM PROMPT ===\n{sp}\n\n=== AGENTS.md ===\n{agent_md}\n\n=== PERSONA ===\n{persona}\n\n=== SKILLS ===\n{skills}"
);
let raw = runtime.complete(ENHANCE_SYSTEM, &user_prompt, "claude-opus-4-8", 16000, true).await?;
let v = extract_json(&raw).ok_or_else(|| "unparseable enhance output".to_string())?;
let enh = v.get("enhanced").cloned().unwrap_or(Value::Null);
let field = |k: &str| enh.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
{
let mut b = cm_brain::ClawBrain::open_or_create(&path, reference).map_err(|e| e.to_string())?;
if !field("system_prompt").trim().is_empty() { let _ = b.set_system_prompt(&field("system_prompt")); }
if !field("agent_md").trim().is_empty() { let _ = b.set_agent_md(&field("agent_md")); }
if !field("persona").trim().is_empty() { let _ = b.set_personality(&field("persona")); }
if !field("skills_md").trim().is_empty() { let _ = b.set_skills_md(&field("skills_md")); }
}
let owner = cm_brain::hub::whoami().await.unwrap_or_else(|_| "me".to_string());
let on = pulled.meta.reference.rsplit_once(':').map(|(o, _)| o).unwrap_or(&pulled.meta.reference);
let name = on.rsplit_once('/').map(|(_, n)| n.to_string()).unwrap_or_else(|| on.to_string());
let cur_ver = pulled.meta.reference.rsplit_once(':').map(|(_, vv)| vv.to_string()).unwrap_or_else(|| "1.0.0".to_string());
let new_ref = format!("{owner}/{name}:{}", bump_version(&cur_ver));
let result = match cm_brain::hub::push(&new_ref, &path, "Refined by Master Planner (Opus 4.8)", &[]).await {
Ok(()) => new_ref,
Err(_) => reference.to_string(),
};
let _ = std::fs::remove_file(&path);
Ok(result)
}
/// Attach a brain reference to a freshly-created claw (merge + set its
/// authoritative system prompt). Used by the Master Planner scaffold.
pub(crate) async fn apply_reference_to_claw(state: &AppState, id: AgentId, reference: &str) -> Result<(), String> {
let path = brain_dir().join(format!("claw_{id}.h5"));
let pulled = cm_brain::hub::pull_merge(reference, &path).await.map_err(|e| e.to_string())?;
if !pulled.system_prompt.trim().is_empty() {
let _ = cm_db::repo::agents::update_profile(
&state.pool, id, None, None, Some(pulled.system_prompt.trim()), None, None, None,
)
.await;
}
// ClawSync: snapshot this agent's starting brain as its first revision.
if let Ok(b) = cm_brain::ClawBrain::open_or_create(&path, &id.to_string()) {
let _ = b.commit(Some(&format!("scaffolded from {reference}")));
}
Ok(())
}
/// `POST /api/brainhub/pull` — pull a `.brain` from ClawBrainHub and create a
/// claw from it (identity + skills + memory come from the brain). Public brains
/// pull anonymously; private ones need `BRAINHUB_API_KEY`.
#[derive(Deserialize)]
pub struct PullBrainRequest {
/// `owner/name[:version]`, e.g. `redclawsystems/general-assistant`.
reference: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
job_title: Option<String>,
#[serde(default)]
accent: Option<String>,
}
pub async fn pull_brain(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<PullBrainRequest>,
) -> Result<(StatusCode, Json<Agent>), ApiError> {
let reference = body.reference.trim().to_string();
if reference.is_empty() || !reference.contains('/') {
return Err(ApiError::BadRequest);
}
let id = AgentId::new();
let dest = brain_dir().join(format!("claw_{id}.h5"));
let pulled = cm_brain::hub::pull(&reference, &dest).await.map_err(|e| {
eprintln!("cm-api: brain pull failed for {reference}: {e}");
ApiError::BadRequest
})?;
let agent = Agent {
id,
workspace_id: user.workspace_id,
name: body.name.filter(|s| !s.trim().is_empty()).unwrap_or(pulled.name),
job_title: body
.job_title
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "Pulled from ClawBrainHub".into()),
system_prompt: pulled.system_prompt,
avatar: String::new(),
accent: body.accent.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "#ff6f61".into()),
wallpaper: String::new(),
managed_by: user.user_id,
status: AgentStatus::Online,
};
cm_db::repo::agents::insert(&state.pool, &agent, &AccessPolicy::default()).await?;
cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
Actor::User(user.user_id),
"agent.pulled_from_brain",
"agent",
&agent.id.to_string(),
json!({"reference": pulled.meta.reference, "trust_score": pulled.meta.trust_score}),
)
.await?;
Ok((StatusCode::CREATED, Json(agent)))
}
/// `POST /api/claws/{id}/brain/push` — publish a claw's `.brain` to ClawBrainHub.
/// Requires `BRAINHUB_API_KEY`.
#[derive(Deserialize)]
pub struct PushBrainRequest {
/// `owner/name:version` to publish under.
reference: String,
#[serde(default)]
description: String,
#[serde(default)]
tags: Vec<String>,
}
pub async fn push_brain(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
Json(body): Json<PushBrainRequest>,
) -> Result<StatusCode, ApiError> {
let agent = workspace_agent(&state, &user, id).await?;
let path = brain_dir().join(format!("claw_{id}.h5"));
if !path.exists() {
// No working brain yet — seed it from the DB definition so there's
// something to publish.
let skills: Vec<(String, String)> = cm_db::repo::skills::installed(&state.pool, agent.id)
.await
.unwrap_or_default()
.into_iter()
.map(|s| (s.title, s.body))
.collect();
let _ = load_brain(&agent, &skills);
}
cm_brain::hub::push(body.reference.trim(), &path, &body.description, &body.tags)
.await
.map_err(|e| {
eprintln!("cm-api: brain push failed: {e}");
ApiError::BadRequest
})?;
cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
Actor::User(user.user_id),
"agent.pushed_to_brain",
"agent",
&id.to_string(),
json!({"reference": body.reference}),
)
.await?;
Ok(StatusCode::CREATED)
}
/// `GET /api/brainhub/search?q=` — list/search ClawBrainHub brains (anonymous).
#[derive(Deserialize)]
pub struct BrainSearchQuery {
#[serde(default)]
q: String,
}
pub async fn brainhub_search(
State(_state): State<AppState>,
Authed(_user): Authed,
Query(query): Query<BrainSearchQuery>,
) -> Result<Json<Vec<cm_brain::hub::BrainListing>>, ApiError> {
Ok(Json(cm_brain::hub::list(&query.q).await.unwrap_or_default()))
}
/// `GET /api/brainhub/preview?ref=owner/name` — overview of a brain's contents
/// (which sections are populated) for the registry detail slide-out.
#[derive(Deserialize)]
pub struct BrainPreviewQuery {
#[serde(rename = "ref")]
reference: String,
}
pub async fn brainhub_preview(
State(_state): State<AppState>,
Authed(_user): Authed,
Query(query): Query<BrainPreviewQuery>,
) -> Result<Json<cm_brain::hub::BrainPreview>, ApiError> {
let reference = query.reference.trim();
if reference.is_empty() || !reference.contains('/') {
return Err(ApiError::BadRequest);
}
cm_brain::hub::preview(reference).await.map(Json).map_err(|e| {
eprintln!("cm-api: brain preview failed for {reference}: {e}");
ApiError::BadRequest
})
}
/// `POST /api/claws/{id}/brain/apply` — pull a brain and inject its contents
/// into THIS claw (identity → system prompt, +skills/+tools, +memory). Returns
/// the merged brain so the UI repopulates the cards.
#[derive(Deserialize)]
pub struct ApplyBrainRequest {
reference: String,
}
pub async fn apply_brain(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
Json(body): Json<ApplyBrainRequest>,
) -> Result<Json<ClawBrainResponse>, ApiError> {
let agent = workspace_agent(&state, &user, id).await?;
let reference = body.reference.trim();
if reference.is_empty() || !reference.contains('/') {
return Err(ApiError::BadRequest);
}
let path = brain_dir().join(format!("claw_{id}.h5"));
let pulled = cm_brain::hub::pull_merge(reference, &path).await.map_err(|e| {
eprintln!("cm-api: brain apply failed for {reference}: {e}");
ApiError::BadRequest
})?;
// The assembled identity becomes the agent's authoritative system prompt
// (safe replace — the chat path is raw‑API for every provider).
if !pulled.system_prompt.trim().is_empty() {
let _ = cm_db::repo::agents::update_profile(
&state.pool,
id,
None,
None,
Some(pulled.system_prompt.trim()),
None,
None,
None,
)
.await;
}
// ClawSync: snapshot the applied state as a revision (enables rollback).
if let Ok(b) = cm_brain::ClawBrain::open_or_create(&path, &id.to_string()) {
let _ = b.commit(Some(&format!("applied {}", pulled.meta.reference)));
}
cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
Actor::User(user.user_id),
"agent.brain_applied",
"agent",
&id.to_string(),
json!({"reference": pulled.meta.reference}),
)
.await?;
let skills: Vec<(String, String)> = cm_db::repo::skills::installed(&state.pool, agent.id)
.await
.unwrap_or_default()
.into_iter()
.map(|s| (s.title, s.body))
.collect();
Ok(Json(load_brain(&agent, &skills)))
}
#[derive(Deserialize)]
pub struct RollbackRequest {
pub revision: u64,
}
/// `GET /api/claws/{id}/brain/revisions` — the brain's ClawSync revision history.
pub async fn brain_revisions(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
) -> Result<Json<Value>, ApiError> {
workspace_agent(&state, &user, id).await?;
let path = brain_dir().join(format!("claw_{id}.h5"));
let revs = match cm_brain::ClawBrain::open_or_create(&path, &id.to_string()) {
Ok(b) => b.revisions().unwrap_or_default(),
Err(_) => Vec::new(),
};
let out: Vec<Value> = revs
.iter()
.map(|r| json!({"revision": r.revision, "branch_id": r.branch_id, "annotation": r.annotation, "is_snapshot": r.is_snapshot}))
.collect();
Ok(Json(json!({ "revisions": out })))
}
/// `POST /api/claws/{id}/brain/rollback {revision}` — materialize a prior brain
/// revision and re-make its identity the agent's authoritative system prompt.
pub async fn brain_rollback(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
Json(body): Json<RollbackRequest>,
) -> Result<Json<Value>, ApiError> {
workspace_agent(&state, &user, id).await?;
let path = brain_dir().join(format!("claw_{id}.h5"));
let b = cm_brain::ClawBrain::open_or_create(&path, &id.to_string()).map_err(|_| ApiError::Internal)?;
b.rollback(body.revision).map_err(|_| ApiError::BadRequest)?;
// Re-open the rolled-back brain and restore its identity as the live prompt.
if let Ok(reb) = cm_brain::ClawBrain::open_or_create(&path, &id.to_string()) {
let sp = reb.assembled_identity();
if !sp.trim().is_empty() {
let _ = cm_db::repo::agents::update_profile(&state.pool, id, None, None, Some(sp.trim()), None, None, None).await;
}
}
cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
Actor::User(user.user_id),
"agent.brain_rolledback",
"agent",
&id.to_string(),
json!({"revision": body.revision}),
)
.await
.ok();
Ok(Json(json!({ "ok": true, "revision": body.revision })))
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateClawRequest { pub struct CreateClawRequest {
name: String, name: String,
@@ -234,6 +835,69 @@ pub async fn delete(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[derive(Deserialize)]
pub struct BatchDeleteRequest {
pub ids: Vec<AgentId>,
}
/// `POST /api/claws/batch-delete` (SSE) — HARD-purge multiple agents and reap
/// every attached resource: deprovision the ZeroClaw runtime, tear down the
/// sandbox container, unlink the `.brain`/`.onion` files, then transactionally
/// purge all DB rows (`agents::hard_purge`). Streams per-agent/per-resource
/// progress; the agents vanish from the roster (hard delete) on refresh.
pub async fn batch_delete(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<BatchDeleteRequest>,
) -> impl axum::response::IntoResponse {
let stream = async_stream::stream! {
let total = body.ids.len().max(1);
let provisioner = crate::runtime_provision::RuntimeProvisioner::from_env();
let mut done = 0usize;
for id in body.ids {
let base = 100 * done / total;
let agent = match workspace_agent(&state, &user, id).await {
Ok(a) => a,
Err(_) => { yield sse(json!({"stage":"skip","pct":base,"label":format!("{id}: not found or no access")})); done += 1; continue; }
};
if !user.role.is_owner() && agent.managed_by != user.user_id {
yield sse(json!({"stage":"skip","pct":base,"label":format!("{}: not permitted", agent.name)})); done += 1; continue;
}
let name = agent.name.clone();
yield sse(json!({"stage":"start","pct":base,"label":format!("Removing {name}…")}));
// 1. Deprovision the ZeroClaw runtime agent (best-effort).
yield sse(json!({"stage":"deprovision","pct":base,"label":format!("{name}: deprovisioning runtime…")}));
if let Some(p) = &provisioner {
let _ = p.deprovision_claw(id.as_uuid()).await;
}
// 2. Reap the sandbox/browser container if one is attached.
let had_container = state.runtime.reap_sandbox(id).await;
yield sse(json!({"stage":"container","pct":base,"label":format!("{name}: {}", if had_container { "reaped sandbox container" } else { "no container attached" })}));
// 3. Unlink the brain files.
let brain_gone = std::fs::remove_file(brain_dir().join(format!("claw_{id}.h5"))).is_ok();
let _ = std::fs::remove_file(brain_dir().join(format!("claw_{id}.h5.onion")));
yield sse(json!({"stage":"brain","pct":base,"label":format!("{name}: {}", if brain_gone { "deleted .brain file" } else { "no .brain file" })}));
// 4. Transactionally purge all DB rows + the agent itself.
yield sse(json!({"stage":"purge","pct":base,"label":format!("{name}: purging data…")}));
match cm_db::repo::agents::hard_purge(&state.pool, id).await {
Ok(c) => {
let _ = cm_db::repo::audit::append(
&state.pool, user.workspace_id, Actor::User(user.user_id),
"agent.purged", "agent", &id.to_string(),
json!({"name": name, "sessions": c.sessions, "files": c.files, "approvals": c.approvals, "connections": c.connections}),
).await;
done += 1;
yield sse(json!({"stage":"removed","pct":100 * done / total,"label":format!("✓ {name} removed — {} sessions, {} files, {} connections cleared", c.sessions, c.files, c.connections)}));
}
Err(e) => { done += 1; yield sse(json!({"stage":"error","pct":100 * done / total,"label":format!("{name}: purge failed: {e}")})); }
}
}
yield sse(json!({"stage":"done","pct":100,"label":"Done"}));
};
Sse::new(Box::pin(stream)).keep_alive(KeepAlive::new())
}
/// PUT /api/claws/{id}/access — the §7.7 access toggles. /// PUT /api/claws/{id}/access — the §7.7 access toggles.
pub async fn set_access( pub async fn set_access(
State(state): State<AppState>, State(state): State<AppState>,
+49
View File
@@ -112,6 +112,55 @@ pub async fn create_company(
)) ))
} }
#[derive(Deserialize)]
pub struct PatchCompanyRequest {
/// New TopologyKind (snake_case).
pub kind: String,
}
/// `PATCH /api/companies/{id}` — change a company's topology: rebuild the graph
/// over its bound teams (stable node ids keep the node→team bindings) + persist.
pub async fn patch_company(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<PatchCompanyRequest>,
) -> Result<StatusCode, ApiError> {
let company = cm_db::repo::companies::get(&state.pool, id, user.workspace_id).await?;
let kind = parse_kind(&body.kind)?;
let bindings = cm_db::repo::companies::teams_for_company(&state.pool, company.id).await?;
let by_node: std::collections::HashMap<String, (Uuid, String)> = bindings
.into_iter()
.map(|b| (b.node_id, (b.team_id, b.role)))
.collect();
let n = by_node.len();
let roles: Vec<String> = (0..n)
.map(|i| by_node.get(&format!("n{i}")).map(|(_, r)| r.clone()).unwrap_or_else(|| "team".into()))
.collect();
let role_refs: Vec<&str> = roles.iter().map(String::as_str).collect();
let mut graph = build(kind, &role_refs).map_err(|_| ApiError::BadRequest)?;
for node in graph.nodes.iter_mut() {
if let Some((tid, _)) = by_node.get(&node.id) {
node.attrs.insert("team_id".into(), tid.to_string());
node.attrs.insert("agent".into(), tid.to_string());
}
}
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
cm_db::repo::companies::set_topology(&state.pool, company.id, user.workspace_id, kind.as_str(), &graph_json).await?;
Ok(StatusCode::NO_CONTENT)
}
/// `DELETE /api/companies/{id}` — remove a company (its teams remain).
pub async fn delete_company(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
cm_db::repo::companies::delete_company(&state.pool, id, user.workspace_id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct CompanySummaryOut { pub struct CompanySummaryOut {
pub id: String, pub id: String,
+2
View File
@@ -6,6 +6,8 @@ pub mod browser;
pub mod claw_chat; pub mod claw_chat;
pub mod claws; pub mod claws;
pub mod companies; pub mod companies;
pub mod planner;
pub mod webhooks;
pub mod files; pub mod files;
pub mod gateway; pub mod gateway;
pub mod health; pub mod health;
+11
View File
@@ -152,6 +152,17 @@ pub struct OrgDetail {
} }
/// `GET /api/orgs/{id}` — an org's graph + node→company bindings. /// `GET /api/orgs/{id}` — an org's graph + node→company bindings.
/// `DELETE /api/orgs/{id}` — remove the org (structural: companies survive, just
/// ungrouped from it).
pub async fn delete_org(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<axum::http::StatusCode, ApiError> {
cm_db::repo::orgs::delete_org(&state.pool, id, user.workspace_id).await?;
Ok(axum::http::StatusCode::NO_CONTENT)
}
pub async fn get_org( pub async fn get_org(
State(state): State<AppState>, State(state): State<AppState>,
Authed(user): Authed, Authed(user): Authed,
+225
View File
@@ -0,0 +1,225 @@
//! Master Planner — a chat with Claude Opus 4.8 that proposes a team of agents
//! (named, role'd, one model each) and then scaffolds it end-to-end: creates the
//! team + topology, refines + attaches a brain per agent, and sets up the nightly
//! loop. Replaces the old "+" deploy wizard.
use axum::extract::State;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use std::convert::Infallible;
use crate::routes::claws::{apply_reference_to_claw, enhance_and_publish, extract_json};
use crate::routes::teams::{build_team, TeamMemberInput};
use crate::{AppState, Authed};
fn sse(v: Value) -> Result<Event, Infallible> {
Ok(Event::default().data(v.to_string()))
}
const PLANNER_SYSTEM: &str = "You are the ClawMates Master Planner. You design teams of AI agents to \
accomplish a user's goal across the platform's hierarchy (organizations → companies → teams → agents). \
Have a brief, helpful conversation to understand the goal, then propose a concrete team. For the proposal: \
choose a sensible number of agents (usually 2–6), each with a UNIQUE human name, a clear ROLE, the best \
MODEL for that role (see catalog), a short brain_query (one domain keyword to fetch a starting brain from \
the registry, e.g. 'research', 'writing', 'data', 'security'), a focused system_prompt, and a one-line \
rationale. Pick a topology_kind from: hub_spoke, hierarchical, pipeline, mesh, flat. If the goal implies \
recurring/autonomous work (e.g. 'nightly'), include a schedule {cron (5-field), prompt (the mission the \
lead agent runs each cycle)}. Use web search to ground version-sensitive or current-fact claims. \
ALWAYS respond with STRICT JSON ONLY (no prose, no markdown), exactly: \
{\"reply\":\"<concise message to the user>\",\"proposal\":null|{\"team_name\":\"...\",\
\"topology_kind\":\"hub_spoke\",\"schedule\":null|{\"cron\":\"0 2 * * *\",\"prompt\":\"...\"},\
\"members\":[{\"name\":\"...\",\"role\":\"...\",\"model\":\"...\",\"brain_query\":\"...\",\
\"system_prompt\":\"...\",\"rationale\":\"...\"}]}}. Set proposal to null while still clarifying; include \
it once you have a concrete team. \n\nMODELS (set each member's \"model\" to exactly one token):\n\
- claude — Claude Opus 4.8: strongest reasoning/planning; coordinators, hard analysis. Highest cost.\n\
- glm-4.7 — strong general reasoning (Z.ai); best cost/quality default for most workers.\n\
- glm-5.2 — GLM Opus-class for the hardest reasoning roles; higher cost.\n\
- kimi — excellent for code-heavy roles.\n\
- gemini — Gemini 2.5 Flash: very fast; classification, summarization, high-volume tasks.\n\
- groq — fastest/cheapest; simple sequential high-throughput steps.\n\
AGENT TOOLS each agent can use at runtime: web.search (find sources), browser.goto (fetch a URL), \
files.write (build a markdown vault in the shared drive), chat.send (delegate to teammates), \
routine.schedule (self-schedule).";
#[derive(Deserialize)]
pub struct PlannerMessage {
pub role: String,
pub content: String,
}
#[derive(Deserialize)]
pub struct PlannerChatRequest {
pub messages: Vec<PlannerMessage>,
/// Deploy mode: specialists | swarm | scheduled | triggered (default specialists).
#[serde(default)]
pub mode: String,
}
const SPECIALISTS_NOTE: &str = "\n\nMODE: Specialists. Each member is a DOMAIN SPECIALIST — set each member's \
brain_query to a concrete domain brain keyword (e.g. 'rust-2024', 'react-native', 'pentest-web', 'db-ops').";
const SCHEDULED_NOTE: &str = "\n\nMODE: Scheduled. Include a schedule in the proposal. Recurring: \
{\"cron\":\"<5-field>\",\"prompt\":\"<mission run each cycle>\"}. One-time: \
{\"one_shot_at\":\"<RFC3339 UTC datetime>\",\"prompt\":\"<mission>\"}.";
const TRIGGERED_NOTE: &str = "\n\nMODE: Triggered. The team will be fired by a webhook on demand. Set the \
schedule field to {\"prompt\":\"<the default task the webhook runs>\"} (NO cron / one_shot_at).";
const SWARM_SYSTEM: &str = "You are the planner for a self-verifying agent SWARM (Opus plans + verifies, a worker \
swarm executes, the loop repeats until every output passes). The user describes a job; you turn it into a swarm \
spec. The CHECKLIST is the verification contract — each item must be objectively checkable per task (e.g. 'states \
a revenue figure', 'cites a resolvable source URL', 'no field left empty'). Have a brief conversation, then emit \
the spec. ALWAYS respond with STRICT JSON ONLY: {\"reply\":\"<concise message>\",\"swarm\":null|{\"goal\":\"<the \
decomposable job>\",\"checklist\":[\"...\",\"...\"],\"task_count\":<int>,\"worker_model\":\"auto\"}}. Set swarm to \
null while still clarifying; include it once the job + checklist are concrete.";
fn planner_system_for(mode: &str) -> String {
match mode {
"swarm" => SWARM_SYSTEM.to_string(),
"scheduled" => format!("{PLANNER_SYSTEM}{SCHEDULED_NOTE}"),
"triggered" => format!("{PLANNER_SYSTEM}{TRIGGERED_NOTE}"),
_ => format!("{PLANNER_SYSTEM}{SPECIALISTS_NOTE}"),
}
}
/// `POST /api/planner/chat` — one planner turn (SSE: thinking → done{reply,proposal}).
pub async fn planner_chat(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<PlannerChatRequest>,
) -> impl axum::response::IntoResponse {
let runtime = state.runtime.clone();
let pool = state.pool.clone();
let ws = user.workspace_id;
let stream = async_stream::stream! {
yield sse(json!({"stage":"thinking","label":"Planning with Claude Opus 4.8…"}));
let agents = cm_db::repo::agents::roster(&pool, ws).await.unwrap_or_default();
let teams = cm_db::repo::teams::list_for_workspace(&pool, ws, 100).await.unwrap_or_default();
let agent_names: Vec<String> = agents.iter().take(40).map(|a| format!("{} ({})", a.name, a.job_title)).collect();
let team_names: Vec<String> = teams.iter().take(40).map(|t| t.name.clone()).collect();
let hierarchy = format!(
"CURRENT WORKSPACE: {} agents, {} teams.\nExisting agents: {}\nExisting teams: {}",
agents.len(), teams.len(),
if agent_names.is_empty() { "(none)".to_string() } else { agent_names.join(", ") },
if team_names.is_empty() { "(none)".to_string() } else { team_names.join(", ") },
);
let convo = body.messages.iter()
.map(|m| format!("{}: {}", if m.role == "user" { "USER" } else { "PLANNER" }, m.content))
.collect::<Vec<_>>().join("\n\n");
let user_prompt = format!("{hierarchy}\n\n=== CONVERSATION ===\n{convo}\n\nRespond now (JSON only).");
let system = planner_system_for(&body.mode);
let raw = match runtime.complete(&system, &user_prompt, "claude-opus-4-8", 8000, true).await {
Ok(t) => t,
Err(e) => { yield sse(json!({"stage":"error","label":format!("Opus error: {e}")})); return; }
};
match extract_json(&raw) {
Some(v) => {
let reply = v.get("reply").and_then(|x| x.as_str()).unwrap_or("").to_string();
let proposal = v.get("proposal").cloned().unwrap_or(Value::Null);
let swarm = v.get("swarm").cloned().unwrap_or(Value::Null);
yield sse(json!({"stage":"done","reply":reply,"proposal":proposal,"swarm":swarm}));
}
None => yield sse(json!({"stage":"done","reply":raw,"proposal":Value::Null,"swarm":Value::Null})),
}
};
Sse::new(Box::pin(stream)).keep_alive(KeepAlive::new())
}
#[derive(Deserialize)]
pub struct ScaffoldMember {
pub name: String,
pub role: String,
#[serde(default)]
pub model: String,
#[serde(default)]
pub brain_query: String,
#[serde(default)]
pub system_prompt: String,
}
#[derive(Deserialize)]
pub struct ScaffoldSchedule {
#[serde(default)]
pub cron: String,
/// One-shot fire time (RFC3339 UTC). When set, takes precedence over `cron`.
#[serde(default)]
pub one_shot_at: String,
pub prompt: String,
}
#[derive(Deserialize)]
pub struct ScaffoldRequest {
pub team_name: String,
#[serde(default = "default_kind")]
pub topology_kind: String,
#[serde(default)]
pub schedule: Option<ScaffoldSchedule>,
pub members: Vec<ScaffoldMember>,
}
fn default_kind() -> String {
"hub_spoke".to_string()
}
/// `POST /api/planner/scaffold` — build the approved team (SSE progress): create
/// agents + topology, refine+attach a brain per agent, set up the nightly loop.
pub async fn planner_scaffold(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<ScaffoldRequest>,
) -> impl axum::response::IntoResponse {
let runtime = state.runtime.clone();
let stream = async_stream::stream! {
if body.members.is_empty() {
yield sse(json!({"stage":"error","pct":100,"label":"Empty proposal"}));
return;
}
let n = body.members.len().max(1);
yield sse(json!({"stage":"team","pct":8,"label":format!("Creating team “{}” ({} agents)…", body.team_name, n)}));
let members: Vec<TeamMemberInput> = body.members.iter().map(|m| TeamMemberInput {
role: m.role.clone(),
name: m.name.clone(),
model: if m.model.trim().is_empty() { "claude".to_string() } else { m.model.clone() },
system_prompt: m.system_prompt.clone(),
accent: String::new(),
}).collect();
let (team_id, claw_ids) = match build_team(&state, user.workspace_id, user.user_id, &body.team_name, &body.topology_kind, &members).await {
Ok(r) => r,
Err(_) => { yield sse(json!({"stage":"error","pct":100,"label":"Team creation failed"})); return; }
};
for (i, (cid, m)) in claw_ids.iter().zip(body.members.iter()).enumerate() {
let pct = 15 + (i as u32) * 70 / (n as u32);
yield sse(json!({"stage":"brain","pct":pct,"label":format!("Refining brain for {} ({})…", m.name, m.role)}));
let q = if m.brain_query.trim().is_empty() { m.role.clone() } else { m.brain_query.clone() };
let found = cm_brain::hub::list(&q).await.unwrap_or_default().into_iter().next().map(|b| b.reference);
if let Some(reference) = found {
let role_ctx = format!("Agent '{}', role '{}', on team '{}'. Mission: {}", m.name, m.role, body.team_name, m.system_prompt);
let refined = enhance_and_publish(&runtime, &reference, &role_ctx).await.unwrap_or(reference);
let id = cm_domain::AgentId::from(*cid);
let _ = apply_reference_to_claw(&state, id, &refined).await;
}
}
if let Some(sch) = &body.schedule {
// A `topology` action fires the whole team's stored graph as a durable
// run (not just one message to the lead).
if let Some(cid) = claw_ids.first() {
let id = cm_domain::AgentId::from(*cid);
let one_shot_at = sch.one_shot_at.trim();
if !one_shot_at.is_empty() {
// One-shot: fire once at the given datetime, never reschedule.
if let Ok(when) = time::OffsetDateTime::parse(one_shot_at, &time::format_description::well_known::Rfc3339) {
yield sse(json!({"stage":"routine","pct":92,"label":"Scheduling the one-time run…"}));
let action = json!({"topology": {"team_id": team_id.to_string(), "task": sch.prompt}, "one_shot": true});
let _ = cm_db::repo::routines::create(&state.pool, id, "Scheduled run", "0 0 1 1 *", action, when).await;
}
} else if !sch.cron.trim().is_empty() {
if let Ok(next) = cm_scheduler::next_occurrence(&sch.cron, time::OffsetDateTime::now_utc()) {
yield sse(json!({"stage":"routine","pct":92,"label":"Scheduling the recurring team loop…"}));
let action = json!({"topology": {"team_id": team_id.to_string(), "task": sch.prompt}});
let _ = cm_db::repo::routines::create(&state.pool, id, "Scheduled team loop", &sch.cron, action, next).await;
}
}
}
}
yield sse(json!({"stage":"done","pct":100,"label":"Team deployed","team_id":team_id.to_string()}));
};
Sse::new(Box::pin(stream)).keep_alive(KeepAlive::new())
}
+144 -25
View File
@@ -45,52 +45,49 @@ fn parse_kind(s: &str) -> Result<TopologyKind, ApiError> {
serde_json::from_value(Value::String(s.to_string())).map_err(|_| ApiError::BadRequest) serde_json::from_value(Value::String(s.to_string())).map_err(|_| ApiError::BadRequest)
} }
/// `POST /api/teams` — create a team: for each member create a claw + provision a /// Create a team end-to-end: for each member create a claw + provision a runtime
/// runtime agent, build the baseline topology, bind node→claw, persist. /// agent, build the baseline topology, bind node→claw, persist. Returns the team
pub async fn create_team( /// id + the created claw ids (in member order) so callers (e.g. the Master
State(state): State<AppState>, /// Planner scaffold) can attach brains afterward.
Authed(user): Authed, pub(crate) async fn build_team(
Json(body): Json<CreateTeamRequest>, state: &AppState,
) -> Result<(StatusCode, Json<TeamCreated>), ApiError> { workspace_id: cm_domain::WorkspaceId,
if body.members.is_empty() { user_id: cm_domain::UserId,
name: &str,
kind_str: &str,
members: &[TeamMemberInput],
) -> Result<(Uuid, Vec<Uuid>), ApiError> {
if members.is_empty() {
return Err(ApiError::BadRequest); return Err(ApiError::BadRequest);
} }
let kind = parse_kind(&body.kind)?; let kind = parse_kind(kind_str)?;
let provisioner = RuntimeProvisioner::from_env().ok_or(ApiError::Internal)?; let provisioner = RuntimeProvisioner::from_env().ok_or(ApiError::Internal)?;
// 1. Create each claw (DB row) + provision it as a live runtime agent. let mut claw_ids: Vec<Uuid> = Vec::with_capacity(members.len());
let mut claw_ids: Vec<Uuid> = Vec::with_capacity(body.members.len()); for m in members {
for m in &body.members {
let agent = Agent { let agent = Agent {
id: AgentId::new(), id: AgentId::new(),
workspace_id: user.workspace_id, workspace_id,
name: m.name.clone(), name: m.name.clone(),
job_title: m.role.clone(), job_title: m.role.clone(),
system_prompt: m.system_prompt.clone(), system_prompt: m.system_prompt.clone(),
avatar: String::new(), avatar: String::new(),
accent: m.accent.clone(), accent: m.accent.clone(),
wallpaper: String::new(), wallpaper: String::new(),
managed_by: user.user_id, managed_by: user_id,
status: AgentStatus::Online, status: AgentStatus::Online,
}; };
cm_db::repo::agents::insert(&state.pool, &agent, &AccessPolicy::default()).await?; cm_db::repo::agents::insert(&state.pool, &agent, &AccessPolicy::default()).await?;
let claw_id = agent.id.as_uuid(); let claw_id = agent.id.as_uuid();
// Provisioning failure rolls the team back at the runtime layer is best- provisioner.provision_claw(claw_id, &m.model).await.map_err(|e| {
// effort; the claw row stays (visible in the roster) so nothing is lost.
provisioner
.provision_claw(claw_id, &m.model)
.await
.map_err(|e| {
eprintln!("teams: provision claw {claw_id} failed: {e}"); eprintln!("teams: provision claw {claw_id} failed: {e}");
ApiError::Internal ApiError::Internal
})?; })?;
// Persist the model so the claw card / anatomy can show it later.
cm_db::repo::agents::set_model_binding(&state.pool, agent.id, &m.model).await?; cm_db::repo::agents::set_model_binding(&state.pool, agent.id, &m.model).await?;
claw_ids.push(claw_id); claw_ids.push(claw_id);
} }
// 2. Build the baseline topology and bind each node to its claw's runtime alias. let roles: Vec<&str> = members.iter().map(|m| m.role.as_str()).collect();
let roles: Vec<&str> = body.members.iter().map(|m| m.role.as_str()).collect();
let mut graph = build(kind, &roles).map_err(|_| ApiError::BadRequest)?; let mut graph = build(kind, &roles).map_err(|_| ApiError::BadRequest)?;
for (i, node) in graph.nodes.iter_mut().enumerate() { for (i, node) in graph.nodes.iter_mut().enumerate() {
if let Some(cid) = claw_ids.get(i) { if let Some(cid) = claw_ids.get(i) {
@@ -99,7 +96,78 @@ pub async fn create_team(
} }
} }
// 3. Persist team + node→claw bindings. let team_id = Uuid::now_v7();
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
cm_db::repo::teams::insert_team(&state.pool, team_id, workspace_id, name, kind.as_str(), &graph_json).await?;
for (i, node) in graph.nodes.iter().enumerate() {
if let Some(cid) = claw_ids.get(i) {
cm_db::repo::teams::add_member(&state.pool, team_id, &node.id, *cid, &node.role).await?;
}
}
Ok((team_id, claw_ids))
}
/// `POST /api/teams` — create a team from explicit members.
pub async fn create_team(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<CreateTeamRequest>,
) -> Result<(StatusCode, Json<TeamCreated>), ApiError> {
let (team_id, _) = build_team(
&state,
user.workspace_id,
user.user_id,
&body.name,
&body.kind,
&body.members,
)
.await?;
Ok((StatusCode::CREATED, Json(TeamCreated { team_id: team_id.to_string() })))
}
#[derive(Deserialize)]
pub struct ComposeTeamRequest {
pub name: String,
/// TopologyKind (snake_case); defaults to `hub_spoke` when omitted.
#[serde(default)]
pub kind: String,
/// Existing claws (agents) to group into the new team.
pub claw_ids: Vec<Uuid>,
}
/// `POST /api/teams/from-claws` — create a team from EXISTING claws (no new
/// provisioning): verify each claw is in the caller's workspace, build the
/// baseline topology over their roles, bind node→claw, persist.
pub async fn create_team_from_claws(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<ComposeTeamRequest>,
) -> Result<(StatusCode, Json<TeamCreated>), ApiError> {
if body.claw_ids.is_empty() {
return Err(ApiError::BadRequest);
}
let kind = parse_kind(if body.kind.is_empty() { "hub_spoke" } else { &body.kind })?;
// Resolve + authorize each claw, collecting its role for the topology.
let mut roles: Vec<String> = Vec::with_capacity(body.claw_ids.len());
for cid in &body.claw_ids {
let agent = crate::routes::claws::workspace_agent(&state, &user, AgentId::from(*cid)).await?;
roles.push(if agent.job_title.is_empty() {
"claw".into()
} else {
agent.job_title
});
}
let role_refs: Vec<&str> = roles.iter().map(|s| s.as_str()).collect();
let mut graph = build(kind, &role_refs).map_err(|_| ApiError::BadRequest)?;
for (i, node) in graph.nodes.iter_mut().enumerate() {
if let Some(cid) = body.claw_ids.get(i) {
node.attrs.insert("agent".into(), claw_alias(*cid));
node.attrs.insert("claw_id".into(), cid.to_string());
}
}
let team_id = Uuid::now_v7(); let team_id = Uuid::now_v7();
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?; let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
cm_db::repo::teams::insert_team( cm_db::repo::teams::insert_team(
@@ -112,7 +180,7 @@ pub async fn create_team(
) )
.await?; .await?;
for (i, node) in graph.nodes.iter().enumerate() { for (i, node) in graph.nodes.iter().enumerate() {
if let Some(cid) = claw_ids.get(i) { if let Some(cid) = body.claw_ids.get(i) {
cm_db::repo::teams::add_member(&state.pool, team_id, &node.id, *cid, &node.role) cm_db::repo::teams::add_member(&state.pool, team_id, &node.id, *cid, &node.role)
.await?; .await?;
} }
@@ -198,6 +266,57 @@ pub async fn get_team(
})) }))
} }
#[derive(Deserialize)]
pub struct PatchTeamRequest {
/// New TopologyKind (snake_case), e.g. "hierarchical", "hub_spoke".
pub kind: String,
}
/// `PATCH /api/teams/{id}` — change a team's topology: rebuild the graph over the
/// existing members' roles (stable node ids keep the node→claw bindings valid)
/// and persist the new kind + graph.
pub async fn patch_team(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<PatchTeamRequest>,
) -> Result<StatusCode, ApiError> {
let team = cm_db::repo::teams::get_team(&state.pool, id, user.workspace_id).await?;
let kind = parse_kind(&body.kind)?;
let members = cm_db::repo::teams::members_for_team(&state.pool, team.id).await?;
let by_node: std::collections::HashMap<String, (Uuid, String)> = members
.into_iter()
.map(|m| (m.node_id, (m.claw_id, m.role)))
.collect();
let n = by_node.len();
let roles: Vec<String> = (0..n)
.map(|i| by_node.get(&format!("n{i}")).map(|(_, r)| r.clone()).unwrap_or_else(|| "claw".into()))
.collect();
let role_refs: Vec<&str> = roles.iter().map(String::as_str).collect();
let mut graph = build(kind, &role_refs).map_err(|_| ApiError::BadRequest)?;
for node in graph.nodes.iter_mut() {
if let Some((cid, _)) = by_node.get(&node.id) {
node.attrs.insert("agent".into(), claw_alias(*cid));
node.attrs.insert("claw_id".into(), cid.to_string());
}
}
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
cm_db::repo::teams::set_topology(&state.pool, team.id, user.workspace_id, kind.as_str(), &graph_json).await?;
Ok(StatusCode::NO_CONTENT)
}
/// `DELETE /api/teams/{id}` — remove a team and its node→claw bindings (the claws
/// themselves remain in the workspace).
pub async fn delete_team(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
cm_db::repo::teams::delete_team(&state.pool, id, user.workspace_id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct RunTeamRequest { pub struct RunTeamRequest {
pub task: String, pub task: String,
+37
View File
@@ -168,6 +168,43 @@ pub async fn run_topology(
)) ))
} }
/// `POST /api/swarm/run` — ENQUEUE a self-verifying swarm run (tier `swarm`): Opus
/// plans tasks → a worker swarm executes → Opus verifies each against the checklist
/// → failures requeue → loop until clean. Streams into the Runs view like any run.
#[derive(serde::Deserialize)]
pub struct SwarmRunRequest {
pub goal: String,
#[serde(default)]
pub checklist: Vec<String>,
#[serde(default)]
pub task_count: Option<usize>,
#[serde(default)]
pub worker_model: String,
}
pub async fn run_swarm(
State(state): State<AppState>,
Authed(user): Authed,
Json(req): Json<SwarmRunRequest>,
) -> Result<(StatusCode, Json<RunAccepted>), ApiError> {
if req.goal.trim().is_empty() {
return Err(ApiError::BadRequest);
}
let id = Uuid::now_v7();
let config = serde_json::json!({
"goal": req.goal,
"checklist": req.checklist,
"task_count": req.task_count,
"worker_model": req.worker_model,
});
cm_db::repo::topology_runs::enqueue_run_tier(&state.pool, id, user.workspace_id, &req.goal, &config, "swarm")
.await?;
Ok((
StatusCode::ACCEPTED,
Json(RunAccepted { run_id: id.to_string(), status: "queued".into() }),
))
}
/// A saved/queued run, summarized (now includes lifecycle status + kind). /// A saved/queued run, summarized (now includes lifecycle status + kind).
#[derive(Serialize)] #[derive(Serialize)]
pub struct RunSummary { pub struct RunSummary {
+122
View File
@@ -0,0 +1,122 @@
//! Inbound webhook triggers (Triggered deploy mode). A team gets a token whose
//! public URL, when POSTed, enqueues the team's topology run — reusing the same
//! durable-run path as the scheduler and the UI "Run team" button.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::{ApiError, AppState, Authed};
/// `POST /api/teams/{id}/webhooks` — mint a webhook token for the team.
pub async fn create_webhook(
State(state): State<AppState>,
Authed(user): Authed,
Path(team_id): Path<Uuid>,
body: Option<Json<CreateBody>>,
) -> Result<Json<Value>, ApiError> {
// Scope check: the team must belong to the caller's workspace.
cm_db::repo::teams::get_team(&state.pool, team_id, user.workspace_id).await?;
let default_task = body.map(|b| b.0.task).unwrap_or_default();
let id = Uuid::now_v7();
let token = Uuid::now_v7();
sqlx::query("INSERT INTO webhook_tokens (id, workspace_id, team_id, token, task) VALUES ($1, $2, $3, $4, $5)")
.bind(id)
.bind(user.workspace_id.as_uuid())
.bind(team_id)
.bind(token)
.bind(&default_task)
.execute(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
Ok(Json(json!({ "token": token.to_string(), "url": format!("/api/hooks/{token}") })))
}
#[derive(Deserialize, Default)]
pub struct CreateBody {
#[serde(default)]
pub task: String,
}
/// `GET /api/teams/{id}/webhooks` — list the team's webhook tokens.
pub async fn list_webhooks(
State(state): State<AppState>,
Authed(user): Authed,
Path(team_id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
cm_db::repo::teams::get_team(&state.pool, team_id, user.workspace_id).await?;
let rows = sqlx::query_as::<_, (Uuid,)>(
"SELECT token FROM webhook_tokens WHERE team_id = $1 ORDER BY created_at DESC",
)
.bind(team_id)
.fetch_all(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
let hooks: Vec<Value> = rows
.into_iter()
.map(|(t,)| json!({ "token": t.to_string(), "url": format!("/api/hooks/{t}") }))
.collect();
Ok(Json(json!({ "webhooks": hooks })))
}
#[derive(Deserialize, Default)]
struct HookBody {
#[serde(default)]
task: String,
}
/// `POST /api/hooks/{token}` — PUBLIC. Fire the bound team's topology run. Body
/// `{task?}` overrides the team's default task. Mirrors the Slack/Stripe inbound
/// pattern (no session auth; the unguessable token is the credential).
pub async fn trigger_hook(
State(state): State<AppState>,
Path(token): Path<Uuid>,
body: axum::body::Bytes,
) -> StatusCode {
let row = sqlx::query_as::<_, (Uuid, Uuid, String)>(
"SELECT workspace_id, team_id, task FROM webhook_tokens WHERE token = $1",
)
.bind(token)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
let Some((ws_id, team_id, default_task)) = row else {
return StatusCode::NOT_FOUND;
};
let ws = cm_domain::WorkspaceId::from(ws_id);
let Ok(team) = cm_db::repo::teams::get_team(&state.pool, team_id, ws).await else {
return StatusCode::NOT_FOUND;
};
let task = serde_json::from_slice::<HookBody>(&body)
.ok()
.map(|b| b.task)
.filter(|t| !t.trim().is_empty())
.or_else(|| (!default_task.trim().is_empty()).then_some(default_task))
.unwrap_or_else(|| "webhook trigger".to_string());
let run_id = Uuid::now_v7();
if cm_db::repo::topology_runs::enqueue_run(&state.pool, run_id, ws, &task, &team.graph)
.await
.is_err()
{
return StatusCode::INTERNAL_SERVER_ERROR;
}
let _ = sqlx::query("UPDATE webhook_tokens SET last_fired_at = now() WHERE token = $1")
.bind(token)
.execute(&state.pool)
.await;
let _ = cm_db::repo::audit::append(
&state.pool,
ws,
cm_db::repo::audit::Actor::System,
"topology.webhook_triggered",
"webhook",
&token.to_string(),
json!({ "team_id": team_id, "run_id": run_id }),
)
.await;
StatusCode::ACCEPTED
}
+169
View File
@@ -0,0 +1,169 @@
//! The self-verifying swarm loop: Opus 4.8 decomposes a GOAL into worker tasks →
//! a worker swarm executes each (grounded with web search) → Opus verifies each
//! output against the per-task checklist (and that cited URLs resolve) → rejected
//! tasks requeue with the reason → loop until nothing fails (or MAX_PASSES).
//!
//! It runs as a durable run (tier `swarm`) so it streams into the Runs view and
//! survives restarts. Each worker output and each verdict is journaled as a
//! `StepRecord` in the checkpoint, which `run_events_sse` emits as `step` events.
use cm_orchestrator::{RunMetrics, RunProgress, RunRecord, StepPhase, StepRecord};
use cm_runtime::Runtime;
use cm_topology::TopologyKind;
use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
use crate::routes::claws::extract_json;
/// The swarm config (stored in the durable run's `graph` JSONB).
#[derive(Deserialize, Default)]
pub struct SwarmJob {
pub goal: String,
#[serde(default)]
pub checklist: Vec<String>,
#[serde(default)]
pub task_count: Option<usize>,
#[serde(default)]
pub worker_model: String,
}
const MAX_PASSES: usize = 3;
const PLAN_SYSTEM: &str = "You are the planner for a self-verifying agent swarm. Decompose the GOAL into a list \
of independent, concrete worker tasks — one unit of work each (e.g. one company, one file, one question). Each \
task must be self-contained and instruct the worker to cite resolvable source URLs. Respond with STRICT JSON \
ONLY: {\"tasks\":[\"task 1\",\"task 2\", ...]}. Aim for the requested count if one is given, else pick a sensible number.";
fn checklist_lines(checklist: &[String]) -> String {
checklist.iter().map(|c| format!("- {c}")).collect::<Vec<_>>().join("\n")
}
fn worker_system(checklist: &[String]) -> String {
format!(
"You are a worker in a research swarm. Complete the TASK precisely and concisely. Every factual claim \
MUST include a resolvable source URL (use web search to find real sources). Your output will be verified against \
this checklist — satisfy ALL of it:\n{}",
checklist_lines(checklist)
)
}
fn verify_system(checklist: &[String]) -> String {
format!(
"You are a STRICT verifier. Check the worker OUTPUT for its TASK against this checklist:\n{}\n\nAlso \
confirm any cited source URLs are real/resolvable and use web search to spot-check the key claims. If ANYTHING \
fails, reject it. Respond with STRICT JSON ONLY: {{\"passed\": true|false, \"reason\": \"one concise sentence\"}}.",
checklist_lines(checklist)
)
}
/// Resolve a worker-model spec to something the runtime can call. "auto"/unknown
/// → cheap, always-available Claude Haiku (workers are cheap; Opus verifies).
/// An explicit `name:model` (e.g. `kimi:kimi-k2.6`) is honored as-is.
fn resolve_worker_model(requested: &str) -> String {
let r = requested.trim();
if r.is_empty() || r.eq_ignore_ascii_case("auto") {
"claude-haiku-4-5-20251001".to_string()
} else {
r.to_string()
}
}
fn step(node_id: impl Into<String>, role: impl Into<String>, phase: StepPhase, output: impl Into<String>) -> StepRecord {
StepRecord { node_id: node_id.into(), role: role.into(), phase, output: output.into(), gated: Vec::new(), tokens: 0 }
}
async fn ckpt(pool: &PgPool, id: Uuid, records: &[StepRecord], totals: &RunMetrics) {
let prog = RunProgress { completed: records.len(), outputs: Vec::new(), records: records.to_vec(), totals: *totals };
if let Ok(v) = serde_json::to_value(&prog) {
let _ = cm_db::repo::topology_runs::checkpoint(pool, id, &v, records.len() as i64).await;
}
}
/// Run a swarm job to completion, journaling every worker output + verdict.
pub async fn run_swarm_job(pool: &PgPool, runtime: &Runtime, id: Uuid, job: SwarmJob, goal: &str) -> Result<RunRecord, String> {
let mut records: Vec<StepRecord> = Vec::new();
let totals = RunMetrics::default();
let checklist = if job.checklist.is_empty() {
vec!["output is accurate and complete".to_string(), "every claim cites a resolvable source URL".to_string()]
} else {
job.checklist.clone()
};
let worker_model = resolve_worker_model(&job.worker_model);
// 1) PLAN — Opus decomposes the goal into worker tasks.
records.push(step("planner", "planner:opus", StepPhase::Plan, format!("Planning tasks for: {goal}")));
ckpt(pool, id, &records, &totals).await;
let want = job.task_count.map(|n| format!("\n\nDesired number of tasks: {n}.")).unwrap_or_default();
let plan_user = format!("GOAL:\n{goal}\n\nCHECKLIST each task's output must satisfy:\n{}{want}", checklist_lines(&checklist));
let plan_raw = runtime.complete(PLAN_SYSTEM, &plan_user, "claude-opus-4-8", 4000, false).await?;
let tasks: Vec<String> = extract_json(&plan_raw)
.and_then(|v| v.get("tasks").and_then(|t| t.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()))
.unwrap_or_default();
if tasks.is_empty() {
return Err("planner produced no tasks".to_string());
}
records.push(step("planner", "planner:opus", StepPhase::Plan,
format!("Decomposed into {} tasks. Workers: {worker_model}. Verifier: claude-opus-4-8.", tasks.len())));
ckpt(pool, id, &records, &totals).await;
// 2) LOOP — run pending tasks, verify each, requeue failures until clean.
let mut pending: Vec<(usize, String)> = tasks.iter().cloned().enumerate().collect();
let mut results: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
let wsys = worker_system(&checklist);
let vsys = verify_system(&checklist);
for pass in 1..=MAX_PASSES {
if pending.is_empty() {
break;
}
let count = pending.len();
let mut still: Vec<(usize, String)> = Vec::new();
let mut rejected = 0usize;
for (idx, task) in pending.iter() {
let out = runtime
.complete(&wsys, task, &worker_model, 4000, true)
.await
.unwrap_or_else(|e| format!("worker error: {e}"));
records.push(step(format!("task-{idx}"), format!("worker:{worker_model}"), StepPhase::Work, out.clone()));
ckpt(pool, id, &records, &totals).await;
let vuser = format!("TASK:\n{task}\n\nWORKER OUTPUT:\n{out}");
let v_raw = runtime.complete(&vsys, &vuser, "claude-opus-4-8", 1200, true).await.unwrap_or_default();
let v = extract_json(&v_raw);
let passed = v.as_ref().and_then(|x| x.get("passed").and_then(|p| p.as_bool())).unwrap_or(false);
let reason = v
.as_ref()
.and_then(|x| x.get("reason").and_then(|r| r.as_str()))
.unwrap_or("no verifier response")
.to_string();
records.push(step(format!("verify-{idx}"), "verifier:opus", StepPhase::Aggregate,
format!("{} — {reason}", if passed { "✓ PASS" } else { "✗ REJECT" })));
ckpt(pool, id, &records, &totals).await;
if passed {
results.insert(*idx, out);
} else {
rejected += 1;
still.push((*idx, format!("{task}\n\n(Your previous attempt was REJECTED: {reason}. Correct it.)")));
}
}
records.push(step("verifier", "verifier:opus", StepPhase::Aggregate,
format!("Verify pass {pass}: checked {count}, rejected {rejected}.")));
ckpt(pool, id, &records, &totals).await;
pending = still;
}
// 3) Assemble the report.
let mut report = format!("# Swarm result — {goal}\n\n{} of {} tasks verified clean.\n", results.len(), tasks.len());
for (idx, task) in tasks.iter().enumerate() {
report.push_str(&format!("\n## Task {}\n", idx + 1));
match results.get(&idx) {
Some(out) => report.push_str(out),
None => report.push_str(&format!("(unresolved after {MAX_PASSES} passes)\n{task}")),
}
report.push('\n');
}
Ok(RunRecord { kind: TopologyKind::Swarm, steps: records, final_output: report, totals })
}
+23 -3
View File
@@ -27,7 +27,7 @@ const STALE_AFTER_SECS: f64 = 180.0;
/// Spawn the durable topology job worker. Polls for queued jobs every `poll` /// Spawn the durable topology job worker. Polls for queued jobs every `poll`
/// interval; runs each to completion (or failure), checkpointing per step. /// interval; runs each to completion (or failure), checkpointing per step.
pub fn spawn(pool: PgPool, poll: Duration) { pub fn spawn(pool: PgPool, runtime: cm_runtime::Runtime, poll: Duration) {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
// Recover jobs orphaned by a dead worker before claiming new ones. // Recover jobs orphaned by a dead worker before claiming new ones.
@@ -36,7 +36,7 @@ pub fn spawn(pool: PgPool, poll: Duration) {
eprintln!("topology_worker: requeue_stale failed: {e}"); eprintln!("topology_worker: requeue_stale failed: {e}");
} }
match cm_db::repo::topology_runs::claim_next_queued(&pool).await { match cm_db::repo::topology_runs::claim_next_queued(&pool).await {
Ok(Some(job)) => run_job(&pool, job).await, Ok(Some(job)) => run_job(&pool, &runtime, job).await,
Ok(None) => tokio::time::sleep(poll).await, Ok(None) => tokio::time::sleep(poll).await,
Err(e) => { Err(e) => {
eprintln!("topology_worker: claim failed: {e}"); eprintln!("topology_worker: claim failed: {e}");
@@ -48,9 +48,29 @@ pub fn spawn(pool: PgPool, poll: Duration) {
} }
/// Drive one claimed job to a terminal state, persisting checkpoints as it goes. /// Drive one claimed job to a terminal state, persisting checkpoints as it goes.
async fn run_job(pool: &PgPool, job: cm_db::repo::topology_runs::ClaimedTopologyRun) { async fn run_job(pool: &PgPool, runtime: &cm_runtime::Runtime, job: cm_db::repo::topology_runs::ClaimedTopologyRun) {
let id = job.id; let id = job.id;
// Swarm runs aren't graph topologies — the `graph` JSONB holds the swarm
// config. Branch before the graph parse and run the self-verifying loop.
if job.tier == "swarm" {
let cfg = job.graph.clone().unwrap_or(serde_json::Value::Null);
let swarm_job: crate::swarm::SwarmJob = serde_json::from_value(cfg).unwrap_or_default();
let result = crate::swarm::run_swarm_job(pool, runtime, id, swarm_job, &job.task).await;
match result {
Ok(record) => {
let value = serde_json::to_value(&record).unwrap_or(serde_json::Value::Null);
if let Err(e) = cm_db::repo::topology_runs::complete(pool, id, &value).await {
eprintln!("topology_worker: swarm complete({id}) failed: {e}");
}
}
Err(e) => {
let _ = cm_db::repo::topology_runs::fail(pool, id, &e).await;
}
}
return;
}
let Some(graph) = job let Some(graph) = job
.graph .graph
.as_ref() .as_ref()
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "cm-brain"
version = "0.1.0"
edition = "2021"
rust-version = "1.96"
license = "UNLICENSED"
publish = false
# The portable per-claw ".brain": one ClawhDF5 file holding the agent's
# definition (system prompt, personality, skills, tools, runtime, provenance)
# and its memory. Backed by the local clawhdf5 repo (git.redclaw.dev mirror of
# ~/projects/clawhdf5), pinned to a commit so the server's Docker build can
# fetch it anonymously without a sibling-path checkout.
[dependencies]
# The canonical brain-pack format + retrieval + ClawBrainHub registry client +
# (behind the `sync` feature) ClawSync. cm-brain is a thin ClawMates-shaped
# facade over it. Pinned to a clawverse commit so the Docker build fetches it
# anonymously over Gitea (needs CARGO_NET_GIT_FETCH_WITH_CLI + git/cmake/make).
claw-brain = { git = "https://git.redclaw.dev/clawverse/clawverse.git", rev = "0ee183acc1600aba01546bd648bf3cae6f42dcc2", package = "claw-brain", features = ["sync"] }
claw-core = { git = "https://git.redclaw.dev/clawverse/clawverse.git", rev = "0ee183acc1600aba01546bd648bf3cae6f42dcc2", package = "claw-core" }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
futures = "0.3"
[dev-dependencies]
tokio = { workspace = true }
+480
View File
@@ -0,0 +1,480 @@
//! ClawBrainHub pull/push, over `claw-brain`'s production registry client.
//!
//! Pull works anonymously for public brains (uses `BRAINHUB_API_KEY` when set);
//! push requires the key. Brains arrive either as the canonical HDF5 brain-pack
//! or as a JSON export (the public reference brains are unsigned JSON) — pull
//! detects which and normalizes JSON into a brain-pack `.brain` on disk.
use std::path::Path;
use claw_brain::{parse_brain_ref, BrainRegistryClient, BrainRegistryConfig};
use claw_core::prelude::BrainRef;
use serde::Deserialize;
use crate::{be, BrainError, ClawBrain};
const DEFAULT_BASE: &str = "https://clawbrainhub.com/api/v1";
fn config() -> BrainRegistryConfig {
BrainRegistryConfig {
base_url: std::env::var("BRAINHUB_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE.to_string()),
api_key: std::env::var("BRAINHUB_API_KEY").unwrap_or_default(),
timeout_secs: 30,
// ClawMates runs pulled claws sandboxed and re-scans itself, so it does
// not gate pulls on the hub's trust score or signature presence.
min_trust_score: 0.0,
require_signature: false,
trusted_publishers: vec![],
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PulledMeta {
pub reference: String,
pub size_bytes: u64,
pub trust_score: f32,
pub description: String,
}
#[derive(Debug, Clone)]
pub struct PulledBrain {
pub meta: PulledMeta,
/// Suggested claw name (from the brain meta or the ref).
pub name: String,
pub system_prompt: String,
}
/// Pull `owner/name[:version]` and normalize it into a brain-pack `.brain` at
/// `dest`. Returns metadata + the extracted identity for claw creation.
pub async fn pull(reference: &str, dest: &Path) -> Result<PulledBrain, BrainError> {
if let Some(d) = dest.parent() {
std::fs::create_dir_all(d).map_err(be)?;
}
let tmp = dest.with_extension("pull-tmp");
let client = BrainRegistryClient::new(config());
let entry = client
.fetch_metadata(&BrainRef::from(reference))
.await
.map_err(be)?;
client.download(&entry, &tmp).await.map_err(be)?;
let bytes = std::fs::read(&tmp).map_err(be)?;
let parts = parse_brain_ref(&entry.brain_ref).map_err(be)?;
let meta = PulledMeta {
reference: format!("{}/{}:{}", parts.owner, parts.name, parts.tag),
size_bytes: entry.size_bytes,
trust_score: entry.trust_score,
description: entry.description.clone(),
};
// HDF5 brain-pack → use as-is; JSON export → convert to a brain-pack.
let (name, system_prompt) = if bytes.starts_with(b"\x89HDF") {
let _ = std::fs::remove_file(dest);
std::fs::rename(&tmp, dest).map_err(be)?;
let b = ClawBrain::open_or_create(dest, reference)?;
(parts.name.clone(), b.system_prompt().unwrap_or_default())
} else {
let r = build_from_json(&bytes, dest, reference, &parts.name)?;
let _ = std::fs::remove_file(&tmp);
r
};
Ok(PulledBrain { meta, name, system_prompt })
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BrainListing {
/// `owner/name` (no version) — apply/pull resolve `:latest` themselves.
pub reference: String,
pub owner: String,
pub name: String,
pub version: String,
pub description: String,
pub trust_score: f32,
pub size_bytes: u64,
}
/// List/search registry brains (anonymous OK). The hub's `/search` is
/// keyword‑only with no list‑all, so an **empty query** approximates "browse" by
/// merging a handful of seed‑keyword searches (deduped by reference).
pub async fn list(query: &str) -> Result<Vec<BrainListing>, BrainError> {
let client = BrainRegistryClient::new(config());
let q = query.trim();
let raw = if !q.is_empty() {
search_one(&client, q).await?
} else {
const SEEDS: &[&str] = &[
"assistant", "agent", "code", "react", "data", "research", "write", "general", "support",
];
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for s in SEEDS {
if let Ok(items) = search_one(&client, s).await {
for it in items {
if seen.insert(it.reference.clone()) {
out.push(it);
}
}
}
}
out
};
Ok(filter_loadable(raw).await)
}
/// Drop brains that don't actually load with usable data — corrupt downloads
/// (registry 500) or empty brains — so the registry widget only shows brains a
/// claw can really use. Checks run concurrently.
async fn filter_loadable(raw: Vec<BrainListing>) -> Vec<BrainListing> {
let checks = raw.into_iter().map(|b| async move {
match preview(&b.reference).await {
Ok(pv) if !is_empty_preview(&pv) => Some(b),
_ => None,
}
});
futures::future::join_all(checks)
.await
.into_iter()
.flatten()
.collect()
}
async fn search_one(client: &BrainRegistryClient, q: &str) -> Result<Vec<BrainListing>, BrainError> {
let entries = client.list(Some(q), None, None).await.map_err(be)?;
let mut out = Vec::new();
for e in entries {
if let Ok(p) = parse_brain_ref(&e.brain_ref) {
out.push(BrainListing {
reference: format!("{}/{}", p.owner, p.name),
owner: p.owner,
name: p.name,
version: p.tag,
description: e.description,
trust_score: e.trust_score,
size_bytes: e.size_bytes,
});
}
}
Ok(out)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SectionText {
pub populated: bool,
pub chars: usize,
pub preview: String,
}
/// A read-only overview of a brain's contents (which sections are populated vs
/// empty), for the registry detail slide-out. Pulls + introspects, then discards.
#[derive(Debug, Clone, serde::Serialize)]
pub struct BrainPreview {
pub reference: String,
pub size_bytes: u64,
pub system_prompt: SectionText,
pub agent_md: SectionText,
pub personality: SectionText,
pub skills: Vec<String>,
pub tools: Vec<(String, String)>,
pub memory_count: usize,
pub memory_recent: Vec<String>,
pub runtime: bool,
pub provenance: bool,
}
fn section_text(s: Option<String>) -> SectionText {
let txt = s.unwrap_or_default();
SectionText {
populated: !txt.trim().is_empty(),
chars: txt.chars().count(),
preview: txt.chars().take(240).collect(),
}
}
/// Pull a brain and summarize its sections without applying it to any agent.
pub async fn preview(reference: &str) -> Result<BrainPreview, BrainError> {
let safe: String = reference.chars().map(|c| if c == '/' || c == ':' { '_' } else { c }).collect();
let tmp = std::env::temp_dir().join(format!("cm-brain-preview-{}-{}.brain", std::process::id(), safe));
let _ = std::fs::remove_file(&tmp);
let pulled = pull(reference, &tmp).await?;
let b = ClawBrain::open_or_create(&tmp, reference)?;
let pv = BrainPreview {
reference: pulled.meta.reference,
size_bytes: pulled.meta.size_bytes,
system_prompt: section_text(b.system_prompt()),
agent_md: section_text(b.agent_md()),
personality: section_text(b.personality()),
skills: b.skills().into_iter().map(|(n, _)| n).collect(),
tools: b.tools(),
memory_count: b.memory_count(),
memory_recent: b.recent_memory(4).into_iter().map(|(_, t)| t).collect(),
runtime: b.runtime().map(|s| !s.trim().is_empty()).unwrap_or(false),
provenance: b.provenance().map(|s| !s.trim().is_empty()).unwrap_or(false),
};
let _ = std::fs::remove_file(&tmp);
Ok(pv)
}
/// Pull `reference` and **merge** its contents into the existing brain at
/// `dest_brain` (apply‑to‑existing‑agent): overwrite identity (soul/agent_md/
/// persona), upsert skills + tools, append memory. The returned
/// `PulledBrain.system_prompt` is the merged brain's `assembled_identity()` —
/// the authoritative system prompt the caller should persist.
pub async fn pull_merge(reference: &str, dest_brain: &Path) -> Result<PulledBrain, BrainError> {
let tmp = dest_brain.with_extension("incoming");
let _ = std::fs::remove_file(&tmp);
let mut pulled = pull(reference, &tmp).await?;
let assembled = {
let src = ClawBrain::open_or_create(&tmp, reference)?;
let mut dst = ClawBrain::open_or_create(dest_brain, reference)?;
if let Some(s) = src.system_prompt() { dst.set_system_prompt(&s)?; }
if let Some(a) = src.agent_md() { dst.set_agent_md(&a)?; }
if let Some(p) = src.personality() { dst.set_personality(&p)?; }
for (n, b) in src.skills() { dst.set_skill(&n, &b)?; }
for (n, st) in src.tools() { dst.set_tool(&n, &st)?; }
if let Some(rt) = src.runtime() { let _ = dst.set_runtime(&rt); }
for (_, text) in src.recent_memory(200) { let _ = dst.remember("memory", &text, "brain-apply"); }
dst.assembled_identity()
};
let _ = std::fs::remove_file(&tmp);
if !assembled.trim().is_empty() {
pulled.system_prompt = assembled;
}
Ok(pulled)
}
/// Push a local `.brain` (brain-pack) as `owner/name:version`. Requires
/// `BRAINHUB_API_KEY`.
pub async fn push(
reference: &str,
path: &Path,
description: &str,
tags: &[String],
) -> Result<(), BrainError> {
if std::env::var("BRAINHUB_API_KEY").unwrap_or_default().trim().is_empty() {
return Err(BrainError::Backend(
"BRAINHUB_API_KEY is not set — cannot push to ClawBrainHub".into(),
));
}
let client = BrainRegistryClient::new(config());
client
.push(&BrainRef::from(reference), path, description, tags)
.await
.map_err(be)
}
/// The owner namespace the configured `BRAINHUB_API_KEY` authenticates as
/// (where pushes/enhanced versions land).
pub async fn whoami() -> Result<String, BrainError> {
let client = BrainRegistryClient::new(config());
Ok(client.whoami().await.map_err(be)?.owner)
}
/// Delete a brain (all versions) from the registry. Requires `BRAINHUB_API_KEY`
/// and ownership of the namespace.
pub async fn delete(reference: &str) -> Result<(), BrainError> {
if std::env::var("BRAINHUB_API_KEY").unwrap_or_default().trim().is_empty() {
return Err(BrainError::Backend("BRAINHUB_API_KEY not set".into()));
}
let client = BrainRegistryClient::new(config());
client.delete(&BrainRef::from(reference)).await.map_err(be)
}
/// True when a brain carries no usable content (all sections empty).
pub fn is_empty_preview(pv: &BrainPreview) -> bool {
!pv.system_prompt.populated
&& !pv.agent_md.populated
&& !pv.personality.populated
&& pv.skills.is_empty()
&& pv.tools.is_empty()
&& pv.memory_count == 0
}
// ── JSON export → brain-pack conversion ─────────────────────────────────────
#[derive(Deserialize, Default)]
struct HubExport {
meta: Option<HubMeta>,
identity: Option<HubIdentity>,
skills: Option<HubSkills>,
memory: Option<HubMemory>,
runtime: Option<serde_json::Value>,
}
#[derive(Deserialize, Default)]
struct HubMeta {
brain_name: Option<String>,
}
#[derive(Deserialize, Default)]
struct HubIdentity {
soul_md: Option<String>,
agent_md: Option<String>,
system_prompt: Option<String>,
persona: Option<String>,
}
#[derive(Deserialize, Default)]
struct HubSkills {
skills_md: Option<String>,
}
#[derive(Deserialize, Default)]
struct HubMemory {
entries: Option<Vec<HubMemEntry>>,
}
#[derive(Deserialize, Default)]
struct HubMemEntry {
chunk: Option<String>,
}
fn nonempty(s: Option<String>) -> Option<String> {
s.filter(|t| !t.trim().is_empty())
}
fn build_from_json(
bytes: &[u8],
dest: &Path,
reference: &str,
ref_name: &str,
) -> Result<(String, String), BrainError> {
let exp: HubExport = serde_json::from_slice(bytes).map_err(be)?;
let _ = std::fs::remove_file(dest); // start clean
let mut brain = ClawBrain::open_or_create(dest, reference)?;
let id = exp.identity.unwrap_or_default();
// soul_md is the rich identity; the public brains leave system_prompt empty.
let system_prompt = nonempty(id.system_prompt)
.or_else(|| nonempty(id.soul_md))
.unwrap_or_default();
if !system_prompt.is_empty() {
brain.set_system_prompt(&system_prompt)?;
}
if let Some(a) = nonempty(id.agent_md) {
brain.set_agent_md(&a)?;
}
if let Some(p) = nonempty(id.persona) {
brain.set_personality(&p)?;
}
if let Some(md) = exp.skills.and_then(|s| nonempty(s.skills_md)) {
brain.set_skills_md(&md)?;
}
if let Some(entries) = exp.memory.and_then(|m| m.entries) {
for e in entries {
if let Some(chunk) = nonempty(e.chunk) {
let _ = brain.remember("import", &chunk, "pack-import");
}
}
}
if let Some(rt) = exp.runtime {
let _ = brain.set_runtime(&rt.to_string());
}
let name = exp
.meta
.and_then(|m| nonempty(m.brain_name))
.unwrap_or_else(|| ref_name.to_string());
Ok((name, system_prompt))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[ignore = "hits live clawbrainhub.com"]
async fn live_pull_general_assistant() {
let dest =
std::env::temp_dir().join(format!("cm-brain-pull-{}.brain", std::process::id()));
let _ = std::fs::remove_file(&dest);
let pulled = pull("redclawsystems/general-assistant", &dest)
.await
.expect("pull should succeed anonymously");
assert!(dest.exists(), "brain file written");
assert!(!pulled.system_prompt.is_empty(), "identity extracted");
let b = ClawBrain::open_or_create(&dest, "x").unwrap();
assert!(!b.skills().is_empty(), "skills parsed from skills_md");
eprintln!(
"pulled {} ({} bytes, {} skills, {} memories)",
pulled.meta.reference,
pulled.meta.size_bytes,
b.skills().len(),
b.memory_count()
);
let _ = std::fs::remove_file(&dest);
}
#[tokio::test]
#[ignore = "hits live clawbrainhub.com"]
async fn live_pull_merge_into_existing() {
let dest = std::env::temp_dir().join(format!("cm-brain-merge-{}.brain", std::process::id()));
let _ = std::fs::remove_file(&dest);
{
let mut b = ClawBrain::open_or_create(&dest, "x").unwrap();
b.set_skill("existing", "keep me").unwrap();
}
let pulled = pull_merge("redclawsystems/general-assistant", &dest).await.expect("merge");
assert!(!pulled.system_prompt.is_empty(), "assembled identity returned");
let b = ClawBrain::open_or_create(&dest, "x").unwrap();
let names: Vec<String> = b.skills().into_iter().map(|(n, _)| n).collect();
assert!(names.iter().any(|n| n == "existing"), "existing skill preserved; got {names:?}");
assert!(names.len() > 1, "merged skills added; got {names:?}");
eprintln!("merged → {} skills, {} memories", names.len(), b.memory_count());
let _ = std::fs::remove_file(&dest);
}
#[tokio::test]
#[ignore = "hits live clawbrainhub.com"]
async fn live_list() {
let items = list("").await.expect("list");
eprintln!("registry browse returned {} brains: {:?}", items.len(), items.iter().map(|b| &b.reference).collect::<Vec<_>>());
assert!(!items.is_empty(), "seed-browse should surface brains");
assert!(items.iter().any(|b| b.reference == "omar/react-native"), "react-native should appear");
}
#[tokio::test]
#[ignore = "dumps a brain's sections to /tmp/brain-dump.txt"]
async fn live_dump_brain() {
let r = std::env::var("DUMP_REF").unwrap_or_else(|_| "omar/rust-2024".to_string());
let dest = std::env::temp_dir().join("dump.brain");
let _ = std::fs::remove_file(&dest);
pull(&r, &dest).await.expect("pull");
let b = ClawBrain::open_or_create(&dest, &r).unwrap();
let sp = b.system_prompt().unwrap_or_default();
let am = b.agent_md().unwrap_or_default();
let pe = b.personality().unwrap_or_default();
let sk = b.skills().into_iter().map(|(n, bd)| format!("## {n}\n{bd}")).collect::<Vec<_>>().join("\n\n");
eprintln!("{r}: system_prompt={} agent_md={} persona={} skills_md={} chars (skills={})", sp.len(), am.len(), pe.len(), sk.len(), b.skills().len());
let payload = format!("BRAIN: {r}\n\n=== SYSTEM PROMPT ===\n{sp}\n\n=== AGENTS.md ===\n{am}\n\n=== PERSONA ===\n{pe}\n\n=== SKILLS ===\n{sk}");
std::fs::write("/tmp/brain-dump.txt", &payload).unwrap();
eprintln!("wrote /tmp/brain-dump.txt ({} chars)", payload.len());
let _ = std::fs::remove_file(&dest);
}
#[tokio::test]
#[ignore = "hits live clawbrainhub.com — read-only scan"]
async fn live_scan_empty_brains() {
let queries = ["", "omar", "assistant", "default", "test", "claw", "agent", "brain", "react", "general", "data", "code"];
let mut seen = std::collections::HashSet::new();
for q in queries {
for it in list(q).await.unwrap_or_default() {
if !seen.insert(it.reference.clone()) {
continue;
}
match preview(&it.reference).await {
Ok(pv) => eprintln!(
"{:42} owner={:34} EMPTY={} (sp={} agent={} persona={} skills={} tools={} mem={})",
it.reference, it.owner, is_empty_preview(&pv),
pv.system_prompt.populated, pv.agent_md.populated, pv.personality.populated,
pv.skills.len(), pv.tools.len(), pv.memory_count,
),
Err(e) => eprintln!("{:42} owner={:34} preview-error: {e}", it.reference, it.owner),
}
}
}
}
#[tokio::test]
#[ignore = "hits live clawbrainhub.com"]
async fn live_apply_react_native_hdf5() {
let dest = std::env::temp_dir().join(format!("cm-brain-rn-{}.brain", std::process::id()));
let _ = std::fs::remove_file(&dest);
let pulled = pull_merge("omar/react-native", &dest).await.expect("merge HDF5 brain");
let b = ClawBrain::open_or_create(&dest, "x").unwrap();
eprintln!("react-native → system_prompt {} chars, {} skills", pulled.system_prompt.len(), b.skills().len());
let _ = std::fs::remove_file(&dest);
}
}
+345
View File
@@ -0,0 +1,345 @@
//! `cm-brain` — ClawMates' facade over the canonical **`.brain`** (a `claw-brain`
//! / ClawhDF5 *brain-pack*): one HDF5 file holding an agent's full definition
//! (system prompt · personality · skills · tools · runtime · provenance) **and**
//! its memory, so claws can be built, deployed, loaded, upskilled, reloaded —
//! and pulled/pushed to ClawBrainHub + synced with ClawSync — as a single file.
//!
//! This is a thin, ClawMates-shaped wrapper over `claw_brain::BrainHandle` (the
//! brain-pack KV) + its keyword index for recall. Sections live under the
//! conventional brain-pack keys (`identity/system_prompt`, `skills/<name>`, …);
//! conversational memory lives under `memory/<ts>` and is keyword-searchable.
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use claw_brain::{index_entry, keyword_search, BrainHandle};
pub use claw_brain::RevisionInfo;
pub mod hub;
const K_SYSTEM_PROMPT: &str = "identity/system_prompt";
const K_PERSONA: &str = "identity/persona";
const K_SOUL: &str = "identity/soul_md";
const K_AGENT_MD: &str = "identity/agent_md";
const K_RUNTIME: &str = "runtime/clawmates";
const K_PROVENANCE: &str = "provenance/clawmates";
const P_SKILL: &str = "skills/"; // skills/<name>
const P_TOOL: &str = "tools/"; // tools/<name>
const P_MEMORY: &str = "memory/"; // memory/<ts_nanos>
const K_SKILLS_MD: &str = "skills/skills_md"; // brain-pack narrative skills doc
#[derive(Debug, thiserror::Error)]
pub enum BrainError {
#[error("brain backend: {0}")]
Backend(String),
}
pub(crate) fn be<E: std::fmt::Display>(e: E) -> BrainError {
BrainError::Backend(e.to_string())
}
fn now_nanos() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
}
/// A claw's brain: a single `.brain` (ClawhDF5 brain-pack) file.
pub struct ClawBrain {
h: BrainHandle,
path: PathBuf,
}
impl ClawBrain {
/// Open an existing `.brain`, or create a fresh one. `agent_id` is currently
/// advisory (the brain is identified by its file path).
pub fn open_or_create(path: impl AsRef<Path>, _agent_id: &str) -> Result<Self, BrainError> {
let p = path.as_ref().to_path_buf();
let h = if p.exists() {
BrainHandle::open(&p).map_err(be)?
} else {
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).map_err(be)?;
}
BrainHandle::create(&p).map_err(be)?
};
Ok(Self { h, path: p })
}
/// Path to the underlying `.brain` file (for registry push / ClawSync).
pub fn path(&self) -> &Path {
&self.path
}
// ── ClawSync: local revision history + rollback (`.onion` sidecar) ─────────
/// Commit the current brain state as a new revision in the `.brain.onion`
/// sidecar. The base `.brain` stays current; history lives in the sidecar.
/// First commit creates the sidecar; later commits store only changed pages.
pub fn commit(&self, annotation: Option<&str>) -> Result<u64, BrainError> {
self.h.flush().map_err(be)?;
claw_brain::commit_versioned(&self.h, annotation).map_err(be)
}
/// List the brain's revision history (empty if never committed).
pub fn revisions(&self) -> Result<Vec<claw_brain::RevisionInfo>, BrainError> {
claw_brain::list_brain_revisions(&self.path).map_err(be)
}
/// Materialize a prior revision back onto the `.brain` file (the sidecar
/// history is preserved, so a rollback is itself reversible).
pub fn rollback(&self, revision: u64) -> Result<(), BrainError> {
claw_brain::rollback_to_revision(&self.path, revision).map_err(be)
}
// ── raw key helpers ───────────────────────────────────────────────────────
fn put(&self, key: &str, content: &str) -> Result<(), BrainError> {
self.h.write(key, content.as_bytes().to_vec()).map_err(be)?;
self.h.flush().map_err(be)
}
fn get(&self, key: &str) -> Option<String> {
self.h.read(key).ok().and_then(|b| String::from_utf8(b).ok())
}
fn del(&self, key: &str) -> Result<bool, BrainError> {
if self.h.read(key).is_err() {
return Ok(false);
}
self.h.remove(key).map_err(be)?;
self.h.flush().map_err(be)?;
Ok(true)
}
/// `(suffix, value)` for every active key under `prefix` (excluding nested).
fn list(&self, prefix: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for k in self.h.keys() {
if let Some(name) = k.strip_prefix(prefix) {
if name.is_empty() || name.contains('/') {
continue;
}
if let Some(v) = self.get(&k) {
out.push((name.to_string(), v));
}
}
}
out
}
// ── identity ──────────────────────────────────────────────────────────────
pub fn set_system_prompt(&mut self, s: &str) -> Result<(), BrainError> { self.put(K_SYSTEM_PROMPT, s) }
/// System prompt, falling back to the brain-pack `soul_md` (pulled brains
/// often carry the identity there with an empty `system_prompt`).
pub fn system_prompt(&self) -> Option<String> {
match self.get(K_SYSTEM_PROMPT) {
Some(s) if !s.trim().is_empty() => Some(s),
_ => self.get(K_SOUL).filter(|s| !s.trim().is_empty()),
}
}
pub fn set_personality(&mut self, s: &str) -> Result<(), BrainError> { self.put(K_PERSONA, s) }
pub fn personality(&self) -> Option<String> { self.get(K_PERSONA).filter(|s| !s.trim().is_empty()) }
/// AGENTS.md — "how I operate" (workflow/rules), prepended to the prompt.
pub fn set_agent_md(&mut self, s: &str) -> Result<(), BrainError> { self.put(K_AGENT_MD, s) }
pub fn agent_md(&self) -> Option<String> { self.get(K_AGENT_MD).filter(|s| !s.trim().is_empty()) }
/// Assemble the agent's full system prompt from the canonical identity files
/// (mirrors ZeroClaw's personality render): `system_prompt`‖`soul_md`, then
/// `agent_md` (AGENTS.md), then `persona`.
pub fn assembled_identity(&self) -> String {
let mut out = String::new();
if let Some(sp) = self.system_prompt() {
out.push_str(&sp);
}
if let Some(a) = self.agent_md() {
if !out.is_empty() { out.push_str("\n\n"); }
out.push_str("## How I operate\n");
out.push_str(&a);
}
if let Some(p) = self.personality() {
if !out.is_empty() { out.push_str("\n\n"); }
out.push_str("## Personality\n");
out.push_str(&p);
}
out
}
// ── skills ────────────────────────────────────────────────────────────────
pub fn set_skill(&mut self, name: &str, body: &str) -> Result<(), BrainError> { self.put(&format!("{P_SKILL}{name}"), body) }
/// Set the brain-pack narrative skills doc (`skills/skills_md`); `skills()`
/// parses its `## <name>` sections.
pub fn set_skills_md(&mut self, md: &str) -> Result<(), BrainError> { self.put(K_SKILLS_MD, md) }
pub fn remove_skill(&mut self, name: &str) -> Result<bool, BrainError> { self.del(&format!("{P_SKILL}{name}")) }
/// All skills as `(name, body)` — both per-skill keys and, if present, the
/// brain-pack narrative `skills/skills_md` parsed into `## <name>` sections.
pub fn skills(&self) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = self
.list(P_SKILL)
.into_iter()
.filter(|(n, _)| format!("{P_SKILL}{n}") != K_SKILLS_MD)
.collect();
if let Some(md) = self.get(K_SKILLS_MD) {
for (n, b) in parse_skills_md(&md) {
if !out.iter().any(|(en, _)| *en == n) {
out.push((n, b));
}
}
}
out
}
// ── tools / doors (value = state, e.g. "gated" | "blocked") ────────────────
pub fn set_tool(&mut self, name: &str, state: &str) -> Result<(), BrainError> { self.put(&format!("{P_TOOL}{name}"), state) }
pub fn remove_tool(&mut self, name: &str) -> Result<bool, BrainError> { self.del(&format!("{P_TOOL}{name}")) }
pub fn tools(&self) -> Vec<(String, String)> { self.list(P_TOOL) }
// ── runtime + provenance (opaque JSON blobs) ───────────────────────────────
pub fn set_runtime(&mut self, json: &str) -> Result<(), BrainError> { self.put(K_RUNTIME, json) }
pub fn runtime(&self) -> Option<String> { self.get(K_RUNTIME) }
pub fn set_provenance(&mut self, json: &str) -> Result<(), BrainError> { self.put(K_PROVENANCE, json) }
pub fn provenance(&self) -> Option<String> { self.get(K_PROVENANCE) }
// ── conversational memory ──────────────────────────────────────────────────
/// Append a turn to the brain's memory (keyword-indexed, recallable later).
pub fn remember(&mut self, role: &str, text: &str, _session_id: &str) -> Result<(), BrainError> {
let key = format!("{P_MEMORY}{:020}", now_nanos());
let chunk = format!("{role}: {text}");
self.h.write(&key, chunk.into_bytes()).map_err(be)?;
index_entry(&self.h, &key, text).map_err(be)?;
self.h.flush().map_err(be)
}
/// Recall up to `k` past memory chunks most relevant to `query` (BM25 over
/// the keyword index; only `memory/` entries). Best-effort.
pub fn recall(&self, query: &str, k: usize) -> Vec<String> {
let hits = match keyword_search(&self.h, query, k.saturating_mul(2).max(k)) {
Ok(h) => h,
Err(_) => return Vec::new(),
};
hits.into_iter()
.filter(|r| r.key.starts_with(P_MEMORY))
.filter_map(|r| self.get(&r.key))
.take(k)
.collect()
}
/// Most recent memory chunks, newest first, as `(timestamp_secs, text)`.
pub fn recent_memory(&self, k: usize) -> Vec<(f64, String)> {
let mut keys: Vec<(u128, String)> = self
.h
.keys()
.into_iter()
.filter_map(|key| key.strip_prefix(P_MEMORY).and_then(|s| s.parse::<u128>().ok()).map(|n| (n, key)))
.collect();
keys.sort_by(|a, b| b.0.cmp(&a.0));
keys.into_iter()
.take(k)
.filter_map(|(n, key)| self.get(&key).map(|t| (n as f64 / 1e9, t)))
.collect()
}
pub fn memory_count(&self) -> usize {
self.h.keys().iter().filter(|k| k.starts_with(P_MEMORY)).count()
}
/// Render identity + skills as Markdown (for ZeroClaw workspace hydration).
pub fn export_markdown(&self) -> String {
let mut s = String::new();
if let Some(sp) = self.system_prompt() {
s.push_str("# System Prompt\n\n");
s.push_str(&sp);
s.push_str("\n\n");
}
if let Some(p) = self.personality() {
s.push_str("# Personality\n\n");
s.push_str(&p);
s.push_str("\n\n");
}
let skills = self.skills();
if !skills.is_empty() {
s.push_str("# Skills\n\n");
for (name, body) in skills {
s.push_str(&format!("## {name}\n\n{body}\n\n"));
}
}
s
}
}
/// Split a `skills_md` doc into `(name, body)` by its `## <name>` headings.
fn parse_skills_md(md: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut name: Option<String> = None;
let mut body = String::new();
for line in md.lines() {
if let Some(h) = line.strip_prefix("## ") {
if let Some(n) = name.take() {
out.push((n, body.trim().to_string()));
body.clear();
}
name = Some(h.trim().to_string());
} else if name.is_some() {
body.push_str(line);
body.push('\n');
}
}
if let Some(n) = name.take() {
out.push((n, body.trim().to_string()));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_path(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("cm-brain-{}-{}.brain", std::process::id(), tag))
}
#[test]
fn brain_roundtrip_definition_and_memory() {
let p = temp_path("rt");
let _ = std::fs::remove_file(&p);
{
let mut b = ClawBrain::open_or_create(&p, "agent-x").expect("create");
b.set_system_prompt("You are Atlas, a meticulous planner.").unwrap();
b.set_personality("calm, precise, terse").unwrap();
b.set_skill("python", "Write idiomatic, tested Python.").unwrap();
b.set_tool("browser", "gated").unwrap();
b.set_runtime(r#"{"model":"claude","risk":"toolfree"}"#).unwrap();
b.remember("user", "My favorite color is teal.", "s1").unwrap();
b.remember("user", "I live in Boston.", "s1").unwrap();
}
let b = ClawBrain::open_or_create(&p, "agent-x").expect("open");
assert_eq!(b.system_prompt().as_deref(), Some("You are Atlas, a meticulous planner."));
assert_eq!(b.personality().as_deref(), Some("calm, precise, terse"));
assert_eq!(b.skills(), vec![("python".to_string(), "Write idiomatic, tested Python.".to_string())]);
assert_eq!(b.tools(), vec![("browser".to_string(), "gated".to_string())]);
assert!(b.runtime().unwrap().contains("toolfree"));
assert_eq!(b.memory_count(), 2);
let hits = b.recall("what is my favorite color", 3);
assert!(hits.iter().any(|h| h.contains("teal")), "recall should surface teal; got {hits:?}");
let _ = std::fs::remove_file(&p);
}
#[test]
fn upsert_and_remove_skill() {
let p = temp_path("skill");
let _ = std::fs::remove_file(&p);
let mut b = ClawBrain::open_or_create(&p, "a").unwrap();
b.set_skill("py", "v1").unwrap();
b.set_skill("py", "v2").unwrap();
assert_eq!(b.skills(), vec![("py".to_string(), "v2".to_string())]);
assert!(b.remove_skill("py").unwrap());
assert!(b.skills().is_empty());
let _ = std::fs::remove_file(&p);
}
#[test]
fn parses_skills_md() {
let md = "# Skills\n\n## web_search\n\nSearch the web.\n\n## read_file\n\nRead a file.\n";
let s = parse_skills_md(md);
assert_eq!(s.len(), 2);
assert_eq!(s[0].0, "web_search");
assert!(s[0].1.contains("Search the web"));
}
}
+86
View File
@@ -256,3 +256,89 @@ pub async fn soft_delete(pool: &PgPool, agent_id: AgentId) -> Result<(), DbError
} }
Ok(()) Ok(())
} }
/// Counts of the rows reaped by [`hard_purge`], for the progress summary.
#[derive(Debug, Default, Clone, Copy)]
pub struct PurgeCounts {
pub sessions: u64,
pub approvals: u64,
pub connections: u64,
pub files: u64,
}
/// Hard-delete an agent and everything that references it, in FK-dependency
/// order, in one transaction. Auto-CASCADE handles access_policies,
/// installed_skills, routines(+routine_runs) and team_members; the non-cascade
/// references (chat history, approvals, threads, connections, queued mail, file
/// drives, usage) are cleared first so the final `DELETE FROM agents` succeeds.
/// Only the immutable `audit_log` survives. Returns NotFound if the agent is gone.
pub async fn hard_purge(pool: &PgPool, agent_id: AgentId) -> Result<PurgeCounts, DbError> {
let aid = agent_id.as_uuid();
let mut tx = pool.begin().await?;
let mut c = PurgeCounts::default();
// Chat history: steps → messages → agent_runs → sessions (none cascade).
sqlx::query(
"DELETE FROM steps WHERE message_id IN \
(SELECT m.id FROM messages m JOIN sessions s ON m.session_id = s.id WHERE s.agent_id = $1)",
)
.bind(aid)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM messages WHERE session_id IN (SELECT id FROM sessions WHERE agent_id = $1)")
.bind(aid)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM agent_runs WHERE session_id IN (SELECT id FROM sessions WHERE agent_id = $1)")
.bind(aid)
.execute(&mut *tx)
.await?;
c.sessions = sqlx::query("DELETE FROM sessions WHERE agent_id = $1")
.bind(aid)
.execute(&mut *tx)
.await?
.rows_affected();
// Approvals: execution_grants → approvals.
sqlx::query(
"DELETE FROM execution_grants WHERE approval_id IN \
(SELECT id FROM approvals WHERE requested_by_agent = $1)",
)
.bind(aid)
.execute(&mut *tx)
.await?;
c.approvals = sqlx::query("DELETE FROM approvals WHERE requested_by_agent = $1")
.bind(aid)
.execute(&mut *tx)
.await?
.rows_affected();
// Inter-agent threads, queued mail, oauth flows.
sqlx::query("DELETE FROM thread_messages WHERE from_agent = $1").bind(aid).execute(&mut *tx).await?;
sqlx::query("DELETE FROM thread_participants WHERE agent_id = $1").bind(aid).execute(&mut *tx).await?;
sqlx::query("DELETE FROM outbox WHERE agent_id = $1").bind(aid).execute(&mut *tx).await?;
sqlx::query("DELETE FROM oauth_states WHERE agent_id = $1").bind(aid).execute(&mut *tx).await?;
c.connections = sqlx::query("DELETE FROM app_connections WHERE agent_id = $1")
.bind(aid)
.execute(&mut *tx)
.await?
.rows_affected();
c.files = sqlx::query("DELETE FROM file_nodes WHERE agent_id = $1")
.bind(aid)
.execute(&mut *tx)
.await?
.rows_affected();
sqlx::query("DELETE FROM usage_events WHERE agent_id = $1").bind(aid).execute(&mut *tx).await?;
// Finally the agent itself (cascades the rest).
let n = sqlx::query("DELETE FROM agents WHERE id = $1")
.bind(aid)
.execute(&mut *tx)
.await?
.rows_affected();
if n == 0 {
return Err(DbError::NotFound);
}
tx.commit().await?;
Ok(c)
}
+39
View File
@@ -61,6 +61,45 @@ pub async fn insert_company(
Ok(()) Ok(())
} }
/// Rebuild a company's topology (kind + graph) in place; node→team bindings
/// (keyed off stable node ids `n0..`) are left untouched. Non-macro query.
pub async fn set_topology(
pool: &PgPool,
id: Uuid,
workspace_id: WorkspaceId,
kind: &str,
graph: &Value,
) -> Result<(), DbError> {
let res = sqlx::query("UPDATE companies SET kind = $3, graph = $4 WHERE id = $1 AND workspace_id = $2")
.bind(id)
.bind(workspace_id.as_uuid())
.bind(kind)
.bind(graph)
.execute(pool)
.await?;
if res.rows_affected() == 0 {
return Err(DbError::NotFound);
}
Ok(())
}
/// Delete a company and its node→team bindings (teams themselves remain).
pub async fn delete_company(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<(), DbError> {
sqlx::query("DELETE FROM company_teams WHERE company_id = $1")
.bind(id)
.execute(pool)
.await?;
let res = sqlx::query("DELETE FROM companies WHERE id = $1 AND workspace_id = $2")
.bind(id)
.bind(workspace_id.as_uuid())
.execute(pool)
.await?;
if res.rows_affected() == 0 {
return Err(DbError::NotFound);
}
Ok(())
}
/// Bind a team to a topology node within a company. /// Bind a team to a topology node within a company.
pub async fn add_team( pub async fn add_team(
pool: &PgPool, pool: &PgPool,
+18
View File
@@ -146,3 +146,21 @@ pub async fn companies_for_org(pool: &PgPool, org_id: Uuid) -> Result<Vec<OrgCom
}) })
.collect()) .collect())
} }
/// Delete an org. Structural only: the `org_companies` links are removed
/// (cascade) so the companies survive, just ungrouped from this org.
pub async fn delete_org(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<(), DbError> {
sqlx::query("DELETE FROM org_companies WHERE org_id = $1")
.bind(id)
.execute(pool)
.await?;
let res = sqlx::query("DELETE FROM orgs WHERE id = $1 AND workspace_id = $2")
.bind(id)
.bind(workspace_id.as_uuid())
.execute(pool)
.await?;
if res.rows_affected() == 0 {
return Err(DbError::NotFound);
}
Ok(())
}
+40
View File
@@ -59,6 +59,46 @@ pub async fn insert_team(
Ok(()) Ok(())
} }
/// Rebuild a team's topology (kind + graph) in place; node→claw bindings (which
/// key off stable node ids `n0..`) are left untouched. Non-macro query so it
/// needs no offline sqlx cache entry.
pub async fn set_topology(
pool: &PgPool,
id: Uuid,
workspace_id: WorkspaceId,
kind: &str,
graph: &Value,
) -> Result<(), DbError> {
let res = sqlx::query("UPDATE teams SET kind = $3, graph = $4 WHERE id = $1 AND workspace_id = $2")
.bind(id)
.bind(workspace_id.as_uuid())
.bind(kind)
.bind(graph)
.execute(pool)
.await?;
if res.rows_affected() == 0 {
return Err(DbError::NotFound);
}
Ok(())
}
/// Delete a team and its node→claw bindings.
pub async fn delete_team(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<(), DbError> {
sqlx::query("DELETE FROM team_members WHERE team_id = $1")
.bind(id)
.execute(pool)
.await?;
let res = sqlx::query("DELETE FROM teams WHERE id = $1 AND workspace_id = $2")
.bind(id)
.bind(workspace_id.as_uuid())
.execute(pool)
.await?;
if res.rows_affected() == 0 {
return Err(DbError::NotFound);
}
Ok(())
}
/// Bind a claw to a topology node within a team. /// Bind a claw to a topology node within a team.
pub async fn add_member( pub async fn add_member(
pool: &PgPool, pool: &PgPool,
+5 -1
View File
@@ -74,7 +74,7 @@ fn stop_reason(wire: &str) -> StopReason {
#[async_trait::async_trait] #[async_trait::async_trait]
impl LlmProvider for AnthropicProvider { impl LlmProvider for AnthropicProvider {
async fn stream(&self, request: ChatRequest) -> Result<EventStream, LlmError> { async fn stream(&self, request: ChatRequest) -> Result<EventStream, LlmError> {
let tools: Vec<Value> = request let mut tools: Vec<Value> = request
.tools .tools
.iter() .iter()
.map(|t| { .map(|t| {
@@ -85,6 +85,10 @@ impl LlmProvider for AnthropicProvider {
}) })
}) })
.collect(); .collect();
if request.web_search {
// Anthropic server-side web search — the model searches the web itself.
tools.push(json!({"type": "web_search_20250305", "name": "web_search", "max_uses": 5}));
}
let body = json!({ let body = json!({
"model": request.model, "model": request.model,
"max_tokens": request.max_tokens, "max_tokens": request.max_tokens,
+4
View File
@@ -77,6 +77,10 @@ pub struct ChatRequest {
pub tools: Vec<ToolDescriptor>, pub tools: Vec<ToolDescriptor>,
pub model: String, pub model: String,
pub max_tokens: u32, pub max_tokens: u32,
/// When true, providers that support a server-side web-search tool (Anthropic)
/// attach it so the model can ground answers in live web data.
#[serde(default)]
pub web_search: bool,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+2
View File
@@ -25,6 +25,7 @@ fn simple_request(model: &str) -> ChatRequest {
tools: vec![], tools: vec![],
model: model.into(), model: model.into(),
max_tokens: 64, max_tokens: 64,
web_search: false,
} }
} }
@@ -99,6 +100,7 @@ async fn anthropic_tool_round_trip_with_usage() {
tools: vec![clock_tool], tools: vec![clock_tool],
model: "claude-haiku-4-5-20251001".into(), model: "claude-haiku-4-5-20251001".into(),
max_tokens: 300, max_tokens: 300,
web_search: false,
}; };
// Leg 1: the model must emit a real ToolUse with an id. // Leg 1: the model must emit a real ToolUse with an id.
+1
View File
@@ -39,6 +39,7 @@ fn request_with_user_text(text: &str) -> ChatRequest {
tools: vec![], tools: vec![],
model: "scripted".into(), model: "scripted".into(),
max_tokens: 1024, max_tokens: 1024,
web_search: false,
} }
} }
+1
View File
@@ -63,6 +63,7 @@ impl Scorer for JudgeScorer {
tools: vec![], tools: vec![],
model: self.model.clone(), model: self.model.clone(),
max_tokens: self.max_tokens, max_tokens: self.max_tokens,
web_search: false,
}; };
let mut stream = match self.provider.stream(request).await { let mut stream = match self.provider.stream(request).await {
@@ -62,6 +62,7 @@ impl TurnExecutor for ProviderExecutor {
tools: vec![], tools: vec![],
model: self.model.clone(), model: self.model.clone(),
max_tokens: self.max_tokens, max_tokens: self.max_tokens,
web_search: false,
}; };
let mut stream = self let mut stream = self
+1
View File
@@ -21,6 +21,7 @@ serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
cm-billing = { path = "../cm-billing" } cm-billing = { path = "../cm-billing" }
cm-brain = { path = "../cm-brain" }
cm-db = { path = "../cm-db" } cm-db = { path = "../cm-db" }
cm-domain = { path = "../cm-domain" } cm-domain = { path = "../cm-domain" }
cm-files = { path = "../cm-files" } cm-files = { path = "../cm-files" }
+84
View File
@@ -0,0 +1,84 @@
//! Best-effort brain augmentation for the chat path.
//!
//! Each turn we open the claw's local working `.brain` (cm-brain / ClawhDF5),
//! recall relevant memory, record the user's turn, and compose a system prompt
//! that injects the claw's **skills** and **recalled memory** on top of its
//! Postgres-authoritative system prompt. Any failure falls back to the plain
//! prompt — the brain must never break chat.
//!
//! The local file is a working cache of the claw's brain (canonical home is
//! ClawBrainHub); memory accrues here and is pushed back on save/publish.
use std::path::PathBuf;
use cm_brain::ClawBrain;
fn brain_dir() -> PathBuf {
std::env::var("CLAWMATES_BRAIN_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir().join("clawmates-brains"))
}
/// Compose the system prompt for a claw, augmenting `base_prompt` with its
/// skills + recalled memory, and recording the user turn. Best-effort: returns
/// `base_prompt` unchanged on any brain error.
pub fn compose_system(
agent_id: &str,
base_prompt: &str,
skills: &[(String, String)], // (title, body)
user_text: &str,
session_label: &str,
) -> String {
match try_compose(agent_id, base_prompt, skills, user_text, session_label) {
Ok(s) => s,
Err(e) => {
eprintln!("cm-runtime: brain augmentation skipped for {agent_id}: {e}");
base_prompt.to_string()
}
}
}
fn try_compose(
agent_id: &str,
base_prompt: &str,
skills: &[(String, String)],
user_text: &str,
session_label: &str,
) -> Result<String, cm_brain::BrainError> {
let path = brain_dir().join(format!("claw_{agent_id}.h5"));
let mut brain = ClawBrain::open_or_create(&path, agent_id)?;
// First touch: seed the brain's definition (Postgres stays authoritative;
// the live prompt below always uses the DB values, so this is just so the
// .brain is a complete, portable artifact for push-back).
if brain.system_prompt().is_none() {
if !base_prompt.is_empty() {
brain.set_system_prompt(base_prompt)?;
}
for (name, body) in skills {
brain.set_skill(name, body)?;
}
}
let recalled = brain.recall(user_text, 4);
// Record the user's turn so future sessions can recall it (best-effort).
let _ = brain.remember("user", user_text, session_label);
let mut out = String::with_capacity(base_prompt.len() + 256);
out.push_str(base_prompt);
if !skills.is_empty() {
out.push_str("\n\n## Your skills (apply them when relevant)\n");
for (name, body) in skills {
out.push_str(&format!("\n### {name}\n{body}\n"));
}
}
if !recalled.is_empty() {
out.push_str("\n\n## Relevant memory from past sessions\n");
for r in &recalled {
out.push_str("- ");
out.push_str(r);
out.push('\n');
}
}
Ok(out)
}
+1
View File
@@ -2,6 +2,7 @@
//! executes tools, and journals every event before any observer sees it //! executes tools, and journals every event before any observer sees it
//! (the gateway streams exactly this journal, live or replayed). //! (the gateway streams exactly this journal, live or replayed).
mod brain;
mod events; mod events;
pub mod outbox; pub mod outbox;
mod runtime; mod runtime;
+68 -1
View File
@@ -226,6 +226,19 @@ impl Runtime {
&self.inner.config.model &self.inner.config.model
} }
/// Tear down an agent's sandbox + browser containers (on deletion). Returns
/// whether any container existed and was destroyed.
pub async fn reap_sandbox(&self, agent_id: cm_domain::AgentId) -> bool {
let mut any = false;
if let Some(sb) = &self.inner.config.sandboxes {
any |= sb.release_agent(agent_id).await;
}
if let Some(br) = &self.inner.config.browser {
any |= br.release_agent(agent_id).await;
}
any
}
/// 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
@@ -292,6 +305,7 @@ impl Runtime {
}], }],
tools: vec![], tools: vec![],
max_tokens: 256, max_tokens: 256,
web_search: false,
}; };
let mut text = String::new(); let mut text = String::new();
match provider.stream(request).await { match provider.stream(request).await {
@@ -308,6 +322,41 @@ impl Runtime {
(allow, text.trim().to_string()) (allow, text.trim().to_string())
} }
/// One-shot completion: send `system`+`user` to `model`, collect the full
/// assistant text. Used for non-chat LLM work (e.g. brain enhancement on
/// `claude-opus-4-8`).
pub async fn complete(
&self,
system: &str,
user: &str,
model: &str,
max_tokens: u32,
web_search: bool,
) -> Result<String, String> {
let (provider, resolved) = self.resolve_provider(model);
let request = ChatRequest {
system: system.to_string(),
model: resolved,
messages: vec![ChatMessage {
role: ChatRole::User,
parts: vec![ContentPart::text(user)],
}],
tools: vec![],
max_tokens,
web_search,
};
let mut text = String::new();
let mut stream = provider.stream(request).await.map_err(|e| e.to_string())?;
while let Some(event) = stream.next().await {
match event {
Ok(LlmEvent::TextDelta(t)) => text.push_str(&t),
Ok(_) => {}
Err(e) => return Err(e.to_string()),
}
}
Ok(text)
}
/// Execute a tool on behalf of the MCP door — capability is already decided /// Execute a tool on behalf of the MCP door — capability is already decided
/// by door policy (the human approver is replaced by an automated policy / /// by door policy (the human approver is replaced by an automated policy /
/// governor). Builds the tool context from runtime config; when an /// governor). Builds the tool context from runtime config; when an
@@ -364,6 +413,23 @@ impl Runtime {
let agent = cm_db::repo::agents::get(&inner.pool, session.agent_id).await?; let agent = cm_db::repo::agents::get(&inner.pool, session.agent_id).await?;
let history = messages::history(&inner.pool, session_id).await?; let history = messages::history(&inner.pool, session_id).await?;
// Brain-augmented system prompt: inject the claw's installed skills +
// recall relevant memory from its .brain, and record the user turn.
// Best-effort — falls back to the plain system prompt on any error.
let skills: Vec<(String, String)> = cm_db::repo::skills::installed(&inner.pool, agent.id)
.await
.unwrap_or_default()
.into_iter()
.map(|s| (s.title, s.body))
.collect();
let system_prompt = crate::brain::compose_system(
&agent.id.to_string(),
&agent.system_prompt,
&skills,
user_text,
&session_id.to_string(),
);
let run_id = runs::create(&inner.pool, session_id).await?; let run_id = runs::create(&inner.pool, session_id).await?;
let receiver = self.open_channel(run_id).await; let receiver = self.open_channel(run_id).await;
@@ -390,11 +456,12 @@ impl Runtime {
agent_id: agent.id, agent_id: agent.id,
reply_message_id: reply.id, reply_message_id: reply.id,
request: ChatRequest { request: ChatRequest {
system: agent.system_prompt.clone(), system: system_prompt,
messages: chat_messages(&history, user_text), messages: chat_messages(&history, user_text),
tools: inner.tools.descriptors(), tools: inner.tools.descriptors(),
model: inner.config.model.clone(), model: inner.config.model.clone(),
max_tokens: inner.config.max_tokens, max_tokens: inner.config.max_tokens,
web_search: false,
}, },
full_text: String::new(), full_text: String::new(),
step_seq: 0, step_seq: 0,
+15
View File
@@ -129,6 +129,21 @@ impl SandboxManager {
.map_err(|e| format!("sandbox exec failed: {e}")) .map_err(|e| format!("sandbox exec failed: {e}"))
} }
/// Tear down a single agent's sandbox if it has one (on agent deletion).
/// Returns whether a container existed and was destroyed.
pub async fn release_agent(&self, agent_id: AgentId) -> bool {
let handle = { self.handles.lock().await.remove(&agent_id) };
match handle {
Some(h) => {
if let Err(e) = self.driver.destroy(&h).await {
eprintln!("sandbox release: failed to remove {}: {e}", h.id);
}
true
}
None => false,
}
}
/// Destroys every sandbox this manager provisioned — assigned and /// Destroys every sandbox this manager provisioned — assigned and
/// pooled — and stops the warmer. Called on graceful shutdown (SIGTERM). /// pooled — and stops the warmer. Called on graceful shutdown (SIGTERM).
pub async fn shutdown(&self) { pub async fn shutdown(&self) {
+2
View File
@@ -11,6 +11,7 @@ mod files;
mod routine; mod routine;
mod shell; mod shell;
mod slack; mod slack;
mod websearch;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@@ -87,6 +88,7 @@ impl Default for ToolRegistry {
registry.register(Arc::new(browser::BrowserGoto)); registry.register(Arc::new(browser::BrowserGoto));
registry.register(Arc::new(shell::ShellExec)); registry.register(Arc::new(shell::ShellExec));
registry.register(Arc::new(SlackPost)); registry.register(Arc::new(SlackPost));
registry.register(Arc::new(websearch::WebSearch));
registry registry
} }
} }
+70
View File
@@ -0,0 +1,70 @@
//! `web.search` — grounded web search via Anthropic's server-side web-search tool,
//! so agents (e.g. a nightly research team) can find current papers/news/docs.
use cm_llm::{
AnthropicProvider, ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent, LlmProvider,
ToolDescriptor,
};
use cm_tools::{Effect, TaintSource};
use futures::StreamExt;
use serde_json::{json, Value};
use super::{Tool, ToolContext};
pub struct WebSearch;
#[async_trait::async_trait]
impl Tool for WebSearch {
fn descriptor(&self) -> ToolDescriptor {
ToolDescriptor {
name: "web.search".into(),
description: "Search the web for current information (research papers, news, docs) and return a \
grounded summary with source URLs."
.into(),
input_schema: json!({
"type": "object",
"properties": { "query": { "type": "string", "description": "What to search the web for" } },
"required": ["query"]
}),
}
}
fn effects(&self) -> &'static [Effect] {
&[Effect::ReachesExternally]
}
fn output_taint(&self) -> Option<TaintSource> {
Some(TaintSource::Web)
}
async fn execute(&self, _ctx: &ToolContext, input: Value) -> Result<Value, String> {
let query = input.get("query").and_then(|q| q.as_str()).unwrap_or("").trim().to_string();
if query.is_empty() {
return Err("query is required".into());
}
let key = std::env::var("ANTHROPIC_API_KEY")
.map_err(|_| "web.search unavailable (ANTHROPIC_API_KEY not set)".to_string())?;
let provider = AnthropicProvider::new(key);
let request = ChatRequest {
system: "You are a web research assistant. Use web search to find current, accurate information \
and answer concisely with the key facts and the source URLs."
.into(),
model: "claude-haiku-4-5-20251001".into(),
messages: vec![ChatMessage {
role: ChatRole::User,
parts: vec![ContentPart::text(query.clone())],
}],
tools: vec![],
max_tokens: 1500,
web_search: true,
};
let mut text = String::new();
let mut stream = provider.stream(request).await.map_err(|e| e.to_string())?;
while let Some(ev) = stream.next().await {
if let Ok(LlmEvent::TextDelta(t)) = ev {
text.push_str(&t);
}
}
Ok(json!({ "query": query, "results": text }))
}
}
+1
View File
@@ -15,6 +15,7 @@ cm-runtime = { path = "../cm-runtime" }
thiserror = { workspace = true } thiserror = { workspace = true }
time = { workspace = true } time = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
uuid = { workspace = true }
[dev-dependencies] [dev-dependencies]
cm-llm = { path = "../cm-llm" } cm-llm = { path = "../cm-llm" }
+48 -7
View File
@@ -3,7 +3,7 @@
//! drives a REAL run through the runtime in the routine's dedicated //! drives a REAL run through the runtime in the routine's dedicated
//! session — gated tools inside a routine still hit the approval queue. //! session — gated tools inside a routine still hit the approval queue.
use cm_db::repo::{agents, routine_runs, routines, sessions}; use cm_db::repo::{agents, routine_runs, routines, sessions, teams, topology_runs};
use cm_runtime::Runtime; use cm_runtime::Runtime;
use sqlx::PgPool; use sqlx::PgPool;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -33,18 +33,59 @@ impl Scheduler {
pub async fn tick(&self, now: OffsetDateTime) -> Result<usize, ScheduleError> { pub async fn tick(&self, now: OffsetDateTime) -> Result<usize, ScheduleError> {
let due = routines::claim_due(&self.pool, now).await?; let due = routines::claim_due(&self.pool, now).await?;
for routine in &due { for routine in &due {
// Reschedule first: a firing failure must not stall the clock. // Reschedule first: a firing failure must not stall the clock. A
let next = next_occurrence(&routine.schedule_cron, now).ok(); // one-shot routine (Scheduled mode, a specific date/time) fires once
// and never reschedules.
let one_shot = routine.action.get("one_shot").and_then(|v| v.as_bool()).unwrap_or(false);
let next = if one_shot { None } else { next_occurrence(&routine.schedule_cron, now).ok() };
routines::set_next_run(&self.pool, routine.id, next).await?; routines::set_next_run(&self.pool, routine.id, next).await?;
let agent_id = cm_domain::AgentId::from(routine.agent_id);
let Ok(agent) = agents::get(&self.pool, agent_id).await else {
continue; // deleted agent: routine is orphaned
};
// Topology routine: fire the whole team's stored topology as one
// durable run (the entire team loops, not just the coordinator).
if let Some(topo) = routine.action.get("topology") {
let team_id = topo
.get("team_id")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<uuid::Uuid>().ok());
let task = topo
.get("task")
.and_then(|v| v.as_str())
.unwrap_or(routine.name.as_str());
let run_id = routine_runs::start(&self.pool, routine.id).await.ok();
let res: Result<(), String> = match team_id {
Some(tid) => match teams::get_team(&self.pool, tid, agent.workspace_id).await {
Ok(team) => topology_runs::enqueue_run(
&self.pool,
uuid::Uuid::now_v7(),
agent.workspace_id,
task,
&team.graph,
)
.await
.map_err(|e| e.to_string()),
Err(e) => Err(e.to_string()),
},
None => Err("routine topology action missing team_id".to_string()),
};
if let Some(rid) = run_id {
let (status, err) = match &res {
Ok(_) => ("ok", None),
Err(e) => ("error", Some(e.clone())),
};
let _ = routine_runs::finish(&self.pool, rid, status, err.as_deref()).await;
}
continue;
}
let message = routine.action["message"].as_str().unwrap_or_default(); let message = routine.action["message"].as_str().unwrap_or_default();
if message.is_empty() { if message.is_empty() {
continue; continue;
} }
let agent_id = cm_domain::AgentId::from(routine.agent_id);
let Ok(agent) = agents::get(&self.pool, agent_id).await else {
continue; // deleted agent: routine is orphaned
};
// Each routine runs in one dedicated, recognizable session. // Each routine runs in one dedicated, recognizable session.
let title = format!("⏰ {}", routine.name); let title = format!("⏰ {}", routine.name);
+12
View File
@@ -4,6 +4,18 @@ const nextConfig: NextConfig = {
// Standalone output: the frontend image runs `node server.js` from // Standalone output: the frontend image runs `node server.js` from
// distroless with no node_modules — required for the air-gapped bundle. // distroless with no node_modules — required for the air-gapped bundle.
output: "standalone", output: "standalone",
// The service worker must never be cached: a stale sw.js (e.g. Cloudflare's
// default 4h Browser Cache TTL) delays update detection, so a new deploy's
// auto-reload never fires and users stay on the old build. Force revalidation
// so the browser always re-checks sw.js and picks up new versions at once.
async headers() {
return [
{
source: "/sw.js",
headers: [{ key: "Cache-Control", value: "no-cache, no-store, must-revalidate" }],
},
];
},
}; };
export default nextConfig; export default nextConfig;
+243 -3
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@clerk/nextjs": "^7.5.0", "@clerk/nextjs": "^7.5.0",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.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",
"geist": "^1.7.2", "geist": "^1.7.2",
@@ -2139,6 +2140,55 @@
"assertion-error": "^2.0.1" "assertion-error": "^2.0.1"
} }
}, },
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/deep-eql": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -2181,7 +2231,7 @@
"version": "19.2.17", "version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -2191,7 +2241,7 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
@@ -2979,6 +3029,48 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@xyflow/react": {
"version": "12.11.0",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz",
"integrity": "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.77",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"@types/react": ">=17",
"@types/react-dom": ">=17",
"react": ">=17",
"react-dom": ">=17"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.77",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.77.tgz",
"integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3495,6 +3587,12 @@
"url": "https://polar.sh/cva" "url": "https://polar.sh/cva"
} }
}, },
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/client-only": { "node_modules/client-only": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -3570,9 +3668,114 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -7777,6 +7980,15 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.16", "version": "8.0.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
@@ -8176,6 +8388,34 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
} }
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
} }
} }
} }
+1
View File
@@ -13,6 +13,7 @@
"dependencies": { "dependencies": {
"@clerk/nextjs": "^7.5.0", "@clerk/nextjs": "^7.5.0",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.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",
"geist": "^1.7.2", "geist": "^1.7.2",
+14 -46
View File
@@ -1,10 +1,15 @@
// Clawmates service worker (§16 PWA). Hand-rolled on purpose: ~50 lines we // Clawmates service worker — KILL SWITCH.
// fully control beats a build-tool plugin. Strategy: //
// /api/** -> NEVER touched (approvals + SSE must be live) // A previous caching service worker (tc-static-v*) could serve stale or
// /_next/static/** -> cache-first (content-hashed, immutable) // mismatched /_next/static chunks after a deploy, which broke client-side
// navigations -> network-first, cache fallback for offline shell // hydration (React Flow nodes stayed `visibility:hidden`, blank dashboard).
const STATIC_CACHE = "tc-static-v3"; // Rather than ship another caching SW, this version removes the SW entirely:
const PAGE_CACHE = "tc-pages-v3"; // it deletes every cache and unregisters itself, returning every client to a
// clean, network-only state. There is intentionally no `fetch` handler, so the
// browser handles all requests directly even before unregister completes.
//
// Re-register loops are avoided because the new RegisterServiceWorker no longer
// calls register() — it only cleans up (see RegisterServiceWorker.tsx).
self.addEventListener("install", () => { self.addEventListener("install", () => {
self.skipWaiting(); self.skipWaiting();
@@ -13,48 +18,11 @@ self.addEventListener("install", () => {
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
(async () => { (async () => {
const keep = [STATIC_CACHE, PAGE_CACHE];
for (const key of await caches.keys()) { for (const key of await caches.keys()) {
if (!keep.includes(key)) await caches.delete(key); await caches.delete(key);
} }
await self.clients.claim(); await self.clients.claim();
await self.registration.unregister();
})(), })(),
); );
}); });
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/api/")) return;
if (url.pathname.startsWith("/_next/static/")) {
event.respondWith(
(async () => {
const cache = await caches.open(STATIC_CACHE);
const hit = await cache.match(event.request);
if (hit) return hit;
const response = await fetch(event.request);
if (response.ok) cache.put(event.request, response.clone());
return response;
})(),
);
return;
}
if (event.request.mode === "navigate") {
event.respondWith(
(async () => {
const cache = await caches.open(PAGE_CACHE);
try {
const response = await fetch(event.request);
if (response.ok) cache.put(event.request, response.clone());
return response;
} catch {
const hit = await cache.match(event.request);
if (hit) return hit;
throw new Error("offline and not cached");
}
})(),
);
}
});
@@ -5,7 +5,7 @@ import { Landing } from "@/components/marketing/Landing";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Clawmates — deploy agents at any scale", title: "Clawmates — deploy agents at any scale",
description: description:
"Every unit of work is a topology — a graph of role-slots bound to real AI agents. Compose and run agentic systems from a single claw up to a whole org, on a durable, §15-safe runner.", "Every unit of work is a topology — a graph of role-slots bound to real AI agents. Compose and run agentic systems from a single agent up to a whole org, on a durable, §15-safe runner.",
}; };
// The public marketing landing (served at "/" for logged-out visitors via the // The public marketing landing (served at "/" for logged-out visitors via the
@@ -1,20 +1,8 @@
import { ApprovalQueue } from "@/components/safety/ApprovalQueue"; import { redirect } from "next/navigation";
import { fetchPendingApprovals } from "@/lib/api/approvals";
// The approvals queue (§10): every gated action waiting on a human. // Retired as a standalone page: this tool now opens as an in-dashboard panel
export default async function ApprovalsPage() { // (the four-square launcher). Kept as a redirect so old links/bookmarks land in
const approvals = await fetchPendingApprovals(); // the new interface. The tool's content component is reused inside ToolPanel.
return ( export default function Page() {
<section className="mx-auto max-w-3xl px-8 py-12"> redirect("/");
<h1 className="text-2xl font-semibold tracking-tight">Approvals</h1>
<p className="pt-1 text-sm text-muted-foreground">
{approvals.length === 0
? "All clear — no actions awaiting review."
: `${approvals.length} ${
approvals.length === 1 ? "action awaits" : "actions await"
} your review. Nothing runs until you decide.`}
</p>
<ApprovalQueue approvals={approvals} />
</section>
);
} }
+6 -27
View File
@@ -1,29 +1,8 @@
import { z } from "zod"; import { redirect } from "next/navigation";
import { AppsDirectory } from "@/components/global/AppsDirectory"; // Retired as a standalone page: this tool now opens as an in-dashboard panel
import { PageChrome } from "@/components/global/PageChrome"; // (the four-square launcher). Kept as a redirect so old links/bookmarks land in
import { apiFetch } from "@/lib/api/http"; // the new interface. The tool's content component is reused inside ToolPanel.
export default function Page() {
const DirectoryAppSchema = z.object({ redirect("/");
id: z.string(),
name: z.string(),
description: z.string(),
category: z.string(),
connected: z.boolean(),
});
// The global Apps directory (§8.2): workspace-wide connections.
export default async function AppsPage() {
const apps = await apiFetch(
z.array(DirectoryAppSchema),
"/api/apps?workspace=true",
);
return (
<PageChrome
title="Apps"
description="Connect the tools your claws can use. Connections here are available to your whole workspace."
>
<AppsDirectory apps={apps} />
</PageChrome>
);
} }
@@ -12,13 +12,16 @@ export default async function ClawHome({
searchParams, searchParams,
}: { }: {
params: Promise<{ clawId: string }>; params: Promise<{ clawId: string }>;
searchParams: Promise<{ app?: string }>; searchParams: Promise<{ app?: string; embed?: string }>;
}) { }) {
const { clawId } = await params; const { clawId } = await params;
const { app } = await searchParams; const { app, embed } = await searchParams;
const sessions = await fetchSessions(clawId); const sessions = await fetchSessions(clawId);
const target = sessions[0] ?? (await createSession(clawId)); const target = sessions[0] ?? (await createSession(clawId));
const suffix = app ? `?app=${encodeURIComponent(app)}` : ""; const q = new URLSearchParams();
if (app) q.set("app", app);
if (embed) q.set("embed", embed); // keep the chrome-less flag for in-dashboard chat
const suffix = q.toString() ? `?${q.toString()}` : "";
redirect( redirect(
`/claws/${clawId}/chat/${encodeSessionKeyParam(target.sessionKey)}${suffix}`, `/claws/${clawId}/chat/${encodeSessionKeyParam(target.sessionKey)}${suffix}`,
); );
@@ -1,6 +1,8 @@
import { StructureCanvas } from "@/components/structure/StructureCanvas"; import { redirect } from "next/navigation";
export default async function CompanyPage({ params }: { params: Promise<{ id: string }> }) { // Retired: the org / company / team structure browser now lives in the
const { id } = await params; // integrated dashboard at "/" (the new interface). Kept as a redirect so old
return <StructureCanvas level="company" id={id} />; // links and bookmarks land in the right place.
export default function Page() {
redirect("/");
} }
@@ -1,63 +1,8 @@
"use client"; import { redirect } from "next/navigation";
import { useEffect, useState } from "react"; // Retired: the org / company / team structure browser now lives in the
import Link from "next/link"; // integrated dashboard at "/" (the new interface). Kept as a redirect so old
// links and bookmarks land in the right place.
interface GroupSummary { export default function Page() {
id: string; redirect("/");
name: string;
kind: string;
status: string;
}
export default function CompaniesPage() {
const [companies, setCompanies] = useState<GroupSummary[]>([]);
useEffect(() => {
void (async () => {
try {
const r = await fetch("/api/companies");
if (r.ok) setCompanies((await r.json()) as GroupSummary[]);
} catch {
/* ignore */
}
})();
}, []);
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold tracking-tight">Companies</h1>
<Link
href="/claws/new"
className="rounded-full bg-coral px-4 py-2 text-sm font-medium text-white"
>
Deploy a company
</Link>
</div>
{companies.length === 0 ? (
<p className="text-sm text-muted-foreground">
No companies yet — a company is a topology of teams.
</p>
) : (
<ul className="flex flex-col gap-2">
{companies.map((c) => (
<li key={c.id}>
<Link
href={`/companies/${c.id}`}
className="flex items-center gap-3 rounded-lg border border-border p-3 hover:bg-muted/30"
>
<span className="text-sm font-medium text-foreground">{c.name}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs capitalize text-muted-foreground">
{c.kind}
</span>
<span className="text-xs text-muted-foreground">{c.status}</span>
</Link>
</li>
))}
</ul>
)}
</div>
);
} }
+6 -91
View File
@@ -1,93 +1,8 @@
import { Sparkles } from "lucide-react"; import { redirect } from "next/navigation";
import { z } from "zod";
import { BuyCredits } from "@/components/global/BuyCredits"; // Retired as a standalone page: this tool now opens as an in-dashboard panel
import { PageChrome } from "@/components/global/PageChrome"; // (the four-square launcher). Kept as a redirect so old links/bookmarks land in
import { PromoRedeem } from "@/components/global/PromoRedeem"; // the new interface. The tool's content component is reused inside ToolPanel.
import { apiFetch } from "@/lib/api/http"; export default function Page() {
import { fetchCredits } from "@/lib/api/team"; redirect("/");
import { Card } from "@/components/ui/Card";
const UsageSchema = z.object({
tokens_in: z.number(),
tokens_out: z.number(),
credits: z.number(),
});
// Credits page (§8.4): balance + usage meter, promo redemption, and the
// sales card — three cards per the reference layout.
export default async function CreditsPage() {
const [credits, usage] = await Promise.all([
fetchCredits(),
apiFetch(UsageSchema, "/api/team/usage"),
]);
const burn = usage.credits;
const runwayDays =
burn > 0 ? Math.floor((credits.available / burn) * 7) : null;
return (
<PageChrome
title="Credits"
description="Manage your balance, subscriptions, and usage history."
>
<div className="flex flex-col gap-4">
<Card className="flex flex-wrap items-end justify-between gap-4 shadow-card">
<div>
<p className="text-xs tracking-wide text-muted-foreground uppercase">
Available credits
</p>
<p
data-testid="credit-balance"
className={`pt-2 font-mono text-xxxl font-semibold ${
credits.available < 0 ? "text-coral" : ""
}`}
>
{credits.available.toLocaleString("en-US")}
</p>
<p className="pt-2 text-xs text-muted-foreground">
Credits never expire.
</p>
</div>
<BuyCredits />
</Card>
<Card data-testid="usage-card">
<p className="text-xs tracking-wide text-muted-foreground uppercase">
Usage · last 7 days
</p>
<p className="pt-2 text-sm">
{usage.credits.toLocaleString("en-US")} credits ·{" "}
{(usage.tokens_in + usage.tokens_out).toLocaleString("en-US")}{" "}
tokens ({usage.tokens_in.toLocaleString("en-US")} in /{" "}
{usage.tokens_out.toLocaleString("en-US")} out)
</p>
<p className="pt-1 text-xs text-muted-foreground">
{runwayDays !== null
? `~${runwayDays} days of runway at this pace.`
: "No usage yet this week."}
</p>
</Card>
<PromoRedeem />
<Card className="flex items-center gap-4">
<span className="flex size-10 shrink-0 items-center justify-center rounded-2xl bg-surface-warm-muted">
<Sparkles aria-hidden size={18} className="text-coral" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Scaling beyond self-serve?</p>
<p className="text-xs text-muted-foreground">
Volume pricing, SSO, and dedicated support for larger teams.
</p>
</div>
<a
href="mailto:[email protected]"
className="rounded-full border border-border px-4 py-2 text-sm text-foreground transition-colors duration-(--duration-normal) ease-app hover:bg-hover-bg"
>
Talk to sales
</a>
</Card>
</div>
</PageChrome>
);
} }
@@ -1,6 +1,8 @@
import { StructureCanvas } from "@/components/structure/StructureCanvas"; import { redirect } from "next/navigation";
export default async function OrgPage({ params }: { params: Promise<{ id: string }> }) { // Retired: the org / company / team structure browser now lives in the
const { id } = await params; // integrated dashboard at "/" (the new interface). Kept as a redirect so old
return <StructureCanvas level="org" id={id} />; // links and bookmarks land in the right place.
export default function Page() {
redirect("/");
} }
+6 -61
View File
@@ -1,63 +1,8 @@
"use client"; import { redirect } from "next/navigation";
import { useEffect, useState } from "react"; // Retired: the org / company / team structure browser now lives in the
import Link from "next/link"; // integrated dashboard at "/" (the new interface). Kept as a redirect so old
// links and bookmarks land in the right place.
interface GroupSummary { export default function Page() {
id: string; redirect("/");
name: string;
kind: string;
status: string;
}
export default function OrgsPage() {
const [orgs, setOrgs] = useState<GroupSummary[]>([]);
useEffect(() => {
void (async () => {
try {
const r = await fetch("/api/orgs");
if (r.ok) setOrgs((await r.json()) as GroupSummary[]);
} catch {
/* ignore */
}
})();
}, []);
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold tracking-tight">Organizations</h1>
<Link
href="/claws/new"
className="rounded-full bg-coral px-4 py-2 text-sm font-medium text-white"
>
Deploy an org
</Link>
</div>
{orgs.length === 0 ? (
<p className="text-sm text-muted-foreground">
No organizations yet — an org is a topology of companies.
</p>
) : (
<ul className="flex flex-col gap-2">
{orgs.map((o) => (
<li key={o.id}>
<Link
href={`/orgs/${o.id}`}
className="flex items-center gap-3 rounded-lg border border-border p-3 hover:bg-muted/30"
>
<span className="text-sm font-medium text-foreground">{o.name}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs capitalize text-muted-foreground">
{o.kind}
</span>
<span className="text-xs text-muted-foreground">{o.status}</span>
</Link>
</li>
))}
</ul>
)}
</div>
);
} }
+8 -4
View File
@@ -3,17 +3,21 @@ import { redirect } from "next/navigation";
import { Dashboard } from "@/components/dashboard/Dashboard"; import { Dashboard } from "@/components/dashboard/Dashboard";
import { ApiAuthError } from "@/lib/api/http"; import { ApiAuthError } from "@/lib/api/http";
import { fetchMe } from "@/lib/api/team"; import { fetchMe } from "@/lib/api/team";
import type { User } from "@/lib/api/schemas"; import { loadWorkspace } from "@/lib/dashboard-data";
import type { Agent, User } from "@/lib/api/schemas";
import type { DemoOrg } from "@/lib/dashboard-demo";
// Workspace home = the integrated dashboard (tier rail → topology canvas → // Workspace home = the integrated dashboard (tier rail → topology canvas →
// agent computer slide-out). // agent computer slide-out), now driven by the live workspace (real claws +
// org/company/team structure).
export default async function WorkspaceHome() { export default async function WorkspaceHome() {
let user: User; let user: User;
let workspace: { orgs: DemoOrg[]; claws: Agent[] };
try { try {
user = await fetchMe(); [user, workspace] = await Promise.all([fetchMe(), loadWorkspace()]);
} catch (error) { } catch (error) {
if (error instanceof ApiAuthError) redirect("/login"); if (error instanceof ApiAuthError) redirect("/login");
throw error; throw error;
} }
return <Dashboard user={{ display_name: user.display_name, email: user.email }} />; return <Dashboard user={{ display_name: user.display_name, email: user.email }} orgs={workspace.orgs} claws={workspace.claws} />;
} }
+6 -48
View File
@@ -1,50 +1,8 @@
import { z } from "zod"; import { redirect } from "next/navigation";
import { PageChrome } from "@/components/global/PageChrome"; // Retired as a standalone page: this tool now opens as an in-dashboard panel
import { apiFetch } from "@/lib/api/http"; // (the four-square launcher). Kept as a redirect so old links/bookmarks land in
import { Card } from "@/components/ui/Card"; // the new interface. The tool's content component is reused inside ToolPanel.
export default function Page() {
const SkillSchema = z.object({ redirect("/");
id: z.string().uuid(),
title: z.string(),
author: z.string(),
description: z.string(),
installs: z.number(),
});
// The Skill Library (§8.1): catalog + workspace skills, two-column cards.
export default async function SkillsPage() {
const skills = await apiFetch(z.array(SkillSchema), "/api/skills");
return (
<PageChrome
title="Skill Library"
description="Browse skills published by your team and the catalog. Install them on a claw from its Computer → Skills app."
>
{skills.length === 0 ? (
<p className="text-sm text-muted-foreground">
No skills published yet.
</p>
) : (
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{skills.map((skill) => (
<li key={skill.id}>
<Card className="h-full">
<p className="text-lg font-semibold">{skill.title}</p>
<p className="text-xxs text-muted-foreground">
by {skill.author}
</p>
<p className="pt-3 text-xs text-muted-foreground">
{skill.description}
</p>
<p className="pt-4 text-xxs text-muted-foreground">
{skill.installs}{" "}
{skill.installs === 1 ? "install" : "installs"} on your team
</p>
</Card>
</li>
))}
</ul>
)}
</PageChrome>
);
} }
+6 -26
View File
@@ -1,28 +1,8 @@
import { PageChrome } from "@/components/global/PageChrome"; import { redirect } from "next/navigation";
import { TeamTabs } from "@/components/global/TeamTabs";
import {
fetchLeaderboard,
fetchMembers,
fetchOrgChart,
} from "@/lib/api/team";
// Team page (§8.3): members, the claw org chart, and the usage leaderboard. // Retired as a standalone page: this tool now opens as an in-dashboard panel
export default async function TeamPage() { // (the four-square launcher). Kept as a redirect so old links/bookmarks land in
const [members, orgchart, leaderboard] = await Promise.all([ // the new interface. The tool's content component is reused inside ToolPanel.
fetchMembers(), export default function Page() {
fetchOrgChart(), redirect("/");
fetchLeaderboard(),
]);
return (
<PageChrome
title="Team"
description={`${members.length} ${members.length === 1 ? "member" : "members"} in your workspace`}
>
<TeamTabs
members={members}
orgchart={orgchart}
leaderboard={leaderboard}
/>
</PageChrome>
);
} }
@@ -1,6 +1,8 @@
import { StructureCanvas } from "@/components/structure/StructureCanvas"; import { redirect } from "next/navigation";
export default async function TeamPage({ params }: { params: Promise<{ id: string }> }) { // Retired: the org / company / team structure browser now lives in the
const { id } = await params; // integrated dashboard at "/" (the new interface). Kept as a redirect so old
return <StructureCanvas level="team" id={id} />; // links and bookmarks land in the right place.
export default function Page() {
redirect("/");
} }
+6 -62
View File
@@ -1,64 +1,8 @@
"use client"; import { redirect } from "next/navigation";
import { useEffect, useState } from "react"; // Retired: the org / company / team structure browser now lives in the
import Link from "next/link"; // integrated dashboard at "/" (the new interface). Kept as a redirect so old
// links and bookmarks land in the right place.
interface TeamSummary { export default function Page() {
id: string; redirect("/");
name: string;
kind: string;
status: string;
created_at: string;
}
export default function TeamsPage() {
const [teams, setTeams] = useState<TeamSummary[]>([]);
useEffect(() => {
void (async () => {
try {
const r = await fetch("/api/teams");
if (r.ok) setTeams((await r.json()) as TeamSummary[]);
} catch {
/* ignore */
}
})();
}, []);
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold tracking-tight">Teams</h1>
<Link
href="/claws/new"
className="rounded-full bg-coral px-4 py-2 text-sm font-medium text-white"
>
Deploy a team
</Link>
</div>
{teams.length === 0 ? (
<p className="text-sm text-muted-foreground">
No teams yet — deploy a baseline topology staffed with claws.
</p>
) : (
<ul className="flex flex-col gap-2">
{teams.map((t) => (
<li key={t.id}>
<Link
href={`/teams/${t.id}`}
className="flex items-center gap-3 rounded-lg border border-border p-3 hover:bg-muted/30"
>
<span className="text-sm font-medium text-foreground">{t.name}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs capitalize text-muted-foreground">
{t.kind}
</span>
<span className="text-xs text-muted-foreground">{t.status}</span>
</Link>
</li>
))}
</ul>
)}
</div>
);
} }
@@ -1,16 +1,8 @@
import { PageChrome } from "@/components/global/PageChrome"; import { redirect } from "next/navigation";
import { TopologyWorkbench } from "@/components/topology/TopologyWorkbench";
import { fetchTopologyCatalog } from "@/lib/api/topology";
// Topologies page: browse the catalog and build/visualize a topology. // Retired as a standalone page: this tool now opens as an in-dashboard panel
export default async function TopologiesPage() { // (the four-square launcher). Kept as a redirect so old links/bookmarks land in
const catalog = await fetchTopologyCatalog(); // the new interface. The tool's content component is reused inside ToolPanel.
return ( export default function Page() {
<PageChrome redirect("/");
title="Topologies"
description={`${catalog.length} organizational patterns to run your agents in`}
>
<TopologyWorkbench catalog={catalog} />
</PageChrome>
);
} }
+13 -6
View File
@@ -7,21 +7,28 @@ import { NextResponse, type NextRequest } from "next/server";
import { apiOrigin } from "@/lib/api/http"; import { apiOrigin } from "@/lib/api/http";
import { resolveBearer } from "@/lib/auth/bearer"; import { resolveBearer } from "@/lib/auth/bearer";
// Public API prefixes that don't require a session: the inbound webhook trigger
// authenticates by its unguessable token in the path, not the user cookie.
const PUBLIC_PREFIXES = ["hooks/"];
async function proxy( async function proxy(
request: NextRequest, request: NextRequest,
context: { params: Promise<{ path: string[] }> }, context: { params: Promise<{ path: string[] }> },
) { ) {
const { path } = await context.params;
const joined = path.join("/");
const isPublic = PUBLIC_PREFIXES.some((p) => joined.startsWith(p));
const token = await resolveBearer(); const token = await resolveBearer();
if (!token) { if (!token && !isPublic) {
return NextResponse.json({ error: "unauthenticated" }, { status: 401 }); return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
} }
const { path } = await context.params; const url = new URL(`/api/${joined}`, apiOrigin());
const url = new URL(`/api/${path.join("/")}`, apiOrigin());
url.search = request.nextUrl.search; url.search = request.nextUrl.search;
const headers: Record<string, string> = { const headers: Record<string, string> = {};
Authorization: `Bearer ${token}`, if (token) {
}; headers["Authorization"] = `Bearer ${token}`;
}
const contentType = request.headers.get("content-type"); const contentType = request.headers.get("content-type");
if (contentType) { if (contentType) {
headers["Content-Type"] = contentType; headers["Content-Type"] = contentType;
@@ -0,0 +1,62 @@
// Local Next route (NOT proxied — a specific path beats the /api/[...path]
// catch-all): generates an agent avatar with Gemini's image model ("Nano
// Banana", gemini-2.5-flash-image) using GEMINI_API_KEY from the frontend env,
// and returns a base64 data URL. Saving the chosen image is a separate
// PATCH /api/claws/{id} {avatar} (the existing backend route).
import { NextResponse, type NextRequest } from "next/server";
import { resolveBearer } from "@/lib/auth/bearer";
const MODEL = "gemini-2.5-flash-image";
const ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
interface InlineData { data?: string; mimeType?: string; mime_type?: string }
interface GeminiPart { inlineData?: InlineData; inline_data?: InlineData }
interface GeminiResponse { candidates?: Array<{ content?: { parts?: GeminiPart[] } }> }
export async function POST(request: NextRequest) {
const token = await resolveBearer();
if (!token) return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
const key = process.env.GEMINI_API_KEY;
if (!key) return NextResponse.json({ error: "image generation is not configured (set GEMINI_API_KEY)" }, { status: 503 });
let prompt = "";
try {
const body = (await request.json()) as { prompt?: unknown };
prompt = String(body?.prompt ?? "").trim();
} catch {
/* fall through to the 400 below */
}
if (!prompt) return NextResponse.json({ error: "prompt required" }, { status: 400 });
let upstream: Response;
try {
upstream = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", "x-goog-api-key": key },
body: JSON.stringify({
contents: [{ parts: [{ text: `A clean, centered square avatar portrait for an AI agent. ${prompt}` }] }],
generationConfig: { responseModalities: ["IMAGE"] },
}),
});
} catch {
return NextResponse.json({ error: "could not reach the image service" }, { status: 502 });
}
if (!upstream.ok) {
const detail = await upstream.text().catch(() => "");
return NextResponse.json({ error: `image service error (${upstream.status})`, detail: detail.slice(0, 400) }, { status: 502 });
}
const data = (await upstream.json().catch(() => null)) as GeminiResponse | null;
const parts = data?.candidates?.[0]?.content?.parts ?? [];
const part = parts.find((p) => p.inlineData?.data || p.inline_data?.data);
const inline = part?.inlineData ?? part?.inline_data;
if (!inline?.data) {
return NextResponse.json({ error: "the model did not return an image — try a different prompt" }, { status: 502 });
}
const mime = inline.mimeType ?? inline.mime_type ?? "image/png";
return NextResponse.json({ image: `data:${mime};base64,${inline.data}` });
}
+1 -1
View File
@@ -42,7 +42,7 @@ const geistMono = localFont({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Clawmates", title: "Clawmates",
description: "Deploy agents at any scale — a single claw to a whole org.", description: "Deploy agents at any scale — a single agent to a whole org.",
}; };
export default function RootLayout({ export default function RootLayout({
+4 -1
View File
@@ -41,7 +41,10 @@ export default async function LoginPage() {
const { SignIn } = await import("@clerk/nextjs"); const { SignIn } = await import("@clerk/nextjs");
return ( return (
<AuthShell> <AuthShell>
<SignIn routing="hash" appearance={clerkAppearance} /> {/* Always land on the workspace home after sign-in (the middleware then
resolves "/" to the dashboard for authed users). Without this Clerk
can bounce back to a redirect_url like /marketing. */}
<SignIn routing="hash" forceRedirectUrl="/" appearance={clerkAppearance} />
</AuthShell> </AuthShell>
); );
} }
+1 -1
View File
@@ -6,7 +6,7 @@ export default function manifest(): MetadataRoute.Manifest {
return { return {
name: "Clawmates", name: "Clawmates",
short_name: "Clawmates", short_name: "Clawmates",
description: "Deploy agents at any scale — a single claw to a whole org.", description: "Deploy agents at any scale — a single agent to a whole org.",
start_url: "/", start_url: "/",
display: "standalone", display: "standalone",
background_color: "#08080a", background_color: "#08080a",
+1 -1
View File
@@ -87,7 +87,7 @@ export function AuthShell({ children }: { children: ReactNode }) {
<div className="relative z-10 max-w-[380px]"> <div className="relative z-10 max-w-[380px]">
<div className="mb-2.5 text-2xl font-bold leading-snug tracking-[-0.02em] text-balance"> <div className="mb-2.5 text-2xl font-bold leading-snug tracking-[-0.02em] text-balance">
Deploy agents at any scale — a single claw to a whole org. Deploy agents at any scale — a single agent to a whole org.
</div> </div>
<div className="font-mono text-[11px] tracking-[0.04em] text-[#6a6a72]"> <div className="font-mono text-[11px] tracking-[0.04em] text-[#6a6a72]">
12 topologies · durable runner · §15-safe 12 topologies · durable runner · §15-safe
+5 -3
View File
@@ -7,6 +7,7 @@ import type { Agent } from "@/lib/api/schemas";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import { ApprovalCard } from "@/components/safety/ApprovalCard"; import { ApprovalCard } from "@/components/safety/ApprovalCard";
import { StepTrace } from "./steps/StepTrace"; import { StepTrace } from "./steps/StepTrace";
import { RichText } from "./RichText";
type DecideFn = (approvalId: string, decision: "approve" | "reject") => void; type DecideFn = (approvalId: string, decision: "approve" | "reject") => void;
@@ -42,19 +43,20 @@ function AgentMessage({
<Avatar <Avatar
name={agent.name} name={agent.name}
accent={agent.accent} accent={agent.accent}
image={agent.avatar || undefined}
size="chat" size="chat"
shape="squircle" shape="squircle"
/> />
<div className="min-w-0 flex-1 pt-3"> <div className="min-w-0 flex-1 pt-3">
<p className="text-sm leading-[1.7] whitespace-pre-wrap text-neutral-200"> <div className="text-sm leading-[1.7] text-neutral-200">
{message.text} <RichText text={message.text} />
{message.status === "streaming" && ( {message.status === "streaming" && (
<span <span
aria-hidden aria-hidden
className="ml-0.5 inline-block h-4 w-0.5 translate-y-0.5 bg-coral motion-safe:animate-[caret-blink_1s_steps(1)_infinite]" className="ml-0.5 inline-block h-4 w-0.5 translate-y-0.5 bg-coral motion-safe:animate-[caret-blink_1s_steps(1)_infinite]"
/> />
)} )}
</p> </div>
{message.status === "error" && ( {message.status === "error" && (
<p role="alert" className="pt-1 text-xs text-coral"> <p role="alert" className="pt-1 text-xs text-coral">
Something went wrong with this reply. Something went wrong with this reply.
+143
View File
@@ -0,0 +1,143 @@
"use client";
// A small, dependency-free renderer for chat message text: a practical Markdown
// subset (headings, bold/italic, inline code, links, ordered/unordered lists,
// blockquotes, fenced code) plus JSON pretty-printing. Everything is rendered as
// React elements (text is auto-escaped → XSS-safe) and themed for legibility.
import { Fragment, type ReactNode } from "react";
function tryJson(s: string): string | null {
const t = s.trim();
if (!(t.startsWith("{") || t.startsWith("["))) return null;
try {
return JSON.stringify(JSON.parse(t), null, 2);
} catch {
return null;
}
}
function CodeBlock({ lang, code }: { lang?: string; code: string }) {
let body = code.replace(/\n$/, "");
let label = lang || "";
if (lang === "json" || !lang) {
const pretty = tryJson(body);
if (pretty) { body = pretty; label = "json"; }
}
return (
<pre className="my-2 overflow-x-auto rounded-lg border border-white/10 bg-black/40 p-3">
{label ? (
<div className="mb-1.5 font-mono text-[10px] tracking-wide text-neutral-500 uppercase">{label}</div>
) : null}
<code className="font-mono text-[12.5px] leading-relaxed whitespace-pre text-neutral-200">{body}</code>
</pre>
);
}
// Inline formatting: `code`, **bold**, *italic*/_italic_, [text](url).
function Inline({ text }: { text: string }): ReactNode {
const out: ReactNode[] = [];
const re = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*\s][^*]*\*|_[^_\s][^_]*_)|(\[[^\]]+\]\([^)\s]+\))/g;
let last = 0;
let k = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
if (m.index > last) out.push(text.slice(last, m.index));
const tok = m[0];
if (tok.startsWith("`")) {
out.push(<code key={k++} className="rounded bg-white/10 px-1 py-0.5 font-mono text-[0.85em] text-neutral-100">{tok.slice(1, -1)}</code>);
} else if (tok.startsWith("**")) {
out.push(<strong key={k++} className="font-semibold text-neutral-100">{tok.slice(2, -2)}</strong>);
} else if (tok.startsWith("[")) {
const mm = /\[([^\]]+)\]\(([^)\s]+)\)/.exec(tok);
if (mm) out.push(<a key={k++} href={mm[2]} target="_blank" rel="noreferrer" className="text-coral underline underline-offset-2">{mm[1]}</a>);
else out.push(tok);
} else {
out.push(<em key={k++} className="italic">{tok.slice(1, -1)}</em>);
}
last = m.index + tok.length;
}
if (last < text.length) out.push(text.slice(last));
return out;
}
// Block-level Markdown for non-code text.
function Prose({ text }: { text: string }) {
const lines = text.replace(/\s+$/, "").split("\n");
const blocks: ReactNode[] = [];
let para: string[] = [];
let list: { ordered: boolean; items: string[] } | null = null;
let k = 0;
const flushPara = () => {
if (!para.length) return;
const buf = para;
blocks.push(
<p key={k++} className="mb-2 last:mb-0">
{buf.map((l, ix) => (
<Fragment key={ix}>{ix > 0 ? <br /> : null}<Inline text={l} /></Fragment>
))}
</p>,
);
para = [];
};
const flushList = () => {
if (!list) return;
const L = list;
blocks.push(
L.ordered ? (
<ol key={k++} className="mb-2 list-decimal space-y-1 pl-5">{L.items.map((it, ix) => <li key={ix}><Inline text={it} /></li>)}</ol>
) : (
<ul key={k++} className="mb-2 list-disc space-y-1 pl-5">{L.items.map((it, ix) => <li key={ix}><Inline text={it} /></li>)}</ul>
),
);
list = null;
};
for (const line of lines) {
if (!line.trim()) { flushPara(); flushList(); continue; }
const h = /^(#{1,6})\s+(.*)$/.exec(line);
const ul = /^\s*[-*+]\s+(.*)$/.exec(line);
const ol = /^\s*\d+\.\s+(.*)$/.exec(line);
const bq = /^>\s?(.*)$/.exec(line);
if (h) {
flushPara(); flushList();
const big = h[1].length <= 2;
blocks.push(<p key={k++} className={`mb-1.5 font-semibold text-neutral-100 ${big ? "text-[15px]" : "text-[13.5px]"}`}><Inline text={h[2]} /></p>);
} else if (ul) {
flushPara();
if (!list || list.ordered) { flushList(); list = { ordered: false, items: [] }; }
list.items.push(ul[1]);
} else if (ol) {
flushPara();
if (!list || !list.ordered) { flushList(); list = { ordered: true, items: [] }; }
list.items.push(ol[1]);
} else if (bq) {
flushPara(); flushList();
blocks.push(<blockquote key={k++} className="mb-2 border-l-2 border-white/20 pl-3 text-neutral-400"><Inline text={bq[1]} /></blockquote>);
} else {
flushList();
para.push(line);
}
}
flushPara(); flushList();
return <>{blocks}</>;
}
export function RichText({ text, className }: { text: string; className?: string }) {
// Whole-message JSON → one pretty block.
const whole = tryJson(text);
if (whole) {
return <div className={className}><CodeBlock lang="json" code={whole} /></div>;
}
// Split out fenced code; render the rest as prose.
const parts: ReactNode[] = [];
let last = 0;
let k = 0;
for (const m of text.matchAll(/```([\w-]*)\r?\n?([\s\S]*?)```/g)) {
const idx = m.index ?? 0;
if (idx > last) parts.push(<Prose key={k++} text={text.slice(last, idx)} />);
parts.push(<CodeBlock key={k++} lang={m[1]} code={m[2]} />);
last = idx + m[0].length;
}
if (last < text.length) parts.push(<Prose key={k++} text={text.slice(last)} />);
return <div className={className}>{parts}</div>;
}
+1 -1
View File
@@ -32,7 +32,7 @@ const TINT: Record<string, string> = {
export function ClawAnatomy({ clawId }: { clawId: string }) { export function ClawAnatomy({ clawId }: { clawId: string }) {
const [compartments, setCompartments] = useState<Compartment[]>([]); const [compartments, setCompartments] = useState<Compartment[]>([]);
const [cfg, setCfg] = useState<RuntimeConfig | null>(null); const [cfg, setCfg] = useState<RuntimeConfig | null>(null);
const [name, setName] = useState("Claw"); const [name, setName] = useState("Agent");
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
useEffect(() => { useEffect(() => {
@@ -22,7 +22,7 @@ export function appTitle(app: AppId): string {
case "slack": case "slack":
return "Slack"; return "Slack";
case "chat": case "chat":
return "Claw Chat"; return "Agent Chat";
case "skills": case "skills":
return "Skills"; return "Skills";
case "files": case "files":
@@ -74,7 +74,7 @@ export default function ClawChatApp({ agent }: { agent: Agent }) {
<PanelEmptyState <PanelEmptyState
icon={MessageCircle} icon={MessageCircle}
title="No conversations yet" title="No conversations yet"
subtitle="No claw-to-claw conversations yet." subtitle="No agent-to-agent conversations yet."
/> />
) : ( ) : (
<ul aria-label="Threads"> <ul aria-label="Threads">
@@ -41,7 +41,7 @@ export default function RoutinesApp({ agent }: { agent: Agent }) {
<PanelEmptyState <PanelEmptyState
icon={Bell} icon={Bell}
title="No routines yet" title="No routines yet"
subtitle="Ask your claw to set up a routine — daily digest, newsletter, calendar block." subtitle="Ask your agent to set up a routine — daily digest, newsletter, calendar block."
/> />
) : ( ) : (
<ul aria-label="Routines" className="flex-1"> <ul aria-label="Routines" className="flex-1">
@@ -98,7 +98,7 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
name="system_prompt" name="system_prompt"
rows={5} rows={5}
defaultValue={live.system_prompt} defaultValue={live.system_prompt}
placeholder="Describe how this claw should think..." placeholder="Describe how this agent should think..."
className="rounded-xl border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-coral" className="rounded-xl border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-coral"
/> />
<span className="text-xxs">Becomes part of its system prompt.</span> <span className="text-xxs">Becomes part of its system prompt.</span>
@@ -126,7 +126,7 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
onClick={deleteClaw} onClick={deleteClaw}
className="rounded-full bg-destructive px-3 py-1.5 text-foreground" className="rounded-full bg-destructive px-3 py-1.5 text-foreground"
> >
Delete claw Delete agent
</button> </button>
</div> </div>
</div> </div>
@@ -136,7 +136,7 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
onClick={() => setConfirmingDelete(true)} onClick={() => setConfirmingDelete(true)}
className="flex items-center gap-1.5 self-start text-xs text-coral transition-colors hover:text-coral-light" className="flex items-center gap-1.5 self-start text-xs text-coral transition-colors hover:text-coral-light"
> >
<Trash2 aria-hidden size={13} /> Delete claw <Trash2 aria-hidden size={13} /> Delete agent
</button> </button>
)} )}
</form> </form>
@@ -176,10 +176,10 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
</p> </p>
<div <div
role="radiogroup" role="radiogroup"
aria-label="Other Claws" aria-label="Other Agents"
className="mx-6 rounded-xl bg-neutral-900 p-2 text-xs ring-1 ring-neutral-800/70" className="mx-6 rounded-xl bg-neutral-900 p-2 text-xs ring-1 ring-neutral-800/70"
> >
<p className="px-1 pb-1 text-muted-foreground">Other Claws</p> <p className="px-1 pb-1 text-muted-foreground">Other Agents</p>
{(["any", "specific"] as const).map((mode) => ( {(["any", "specific"] as const).map((mode) => (
<button <button
key={mode} key={mode}
@@ -191,8 +191,8 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
> >
<span aria-hidden className={agentsMode === mode ? "text-coral" : "text-muted-foreground"}>{agentsMode === mode ? "◉" : "○"}</span> <span aria-hidden className={agentsMode === mode ? "text-coral" : "text-muted-foreground"}>{agentsMode === mode ? "◉" : "○"}</span>
{mode === "any" {mode === "any"
? "Any Claw on the team" ? "Any Agent on the team"
: "Specific claws — pick claws"} : "Specific agents — pick agents"}
</button> </button>
))} ))}
</div> </div>
@@ -0,0 +1,105 @@
"use client";
// "Add to company" workflow: name a new company and pick which existing teams
// belong to it, then create it (POST /api/companies — composes existing teams,
// provisions nothing) and land on that company's page (?company=<id> + refresh).
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Building2, Check, X } from "lucide-react";
const mono = "'JetBrains Mono', ui-monospace, monospace";
export interface PickableTeam { id: string; name: string; claws: number; topology: string }
export function AddToCompanyModal({ teams, onClose }: { teams: PickableTeam[]; onClose: () => void }) {
const router = useRouter();
const [name, setName] = useState("");
const [picked, setPicked] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
function toggle(id: string) {
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
const canCreate = name.trim().length > 0 && picked.size > 0 && !busy;
async function create() {
if (!canCreate) return;
setBusy(true);
setError(null);
try {
const res = await fetch("/api/companies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), kind: "hub_spoke", members: [...picked].map((team_id) => ({ team_id, role: "team" })) }),
});
if (res.status !== 201) { setError(`Could not create company (${res.status})`); setBusy(false); return; }
const { company_id } = (await res.json()) as { company_id: string };
onClose();
router.push(`/?company=${encodeURIComponent(company_id)}`);
router.refresh();
} catch {
setError("Could not create company");
setBusy(false);
}
}
return (
<div onClick={onClose} role="presentation" style={{ position: "fixed", inset: 0, zIndex: 110, background: "rgba(0,0,0,.62)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24, animation: "cm-fade .18s ease" }}>
<div onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Add to company" style={{ width: "100%", maxWidth: 520, maxHeight: "84vh", display: "flex", flexDirection: "column", borderRadius: 16, background: "#0d0d10", border: "1px solid rgba(255,255,255,.1)", boxShadow: "0 30px 90px rgba(0,0,0,.6)", padding: 22, animation: "scale-in .18s ease" }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12, marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 18, fontWeight: 700, color: "#f3f3f5" }}><Building2 size={17} color="#9a8cff" /> New company</div>
<div style={{ fontSize: 12.5, color: "#8a8a92", marginTop: 2 }}>Name it, then pick the teams to group into it.</div>
</div>
<button type="button" onClick={onClose} aria-label="Close" style={{ width: 30, height: 30, flex: "none", borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><X size={15} /></button>
</div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Company name (e.g. Acme)"
style={{ width: "100%", borderRadius: 9, border: "1px solid rgba(255,255,255,.12)", background: "#101014", color: "#eaeaee", fontSize: 13.5, padding: "10px 12px", outline: "none", marginBottom: 14 }}
/>
<div style={{ fontFamily: mono, fontSize: 9, letterSpacing: ".1em", color: "#5a5a62", marginBottom: 8 }}>TEAMS · {picked.size} SELECTED</div>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", display: "flex", flexDirection: "column", gap: 6 }}>
{teams.length === 0 ? (
<span style={{ fontFamily: mono, fontSize: 11, color: "#6a6a72", padding: 8 }}>No teams yet — create one first with “Add to teams”.</span>
) : teams.map((t) => {
const on = picked.has(t.id);
return (
<button key={t.id} type="button" onClick={() => toggle(t.id)} style={{ display: "flex", alignItems: "center", gap: 10, padding: "9px 10px", borderRadius: 10, cursor: "pointer", textAlign: "left", background: on ? "rgba(154,140,255,.14)" : "#101014", border: on ? "1px solid rgba(154,140,255,.45)" : "1px solid rgba(255,255,255,.06)" }}>
<span style={{ width: 26, height: 26, flex: "none", borderRadius: 7, background: "rgba(154,140,255,.18)", display: "flex", alignItems: "center", justifyContent: "center", color: "#c9c0ff" }}><Building2 size={14} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: "#eaeaee" }}>{t.name}</div>
<div style={{ fontFamily: mono, fontSize: 9, color: "#6a6a72" }}>{t.claws} claw{t.claws === 1 ? "" : "s"}</div>
</div>
<span style={{ width: 18, height: 18, flex: "none", borderRadius: 5, border: on ? "0" : "1px solid rgba(255,255,255,.2)", background: on ? "#9a8cff" : "transparent", display: "flex", alignItems: "center", justifyContent: "center", color: "#0a0a0c" }}>{on ? <Check size={13} /> : null}</span>
</button>
);
})}
</div>
{error ? <div role="alert" style={{ marginTop: 12, fontFamily: mono, fontSize: 10.5, color: "#ff8a7a" }}>{error}</div> : null}
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<button type="button" onClick={onClose} style={{ flex: 1, padding: "9px 0", borderRadius: 9, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#cfcfd5", fontSize: 13, cursor: "pointer" }}>Cancel</button>
<button type="button" onClick={create} disabled={!canCreate} style={{ flex: 1, padding: "9px 0", borderRadius: 9, border: 0, background: canCreate ? "#9a8cff" : "rgba(154,140,255,.4)", color: "#0d0820", fontSize: 13, fontWeight: 700, cursor: canCreate ? "pointer" : "default" }}>{busy ? "Creating…" : "Create company"}</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,105 @@
"use client";
// "Add to organization" workflow: name a new org and pick which existing
// companies belong to it (POST /api/orgs — composes existing companies), then
// land on that org's page (?org=<id> + refresh).
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Check, Network, X } from "lucide-react";
const mono = "'JetBrains Mono', ui-monospace, monospace";
export interface PickableCompany { id: string; name: string; teams: number }
export function AddToOrgModal({ companies, onClose }: { companies: PickableCompany[]; onClose: () => void }) {
const router = useRouter();
const [name, setName] = useState("");
const [picked, setPicked] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
function toggle(id: string) {
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
const canCreate = name.trim().length > 0 && picked.size > 0 && !busy;
async function create() {
if (!canCreate) return;
setBusy(true);
setError(null);
try {
const res = await fetch("/api/orgs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), kind: "hub_spoke", members: [...picked].map((company_id) => ({ company_id, role: "company" })) }),
});
if (res.status !== 201) { setError(`Could not create organization (${res.status})`); setBusy(false); return; }
const { org_id } = (await res.json()) as { org_id: string };
onClose();
router.push(`/?org=${encodeURIComponent(org_id)}`);
router.refresh();
} catch {
setError("Could not create organization");
setBusy(false);
}
}
return (
<div onClick={onClose} role="presentation" style={{ position: "fixed", inset: 0, zIndex: 110, background: "rgba(0,0,0,.62)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24, animation: "cm-fade .18s ease" }}>
<div onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Add to organization" style={{ width: "100%", maxWidth: 520, maxHeight: "84vh", display: "flex", flexDirection: "column", borderRadius: 16, background: "#0d0d10", border: "1px solid rgba(255,255,255,.1)", boxShadow: "0 30px 90px rgba(0,0,0,.6)", padding: 22, animation: "scale-in .18s ease" }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12, marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 18, fontWeight: 700, color: "#f3f3f5" }}><Network size={17} color="#e8b465" /> New organization</div>
<div style={{ fontSize: 12.5, color: "#8a8a92", marginTop: 2 }}>Name it, then pick the companies to group into it.</div>
</div>
<button type="button" onClick={onClose} aria-label="Close" style={{ width: 30, height: 30, flex: "none", borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><X size={15} /></button>
</div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Organization name (e.g. Acme Holdings)"
style={{ width: "100%", borderRadius: 9, border: "1px solid rgba(255,255,255,.12)", background: "#101014", color: "#eaeaee", fontSize: 13.5, padding: "10px 12px", outline: "none", marginBottom: 14 }}
/>
<div style={{ fontFamily: mono, fontSize: 9, letterSpacing: ".1em", color: "#5a5a62", marginBottom: 8 }}>COMPANIES · {picked.size} SELECTED</div>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", display: "flex", flexDirection: "column", gap: 6 }}>
{companies.length === 0 ? (
<span style={{ fontFamily: mono, fontSize: 11, color: "#6a6a72", padding: 8 }}>No companies yet — create one first with “Add to company”.</span>
) : companies.map((c) => {
const on = picked.has(c.id);
return (
<button key={c.id} type="button" onClick={() => toggle(c.id)} style={{ display: "flex", alignItems: "center", gap: 10, padding: "9px 10px", borderRadius: 10, cursor: "pointer", textAlign: "left", background: on ? "rgba(232,180,101,.14)" : "#101014", border: on ? "1px solid rgba(232,180,101,.45)" : "1px solid rgba(255,255,255,.06)" }}>
<span style={{ width: 26, height: 26, flex: "none", borderRadius: 7, background: "rgba(232,180,101,.18)", display: "flex", alignItems: "center", justifyContent: "center", color: "#f0cd8f" }}><Network size={14} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: "#eaeaee" }}>{c.name}</div>
<div style={{ fontFamily: mono, fontSize: 9, color: "#6a6a72" }}>{c.teams} team{c.teams === 1 ? "" : "s"}</div>
</div>
<span style={{ width: 18, height: 18, flex: "none", borderRadius: 5, border: on ? "0" : "1px solid rgba(255,255,255,.2)", background: on ? "#e8b465" : "transparent", display: "flex", alignItems: "center", justifyContent: "center", color: "#0a0a0c" }}>{on ? <Check size={13} /> : null}</span>
</button>
);
})}
</div>
{error ? <div role="alert" style={{ marginTop: 12, fontFamily: mono, fontSize: 10.5, color: "#ff8a7a" }}>{error}</div> : null}
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<button type="button" onClick={onClose} style={{ flex: 1, padding: "9px 0", borderRadius: 9, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#cfcfd5", fontSize: 13, cursor: "pointer" }}>Cancel</button>
<button type="button" onClick={create} disabled={!canCreate} style={{ flex: 1, padding: "9px 0", borderRadius: 9, border: 0, background: canCreate ? "#e8b465" : "rgba(232,180,101,.4)", color: "#231803", fontSize: 13, fontWeight: 700, cursor: canCreate ? "pointer" : "default" }}>{busy ? "Creating…" : "Create organization"}</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,105 @@
"use client";
// "Add to teams" workflow: name a new team and pick which existing claws belong
// to it, then create it (POST /api/teams/from-claws — groups the EXISTING claws,
// no re-provisioning) and land on that team's page (?team=<id> + refresh).
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Check, Users, X } from "lucide-react";
import type { Agent } from "@/lib/api/schemas";
const mono = "'JetBrains Mono', ui-monospace, monospace";
export function AddToTeamModal({ claws, onClose }: { claws: Agent[]; onClose: () => void }) {
const router = useRouter();
const [name, setName] = useState("");
const [picked, setPicked] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
function toggle(id: string) {
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
const canCreate = name.trim().length > 0 && picked.size > 0 && !busy;
async function create() {
if (!canCreate) return;
setBusy(true);
setError(null);
try {
const res = await fetch("/api/teams/from-claws", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), kind: "hub_spoke", claw_ids: [...picked] }),
});
if (res.status !== 201) { setError(`Could not create team (${res.status})`); setBusy(false); return; }
const { team_id } = (await res.json()) as { team_id: string };
onClose();
router.push(`/?team=${encodeURIComponent(team_id)}`);
router.refresh();
} catch {
setError("Could not create team");
setBusy(false);
}
}
return (
<div onClick={onClose} role="presentation" style={{ position: "fixed", inset: 0, zIndex: 110, background: "rgba(0,0,0,.62)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24, animation: "cm-fade .18s ease" }}>
<div onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Add to teams" style={{ width: "100%", maxWidth: 520, maxHeight: "84vh", display: "flex", flexDirection: "column", borderRadius: 16, background: "#0d0d10", border: "1px solid rgba(255,255,255,.1)", boxShadow: "0 30px 90px rgba(0,0,0,.6)", padding: 22, animation: "scale-in .18s ease" }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12, marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 18, fontWeight: 700, color: "#f3f3f5" }}><Users size={17} color="#5ec8d8" /> New team</div>
<div style={{ fontSize: 12.5, color: "#8a8a92", marginTop: 2 }}>Name it, then pick the agents to group into it.</div>
</div>
<button type="button" onClick={onClose} aria-label="Close" style={{ width: 30, height: 30, flex: "none", borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><X size={15} /></button>
</div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Team name (e.g. Growth)"
style={{ width: "100%", borderRadius: 9, border: "1px solid rgba(255,255,255,.12)", background: "#101014", color: "#eaeaee", fontSize: 13.5, padding: "10px 12px", outline: "none", marginBottom: 14 }}
/>
<div style={{ fontFamily: mono, fontSize: 9, letterSpacing: ".1em", color: "#5a5a62", marginBottom: 8 }}>CLAWS · {picked.size} SELECTED</div>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", display: "flex", flexDirection: "column", gap: 6 }}>
{claws.length === 0 ? (
<span style={{ fontFamily: mono, fontSize: 11, color: "#6a6a72", padding: 8 }}>No agents yet — create one first.</span>
) : claws.map((a) => {
const on = picked.has(a.id);
return (
<button key={a.id} type="button" onClick={() => toggle(a.id)} style={{ display: "flex", alignItems: "center", gap: 10, padding: "9px 10px", borderRadius: 10, cursor: "pointer", textAlign: "left", background: on ? "rgba(94,200,216,.12)" : "#101014", border: on ? "1px solid rgba(94,200,216,.4)" : "1px solid rgba(255,255,255,.06)" }}>
<span style={{ width: 26, height: 26, flex: "none", borderRadius: 7, background: a.avatar ? `center/cover no-repeat url(${a.avatar})` : (a.accent || "#ff6f61"), display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, fontWeight: 700, color: "#fff" }}>{a.avatar ? "" : (a.name[0] || "?").toUpperCase()}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: "#eaeaee" }}>{a.name}</div>
<div style={{ fontFamily: mono, fontSize: 9, color: "#6a6a72" }}>{a.job_title || "agent"}</div>
</div>
<span style={{ width: 18, height: 18, flex: "none", borderRadius: 5, border: on ? "0" : "1px solid rgba(255,255,255,.2)", background: on ? "#5ec8d8" : "transparent", display: "flex", alignItems: "center", justifyContent: "center", color: "#0a0a0c" }}>{on ? <Check size={13} /> : null}</span>
</button>
);
})}
</div>
{error ? <div role="alert" style={{ marginTop: 12, fontFamily: mono, fontSize: 10.5, color: "#ff8a7a" }}>{error}</div> : null}
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<button type="button" onClick={onClose} style={{ flex: 1, padding: "9px 0", borderRadius: 9, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#cfcfd5", fontSize: 13, cursor: "pointer" }}>Cancel</button>
<button type="button" onClick={create} disabled={!canCreate} style={{ flex: 1, padding: "9px 0", borderRadius: 9, border: 0, background: canCreate ? "#5ec8d8" : "rgba(94,200,216,.4)", color: "#062028", fontSize: 13, fontWeight: 700, cursor: canCreate ? "pointer" : "default" }}>{busy ? "Creating…" : "Create team"}</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,88 @@
"use client";
// "Add tool" modal for a claw: browse the installable capability catalog
// (GET /api/skills — the backend's catalog of tools/skills an agent can be
// given) and assign one to this agent (POST /api/skills/install). On change it
// calls onAdded so the anatomy re-fetches its compartments.
import { useEffect, useMemo, useState } from "react";
import { Check, Plus, Search, X } from "lucide-react";
import { useJson } from "./metrics";
const mono = "'JetBrains Mono', ui-monospace, monospace";
interface Skill { id: string; title: string; author: string; description: string; installs: number }
export function AddToolModal({ clawId, clawName, onClose, onAdded }: { clawId: string; clawName: string; onClose: () => void; onAdded: () => void }) {
const { data: catalog, loading } = useJson<Skill[]>("/api/skills");
const { data: installed } = useJson<Skill[]>(`/api/skills?clawId=${encodeURIComponent(clawId)}`);
const [added, setAdded] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState<string | null>(null);
const [q, setQ] = useState("");
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const installedIds = useMemo(() => new Set([...(installed ?? []).map((s) => s.id), ...added]), [installed, added]);
const rows = useMemo(() => {
const list = catalog ?? [];
const needle = q.trim().toLowerCase();
return needle ? list.filter((s) => `${s.title} ${s.description} ${s.author}`.toLowerCase().includes(needle)) : list;
}, [catalog, q]);
async function install(id: string) {
if (busy) return;
setBusy(id);
try {
const res = await fetch("/api/skills/install", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clawId, skillId: id }) });
if (res.ok) { setAdded((p) => new Set(p).add(id)); onAdded(); }
} catch {
/* ignore — row stays addable */
}
setBusy(null);
}
return (
<div onClick={onClose} role="presentation" style={{ position: "fixed", inset: 0, zIndex: 110, background: "rgba(0,0,0,.62)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24, animation: "cm-fade .18s ease" }}>
<div onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Add tool" style={{ width: "100%", maxWidth: 520, maxHeight: "82vh", display: "flex", flexDirection: "column", borderRadius: 16, background: "#0d0d10", border: "1px solid rgba(255,255,255,.1)", boxShadow: "0 30px 90px rgba(0,0,0,.6)", padding: 22, animation: "scale-in .18s ease" }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12, marginBottom: 14 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: "#f3f3f5" }}>Add a tool</div>
<div style={{ fontSize: 12.5, color: "#8a8a92", marginTop: 2 }}>Give {clawName} a new capability from the catalog.</div>
</div>
<button type="button" onClick={onClose} aria-label="Close" style={{ width: 30, height: 30, flex: "none", borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><X size={15} /></button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "7px 10px", borderRadius: 9, border: "1px solid rgba(255,255,255,.12)", background: "#101014", marginBottom: 12 }}>
<Search size={14} color="#6a6a72" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search tools…" style={{ flex: 1, border: 0, background: "transparent", color: "#eaeaee", fontSize: 13, outline: "none" }} />
</div>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", display: "flex", flexDirection: "column", gap: 7 }}>
{loading ? (
<span style={{ fontFamily: mono, fontSize: 11, color: "#6a6a72", padding: 8 }}>Loading catalog…</span>
) : rows.length === 0 ? (
<span style={{ fontFamily: mono, fontSize: 11, color: "#6a6a72", padding: 8 }}>No tools found.</span>
) : rows.map((s) => {
const on = installedIds.has(s.id);
return (
<div key={s.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 11px", borderRadius: 10, background: "#101014", border: "1px solid rgba(255,255,255,.06)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: "#eaeaee" }}>{s.title}</div>
<div style={{ fontSize: 11, color: "#8a8a92", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.description || s.author}</div>
</div>
<button type="button" disabled={on || busy === s.id} onClick={() => install(s.id)} style={{ flex: "none", display: "inline-flex", alignItems: "center", gap: 5, padding: "6px 10px", borderRadius: 8, border: on ? "1px solid rgba(95,208,138,.4)" : "1px solid rgba(94,200,216,.35)", background: on ? "rgba(95,208,138,.1)" : "rgba(94,200,216,.1)", color: on ? "#5fd08a" : "#5ec8d8", fontSize: 11.5, fontWeight: 600, cursor: on ? "default" : "pointer" }}>
{on ? (<><Check size={13} /> Added</>) : busy === s.id ? "Adding…" : (<><Plus size={13} /> Add</>)}
</button>
</div>
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,93 @@
"use client";
// The per-agent "computer" slide-out — templated from the selected agent's own
// model / apps / running tasks (each agent differs).
import { Bell, BookOpen, Boxes, FolderClosed, Phone, Settings, Zap } from "lucide-react";
import type { DemoAgent, DemoApp } from "@/lib/dashboard-demo";
const mono = "'JetBrains Mono', ui-monospace, monospace";
function appIcon(kind: DemoApp["kind"]) {
switch (kind) {
case "browser":
return (<div style={{ width: 48, height: 48, borderRadius: 13, background: "#fff", display: "flex", alignItems: "center", justifyContent: "center" }}><svg width="26" height="26" viewBox="0 0 26 26"><circle cx="13" cy="13" r="11" fill="#fff" stroke="#e0e0e0" /><circle cx="13" cy="13" r="4.4" fill="#4a90e2" /><path d="M13 8.6 H24" stroke="#ea4335" strokeWidth="3.6" /><path d="M9.2 11 L4 3.4" stroke="#34a853" strokeWidth="3.6" /><path d="M13 17.4 L7.5 22.5" stroke="#fbbc05" strokeWidth="3.6" /></svg></div>);
case "slack":
return (<div style={{ width: 48, height: 48, borderRadius: 13, background: "#fff", display: "flex", alignItems: "center", justifyContent: "center" }}><svg width="22" height="22" viewBox="0 0 22 22"><rect x="9" y="2" width="4" height="11" rx="2" fill="#36c5f0" /><rect x="9" y="13" width="4" height="7" rx="2" fill="#2eb67d" /><rect x="2" y="9" width="11" height="4" rx="2" fill="#ecb22e" /><rect x="9" y="9" width="11" height="4" rx="2" fill="#e01e5a" /></svg></div>);
case "chat":
return (<div style={{ width: 48, height: 48, borderRadius: 13, background: "linear-gradient(135deg,#ff8a7a,#ff5f57)", display: "flex", alignItems: "center", justifyContent: "center" }}><svg width="22" height="22" viewBox="0 0 22 22"><path d="M3 5a2 2 0 012-2h12a2 2 0 012 2v8a2 2 0 01-2 2H8l-4 4v-4H5a2 2 0 01-2-2z" fill="#fff" /></svg></div>);
default: {
const Icon = kind === "wiki" ? BookOpen : kind === "voip" ? Phone : kind === "cluster" ? Boxes : FolderClosed;
return (<div style={{ width: 48, height: 48, borderRadius: 13, background: "#141417", border: "1px solid rgba(255,255,255,.08)", display: "flex", alignItems: "center", justifyContent: "center", color: "#cfcfd5" }}><Icon size={20} /></div>);
}
}
}
const dockTile = (label: string, coral: boolean, icon: React.ReactNode) => (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 6 }}>
<div style={{ width: 40, height: 40, borderRadius: 11, background: "linear-gradient(135deg,#ff8a7a,#ff5f57)", display: "flex", alignItems: "center", justifyContent: "center", color: "#fff" }}>{icon}</div>
<span style={{ fontSize: 10, color: coral ? "#ff8a7a" : "#b5b5bd" }}>{label}</span>
</div>
);
export function AgentComputer({ agent, onClose }: { agent: DemoAgent; onClose: () => void }) {
return (
<div style={{ width: 330, flex: "none", borderLeft: "1px solid rgba(255,255,255,.06)", background: "#0b0b0e", display: "flex", flexDirection: "column", minHeight: 0, animation: "cm-fade .25s ease" }}>
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ position: "relative", width: 38, height: 38 }}>
<div style={{ width: 38, height: 38, borderRadius: 10, background: agent.grad, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 16, fontWeight: 700, color: agent.ink }}>{agent.initial}</div>
<span style={{ position: "absolute", right: -2, bottom: -2, width: 11, height: 11, borderRadius: "50%", background: "#5fd08a", border: "2px solid #0b0b0e" }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: "#f3f3f5" }}>{agent.name}&apos;s Computer</div>
<div style={{ fontFamily: mono, fontSize: 10, color: "#5ec8d8" }}>● {agent.model}</div>
</div>
<div onClick={onClose} style={{ width: 26, height: 26, borderRadius: 7, border: "1px solid rgba(255,255,255,.1)", display: "flex", alignItems: "center", justifyContent: "center", color: "#6a6a72", fontSize: 13, cursor: "pointer" }}>⤢</div>
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: 16 }}>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 12 }}>APPS</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: "10px 6px", marginBottom: 22 }}>
{agent.apps.map((a) => (
<div key={a.id} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 6 }}>
{appIcon(a.kind)}
<span style={{ fontSize: 10, color: "#b5b5bd" }}>{a.name}</span>
</div>
))}
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 6 }}>
<div style={{ width: 48, height: 48, borderRadius: 13, border: "1.5px dashed rgba(255,255,255,.18)", display: "flex", alignItems: "center", justifyContent: "center", color: "#ff6f61", fontSize: 22, fontWeight: 300, cursor: "pointer" }}>+</div>
<span style={{ fontSize: 10, color: "#8a8a92" }}>Add Apps</span>
</div>
</div>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 10 }}>NOW RUNNING</div>
{agent.nowRunning.length === 0 ? (
<p style={{ fontSize: 12, color: "#6a6a72" }}>Idle — no active tasks.</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{agent.nowRunning.map((t, i) => (
<div key={i} style={{ padding: "10px 11px", borderRadius: 10, background: "#101014", border: "1px solid rgba(255,255,255,.06)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: t.progress != null ? 6 : 0 }}>
<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={{ flex: 1 }} />
<span style={{ fontFamily: mono, fontSize: 9, color: t.kind === "loop" ? "#5ec8d8" : "#e8b465" }}>{t.detail}</span>
</div>
{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>
) : null}
</div>
))}
</div>
)}
</div>
<div style={{ flex: "none", margin: "0 12px 14px", padding: 12, borderRadius: 14, background: "#121216", border: "1px solid rgba(255,255,255,.07)", display: "flex", justifyContent: "space-around" }}>
{dockTile("Skills", false, <Zap size={18} />)}
{dockTile("Files", false, <FolderClosed size={18} />)}
{dockTile("Routines", true, <Bell size={18} />)}
{dockTile("Settings", false, <Settings size={18} />)}
</div>
</div>
);
}
@@ -0,0 +1,139 @@
"use client";
// Avatar editor modal for a claw: upload an image OR generate one from a prompt
// (Gemini / Nano Banana, via /api/generate-avatar — max 5 attempts), preview it,
// then Save (downscaled to 256² and persisted via PATCH /api/claws/{id}).
import { useEffect, useRef, useState } from "react";
import { Camera, Sparkles, Upload, X } from "lucide-react";
const mono = "'JetBrains Mono', ui-monospace, monospace";
const MAX_ATTEMPTS = 5;
// Cover-fit to a square and re-encode small so avatars stay lightweight.
function downscale(dataUrl: string, size = 256): Promise<string> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const c = document.createElement("canvas");
c.width = size;
c.height = size;
const ctx = c.getContext("2d");
if (!ctx) { resolve(dataUrl); return; }
const scale = Math.max(size / img.width, size / img.height);
const w = img.width * scale;
const h = img.height * scale;
ctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h);
resolve(c.toDataURL("image/jpeg", 0.85));
};
img.onerror = () => resolve(dataUrl);
img.src = dataUrl;
});
}
export function AvatarModal({ clawId, clawName, current, onClose, onSaved }: { clawId: string; clawName: string; current?: string | null; onClose: () => void; onSaved: (dataUrl: string) => void }) {
const [preview, setPreview] = useState<string | null>(current ?? null);
const [prompt, setPrompt] = useState("");
const [attempts, setAttempts] = useState(0);
const [busy, setBusy] = useState<null | "gen" | "save">(null);
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
function onPick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => { setPreview(String(reader.result)); setError(null); };
reader.readAsDataURL(file);
}
async function generate() {
if (busy || attempts >= MAX_ATTEMPTS || !prompt.trim()) return;
setBusy("gen");
setError(null);
try {
const res = await fetch("/api/generate-avatar", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt }) });
const j = (await res.json().catch(() => ({}))) as { image?: string; error?: string };
if (!res.ok || !j.image) setError(j.error || "generation failed");
else { setPreview(j.image); setAttempts((a) => a + 1); }
} catch {
setError("generation failed");
}
setBusy(null);
}
async function save() {
if (!preview || busy) return;
setBusy("save");
setError(null);
try {
const small = await downscale(preview);
const res = await fetch(`/api/claws/${clawId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ avatar: small }) });
if (!res.ok) { setError("could not save image"); setBusy(null); return; }
onSaved(small);
onClose();
} catch {
setError("could not save image");
setBusy(null);
}
}
const maxed = attempts >= MAX_ATTEMPTS;
return (
<div onClick={onClose} role="presentation" style={{ position: "fixed", inset: 0, zIndex: 110, background: "rgba(0,0,0,.62)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24, animation: "cm-fade .18s ease" }}>
<div onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Agent image" style={{ width: "100%", maxWidth: 460, borderRadius: 16, background: "#0d0d10", border: "1px solid rgba(255,255,255,.1)", boxShadow: "0 30px 90px rgba(0,0,0,.6)", padding: 22, animation: "scale-in .18s ease" }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12, marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: "#f3f3f5" }}>{clawName}&apos;s image</div>
<div style={{ fontSize: 12.5, color: "#8a8a92", marginTop: 2 }}>Upload one, or generate from a prompt.</div>
</div>
<button type="button" onClick={onClose} aria-label="Close" style={{ width: 30, height: 30, flex: "none", borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><X size={15} /></button>
</div>
<div style={{ display: "flex", gap: 16, marginBottom: 16 }}>
<div style={{ width: 120, height: 120, flex: "none", borderRadius: 16, overflow: "hidden", border: "1px solid rgba(255,255,255,.1)", background: preview ? `center/cover no-repeat url(${preview})` : "#101014", display: "flex", alignItems: "center", justifyContent: "center", color: "#3a3a40" }}>
{preview ? null : <Camera size={26} />}
</div>
<div style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", gap: 8 }}>
<button type="button" onClick={() => fileRef.current?.click()} style={{ display: "inline-flex", alignItems: "center", gap: 7, justifyContent: "center", padding: "8px 10px", borderRadius: 9, border: "1px solid rgba(255,255,255,.14)", background: "transparent", color: "#dcdce2", fontSize: 12.5, cursor: "pointer" }}><Upload size={14} /> Upload image</button>
<input ref={fileRef} type="file" accept="image/*" onChange={onPick} style={{ display: "none" }} />
<span style={{ fontFamily: mono, fontSize: 9, color: "#5a5a62" }}>PNG/JPG · saved at 256×256</span>
</div>
</div>
<div style={{ borderTop: "1px solid rgba(255,255,255,.07)", paddingTop: 14 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 8 }}>
<Sparkles size={13} color="#c98af0" />
<span style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".08em", color: "#c98af0" }}>GENERATE FROM PROMPT</span>
<span style={{ flex: 1 }} />
<span style={{ fontFamily: mono, fontSize: 9, color: maxed ? "#e8b465" : "#5a5a62" }}>{attempts}/{MAX_ATTEMPTS} attempts</span>
</div>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="e.g. a calm robotic owl mascot, soft teal gradient, minimal"
rows={2}
style={{ width: "100%", resize: "none", borderRadius: 9, border: "1px solid rgba(255,255,255,.12)", background: "#101014", color: "#eaeaee", fontSize: 12.5, padding: "8px 10px", outline: "none", fontFamily: "inherit" }}
/>
<button type="button" onClick={generate} disabled={busy !== null || maxed || !prompt.trim()} style={{ marginTop: 8, width: "100%", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "9px 0", borderRadius: 9, border: "1px solid rgba(201,138,240,.4)", background: maxed || !prompt.trim() ? "rgba(201,138,240,.06)" : "rgba(201,138,240,.14)", color: "#d9b6f7", fontSize: 12.5, fontWeight: 600, cursor: busy || maxed || !prompt.trim() ? "default" : "pointer", opacity: busy === "gen" ? 0.7 : 1 }}>
<Sparkles size={14} /> {busy === "gen" ? "Generating…" : maxed ? "Max attempts reached" : attempts > 0 ? "Regenerate" : "Generate"}
</button>
</div>
{error ? <div role="alert" style={{ marginTop: 12, fontFamily: mono, fontSize: 10.5, color: "#ff8a7a" }}>{error}</div> : null}
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<button type="button" onClick={onClose} style={{ flex: 1, padding: "9px 0", borderRadius: 9, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#cfcfd5", fontSize: 13, cursor: "pointer" }}>Cancel</button>
<button type="button" onClick={save} disabled={!preview || busy !== null} style={{ flex: 1, padding: "9px 0", borderRadius: 9, border: 0, background: !preview || busy ? "rgba(255,111,97,.4)" : "#ff6f61", color: "#2a0d0a", fontSize: 13, fontWeight: 700, cursor: !preview || busy ? "default" : "pointer" }}>{busy === "save" ? "Saving…" : "Save image"}</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
"use client";
// ClawSync revision history for an agent's .brain — list revisions (from the
// .onion sidecar) and roll back to any prior one. GET /revisions + POST /rollback.
import { useEffect, useState } from "react";
import { History, RotateCcw, Camera } from "lucide-react";
const mono = "'JetBrains Mono', ui-monospace, monospace";
type Revision = { revision: number; branch_id: number; annotation: string | null; is_snapshot: boolean };
export function BrainHistoryModal({ clawId, clawName, onClose, onRolledBack }: { clawId: string; clawName: string; onClose: () => void; onRolledBack: () => void }) {
const [revs, setRevs] = useState<Revision[] | null>(null);
const [busy, setBusy] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
useEffect(() => {
let alive = true;
(async () => {
try {
const res = await fetch(`/api/claws/${clawId}/brain/revisions`);
const data = res.ok ? await res.json() : { revisions: [] };
if (alive) setRevs(Array.isArray(data.revisions) ? data.revisions : []);
} catch { if (alive) setRevs([]); }
})();
return () => { alive = false; };
}, [clawId]);
async function rollback(revision: number) {
if (busy !== null) return;
setBusy(revision); setError(null);
try {
const res = await fetch(`/api/claws/${clawId}/brain/rollback`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ revision }) });
if (!res.ok) { setError(`Rollback failed (${res.status})`); setBusy(null); return; }
onRolledBack();
onClose();
} catch { setError("Network error"); setBusy(null); }
}
const sorted = revs ? [...revs].sort((a, b) => b.revision - a.revision) : [];
return (
<div onClick={onClose} role="presentation" style={{ position: "fixed", inset: 0, zIndex: 120, background: "rgba(0,0,0,.62)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24, animation: "cm-fade .18s ease" }}>
<div onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Brain history" style={{ width: "100%", maxWidth: 500, maxHeight: "80vh", display: "flex", flexDirection: "column", borderRadius: 16, background: "#0d0d10", border: "1px solid rgba(255,255,255,.1)", boxShadow: "0 30px 90px rgba(0,0,0,.6)", overflow: "hidden", animation: "scale-in .18s ease" }}>
<div style={{ flex: "none", display: "flex", alignItems: "center", gap: 11, padding: "18px 20px", borderBottom: "1px solid rgba(255,255,255,.07)" }}>
<span style={{ width: 34, height: 34, flex: "none", borderRadius: 9, background: "rgba(127,200,255,.12)", border: "1px solid rgba(127,200,255,.3)", display: "flex", alignItems: "center", justifyContent: "center", color: "#7fc8ff" }}><History size={17} /></span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 17, fontWeight: 700, color: "#f3f3f5" }}>Brain history</div>
<div style={{ fontSize: 12.5, color: "#8a8a92", marginTop: 2 }}>{clawName}&apos;s <code>.brain</code> revisions — roll back to any prior state.</div>
</div>
<button type="button" onClick={onClose} aria-label="Close" style={{ width: 28, height: 28, flex: "none", borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer" }}>✕</button>
</div>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 14 }}>
{revs === null ? (
<div style={{ fontFamily: mono, fontSize: 11, color: "#7fc8ff", padding: 8 }}>Loading revisions…</div>
) : sorted.length === 0 ? (
<div style={{ fontSize: 13, color: "#8a8a92", padding: 8, lineHeight: 1.5 }}>No revisions yet. Apply or refine a brain on this agent and its history starts here — every change becomes a revision you can roll back to.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{sorted.map((r, idx) => (
<div key={r.revision} style={{ display: "flex", alignItems: "center", gap: 10, borderRadius: 10, border: "1px solid rgba(255,255,255,.08)", background: "#101013", padding: "10px 12px" }}>
<span style={{ fontFamily: mono, fontSize: 11, fontWeight: 700, color: "#7fc8ff", minWidth: 30 }}>r{r.revision}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12.5, color: "#e6e6ea", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.annotation || "(no message)"}</div>
<div style={{ display: "flex", gap: 8, marginTop: 2 }}>
{idx === 0 ? <span style={{ fontFamily: mono, fontSize: 9, color: "#7fd0a0" }}>CURRENT</span> : null}
{r.is_snapshot ? <span style={{ fontFamily: mono, fontSize: 9, color: "#c98af0", display: "inline-flex", alignItems: "center", gap: 3 }}><Camera size={9} />snapshot</span> : null}
</div>
</div>
{idx === 0 ? null : (
<button type="button" disabled={busy !== null} onClick={() => rollback(r.revision)} style={{ flex: "none", display: "inline-flex", alignItems: "center", gap: 5, padding: "6px 10px", borderRadius: 8, border: "1px solid rgba(127,200,255,.3)", background: busy === r.revision ? "rgba(127,200,255,.2)" : "transparent", color: "#7fc8ff", fontSize: 11.5, fontWeight: 600, cursor: busy !== null ? "default" : "pointer" }}><RotateCcw size={12} />{busy === r.revision ? "Rolling back…" : "Roll back"}</button>
)}
</div>
))}
</div>
)}
{error ? <div style={{ fontSize: 12, color: "#ff8a7a", padding: "8px 4px 0" }}>{error}</div> : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,294 @@
"use client";
// Brain Registry rail on the claw page: search ClawBrainHub, click a card to
// slide out a content overview (what's inside, empty vs populated), and Add a
// brain to the SELECTED agent (POST /api/claws/{id}/brain/apply → cards repopulate).
import { useEffect, useState } from "react";
import { Brain, Check, Plus, Search, Sparkles, X } from "lucide-react";
const mono = "'JetBrains Mono', ui-monospace, monospace";
type BrainListing = {
reference: string;
owner: string;
name: string;
version: string;
description: string;
trust_score: number;
size_bytes: number;
};
type SectionText = { populated: boolean; chars: number; preview: string };
type BrainPreview = {
reference: string;
size_bytes: number;
system_prompt: SectionText;
agent_md: SectionText;
personality: SectionText;
skills: string[];
tools: [string, string][];
memory_count: number;
memory_recent: string[];
runtime: boolean;
provenance: boolean;
};
function Badge({ on, label }: { on: boolean; label: string }) {
return (
<span style={{ fontFamily: mono, fontSize: 9, padding: "2px 7px", borderRadius: 5, color: on ? "#7fd0a0" : "#7a7a82", background: on ? "rgba(95,208,138,.12)" : "rgba(255,255,255,.04)", border: `1px solid ${on ? "rgba(95,208,138,.3)" : "rgba(255,255,255,.08)"}` }}>{label}</span>
);
}
function kb(n: number) { return n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`; }
type Axis = { score?: number; notes?: string };
type EnhResult = {
new_reference: string | null;
label?: string;
analysis?: { effectiveness?: Axis; exploitability?: Axis; personality?: Axis; tools_access?: Axis; summary?: string };
};
export function BrainRegistryPanel({ clawId, clawName, onApplied, onClose }: { clawId: string; clawName: string; onApplied: () => void; onClose: () => void }) {
const [query, setQuery] = useState("");
const [items, setItems] = useState<BrainListing[]>([]);
const [loading, setLoading] = useState(true);
const [busyRef, setBusyRef] = useState<string | null>(null);
const [doneRefs, setDoneRefs] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const [selectedRef, setSelectedRef] = useState<string | null>(null);
const [preview, setPreview] = useState<BrainPreview | null>(null);
const [pvLoading, setPvLoading] = useState(false);
const [enhancing, setEnhancing] = useState(false);
const [enhProgress, setEnhProgress] = useState<{ pct: number; label: string } | null>(null);
const [enhResult, setEnhResult] = useState<EnhResult | null>(null);
const [enhError, setEnhError] = useState<string | null>(null);
const [listBump, setListBump] = useState(0);
useEffect(() => {
let alive = true;
const t = setTimeout(() => {
setLoading(true);
fetch(`/api/brainhub/search?q=${encodeURIComponent(query)}`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : []))
.then((d) => { if (alive) { setItems(Array.isArray(d) ? d : []); setLoading(false); } })
.catch(() => { if (alive) { setItems([]); setLoading(false); } });
}, 250);
return () => { alive = false; clearTimeout(t); };
}, [query, listBump]);
useEffect(() => {
if (!selectedRef) return;
let alive = true;
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset overview on selection change
setPreview(null);
setPvLoading(true);
setEnhProgress(null); setEnhResult(null); setEnhError(null); setEnhancing(false);
fetch(`/api/brainhub/preview?ref=${encodeURIComponent(selectedRef)}`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (alive) { setPreview(d); setPvLoading(false); } })
.catch(() => { if (alive) { setPreview(null); setPvLoading(false); } });
return () => { alive = false; };
}, [selectedRef]);
async function enhance(reference: string) {
setEnhError(null); setEnhResult(null); setEnhProgress({ pct: 2, label: "Starting…" }); setEnhancing(true);
try {
const res = await fetch("/api/brainhub/enhance", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reference }) });
if (!res.ok || !res.body) { setEnhError(`Enhance failed (${res.status})`); setEnhancing(false); return; }
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
const line = frame.split("\n").find((l) => l.startsWith("data:"));
if (!line) continue;
let evt: { stage?: string; pct?: number; label?: string } & EnhResult;
try { evt = JSON.parse(line.slice(5).trim()); } catch { continue; }
if (evt.stage === "error") { setEnhError(evt.label || "Enhance error"); setEnhancing(false); }
else if (evt.stage === "done") {
setEnhResult(evt);
setEnhProgress({ pct: 100, label: evt.label || "Done" });
setEnhancing(false);
// Re-read the committed (enhanced) version so every section shows the
// new contents, and refresh the list so the new version appears.
if (evt.new_reference) {
fetch(`/api/brainhub/preview?ref=${encodeURIComponent(evt.new_reference)}`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (d) setPreview(d); })
.catch(() => {});
}
setListBump((b) => b + 1);
}
else { setEnhProgress({ pct: evt.pct ?? 0, label: evt.label || "" }); }
}
}
setEnhancing(false);
} catch { setEnhError("Network error"); setEnhancing(false); }
}
async function add(reference: string) {
setBusyRef(reference); setError(null);
try {
const res = await fetch(`/api/claws/${clawId}/brain/apply`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reference }),
});
if (!res.ok) { setError("Couldn't add that brain — try again."); setBusyRef(null); return; }
setDoneRefs((p) => { const n = new Set(p); n.add(reference); return n; });
setBusyRef(null);
onApplied();
} catch { setError("Network error"); setBusyRef(null); }
}
return (
<div style={{ position: "absolute", inset: 0, background: "#0b0b0e", borderRight: "1px solid rgba(255,255,255,.07)" }}>
{/* Search panel */}
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, width: 220, borderRight: "1px solid rgba(255,255,255,.06)", display: "flex", flexDirection: "column", padding: "14px 13px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ flex: 1, display: "inline-flex", alignItems: "center", gap: 6, fontFamily: mono, fontSize: 10, letterSpacing: ".1em", color: "#ff8a7a" }}><Brain aria-hidden size={12} />BRAIN REGISTRY</span>
<button type="button" onClick={onClose} aria-label="Close registry" style={{ flex: "none", width: 22, height: 22, borderRadius: 6, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><X size={12} /></button>
</div>
<div style={{ fontSize: 11.5, color: "#8a8a92", marginTop: 7 }}>Search the hub and add a brain to <span style={{ color: "#cfcfd5" }}>{clawName}</span>.</div>
<div style={{ position: "relative", marginTop: 10 }}>
<Search aria-hidden size={13} style={{ position: "absolute", left: 9, top: 9, color: "#5a5a62" }} />
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search brains…" autoFocus style={{ width: "100%", boxSizing: "border-box", padding: "7px 9px 7px 28px", borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "#141417", color: "#eaeaee", fontSize: 12.5 }} />
</div>
<div style={{ marginTop: 12, fontFamily: mono, fontSize: 9.5, color: "#55555c", lineHeight: 1.7 }}>Results appear to the right →<br />Click a result for its contents, then Enhance or Add.</div>
</div>
{/* Results panel */}
<div style={{ position: "absolute", top: 0, bottom: 0, left: 220, right: 0, display: "flex", flexDirection: "column", background: "#0a0a0d" }}>
<div style={{ flex: "none", padding: "14px 13px 9px", borderBottom: "1px solid rgba(255,255,255,.06)", fontFamily: mono, fontSize: 9.5, letterSpacing: ".1em", color: "#6a6a72" }}>RESULTS{items.length ? ` · ${items.length}` : ""}{query ? ` · “${query}”` : ""}</div>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 10 }}>
{error ? <div style={{ fontSize: 11, color: "#ff8a7a", marginBottom: 8 }}>{error}</div> : null}
{loading ? (
<div style={{ fontFamily: mono, fontSize: 11, color: "#6a6a72", padding: 8 }}>Loading…</div>
) : items.length === 0 ? (
<div style={{ fontSize: 11.5, color: "#6a6a72", padding: 8, lineHeight: 1.5 }}>No brains found.{query ? "" : " Publish one with “Push to Hub”."}</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{items.map((b) => {
const done = doneRefs.has(b.reference);
const busy = busyRef === b.reference;
const sel = selectedRef === b.reference;
return (
<div key={b.reference} onClick={() => setSelectedRef(b.reference)} style={{ borderRadius: 10, border: `1px solid ${sel ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.08)"}`, background: sel ? "rgba(255,111,97,.06)" : "#101013", padding: 10, cursor: "pointer" }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: "#eaeaee", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.name}</div>
<div style={{ fontFamily: mono, fontSize: 9.5, color: "#6a6a72" }}>{b.owner} · v{b.version}{b.trust_score > 0 ? ` · trust ${Math.round(b.trust_score)}` : ""}</div>
</div>
<button type="button" disabled={busy || done} onClick={(e) => { e.stopPropagation(); add(b.reference); }} aria-label={`Add ${b.name}`} title={done ? "Added" : "Add to agent"} style={{ flex: "none", display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28, borderRadius: 8, border: `1px solid ${done ? "rgba(95,208,138,.4)" : "rgba(255,111,97,.4)"}`, background: done ? "rgba(95,208,138,.12)" : "rgba(255,111,97,.08)", color: done ? "#5fd08a" : "#ff6f61", cursor: busy || done ? "default" : "pointer" }}>
{done ? <Check size={14} /> : busy ? <span style={{ fontFamily: mono, fontSize: 9 }}>…</span> : <Plus size={15} />}
</button>
</div>
{b.description ? <div style={{ fontSize: 11, color: "#8a8a92", marginTop: 6, lineHeight: 1.45, display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>{b.description}</div> : null}
</div>
);
})}
</div>
)}
</div>
</div>
{/* Detail slide-out — to the RIGHT of the rail. */}
{selectedRef ? (
<div style={{ position: "absolute", top: 0, left: "100%", width: 340, height: "100%", background: "#0d0d11", borderRight: "1px solid rgba(255,255,255,.08)", boxShadow: "18px 0 40px rgba(0,0,0,.45)", zIndex: 8, display: "flex", flexDirection: "column", animation: "cm-fade .15s ease" }}>
<div style={{ flex: "none", display: "flex", alignItems: "center", gap: 8, padding: "13px 13px 11px", borderBottom: "1px solid rgba(255,255,255,.06)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".1em", color: "#6a6a72" }}>BRAIN CONTENTS{enhResult?.new_reference ? " · ENHANCED" : ""}</div>
<div style={{ fontSize: 13, fontWeight: 700, color: "#f3f3f5", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{enhResult?.new_reference ?? selectedRef}</div>
</div>
<button type="button" onClick={() => setSelectedRef(null)} aria-label="Close" style={{ flex: "none", width: 26, height: 26, borderRadius: 7, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer" }}><X size={13} /></button>
</div>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 13, display: "flex", flexDirection: "column", gap: 12 }}>
{pvLoading ? (
<div style={{ fontFamily: mono, fontSize: 11, color: "#6a6a72" }}>Reading brain…</div>
) : !preview ? (
<div style={{ fontSize: 11.5, color: "#ff8a7a" }}>Couldn’t read this brain.</div>
) : (
<>
<div style={{ fontFamily: mono, fontSize: 9.5, color: "#5a5a62" }}>{kb(preview.size_bytes)}</div>
{([
["System prompt", preview.system_prompt],
["AGENTS.md (how it operates)", preview.agent_md],
["Personality", preview.personality],
] as [string, SectionText][]).map(([label, st]) => (
<div key={label}>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 5 }}>
<span style={{ fontSize: 11.5, fontWeight: 600, color: "#cfcfd5" }}>{label}</span>
<span style={{ flex: 1 }} />
<Badge on={st.populated} label={st.populated ? `${st.chars} chars` : "empty"} />
</div>
{st.populated ? <div style={{ fontFamily: mono, fontSize: 10, color: "#8a8a92", lineHeight: 1.5, maxHeight: 92, overflow: "hidden", whiteSpace: "pre-wrap" }}>{st.preview}{st.chars > st.preview.length ? "…" : ""}</div> : null}
</div>
))}
<div>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 5 }}>
<span style={{ fontSize: 11.5, fontWeight: 600, color: "#cfcfd5" }}>Skills</span>
<span style={{ flex: 1 }} />
<Badge on={preview.skills.length > 0} label={preview.skills.length > 0 ? String(preview.skills.length) : "empty"} />
</div>
{preview.skills.length > 0 ? <div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>{preview.skills.map((s) => <span key={s} style={{ fontFamily: mono, fontSize: 10, color: "#cfcfd5", padding: "2px 7px", borderRadius: 5, background: "rgba(255,255,255,.05)" }}>{s}</span>)}</div> : null}
</div>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 5 }}>
<span style={{ fontSize: 11.5, fontWeight: 600, color: "#cfcfd5" }}>Tools</span>
<span style={{ flex: 1 }} />
<Badge on={preview.tools.length > 0} label={preview.tools.length > 0 ? String(preview.tools.length) : "empty"} />
</div>
{preview.tools.length > 0 ? <div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>{preview.tools.map(([n, s]) => <span key={n} style={{ fontFamily: mono, fontSize: 10, color: "#cfcfd5", padding: "2px 7px", borderRadius: 5, background: "rgba(255,255,255,.05)" }}>{n} · {s}</span>)}</div> : null}
</div>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 5 }}>
<span style={{ fontSize: 11.5, fontWeight: 600, color: "#cfcfd5" }}>Memory</span>
<span style={{ flex: 1 }} />
<Badge on={preview.memory_count > 0} label={preview.memory_count > 0 ? `${preview.memory_count} entries` : "empty"} />
</div>
{preview.memory_recent.length > 0 ? <div style={{ display: "flex", flexDirection: "column", gap: 3 }}>{preview.memory_recent.map((m, i) => <div key={i} style={{ fontFamily: mono, fontSize: 9.5, color: "#7a7a82", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m}</div>)}</div> : null}
</div>
<div style={{ display: "flex", gap: 7 }}>
<Badge on={preview.runtime} label={`runtime ${preview.runtime ? "✓" : "—"}`} />
<Badge on={preview.provenance} label={`provenance ${preview.provenance ? "✓" : "—"}`} />
</div>
</>
)}
</div>
<div style={{ flex: "none", padding: 12, borderTop: "1px solid rgba(255,255,255,.06)", display: "flex", flexDirection: "column", gap: 9 }}>
{enhProgress ? (
<div>
<div style={{ display: "flex", justifyContent: "space-between", fontFamily: mono, fontSize: 9.5, color: "#9a9aa2", marginBottom: 4 }}><span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{enhProgress.label}</span><span>{enhProgress.pct}%</span></div>
<div style={{ height: 5, borderRadius: 3, background: "rgba(255,255,255,.08)", overflow: "hidden" }}><div style={{ width: `${enhProgress.pct}%`, height: "100%", background: "linear-gradient(90deg,#c98af0,#ff6f61)", transition: "width .3s ease" }} /></div>
</div>
) : null}
{enhError ? <div style={{ fontSize: 11, color: "#ff8a7a" }}>{enhError}</div> : null}
{enhResult ? (
<div style={{ borderRadius: 8, border: "1px solid rgba(201,138,240,.3)", background: "rgba(201,138,240,.06)", padding: 9, fontSize: 10.5, color: "#cfcfd5" }}>
<div style={{ fontFamily: mono, fontSize: 9, color: "#c98af0", marginBottom: 5 }}>OPUS 4.8 REVIEW</div>
{enhResult.analysis ? (
<div style={{ display: "flex", flexWrap: "wrap", gap: 5, marginBottom: 6 }}>
{([["effectiveness", "Effect"], ["exploitability", "Exploit"], ["personality", "Persona"], ["tools_access", "Tools"]] as const).map(([k, short]) => {
const sc = enhResult.analysis?.[k]?.score;
return sc != null ? <span key={k} style={{ fontFamily: mono, fontSize: 9.5, padding: "2px 6px", borderRadius: 5, background: "rgba(255,255,255,.06)" }}>{short} {sc}/10</span> : null;
})}
</div>
) : null}
{enhResult.analysis?.summary ? <div style={{ color: "#9a9aa2", lineHeight: 1.45 }}>{enhResult.analysis.summary}</div> : null}
{enhResult.new_reference ? <div style={{ fontFamily: mono, fontSize: 9.5, color: "#7fd0a0", marginTop: 6 }}>✓ committed {enhResult.new_reference}</div> : <div style={{ fontFamily: mono, fontSize: 9.5, color: "#e8b465", marginTop: 6 }}>enhanced — not committed (push not permitted)</div>}
</div>
) : null}
<button type="button" disabled={enhancing} onClick={() => enhance(selectedRef)} style={{ width: "100%", padding: "9px 0", borderRadius: 9, border: "1px solid rgba(201,138,240,.45)", background: "rgba(201,138,240,.1)", color: "#d9b3f5", fontSize: 12, fontWeight: 600, cursor: enhancing ? "default" : "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 7 }}>
<Sparkles size={14} />{enhancing ? "Enhancing…" : "Enhance with Opus 4.8"}
</button>
<button type="button" disabled={busyRef === selectedRef || doneRefs.has(selectedRef)} onClick={() => add(selectedRef)} style={{ width: "100%", padding: "9px 0", borderRadius: 9, border: 0, background: doneRefs.has(selectedRef) ? "rgba(95,208,138,.18)" : "#ff6f61", color: doneRefs.has(selectedRef) ? "#7fd0a0" : "#1a0d0b", fontSize: 12.5, fontWeight: 700, cursor: busyRef === selectedRef || doneRefs.has(selectedRef) ? "default" : "pointer" }}>
{doneRefs.has(selectedRef) ? "✓ Added" : busyRef === selectedRef ? "Adding…" : `Add to ${clawName}`}
</button>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,165 @@
"use client";
// The native chat embedded in the dashboard's claw page (below the anatomy).
// Resolves a session + history via the same-origin /api proxy (the sessions
// lib helpers are server-only), then mounts a runner that drives the real
// streaming `useChat` hook and reuses the chat UI (WelcomeState/MessageList/
// Composer). The runner is keyed by sessionKey so switching sessions re-seeds
// the hook cleanly (useChat takes initialMessages only at mount).
import { useEffect, useState } from "react";
import { MessageSquare, Minus, Plus } from "lucide-react";
import type { Agent } from "@/lib/api/schemas";
import type { HistoryMessage, Session } from "@/lib/api/sessions";
import type { UiMessage } from "@/lib/gateway/transcript";
import { useChat } from "@/lib/gateway/use-chat";
import { MessageList } from "@/components/chat/MessageList";
import { Composer } from "@/components/chat/Composer";
import { WelcomeState } from "@/components/chat/WelcomeState";
function toUiMessage(entry: HistoryMessage): UiMessage | null {
if (entry.role === "system") return null;
return {
id: entry.id,
role: entry.role,
text: entry.content.text,
steps: entry.steps.map((s) => ({ seq: s.seq, tool: s.tool_name ?? s.kind, input: s.input, output: s.output, status: s.status })),
status: "complete",
};
}
async function getJson<T>(url: string): Promise<T> {
const r = await fetch(url, { cache: "no-store" });
if (!r.ok) throw new Error(`${url} → ${r.status}`);
return r.json() as Promise<T>;
}
const loadHistory = async (sessionKey: string): Promise<UiMessage[]> =>
(await getJson<HistoryMessage[]>(`/api/sessions/history?sessionKey=${encodeURIComponent(sessionKey)}&tools=true`))
.map(toUiMessage)
.filter((m): m is UiMessage => m !== null);
export function ClawChatSection({ agent, onOpenComputerApp, onMinimize }: { agent: Agent; onOpenComputerApp?: (app: "slack") => void; onMinimize?: () => void }) {
const [sessions, setSessions] = useState<Session[]>([]);
const [sessionKey, setSessionKey] = useState<string | null>(null);
const [initial, setInitial] = useState<UiMessage[]>([]);
const [phase, setPhase] = useState<"loading" | "ready" | "error">("loading");
// Reset to loading when the claw changes (render-phase, not in the effect).
const [seenAgent, setSeenAgent] = useState(agent.id);
if (seenAgent !== agent.id) {
setSeenAgent(agent.id);
setPhase("loading");
setSessionKey(null);
setInitial([]);
}
// Resolve the latest session (or create one) + load its history, per claw.
useEffect(() => {
let alive = true;
(async () => {
try {
let list = await getJson<Session[]>(`/api/sessions?clawId=${encodeURIComponent(agent.id)}`);
let target = list[0];
if (!target) {
const res = await fetch("/api/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clawId: agent.id, title: "" }) });
target = (await res.json()) as Session;
list = [target];
}
const msgs = await loadHistory(target.sessionKey);
if (!alive) return;
setSessions(list);
setSessionKey(target.sessionKey);
setInitial(msgs);
setPhase("ready");
} catch {
if (alive) setPhase("error");
}
})();
return () => { alive = false; };
}, [agent.id]);
async function switchSession(key: string) {
setPhase("loading");
try {
const msgs = await loadHistory(key);
setSessionKey(key);
setInitial(msgs);
setPhase("ready");
} catch { setPhase("error"); }
}
async function newSession() {
setPhase("loading");
try {
const res = await fetch("/api/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clawId: agent.id, title: "" }) });
const s = (await res.json()) as Session;
setSessions((prev) => [s, ...prev]);
setSessionKey(s.sessionKey);
setInitial([]);
setPhase("ready");
} catch { setPhase("error"); }
}
return (
<div className="flex h-full flex-col bg-background">
<div className="flex shrink-0 items-center gap-2 border-b border-white/[0.06]" style={{ padding: "11px 16px 11px 18px" }}>
{/* Same pill signature as the BRAINS bar so the two line up when stacked. */}
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: 11, letterSpacing: ".1em", color: "#ff8a7a", padding: "4px 9px", borderRadius: 6, border: "1px solid rgba(255,111,97,.25)", background: "rgba(255,111,97,.07)" }}>
<MessageSquare aria-hidden size={13} />CHAT
</span>
{/* Session controls live on the left, beside the pill. */}
{sessions.length > 1 ? (
<select
value={sessionKey ?? ""}
onChange={(e) => switchSession(e.target.value)}
className="max-w-[160px] rounded-md border border-white/10 bg-transparent px-2 py-1 text-xs text-muted-foreground"
>
{sessions.map((s) => (
<option key={s.id} value={s.sessionKey}>{s.title || new Date(s.last_active_at).toLocaleString()}</option>
))}
</select>
) : null}
{onOpenComputerApp ? (
<button type="button" onClick={() => onOpenComputerApp("slack")} className="rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-hover-bg hover:text-foreground">Slack</button>
) : null}
<button type="button" onClick={newSession} className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-hover-bg hover:text-foreground"><Plus aria-hidden size={13} /> New</button>
<span className="flex-1" />
{/* Minimize the chat into the top-right launcher (mirrors the computer). */}
{onMinimize ? (
<button type="button" aria-label="Minimize chat" title="Minimize chat" onClick={onMinimize} className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-hover-bg hover:text-foreground"><Minus aria-hidden size={16} /></button>
) : null}
</div>
<div className="min-h-0 flex-1">
{phase === "loading" ? (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">Loading chat…</div>
) : phase === "error" ? (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">Couldn’t load chat.</div>
) : sessionKey ? (
<ChatRunner key={sessionKey} agent={agent} sessionKey={sessionKey} initialMessages={initial} />
) : null}
</div>
</div>
);
}
function ChatRunner({ agent, sessionKey, initialMessages }: { agent: Agent; sessionKey: string; initialMessages: UiMessage[] }) {
const { state, send, decide, stop } = useChat(agent.id, sessionKey, initialMessages);
const [draft, setDraft] = useState("");
const empty = state.messages.length === 0;
return (
<div className="flex h-full w-full min-w-0 flex-col overflow-hidden px-5">
{empty ? (
<WelcomeState
agent={agent}
onPick={setDraft}
composer={<Composer agentName={agent.name} variant="welcome" disabled={state.streaming} onSend={send} onStop={stop} draft={draft} onDraftChange={setDraft} />}
/>
) : (
<>
<MessageList messages={state.messages} agent={agent} onDecide={decide} />
<Composer agentName={agent.name} variant="active" disabled={state.streaming} onSend={send} onStop={stop} draft={draft} onDraftChange={setDraft} />
</>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,301 @@
"use client";
// Master Planner — the "+" deploy interface. A mode selector (top-right) picks how
// to deploy: Specialists (domain-expert team) · Swarm (self-verifying loop) ·
// Scheduled (date/time) · Triggered (webhook). You chat with Opus 4.8; it proposes,
// and the build action depends on the mode.
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Activity, CalendarClock, Send, Sparkles, Users, Webhook } from "lucide-react";
const mono = "'JetBrains Mono', ui-monospace, monospace";
type Mode = "specialists" | "swarm" | "scheduled" | "triggered";
const MODES: { key: Mode; label: string; hint: string }[] = [
{ key: "specialists", label: "Specialists", hint: "domain experts" },
{ key: "swarm", label: "Swarm", hint: "self-verifying loop" },
{ key: "scheduled", label: "Scheduled", hint: "date / time" },
{ key: "triggered", label: "Triggered", hint: "webhook" },
];
const INTRO: Record<Mode, string> = {
specialists: "Describe what you need — e.g. “a team of specialists for a React Native app: a Rust backend expert, a RN UI dev, and a release engineer.”",
swarm: "Describe a job to verify-loop — e.g. “analyze 100 EV companies; every figure needs a resolvable source URL.” I'll turn it into a checklist the verifier enforces.",
scheduled: "Describe a team and when it should run — e.g. “a market-news digest team, every weekday at 7am.”",
triggered: "Describe a team to fire from a webhook — e.g. “when a support ticket arrives, triage and draft a reply.”",
};
type Member = { name: string; role: string; model: string; rationale?: string };
type Proposal = { team_name: string; topology_kind: string; schedule?: { cron?: string; one_shot_at?: string; prompt: string } | null; members: Member[] };
type SwarmSpec = { goal: string; checklist: string[]; task_count?: number; worker_model?: string };
type Msg = { role: "user" | "planner"; content: string };
type Step = { node_id: string; role: string; phase: string; output: string };
async function readSse(url: string, body: unknown, onEvt: (e: Record<string, unknown>) => void) {
const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
if (!res.ok || !res.body) throw new Error(`${res.status}`);
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
const line = frame.split("\n").find((l) => l.startsWith("data:"));
if (!line) continue;
try { onEvt(JSON.parse(line.slice(5).trim())); } catch { /* skip */ }
}
}
}
export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
const router = useRouter();
const [mode, setMode] = useState<Mode>("specialists");
const [messages, setMessages] = useState<Msg[]>([{ role: "planner", content: INTRO.specialists }]);
const [input, setInput] = useState("");
const [thinking, setThinking] = useState(false);
const [proposal, setProposal] = useState<Proposal | null>(null);
const [swarm, setSwarm] = useState<SwarmSpec | null>(null);
const [building, setBuilding] = useState(false);
const [buildProg, setBuildProg] = useState<{ pct: number; label: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const [webhookUrl, setWebhookUrl] = useState<string | null>(null);
// Swarm run viewer
const [runSteps, setRunSteps] = useState<Step[]>([]);
const [runStatus, setRunStatus] = useState<string | null>(null);
const [runFinal, setRunFinal] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const esRef = useRef<EventSource | null>(null);
useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]);
useEffect(() => { scrollRef.current?.scrollTo({ top: 1e9, behavior: "smooth" }); }, [messages, thinking]);
useEffect(() => () => { esRef.current?.close(); }, []);
function switchMode(m: Mode) {
if (m === mode) return;
esRef.current?.close();
setMode(m); setMessages([{ role: "planner", content: INTRO[m] }]); setInput("");
setProposal(null); setSwarm(null); setError(null); setBuildProg(null); setWebhookUrl(null);
setRunSteps([]); setRunStatus(null); setRunFinal(null);
}
async function send() {
const text = input.trim();
if (!text || thinking) return;
const next: Msg[] = [...messages, { role: "user", content: text }];
setMessages(next); setInput(""); setThinking(true); setError(null);
try {
await readSse("/api/planner/chat", { mode, messages: next.map((m) => ({ role: m.role === "user" ? "user" : "assistant", content: m.content })) }, (e) => {
if (e.stage === "error") setError(String(e.label || "error"));
else if (e.stage === "done") {
if (e.reply) setMessages((m) => [...m, { role: "planner", content: String(e.reply) }]);
if (e.proposal) setProposal(e.proposal as Proposal);
if (e.swarm) setSwarm(e.swarm as SwarmSpec);
}
});
} catch { setError("Network error"); }
setThinking(false);
}
async function buildTeam() {
if (!proposal || building) return;
setBuilding(true); setError(null); setBuildProg({ pct: 2, label: "Starting…" });
try {
await readSse("/api/planner/scaffold", proposal, async (e) => {
if (e.stage === "error") { setError(String(e.label || "build error")); setBuilding(false); }
else if (e.stage === "done") {
setBuildProg({ pct: 100, label: String(e.label || "Deployed") });
const teamId = e.team_id ? String(e.team_id) : null;
if (mode === "triggered" && teamId) {
// Mint a webhook for the freshly-built team and surface the URL.
try {
const r = await fetch(`/api/teams/${teamId}/webhooks`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ task: proposal.schedule?.prompt || "" }) });
const d = r.ok ? await r.json() : null;
if (d?.url) setWebhookUrl(`${window.location.origin}${d.url}`);
} catch { /* ignore */ }
setBuilding(false);
} else {
setBuilding(false);
setTimeout(() => { onClose(); if (teamId) router.push(`/?team=${teamId}`); router.refresh(); }, 800);
}
} else setBuildProg({ pct: Number(e.pct ?? 0), label: String(e.label || "") });
});
} catch { setError("Network error"); setBuilding(false); }
}
async function runSwarm() {
if (!swarm || building) return;
setBuilding(true); setError(null); setRunSteps([]); setRunFinal(null); setRunStatus("queued");
try {
const res = await fetch("/api/swarm/run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(swarm) });
if (res.status !== 202) { setError(`Run failed (${res.status})`); setBuilding(false); return; }
const { run_id } = (await res.json()) as { run_id: string };
setRunStatus("running");
const es = new EventSource(`/api/topology-runs/${run_id}/events`);
esRef.current = es;
es.addEventListener("step", (ev) => { try { setRunSteps((s) => [...s, JSON.parse((ev as MessageEvent).data) as Step]); } catch { /* skip */ } });
es.addEventListener("done", (ev) => {
try { const d = JSON.parse((ev as MessageEvent).data) as { status: string; final_output: string | null }; setRunStatus(d.status); if (d.final_output) setRunFinal(d.final_output); } catch { /* skip */ }
es.close(); esRef.current = null; setBuilding(false);
});
es.onerror = () => { es.close(); esRef.current = null; setBuilding(false); };
} catch { setError("Network error"); setBuilding(false); }
}
const chip: React.CSSProperties = { fontFamily: mono, fontSize: 10, padding: "2px 7px", borderRadius: 5, background: "rgba(255,255,255,.06)", color: "#cfcfd5" };
const buildLabel = mode === "scheduled" ? "Build & schedule" : mode === "triggered" ? "Build & create webhook" : "Build team";
const hasRightPanel = (mode === "swarm" && swarm) || (mode !== "swarm" && proposal);
return (
<div onClick={onClose} role="presentation" style={{ position: "fixed", inset: 0, zIndex: 100, background: "rgba(0,0,0,.62)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24, animation: "cm-fade .18s ease" }}>
<div onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Master Planner" style={{ width: "100%", maxWidth: 960, height: "86vh", display: "flex", flexDirection: "column", borderRadius: 16, background: "#0d0d10", border: "1px solid rgba(255,255,255,.1)", boxShadow: "0 30px 90px rgba(0,0,0,.6)", overflow: "hidden", animation: "scale-in .18s ease" }}>
{/* Header + mode selector */}
<div style={{ flex: "none", display: "flex", alignItems: "center", gap: 10, padding: "14px 18px", borderBottom: "1px solid rgba(255,255,255,.07)" }}>
<span style={{ width: 32, height: 32, borderRadius: 9, background: "rgba(201,138,240,.14)", border: "1px solid rgba(201,138,240,.32)", display: "flex", alignItems: "center", justifyContent: "center", color: "#c98af0" }}><Sparkles size={16} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>Master Planner</div>
<div style={{ fontSize: 11.5, color: "#8a8a92" }}>Claude Opus 4.8 designs &amp; deploys.</div>
</div>
<div style={{ display: "flex", gap: 3, padding: 3, borderRadius: 9, background: "rgba(255,255,255,.04)", border: "1px solid rgba(255,255,255,.08)" }}>
{MODES.map((m) => (
<button key={m.key} type="button" onClick={() => switchMode(m.key)} title={m.hint} style={{ padding: "5px 10px", borderRadius: 7, border: 0, cursor: "pointer", fontSize: 11.5, fontWeight: 600, background: mode === m.key ? "#c98af0" : "transparent", color: mode === m.key ? "#1a0820" : "#9a9aa2" }}>{m.label}</button>
))}
</div>
<button type="button" onClick={onClose} aria-label="Close" style={{ width: 28, height: 28, borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer" }}>✕</button>
</div>
<div style={{ flex: 1, minHeight: 0, display: "flex" }}>
{/* Chat */}
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", borderRight: hasRightPanel ? "1px solid rgba(255,255,255,.07)" : undefined }}>
<div ref={scrollRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 12 }}>
{messages.map((m, i) => (
<div key={i} style={{ alignSelf: m.role === "user" ? "flex-end" : "flex-start", maxWidth: "85%", borderRadius: 12, padding: "9px 12px", fontSize: 13, lineHeight: 1.5, background: m.role === "user" ? "#ff6f61" : "#16161b", color: m.role === "user" ? "#1a0d0b" : "#e6e6ea", whiteSpace: "pre-wrap" }}>{m.content}</div>
))}
{thinking ? <div style={{ alignSelf: "flex-start", fontFamily: mono, fontSize: 11, color: "#c98af0" }}>Planning with Opus 4.8…</div> : null}
{error ? <div style={{ alignSelf: "center", fontSize: 11.5, color: "#ff8a7a" }}>{error}</div> : null}
</div>
<div style={{ flex: "none", display: "flex", gap: 8, padding: 12, borderTop: "1px solid rgba(255,255,255,.07)" }}>
<textarea value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }} placeholder="Describe what you want…" rows={1} style={{ flex: 1, resize: "none", padding: "9px 11px", borderRadius: 9, border: "1px solid rgba(255,255,255,.12)", background: "#141417", color: "#eaeaee", fontSize: 13, fontFamily: "inherit" }} />
<button type="button" onClick={send} disabled={thinking || !input.trim()} aria-label="Send" style={{ flex: "none", width: 40, borderRadius: 9, border: 0, background: thinking || !input.trim() ? "rgba(255,111,97,.3)" : "#ff6f61", color: "#1a0d0b", cursor: thinking || !input.trim() ? "default" : "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><Send size={16} /></button>
</div>
</div>
{/* Right panel — proposal (team modes) or swarm spec / run viewer */}
{hasRightPanel ? (
<div style={{ flex: "none", width: 376, display: "flex", flexDirection: "column", background: "#0a0a0d" }}>
{mode === "swarm" && swarm ? (
<SwarmPanel swarm={swarm} steps={runSteps} status={runStatus} final={runFinal} building={building} onRun={runSwarm} />
) : proposal ? (
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 14 }}>
<div style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".1em", color: "#c98af0", marginBottom: 6 }}>PROPOSED TEAM</div>
<div style={{ fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>{proposal.team_name}</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginTop: 6 }}>
<span style={chip}>{proposal.topology_kind}</span>
<span style={chip}>{proposal.members.length} agents</span>
{proposal.schedule?.cron ? <span style={{ ...chip, color: "#7fd0a0" }}>⏰ {proposal.schedule.cron}</span> : null}
{proposal.schedule?.one_shot_at ? <span style={{ ...chip, color: "#7fd0a0" }}>⏰ {proposal.schedule.one_shot_at}</span> : null}
{mode === "triggered" ? <span style={{ ...chip, color: "#7fc8ff" }}>webhook</span> : null}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 12 }}>
{proposal.members.map((m, i) => (
<div key={i} style={{ borderRadius: 10, border: "1px solid rgba(255,255,255,.08)", background: "#101013", padding: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "#eaeaee" }}>{m.name}</span>
<span style={{ flex: 1 }} />
<span style={{ fontFamily: mono, fontSize: 9, padding: "2px 6px", borderRadius: 5, background: "rgba(201,138,240,.14)", color: "#d9b3f5" }}>{m.model}</span>
</div>
<div style={{ fontFamily: mono, fontSize: 9.5, color: "#6a6a72", marginTop: 2 }}>{m.role}</div>
{m.rationale ? <div style={{ fontSize: 10.5, color: "#8a8a92", marginTop: 5, lineHeight: 1.45 }}>{m.rationale}</div> : null}
</div>
))}
</div>
{webhookUrl ? (
<div style={{ marginTop: 12, borderRadius: 10, border: "1px solid rgba(127,200,255,.3)", background: "rgba(127,200,255,.06)", padding: 11 }}>
<div style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".1em", color: "#7fc8ff", marginBottom: 5, display: "flex", alignItems: "center", gap: 5 }}><Webhook size={11} />WEBHOOK URL</div>
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#e6e6ea", wordBreak: "break-all", lineHeight: 1.5 }}>{webhookUrl}</div>
<button type="button" onClick={() => { navigator.clipboard?.writeText(webhookUrl); }} style={{ marginTop: 8, padding: "5px 10px", borderRadius: 7, border: "1px solid rgba(127,200,255,.3)", background: "transparent", color: "#7fc8ff", fontSize: 11, cursor: "pointer" }}>Copy — POST to fire the team</button>
</div>
) : null}
</div>
<div style={{ flex: "none", padding: 12, borderTop: "1px solid rgba(255,255,255,.07)", display: "flex", flexDirection: "column", gap: 8 }}>
{buildProg ? (
<div>
<div style={{ display: "flex", justifyContent: "space-between", fontFamily: mono, fontSize: 9.5, color: "#9a9aa2", marginBottom: 4 }}><span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{buildProg.label}</span><span>{buildProg.pct}%</span></div>
<div style={{ height: 5, borderRadius: 3, background: "rgba(255,255,255,.08)", overflow: "hidden" }}><div style={{ width: `${buildProg.pct}%`, height: "100%", background: "linear-gradient(90deg,#c98af0,#ff6f61)", transition: "width .3s ease" }} /></div>
</div>
) : null}
{webhookUrl ? (
<button type="button" onClick={onClose} style={{ width: "100%", padding: "10px 0", borderRadius: 10, border: 0, background: "#5fd08a", color: "#06140c", fontSize: 13, fontWeight: 700, cursor: "pointer" }}>Done</button>
) : (
<button type="button" onClick={buildTeam} disabled={building} style={{ width: "100%", padding: "10px 0", borderRadius: 10, border: 0, background: building ? "rgba(255,111,97,.3)" : "#ff6f61", color: "#1a0d0b", fontSize: 13, fontWeight: 700, cursor: building ? "default" : "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 7 }}>
{mode === "scheduled" ? <CalendarClock size={15} /> : mode === "triggered" ? <Webhook size={15} /> : <Users size={15} />}
{building ? "Building…" : buildLabel}
</button>
)}
</div>
</div>
) : null}
</div>
) : null}
</div>
</div>
</div>
);
}
function statusColor(s: string): string {
const t = s.toLowerCase();
if (t === "ok" || t === "completed" || t === "done") return "#7fd0a0";
if (t === "error" || t === "failed") return "#ff8a7a";
return "#f0c264";
}
function SwarmPanel({ swarm, steps, status, final, building, onRun }: { swarm: SwarmSpec; steps: Step[]; status: string | null; final: string | null; building: boolean; onRun: () => void }) {
const started = status !== null;
return (
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 14 }}>
<div style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".1em", color: "#c98af0", marginBottom: 6, display: "flex", alignItems: "center", gap: 5 }}><Activity size={11} />SWARM JOB</div>
<div style={{ fontSize: 13.5, color: "#eaeaee", lineHeight: 1.5 }}>{swarm.goal}</div>
<div style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".1em", color: "#6a6a72", margin: "12px 0 6px" }}>VERIFY CHECKLIST</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{(swarm.checklist || []).map((c, i) => (
<div key={i} style={{ fontSize: 11.5, color: "#cfcfd5", display: "flex", gap: 6 }}><span style={{ color: "#7fd0a0" }}>✓</span>{c}</div>
))}
</div>
{started ? (
<div style={{ marginTop: 14 }}>
<div style={{ fontFamily: mono, fontSize: 10, color: statusColor(status || "running"), textTransform: "uppercase", marginBottom: 8 }}>{status === "running" || status === "queued" ? "● running" : status}</div>
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
{steps.map((s, i) => {
const reject = s.output.startsWith("✗");
const pass = s.output.startsWith("✓");
return (
<div key={i} style={{ borderRadius: 9, border: "1px solid rgba(255,255,255,.08)", background: "#101013", padding: 9 }}>
<div style={{ fontFamily: mono, fontSize: 9, color: pass ? "#7fd0a0" : reject ? "#ff8a7a" : "#5ec8d8", marginBottom: 3 }}>{s.role}</div>
<div style={{ fontSize: 11.5, color: "#cfcfd5", lineHeight: 1.45, whiteSpace: "pre-wrap", maxHeight: 120, overflow: "hidden" }}>{s.output}</div>
</div>
);
})}
{final ? (
<div style={{ borderRadius: 9, border: "1px solid rgba(127,208,160,.3)", background: "rgba(127,208,160,.06)", padding: 10 }}>
<div style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".1em", color: "#7fd0a0", marginBottom: 5 }}>FINAL REPORT</div>
<div style={{ fontSize: 11.5, color: "#e6e6ea", lineHeight: 1.5, whiteSpace: "pre-wrap" }}>{final}</div>
</div>
) : null}
</div>
</div>
) : null}
</div>
{!started ? (
<div style={{ flex: "none", padding: 12, borderTop: "1px solid rgba(255,255,255,.07)" }}>
<button type="button" onClick={onRun} disabled={building} style={{ width: "100%", padding: "10px 0", borderRadius: 10, border: 0, background: building ? "rgba(94,200,216,.3)" : "#5ec8d8", color: "#04181c", fontSize: 13, fontWeight: 700, cursor: building ? "default" : "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 7 }}><Activity size={15} />{building ? "Starting…" : "Run swarm"}</button>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,82 @@
"use client";
// "Push to Hub": publish this claw's .brain (identity + skills + memory) to
// ClawBrainHub under owner/name:version (POST /api/claws/{id}/brain/push).
// Requires BRAINHUB_API_KEY on the server; surfaces a clear error if unset.
import { useEffect, useState } from "react";
import { UploadCloud, X } from "lucide-react";
const mono = "'JetBrains Mono', ui-monospace, monospace";
export function PushBrainModal({ clawId, clawName, onClose }: { clawId: string; clawName: string; onClose: () => void }) {
const slug = clawName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "agent";
const [reference, setReference] = useState(`me/${slug}:1.0.0`);
const [description, setDescription] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const canPush = /.+\/.+:.+/.test(reference.trim()) && !busy;
async function push() {
if (!canPush) { setError("Use owner/name:version, e.g. me/my-agent:1.0.0"); return; }
setBusy(true); setError(null);
try {
const res = await fetch(`/api/claws/${clawId}/brain/push`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reference: reference.trim(), description: description.trim(), tags: [] }),
});
if (!res.ok) {
setError(res.status === 400 ? "Push rejected — check the name, or BRAINHUB_API_KEY isn't set on the server." : `Push failed (${res.status})`);
setBusy(false);
return;
}
setDone(true); setBusy(false);
} catch { setError("Network error"); setBusy(false); }
}
const field: React.CSSProperties = { width: "100%", boxSizing: "border-box", padding: "9px 11px", borderRadius: 9, border: "1px solid rgba(255,255,255,.12)", background: "#141417", color: "#eaeaee", fontSize: 13, fontFamily: mono };
return (
<div onClick={onClose} role="presentation" style={{ position: "fixed", inset: 0, zIndex: 120, background: "rgba(0,0,0,.62)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24, animation: "cm-fade .18s ease" }}>
<div onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Push to ClawBrainHub" style={{ width: "100%", maxWidth: 460, borderRadius: 16, background: "#0d0d10", border: "1px solid rgba(255,255,255,.1)", boxShadow: "0 30px 90px rgba(0,0,0,.6)", padding: 22, animation: "scale-in .18s ease" }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12, marginBottom: 16 }}>
<span style={{ width: 34, height: 34, flex: "none", borderRadius: 9, background: "rgba(95,208,138,.12)", border: "1px solid rgba(95,208,138,.3)", display: "flex", alignItems: "center", justifyContent: "center", color: "#5fd08a" }}><UploadCloud size={17} /></span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 17, fontWeight: 700, color: "#f3f3f5" }}>Push to ClawBrainHub</div>
<div style={{ fontSize: 12.5, color: "#8a8a92", marginTop: 2 }}>Publish {clawName}&apos;s <code>.brain</code> as a versioned release.</div>
</div>
<button type="button" onClick={onClose} aria-label="Close" style={{ width: 28, height: 28, flex: "none", borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer" }}><X size={14} /></button>
</div>
{done ? (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ fontSize: 13, color: "#7fd0a0" }}>Published <code>{reference.trim()}</code> to ClawBrainHub.</div>
<button type="button" onClick={onClose} style={{ padding: "10px 0", borderRadius: 10, border: 0, background: "#5fd08a", color: "#06140c", fontSize: 13, fontWeight: 700, cursor: "pointer" }}>Done</button>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 11 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<span style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".08em", color: "#6a6a72" }}>REFERENCE (owner/name:version)</span>
<input value={reference} onChange={(e) => setReference(e.target.value)} placeholder="me/my-agent:1.0.0" style={field} />
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<span style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".08em", color: "#6a6a72" }}>DESCRIPTION (optional)</span>
<input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What this agent is good at" style={{ ...field, fontFamily: "inherit" }} />
</label>
{error ? <div style={{ fontSize: 12, color: "#ff8a7a" }}>{error}</div> : null}
<button type="button" disabled={!canPush} onClick={push} style={{ marginTop: 4, padding: "10px 0", borderRadius: 10, border: 0, background: canPush ? "#5fd08a" : "rgba(95,208,138,.25)", color: "#06140c", fontSize: 13, fontWeight: 700, cursor: canPush ? "pointer" : "default" }}>{busy ? "Pushing…" : "Push brain"}</button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,117 @@
"use client";
// Streams the deletion of selected entities. Agents hard-purge with full reaping
// (POST /api/claws/batch-delete, SSE: runtime/containers/files/DB). Teams,
// companies and orgs are structural deletes (DELETE /api/<kind>/{id} per item —
// the children survive, just ungrouped). On done, the sidebar refreshes.
import { useEffect, useRef, useState } from "react";
import { Trash2 } from "lucide-react";
const mono = "'JetBrains Mono', ui-monospace, monospace";
export type ReapKind = "agents" | "teams" | "companies" | "orgs";
type Item = { id: string; name: string };
type Line = { stage: string; label: string };
const NOUN: Record<ReapKind, string> = { agents: "agent", teams: "team", companies: "company", orgs: "organization" };
const ENDPOINT: Record<Exclude<ReapKind, "agents">, string> = { teams: "teams", companies: "companies", orgs: "orgs" };
function lineColor(stage: string): string {
if (stage === "removed" || stage === "done") return "#7fd0a0";
if (stage === "error") return "#ff8a7a";
if (stage === "skip") return "#f0c264";
if (stage === "start") return "#eaeaee";
return "#9a9aa2";
}
export function ReapProgressModal({ items, kind, onClose, onDone }: { items: Item[]; kind: ReapKind; onClose: () => void; onDone: () => void }) {
const [lines, setLines] = useState<Line[]>([]);
const [pct, setPct] = useState(0);
const [finished, setFinished] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const started = useRef(false);
const count = items.length;
useEffect(() => { scrollRef.current?.scrollTo({ top: 1e9, behavior: "smooth" }); }, [lines]);
useEffect(() => {
if (started.current) return;
started.current = true;
(async () => {
try {
if (kind === "agents") {
// Full reap via the streaming batch endpoint.
const res = await fetch("/api/claws/batch-delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids: items.map((i) => i.id) }) });
if (!res.ok || !res.body) { setError(`Request failed (${res.status})`); setFinished(true); return; }
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
const l = frame.split("\n").find((x) => x.startsWith("data:"));
if (!l) continue;
try {
const e = JSON.parse(l.slice(5).trim()) as { stage: string; label?: string; pct?: number };
if (typeof e.pct === "number") setPct(e.pct);
if (e.label) setLines((p) => [...p, { stage: e.stage, label: e.label as string }]);
if (e.stage === "done") setFinished(true);
} catch { /* skip */ }
}
}
} else {
// Structural delete, one request per item.
const ep = ENDPOINT[kind];
for (let i = 0; i < items.length; i++) {
const it = items[i];
setLines((p) => [...p, { stage: "start", label: `Removing ${it.name}…` }]);
try {
const res = await fetch(`/api/${ep}/${it.id}`, { method: "DELETE" });
if (res.ok) setLines((p) => [...p, { stage: "removed", label: `✓ ${it.name} removed` }]);
else setLines((p) => [...p, { stage: "error", label: `${it.name}: failed (${res.status})` }]);
} catch { setLines((p) => [...p, { stage: "error", label: `${it.name}: network error` }]); }
setPct(Math.round((100 * (i + 1)) / Math.max(1, items.length)));
}
}
setFinished(true);
} catch { setError("Network error"); setFinished(true); }
})();
}, [items, kind]);
return (
<div onClick={finished ? onClose : undefined} role="presentation" style={{ position: "fixed", inset: 0, zIndex: 130, background: "rgba(0,0,0,.62)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24, animation: "cm-fade .18s ease" }}>
<div onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Removing items" style={{ width: "100%", maxWidth: 560, maxHeight: "82vh", display: "flex", flexDirection: "column", borderRadius: 16, background: "#0d0d10", border: "1px solid rgba(255,255,255,.1)", boxShadow: "0 30px 90px rgba(0,0,0,.6)", overflow: "hidden", animation: "scale-in .18s ease" }}>
<div style={{ flex: "none", display: "flex", alignItems: "center", gap: 11, padding: "16px 20px", borderBottom: "1px solid rgba(255,255,255,.07)" }}>
<span style={{ width: 34, height: 34, flex: "none", borderRadius: 9, background: "rgba(255,111,97,.12)", border: "1px solid rgba(255,111,97,.3)", display: "flex", alignItems: "center", justifyContent: "center", color: "#ff6f61" }}><Trash2 size={16} /></span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>{finished ? "Removal complete" : `Removing ${count} ${NOUN[kind]}${count === 1 ? "" : "s"}…`}</div>
<div style={{ fontSize: 12, color: "#8a8a92", marginTop: 2 }}>{kind === "agents" ? "Reaping runtime, containers, files & data — this is permanent." : "Removing the grouping — the items inside survive, just ungrouped."}</div>
</div>
</div>
<div style={{ flex: "none", padding: "12px 20px 8px" }}>
<div style={{ height: 6, borderRadius: 3, background: "rgba(255,255,255,.08)", overflow: "hidden" }}>
<div style={{ width: `${pct}%`, height: "100%", background: finished ? "#5fd08a" : "linear-gradient(90deg,#ff6f61,#c98af0)", transition: "width .3s ease" }} />
</div>
</div>
<div ref={scrollRef} style={{ flex: 1, minHeight: 80, overflowY: "auto", padding: "6px 20px 14px", display: "flex", flexDirection: "column", gap: 4 }}>
{lines.map((ln, i) => (
<div key={i} style={{ fontFamily: mono, fontSize: 11.5, color: lineColor(ln.stage), lineHeight: 1.5 }}>{ln.label}</div>
))}
{error ? <div style={{ fontFamily: mono, fontSize: 11.5, color: "#ff8a7a" }}>{error}</div> : null}
</div>
<div style={{ flex: "none", padding: 14, borderTop: "1px solid rgba(255,255,255,.07)" }}>
<button type="button" disabled={!finished} onClick={onDone} style={{ width: "100%", padding: "10px 0", borderRadius: 10, border: 0, background: finished ? "#5fd08a" : "rgba(255,255,255,.08)", color: finished ? "#06140c" : "#6a6a72", fontSize: 13, fontWeight: 700, cursor: finished ? "pointer" : "default" }}>{finished ? "Done — refresh" : "Working…"}</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
"use client";
// A vertical split with a draggable horizontal divider: `top` and `bottom`
// share the height by a ratio the user drags. Both panes are position:relative
// + minHeight:0 so absolute-inset children (FlowCanvas, ClawAnatomyCanvas,
// ClawChatSection) fill them. Double-clicking the divider maximizes the bottom
// pane (collapse top to its min) and toggles back. Ratio is local state.
import { useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
export function ResizableSplit({
top,
bottom,
defaultRatio = 0.55,
minTop = 120,
minBottom = 160,
}: {
top: ReactNode;
bottom: ReactNode;
/** Top pane's initial fraction of the height (0..1). */
defaultRatio?: number;
minTop?: number;
minBottom?: number;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef(false);
const [ratio, setRatio] = useState(defaultRatio);
const [restoreRatio, setRestoreRatio] = useState(defaultRatio);
function clampToBox(r: number): number {
const h = containerRef.current?.getBoundingClientRect().height ?? 0;
if (h <= minTop + minBottom) return r;
return Math.max(minTop / h, Math.min(1 - minBottom / h, r));
}
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>) {
e.preventDefault();
draggingRef.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
}
function onPointerMove(e: ReactPointerEvent<HTMLDivElement>) {
if (!draggingRef.current) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
setRatio(clampToBox((e.clientY - rect.top) / rect.height));
}
function onPointerUp(e: ReactPointerEvent<HTMLDivElement>) {
draggingRef.current = false;
e.currentTarget.releasePointerCapture(e.pointerId);
}
function onDoubleClick() {
const h = containerRef.current?.getBoundingClientRect().height ?? 1;
const minR = minTop / h;
if (ratio > minR + 0.02) {
setRestoreRatio(ratio);
setRatio(minR);
} else {
setRatio(restoreRatio);
}
}
return (
<div ref={containerRef} style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%", touchAction: "none" }}>
<div style={{ position: "relative", flexGrow: ratio, flexShrink: 1, flexBasis: 0, minHeight: 0, overflow: "hidden" }}>{top}</div>
<div
role="separator"
aria-orientation="horizontal"
aria-label="Resize"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onDoubleClick={onDoubleClick}
style={{ flex: "none", height: 9, cursor: "row-resize", display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(255,255,255,.025)", borderTop: "1px solid rgba(255,255,255,.07)", borderBottom: "1px solid rgba(255,255,255,.07)" }}
>
<span style={{ width: 42, height: 3, borderRadius: 2, background: "rgba(255,255,255,.2)" }} />
</div>
<div style={{ position: "relative", flexGrow: 1 - ratio, flexShrink: 1, flexBasis: 0, minHeight: 0, overflow: "hidden" }}>{bottom}</div>
</div>
);
}
@@ -0,0 +1,155 @@
"use client";
// A collapsible org → company → team → claw tree for the dashboard's left
// sidebar. The same component serves every tier — each tier just roots the
// forest at a different level (orgs / companies / teams) and auto-expands the
// path to the current selection. Clicking a parent expands it (and selects it);
// clicking a claw opens that claw. Built to stay legible even when a whole org
// of claws is expanded (indented rows, scrollable).
import { useState } from "react";
import type { DemoAgent, DemoCompany, DemoOrg, DemoTeam } from "@/lib/dashboard-demo";
const mono = "'JetBrains Mono', ui-monospace, monospace";
const statusColor = (s: string) => (s === "running" ? "#5ec8d8" : s === "online" ? "#5fd08a" : "#3a3a40");
export type TreeLevel = "org" | "company" | "team" | "claw";
export interface TreeItem {
id: string;
level: TreeLevel;
label: string;
meta?: string;
grad?: string;
ink?: string;
initial?: string;
avatar?: string;
dot?: string;
status?: string;
children?: TreeItem[];
}
// ── Builders (compose the forest from the demo tree) ────────────────────────
export const clawNode = (a: DemoAgent): TreeItem => ({
id: a.id, level: "claw", label: a.name, meta: a.role, grad: a.grad, ink: a.ink, initial: a.initial, avatar: a.avatar, status: a.status,
});
export const teamNode = (t: DemoTeam): TreeItem => ({
id: t.id, level: "team", label: t.name, meta: `${t.agents.length} agent${t.agents.length === 1 ? "" : "s"}`, dot: t.dot, status: t.status,
children: t.agents.map(clawNode),
});
export const companyNode = (c: DemoCompany): TreeItem => ({
id: c.id, level: "company", label: c.name, meta: c.meta, children: c.teams.map(teamNode),
});
export const orgNode = (o: DemoOrg): TreeItem => ({
id: o.id, level: "org", label: o.name, meta: `${o.companies.length} ${o.companies.length === 1 ? "company" : "companies"}`,
children: o.companies.map(companyNode),
});
function levelIcon(item: TreeItem, active: boolean) {
if (item.level === "claw") {
return (
<span style={{ position: "relative", width: 26, height: 26, flex: "none" }}>
<span style={{ width: 26, height: 26, borderRadius: 7, background: item.avatar ? `center/cover no-repeat url(${item.avatar})` : item.grad, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, fontWeight: 700, color: item.ink }}>{item.avatar ? null : item.initial}</span>
<span style={{ position: "absolute", right: -2, bottom: -2, width: 8, height: 8, borderRadius: "50%", background: statusColor(item.status ?? "idle"), border: "2px solid #0b0b0e" }} />
</span>
);
}
const color = active ? "#ff6f61" : "#6a6a72";
if (item.level === "org")
return <span style={{ flex: "none", color }}><svg width="16" height="16" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="10" cy="10" r="2.6" fill="currentColor" /></svg></span>;
if (item.level === "company")
return <span style={{ flex: "none", color }}><svg width="16" height="16" viewBox="0 0 20 20"><rect x="3.5" y="5" width="6" height="11" rx="1" stroke="currentColor" strokeWidth="1.4" fill="none" /><rect x="10.5" y="2.5" width="6" height="13.5" rx="1" stroke="currentColor" strokeWidth="1.4" fill="none" /></svg></span>;
// team
return <span style={{ flex: "none", width: 16, display: "flex", justifyContent: "center" }}><span style={{ width: 9, height: 9, borderRadius: item.dot === "#ff6f61" ? "50%" : 2, background: item.dot ?? "#3a3a40" }} /></span>;
}
function TreeRow({
item, depth, expanded, toggle, activeId, onSelectNode, selectMode, selectLevel, selectedIds, onToggleSelect,
}: {
item: TreeItem;
depth: number;
expanded: Set<string>;
toggle: (id: string) => void;
activeId: string | null;
onSelectNode: (item: TreeItem) => void;
selectMode?: boolean;
selectLevel?: string;
selectedIds?: Set<string>;
onToggleSelect?: (id: string) => void;
}) {
const hasChildren = !!item.children?.length;
const isOpen = expanded.has(item.id);
const active = item.id === activeId;
// selectLevel "*" → any node is selectable (World tree spans all levels).
const selectable = !!selectMode && (selectLevel === "*" ? true : item.level === (selectLevel ?? "claw"));
const checked = selectedIds?.has(item.id) ?? false;
return (
<>
<div
onClick={() => { if (selectable) { onToggleSelect?.(item.id); return; } if (hasChildren) toggle(item.id); onSelectNode(item); }}
style={{ position: "relative", display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", paddingLeft: 8 + depth * 14, borderRadius: 8, cursor: "pointer", background: checked ? "rgba(255,111,97,.16)" : active ? "rgba(255,111,97,.12)" : "transparent" }}
>
{active && !selectable ? <span style={{ position: "absolute", left: 0, top: 6, bottom: 6, width: 3, borderRadius: "0 3px 3px 0", background: "#ff6f61" }} /> : null}
{selectable ? (
<span
onClick={(e) => { e.stopPropagation(); onToggleSelect?.(item.id); }}
style={{ flex: "none", width: 16, height: 16, borderRadius: 4, border: `1.5px solid ${checked ? "#ff6f61" : "rgba(255,255,255,.28)"}`, background: checked ? "#ff6f61" : "transparent", display: "flex", alignItems: "center", justifyContent: "center", color: "#1a0d0b", fontSize: 11, fontWeight: 700, cursor: "pointer" }}
>{checked ? "✓" : ""}</span>
) : (
<span style={{ width: 12, flex: "none", color: "#6a6a72", fontSize: 9, textAlign: "center" }}>
{hasChildren ? (isOpen ? "▾" : "▸") : ""}
</span>
)}
{levelIcon(item, active)}
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontSize: 12.5, fontWeight: active ? 600 : 500, color: active ? "#fff" : "#dcdce2", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{item.label}</span>
{item.meta ? <span style={{ display: "block", fontFamily: mono, fontSize: 9.5, color: active ? "#ff8a7a" : "#6a6a72", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{item.meta}</span> : null}
</span>
{item.level !== "claw" && item.status ? <span style={{ flex: "none", width: 6, height: 6, borderRadius: "50%", background: statusColor(item.status) }} /> : null}
</div>
{hasChildren && isOpen
? item.children!.map((c) => (
<TreeRow key={c.id} item={c} depth={depth + 1} expanded={expanded} toggle={toggle} activeId={activeId} onSelectNode={onSelectNode} selectMode={selectMode} selectLevel={selectLevel} selectedIds={selectedIds} onToggleSelect={onToggleSelect} />
))
: null}
</>
);
}
export function StructureTree({
roots, activeId, autoExpand, onSelectNode, selectMode, selectLevel, selectedIds, onToggleSelect,
}: {
roots: TreeItem[];
activeId: string | null;
autoExpand: string[];
onSelectNode: (item: TreeItem) => void;
selectMode?: boolean;
selectLevel?: string;
selectedIds?: Set<string>;
onToggleSelect?: (id: string) => void;
}) {
const autoKey = autoExpand.join("|");
const [seenAuto, setSeenAuto] = useState<string>(autoKey);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(autoExpand));
// When the current path changes (e.g. a new claw selected elsewhere), expand
// its ancestors without collapsing branches the user opened.
if (seenAuto !== autoKey) {
setSeenAuto(autoKey);
setExpanded((prev) => new Set([...prev, ...autoExpand]));
}
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
return (
<div style={{ flex: 1, overflowY: "auto", padding: 8 }}>
{roots.map((r) => (
<TreeRow key={r.id} item={r} depth={0} expanded={expanded} toggle={toggle} activeId={activeId} onSelectNode={onSelectNode} selectMode={selectMode} selectLevel={selectLevel} selectedIds={selectedIds} onToggleSelect={onToggleSelect} />
))}
</div>
);
}
@@ -0,0 +1,52 @@
"use client";
// The team-level "group apps" drawer — reuses the slide-out shell, but instead
// of one agent's computer it lists the apps shared by the whole team
// (cluster / wiki / VoIP / Slack …).
import { BookOpen, Boxes, FolderClosed, MessageSquare, Phone } from "lucide-react";
import type { DemoApp, DemoTeam } from "@/lib/dashboard-demo";
const mono = "'JetBrains Mono', ui-monospace, monospace";
function icon(kind: DemoApp["kind"]) {
switch (kind) {
case "cluster": return <Boxes size={20} />;
case "wiki": return <BookOpen size={20} />;
case "voip": return <Phone size={20} />;
case "slack": return <MessageSquare size={20} />;
default: return <FolderClosed size={20} />;
}
}
export function TeamAppsDrawer({ team, onClose }: { team: DemoTeam; onClose: () => void }) {
return (
<div style={{ width: 330, flex: "none", borderLeft: "1px solid rgba(255,255,255,.06)", background: "#0b0b0e", display: "flex", flexDirection: "column", minHeight: 0, animation: "cm-fade .25s ease" }}>
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: "#f3f3f5" }}>{team.name} · Shared apps</div>
<div style={{ fontFamily: mono, fontSize: 10, color: "#5ec8d8" }}>● enabled for {team.agents.length} members</div>
</div>
<div onClick={onClose} style={{ width: 26, height: 26, borderRadius: 7, border: "1px solid rgba(255,255,255,.1)", display: "flex", alignItems: "center", justifyContent: "center", color: "#6a6a72", fontSize: 13, cursor: "pointer" }}>⤢</div>
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: 16 }}>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 12 }}>GROUP APPLICATIONS</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{team.groupApps.map((a) => (
<div key={a.id} style={{ display: "flex", alignItems: "center", gap: 11, padding: "10px 11px", borderRadius: 11, background: "#101014", border: "1px solid rgba(255,255,255,.06)" }}>
<span style={{ width: 36, height: 36, borderRadius: 10, background: "#141417", border: "1px solid rgba(255,255,255,.08)", display: "flex", alignItems: "center", justifyContent: "center", color: "#cfcfd5" }}>{icon(a.kind)}</span>
<span style={{ flex: 1, fontSize: 13, fontWeight: 600, color: "#e6e6ea" }}>{a.name}</span>
<span style={{ fontFamily: mono, fontSize: 9, color: "#5fd08a" }}>enabled</span>
</div>
))}
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "10px 11px", borderRadius: 11, border: "1.5px dashed rgba(255,255,255,.16)", color: "#ff6f61", fontSize: 12, fontWeight: 600, cursor: "pointer" }}>
<span style={{ fontSize: 15, lineHeight: 1 }}>+</span> Enable an app for the team
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,148 @@
"use client";
// The bottom half of the teams page: real team metrics as clickable cards.
// Clicking a card reveals a detail list below it (errors → recent failed runs,
// msgs → recent threads, runs/tokens/credits → per-member breakdown). All data
// is real (leaderboard / routine runs / inter-agent threads); absent data shows
// 0 / "No data yet" rather than a fabricated number.
import { useState } from "react";
import type { DemoTeam } from "@/lib/dashboard-demo";
import { ago, fmtNum, useJson, useThreads, type LeaderboardRow } from "./metrics";
const mono = "'JetBrains Mono', ui-monospace, monospace";
type MetricKey = "runs" | "tokens" | "credits" | "errors" | "msgs" | "active";
export function TeamMetricsPanel({ team }: { team: DemoTeam }) {
const ids = team.agents.map((a) => a.id);
const idSet = new Set(ids);
const { data: leaderboard } = useJson<LeaderboardRow[]>("/api/team/leaderboard");
const threads = useThreads(ids);
const [open, setOpen] = useState<MetricKey | null>(null);
// Only show a number where it's genuinely wired + team-scoped; otherwise "—"
// (no fabricated values). Real-zero (e.g. a brand-new team with no usage yet)
// still shows 0 — that's a true, live value.
const lbReady = leaderboard !== null; // per-agent usage is real + team-scoped
const teamRows = (leaderboard ?? []).filter((r) => idSet.has(r.id));
const totalRuns = teamRows.reduce((s, r) => s + (r.runs || 0), 0);
const totalTokens = teamRows.reduce((s, r) => s + (r.tokens || 0), 0);
const totalCredits = teamRows.reduce((s, r) => s + (r.credits || 0), 0);
const active = team.agents.filter((a) => a.status === "running" || a.status === "online");
const cards: { key: MetricKey; label: string; value: string; tint: string }[] = [
{ key: "runs", label: "RUNS", value: lbReady ? fmtNum(totalRuns) : "—", tint: "#5ec8d8" },
{ key: "tokens", label: "TOKENS", value: lbReady ? fmtNum(totalTokens) : "—", tint: "#9a8cff" },
{ key: "credits", label: "CREDITS", value: lbReady ? fmtNum(totalCredits) : "—", tint: "#5fd08a" },
// Errors come from workspace-wide routine runs (not team-scoped yet) → "—".
{ key: "errors", label: "RECENT ERRORS", value: "—", tint: "#ff6f61" },
{ key: "msgs", label: "INTER-AGENT MSGS", value: String(threads.length), tint: "#ffb05e" },
{ key: "active", label: "ACTIVE NOW", value: `${active.length}/${team.agents.length}`, tint: "#5fd08a" },
];
return (
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", background: "#0a0a0c", overflow: "hidden" }}>
<div style={{ flex: "none", display: "flex", alignItems: "center", gap: 9, padding: "12px 16px 8px" }}>
<span style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62" }}>TEAM METRICS</span>
<span style={{ fontFamily: mono, fontSize: 10, color: "#4a4a52" }}>· {team.name}</span>
</div>
<div style={{ flex: "none", display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(132px, 1fr))", gap: 8, padding: "0 16px 10px" }}>
{cards.map((c) => {
const on = open === c.key;
return (
<button key={c.key} type="button" onClick={() => setOpen(on ? null : c.key)}
style={{ textAlign: "left", cursor: "pointer", borderRadius: 12, padding: "10px 12px", background: on ? `${c.tint}14` : "#0f0f13", border: `1px solid ${on ? `${c.tint}66` : "rgba(255,255,255,.07)"}`, transition: "border-color .15s, background .15s" }}>
<div style={{ fontFamily: mono, fontSize: 9, letterSpacing: ".08em", color: c.tint, marginBottom: 6 }}>{c.label}</div>
<div style={{ fontSize: 22, fontWeight: 700, color: "#f3f3f5", lineHeight: 1 }}>{c.value}</div>
</button>
);
})}
</div>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: "4px 16px 16px" }}>
<MetricDetail open={open} lbReady={lbReady} teamRows={teamRows} threads={threads} active={active} />
</div>
</div>
);
}
function Empty({ label }: { label: string }) {
return <div style={{ fontFamily: mono, fontSize: 11, color: "#5a5a62", padding: "10px 2px" }}>{label}</div>;
}
function Bar({ frac, tint }: { frac: number; tint: string }) {
return (
<div style={{ height: 4, borderRadius: 2, background: "rgba(255,255,255,.07)", overflow: "hidden", marginTop: 5 }}>
<div style={{ width: `${Math.max(2, frac * 100)}%`, height: "100%", background: tint }} />
</div>
);
}
function MetricDetail({
open, lbReady, teamRows, threads, active,
}: {
open: MetricKey | null;
lbReady: boolean;
teamRows: LeaderboardRow[];
threads: import("./metrics").ClawThread[];
active: { id: string; name: string; role: string; status: string }[];
}) {
if (!open) return <Empty label="Click a metric above to break it down." />;
if (open === "errors") {
return <Empty label="Per-team error tracking isn't wired up to live data yet." />;
}
if (open === "msgs") {
if (threads.length === 0) return <Empty label="No inter-agent threads yet." />;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{threads.slice(0, 12).map((t) => (
<div key={t.id} style={{ borderRadius: 9, background: "#0f0f13", border: "1px solid rgba(255,255,255,.07)", padding: "8px 10px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12.5, color: "#eaeaee", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.subject || "(untitled thread)"}</span>
<span style={{ fontFamily: mono, fontSize: 9, color: "#6a6a72" }}>{ago(t.created_at)}</span>
</div>
{t.last_preview ? <div style={{ fontFamily: mono, fontSize: 10, color: "#6a6a72", marginTop: 4, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.last_preview}</div> : null}
</div>
))}
</div>
);
}
if (open === "active") {
if (active.length === 0) return <Empty label="No members active right now." />;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{active.map((a) => (
<div key={a.id} style={{ display: "flex", alignItems: "center", gap: 8, borderRadius: 9, background: "#0f0f13", border: "1px solid rgba(255,255,255,.07)", padding: "8px 10px" }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: a.status === "running" ? "#5ec8d8" : "#5fd08a", flex: "none" }} />
<span style={{ fontSize: 12.5, color: "#eaeaee", flex: 1 }}>{a.name}</span>
<span style={{ fontFamily: mono, fontSize: 9, color: "#6a6a72" }}>{a.role}</span>
</div>
))}
</div>
);
}
// runs | tokens | credits → per-member breakdown
const metric = open;
if (!lbReady) return <Empty label="Not wired up to live usage yet." />;
const rows = [...teamRows].sort((a, b) => (b[metric] || 0) - (a[metric] || 0));
if (rows.length === 0) return <Empty label="No usage recorded yet." />;
const max = Math.max(1, ...rows.map((r) => r[metric] || 0));
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{rows.map((r) => (
<div key={r.id}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 9, height: 9, borderRadius: 3, background: r.accent || "#5ec8d8", flex: "none" }} />
<span style={{ fontSize: 12.5, color: "#eaeaee", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.name}</span>
<span style={{ fontFamily: mono, fontSize: 11, color: "#cfcfd5" }}>{fmtNum(r[metric] || 0)}</span>
</div>
<Bar frac={(r[metric] || 0) / max} tint={r.accent || "#5ec8d8"} />
</div>
))}
</div>
);
}
@@ -0,0 +1,155 @@
"use client";
// Team runs — see the team's durable topology runs (including the nightly loop),
// watch one stream live (SSE step/done events), and trigger a run on demand.
// GET /api/topology-runs · POST /api/teams/{id}/run · /api/topology-runs/{id}/events
import { useEffect, useRef, useState } from "react";
import { Activity, Play, RefreshCw } from "lucide-react";
const mono = "'JetBrains Mono', ui-monospace, monospace";
type Step = { node_id: string; role: string; phase: string; output: string };
type RunSummary = { id: string; task: string; status: string; kind: string; created_at: string };
function statusColor(s: string): string {
const t = s.toLowerCase();
if (t === "ok" || t === "done" || t === "completed" || t === "succeeded") return "#7fd0a0";
if (t === "error" || t === "failed" || t === "cancelled") return "#ff8a7a";
if (t === "running" || t === "streaming" || t === "active") return "#f0c264";
return "#8a8a92";
}
export function TeamRunsModal({ teamId, teamName, onClose }: { teamId: string; teamName: string; onClose: () => void }) {
const [runs, setRuns] = useState<RunSummary[] | null>(null);
const [task, setTask] = useState("Run the team on its core objective.");
const [activeRun, setActiveRun] = useState<string | null>(null);
const [steps, setSteps] = useState<Step[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [finalOutput, setFinalOutput] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const esRef = useRef<EventSource | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
useEffect(() => () => { esRef.current?.close(); }, []);
async function loadRuns() {
try { const res = await fetch("/api/topology-runs"); setRuns(res.ok ? await res.json() : []); }
catch { setRuns([]); }
}
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
loadRuns();
}, []);
function watch(runId: string) {
esRef.current?.close();
setActiveRun(runId); setSteps([]); setStatus("running"); setFinalOutput(null); setError(null);
const es = new EventSource(`/api/topology-runs/${runId}/events`);
esRef.current = es;
es.addEventListener("step", (e) => {
try { setSteps((s) => [...s, JSON.parse((e as MessageEvent).data) as Step]); } catch { /* skip */ }
});
es.addEventListener("done", (e) => {
try {
const d = JSON.parse((e as MessageEvent).data) as { status: string; error: string | null; final_output: string | null };
setStatus(d.status); if (d.final_output) setFinalOutput(d.final_output); if (d.error) setError(d.error);
} catch { /* skip */ }
es.close(); esRef.current = null; setBusy(false); loadRuns();
});
es.onerror = () => { es.close(); esRef.current = null; setBusy(false); };
}
async function runNow() {
if (busy || !task.trim()) return;
setBusy(true); setError(null);
try {
const res = await fetch(`/api/teams/${teamId}/run`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ task }) });
if (res.status !== 202) { setError(`Run failed (${res.status})`); setBusy(false); return; }
const { run_id } = (await res.json()) as { run_id: string };
watch(run_id);
} catch { setError("Network error"); setBusy(false); }
}
const fieldBtn: React.CSSProperties = { display: "inline-flex", alignItems: "center", gap: 6, padding: "8px 12px", borderRadius: 9, border: 0, background: busy ? "rgba(94,200,216,.3)" : "#5ec8d8", color: "#04181c", fontSize: 12.5, fontWeight: 700, cursor: busy ? "default" : "pointer" };
return (
<div onClick={onClose} role="presentation" style={{ position: "fixed", inset: 0, zIndex: 110, background: "rgba(0,0,0,.62)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24, animation: "cm-fade .18s ease" }}>
<div onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Team runs" style={{ width: "100%", maxWidth: 900, height: "82vh", display: "flex", flexDirection: "column", borderRadius: 16, background: "#0d0d10", border: "1px solid rgba(255,255,255,.1)", boxShadow: "0 30px 90px rgba(0,0,0,.6)", overflow: "hidden", animation: "scale-in .18s ease" }}>
<div style={{ flex: "none", display: "flex", alignItems: "center", gap: 11, padding: "16px 20px", borderBottom: "1px solid rgba(255,255,255,.07)" }}>
<span style={{ width: 34, height: 34, flex: "none", borderRadius: 9, background: "rgba(94,200,216,.12)", border: "1px solid rgba(94,200,216,.3)", display: "flex", alignItems: "center", justifyContent: "center", color: "#5ec8d8" }}><Activity size={17} /></span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 17, fontWeight: 700, color: "#f3f3f5" }}>Runs — {teamName}</div>
<div style={{ fontSize: 12.5, color: "#8a8a92", marginTop: 2 }}>Watch the team&apos;s topology runs (incl. the nightly loop) or trigger one now.</div>
</div>
<button type="button" onClick={onClose} aria-label="Close" style={{ width: 28, height: 28, flex: "none", borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer" }}>✕</button>
</div>
<div style={{ flex: 1, minHeight: 0, display: "flex" }}>
{/* Left: run-now + recent runs */}
<div style={{ flex: "none", width: 320, display: "flex", flexDirection: "column", borderRight: "1px solid rgba(255,255,255,.07)" }}>
<div style={{ flex: "none", padding: 14, borderBottom: "1px solid rgba(255,255,255,.06)" }}>
<div style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".1em", color: "#5ec8d8", marginBottom: 6 }}>RUN NOW</div>
<textarea value={task} onChange={(e) => setTask(e.target.value)} rows={2} placeholder="Task for this run…" style={{ width: "100%", boxSizing: "border-box", resize: "none", padding: "8px 10px", borderRadius: 9, border: "1px solid rgba(255,255,255,.12)", background: "#141417", color: "#eaeaee", fontSize: 12.5, fontFamily: "inherit" }} />
<button type="button" onClick={runNow} disabled={busy} style={{ ...fieldBtn, marginTop: 8, width: "100%", justifyContent: "center" }}><Play size={13} />{busy ? "Running…" : "Run team"}</button>
</div>
<div style={{ flex: "none", display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 14px 4px" }}>
<span style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".1em", color: "#6a6a72" }}>RECENT RUNS</span>
<button type="button" onClick={loadRuns} aria-label="Refresh" title="Refresh" style={{ width: 24, height: 24, borderRadius: 6, border: "1px solid rgba(255,255,255,.1)", background: "transparent", color: "#8a8a92", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><RefreshCw size={12} /></button>
</div>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: "4px 10px 12px" }}>
{runs === null ? (
<div style={{ fontFamily: mono, fontSize: 11, color: "#5ec8d8", padding: 8 }}>Loading…</div>
) : runs.length === 0 ? (
<div style={{ fontSize: 12, color: "#8a8a92", padding: 8, lineHeight: 1.5 }}>No runs yet. Trigger one above, or wait for the nightly loop.</div>
) : runs.map((r) => (
<button key={r.id} type="button" onClick={() => watch(r.id)} style={{ width: "100%", textAlign: "left", display: "flex", flexDirection: "column", gap: 3, padding: "8px 10px", marginBottom: 6, borderRadius: 9, border: `1px solid ${activeRun === r.id ? "rgba(94,200,216,.5)" : "rgba(255,255,255,.07)"}`, background: activeRun === r.id ? "rgba(94,200,216,.08)" : "#101013", cursor: "pointer" }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: statusColor(r.status), flex: "none" }} />
<span style={{ fontFamily: mono, fontSize: 9, color: statusColor(r.status), textTransform: "uppercase" }}>{r.status}</span>
<span style={{ fontFamily: mono, fontSize: 9, color: "#5a5a62", marginLeft: "auto" }}>{r.kind}</span>
</div>
<div style={{ fontSize: 12, color: "#d6d6da", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.task}</div>
</button>
))}
</div>
</div>
{/* Right: live step viewer */}
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
{activeRun === null ? (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#6a6a72", fontSize: 13, padding: 24, textAlign: "center" }}>Pick a run on the left to watch its steps, or run the team now.</div>
) : (
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ fontFamily: mono, fontSize: 10, color: statusColor(status || "running"), textTransform: "uppercase" }}>{status === "running" ? "● streaming" : status}</div>
{steps.map((s, i) => (
<div key={i} style={{ borderRadius: 10, border: "1px solid rgba(255,255,255,.08)", background: "#101013", padding: 11 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 5 }}>
<span style={{ fontFamily: mono, fontSize: 11, fontWeight: 700, color: "#5ec8d8" }}>{s.role || s.node_id}</span>
{s.phase ? <span style={{ fontFamily: mono, fontSize: 9, color: "#6a6a72" }}>{s.phase}</span> : null}
</div>
<div style={{ fontSize: 12.5, color: "#cfcfd5", lineHeight: 1.5, whiteSpace: "pre-wrap" }}>{s.output}</div>
</div>
))}
{finalOutput ? (
<div style={{ borderRadius: 10, border: "1px solid rgba(127,208,160,.3)", background: "rgba(127,208,160,.06)", padding: 12 }}>
<div style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".1em", color: "#7fd0a0", marginBottom: 5 }}>FINAL OUTPUT</div>
<div style={{ fontSize: 12.5, color: "#e6e6ea", lineHeight: 1.5, whiteSpace: "pre-wrap" }}>{finalOutput}</div>
</div>
) : null}
{error ? <div style={{ fontSize: 12, color: "#ff8a7a" }}>{error}</div> : null}
{steps.length === 0 && !finalOutput && !error ? <div style={{ fontFamily: mono, fontSize: 11, color: "#6a6a72" }}>Waiting for steps…</div> : null}
</div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,310 @@
"use client";
// The cross-cutting tools (Skills / Apps / Topologies / Approvals / Team /
// Credits) folded INTO the dashboard: instead of navigating to the old
// shell-chrome pages, the four-square launcher opens this full-screen overlay
// and renders the same content client-side (fetched via the same-origin /api
// proxy, which forwards the session). You never leave the new interface.
import { useEffect, useState } from "react";
import { Box, Brain, CalendarClock, Clock, Cloud, FolderOpen, Globe, HardDrive, LogOut, Mail, MessageSquare, Search, Server, Sparkles, Terminal } from "lucide-react";
import { AppsDirectory } from "@/components/global/AppsDirectory";
import { TopologyWorkbench } from "@/components/topology/TopologyWorkbench";
import { TeamTabs } from "@/components/global/TeamTabs";
import { ApprovalQueue } from "@/components/safety/ApprovalQueue";
import { BuyCredits } from "@/components/global/BuyCredits";
import { PromoRedeem } from "@/components/global/PromoRedeem";
import { Card } from "@/components/ui/Card";
import type { CatalogEntry } from "@/lib/api/topology";
import type { Approval } from "@/lib/api/approvals";
import type { User, Credits } from "@/lib/api/schemas";
import type { OrgChartNode, LeaderboardRow } from "@/lib/api/team";
const mono = "'JetBrains Mono', ui-monospace, monospace";
export type ToolKey = "infrastructure" | "brains" | "tools" | "profile" | "credits" | "skills" | "apps" | "topologies" | "approvals" | "team";
export const TOOL_META: Record<ToolKey, { title: string; sub: string }> = {
infrastructure: { title: "Infrastructure", sub: "Local hardware, containers, VMs and clouds you wire up to the platform." },
brains: { title: "Brains", sub: "Browse and search the ClawBrainHub registry." },
tools: { title: "Tools", sub: "The tools you can expose to your agents, plus workspace connections." },
profile: { title: "Profile", sub: "Your account." },
credits: { title: "Credits", sub: "Balance, usage, and subscriptions." },
skills: { title: "Skill Library", sub: "Skills published by your team and the catalog." },
apps: { title: "Apps", sub: "Workspace-wide connections your agents can use." },
topologies: { title: "Topologies", sub: "Organizational patterns to run your agents in." },
approvals: { title: "Approvals", sub: "Gated actions waiting on your review." },
team: { title: "Team", sub: "Members, the agent org chart, and usage." },
};
// Tiny same-origin JSON fetch hook (the /api proxy forwards the session cookie).
function useJson<T>(url: string): { data: T | null; loading: boolean; error: boolean } {
const [state, setState] = useState<{ data: T | null; loading: boolean; error: boolean }>({ data: null, loading: true, error: false });
useEffect(() => {
// Each tool panel mounts with a fixed url, so no synchronous reset is
// needed here — state starts at loading:true and only the async results set it.
let alive = true;
fetch(url, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
.then((d) => { if (alive) setState({ data: d as T, loading: false, error: false }); })
.catch(() => { if (alive) setState({ data: null, loading: false, error: true }); });
return () => { alive = false; };
}, [url]);
return state;
}
function Status({ loading, error, emptyLabel }: { loading: boolean; error: boolean; emptyLabel?: string }) {
return (
<p style={{ fontSize: 13, color: error ? "#e8b465" : "#6a6a72" }}>
{error ? "Couldn’t load — try again." : loading ? "Loading…" : emptyLabel ?? "Nothing here yet."}
</p>
);
}
interface Skill { id: string; title: string; author: string; description: string; installs: number }
interface DirectoryApp { id: string; name: string; description: string; category: string; connected: boolean }
interface Usage { tokens_in: number; tokens_out: number; credits: number }
function SkillsPanel() {
const { data, loading, error } = useJson<Skill[]>("/api/skills");
if (!data?.length) return <Status loading={loading} error={error} emptyLabel="No skills published yet." />;
return (
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{data.map((s) => (
<li key={s.id}>
<Card className="h-full">
<p className="text-lg font-semibold">{s.title}</p>
<p className="text-xxs text-muted-foreground">by {s.author}</p>
<p className="pt-3 text-xs text-muted-foreground">{s.description}</p>
<p className="pt-4 text-xxs text-muted-foreground">{s.installs} {s.installs === 1 ? "install" : "installs"} on your team</p>
</Card>
</li>
))}
</ul>
);
}
function AppsPanel() {
const { data, loading, error } = useJson<DirectoryApp[]>("/api/apps?workspace=true");
if (!data) return <Status loading={loading} error={error} />;
return <AppsDirectory apps={data} />;
}
function TopologiesPanel() {
const { data, loading, error } = useJson<CatalogEntry[]>("/api/topologies");
if (!data) return <Status loading={loading} error={error} />;
return <TopologyWorkbench catalog={data} />;
}
function ApprovalsPanel() {
const { data, loading, error } = useJson<Approval[]>("/api/approvals");
if (!data) return <Status loading={loading} error={error} />;
return <ApprovalQueue approvals={data} />;
}
function TeamPanel() {
const members = useJson<User[]>("/api/team/members");
const orgchart = useJson<OrgChartNode[]>("/api/team/orgchart");
const leaderboard = useJson<LeaderboardRow[]>("/api/team/leaderboard");
if (!members.data || !orgchart.data || !leaderboard.data) {
return <Status loading={members.loading || orgchart.loading || leaderboard.loading} error={members.error || orgchart.error || leaderboard.error} />;
}
return <TeamTabs members={members.data} orgchart={orgchart.data} leaderboard={leaderboard.data} />;
}
function CreditsPanel() {
const credits = useJson<Credits>("/api/team/credits");
const usage = useJson<Usage>("/api/team/usage");
if (!credits.data || !usage.data) return <Status loading={credits.loading || usage.loading} error={credits.error || usage.error} />;
const burn = usage.data.credits;
const runwayDays = burn > 0 ? Math.floor((credits.data.available / burn) * 7) : null;
return (
<div className="flex flex-col gap-4">
<Card className="flex flex-wrap items-end justify-between gap-4 shadow-card">
<div>
<p className="text-xs tracking-wide text-muted-foreground uppercase">Available credits</p>
<p data-testid="credit-balance" className={`pt-2 font-mono text-xxxl font-semibold ${credits.data.available < 0 ? "text-coral" : ""}`}>{credits.data.available.toLocaleString("en-US")}</p>
<p className="pt-2 text-xs text-muted-foreground">Credits never expire.</p>
</div>
<BuyCredits />
</Card>
<Card>
<p className="text-xs tracking-wide text-muted-foreground uppercase">Usage · last 7 days</p>
<p className="pt-2 text-sm">{usage.data.credits.toLocaleString("en-US")} credits · {(usage.data.tokens_in + usage.data.tokens_out).toLocaleString("en-US")} tokens ({usage.data.tokens_in.toLocaleString("en-US")} in / {usage.data.tokens_out.toLocaleString("en-US")} out)</p>
<p className="pt-1 text-xs text-muted-foreground">{runwayDays !== null ? `~${runwayDays} days of runway at this pace.` : "No usage yet this week."}</p>
</Card>
<PromoRedeem />
<Card className="flex items-center gap-4">
<span className="flex size-10 shrink-0 items-center justify-center rounded-2xl bg-surface-warm-muted"><Sparkles aria-hidden size={18} className="text-coral" /></span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Scaling beyond self-serve?</p>
<p className="text-xs text-muted-foreground">Volume pricing, SSO, and dedicated support for larger teams.</p>
</div>
<a href="mailto:[email protected]" className="rounded-full border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-hover-bg">Talk to sales</a>
</Card>
</div>
);
}
// ── Infrastructure (coming soon) ────────────────────────────────────────────
const INFRA: { icon: typeof HardDrive; label: string; desc: string }[] = [
{ icon: HardDrive, label: "Local hardware", desc: "Run agents on your own machines — Macs, Linux boxes, edge devices." },
{ icon: Box, label: "Containers", desc: "Deploy agent runtimes as Docker / OCI containers." },
{ icon: Server, label: "Virtual machines", desc: "Provision agents on VMs across your fleet." },
{ icon: Cloud, label: "Clouds", desc: "Connect AWS, GCP and Azure and run at scale." },
];
function ComingSoon() {
return <span style={{ fontFamily: mono, fontSize: 9, letterSpacing: ".1em", color: "#e8b465", border: "1px solid rgba(232,180,101,.35)", background: "rgba(232,180,101,.08)", padding: "2px 7px", borderRadius: 5 }}>COMING SOON</span>;
}
function InfrastructurePanel() {
return (
<div>
<p style={{ fontSize: 13, color: "#8a8a92", marginBottom: 18, lineHeight: 1.5 }}>Wire your own infrastructure to the platform and run agents wherever you need them. These integrations are on the way.</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))", gap: 14 }}>
{INFRA.map((t) => (
<div key={t.label} style={{ borderRadius: 14, background: "#0f0f13", border: "1px solid rgba(255,255,255,.08)", padding: 16, display: "flex", flexDirection: "column", gap: 9, minHeight: 150 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ width: 38, height: 38, borderRadius: 10, background: "rgba(255,111,97,.1)", border: "1px solid rgba(255,111,97,.25)", display: "flex", alignItems: "center", justifyContent: "center", color: "#ff8a7a" }}><t.icon size={19} /></span>
<span style={{ fontSize: 15, fontWeight: 700, color: "#f3f3f5" }}>{t.label}</span>
</div>
<p style={{ fontSize: 12.5, color: "#9a9aa2", lineHeight: 1.5, margin: 0 }}>{t.desc}</p>
<div style={{ marginTop: "auto", paddingTop: 6 }}><ComingSoon /></div>
</div>
))}
</div>
</div>
);
}
// ── Tools you can expose to agents (+ workspace connections) ─────────────────
const AGENT_TOOLS: { name: string; icon: typeof Search; desc: string; tint: string }[] = [
{ name: "web.search", icon: Search, desc: "Search the web for current information — papers, news, docs.", tint: "#5ec8d8" },
{ name: "browser.goto", icon: Globe, desc: "Fetch a web page in a sandboxed headless browser.", tint: "#7fc8ff" },
{ name: "files.write", icon: FolderOpen, desc: "Read & write files in the agent's drives (documents, shared).", tint: "#5fd08a" },
{ name: "shell.exec", icon: Terminal, desc: "Run commands in an isolated, network-free sandbox.", tint: "#e8b465" },
{ name: "email.send", icon: Mail, desc: "Send email — gated on your approval.", tint: "#c98af0" },
{ name: "slack.post", icon: MessageSquare, desc: "Post to Slack — gated on your approval.", tint: "#ff8a7a" },
{ name: "chat.send", icon: MessageSquare, desc: "Message other agents on the team.", tint: "#9a8cff" },
{ name: "routine.schedule", icon: CalendarClock, desc: "Schedule recurring work on a cron.", tint: "#6fd0c0" },
{ name: "clock.now", icon: Clock, desc: "Get the current date & time.", tint: "#8a8a92" },
];
function ToolsPanel() {
const { data, loading, error } = useJson<DirectoryApp[]>("/api/apps?workspace=true");
return (
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<section>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 12 }}>AGENT TOOLS · {AGENT_TOOLS.length}</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: 12 }}>
{AGENT_TOOLS.map((t) => (
<div key={t.name} style={{ borderRadius: 12, background: "#0f0f13", border: `1px solid ${t.tint}30`, padding: 13, display: "flex", gap: 11 }}>
<span style={{ width: 34, height: 34, flex: "none", borderRadius: 9, background: `${t.tint}1f`, border: `1px solid ${t.tint}40`, display: "flex", alignItems: "center", justifyContent: "center", color: t.tint }}><t.icon size={17} /></span>
<div style={{ minWidth: 0 }}>
<div style={{ fontFamily: mono, fontSize: 12.5, fontWeight: 600, color: "#eaeaee" }}>{t.name}</div>
<p style={{ fontSize: 11.5, color: "#9a9aa2", lineHeight: 1.45, margin: "3px 0 0" }}>{t.desc}</p>
</div>
</div>
))}
</div>
</section>
<section>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 12 }}>CONNECTIONS</div>
{data ? <AppsDirectory apps={data} /> : <Status loading={loading} error={error} />}
</section>
</div>
);
}
// ── Brains: search the ClawBrainHub registry on a full screen ────────────────
interface BrainListing { reference: string; owner: string; name: string; description: string; trust_score?: number; size_bytes?: number }
function BrainsPanel() {
const [query, setQuery] = useState("");
const [items, setItems] = useState<BrainListing[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true);
const t = setTimeout(() => {
fetch(`/api/brainhub/search?q=${encodeURIComponent(query)}`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : []))
.then((d) => { if (alive) { const arr = Array.isArray(d) ? d : (d?.brains ?? []); setItems(arr as BrainListing[]); setLoading(false); } })
.catch(() => { if (alive) { setItems([]); setLoading(false); } });
}, 250);
return () => { alive = false; clearTimeout(t); };
}, [query]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search ClawBrainHub — e.g. rust, react native, research…" style={{ width: "100%", boxSizing: "border-box", padding: "12px 14px", borderRadius: 10, border: "1px solid rgba(255,255,255,.12)", background: "#141417", color: "#eaeaee", fontSize: 14, marginBottom: 18 }} />
{loading ? <Status loading error={false} /> : items.length === 0 ? <Status loading={false} error={false} emptyLabel="No brains found — try a keyword." /> : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 12 }}>
{items.map((b) => (
<div key={b.reference} style={{ borderRadius: 12, background: "#0f0f13", border: "1px solid rgba(255,255,255,.08)", padding: 14 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 30, height: 30, flex: "none", borderRadius: 8, background: "rgba(127,200,255,.12)", border: "1px solid rgba(127,200,255,.3)", display: "flex", alignItems: "center", justifyContent: "center", color: "#7fc8ff" }}><Brain size={16} /></span>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 13.5, fontWeight: 700, color: "#f3f3f5", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{b.name}</div>
<div style={{ fontFamily: mono, fontSize: 9.5, color: "#6a6a72" }}>{b.owner}</div>
</div>
{typeof b.trust_score === "number" ? <span style={{ fontFamily: mono, fontSize: 9, color: "#7fd0a0" }}>{Math.round(b.trust_score)}%</span> : null}
</div>
{b.description ? <p style={{ fontSize: 11.5, color: "#9a9aa2", lineHeight: 1.45, margin: "9px 0 0" }}>{b.description}</p> : null}
<div style={{ fontFamily: mono, fontSize: 9.5, color: "#5a5a62", marginTop: 9, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{b.reference}</div>
</div>
))}
</div>
)}
</div>
);
}
// ── Profile ─────────────────────────────────────────────────────────────────
function ProfilePanel({ user }: { user?: { display_name?: string; email?: string } }) {
async function signOut() {
try { await fetch("/auth/session", { method: "DELETE" }); } catch { /* ignore */ }
window.location.href = "/";
}
const initial = (user?.display_name || user?.email || "Y").trim().charAt(0).toUpperCase();
return (
<div style={{ maxWidth: 460, display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 14, borderRadius: 14, background: "#0f0f13", border: "1px solid rgba(255,255,255,.08)", padding: 18 }}>
<span style={{ width: 52, height: 52, flex: "none", borderRadius: 14, background: "linear-gradient(135deg,#ff8a7a,#ff5f57)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22, fontWeight: 700, color: "#2a0d0a" }}>{initial}</span>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 17, fontWeight: 700, color: "#f3f3f5" }}>{user?.display_name || "—"}</div>
<div style={{ fontSize: 13, color: "#8a8a92", overflow: "hidden", textOverflow: "ellipsis" }}>{user?.email || ""}</div>
</div>
</div>
<button type="button" onClick={signOut} style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 8, padding: "11px 0", borderRadius: 10, border: "1px solid rgba(255,111,97,.4)", background: "rgba(255,111,97,.08)", color: "#ff8a7a", fontSize: 13.5, fontWeight: 600, cursor: "pointer" }}><LogOut size={16} />Sign out</button>
</div>
);
}
export function ToolPanel({ tool, user, onClose }: { tool: ToolKey; user?: { display_name?: string; email?: string }; onClose: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const meta = TOOL_META[tool];
return (
<div style={{ position: "fixed", inset: 0, zIndex: 90, background: "#08080a", display: "flex", flexDirection: "column", animation: "cm-fade .18s ease" }}>
<div style={{ flex: "none", display: "flex", alignItems: "flex-start", gap: 12, padding: "18px 24px 14px", borderBottom: "1px solid rgba(255,255,255,.06)" }}>
<div style={{ flex: 1 }}>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 5 }}>TOOLS</div>
<div style={{ fontSize: 22, fontWeight: 700, color: "#f3f3f5", letterSpacing: "-.01em" }}>{meta.title}</div>
<div style={{ fontSize: 13, color: "#8a8a92", marginTop: 2 }}>{meta.sub}</div>
</div>
<button type="button" onClick={onClose} aria-label="Close" style={{ display: "flex", alignItems: "center", gap: 7, padding: "7px 12px", borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#cfcfd5", fontSize: 13, cursor: "pointer" }}>✕ Close</button>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: 24 }}>
<div style={{ maxWidth: 960, margin: "0 auto" }}>
{tool === "infrastructure" ? <InfrastructurePanel /> : tool === "brains" ? <BrainsPanel /> : tool === "tools" ? <ToolsPanel /> : tool === "profile" ? <ProfilePanel user={user} /> : tool === "skills" ? <SkillsPanel /> : tool === "apps" ? <AppsPanel /> : tool === "topologies" ? <TopologiesPanel /> : tool === "approvals" ? <ApprovalsPanel /> : tool === "team" ? <TeamPanel /> : <CreditsPanel />}
</div>
</div>
</div>
);
}
@@ -0,0 +1,53 @@
"use client";
// The dashboard's four-square tools launcher. Same six tools as the old shell's
// SecondaryNav, but instead of navigating to old routes it opens them as
// in-dashboard panels (ToolPanel) via onOpen — so you never leave the new
// interface.
import { useState } from "react";
import { Blocks, CreditCard, LayoutGrid, Share2, ShieldCheck, Users, Zap } from "lucide-react";
import type { ToolKey } from "./ToolPanel";
const ITEMS: { key: ToolKey; label: string; icon: typeof Zap }[] = [
{ key: "skills", label: "Skills", icon: Zap },
{ key: "apps", label: "Apps", icon: Blocks },
{ key: "topologies", label: "Topologies", icon: Share2 },
{ key: "approvals", label: "Approvals", icon: ShieldCheck },
{ key: "team", label: "Team", icon: Users },
{ key: "credits", label: "Credits", icon: CreditCard },
];
export function ToolsLauncher({ onOpen }: { onOpen: (t: ToolKey) => void }) {
const [open, setOpen] = useState(false);
return (
<div style={{ position: "relative", display: "flex", justifyContent: "center" }}>
<button
type="button" title="Tools" aria-label="Tools" aria-expanded={open}
onClick={() => setOpen((v) => !v)}
style={{ width: 36, height: 36, borderRadius: 9, border: "1px solid rgba(255,255,255,.08)", background: open ? "rgba(255,255,255,.06)" : "transparent", display: "flex", alignItems: "center", justifyContent: "center", color: open ? "#f3f3f5" : "#6a6a72", cursor: "pointer" }}
>
<LayoutGrid size={18} />
</button>
{open ? (
<>
<button type="button" aria-label="Close" onClick={() => setOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 40, background: "transparent", border: 0 }} />
<nav style={{ position: "absolute", bottom: 0, left: "100%", marginLeft: 8, zIndex: 50, width: 178, display: "flex", flexDirection: "column", gap: 2, borderRadius: 14, background: "#141417", border: "1px solid rgba(255,255,255,.1)", padding: 8, boxShadow: "0 18px 50px rgba(0,0,0,.55)" }}>
{ITEMS.map((it) => (
<button
key={it.key} type="button"
onClick={() => { onOpen(it.key); setOpen(false); }}
style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 10px", borderRadius: 9, border: 0, background: "transparent", color: "#cfcfd5", fontSize: 13, fontWeight: 500, cursor: "pointer", textAlign: "left" }}
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255,255,255,.05)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
>
<it.icon size={16} style={{ color: "#8a8a92" }} /> {it.label}
</button>
))}
</nav>
</>
) : null}
</div>
);
}
@@ -0,0 +1,69 @@
"use client";
// A compact popover listing the FULL topology taxonomy grouped by category
// (Traditional, Organic, Dynamic, Federated, Novel, Traditional/Future Gov).
// Executable kinds (the ones the cm-topology engine runs) get a "runnable" badge.
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { TOPOLOGY_CATEGORIES, topologyById } from "@/lib/topologies";
const mono = "'JetBrains Mono', ui-monospace, monospace";
export function TopologySelector({
value,
onChange,
label = "TOPOLOGY",
}: {
value: string;
onChange: (id: string) => void;
label?: string;
}) {
const [open, setOpen] = useState(false);
const current = topologyById(value);
return (
<div style={{ position: "relative" }}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 11px", borderRadius: 7, cursor: "pointer", background: "rgba(255,111,97,.14)", border: "1px solid rgba(255,111,97,.45)", color: "#ff8a7a", fontFamily: mono, fontSize: 11, fontWeight: 600 }}
>
<span style={{ fontSize: 9, letterSpacing: ".12em", color: "#ff8a7a99" }}>{label}</span>
{current?.label ?? "Select…"}
<ChevronDown size={12} />
</button>
{open ? (
<>
<button type="button" aria-label="Close" onClick={() => setOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 40, background: "transparent", border: 0 }} />
<div style={{ position: "absolute", top: "calc(100% + 6px)", left: 0, zIndex: 50, width: 320, maxHeight: 420, overflowY: "auto", borderRadius: 12, background: "#0d0d10", border: "1px solid rgba(255,255,255,.1)", boxShadow: "0 24px 60px rgba(0,0,0,.6)", padding: 8 }}>
{TOPOLOGY_CATEGORIES.map((cat) => (
<div key={cat.category} style={{ marginBottom: 6 }}>
<div style={{ fontFamily: mono, fontSize: 9, letterSpacing: ".12em", color: "#5a5a62", padding: "8px 8px 4px" }}>{cat.category.toUpperCase()}</div>
{cat.kinds.map((kind) => {
const on = kind.id === value;
return (
<button
key={kind.id}
type="button"
onClick={() => { onChange(kind.id); setOpen(false); }}
title={kind.blurb}
style={{ display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left", padding: "7px 9px", borderRadius: 8, cursor: "pointer", background: on ? "rgba(255,111,97,.12)" : "transparent", border: 0, color: on ? "#fff" : "#cfcfd5" }}
>
<span style={{ flex: 1, fontSize: 12, fontWeight: on ? 600 : 500 }}>{kind.label}</span>
{kind.executable ? (
<span style={{ fontFamily: mono, fontSize: 8, letterSpacing: ".06em", color: "#5fd08a", padding: "2px 6px", borderRadius: 5, background: "rgba(95,208,138,.1)", border: "1px solid rgba(95,208,138,.25)" }}>runnable</span>
) : null}
</button>
);
})}
</div>
))}
</div>
</>
) : null}
</div>
);
}
@@ -0,0 +1,54 @@
"use client";
// Top-right user menu. The same six tools that used to live in the left rail's
// launcher now hang off the user icon — click it to open them as in-dashboard
// panels (ToolPanel) via onOpen.
import { useState } from "react";
import { Brain, CreditCard, Server, User, Wrench } from "lucide-react";
import type { ToolKey } from "./ToolPanel";
const mono = "'JetBrains Mono', ui-monospace, monospace";
const ITEMS: { key: ToolKey; label: string; icon: typeof User }[] = [
{ key: "infrastructure", label: "Infrastructure", icon: Server },
{ key: "brains", label: "Brains", icon: Brain },
{ key: "tools", label: "Tools", icon: Wrench },
{ key: "profile", label: "Profile", icon: User },
{ key: "credits", label: "Credits", icon: CreditCard },
];
export function UserMenu({ onOpen }: { onOpen: (t: ToolKey) => void }) {
const [open, setOpen] = useState(false);
return (
<div style={{ position: "relative" }}>
<button
type="button" title="Menu" aria-label="Menu" aria-expanded={open}
onClick={() => setOpen((v) => !v)}
style={{ width: 40, height: 40, borderRadius: 11, border: `1px solid ${open ? "rgba(255,138,122,.55)" : "rgba(255,255,255,.12)"}`, background: open ? "rgba(255,111,97,.16)" : "linear-gradient(135deg,#ff8a7a,#ff5f57)", display: "flex", alignItems: "center", justifyContent: "center", color: open ? "#ff8a7a" : "#2a0d0a", cursor: "pointer" }}
>
<User size={21} />
</button>
{open ? (
<>
<button type="button" aria-label="Close" onClick={() => setOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 40, background: "transparent", border: 0 }} />
<nav style={{ position: "absolute", top: "100%", right: 0, marginTop: 8, zIndex: 50, width: 196, display: "flex", flexDirection: "column", gap: 2, borderRadius: 14, background: "#141417", border: "1px solid rgba(255,255,255,.1)", padding: 8, boxShadow: "0 18px 50px rgba(0,0,0,.55)" }}>
<div style={{ fontFamily: mono, fontSize: 9, letterSpacing: ".12em", color: "#5a5a62", padding: "4px 10px 6px" }}>TOOLS</div>
{ITEMS.map((it) => (
<button
key={it.key} type="button"
onClick={() => { onOpen(it.key); setOpen(false); }}
style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 10px", borderRadius: 9, border: 0, background: "transparent", color: "#cfcfd5", fontSize: 13, fontWeight: 500, cursor: "pointer", textAlign: "left" }}
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255,255,255,.05)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
>
<it.icon size={16} style={{ color: "#8a8a92" }} /> {it.label}
</button>
))}
</nav>
</>
) : null}
</div>
);
}
@@ -0,0 +1,190 @@
"use client";
// A GitHub-style contribution grid for an agent: a year of day-squares whose
// colour denotes how much the agent did that day (commits/activity). It's drawn
// on a canvas via requestAnimationFrame so it stays lively without re-rendering
// React: every active cell breathes, a highlight wave sweeps left→right, and
// "hot" days (>100) glow gold and pulse harder. Data is seeded per-agent and
// simulated for now — swap `generate()` for real telemetry later.
import { useEffect, useMemo, useRef } from "react";
const mono = "'JetBrains Mono', ui-monospace, monospace";
const WEEKS = 53;
const DAYS = 7;
// 0 = empty, 1..4 = the classic green ramp, 5 = a "hot" (>100) gold day.
const COLORS = [
"rgba(255,255,255,.05)",
"#0e4d2e",
"#1c854c",
"#2fbf6e",
"#54e892",
"#ffc24b",
];
function levelOf(c: number): number {
if (c <= 0) return 0;
if (c < 8) return 1;
if (c < 25) return 2;
if (c < 60) return 3;
if (c < 100) return 4;
return 5; // hot
}
// Small seeded PRNG so each agent gets a stable-but-distinct year.
function hashStr(s: string): number {
let h = 2166136261;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return h >>> 0;
}
function mulberry32(a: number) {
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function generate(seed: string) {
const rnd = mulberry32(hashStr(seed || "agent"));
const commits: number[] = [];
const levels: number[] = [];
let total = 0;
let hot = 0;
// Per-week "momentum" makes streaky busy/quiet stretches instead of pure noise.
for (let w = 0; w < WEEKS; w++) {
const weekBoost = rnd() < 0.22 ? rnd() * 45 : 0;
const quietWeek = rnd() < 0.16;
for (let d = 0; d < DAYS; d++) {
const weekend = d === 0 || d === 6;
let v = (weekend ? 3 : 16) + rnd() * (weekend ? 8 : 26) + weekBoost;
if (quietWeek) v *= 0.25;
if (rnd() < 0.035) v += 95 + rnd() * 85; // occasional hot day
if (rnd() < 0.12) v = rnd() * 3; // an off day
const c = Math.max(0, Math.round(v));
commits.push(c);
const lv = levelOf(c);
levels.push(lv);
total += c;
if (lv === 5) hot++;
}
}
return { commits, levels, total, hot };
}
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
export function VitalsCard({ seed = "agent" }: { seed?: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const data = useMemo(() => generate(seed), [seed]);
useEffect(() => {
const cv = canvasRef.current;
const wrap = wrapRef.current;
if (!cv || !wrap) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const { levels } = data;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let cssW = 0, cssH = 0, step = 16, cell = 13, cols = WEEKS, padX = 0, padY = 2;
let t = 0, raf = 0;
const resize = () => {
const r = wrap.getBoundingClientRect();
cssW = r.width; cssH = r.height;
cv.width = Math.max(1, Math.round(cssW * dpr));
cv.height = Math.max(1, Math.round(cssH * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// Size cells to the available height (7 rows) and cap, then fit the columns.
step = Math.max(11, Math.min(17, Math.floor((cssH - padY * 2) / DAYS)));
cell = step - 3;
cols = Math.max(8, Math.min(WEEKS, Math.floor((cssW - 2) / step)));
padX = Math.max(0, Math.round((cssW - cols * step) / 2));
padY = Math.max(0, Math.round((cssH - DAYS * step) / 2));
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(wrap);
const draw = () => {
t += 1;
const time = t * 0.016;
const wavePos = ((time * 8) % (cols + 12)) - 6; // sweeping highlight
const offset = WEEKS - cols; // show the most recent weeks
ctx.clearRect(0, 0, cssW, cssH);
for (let w = 0; w < cols; w++) {
for (let d = 0; d < DAYS; d++) {
const lv = levels[(offset + w) * DAYS + d];
const x = padX + w * step;
const y = padY + d * step;
const phase = w * 0.5 + d * 0.85;
const dx = w - wavePos;
const wave = Math.exp(-(dx * dx) / 5) * (lv === 0 ? 0.1 : 0.5);
let alpha: number;
if (lv === 0) {
alpha = 0.55 + wave;
} else if (lv === 5) {
const hp = 0.7 + 0.3 * Math.sin(time * 4 + phase);
ctx.shadowColor = COLORS[5];
ctx.shadowBlur = 7 + 7 * hp;
alpha = Math.min(1, 0.82 + 0.18 * hp + wave);
} else {
const pulse = (0.5 + 0.5 * Math.sin(time * 1.8 + phase)) * (0.1 + lv * 0.035);
alpha = Math.min(1, 0.58 + pulse + wave);
}
ctx.globalAlpha = alpha;
ctx.fillStyle = COLORS[lv];
roundRect(ctx, x, y, cell, cell, 2.5);
ctx.fill();
ctx.shadowBlur = 0;
// Crest of the wave flashes a faint white highlight over lit cells.
if (wave > 0.32 && lv > 0) {
ctx.globalAlpha = (wave - 0.32) * 0.7;
ctx.fillStyle = "#ffffff";
roundRect(ctx, x, y, cell, cell, 2.5);
ctx.fill();
}
}
}
ctx.globalAlpha = 1;
raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
}, [data]);
return (
<div style={{ width: "100%", borderRadius: 12, background: "#0f0f13", border: "1px solid rgba(94,200,216,.25)", padding: 12, boxShadow: "0 8px 22px rgba(0,0,0,.4)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 10 }}>
<span className="cm-blink" style={{ width: 6, height: 6, borderRadius: "50%", background: "#5ec8d8" }} />
<span style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".08em", color: "#5ec8d8" }}>ACTIVITY · LIVE</span>
<span style={{ flex: 1 }} />
<span style={{ fontFamily: mono, fontSize: 9.5, color: "#9a9aa2" }}>{data.total.toLocaleString()} commits</span>
{data.hot > 0 ? <span style={{ fontFamily: mono, fontSize: 9.5, color: "#ffc24b" }}>· {data.hot} hot</span> : null}
</div>
<div ref={wrapRef} style={{ position: "relative", width: "100%", height: 118 }}>
<canvas ref={canvasRef} style={{ width: "100%", height: "100%", display: "block" }} />
</div>
<div style={{ marginTop: 8, display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ fontFamily: mono, fontSize: 9, color: "#3a3a40", flex: 1 }}>past year · simulated, not yet wired to live telemetry</span>
<span style={{ fontFamily: mono, fontSize: 9, color: "#6a6a72" }}>Less</span>
{COLORS.slice(1).map((c) => (<span key={c} style={{ width: 9, height: 9, borderRadius: 2, background: c }} />))}
<span style={{ fontFamily: mono, fontSize: 9, color: "#6a6a72" }}>More</span>
</div>
</div>
);
}
@@ -0,0 +1,46 @@
"use client";
import { memo } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
export interface AgentNodeData {
label: string;
role: string;
grad: string;
ink: string;
status: string;
selected: boolean;
[key: string]: unknown;
}
const statusColor = (s: string) => (s === "running" ? "#5ec8d8" : s === "online" ? "#5fd08a" : "#3a3a40");
// A topology node: gradient avatar + name + role + status dot, coral ring when
// selected, cyan halo while running. Hidden handles let edges connect.
function AgentNodeImpl({ data }: NodeProps) {
const d = data as AgentNodeData;
return (
<div style={{ width: 120, display: "flex", flexDirection: "column", alignItems: "center", gap: 6 }}>
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<div style={{ position: "relative", width: 50, height: 50 }}>
{d.status === "running" ? (
<div className="cm-halo" style={{ position: "absolute", inset: 0, borderRadius: "50%", background: "rgba(94,200,216,.4)" }} />
) : null}
{d.selected ? (
<div style={{ position: "absolute", inset: -6, borderRadius: "50%", border: "2px solid #ff6f61", boxShadow: "0 0 0 4px rgba(255,111,97,.12)" }} />
) : null}
<div style={{ position: "relative", width: 50, height: 50, borderRadius: "50%", background: d.grad, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18, fontWeight: 700, color: d.ink, boxShadow: "0 0 28px rgba(0,0,0,.45)" }}>
{(d.label || "?").charAt(0).toUpperCase()}
</div>
<span style={{ position: "absolute", right: -1, bottom: -1, width: 12, height: 12, borderRadius: "50%", background: statusColor(d.status), border: "2px solid #0a0a0c" }} />
</div>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: 12, fontWeight: 600, color: d.selected ? "#fff" : "#dcdce2" }}>{d.label}</div>
<div style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: 9, color: "#6a6a72" }}>{d.role}</div>
</div>
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
</div>
);
}
export const AgentNode = memo(AgentNodeImpl);
@@ -0,0 +1,147 @@
"use client";
import "@xyflow/react/dist/style.css";
import { useMemo, useState } from "react";
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
type Edge,
type Node,
} from "@xyflow/react";
import type { TopologyPattern } from "@/lib/topologies";
import { AgentNode, type AgentNodeData } from "./AgentNode";
import { edgesFor, layoutFor } from "./layout";
const nodeTypes = { agent: AgentNode };
export interface FlowItem {
id: string;
label: string;
role: string;
grad: string;
ink: string;
status: string;
}
/** A React Flow canvas that lays the items out per `pattern` and draws animated
* data-flow edges. Switching pattern reshuffles the nodes; nodes stay draggable.
* Clicking a node selects it via `onSelect`.
*
* Nodes are managed with `useNodesState` (NOT a controlled useMemo): React Flow
* measures each node via a ResizeObserver and emits `dimensions` changes through
* `onNodesChange`; those measurements MUST be applied back to node state or the
* node's `measured` size is never recorded and it stays `visibility:hidden`
* forever. `useNodesState`'s handler applies every change (position, dimensions,
* select), so measurement sticks. We only override positions when the pattern or
* item set changes (re-layout), preserving prior measurements to avoid a flash. */
export function TopologyFlow({
items,
pattern,
selectedId,
onSelect,
}: {
items: FlowItem[];
pattern: TopologyPattern;
selectedId?: string | null;
onSelect: (id: string) => void;
}) {
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
// Re-layout when the pattern or the set of items changes. Preserve measured
// dimensions for surviving nodes so they don't blink back to hidden.
const layoutKey = `${pattern}:${items.map((i) => i.id).join(",")}`;
const [seenLayout, setSeenLayout] = useState<string>("");
if (seenLayout !== layoutKey) {
setSeenLayout(layoutKey);
const base = layoutFor(pattern, items.length);
setNodes((prev) => {
const prevById = new Map(prev.map((n) => [n.id, n]));
return items.map((it, i) => {
const old = prevById.get(it.id);
return {
id: it.id,
type: "agent",
position: base[i] ?? { x: 0, y: 0 },
data: {
label: it.label,
role: it.role,
grad: it.grad,
ink: it.ink,
status: it.status,
selected: it.id === selectedId,
} satisfies AgentNodeData,
...(old?.measured ? { measured: old.measured, width: old.width, height: old.height } : {}),
} as Node;
});
});
}
// Reflect selection highlight without repositioning (keeps measurements).
const [seenSel, setSeenSel] = useState<string | null | undefined>(selectedId);
if (seenSel !== selectedId) {
setSeenSel(selectedId);
setNodes((prev) =>
prev.map((n) => ({ ...n, data: { ...(n.data as AgentNodeData), selected: n.id === selectedId } })),
);
}
const edges: Edge[] = useMemo(
() =>
edgesFor(pattern, items.length).map(([a, b], i) => {
const live =
items[a]?.id === selectedId ||
items[b]?.id === selectedId ||
items[a]?.status === "running" ||
items[b]?.status === "running";
return {
id: `e${i}`,
source: items[a].id,
target: items[b].id,
animated: true,
style: {
stroke: live ? "rgba(255,111,97,.6)" : "rgba(94,200,216,.45)",
strokeWidth: live ? 2 : 1.4,
},
};
}),
[pattern, items, selectedId],
);
return (
<div style={{ position: "absolute", inset: 0 }}>
<ReactFlow
// v12's stylesheet no longer sizes the root .react-flow element itself,
// so without an explicit size it collapses to 0 height. Fill the wrapper.
style={{ width: "100%", height: "100%" }}
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onNodeClick={(_, n) => onSelect(n.id)}
fitView
fitViewOptions={{ padding: 0.28 }}
proOptions={{ hideAttribution: true }}
nodesConnectable={false}
elementsSelectable={false}
minZoom={0.4}
maxZoom={1.6}
colorMode="dark"
>
<Background color="#1c1c22" gap={26} />
<Controls showInteractive={false} />
<MiniMap
pannable
zoomable
nodeColor={() => "#ff6f61"}
maskColor="rgba(8,8,10,.6)"
style={{ background: "#0b0b0e" }}
/>
</ReactFlow>
</div>
);
}
@@ -0,0 +1,239 @@
"use client";
// Large World — one React Flow stage showing the whole org → company → team →
// agent hierarchy. Nodes expand on click (incremental: reveal direct children);
// agent leaves open their profile page. Each level is colored distinctly. A
// dependency-free tidy top-down tree positions the visible nodes; fitView zooms.
import "@xyflow/react/dist/style.css";
import { memo, useEffect, useMemo, useState } from "react";
import {
ReactFlow,
Background,
Controls,
Handle,
MiniMap,
Position,
useNodesState,
type Edge,
type Node,
type NodeProps,
} from "@xyflow/react";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// Structurally compatible with StructureTree's TreeItem.
export interface WorldItem {
id: string;
level: string; // "org" | "company" | "team" | "claw"
label: string;
meta?: string;
grad?: string;
ink?: string;
status?: string;
children?: WorldItem[];
}
const LEVEL: Record<string, { color: string; ink: string }> = {
org: { color: "#c98af0", ink: "#1a0a2a" },
company: { color: "#8a9af0", ink: "#0a0e2a" },
team: { color: "#6fd0c0", ink: "#06201f" },
claw: { color: "#ff8a7a", ink: "#2a0d05" },
};
const statusColor = (s?: string) => (s === "running" ? "#5ec8d8" : s === "online" ? "#5fd08a" : "#3a3a40");
interface WorldNodeData {
level: string;
label: string;
sub: string;
grad: string;
ink: string;
status: string;
selected: boolean;
hasChildren: boolean;
expanded: boolean;
[key: string]: unknown;
}
function WorldNodeImpl({ data }: NodeProps) {
const d = data as WorldNodeData;
if (d.level === "claw") {
return (
<div style={{ width: 120, display: "flex", flexDirection: "column", alignItems: "center", gap: 6 }}>
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<div style={{ position: "relative", width: 48, height: 48 }}>
{d.status === "running" ? <div className="cm-halo" style={{ position: "absolute", inset: 0, borderRadius: "50%", background: "rgba(94,200,216,.4)" }} /> : null}
{d.selected ? <div style={{ position: "absolute", inset: -6, borderRadius: "50%", border: "2px solid #ff6f61", boxShadow: "0 0 0 4px rgba(255,111,97,.12)" }} /> : null}
<div style={{ position: "relative", width: 48, height: 48, borderRadius: "50%", background: d.grad, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 17, fontWeight: 700, color: d.ink, boxShadow: "0 0 26px rgba(0,0,0,.45)" }}>{(d.label || "?").charAt(0).toUpperCase()}</div>
<span style={{ position: "absolute", right: -1, bottom: -1, width: 11, height: 11, borderRadius: "50%", background: statusColor(d.status), border: "2px solid #0a0a0c" }} />
</div>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: 11.5, fontWeight: 600, color: d.selected ? "#fff" : "#dcdce2" }}>{d.label}</div>
<div style={{ fontFamily: mono, fontSize: 8.5, color: "#6a6a72" }}>{d.sub}</div>
</div>
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
</div>
);
}
const lv = LEVEL[d.level] ?? LEVEL.team;
return (
<div style={{ width: 172 }}>
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<div style={{ display: "flex", alignItems: "center", gap: 9, padding: "9px 11px", borderRadius: 11, background: "#101015", border: `1.5px solid ${d.selected ? "#ff6f61" : lv.color + "66"}`, boxShadow: d.selected ? "0 0 0 4px rgba(255,111,97,.12)" : "0 6px 18px rgba(0,0,0,.4)" }}>
<span style={{ width: 30, height: 30, flex: "none", borderRadius: 8, background: lv.color, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 13, fontWeight: 800, color: lv.ink }}>{(d.label || "?").charAt(0).toUpperCase()}</span>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 12.5, fontWeight: 700, color: "#f3f3f5", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{d.label}</div>
<div style={{ fontFamily: mono, fontSize: 8.5, letterSpacing: ".06em", color: lv.color, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{d.level.toUpperCase()}{d.sub ? ` · ${d.sub}` : ""}</div>
</div>
{d.hasChildren ? <span style={{ flex: "none", color: "#8a8a92", fontSize: 11 }}>{d.expanded ? "▾" : "▸"}</span> : null}
</div>
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
</div>
);
}
const WorldNode = memo(WorldNodeImpl);
const nodeTypes = { world: WorldNode };
function visibleChildren(item: WorldItem, expanded: Set<string>): WorldItem[] {
return expanded.has(item.id) ? (item.children ?? []) : [];
}
function flattenVisible(roots: WorldItem[], expanded: Set<string>): WorldItem[] {
const out: WorldItem[] = [];
const walk = (item: WorldItem) => { out.push(item); visibleChildren(item, expanded).forEach(walk); };
roots.forEach(walk);
return out;
}
interface Pos { x: number; y: number }
function layoutWorld(roots: WorldItem[], expanded: Set<string>): Map<string, Pos> {
const pos = new Map<string, Pos>();
const GAPX = 150;
const GAPY = 150;
let leaf = 0;
const place = (item: WorldItem, depth: number): number => {
const kids = visibleChildren(item, expanded);
let x: number;
if (kids.length === 0) { x = leaf * GAPX; leaf++; }
else { const xs = kids.map((k) => place(k, depth + 1)); x = (xs[0] + xs[xs.length - 1]) / 2; }
pos.set(item.id, { x, y: depth * GAPY });
return x;
};
roots.forEach((r) => place(r, 0));
return pos;
}
// Persisted manual positions (localStorage) so a layout the user arranged
// survives expand/collapse and navigating away. Keyed by node id.
const POS_KEY = "cm.world.pos";
function loadSavedPos(): Record<string, Pos> {
if (typeof window === "undefined") return {};
try { return JSON.parse(window.localStorage.getItem(POS_KEY) || "{}") as Record<string, Pos>; } catch { return {}; }
}
function persistPos(p: Record<string, Pos>) {
try { window.localStorage.setItem(POS_KEY, JSON.stringify(p)); } catch { /* ignore */ }
}
export function WorldFlow({ roots, expanded, selectedId, onToggleExpand, onSelect }: {
roots: WorldItem[];
expanded: Set<string>;
selectedId: string | null;
onToggleExpand: (id: string) => void;
onSelect: (id: string) => void;
}) {
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const visible = useMemo(() => flattenVisible(roots, expanded), [roots, expanded]);
const pos = useMemo(() => layoutWorld(roots, expanded), [roots, expanded]);
// Manual positions the user dragged, loaded once from localStorage. A node the
// user moved keeps its saved position across re-layouts; everything else
// auto-arranges into the tidy tree (so newly-revealed children get placed).
const [savedPos, setSavedPos] = useState<Record<string, Pos> | null>(null);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSavedPos(loadSavedPos());
}, []);
const saved = savedPos ?? {};
// Rebuild when the visible set / selection changes (or saved positions load).
// 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 [seen, setSeen] = useState("");
if (seen !== layoutKey) {
setSeen(layoutKey);
setNodes((prev) => {
const prevById = new Map(prev.map((n) => [n.id, n]));
return visible.map((it) => {
const old = prevById.get(it.id);
const kids = it.children ?? [];
return {
id: it.id,
type: "world",
position: saved[it.id] ?? pos.get(it.id) ?? { x: 0, y: 0 },
data: {
level: it.level,
label: it.label,
sub: it.meta ?? "",
grad: it.grad ?? LEVEL.claw.color,
ink: it.ink ?? "#fff",
status: it.status ?? "idle",
selected: it.id === selectedId,
hasChildren: kids.length > 0,
expanded: expanded.has(it.id),
} satisfies WorldNodeData,
...(old?.measured ? { measured: old.measured, width: old.width, height: old.height } : {}),
} as Node;
});
});
}
const edges: Edge[] = useMemo(() => {
const es: Edge[] = [];
visible.forEach((p) => {
visibleChildren(p, expanded).forEach((c) => {
const live = p.id === selectedId || c.id === selectedId;
es.push({ id: `${p.id}->${c.id}`, source: p.id, target: c.id, animated: true, style: { stroke: live ? "rgba(255,111,97,.6)" : "rgba(94,200,216,.4)", strokeWidth: live ? 2 : 1.3 } });
});
});
return es;
}, [visible, expanded, selectedId]);
return (
<div style={{ position: "absolute", inset: 0 }}>
<ReactFlow
style={{ width: "100%", height: "100%" }}
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onNodeDragStop={(_, node, dragged) => {
const moved = dragged && dragged.length ? dragged : [node];
const next = { ...(savedPos ?? {}) };
moved.forEach((n) => { if (n) next[n.id] = { x: n.position.x, y: n.position.y }; });
setSavedPos(next);
persistPos(next);
}}
onNodeClick={(_, n) => {
const item = visible.find((v) => v.id === n.id);
if (!item) return;
// Agents: just select (opens the summary panel). Non-agents: select + expand.
onSelect(item.id);
if (item.level !== "claw" && (item.children?.length ?? 0) > 0) onToggleExpand(item.id);
}}
fitView
fitViewOptions={{ padding: 0.24 }}
proOptions={{ hideAttribution: true }}
nodesConnectable={false}
elementsSelectable={false}
minZoom={0.3}
maxZoom={1.6}
colorMode="dark"
>
<Background color="#1c1c22" gap={26} />
<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" }} />
</ReactFlow>
</div>
);
}
@@ -0,0 +1,133 @@
// Per-pattern node positioning + edges for the React Flow topology canvas.
// One positioner per layout family (adapted from agentorg's AutoLayoutManager),
// so switching topology reshuffles the same nodes into a new arrangement.
import type { TopologyPattern } from "@/lib/topologies";
export interface Pt {
x: number;
y: number;
}
const W = 820;
const H = 480;
const CX = W / 2;
const CY = H / 2;
function ringPts(n: number, r: number, cx = CX, cy = CY, off = -Math.PI / 2): Pt[] {
return Array.from({ length: n }, (_, i) => {
const a = (i / n) * Math.PI * 2 + off;
return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) };
});
}
const radius = (n: number, base: number, step: number, max: number) => Math.min(max, base + n * step);
export function layoutFor(pattern: TopologyPattern, n: number): Pt[] {
if (n <= 0) return [];
if (n === 1) return [{ x: CX, y: CY }];
switch (pattern) {
case "chain":
return Array.from({ length: n }, (_, i) => ({ x: 70 + (i * (W - 140)) / (n - 1), y: CY }));
case "ring":
return ringPts(n, radius(n, 70, 14, 200));
case "mesh":
return ringPts(n, radius(n, 80, 12, 210));
case "hub":
case "radial":
return [{ x: CX, y: CY }, ...ringPts(n - 1, radius(n, 80, 12, 190))];
case "tree": {
// root at top, the rest fanned across one or two rows below
const rest = n - 1;
const perRow = Math.min(rest, Math.ceil(Math.sqrt(rest) * 1.6) || 1);
const pts: Pt[] = [{ x: CX, y: 70 }];
for (let i = 0; i < rest; i++) {
const rowi = Math.floor(i / perRow);
const col = i % perRow;
const inRow = Math.min(perRow, rest - rowi * perRow);
pts.push({ x: inRow === 1 ? CX : 110 + (col * (W - 220)) / (inRow - 1), y: 200 + rowi * 150 });
}
return pts;
}
case "grid": {
const cols = Math.ceil(Math.sqrt(n));
const rows = Math.ceil(n / cols);
const gx = (W - 160) / Math.max(cols - 1, 1);
const gy = (H - 160) / Math.max(rows - 1, 1);
return Array.from({ length: n }, (_, i) => ({
x: cols === 1 ? CX : 80 + (i % cols) * gx,
y: rows === 1 ? CY : 80 + Math.floor(i / cols) * gy,
}));
}
case "cluster": {
const groups = Math.min(3, Math.max(2, Math.round(n / 3)));
const centers = ringPts(groups, 150);
return Array.from({ length: n }, (_, i) => {
const g = i % groups;
const within = Math.floor(i / groups);
const a = within * 1.7;
return { x: centers[g].x + 46 * Math.cos(a), y: centers[g].y + 46 * Math.sin(a) };
});
}
case "columns": {
const half = Math.ceil(n / 2);
return Array.from({ length: n }, (_, i) => {
const left = i < half;
const idx = left ? i : i - half;
const count = left ? half : n - half;
return { x: left ? CX - 170 : CX + 170, y: count === 1 ? CY : 90 + (idx * (H - 180)) / (count - 1) };
});
}
case "swarm":
default:
return Array.from({ length: n }, (_, i) => {
const a = (i / n) * Math.PI * 2;
const r = 70 + (i % 3) * 38;
return { x: CX + r * Math.cos(a), y: CY + r * Math.sin(a) };
});
}
}
export function edgesFor(pattern: TopologyPattern, n: number): [number, number][] {
const e: [number, number][] = [];
const push = (a: number, b: number) => { if (a !== b && a < n && b < n) e.push([a, b]); };
switch (pattern) {
case "tree":
case "hub":
case "radial":
for (let i = 1; i < n; i++) push(0, i);
break;
case "chain":
for (let i = 0; i < n - 1; i++) push(i, i + 1);
break;
case "ring":
for (let i = 0; i < n; i++) push(i, (i + 1) % n);
break;
case "mesh":
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) push(i, j);
break;
case "grid": {
const cols = Math.ceil(Math.sqrt(n));
for (let i = 0; i < n; i++) {
if ((i + 1) % cols !== 0) push(i, i + 1);
if (i + cols < n) push(i, i + cols);
}
break;
}
case "cluster": {
const groups = Math.min(3, Math.max(2, Math.round(n / 3)));
for (let i = 0; i < n; i++) push(i, (i + groups) % n);
for (let g = 0; g < groups; g++) push(g, (g + 1) % groups);
break;
}
case "columns": {
const half = Math.ceil(n / 2);
for (let i = 0; i < half; i++) for (let j = half; j < n; j++) if ((i + j) % 2 === 0) push(i, j);
break;
}
case "swarm":
default:
for (let i = 1; i < n; i++) push(0, i);
for (let i = 1; i < n; i++) push(i, (i % (n - 1)) + 1);
}
return e;
}
@@ -0,0 +1,73 @@
"use client";
// Client metric fetchers for the dashboard's team + per-agent panels. All hit
// the same-origin /api proxy (the lib helpers in src/lib/api/* are server-only)
// and degrade to null/empty so panels never crash when data is absent.
import { useEffect, useState } from "react";
export interface LeaderboardRow { id: string; name: string; accent: string; credits: number; tokens: number; runs: number; }
export interface RoutineRun { id: string; routine_id?: string; routine_name: string; status: "ok" | "error" | "running"; started_at?: string; completed_at?: string; error?: string | null; }
export interface ClawThread { id: string; subject?: string; participants?: string[]; last_preview?: string; created_at?: string; }
export interface SessionLite { id: string; created_at: string; last_active_at: string; }
export function useJson<T>(url: string | null): { data: T | null; loading: boolean } {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(!!url);
// Reset when the url changes (render-phase, not in the effect — avoids the
// cascading-render lint and keeps stale data from flashing).
const [seenUrl, setSeenUrl] = useState(url);
if (seenUrl !== url) { setSeenUrl(url); setData(null); setLoading(!!url); }
useEffect(() => {
if (!url) return;
let alive = true;
fetch(url, { cache: "no-store" })
.then((r) => (r.ok ? (r.json() as Promise<T>) : null))
.then((d) => { if (alive) { setData(d); setLoading(false); } })
.catch(() => { if (alive) { setData(null); setLoading(false); } });
return () => { alive = false; };
}, [url]);
return { data, loading };
}
/** Distinct inter-agent threads across a set of claws (deduped by thread id). */
export function useThreads(clawIds: string[]): ClawThread[] {
const key = clawIds.join(",");
const [threads, setThreads] = useState<ClawThread[]>([]);
const [seenKey, setSeenKey] = useState(key);
if (seenKey !== key) { setSeenKey(key); setThreads([]); }
useEffect(() => {
const ids = key ? key.split(",") : [];
if (ids.length === 0) return;
let alive = true;
Promise.all(
ids.map((id) =>
fetch(`/api/claw-chat/threads?clawId=${encodeURIComponent(id)}`, { cache: "no-store" })
.then((r) => (r.ok ? (r.json() as Promise<ClawThread[]>) : []))
.catch(() => [] as ClawThread[]),
),
).then((lists) => {
if (!alive) return;
const seen = new Map<string, ClawThread>();
lists.flat().forEach((t) => { if (t && t.id) seen.set(t.id, t); });
setThreads([...seen.values()]);
});
return () => { alive = false; };
}, [key]);
return threads;
}
export const fmtNum = (n: number): string =>
n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(Math.round(n));
export function ago(iso?: string | null): string {
if (!iso) return "";
const ms = Date.now() - new Date(iso).getTime();
if (Number.isNaN(ms)) return "";
const m = Math.floor(ms / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
+2 -2
View File
@@ -9,7 +9,7 @@ import { Avatar } from "@/components/ui/Avatar";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { SegmentedTabs } from "@/components/ui/SegmentedTabs"; import { SegmentedTabs } from "@/components/ui/SegmentedTabs";
const TABS = ["Members", "Claw org chart", "Leaderboard"] as const; const TABS = ["Members", "Agent org chart", "Leaderboard"] as const;
export function TeamTabs({ export function TeamTabs({
members, members,
@@ -30,7 +30,7 @@ export function TeamTabs({
label="Team views" label="Team views"
/> />
{tab === "Members" && <MembersTable members={members} />} {tab === "Members" && <MembersTable members={members} />}
{tab === "Claw org chart" && <OrgChart nodes={orgchart} />} {tab === "Agent org chart" && <OrgChart nodes={orgchart} />}
{tab === "Leaderboard" && <Leaderboard rows={leaderboard} />} {tab === "Leaderboard" && <Leaderboard rows={leaderboard} />}
</div> </div>
); );
+2 -2
View File
@@ -5,8 +5,8 @@ import { useState } from "react";
const FAQS: { q: string; a: string }[] = [ const FAQS: { q: string; a: string }[] = [
{ {
q: "What is a claw?", q: "What is an agent?",
a: "A claw is an AI coworker — an agent with a job, a chat, and its own sandboxed computer. Your team talks to it like a teammate, and it uses tools to get work done.", a: "An agent is an AI coworker — with a job, a chat, and its own sandboxed computer. Your team talks to it like a teammate, and it uses tools to get work done.",
}, },
{ {
q: "How do you keep agents safe?", q: "How do you keep agents safe?",
@@ -76,9 +76,9 @@ export function HierarchyDiagram() {
// node top-centers // node top-centers
const orch = { x: 318, y: 16 }; const orch = { x: 318, y: 16 };
const tier1 = [ const tier1 = [
{ x: 38, y: 168, title: "Marketing Claw", sub: "mid-level agent" }, { x: 38, y: 168, title: "Marketing Agent", sub: "mid-level agent" },
{ x: 318, y: 168, title: "Engineering Claw", sub: "mid-level agent", active: true }, { x: 318, y: 168, title: "Engineering Agent", sub: "mid-level agent", active: true },
{ x: 598, y: 168, title: "Operations Claw", sub: "mid-level agent" }, { x: 598, y: 168, title: "Operations Agent", sub: "mid-level agent" },
]; ];
const tier2 = [ const tier2 = [
{ x: 38, y: 326, title: "Send email", sub: "leaves sandbox", gated: true }, { x: 38, y: 326, title: "Send email", sub: "leaves sandbox", gated: true },

Some files were not shown because too many files have changed in this diff Show More