use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use team_core::compose::Compose;
use team_core::render::{
boot_script_path, claude_settings_path, env_path, mcp_path, render_agent,
render_claude_settings, write_agent_skills, write_role_prompt_concat, write_subagents_json,
};
use team_core::supervisor::{AgentSpec, AgentState, Supervisor, TmuxSupervisor};
use super::agent_filter::AgentSelector;
pub fn run(root: &Path, project: Option<&str>, sel: &AgentSelector, fresh: bool) -> Result<()> {
let compose = super::load(root)?;
super::update_check::maybe_print_banner(&compose.root);
let errs = team_core::validate::validate(&compose);
if !errs.is_empty() {
for e in &errs {
eprintln!("error: {e}");
}
bail!("{} validation error(s) — fix before up", errs.len());
}
let scoped = project
.map(|name| super::project_filter::resolve(&compose, name))
.transpose()?;
let targets = match scoped.as_deref() {
Some(id) => super::agent_filter::resolve(&compose, id, sel)?,
None => None,
};
guard_no_name_collision(&compose, scoped.as_deref())?;
if scoped.is_none() {
ensure_wrapper_and_dirs(&compose)?;
render_all_public(&compose)?;
register_all_public(&compose)?;
ensure_claude_trust(&compose)?;
} else {
render_project_public(&compose, scoped.as_deref().unwrap())?;
ensure_claude_trust_for_project(&compose, scoped.as_deref().unwrap())?;
}
let mut touched = 0usize;
let sup = TmuxSupervisor;
for h in compose.agents() {
if scoped.as_deref().is_some_and(|id| id != h.project) {
continue;
}
if targets.as_ref().is_some_and(|t| !t.contains(h.agent)) {
continue;
}
let spec = AgentSpec::from_handle(h, &compose.root, &compose.global.supervisor.tmux_prefix);
let running = matches!(sup.state(&spec)?, AgentState::Running);
if targets.is_some() && running {
println!("up · {} (already running)", h.id());
touched += 1;
continue;
}
if !running {
freshen_for_spec(&spec, &h.spec.runtime, fresh);
}
sup.up(&spec)?;
println!("up · {}{}", h.id(), fresh_suffix(fresh && !running));
touched += 1;
}
let team_bot = super::bot::team_bot_bin();
source_dotenv_into_process(&compose.root);
for spec in super::bot::bot_specs(&compose) {
let split = spec.manager.split_once(':');
if scoped
.as_deref()
.is_some_and(|id| split.map(|(p, _)| p) != Some(id))
{
continue;
}
if let Some(t) = &targets {
if !t.contains(split.map(|(_, a)| a).unwrap_or("")) {
continue;
}
}
match super::bot::up_one(&spec, &team_bot, &compose.root) {
Ok(true) => {
println!("up · bot {} → {}", spec.session, spec.manager);
touched += 1;
}
Ok(false) => {}
Err(e) => eprintln!("warn · bot {}: {e:#}", spec.session),
}
}
if let (Some(id), 0) = (scoped.as_deref(), touched) {
println!("no agents in scope for project {id}.");
}
let bin = super::team_mcp_bin().display().to_string();
let next = super::snapshot::compute(&compose, &bin);
let snap = match scoped.as_deref() {
Some(id) => {
let prev = super::snapshot::read(&compose.root);
super::snapshot::merge_project_into(prev.as_ref(), &next, id)
}
None => next,
};
super::snapshot::write(&compose.root, &snap)?;
record_in_registry(&compose, scoped.as_deref());
if touched > 0 {
super::caffeinate::ensure_running();
}
Ok(())
}
fn registry_entries<'a>(
agents: impl Iterator<Item = (&'a str, &'a str)>,
root: &Path,
tmux_prefix: &str,
scoped: Option<&str>,
started_at: &str,
) -> Vec<team_core::registry::TeamEntry> {
let mut by_project: BTreeMap<&str, Vec<String>> = BTreeMap::new();
for (project, agent) in agents {
if scoped.is_some_and(|id| id != project) {
continue;
}
by_project
.entry(project)
.or_default()
.push(agent.to_string());
}
by_project
.into_iter()
.map(|(project, mut names)| {
names.sort();
team_core::registry::TeamEntry {
project_id: project.to_string(),
root: root.to_path_buf(),
tmux_prefix: tmux_prefix.to_string(),
agents: names,
started_at: started_at.to_string(),
}
})
.collect()
}
pub(super) fn guard_no_name_collision(compose: &Compose, scoped: Option<&str>) -> Result<()> {
let Some(dir) = team_core::registry::config_dir() else {
return Ok(());
};
let reg = match team_core::registry::load(&dir) {
Ok(r) => r,
Err(_) => return Ok(()), };
guard_no_name_collision_in(®, compose, scoped, &|p| p.exists())
}
fn guard_no_name_collision_in(
reg: &team_core::registry::Registry,
compose: &Compose,
scoped: Option<&str>,
path_exists: &impl Fn(&Path) -> bool,
) -> Result<()> {
let mut seen = BTreeSet::new();
for h in compose.agents() {
if scoped.is_some_and(|s| s != h.project) || !seen.insert(h.project) {
continue;
}
if let Some(other) =
team_core::registry::same_name_other_root(reg, h.project, &compose.root, path_exists)
{
bail!(
"project `{}` is already up at {} — two teams with the same project id in \
different folders alias each other (they share session ids, tmux names, and \
mailbox keys) and can crash-loop. Rename the project here, or `teamctl down` \
the other team first.",
h.project,
other.display()
);
}
}
Ok(())
}
pub(super) fn record_in_registry(compose: &Compose, scoped: Option<&str>) {
let Some(dir) = team_core::registry::config_dir() else {
eprintln!("warn · teams registry: neither HOME nor USERPROFILE set, skipping");
return;
};
record_in_registry_in(&dir, compose, scoped);
}
fn record_in_registry_in(dir: &Path, compose: &Compose, scoped: Option<&str>) {
let started_at = team_core::registry::now_rfc3339();
let entries = registry_entries(
compose.agents().map(|h| (h.project, h.agent)),
&compose.root,
&compose.global.supervisor.tmux_prefix,
scoped,
&started_at,
);
if let Err(e) = team_core::registry::upsert_many(dir, entries) {
eprintln!("warn · teams registry: {e:#}");
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FreshenAction {
Skip,
UnsupportedRuntime,
Freshen,
}
pub(crate) fn freshen_action(runtime: &str, fresh: bool) -> FreshenAction {
if !fresh {
FreshenAction::Skip
} else if runtime == "claude-code" {
FreshenAction::Freshen
} else {
FreshenAction::UnsupportedRuntime
}
}
pub(crate) fn freshen_for_spec(spec: &AgentSpec, runtime: &str, fresh: bool) {
let id = format!("{}:{}", spec.project, spec.agent);
match freshen_action(runtime, fresh) {
FreshenAction::Skip => {}
FreshenAction::UnsupportedRuntime => {
eprintln!("warn · {id} (--fresh skipped: {runtime} runtime has no session resume yet)");
}
FreshenAction::Freshen => {
let Some(home) = team_core::session::claude_home() else {
eprintln!("warn · {id} (--fresh skipped: $HOME unset)");
return;
};
if let Err(e) = team_core::session::freshen_session(&home, &spec.project, &spec.agent) {
eprintln!("warn · {id} (--fresh: could not move session aside: {e})");
}
}
}
}
pub(crate) fn fresh_suffix(fresh: bool) -> &'static str {
if fresh {
" (fresh)"
} else {
""
}
}
pub fn render_project_public(compose: &Compose, project_id: &str) -> Result<()> {
let envs_dir = compose.root.join("state/envs");
let mcp_dir = compose.root.join("state/mcp");
let claude_dir = compose.root.join("state/claude");
fs::create_dir_all(&envs_dir)?;
fs::create_dir_all(&mcp_dir)?;
fs::create_dir_all(&claude_dir)?;
fs::create_dir_all(compose.root.join("state/heartbeats"))?;
let bin = super::team_mcp_bin().display().to_string();
for h in compose.agents().filter(|h| h.project == project_id) {
let (env, mcp) = render_agent(compose, h, &bin);
fs::write(env_path(&compose.root, h.project, h.agent), env)?;
fs::write(mcp_path(&compose.root, h.project, h.agent), mcp)?;
if let Some(settings) = render_claude_settings(compose, h) {
fs::write(
claude_settings_path(&compose.root, h.project, h.agent),
settings,
)?;
}
write_role_prompt_concat(compose, h)
.with_context(|| format!("write role_prompt concat for {}:{}", h.project, h.agent))?;
write_subagents_json(compose, h)
.with_context(|| format!("write sub-agents json for {}:{}", h.project, h.agent))?;
write_agent_skills(compose, h)
.with_context(|| format!("write agent skills for {}:{}", h.project, h.agent))?;
}
Ok(())
}
fn ensure_claude_trust(compose: &Compose) -> Result<()> {
ensure_claude_trust_inner(compose, None)
}
fn ensure_claude_trust_for_project(compose: &Compose, project_id: &str) -> Result<()> {
ensure_claude_trust_inner(compose, Some(project_id))
}
fn ensure_claude_trust_inner(compose: &Compose, project_id: Option<&str>) -> Result<()> {
let cwds: BTreeSet<PathBuf> = compose
.agents()
.filter(|h| project_id.is_none_or(|id| h.project == id))
.filter(|h| h.spec.runtime == "claude-code")
.filter_map(|h| {
let project = compose
.projects
.iter()
.find(|p| p.project.id == h.project)?;
let cwd = if project.project.cwd.is_absolute() {
project.project.cwd.clone()
} else {
compose.root.join(&project.project.cwd)
};
cwd.canonicalize().ok().or(Some(cwd))
})
.collect();
if cwds.is_empty() {
return Ok(());
}
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
return Ok(());
};
let config_path = home.join(".claude.json");
let mut config: serde_json::Value = match fs::read_to_string(&config_path) {
Ok(s) => serde_json::from_str(&s).unwrap_or_else(|_| serde_json::json!({})),
Err(_) => serde_json::json!({}),
};
if !config
.get("projects")
.map(|v| v.is_object())
.unwrap_or(false)
{
config["projects"] = serde_json::json!({});
}
let projects = config["projects"].as_object_mut().unwrap();
let mut newly_trusted = Vec::new();
for cwd in &cwds {
let key = cwd.display().to_string();
let entry = projects
.entry(key.clone())
.or_insert_with(|| serde_json::json!({}));
if !entry.is_object() {
*entry = serde_json::json!({});
}
let obj = entry.as_object_mut().unwrap();
let already = matches!(
obj.get("hasTrustDialogAccepted"),
Some(serde_json::Value::Bool(true))
);
if !already {
obj.insert(
"hasTrustDialogAccepted".into(),
serde_json::Value::Bool(true),
);
newly_trusted.push(key);
}
}
if newly_trusted.is_empty() {
return Ok(());
}
let tmp = config_path.with_extension("json.teamctl.tmp");
fs::write(&tmp, serde_json::to_string_pretty(&config)?)?;
fs::rename(&tmp, &config_path)?;
let n = newly_trusted.len();
let folder_word = if n == 1 { "folder" } else { "folders" };
let delete_phrase = if n == 1 { "that key" } else { "those keys" };
eprintln!();
eprintln!("trust · marked {n} {folder_word} trusted in your Claude Code config");
eprintln!(" so agents don't stall on Claude's \"trust this folder\" prompt:");
for path in &newly_trusted {
eprintln!(" • {path}");
}
eprintln!(
" config: {} (key: hasTrustDialogAccepted)",
config_path.display()
);
eprintln!(" running `teamctl up` granted this trust; delete {delete_phrase} to undo.");
eprintln!();
Ok(())
}
pub fn render_all_public(compose: &Compose) -> Result<()> {
let envs_dir = compose.root.join("state/envs");
let mcp_dir = compose.root.join("state/mcp");
let claude_dir = compose.root.join("state/claude");
fs::create_dir_all(&envs_dir)?;
fs::create_dir_all(&mcp_dir)?;
fs::create_dir_all(&claude_dir)?;
fs::create_dir_all(compose.root.join("state/heartbeats"))?;
let bin = super::team_mcp_bin().display().to_string();
for h in compose.agents() {
let (env, mcp) = render_agent(compose, h, &bin);
fs::write(env_path(&compose.root, h.project, h.agent), env)?;
fs::write(mcp_path(&compose.root, h.project, h.agent), mcp)?;
if let Some(settings) = render_claude_settings(compose, h) {
fs::write(
claude_settings_path(&compose.root, h.project, h.agent),
settings,
)?;
}
write_role_prompt_concat(compose, h)
.with_context(|| format!("write role_prompt concat for {}:{}", h.project, h.agent))?;
write_subagents_json(compose, h)
.with_context(|| format!("write sub-agents json for {}:{}", h.project, h.agent))?;
write_agent_skills(compose, h)
.with_context(|| format!("write agent skills for {}:{}", h.project, h.agent))?;
}
Ok(())
}
pub fn register_all_public(compose: &Compose) -> Result<()> {
use rusqlite::{params, Connection};
let db = compose.root.join(&compose.global.broker.path);
if let Some(parent) = db.parent() {
fs::create_dir_all(parent)?;
}
let conn = Connection::open(&db)?;
conn.busy_timeout(std::time::Duration::from_secs(5))?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
team_core::mailbox::ensure(&conn)?;
for p in &compose.projects {
conn.execute(
"INSERT OR IGNORE INTO projects (id, name) VALUES (?1, ?2)",
params![p.project.id, p.project.name],
)?;
}
for h in compose.agents() {
conn.execute(
"INSERT INTO agents (id, project_id, role, runtime, is_manager, reports_to) VALUES (?1,?2,?3,?4,?5,?6)
ON CONFLICT(id) DO UPDATE SET role=excluded.role, runtime=excluded.runtime, is_manager=excluded.is_manager, reports_to=excluded.reports_to",
params![
h.id(),
h.project,
h.agent,
h.spec.runtime,
if h.is_manager { 1 } else { 0 },
h.spec.reports_to.as_deref(),
],
)?;
let can_dm = serde_json::to_string(&h.spec.can_dm)?;
let can_bc = serde_json::to_string(&h.spec.can_broadcast)?;
conn.execute(
"INSERT INTO agent_acls (agent_id, can_dm_json, can_bcast_json)
VALUES (?1,?2,?3)
ON CONFLICT(agent_id) DO UPDATE SET can_dm_json=excluded.can_dm_json, can_bcast_json=excluded.can_bcast_json",
params![h.id(), can_dm, can_bc],
)?;
}
for p in &compose.projects {
for ch in &p.channels {
let cid = format!("{}:{}", p.project.id, ch.name);
let wildcard = matches!(
ch.members,
team_core::compose::ChannelMembers::All(ref s) if s == "*"
);
conn.execute(
"INSERT INTO channels (id, project_id, name, wildcard) VALUES (?1,?2,?3,?4)
ON CONFLICT(id) DO UPDATE SET wildcard=excluded.wildcard",
params![cid, p.project.id, ch.name, if wildcard { 1 } else { 0 }],
)?;
conn.execute(
"DELETE FROM channel_members WHERE channel_id = ?1",
params![cid],
)?;
match &ch.members {
team_core::compose::ChannelMembers::All(_) => {
let agents: Vec<String> = p
.managers
.keys()
.chain(p.workers.keys())
.map(|a| format!("{}:{}", p.project.id, a))
.collect();
for aid in agents {
conn.execute(
"INSERT OR IGNORE INTO channel_members (channel_id, agent_id) VALUES (?1,?2)",
params![cid, aid],
)?;
}
}
team_core::compose::ChannelMembers::Explicit(members) => {
for m in members {
let aid = format!("{}:{}", p.project.id, m);
conn.execute(
"INSERT OR IGNORE INTO channel_members (channel_id, agent_id) VALUES (?1,?2)",
params![cid, aid],
)?;
}
}
}
}
}
Ok(())
}
pub fn ensure_wrapper_and_dirs(compose: &Compose) -> Result<()> {
write_managed_executable(&super::agent_wrapper(&compose.root), DEFAULT_WRAPPER)?;
write_managed_executable(&boot_script_path(&compose.root), DEFAULT_BOOT_SCRIPT)?;
fs::create_dir_all(compose.root.join("state/envs"))?;
fs::create_dir_all(compose.root.join("state/mcp"))?;
Ok(())
}
fn write_managed_executable(path: &Path, content: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let needs_write = match fs::read_to_string(path) {
Ok(existing) => existing != content,
Err(_) => true,
};
if needs_write {
fs::write(path, content)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)?;
}
Ok(())
}
const DEFAULT_WRAPPER: &str = include_str!("../../assets/agent-wrapper.sh");
const DEFAULT_BOOT_SCRIPT: &str = include_str!("../../assets/boot.sh");
fn source_dotenv_into_process(root: &std::path::Path) {
for f in [
root.join(".env"),
root.parent().unwrap_or(root).join(".env"),
] {
if !f.is_file() {
continue;
}
let Ok(raw) = fs::read_to_string(&f) else {
continue;
};
for line in raw.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line);
if let Some((k, v)) = line.split_once('=') {
let v = v.trim().trim_matches('"').trim_matches('\'');
if std::env::var_os(k).is_none() {
unsafe { std::env::set_var(k, v) };
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::DEFAULT_BOOT_SCRIPT;
use super::DEFAULT_WRAPPER;
use super::*;
use std::collections::BTreeMap;
use team_core::compose::*;
use team_core::render::role_prompt_concat_path;
#[test]
fn registry_entries_groups_by_project_and_sorts_agents() {
let root = Path::new("/r/a/.team");
let agents = vec![("main", "scout"), ("main", "compass"), ("ops", "otto")];
let entries =
registry_entries(agents.into_iter(), root, "t-", None, "2026-06-13T00:00:00Z");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].project_id, "main");
assert_eq!(entries[0].agents, vec!["compass", "scout"]);
assert_eq!(entries[0].root, root);
assert_eq!(entries[0].tmux_prefix, "t-");
assert_eq!(entries[0].started_at, "2026-06-13T00:00:00Z");
assert_eq!(entries[1].project_id, "ops");
assert_eq!(entries[1].agents, vec!["otto"]);
}
#[test]
fn registry_entries_scoped_records_only_that_project() {
let agents = vec![("main", "compass"), ("ops", "otto")];
let entries = registry_entries(
agents.into_iter(),
Path::new("/r/a/.team"),
"t-",
Some("ops"),
"T0",
);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].project_id, "ops");
assert_eq!(entries[0].agents, vec!["otto"]);
}
#[test]
fn up_guards_against_same_name_in_a_different_folder() {
use team_core::registry::{Registry, TeamEntry};
let team = tempfile::tempdir().unwrap();
fs::create_dir_all(team.path().join("projects")).unwrap();
fs::write(
team.path().join("team-compose.yaml"),
"version: 2\nsupervisor:\n type: tmux\n tmux_prefix: t-\nprojects:\n - file: projects/main.yaml\n",
)
.unwrap();
fs::write(
team.path().join("projects/main.yaml"),
"version: 2\nproject:\n id: main\n name: Main\n cwd: ./workspace\nmanagers:\n lead:\n runtime: claude-code\n role_prompt: roles/lead.md\n",
)
.unwrap();
let compose = Compose::load(team.path()).unwrap();
let make_reg = |root: PathBuf| {
let mut reg = Registry::default();
reg.teams.push(TeamEntry {
project_id: "main".into(),
root,
tmux_prefix: "t-".into(),
agents: vec!["lead".into()],
started_at: "T0".into(),
});
reg
};
let other = make_reg(PathBuf::from("/elsewhere/.team"));
let live = |p: &Path| p == Path::new("/elsewhere/.team/team-compose.yaml");
let err = guard_no_name_collision_in(&other, &compose, None, &live)
.expect_err("same name in a different live folder must be refused");
assert!(
err.to_string().contains("/elsewhere/.team"),
"error names the conflicting dir: {err}"
);
assert!(
err.to_string().contains("teamctl down"),
"error gives actionable resolution: {err}"
);
let same = make_reg(compose.root.clone());
assert!(guard_no_name_collision_in(&same, &compose, None, &live).is_ok());
}
#[test]
fn record_then_clear_roundtrips_a_real_compose() {
let team = tempfile::tempdir().unwrap();
fs::create_dir_all(team.path().join("projects")).unwrap();
fs::write(
team.path().join("team-compose.yaml"),
"version: 2\n\
supervisor:\n type: tmux\n tmux_prefix: treg-\n\
projects:\n - file: projects/demo.yaml\n",
)
.unwrap();
fs::write(
team.path().join("projects/demo.yaml"),
"version: 2\n\
project:\n id: demo\n name: Demo\n cwd: ./workspace\n\
managers:\n lead:\n runtime: claude-code\n role_prompt: roles/lead.md\n\
workers:\n helper:\n runtime: claude-code\n role_prompt: roles/helper.md\n reports_to: lead\n",
)
.unwrap();
let compose = Compose::load(team.path()).unwrap();
let cfg = tempfile::tempdir().unwrap();
record_in_registry_in(cfg.path(), &compose, None);
let reg = team_core::registry::load(cfg.path()).unwrap();
assert_eq!(reg.teams.len(), 1, "one row for the single project");
let t = ®.teams[0];
assert_eq!(t.project_id, "demo");
assert_eq!(t.root, compose.root, "row keyed on the compose root");
assert_eq!(t.tmux_prefix, compose.global.supervisor.tmux_prefix);
assert_eq!(t.agents, vec!["helper", "lead"], "roster folded + sorted");
assert!(!t.started_at.is_empty(), "started_at stamped");
team_core::registry::clear(cfg.path(), &compose.root, None).unwrap();
let after = team_core::registry::load(cfg.path()).unwrap();
assert!(
after.teams.is_empty(),
"whole-root clear empties the registry"
);
}
#[test]
fn freshen_action_gates_on_fresh_and_runtime() {
assert_eq!(freshen_action("claude-code", false), FreshenAction::Skip);
assert_eq!(freshen_action("codex", false), FreshenAction::Skip);
assert_eq!(freshen_action("claude-code", true), FreshenAction::Freshen);
assert_eq!(
freshen_action("codex", true),
FreshenAction::UnsupportedRuntime
);
assert_eq!(
freshen_action("gemini", true),
FreshenAction::UnsupportedRuntime
);
}
#[test]
fn wrapper_auto_confirm_patterns_present() {
for marker in [
"Loading development channels",
"Bypass Permissions mode",
"Stop and wait for limit to reset",
"Quick safety check:",
"MCP servers may execute code",
"auto_confirm_known_dialogs",
] {
assert!(
DEFAULT_WRAPPER.contains(marker),
"DEFAULT_WRAPPER missing marker: {marker}",
);
}
assert!(
DEFAULT_WRAPPER.contains("grep -q 'Quick safety check:'")
&& DEFAULT_WRAPPER.contains("grep -q 'trust this folder'"),
"watcher must require 'Quick safety check:' AND 'trust this folder' to co-occur",
);
assert!(
DEFAULT_WRAPPER.contains("grep -q 'MCP servers may execute code'")
&& DEFAULT_WRAPPER.contains("grep -q 'Enter to confirm · Esc'"),
"watcher must require 'MCP servers may execute code' AND the 'Enter to confirm · Esc' \
footer chrome to co-occur",
);
}
#[test]
fn wrapper_self_heals_resume_collision() {
assert!(
DEFAULT_WRAPPER.contains("--resume \"$CLAUDE_SESSION_ID\"")
&& DEFAULT_WRAPPER.contains("RESUMED=1"),
"wrapper must mark RESUMED=1 when launching with --resume",
);
assert!(
DEFAULT_WRAPPER.contains("[ \"${FORCE_FRESH_SESSION:-0}\" = 1 ]")
&& DEFAULT_WRAPPER.contains("--session-id \"$CLAUDE_SESSION_ID\""),
"wrapper must force a fresh --session-id launch when FORCE_FRESH_SESSION is set",
);
assert!(
DEFAULT_WRAPPER.contains("[ \"$ec\" -ne 0 ]")
&& DEFAULT_WRAPPER.contains("[ \"${RESUMED:-0}\" = 1 ]")
&& DEFAULT_WRAPPER.contains("FORCE_FRESH_SESSION=1"),
"wrapper must retry-fresh only after a resumed launch exits non-zero",
);
assert!(
DEFAULT_WRAPPER.contains(
" FORCE_FRESH_SESSION=1\n continue\n fi\n FORCE_FRESH_SESSION=0\n"
),
"the retry must set the one-shot and `continue` before the reset clears it",
);
assert!(
DEFAULT_WRAPPER.contains("RESUMED=0"),
"wrapper must reset RESUMED each iteration",
);
}
#[test]
fn wrapper_threads_subagents_via_agents_flag() {
assert!(
DEFAULT_WRAPPER.contains("--agents \"$(cat \"$CLAUDE_AGENTS_JSON\")\""),
"wrapper must pass --agents from CLAUDE_AGENTS_JSON",
);
}
#[test]
fn wrapper_threads_skills_via_add_dir_flag() {
assert!(
DEFAULT_WRAPPER.contains("--add-dir \"$CLAUDE_AGENT_SCOPE\""),
"wrapper must pass --add-dir from CLAUDE_AGENT_SCOPE",
);
}
#[test]
fn wrapper_defaults_headless_to_permission_mode_auto() {
assert!(
DEFAULT_WRAPPER.contains("--permission-mode \"${PERMISSION_MODE:-auto}\""),
"wrapper must default headless agents to --permission-mode auto",
);
assert!(
!DEFAULT_WRAPPER.contains("--dangerously-skip-permissions"),
"wrapper must not pass --dangerously-skip-permissions (#361)",
);
assert!(
DEFAULT_WRAPPER.contains("[ \"${PERMISSION_MODE:-}\" = \"attended\" ]"),
"wrapper must keep the set -u-safe attended opt-out branch",
);
}
#[test]
fn wrapper_session_id_resume_branch_present() {
for marker in [
"--session-id \"$CLAUDE_SESSION_ID\"",
"--resume \"$CLAUDE_SESSION_ID\"",
"$HOME/.claude/projects/",
"$CLAUDE_SESSION_ID.jsonl",
] {
assert!(
DEFAULT_WRAPPER.contains(marker),
"DEFAULT_WRAPPER missing marker: {marker}",
);
}
}
#[test]
fn wrapper_session_vars_have_set_u_defaults() {
for marker in [
": \"${CLAUDE_SESSION_ID:=}\"",
": \"${CLAUDE_SESSION_NAME:=}\"",
] {
assert!(
DEFAULT_WRAPPER.contains(marker),
"DEFAULT_WRAPPER missing marker: {marker}",
);
}
}
#[test]
fn wrapper_bootstrap_prompt_default_is_macos_safe() {
assert!(
!DEFAULT_WRAPPER.contains("${BOOTSTRAP_PROMPT:="),
"DEFAULT_WRAPPER still uses ${{VAR:=DEFAULT}} for \
BOOTSTRAP_PROMPT — that shape is bash-3.2-fatal on \
macOS when DEFAULT contains escape sequences. Keep \
the conditional-assignment form.",
);
for marker in [
"if [ -z \"${BOOTSTRAP_PROMPT:-}\" ]; then",
"BOOTSTRAP_PROMPT=\"Begin your shift as ${AGENT}.",
] {
assert!(
DEFAULT_WRAPPER.contains(marker),
"DEFAULT_WRAPPER missing marker: {marker}",
);
}
}
fn compose_with_multi_role_prompt(root: &Path, project_id: &str) -> Compose {
let mut managers = BTreeMap::new();
managers.insert(
"mgr".into(),
Agent {
runtime: "claude-code".into(),
model: None,
role_prompt: Some(RolePrompt::Multiple(vec![
PathBuf::from("roles/_base.md"),
PathBuf::from("roles/mgr.md"),
])),
permission_mode: None,
autonomy: "low_risk_only".into(),
can_dm: vec![],
can_broadcast: vec![],
reports_to: None,
on_rate_limit: None,
effort: None,
ultracode: false,
interfaces: None,
display_name: None,
hooks: vec![],
mcps: Default::default(),
subagents: vec![],
skills: vec![],
},
);
Compose {
root: root.to_path_buf(),
global: Global {
version: team_core::compose::SchemaVersion::new("2.0.0"),
broker: Default::default(),
supervisor: Default::default(),
budget: Default::default(),
hitl: Default::default(),
rate_limits: Default::default(),
interfaces: vec![],
projects: vec![],
attachments: Default::default(),
},
projects: vec![Project {
version: 2,
project: ProjectMeta {
id: project_id.into(),
name: project_id.into(),
cwd: root.to_path_buf(),
},
channels: vec![],
managers,
workers: Default::default(),
interfaces: None,
}],
}
}
#[test]
fn render_project_public_writes_role_prompt_concat() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("roles")).unwrap();
std::fs::create_dir_all(root.join("state")).unwrap();
std::fs::write(root.join("roles/_base.md"), "BASE").unwrap();
std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
let compose = compose_with_multi_role_prompt(root, "p");
render_project_public(&compose, "p").expect("render_project_public");
assert!(
root.join("state/heartbeats").is_dir(),
"render must create state/heartbeats/"
);
let concat = role_prompt_concat_path(root, "p", "mgr");
let got = std::fs::read_to_string(&concat).expect("concat file written");
assert_eq!(got, "BASE\n\n—\n\nMGR");
std::fs::write(root.join("roles/_base.md"), "BASE-v2").unwrap();
render_project_public(&compose, "p").expect("render_project_public re-run");
let got = std::fs::read_to_string(&concat).unwrap();
assert_eq!(got, "BASE-v2\n\n—\n\nMGR");
}
static HOME_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct HomeGuard {
_lock: std::sync::MutexGuard<'static, ()>,
prev: Option<std::ffi::OsString>,
}
impl HomeGuard {
fn set(home: &Path) -> Self {
let lock = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", home) };
Self { _lock: lock, prev }
}
}
impl Drop for HomeGuard {
fn drop(&mut self) {
match &self.prev {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
#[test]
fn ensure_claude_trust_writes_key_then_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join(".team");
std::fs::create_dir_all(root.join("projects")).unwrap();
std::fs::write(
root.join("team-compose.yaml"),
r#"
version: 2
broker:
type: sqlite
path: state/mailbox.db
supervisor:
type: tmux
tmux_prefix: a-
projects:
- file: projects/hello.yaml
"#,
)
.unwrap();
std::fs::write(
root.join("projects/hello.yaml"),
r#"
version: 2
project:
id: hello
name: Hello
cwd: .
managers:
manager:
runtime: claude-code
model: claude-opus-4-8
"#,
)
.unwrap();
let compose = Compose::load(&root).expect("compose loads");
let home = dir.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let config_path = home.join(".claude.json");
let _guard = HomeGuard::set(&home);
ensure_claude_trust(&compose).expect("first ensure_claude_trust");
let cfg: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(&config_path).expect("wrote .claude.json"),
)
.expect("config is valid json");
let key = root.canonicalize().unwrap().display().to_string();
assert_eq!(
cfg["projects"][&key]["hasTrustDialogAccepted"],
serde_json::Value::Bool(true),
"trust key must be written for the agent cwd; config: {cfg}",
);
let before = std::fs::read_to_string(&config_path).unwrap();
ensure_claude_trust(&compose).expect("second ensure_claude_trust");
let after = std::fs::read_to_string(&config_path).unwrap();
assert_eq!(before, after, "second run must not rewrite the config");
}
#[test]
fn boot_script_emits_session_start_context() {
assert!(
DEFAULT_BOOT_SCRIPT.contains(r#""hookEventName":"SessionStart""#),
"boot.sh must emit the required hookEventName or CC drops additionalContext"
);
assert!(
DEFAULT_BOOT_SCRIPT.contains("additionalContext"),
"boot.sh must emit additionalContext"
);
for verb in ["resumed", "cleared context", "compacted", "booted"] {
assert!(
DEFAULT_BOOT_SCRIPT.contains(verb),
"boot.sh missing wake-aware verb: {verb}"
);
}
assert!(DEFAULT_BOOT_SCRIPT.starts_with("#!/bin/sh"));
assert!(DEFAULT_BOOT_SCRIPT.contains("set -u"));
assert!(
DEFAULT_BOOT_SCRIPT.contains("${1:-}") && DEFAULT_BOOT_SCRIPT.contains("${2:-}"),
"boot.sh must guard its optional argv under set -u"
);
assert!(
DEFAULT_BOOT_SCRIPT.contains("You were down for"),
"boot.sh must carry the downtime sentence"
);
assert!(
DEFAULT_BOOT_SCRIPT.contains("Re-anchor before continuing"),
"boot.sh must carry the compact re-anchor copy"
);
}
#[test]
fn boot_script_reports_downtime_and_reanchor() {
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::{Duration, SystemTime};
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("boot.sh");
std::fs::write(&script, DEFAULT_BOOT_SCRIPT).unwrap();
let run = |source: &str, args: &[&std::path::Path]| -> String {
let mut cmd = Command::new("/bin/sh");
cmd.arg(&script);
for a in args {
cmd.arg(a);
}
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = cmd.spawn().unwrap();
let payload = format!("{{\"source\":\"{source}\"}}");
child
.stdin
.take()
.unwrap()
.write_all(payload.as_bytes())
.unwrap();
let out = child.wait_with_output().unwrap();
assert!(
out.status.success(),
"boot.sh exited non-zero for source={source}"
);
let stdout = String::from_utf8(out.stdout).unwrap();
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
v["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap()
.to_string()
};
let aged = |name: &str, secs_ago: u64| -> std::path::PathBuf {
let p = dir.path().join(name);
let f = std::fs::File::create(&p).unwrap();
f.set_modified(SystemTime::now() - Duration::from_secs(secs_ago))
.unwrap();
p
};
let missing = dir.path().join("does-not-exist");
let startup = run("startup", &[]);
assert!(startup.starts_with("You booted at "), "{startup}");
assert!(
!startup.contains("You were down"),
"no argv => no downtime: {startup}"
);
for (src, lead) in [
("resume", "You resumed at "),
("clear", "You cleared context at "),
] {
let out = run(src, &[]);
assert!(out.starts_with(lead), "{out}");
assert!(
!out.contains("You were down") && !out.contains("Re-anchor"),
"{src} must stay the base notice: {out}"
);
}
let compact = run("compact", &[]);
assert!(compact.starts_with("You compacted at "), "{compact}");
assert!(
compact.contains("Re-anchor before continuing: re-read your working files"),
"compact must carry the re-anchor copy: {compact}"
);
assert!(
run("startup", &[&aged("ls_2h", 7200), &missing])
.contains("You were down for about 2 hours (last active "),
"2h lastseen => 2 hours"
);
assert!(
run("startup", &[&aged("ls_30s", 30), &missing])
.contains("You were down for under a minute (last active "),
"30s lastseen => under a minute"
);
assert!(
run("startup", &[&aged("l_2h", 7200), &aged("m_10m", 600)])
.contains("You were down for about 10 minutes "),
"present marker (10m) beats lastseen (2h)"
);
assert!(
!run("startup", &[&missing, &missing]).contains("You were down"),
"both missing => omit"
);
assert!(
!run("resume", &[&aged("ls_for_resume", 7200), &missing]).contains("You were down"),
"resume must not report downtime"
);
}
#[test]
fn ensure_wrapper_and_dirs_writes_executable_boot_script() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join(".team");
std::fs::create_dir_all(root.join("projects")).unwrap();
std::fs::write(
root.join("team-compose.yaml"),
r#"
version: 2
broker:
type: sqlite
path: state/mailbox.db
supervisor:
type: tmux
tmux_prefix: a-
projects:
- file: projects/hello.yaml
"#,
)
.unwrap();
std::fs::write(
root.join("projects/hello.yaml"),
r#"
version: 2
project:
id: hello
name: Hello
cwd: .
managers:
manager:
runtime: claude-code
model: claude-opus-4-8
"#,
)
.unwrap();
let compose = Compose::load(&root).expect("compose loads");
ensure_wrapper_and_dirs(&compose).expect("ensure_wrapper_and_dirs");
let boot = team_core::render::boot_script_path(&compose.root);
assert!(boot.is_file(), "bin/boot.sh must be written");
assert_eq!(
std::fs::read_to_string(&boot).unwrap(),
DEFAULT_BOOT_SCRIPT,
"on-disk boot.sh must match the embedded asset"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&boot).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o755, "boot.sh must be chmod 0o755");
}
}
}