1use std::io;
15use std::path::{Path, PathBuf};
16
17use crate::compose::{AgentHandle, Compose, RolePrompt};
18
19const ROLE_PROMPT_SEPARATOR: &str = "\n\n—\n\n";
23
24pub fn env_path(root: &Path, project: &str, agent: &str) -> PathBuf {
26 root.join("state/envs")
27 .join(format!("{project}-{agent}.env"))
28}
29
30pub fn mcp_path(root: &Path, project: &str, agent: &str) -> PathBuf {
32 root.join("state/mcp")
33 .join(format!("{project}-{agent}.json"))
34}
35
36pub fn role_prompt_concat_path(root: &Path, project: &str, agent: &str) -> PathBuf {
40 root.join("state/role_prompts")
41 .join(format!("{project}-{agent}.md"))
42}
43
44pub fn render_agent(
46 compose: &Compose,
47 handle: AgentHandle<'_>,
48 team_mcp_bin: &str,
49) -> (String, String) {
50 let env = render_env(compose, handle);
51 let mcp = render_mcp(compose, handle, team_mcp_bin);
52 (env, mcp)
53}
54
55fn render_env(compose: &Compose, h: AgentHandle<'_>) -> String {
56 let project = compose
57 .projects
58 .iter()
59 .find(|p| p.project.id == h.project)
60 .expect("agent belongs to a loaded project");
61 let mailbox = compose.root.join(&compose.global.broker.path);
62 let mcp = mcp_path(&compose.root, h.project, h.agent);
63 let prompt = system_prompt_path(compose, h)
64 .map(|p| p.display().to_string())
65 .unwrap_or_default();
66
67 let mut s = String::new();
68 s.push_str(&format!("AGENT_ID={}:{}\n", h.project, h.agent));
69 s.push_str(&format!("PROJECT_ID={}\n", h.project));
70 s.push_str(&format!("RUNTIME={}\n", h.spec.runtime));
71 if let Some(m) = &h.spec.model {
72 s.push_str(&format!("MODEL={m}\n"));
73 }
74 if let Some(pm) = &h.spec.permission_mode {
75 s.push_str(&format!("PERMISSION_MODE={pm}\n"));
76 }
77 if let Some(effort) = h.spec.effort {
81 s.push_str(&format!("EFFORT={}\n", effort.as_str()));
82 }
83 s.push_str(&format!("TEAMCTL_MAILBOX={}\n", mailbox.display()));
84 s.push_str(&format!("MCP_CONFIG={}\n", mcp.display()));
85 s.push_str(&format!("SYSTEM_PROMPT_PATH={prompt}\n"));
86 s.push_str(&format!(
87 "CLAUDE_PROJECT_DIR={}\n",
88 project.project.cwd.display()
89 ));
90 s.push_str(&format!("TEAMCTL_ROOT={}\n", compose.root.display()));
98 s.push_str(&format!(
99 "TMUX_SESSION={}{}-{}\n",
100 compose.global.supervisor.tmux_prefix, h.project, h.agent
101 ));
102 if h.spec.runtime == "claude-code" {
108 let session_id = crate::session::derive_session_id(h.project, h.agent);
109 let session_name = crate::session::session_name(h.project, h.agent);
110 s.push_str(&format!("CLAUDE_SESSION_ID={session_id}\n"));
111 s.push_str(&format!("CLAUDE_SESSION_NAME={session_name}\n"));
112 }
113 s
114}
115
116pub fn system_prompt_path(compose: &Compose, h: AgentHandle<'_>) -> Option<PathBuf> {
126 match h.spec.role_prompt.as_ref()? {
127 RolePrompt::Single(p) => Some(compose.root.join(p)),
128 RolePrompt::Multiple(_) => Some(role_prompt_concat_path(&compose.root, h.project, h.agent)),
129 }
130}
131
132pub fn write_role_prompt_concat(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
143 let Some(RolePrompt::Multiple(paths)) = h.spec.role_prompt.as_ref() else {
144 return Ok(());
145 };
146
147 let mut buf = String::new();
148 for (idx, rel) in paths.iter().enumerate() {
149 if idx > 0 {
150 buf.push_str(ROLE_PROMPT_SEPARATOR);
151 }
152 let abs = compose.root.join(rel);
153 let bytes = std::fs::read(&abs).map_err(|e| {
154 io::Error::new(
155 e.kind(),
156 format!("read role_prompt source {}: {e}", abs.display()),
157 )
158 })?;
159 buf.push_str(&String::from_utf8_lossy(&bytes));
162 }
163
164 let dest = role_prompt_concat_path(&compose.root, h.project, h.agent);
165 if let Some(parent) = dest.parent() {
166 std::fs::create_dir_all(parent)?;
167 }
168 std::fs::write(&dest, buf)
169}
170
171fn render_mcp(compose: &Compose, h: AgentHandle<'_>, team_mcp_bin: &str) -> String {
172 let mailbox = compose.root.join(&compose.global.broker.path);
173 let v = serde_json::json!({
174 "mcpServers": {
175 "team": {
176 "command": team_mcp_bin,
177 "args": [
178 "--agent-id", format!("{}:{}", h.project, h.agent),
179 "--mailbox", mailbox.display().to_string(),
180 "--tmux-prefix", compose.global.supervisor.tmux_prefix.clone(),
188 "--compose-root", compose.root.display().to_string(),
194 ],
195 "env": {}
196 }
197 }
198 });
199 serde_json::to_string_pretty(&v).expect("json")
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use crate::compose::*;
206 use std::collections::BTreeMap;
207 use std::path::PathBuf;
208
209 fn fixture() -> Compose {
210 let mut managers = BTreeMap::new();
211 managers.insert(
212 "mgr".into(),
213 Agent {
214 runtime: "claude-code".into(),
215 model: Some("claude-opus-4-7".into()),
216 role_prompt: Some(RolePrompt::Single(PathBuf::from("roles/mgr.md"))),
217 permission_mode: Some("auto".into()),
218 autonomy: "low_risk_only".into(),
219 can_dm: vec![],
220 can_broadcast: vec![],
221 reports_to: None,
222 on_rate_limit: None,
223 effort: None,
224 interfaces: None,
225 display_name: None,
226 },
227 );
228 Compose {
229 root: PathBuf::from("/teamctl"),
230 global: Global {
231 version: 2,
232 broker: Broker {
233 r#type: "sqlite".into(),
234 path: PathBuf::from("state/mailbox.db"),
235 },
236 supervisor: SupervisorCfg {
237 r#type: "tmux".into(),
238 tmux_prefix: "a-".into(),
239 drain_timeout_secs: 10,
240 },
241 budget: Default::default(),
242 hitl: Default::default(),
243 rate_limits: Default::default(),
244 interfaces: vec![],
245 projects: vec![],
246 attachments: Default::default(),
247 },
248 projects: vec![Project {
249 version: 2,
250 project: ProjectMeta {
251 id: "hello".into(),
252 name: "Hello".into(),
253 cwd: PathBuf::from("/teamctl/examples/hello-team"),
254 },
255 channels: vec![],
256 managers,
257 workers: Default::default(),
258 }],
259 }
260 }
261
262 #[test]
263 fn env_contains_agent_id_and_mailbox() {
264 let c = fixture();
265 let h = c.agents().next().unwrap();
266 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
267 assert!(env.contains("AGENT_ID=hello:mgr"));
268 assert!(env.contains("TEAMCTL_MAILBOX=/teamctl/state/mailbox.db"));
269 assert!(env.contains("TMUX_SESSION=a-hello-mgr"));
270 }
271
272 #[test]
273 fn env_emits_claude_session_id_and_name_for_claude_code_runtime() {
274 let c = fixture();
278 let h = c.agents().next().unwrap();
279 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
280 let expected_id = crate::session::derive_session_id(h.project, h.agent);
281 assert!(
282 env.contains(&format!("CLAUDE_SESSION_ID={expected_id}\n")),
283 "env was: {env}"
284 );
285 assert!(
286 env.contains("CLAUDE_SESSION_NAME=teamctl:hello:mgr\n"),
287 "env was: {env}"
288 );
289 }
290
291 #[test]
292 fn env_omits_claude_session_vars_for_non_claude_runtimes() {
293 let mut c = fixture();
298 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
299 let h = c.agents().next().unwrap();
300 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
301 assert!(
302 !env.contains("CLAUDE_SESSION_ID="),
303 "non-claude runtime must not get session id: {env}"
304 );
305 assert!(
306 !env.contains("CLAUDE_SESSION_NAME="),
307 "non-claude runtime must not get session name: {env}"
308 );
309 }
310
311 #[test]
312 fn env_pins_teamctl_root_to_compose_root() {
313 let c = fixture();
319 let h = c.agents().next().unwrap();
320 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
321 assert!(env.contains("TEAMCTL_ROOT=/teamctl\n"), "env was: {env}");
322 }
323
324 #[test]
325 fn env_omits_effort_when_unset() {
326 let c = fixture();
327 let h = c.agents().next().unwrap();
328 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
329 assert!(!env.contains("EFFORT="), "env was: {env}");
330 }
331
332 #[test]
333 fn env_emits_effort_when_set() {
334 let mut c = fixture();
335 c.projects[0].managers.get_mut("mgr").unwrap().effort = Some(EffortLevel::Max);
336 let h = c.agents().next().unwrap();
337 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
338 assert!(env.contains("EFFORT=max\n"), "env was: {env}");
339 }
340
341 #[test]
342 fn mcp_json_parses_back() {
343 let c = fixture();
344 let h = c.agents().next().unwrap();
345 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
346 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
347 assert_eq!(
348 v["mcpServers"]["team"]["command"],
349 "/usr/local/bin/team-mcp"
350 );
351 assert_eq!(
352 v["mcpServers"]["team"]["args"][1].as_str().unwrap(),
353 "hello:mgr"
354 );
355 }
356
357 #[test]
358 fn mcp_json_threads_tmux_prefix_from_compose() {
359 let c = fixture();
365 let h = c.agents().next().unwrap();
366 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
367 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
368 let args: Vec<&str> = v["mcpServers"]["team"]["args"]
369 .as_array()
370 .unwrap()
371 .iter()
372 .map(|a| a.as_str().unwrap())
373 .collect();
374 let i = args.iter().position(|a| *a == "--tmux-prefix").expect(
375 "render_mcp must emit --tmux-prefix so compact_self resolves the caller's pane",
376 );
377 assert_eq!(
378 args[i + 1],
379 "a-",
380 "prefix must come from compose, not the default"
381 );
382 }
383
384 #[test]
385 fn env_points_at_source_for_single_role_prompt() {
386 let c = fixture();
387 let h = c.agents().next().unwrap();
388 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
389 assert!(
390 env.contains("SYSTEM_PROMPT_PATH=/teamctl/roles/mgr.md\n"),
391 "env was: {env}"
392 );
393 }
394
395 #[test]
396 fn env_points_at_concat_path_for_multi_role_prompt() {
397 let mut c = fixture();
398 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
399 Some(RolePrompt::Multiple(vec![
400 PathBuf::from("roles/_base.md"),
401 PathBuf::from("roles/mgr.md"),
402 ]));
403 let h = c.agents().next().unwrap();
404 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
405 assert!(
406 env.contains("SYSTEM_PROMPT_PATH=/teamctl/state/role_prompts/hello-mgr.md\n"),
407 "env was: {env}"
408 );
409 }
410
411 #[test]
412 fn write_role_prompt_concat_is_noop_for_single() {
413 let dir = tempfile::tempdir().unwrap();
414 let mut c = fixture();
415 c.root = dir.path().to_path_buf();
416 let h = c.agents().next().unwrap();
417 write_role_prompt_concat(&c, h).unwrap();
418 assert!(
419 !role_prompt_concat_path(&c.root, h.project, h.agent).exists(),
420 "single-form role_prompt should not produce a concat file"
421 );
422 }
423
424 #[test]
425 fn write_role_prompt_concat_joins_in_declared_order() {
426 let dir = tempfile::tempdir().unwrap();
427 let root = dir.path();
428 std::fs::create_dir_all(root.join("roles")).unwrap();
429 std::fs::write(root.join("roles/_base.md"), "BASE").unwrap();
430 std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
431
432 let mut c = fixture();
433 c.root = root.to_path_buf();
434 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
435 Some(RolePrompt::Multiple(vec![
436 PathBuf::from("roles/_base.md"),
437 PathBuf::from("roles/mgr.md"),
438 ]));
439 let h = c.agents().next().unwrap();
440 write_role_prompt_concat(&c, h).unwrap();
441
442 let dest = role_prompt_concat_path(root, h.project, h.agent);
443 let got = std::fs::read_to_string(&dest).unwrap();
444 assert_eq!(got, "BASE\n\n—\n\nMGR");
445 }
446
447 #[test]
448 fn write_role_prompt_concat_reflects_source_edits() {
449 let dir = tempfile::tempdir().unwrap();
452 let root = dir.path();
453 std::fs::create_dir_all(root.join("roles")).unwrap();
454 std::fs::write(root.join("roles/_base.md"), "v1").unwrap();
455 std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
456
457 let mut c = fixture();
458 c.root = root.to_path_buf();
459 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
460 Some(RolePrompt::Multiple(vec![
461 PathBuf::from("roles/_base.md"),
462 PathBuf::from("roles/mgr.md"),
463 ]));
464 let h = c.agents().next().unwrap();
465 write_role_prompt_concat(&c, h).unwrap();
466
467 std::fs::write(root.join("roles/_base.md"), "v2").unwrap();
468 let h = c.agents().next().unwrap();
469 write_role_prompt_concat(&c, h).unwrap();
470
471 let dest = role_prompt_concat_path(root, h.project, h.agent);
472 let got = std::fs::read_to_string(&dest).unwrap();
473 assert_eq!(got, "v2\n\n—\n\nMGR");
474 }
475
476 #[test]
477 fn write_role_prompt_concat_errors_on_missing_source() {
478 let dir = tempfile::tempdir().unwrap();
479 let mut c = fixture();
480 c.root = dir.path().to_path_buf();
481 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(RolePrompt::Multiple(
482 vec![PathBuf::from("roles/missing.md")],
483 ));
484 let h = c.agents().next().unwrap();
485 let err = write_role_prompt_concat(&c, h).unwrap_err();
486 assert!(err.to_string().contains("missing.md"), "err was: {err}");
487 }
488}