1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
use std::path::Path;
use anyhow::{bail, Result};
use team_core::supervisor::{AgentSpec, Supervisor, TmuxSupervisor};
use super::agent_filter::AgentSelector;
pub fn run(root: &Path, project: Option<&str>, sel: &AgentSelector) -> Result<()> {
let compose = super::load(root)?;
// T-310: gate on validation before the supervisor builds any
// shell-bound command. `up` and `reload` already do this; `down`
// didn't, leaving `build_up_command`'s `{project}:{agent}`
// interpolation reachable from a shell-metacharacter id via the
// `down` path. Mirror the existing up/reload shape so a malicious
// compose can't slip past on this command either.
let errs = team_core::validate::validate(&compose);
if !errs.is_empty() {
for e in &errs {
eprintln!("error: {e}");
}
bail!("{} validation error(s) — fix before down", errs.len());
}
let scoped = project
.map(|name| super::project_filter::resolve(&compose, name))
.transpose()?;
// Per-agent target set (T-305). `None` => no agent-level filter
// (the no-arg / `<project>`-only contracts, untouched). The
// selector is only ever scoped alongside a project (clap enforces
// `requires = "project"`), so `resolve` is only reached when
// `scoped` is `Some`.
let targets = match scoped.as_deref() {
Some(id) => super::agent_filter::resolve(&compose, id, sel)?,
None => None,
};
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);
sup.down(&spec)?;
println!("down · {}", h.id());
touched += 1;
}
for spec in super::bot::bot_specs(&compose) {
// Project guard preserved verbatim from the pre-T-305 path.
let split = spec.manager.split_once(':');
if scoped
.as_deref()
.is_some_and(|id| split.map(|(p, _)| p) != Some(id))
{
continue;
}
// A bot's lifecycle follows its manager agent: in a per-agent
// scope, skip the bot unless its manager is in the target set.
// `targets` is `Some` only when `scoped` is `Some`, so the
// guard above has already pinned `split` to the in-scope
// project's pair here.
if let Some(t) = &targets {
if !t.contains(split.map(|(_, a)| a).unwrap_or("")) {
continue;
}
}
super::bot::down_one(&spec);
println!("down · bot {}", spec.session);
touched += 1;
}
if let (Some(id), 0) = (scoped.as_deref(), touched) {
println!("no agents in scope for project {id}.");
}
// T-370: release the host-level keep-awake once the last teamctl team on
// this host is down (host-wide refcount via the `@teamctl` tmux tagging).
// macOS-only, no-op elsewhere; a stale/dead pid is reaped without error.
super::caffeinate::stop_if_last();
Ok(())
}