use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::{PermissionsExt, MetadataExt as _};
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct ProjectConfig {
#[serde(default = "default_server")]
pub server: String,
pub output_dir: Option<String>,
pub shared_data_dir: Option<String>,
pub codebase_path: Option<String>,
#[serde(default)]
pub model: Option<String>,
pub cli_template: Option<String>,
pub cli_template_light: Option<String>,
pub workers: Vec<WorkerConfig>,
}
fn default_server() -> String {
"http://localhost:8000".to_string()
}
#[derive(Debug, Deserialize, Clone)]
pub struct WorkerConfig {
pub name: String,
pub role: String,
pub tasks: Option<String>,
pub avatar: Option<String>,
pub color: Option<u8>,
pub model: Option<String>,
pub cli_template: Option<String>,
pub cli_template_light: Option<String>,
#[serde(default)]
pub hands_off_to: Vec<String>,
}
impl ProjectConfig {
pub fn new(server: String, output_dir: Option<String>, codebase_path: Option<String>, model: Option<String>, workers: Vec<WorkerConfig>) -> Self {
Self { server, output_dir, shared_data_dir: None, codebase_path, model, cli_template: None, cli_template_light: None, workers }
}
}
pub fn run_from_yaml(yaml_path: &Path, output_dir_override: Option<&str>) -> Result<()> {
let contents = std::fs::read_to_string(yaml_path)
.map_err(|e| anyhow::anyhow!("Cannot read '{}': {}", yaml_path.display(), e))?;
let config: ProjectConfig = serde_yaml::from_str(&contents)
.map_err(|e| anyhow::anyhow!("Invalid YAML in '{}': {}", yaml_path.display(), e))?;
if config.workers.is_empty() {
anyhow::bail!("No workers defined in '{}'", yaml_path.display());
}
println!("Loaded {} worker(s) from {}", config.workers.len(), yaml_path.display());
generate(&config, output_dir_override)
}
pub fn generate(config: &ProjectConfig, output_dir_override: Option<&str>) -> Result<()> {
let base_str = output_dir_override
.map(|s| s.to_string())
.or_else(|| config.output_dir.clone())
.unwrap_or_else(|| ".".to_string());
let base = Path::new(&base_str);
println!("\nGenerating worker environments in '{}':\n", base.display());
for worker in &config.workers {
let dir = base.join(&worker.name);
std::fs::create_dir_all(&dir)?;
let worker_model = worker.model.as_ref()
.or(config.model.as_ref())
.cloned()
.unwrap_or_default();
let md = render_claude_md(worker, &config.workers, &config.server, &config.codebase_path, &worker_model, &config.shared_data_dir, &base_str);
let path = dir.join("AGENT.md");
std::fs::write(&path, md)?;
println!(" ✓ {}", path.display());
}
let project_root = Path::new(".");
write_worker_manifest(project_root, base, &base_str, config)?;
let mut entries = Vec::new();
for (i, worker) in config.workers.iter().enumerate() {
let color = worker.color.unwrap_or((i % 5) as u8);
let avatar = worker.avatar.as_deref().unwrap_or("neutral");
entries.push(format!(
" {}: {{\"avatar\": \"{}\", \"color\": {}}}",
serde_json::to_string(&worker.name).unwrap(),
avatar, color
));
}
let dashboard_cfg = format!("{{\n \"workers\": {{\n{}\n }}\n}}\n", entries.join(",\n"));
let cfg_path = base.join("dashboard-config.json");
std::fs::write(&cfg_path, dashboard_cfg)?;
println!(" ✓ {} (import into dashboard)", cfg_path.display());
println!("\n{} worker environment(s) created.", config.workers.len());
println!("\nNext steps:");
println!(" 1. Start the collab server: collab-server");
println!(" 2. Open each worker directory as a Claude Code project");
println!(" 3. Each worker's AGENT.md has full instructions");
println!(" 4. Import dashboard-config.json via the ⬆ button in collab-web/index.html");
Ok(())
}
fn render_claude_md(worker: &WorkerConfig, all: &[WorkerConfig], server: &str, codebase_path: &Option<String>, model: &str, shared_data_dir: &Option<String>, output_dir: &str) -> String {
let teammates: Vec<&WorkerConfig> = all.iter().filter(|w| w.name != worker.name).collect();
let team_table = if teammates.is_empty() {
"_(no other workers configured)_\n".to_string()
} else {
let rows: String = teammates
.iter()
.map(|w| format!("| `{}` | {} |\n", w.name, w.role))
.collect();
format!("| Instance | Role |\n|----------|------|\n{}", rows)
};
let other = teammates.first().map(|w| w.name.as_str()).unwrap_or("teammate");
let team_list = if teammates.is_empty() {
"_(solo)_".to_string()
} else {
teammates
.iter()
.map(|w| format!("`{}`", w.name))
.collect::<Vec<_>>()
.join(", ")
};
let tasks_section = match &worker.tasks {
Some(t) => {
format!("## Your Tasks\n\n{}\n\n", t.trim())
}
None => String::new(),
};
let data_root = shared_data_dir.as_deref().unwrap_or(output_dir);
let sibling_dirs: String = all.iter()
.filter(|w| w.name != worker.name)
.map(|w| format!(" {}/{}/", data_root, w.name))
.collect::<Vec<_>>()
.join("\n");
let data_section = format!(
"## Data\n\n\
**Check the filesystem before asking a teammate.** Large data lives on disk — \
messages are for coordination only (\"I finished X\", \"blocked on Y\").\n\n\
Your output directory: `{data_root}/{name}/`\n\n\
Sibling worker data:\n{siblings}\n\n\
If `shared_data_dir` is unreachable (e.g. network share down), fall back to \
reading from sibling directories under `{output_dir}/`.\n\n",
data_root = data_root,
name = worker.name,
siblings = if sibling_dirs.is_empty() { " _(no other workers)_".to_string() } else { sibling_dirs },
output_dir = output_dir,
);
let workdir_cmd = codebase_path
.as_ref()
.map(|p| format!("collab worker --workdir {} --model {}", p, model))
.unwrap_or_else(|| format!("collab worker --workdir <path-to-shared-codebase> --model {}", model));
format!(
r#"# {name} — Collab Worker
## Identity
You are **{name}**, a worker instance in a multi-worker collaboration.
**Your role:** {role}
**Your teammates:** {team_list}
## Setup (COPY-PASTE THIS AT SESSION START)
Before running any `collab` commands, set these three environment variables:
```bash
export COLLAB_INSTANCE={name}
export COLLAB_SERVER={server}
export COLLAB_TOKEN="<your-token-from-human>"
```
**Do this every session.** Add to your shell profile if you want to skip it later, but start with copy-paste so you learn the three required variables.
💡 **Where to get COLLAB_TOKEN:** Ask your team lead — it's generated when the server starts. Keep it secret.
## Team
{team_table}
## Session Start
Run these in order at the start of every session:
**1. Check for pending messages and tasks:**
```bash
collab status
collab todo list
```
Pending tasks assigned to you survive context resets — they stay in your queue until you explicitly mark them done.
**2. Run the event-driven worker:**
Start the headless worker to listen for messages and respond automatically. Run this **after** setting env vars (step 1):
```bash
{workdir_cmd}
```
This spawns your configured CLI tool on demand when messages arrive, batches rapid bursts, auto-replies to trivial messages, and maintains state across restarts. **IMPORTANT:** The worker needs:
- Your environment variables set (step 1) ✓
- Your CLI tool installed and in your PATH (configured via `cli_template` in workers.yaml)
- A working internet connection to collab server
If the worker fails silently, check `/tmp/collab-worker-errors.log` for diagnosis.
**3. Stream for the web dashboard (optional but recommended):**
```bash
collab stream --role "{role}"
```
Keeps your role visible in the roster and feeds the web dashboard.
**4. Stop condition:**
When a stop signal arrives via `collab list`, send a final summary and finish:
```bash
collab broadcast "Shutting down: <brief summary of work done>"
```
## Messaging
```bash
# Message a specific teammate
collab add @{other} "Ready to integrate — endpoint is live at /api/users"
# Broadcast to all active workers
collab broadcast "Starting schema migration — hold writes for 60s"
# Reply to the latest message from someone (auto-threads)
collab reply @{other} "Got it, will wait"
# Reply referencing a specific message hash
collab add @{other} "Fixed, commit a1b2c3d" --refs <hash>
```
{tasks_section}## Task Queue
Tasks assigned to you persist across sessions and context resets. Unlike messages, they don't expire.
```bash
collab todo list # your pending tasks (also shown in collab status)
collab todo done <hash> # mark complete when finished — do this before moving on
```
Teammates or @human assign tasks with:
```bash
collab todo add @{name} "description"
```
**Rule:** Always check `collab todo list` at session start. Mark tasks done *before* starting the next one. A task is not done until you run `collab todo done` — acknowledged ≠ complete.
**When assigning work to a teammate, always use `collab todo add` — not just a message.** Messages expire and get lost on context reset. Todos persist until marked done.
```bash
# Assign a task (use this instead of just messaging)
collab todo add @{other} "implement the /api/users endpoint"
# Then optionally send a message with context
collab add @{other} "Added a todo for you — see collab todo list for details"
```
{data_section}## Rules
Follow these without exception:
1. **Run `collab status` before starting any work.** Always.
2. **Announce blockers the moment they happen.** Don't wait silently — message the relevant teammate immediately.
3. **Never idle.** When blocked:
- Pick up another task, or
- Broadcast asking for direction:
```bash
collab broadcast "Blocked waiting on {other}. Available for other tasks."
```
4. **Stop cleanly when all tasks are done.** Broadcast a summary and exit:
```bash
collab broadcast "Tasks complete: <brief summary of what was done>"
```
Then stop. Do not loop or poll after finishing.
5. **Be specific in messages.** File paths, line numbers, commit hashes, exact errors — not vague descriptions.
6. **Finish one task before starting the next.**
7. **Do not reply unless you have new information.** Never confirm a confirmation, acknowledge an acknowledgment, or repeat what someone just said. If a teammate sends a status update or confirms something, do NOT reply unless you have something new to add. Silence is fine.
8. **Mask PII before sending any message.** Redact names, emails, phone numbers, addresses, IDs, and any other personal data. Use placeholders like `[NAME]`, `[EMAIL]`, `[PHONE]`, `[ADDRESS]`, `[ID]` in your messages and broadcasts.
"#,
name = worker.name,
role = worker.role,
server = server,
team_table = team_table,
team_list = team_list,
other = other,
tasks_section = tasks_section,
workdir_cmd = workdir_cmd,
data_section = data_section,
)
}
use crate::lifecycle::WorkerManifestEntry;
fn write_worker_manifest(project_root: &Path, output_dir: &Path, output_dir_str: &str, config: &ProjectConfig) -> Result<()> {
let collab_dir = project_root.join(".collab");
fs::create_dir_all(&collab_dir)?;
let mut manifest_entries = Vec::new();
for worker in &config.workers {
let worker_model = worker.model.as_ref()
.or(config.model.as_ref())
.cloned()
.unwrap_or_default();
let codebase_path = config.codebase_path.as_ref()
.map(|p| p.clone())
.unwrap_or_else(|| {
std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string())
});
let cli_tmpl = Some(worker.cli_template.clone()
.or_else(|| config.cli_template.clone())
.unwrap_or_else(|| "{agent} -p {prompt} --model {model}".to_string()));
let cli_tmpl_light = worker.cli_template_light.clone()
.or_else(|| config.cli_template_light.clone());
manifest_entries.push(WorkerManifestEntry {
name: worker.name.clone(),
role: worker.role.clone(),
codebase_path,
model: worker_model,
cli_template: cli_tmpl,
cli_template_light: cli_tmpl_light,
output_dir: {
let base_str = output_dir.to_string_lossy();
let clean = base_str.strip_prefix("./").unwrap_or(&base_str);
let rel = Path::new(clean).join(&worker.name);
std::env::current_dir()
.map(|cwd| cwd.join(&rel))
.unwrap_or(rel)
.to_string_lossy().to_string()
},
hands_off_to: worker.hands_off_to.clone(),
shared_data_dir: config.shared_data_dir.clone()
.or_else(|| Some(output_dir_str.to_string())),
});
}
let manifest_json = serde_json::to_string_pretty(&manifest_entries)?;
let manifest_path = collab_dir.join("workers.json");
fs::write(&manifest_path, manifest_json)?;
#[cfg(unix)]
{
let perms = std::os::unix::fs::PermissionsExt::from_mode(0o600);
fs::set_permissions(&manifest_path, perms)?;
}
println!(" ✓ {} (manifest for lifecycle commands)", manifest_path.display());
Ok(())
}