Skip to main content

team_core/
render.rs

1//! Render a loaded compose into on-disk artifacts.
2//!
3//! Outputs under `<root>/state/`:
4//! - `envs/<project>-<agent>.env`      — env vars for the agent wrapper.
5//! - `mcp/<project>-<agent>.json`      — MCP stdio config for the runtime.
6//! - `claude/<project>-<agent>.json`   — wrapper-managed Claude Code
7//!   settings (currently a `PreToolUse` deny hook for synchronous-prompt
8//!   tools that strand a headless pane). Claude-code agents only.
9//! - `codex-home/<project>-<agent>/config.toml` — per-agent Codex home
10//!   carrying the same MCP servers as the JSON above in the
11//!   `[mcp_servers.<name>]` table form codex reads (codex has no
12//!   `--mcp-config` flag). Codex agents only.
13//! - `opencode-home/<project>-<agent>/opencode.json` — per-agent OpenCode
14//!   config carrying the same MCP servers in the `mcp` object form
15//!   opencode reads via `OPENCODE_CONFIG` (opencode has no `--mcp-config`
16//!   flag either), plus `instructions` / autoupdate + share opt-outs.
17//!   The agent's session db lives beside it (`OPENCODE_DB`). OpenCode
18//!   agents only.
19//! - `role_prompts/<project>-<agent>.md` (multi-file role_prompt only) —
20//!   the ordered concatenation of every source file declared in the
21//!   role's `role_prompt: [...]` list. Re-materialized on every render
22//!   so any source-file edit lands in the agent's prompt at next boot.
23//!
24//! `systemd` / `launchd` unit rendering lives behind a feature flag when
25//! those back-ends are enabled via `supervisor.type`.
26
27use std::io;
28use std::path::{Path, PathBuf};
29
30use crate::compose::{AgentHandle, Compose, RolePrompt};
31
32/// Separator written between concatenated role-prompt files. Em-dash
33/// framed by blank lines reads cleanly when an operator inspects the
34/// materialized file under `state/role_prompts/`.
35const ROLE_PROMPT_SEPARATOR: &str = "\n\n—\n\n";
36
37/// Absolute path to the rendered env file for a given agent.
38pub fn env_path(root: &Path, project: &str, agent: &str) -> PathBuf {
39    root.join("state/envs")
40        .join(format!("{project}-{agent}.env"))
41}
42
43/// Absolute path to the rendered MCP config for a given agent.
44pub fn mcp_path(root: &Path, project: &str, agent: &str) -> PathBuf {
45    root.join("state/mcp")
46        .join(format!("{project}-{agent}.json"))
47}
48
49/// Absolute path to the wrapper-managed Claude Code settings file. The
50/// file carries the default `PreToolUse` deny hook for synchronous-prompt
51/// tools (`AskUserQuestion`, `EnterPlanMode`, `ExitPlanMode`) so a
52/// headless agent doesn't strand on a picker no one will answer. The
53/// wrapper applies it via `claude --settings <path>` for every
54/// claude-code agent except those in `permission_mode: attended`.
55pub fn claude_settings_path(root: &Path, project: &str, agent: &str) -> PathBuf {
56    root.join("state/claude")
57        .join(format!("{project}-{agent}.json"))
58}
59
60/// Absolute path to the rendered Claude Code `--agents` JSON for one agent
61/// (#383 Phase 3a). Lives beside the settings file under `state/claude/`
62/// and is written only when the agent declares `subagents:`; the wrapper
63/// passes it via `--agents "$(cat <path>)"` when the file exists.
64pub fn subagents_json_path(root: &Path, project: &str, agent: &str) -> PathBuf {
65    root.join("state/claude")
66        .join(format!("{project}-{agent}.agents.json"))
67}
68
69/// Absolute path to the per-agent scope directory passed to Claude Code
70/// via `--add-dir` (#383 Phase 3b). render materializes
71/// `<this>/.claude/skills/<name>` symlinks to each declared skill; the
72/// wrapper adds `--add-dir <this>` so the agent discovers them on top of
73/// the project `.claude/skills/`. The directory is materialized only when
74/// the agent declares `skills:`; the wrapper's `[ -d ]` guard decides
75/// whether the flag is passed.
76pub fn agent_scope_dir(root: &Path, project: &str, agent: &str) -> PathBuf {
77    root.join("state/agent-scope")
78        .join(format!("{project}-{agent}"))
79}
80
81/// Absolute path to the materialized concatenation of a multi-file
82/// `role_prompt` list. Only ever written for the list form — single-file
83/// `role_prompt` keeps pointing at its source path directly.
84pub fn role_prompt_concat_path(root: &Path, project: &str, agent: &str) -> PathBuf {
85    root.join("state/role_prompts")
86        .join(format!("{project}-{agent}.md"))
87}
88
89/// Absolute path to the per-agent activity heartbeat marker (#428). The
90/// `PreToolUse`/`UserPromptSubmit` hooks `touch` it on activity and the
91/// `Stop`/`StopFailure` hooks `rm` it at turn-end; the TUI `stat`s its
92/// mtime at the 1s refresh and classifies the agent Working (touched
93/// within 15s) or Idle. NOT JSON — a bare marker whose mtime is the whole
94/// signal. Compound `<project>-<agent>` like every sibling helper, so
95/// agents that share a name across projects never collide on one marker.
96pub fn heartbeat_path(root: &Path, project: &str, agent: &str) -> PathBuf {
97    root.join("state/heartbeats")
98        .join(format!("{project}-{agent}"))
99}
100
101/// Per-agent "last seen" marker, a sibling of [`heartbeat_path`] (#439). The
102/// boot-context hook `touch`es it at clean turn-end — alongside `rm`-ing the
103/// heartbeat marker — so a freshly woken session can compute how long the
104/// agent was down. Unlike the heartbeat marker (removed at every turn-end,
105/// so present at boot only after an *unclean* shutdown), this one persists
106/// across the gap, making its mtime the agent's last activity. Same compound
107/// `<project>-<agent>` stem as every sibling so cross-project name clashes
108/// can't collide, with a `.lastseen` suffix so it never shadows the marker
109/// the TUI stats for Working/Idle.
110pub fn lastseen_path(root: &Path, project: &str, agent: &str) -> PathBuf {
111    root.join("state/heartbeats")
112        .join(format!("{project}-{agent}.lastseen"))
113}
114
115/// Absolute path to the shared boot-context hook script (#430). Wired into
116/// every claude-code agent's `SessionStart` hook and rewritten by `teamctl
117/// up` (see `ensure_wrapper_and_dirs`), so it sits beside the agent wrapper
118/// in `bin/` rather than under per-agent `state/`. One script serves all
119/// agents — it reads the wake `source` from stdin, so it needs no per-agent
120/// identity baked into the path.
121pub fn boot_script_path(root: &Path) -> PathBuf {
122    root.join("bin/boot.sh")
123}
124
125/// Absolute path to the per-agent Codex home directory. `CODEX_HOME`
126/// relocates codex's entire state root — config, sessions, history — so
127/// pointing each codex agent at its own rendered home is the clean
128/// per-process isolation mechanism: MCP tables and instructions can't
129/// collide across agents. render writes `<this>/config.toml`; the wrapper
130/// exports `CODEX_HOME=<this>` and symlinks the operator's `auth.json` in
131/// so agents share the existing login.
132pub fn codex_home_dir(root: &Path, project: &str, agent: &str) -> PathBuf {
133    root.join("state/codex-home")
134        .join(format!("{project}-{agent}"))
135}
136
137/// Absolute path to the per-agent OpenCode home directory. OpenCode has
138/// no single state-root env var like `CODEX_HOME`; per-agent isolation
139/// is two env vars pointing into this rendered dir instead:
140/// `OPENCODE_DB` relocates the session sqlite db (created beside its
141/// -shm/-wal sidecars) and `OPENCODE_CONFIG` loads the per-process
142/// config json carrying the MCP servers + instructions. render writes
143/// `<this>/opencode.json`; the wrapper's env file points both vars here.
144/// Credentials are NOT relocated — auth stays at the operator's real
145/// `~/.local/share/opencode/auth.json`, shared by every agent.
146pub fn opencode_home_dir(root: &Path, project: &str, agent: &str) -> PathBuf {
147    root.join("state/opencode-home")
148        .join(format!("{project}-{agent}"))
149}
150
151/// Rendered env + MCP content for a single agent.
152pub fn render_agent(
153    compose: &Compose,
154    handle: AgentHandle<'_>,
155    team_mcp_bin: &str,
156) -> (String, String) {
157    let env = render_env(compose, handle);
158    let mcp = render_mcp(compose, handle, team_mcp_bin);
159    (env, mcp)
160}
161
162/// Wrapper-managed Claude Code settings JSON for a single agent. Returns
163/// `Some(json)` for `claude-code` runtime regardless of `permission_mode`
164/// — the wrapper decides whether to apply it. Returns `None` for runtimes
165/// that don't read Claude settings (codex, gemini, …).
166///
167/// The base payload is a single `PreToolUse` deny hook covering the
168/// synchronous-prompt tools that today strand a headless pane:
169/// `AskUserQuestion`, `EnterPlanMode`, `ExitPlanMode`. The `systemMessage`
170/// tells the model *why* the deny fired and points it at the `team` MCP
171/// tools as the headless-safe alternative — without that, the model just
172/// sees the call vanish and may retry. Matcher is a regex; extend it
173/// (rather than the hook count) when claude-code gains new synchronous-
174/// prompt tools.
175///
176/// #383 Phase 2: per-agent hooks declared in compose (`Agent.hooks`) are
177/// merged on top of that base. Each declaration is appended as its own
178/// entry under its event, so the built-in deny hook keeps its slot and a
179/// user hook can extend behavior but not clobber the interactive-prompt
180/// deny. Hook commands are compose-root-relative and rendered absolute.
181pub fn render_claude_settings(compose: &Compose, h: AgentHandle<'_>) -> Option<String> {
182    if h.spec.runtime != "claude-code" {
183        // Hooks are a Claude-Code concept. On other runtimes the whole
184        // settings file is skipped; surface a warning so a declared-but-
185        // ignored hook isn't silently dropped (claude-only v1).
186        if !h.spec.hooks.is_empty() {
187            tracing::warn!(
188                target: "team-core::render",
189                "agent `{}:{}` declares {} hook(s) but runtime `{}` does not support hooks (claude-code only); ignoring",
190                h.project,
191                h.agent,
192                h.spec.hooks.len(),
193                h.spec.runtime
194            );
195        }
196        // #461: same degrade for ultracode — a declared opt-in on a runtime
197        // that doesn't read claude settings is a no-op, so warn rather than
198        // silently drop it (matches the hooks/skills/subagents pattern).
199        if h.spec.ultracode {
200            tracing::warn!(
201                target: "team-core::render",
202                "agent `{}:{}` sets ultracode but runtime `{}` does not support it (claude-code only); ignoring",
203                h.project,
204                h.agent,
205                h.spec.runtime
206            );
207        }
208        return None;
209    }
210    // PreToolUse deny hook. Picked over `--disallowed-tools` so the
211    // model sees the deny + systemMessage (tighter learning loop) rather
212    // than the tool silently vanishing from its catalog. Emitted first
213    // and never removed; declared hooks (below) are appended after it.
214    let mut v = serde_json::json!({
215        // #421: pre-trust every project-scoped MCP server for headless
216        // agents. When Claude Code discovers a `.mcp.json` server it hasn't
217        // seen — on a fresh session or after `update` introduces a new one —
218        // it otherwise blocks on a "New MCP server found in this project:
219        // <name>" prompt. An unattended agent has no human to press Enter, so
220        // it freezes indefinitely (live owner repro). This top-level key
221        // pre-approves all *project* MCP servers (not user/global), so the
222        // prompt never fires. It only reaches headless agents: attended
223        // sessions skip `--settings` entirely, so a human at the terminal
224        // still sees and answers the prompt — a built-in opt-out. The key is
225        // Claude-owned: verified working against Claude Code 2.1.165, but a
226        // future rename would silently no-op and re-freeze headless panes, so
227        // the startup-dialog watcher stays as a version-independent backstop.
228        // Trade-off the owner OK'd: unattended convenience over per-server
229        // confirmation, scoped to this project's declared servers.
230        "enableAllProjectMcpServers": true,
231        "hooks": {
232            "PreToolUse": [
233                {
234                    "matcher": "AskUserQuestion|EnterPlanMode|ExitPlanMode",
235                    "hooks": [
236                        {
237                            "type": "command",
238                            "command": "echo '{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\"},\"systemMessage\":\"Interactive prompts are disabled for teamctl agents. Use the `team` MCP tools to ask people or check in.\"}'"
239                        }
240                    ]
241                }
242            ]
243        }
244    });
245
246    // #383 Phase 2: merge per-agent declared hooks on top. Each
247    // declaration becomes its own entry appended to its event's array, so
248    // the built-in deny hook above always keeps its slot. Commands are
249    // compose-root-relative (like `role_prompt`), rendered as absolute
250    // paths.
251    let hooks_obj = v["hooks"].as_object_mut().expect("hooks is a json object");
252
253    // #428: per-agent activity heartbeat. The TUI derives a Working/Idle
254    // sub-state of `Running` from the mtime of a per-agent marker file
255    // (touched within 15s => Working) — see `heartbeat_path` and
256    // `teamctl-ui`'s `data::is_working`. `PreToolUse` + `UserPromptSubmit`
257    // `touch` the marker on every tool call / prompt; `Stop` + `StopFailure`
258    // `rm` it at turn-end. No `matcher` => match all tools (do NOT borrow
259    // the deny hook's narrow matcher). The marker path is shell-quoted via
260    // `shlex` (not hand-rolled) so a compose root with spaces OR an embedded
261    // quote can't word-split and silently touch/rm the wrong path. The
262    // commands emit no stdout — that matters for `UserPromptSubmit`, whose
263    // exit-0 stdout is injected into the model's context. Zero DB writes:
264    // the hook only touches a file the TUI stat()s. The `state/heartbeats/`
265    // dir is created by `teamctl up`/`reload` alongside the other state
266    // subdirs, so the command is a bare `touch`. (A marker left fresh by an
267    // unclean shutdown is bounded to one 15s window and masked by the
268    // Stopped/Unknown state gate in the roster — see #428 / the PR note.)
269    {
270        let path = heartbeat_path(&compose.root, h.project, h.agent)
271            .display()
272            .to_string();
273        // Reuse the crate's POSIX single-quote escaper (errors only on a NUL
274        // byte, impossible in a filesystem path) rather than hand-rolling
275        // quoting that breaks on an embedded apostrophe.
276        let marker =
277            crate::supervisor::shlex::try_quote(&path).expect("heartbeat marker path is NUL-free");
278        // #439: the turn-end clear first `touch`es the per-agent LASTSEEN
279        // sibling, recording the moment of last activity before removing the
280        // marker. LASTSEEN survives the gap (the marker does not), so the
281        // boot-context hook can read its mtime to report downtime on the next
282        // startup. `touch && rm` keeps it one command CC runs in one /bin/sh;
283        // the touch is on a dir teamctl guarantees exists, so it effectively
284        // never fails — and if it ever did, the marker simply lingers one 15s
285        // window (the same bound an unclean shutdown already carries).
286        let lastseen_p = lastseen_path(&compose.root, h.project, h.agent)
287            .display()
288            .to_string();
289        let lastseen = crate::supervisor::shlex::try_quote(&lastseen_p)
290            .expect("lastseen marker path is NUL-free");
291        let touch = format!("touch {marker}");
292        let clear = format!("touch {lastseen} && rm -f {marker}");
293        for (event, command) in [
294            ("PreToolUse", &touch),
295            ("UserPromptSubmit", &touch),
296            ("Stop", &clear),
297            ("StopFailure", &clear),
298        ] {
299            hooks_obj
300                .entry(event.to_string())
301                .or_insert_with(|| serde_json::Value::Array(Vec::new()))
302                .as_array_mut()
303                .expect("hook event maps to a json array")
304                .push(serde_json::json!({
305                    "hooks": [ { "type": "command", "command": command } ]
306                }));
307        }
308    }
309
310    // #430: boot-context SessionStart hook. On every session (re)start Claude
311    // Code runs this and injects the script's stdout (`additionalContext`)
312    // into the agent's context, so a freshly woken pane knows it just
313    // (re)started and from which transition. The command is the shared
314    // `bin/boot.sh` asset `teamctl up` emits: inlining it would mean
315    // triple-escaping a sed + case + JSON-emit pipeline through shell ×
316    // settings-JSON × Rust, and it runs in the agent's `/bin/sh` (macOS bash
317    // 3.2), so a real file stays readable and `sh -n`-checkable. No `matcher`
318    // => fire on every source (startup|resume|clear|compact); `timeout: 5`
319    // bounds a wedged hook. The script emits the REQUIRED `hookEventName`
320    // itself — without it Claude Code silently drops `additionalContext`, the
321    // exact trap this hook exists to avoid. The path is shlex-quoted (like the
322    // #428 marker) so a compose root with spaces or a quote can't word-split.
323    // Supersedes the bootstrap-prompt mechanism #258 sketched (do not close
324    // #258 — its downtime-context idea lives on here).
325    {
326        let path = boot_script_path(&compose.root).display().to_string();
327        let boot =
328            crate::supervisor::shlex::try_quote(&path).expect("boot script path is NUL-free");
329        // #439: pass the per-agent LASTSEEN + MARKER paths as positional argv
330        // so boot.sh can report downtime on `startup`. The script stays shared
331        // and agent-agnostic — identity arrives via argv, not baked into the
332        // path (the #428 per-agent precedent). Each path is shlex-quoted like
333        // the script path, so a compose root with spaces or a quote can't
334        // word-split into the wrong argument. Order is (lastseen, marker);
335        // boot.sh prefers the marker's mtime when it survives an unclean stop.
336        let lastseen_p = lastseen_path(&compose.root, h.project, h.agent)
337            .display()
338            .to_string();
339        let lastseen = crate::supervisor::shlex::try_quote(&lastseen_p)
340            .expect("lastseen marker path is NUL-free");
341        let marker_p = heartbeat_path(&compose.root, h.project, h.agent)
342            .display()
343            .to_string();
344        let marker = crate::supervisor::shlex::try_quote(&marker_p)
345            .expect("heartbeat marker path is NUL-free");
346        let command = format!("{boot} {lastseen} {marker}");
347        hooks_obj
348            .entry("SessionStart".to_string())
349            .or_insert_with(|| serde_json::Value::Array(Vec::new()))
350            .as_array_mut()
351            .expect("hook event maps to a json array")
352            .push(serde_json::json!({
353                "hooks": [ { "type": "command", "command": command, "timeout": 5 } ]
354            }));
355    }
356
357    // #431: rate-limit hit marker. Appended as a SECOND entry to the same
358    // `StopFailure` bucket the #428 heartbeat clear already lives in (slot 0 =
359    // match-all `rm -f <marker>`; this is slot 1, scoped by `matcher`). On a
360    // turn that ends because the runtime hit its rate limit, Claude Code runs
361    // this and `teamctl rl-hit` records a forensic hit row. The `rate_limit`
362    // matcher is a real `StopFailure` reason value, so the entry only fires on
363    // rate-limit stops, not every failure. The command mirrors the wrapper's
364    // own convention (agent-wrapper.sh): a PATH `teamctl` guarded by
365    // `command -v`, with a trailing `|| true` so this is pure fire-and-forget:
366    // a host without teamctl on PATH (or any rl-hit error) degrades to a silent
367    // exit-0 no-op instead of erroring the stop, matching the heartbeat clear's
368    // always-exit-0 `rm -f`. The compose root and the
369    // `<project>:<agent>` id are baked in (render has both in scope, no env
370    // dependency) and shlex-quoted like the #428 marker / #430 boot path; the
371    // guard and the `--root`/`rl-hit` literals are not quoted. The hook has no
372    // PTY output to read a reset time from, so `rl-hit` stores `resets_at` NULL,
373    // invisible to the TUI countdown (which filters `resets_at IS NOT NULL`),
374    // leaving `rl-watch` the sole countdown source.
375    {
376        let root = crate::supervisor::shlex::try_quote(&compose.root.display().to_string())
377            .expect("compose root is NUL-free");
378        let agent_id = format!("{}:{}", h.project, h.agent);
379        let agent_id =
380            crate::supervisor::shlex::try_quote(&agent_id).expect("agent id is NUL-free");
381        let command = format!(
382            "command -v teamctl >/dev/null 2>&1 && teamctl --root {root} rl-hit {agent_id} || true"
383        );
384        hooks_obj
385            .entry("StopFailure".to_string())
386            .or_insert_with(|| serde_json::Value::Array(Vec::new()))
387            .as_array_mut()
388            .expect("hook event maps to a json array")
389            .push(serde_json::json!({
390                "matcher": "rate_limit",
391                "hooks": [ { "type": "command", "command": command } ]
392            }));
393    }
394
395    // #333: budget cost writer. Appended as a SECOND entry to the same `Stop`
396    // bucket the #428 heartbeat clear already lives in (slot 0 = match-all
397    // touch-lastseen + `rm -f <marker>`; this is slot 1, also match-all so it
398    // fires on every clean turn-end). Claude Code pipes the Stop payload to
399    // this command on stdin; `teamctl budget-record` reads the transcript named
400    // in that payload, sums the just-finished turn's token usage, prices it, and
401    // INSERTs one `budget` row — the missing writer behind a permanently-$0.00
402    // `USD-24H`. The command mirrors the #431 rl-hit shape exactly: a PATH
403    // `teamctl` guarded by `command -v`, with a trailing `|| true` so it's pure
404    // fire-and-forget — a host without teamctl on PATH (or any record error)
405    // degrades to a silent exit-0 no-op instead of erroring the stop, matching
406    // the heartbeat clear's always-exit-0 `rm -f`. The compose root and the
407    // `<project>:<agent>` id are baked in (render has both in scope, no env
408    // dependency) and shlex-quoted like the #428 marker / #431 rl-hit id; the
409    // guard and the `--root`/`budget-record` literals are not quoted. On `Stop`
410    // (clean turn-end) only: a rate-limited turn ends on `StopFailure`, so its
411    // partial spend is intentionally not recorded — an accepted v1 gap.
412    {
413        let root = crate::supervisor::shlex::try_quote(&compose.root.display().to_string())
414            .expect("compose root is NUL-free");
415        let agent_id = format!("{}:{}", h.project, h.agent);
416        let agent_id =
417            crate::supervisor::shlex::try_quote(&agent_id).expect("agent id is NUL-free");
418        let command = format!(
419            "command -v teamctl >/dev/null 2>&1 && teamctl --root {root} budget-record {agent_id} || true"
420        );
421        hooks_obj
422            .entry("Stop".to_string())
423            .or_insert_with(|| serde_json::Value::Array(Vec::new()))
424            .as_array_mut()
425            .expect("hook event maps to a json array")
426            .push(serde_json::json!({
427                "hooks": [ { "type": "command", "command": command } ]
428            }));
429    }
430
431    for hook in &h.spec.hooks {
432        let command = compose.root.join(&hook.command);
433        let mut entry = serde_json::json!({
434            "hooks": [
435                {
436                    "type": "command",
437                    "command": command.display().to_string()
438                }
439            ]
440        });
441        if let Some(matcher) = &hook.matcher {
442            entry["matcher"] = serde_json::Value::String(matcher.clone());
443        }
444        hooks_obj
445            .entry(hook.event.clone())
446            .or_insert_with(|| serde_json::Value::Array(Vec::new()))
447            .as_array_mut()
448            .expect("hook event maps to a json array")
449            .push(entry);
450    }
451
452    // #461: per-agent ultracode opt-in. ultracode is a Claude Code settings
453    // key (verified against 2.1.175: settable via `--settings '{"ultracode":
454    // true}'`; NOT a CLI flag and NOT an effort value), so it rides this same
455    // settings file the wrapper passes via `--settings`. Inserted only when
456    // opted in, so the default settings shape is byte-identical for everyone
457    // else. claude-only falls out for free: this fn already returned `None`
458    // above for non-claude runtimes.
459    if h.spec.ultracode {
460        v["ultracode"] = serde_json::Value::Bool(true);
461    }
462
463    Some(serde_json::to_string_pretty(&v).expect("json"))
464}
465
466/// #383 Phase 3a: build Claude Code's `--agents` inline JSON for one agent
467/// from its declared `subagents:` list. Each list entry is a
468/// compose-root-relative markdown file with standard sub-agent frontmatter
469/// (`name`, `description`, optional `tools`, `model`) and a body that
470/// becomes the sub-agent's system `prompt`. The result is the
471/// `{ "<name>": { description, prompt, [tools], [model] } }` object the
472/// `--agents` flag consumes — the only cwd-stationary way to scope
473/// sub-agents per agent (no arbitrary-path flag exists; see the Phase-1
474/// spike). Returns `Ok(None)` when none are declared (→ no `--agents`
475/// flag) or the runtime isn't claude-code (logs an "unsupported" warning,
476/// claude-only v1); `Err` if a source is unreadable or its frontmatter is
477/// invalid, so a typo fails the apply loudly rather than dropping a
478/// sub-agent silently.
479pub fn render_subagents(compose: &Compose, h: AgentHandle<'_>) -> io::Result<Option<String>> {
480    if h.spec.subagents.is_empty() {
481        return Ok(None);
482    }
483    if h.spec.runtime != "claude-code" {
484        tracing::warn!(
485            target: "team-core::render",
486            "agent `{}:{}` declares {} sub-agent(s) but runtime `{}` does not support sub-agents (claude-code only); ignoring",
487            h.project,
488            h.agent,
489            h.spec.subagents.len(),
490            h.spec.runtime
491        );
492        return Ok(None);
493    }
494
495    let mut map = serde_json::Map::new();
496    for rel in &h.spec.subagents {
497        let abs = compose.root.join(rel);
498        let raw = std::fs::read_to_string(&abs).map_err(|e| {
499            io::Error::new(
500                e.kind(),
501                format!("read sub-agent source {}: {e}", abs.display()),
502            )
503        })?;
504        let (fm, body) = parse_subagent(&raw).map_err(|e| {
505            io::Error::new(
506                io::ErrorKind::InvalidData,
507                format!("parse sub-agent {}: {e}", abs.display()),
508            )
509        })?;
510        // Name from frontmatter, else the file stem (so `agents/foo.md`
511        // without an explicit `name:` registers as sub-agent `foo`).
512        let name = fm.name.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| {
513            rel.file_stem()
514                .map(|s| s.to_string_lossy().into_owned())
515                .unwrap_or_default()
516        });
517        let mut entry = serde_json::json!({
518            "description": fm.description,
519            "prompt": body,
520        });
521        if let Some(tools) = fm.tools {
522            let list = tools.into_list();
523            if !list.is_empty() {
524                entry["tools"] = serde_json::json!(list);
525            }
526        }
527        if let Some(model) = fm.model.filter(|m| !m.trim().is_empty()) {
528            entry["model"] = serde_json::Value::String(model);
529        }
530        map.insert(name, entry);
531    }
532    Ok(Some(
533        serde_json::to_string_pretty(&serde_json::Value::Object(map)).expect("json"),
534    ))
535}
536
537/// Write (or clear) the per-agent `--agents` JSON file. Mirrors
538/// [`write_role_prompt_concat`]: the scoped + full render paths both call
539/// it so a `subagents:` edit flows into the agent at the next render. When
540/// the agent declares no sub-agents (or isn't claude-code) the file is
541/// removed if present, so a stale `--agents` set never lingers across a
542/// reload that dropped them.
543pub fn write_subagents_json(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
544    let dest = subagents_json_path(&compose.root, h.project, h.agent);
545    match render_subagents(compose, h)? {
546        Some(json) => {
547            if let Some(parent) = dest.parent() {
548                std::fs::create_dir_all(parent)?;
549            }
550            std::fs::write(&dest, json)
551        }
552        None => match std::fs::remove_file(&dest) {
553            Ok(()) => Ok(()),
554            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
555            Err(e) => Err(e),
556        },
557    }
558}
559
560/// Materialize (or clear) the per-agent skills scope for one agent (#383
561/// Phase 3b). For a claude-code agent declaring `skills:`, this creates
562/// `state/agent-scope/<project>-<agent>/.claude/skills/` and symlinks each
563/// declared skill directory into it (link name = the skill dir's basename),
564/// so `claude --add-dir <scope>` surfaces them additively atop the project
565/// `.claude/skills/`. Mirrors [`write_subagents_json`]: the scoped + full
566/// render paths both call it, and the skills dir is rebuilt from scratch
567/// every render so a renamed or dropped skill never lingers. When the agent
568/// declares no skills (or isn't claude-code) the scope dir is removed if
569/// present.
570///
571/// Symlink targets are absolute (compose-root-relative input resolved
572/// against `compose.root`); a missing source becomes a dangling link rather
573/// than an error, matching how `role_prompt`/`hooks` treat not-yet-created
574/// paths (existence checks across all path-typed fields are a tracked
575/// follow-up). Clearing always unlinks entries individually — render never
576/// hands a symlink to `remove_dir_all`, so a skill's real files are never
577/// followed or deleted.
578pub fn write_agent_skills(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
579    let scope = agent_scope_dir(&compose.root, h.project, h.agent);
580    let skills_dir = scope.join(".claude/skills");
581
582    if h.spec.runtime != "claude-code" || h.spec.skills.is_empty() {
583        if h.spec.runtime != "claude-code" && !h.spec.skills.is_empty() {
584            // Skills are a Claude-Code concept; surface a warning so a
585            // declared-but-ignored skill isn't silently dropped (claude-
586            // only v1, same shape as hooks/sub-agents).
587            tracing::warn!(
588                target: "team-core::render",
589                "agent `{}:{}` declares {} skill(s) but runtime `{}` does not support skills (claude-code only); ignoring",
590                h.project,
591                h.agent,
592                h.spec.skills.len(),
593                h.spec.runtime
594            );
595        }
596        // Clear a stale scope dir so dropped skills don't linger across a
597        // reload that removed them.
598        return remove_scope_dir(&scope);
599    }
600
601    // Rebuild from scratch each render: clear the existing links (each is a
602    // symlink we created — unlink it, never recurse into its target) then
603    // re-create the current set.
604    clear_skills_dir(&skills_dir)?;
605    std::fs::create_dir_all(&skills_dir)?;
606    for rel in &h.spec.skills {
607        // Link name is the skill directory's basename — Claude Code
608        // discovers `.claude/skills/<name>/SKILL.md`.
609        let Some(name) = rel.file_name() else {
610            continue; // path ending in `..` / root has no skill name
611        };
612        let link = skills_dir.join(name);
613        // Last-wins on a duplicate basename (consistent with sub-agents'
614        // name-keyed map): drop any link already placed for this name.
615        if std::fs::symlink_metadata(&link).is_ok() {
616            std::fs::remove_file(&link)?;
617        }
618        std::os::unix::fs::symlink(compose.root.join(rel), &link)?;
619    }
620    Ok(())
621}
622
623/// Remove the per-agent scope dir if present. Clears the managed symlinks
624/// individually first, so `remove_dir_all` only ever sees plain
625/// directories — it never gets a symlink entry that could be followed into
626/// a skill's real files. No-op when the dir doesn't exist.
627fn remove_scope_dir(scope: &Path) -> io::Result<()> {
628    clear_skills_dir(&scope.join(".claude/skills"))?;
629    match std::fs::remove_dir_all(scope) {
630        Ok(()) => Ok(()),
631        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
632        Err(e) => Err(e),
633    }
634}
635
636/// Remove every entry in the per-agent skills dir. Each entry is a symlink
637/// render created, so we `remove_file` (unlink) it — never recursing into
638/// the skill's real contents. No-op when the dir doesn't exist yet.
639fn clear_skills_dir(skills_dir: &Path) -> io::Result<()> {
640    let entries = match std::fs::read_dir(skills_dir) {
641        Ok(e) => e,
642        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
643        Err(e) => return Err(e),
644    };
645    for entry in entries {
646        let entry = entry?;
647        let path = entry.path();
648        let meta = std::fs::symlink_metadata(&path)?;
649        if meta.file_type().is_symlink() || meta.is_file() {
650            std::fs::remove_file(&path)?;
651        } else {
652            // Defensive: we only create symlinks here, but if a real
653            // subdir somehow appears, clear it without following links.
654            std::fs::remove_dir_all(&path)?;
655        }
656    }
657    Ok(())
658}
659
660/// Parsed frontmatter of a sub-agent markdown file. Mirrors the fields
661/// Claude Code's own `.claude/agents/*.md` use; unknown keys are ignored.
662#[derive(serde::Deserialize)]
663struct SubagentFrontmatter {
664    #[serde(default)]
665    name: Option<String>,
666    description: String,
667    #[serde(default)]
668    tools: Option<Tools>,
669    #[serde(default)]
670    model: Option<String>,
671}
672
673/// `tools:` accepts either Claude Code's comma-separated string form
674/// (`Read, Grep`) or a YAML list (`[Read, Grep]`); both normalize to the
675/// JSON array `--agents` expects.
676#[derive(serde::Deserialize)]
677#[serde(untagged)]
678enum Tools {
679    List(Vec<String>),
680    Csv(String),
681}
682
683impl Tools {
684    fn into_list(self) -> Vec<String> {
685        let raw = match self {
686            Tools::List(v) => v,
687            Tools::Csv(s) => s.split(',').map(str::to_string).collect(),
688        };
689        raw.into_iter()
690            .map(|t| t.trim().to_string())
691            .filter(|t| !t.is_empty())
692            .collect()
693    }
694}
695
696/// Split a sub-agent markdown file into (frontmatter, body). Expects the
697/// standard `---\n<yaml>\n---\n<body>` layout; the body is everything after
698/// the closing delimiter, trimmed of surrounding blank lines.
699fn parse_subagent(raw: &str) -> Result<(SubagentFrontmatter, String), String> {
700    let after_open = raw
701        .strip_prefix("---")
702        .ok_or("missing opening `---` frontmatter delimiter")?;
703    let (yaml, body) = after_open
704        .split_once("\n---")
705        .ok_or("missing closing `---` frontmatter delimiter")?;
706    let fm: SubagentFrontmatter =
707        serde_yaml::from_str(yaml.trim()).map_err(|e| format!("invalid frontmatter YAML: {e}"))?;
708    let body = body.trim_start_matches(['\r', '\n']).trim_end().to_string();
709    Ok((fm, body))
710}
711
712fn render_env(compose: &Compose, h: AgentHandle<'_>) -> String {
713    let project = compose
714        .projects
715        .iter()
716        .find(|p| p.project.id == h.project)
717        .expect("agent belongs to a loaded project");
718    let mailbox = compose.root.join(&compose.global.broker.path);
719    let mcp = mcp_path(&compose.root, h.project, h.agent);
720    let prompt = system_prompt_path(compose, h)
721        .map(|p| p.display().to_string())
722        .unwrap_or_default();
723
724    let mut s = String::new();
725    s.push_str(&format!("AGENT_ID={}:{}\n", h.project, h.agent));
726    s.push_str(&format!("PROJECT_ID={}\n", h.project));
727    s.push_str(&format!("RUNTIME={}\n", h.spec.runtime));
728    if let Some(m) = &h.spec.model {
729        s.push_str(&format!("MODEL={m}\n"));
730    }
731    if let Some(pm) = &h.spec.permission_mode {
732        s.push_str(&format!("PERMISSION_MODE={pm}\n"));
733    }
734    // T-048: per-agent reasoning effort flows through to the runtime
735    // via the wrapper. Workspace-level `.env` `EFFORT=` still wins for
736    // operators not yet on the YAML form (back-compat).
737    if let Some(effort) = h.spec.effort {
738        s.push_str(&format!("EFFORT={}\n", effort.as_str()));
739    }
740    s.push_str(&format!("TEAMCTL_MAILBOX={}\n", mailbox.display()));
741    s.push_str(&format!("MCP_CONFIG={}\n", mcp.display()));
742    s.push_str(&format!("SYSTEM_PROMPT_PATH={prompt}\n"));
743    s.push_str(&format!(
744        "CLAUDE_PROJECT_DIR={}\n",
745        project.project.cwd.display()
746    ));
747    // Absolute path to the compose root (the directory holding
748    // `team-compose.yaml`). The wrapper passes this to `teamctl --root`
749    // so rl-watch resolves the right tree regardless of where
750    // `cd "$CLAUDE_PROJECT_DIR"` lands the shell. Without this,
751    // wrapper falls back to CLAUDE_PROJECT_DIR (often a relative `..`)
752    // which compounds with the post-cd cwd and points at the wrong
753    // directory.
754    s.push_str(&format!("TEAMCTL_ROOT={}\n", compose.root.display()));
755    s.push_str(&format!(
756        "TMUX_SESSION={}{}-{}\n",
757        compose.global.supervisor.tmux_prefix, h.project, h.agent
758    ));
759    // T-118: claude-code agents resume their conversation across
760    // teamctl down/up + crash recovery via a deterministic UUIDv5
761    // session id. Other runtimes don't recognize `--session-id`, so
762    // emit these env vars only for `claude-code` — the wrapper's
763    // claude-code arm picks them up; other arms ignore them.
764    if h.spec.runtime == "claude-code" {
765        let session_id = crate::session::derive_session_id(h.project, h.agent);
766        let session_name = crate::session::session_name(h.project, h.agent);
767        s.push_str(&format!("CLAUDE_SESSION_ID={session_id}\n"));
768        s.push_str(&format!("CLAUDE_SESSION_NAME={session_name}\n"));
769        // T-189: path to the wrapper-managed Claude settings file
770        // carrying the synchronous-prompt deny hook. Wrapper applies
771        // it via `--settings` except when `permission_mode: attended`
772        // (human at the keyboard wants the interactive tools back).
773        let settings = claude_settings_path(&compose.root, h.project, h.agent);
774        s.push_str(&format!("CLAUDE_SETTINGS={}\n", settings.display()));
775        // #383 Phase 3a: path to the rendered `--agents` JSON carrying this
776        // agent's declared sub-agents. Always emitted for claude-code; the
777        // file itself is written only when `subagents:` is non-empty, so
778        // the wrapper's `[ -f ]` guard decides whether `--agents` is passed.
779        let subagents = subagents_json_path(&compose.root, h.project, h.agent);
780        s.push_str(&format!("CLAUDE_AGENTS_JSON={}\n", subagents.display()));
781        // #383 Phase 3b: path to the per-agent skills scope dir passed to
782        // `claude --add-dir`. Always emitted for claude-code; the dir is
783        // materialized only when `skills:` is non-empty, so the wrapper's
784        // `[ -d ]` guard decides whether `--add-dir` is passed.
785        let scope = agent_scope_dir(&compose.root, h.project, h.agent);
786        s.push_str(&format!("CLAUDE_AGENT_SCOPE={}\n", scope.display()));
787    }
788    // Codex has no `--mcp-config` flag — its MCP servers live in
789    // `[mcp_servers.*]` tables inside `$CODEX_HOME/config.toml`, and
790    // `CODEX_HOME` relocates codex's whole state root. Point each codex
791    // agent at its own rendered home (written by [`write_codex_config`])
792    // so the wrapper can export it; other runtimes must not see the var.
793    if h.spec.runtime == "codex" {
794        let home = codex_home_dir(&compose.root, h.project, h.agent);
795        s.push_str(&format!("CODEX_HOME={}\n", home.display()));
796    }
797    // OpenCode has no `--mcp-config` flag either — servers live in the
798    // `mcp` object of the json `OPENCODE_CONFIG` points at (written by
799    // [`write_opencode_config`]), and `OPENCODE_DB` relocates the
800    // session sqlite db so each agent resumes its own conversation via
801    // `-c`. Both are opencode-only; other runtimes must not see them.
802    // NOTE: OPENCODE_DB is present in the binary but undocumented
803    // upstream — provenance lives in `runtimes/opencode.yaml`.
804    if h.spec.runtime == "opencode" {
805        let home = opencode_home_dir(&compose.root, h.project, h.agent);
806        s.push_str(&format!(
807            "OPENCODE_DB={}\n",
808            home.join("agent.db").display()
809        ));
810        s.push_str(&format!(
811            "OPENCODE_CONFIG={}\n",
812            home.join("opencode.json").display()
813        ));
814    }
815    s
816}
817
818/// Resolve the absolute path that `SYSTEM_PROMPT_PATH` will point at.
819///
820/// - `None` role_prompt → `None` (env line renders as blank).
821/// - Single source file → `<root>/<source>` (back-compat, no concat
822///   file is written — the operator's source is the prompt).
823/// - List form → the materialized concat path under
824///   `<root>/state/role_prompts/<project>-<agent>.md`. The file at that
825///   path is produced by [`write_role_prompt_concat`]; this helper is
826///   pure and only computes the destination.
827pub fn system_prompt_path(compose: &Compose, h: AgentHandle<'_>) -> Option<PathBuf> {
828    match h.spec.role_prompt.as_ref()? {
829        RolePrompt::Single(p) => Some(compose.root.join(p)),
830        RolePrompt::Multiple(_) => Some(role_prompt_concat_path(&compose.root, h.project, h.agent)),
831    }
832}
833
834/// Materialize the multi-file `role_prompt` concatenation for one agent.
835///
836/// No-op when `role_prompt` is `None` or `Single` — there is nothing to
837/// concatenate. For the list form, every source file is read in declared
838/// order and joined with [`ROLE_PROMPT_SEPARATOR`]; the result overwrites
839/// `<root>/state/role_prompts/<project>-<agent>.md` so subsequent edits
840/// to any source file flow into the agent's prompt at the next render.
841///
842/// Missing source files surface as the underlying `io::Error` so the
843/// caller can fail the apply rather than silently emit a partial concat.
844pub fn write_role_prompt_concat(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
845    let Some(RolePrompt::Multiple(paths)) = h.spec.role_prompt.as_ref() else {
846        return Ok(());
847    };
848
849    let mut buf = String::new();
850    for (idx, rel) in paths.iter().enumerate() {
851        if idx > 0 {
852            buf.push_str(ROLE_PROMPT_SEPARATOR);
853        }
854        let abs = compose.root.join(rel);
855        let bytes = std::fs::read(&abs).map_err(|e| {
856            io::Error::new(
857                e.kind(),
858                format!("read role_prompt source {}: {e}", abs.display()),
859            )
860        })?;
861        // Source files are expected to be UTF-8 markdown; lossy decode
862        // keeps render diagnostics readable if a stray byte sneaks in.
863        buf.push_str(&String::from_utf8_lossy(&bytes));
864    }
865
866    let dest = role_prompt_concat_path(&compose.root, h.project, h.agent);
867    if let Some(parent) = dest.parent() {
868        std::fs::create_dir_all(parent)?;
869    }
870    std::fs::write(&dest, buf)
871}
872
873/// Args for the built-in `team` MCP stdio server. Single source of truth
874/// shared by [`render_mcp`] (JSON), [`render_codex_config`] (TOML in
875/// the per-agent `CODEX_HOME`), and [`render_opencode_config`] (json in
876/// the per-agent opencode home) so the transports can never drift.
877fn team_server_args(compose: &Compose, h: AgentHandle<'_>) -> Vec<String> {
878    let mailbox = compose.root.join(&compose.global.broker.path);
879    vec![
880        "--agent-id".into(),
881        format!("{}:{}", h.project, h.agent),
882        "--mailbox".into(),
883        mailbox.display().to_string(),
884        // T-109: compact_self resolves the caller's tmux pane
885        // as `<prefix><project>-<agent>`. Pass the configured
886        // prefix explicitly so teams overriding the default
887        // (`a-`, `oss-`, …) route the slash command to the
888        // right session. team-bot gets the same arg threaded
889        // from `teamctl bot up`; this keeps the two MCP-side
890        // and bot-side resolvers in sync.
891        "--tmux-prefix".into(),
892        compose.global.supervisor.tmux_prefix.clone(),
893        // T-32b: compose root used by `read_attachment`
894        // for `attachments:` policy + tempfile staging.
895        // Always passed so the per-agent team-mcp can
896        // serve attachment reads; the staging dir is
897        // computed under this root.
898        "--compose-root".into(),
899        compose.root.display().to_string(),
900    ]
901}
902
903/// Whether declared `mcps:` render for this agent's runtime. Fail open
904/// when the descriptor is missing: an unknown runtime is flagged at
905/// validate, and a load failure shouldn't silently drop declared servers.
906fn runtime_supports_mcp(compose: &Compose, h: AgentHandle<'_>) -> bool {
907    let runtimes = crate::runtimes::load_all(&compose.root).unwrap_or_default();
908    runtimes
909        .get(h.spec.runtime.as_str())
910        .map(|r| r.supports_mcp)
911        .unwrap_or(true)
912}
913
914fn render_mcp(compose: &Compose, h: AgentHandle<'_>, team_mcp_bin: &str) -> String {
915    let mut v = serde_json::json!({
916        "mcpServers": {
917            "team": {
918                "command": team_mcp_bin,
919                "args": team_server_args(compose, h),
920                "env": {}
921            }
922        }
923    });
924
925    // #383 Phase 4: merge per-agent declared MCP servers alongside the
926    // built-in `team` server. Unlike hooks (claude-only), MCP is the
927    // runtime-agnostic bus, so declared servers render for every runtime
928    // whose descriptor sets `supports_mcp`. The `team` server is the
929    // mailbox transport: it stays unconditional and non-clobberable — a
930    // declared server named `team` is skipped here (and rejected at
931    // validate) so it can never shadow the bus. env values pass through
932    // verbatim — render never expands `${VAR}` placeholders, since that
933    // would write resolved secrets to disk. Claude Code expands them at
934    // launch; codex does NOT interpolate config.toml values, so validate
935    // warns on codex agents (McpEnvInterpolationUnsupported).
936    if !h.spec.mcps.is_empty() {
937        if runtime_supports_mcp(compose, h) {
938            let servers = v["mcpServers"]
939                .as_object_mut()
940                .expect("mcpServers is a json object");
941            for (name, server) in &h.spec.mcps {
942                if name == "team" {
943                    continue; // non-clobberable bus; validate rejects this too
944                }
945                servers.insert(
946                    name.clone(),
947                    serde_json::to_value(server).expect("serialize McpServer"),
948                );
949            }
950        } else {
951            tracing::warn!(
952                target: "team-core::render",
953                "agent `{}:{}` declares {} MCP server(s) but runtime `{}` does not set `supports_mcp`; ignoring",
954                h.project,
955                h.agent,
956                h.spec.mcps.len(),
957                h.spec.runtime
958            );
959        }
960    }
961
962    serde_json::to_string_pretty(&v).expect("json")
963}
964
965/// Team-mcp tool names pre-approved in the rendered codex config. Codex
966/// prompts per MCP tool on first call — even under `-a never` — and
967/// persists an "Always allow" answer INTO `config.toml`, which teamctl
968/// rewrites on every `up`/`reload`. So the render must seed the approvals
969/// or an unattended codex pane strands on its very first `inbox_peek`
970/// (observed live on codex-cli 0.144.3). Keep in sync with team-mcp's
971/// `schema()` tool list; a missing entry means one interactive prompt on
972/// that tool's first use.
973const CODEX_PREAPPROVED_TEAM_TOOLS: &[&str] = &[
974    "broadcast",
975    "compact_self",
976    "dm",
977    "inbox_ack",
978    "inbox_peek",
979    "inbox_read",
980    "inbox_watch",
981    "list_team",
982    "org_chart",
983    "react_to_user",
984    "read_attachment",
985    "reply_to_user",
986    "request_approval",
987    "show_typing",
988    "whoami",
989];
990
991/// Per-agent Codex `config.toml` for `runtime: codex` agents. Returns
992/// `None` for every other runtime. Codex has no `--mcp-config` flag —
993/// MCP servers are read from `[mcp_servers.<name>]` tables in
994/// `$CODEX_HOME/config.toml` — so this file is the codex-shaped mirror of
995/// [`render_mcp`]'s JSON: the unconditional `team` bus plus declared
996/// `mcps:` under the same `supports_mcp` gate (render_mcp already warns
997/// when the gate drops them, so this stays quiet). TOML is hand-rendered
998/// with proper string escaping — team-core carries no toml crate and one
999/// table shape doesn't earn the dependency.
1000///
1001/// Two extra seeded tables, both in the exact shape codex 0.144.3 itself
1002/// persists when a human answers its dialogs (verified live):
1003/// `[projects."<cwd>"] trust_level = "trusted"` skips the boot-time
1004/// trust dialog — whose wording has shifted across codex releases, so
1005/// pre-seeding beats pane-text matching (the wrapper's auto-confirm
1006/// patterns stay as a backstop) — and per-tool
1007/// `[mcp_servers.team.tools.<name>] approval_mode = "approve"` entries
1008/// cover the team bus. Declared `mcps:` tools are NOT pre-approved (their
1009/// tool lists are unknown at render time): they prompt once per tool, and
1010/// an attached operator's "Always allow" survives only until the next
1011/// render — documented limitation, follow-up ticket.
1012pub fn render_codex_config(
1013    compose: &Compose,
1014    h: AgentHandle<'_>,
1015    team_mcp_bin: &str,
1016) -> Option<String> {
1017    if h.spec.runtime != "codex" {
1018        return None;
1019    }
1020    let mut s = String::from(
1021        "# teamctl-managed: rewritten on every `teamctl up` / `reload`.\n\
1022         # Customize MCP servers through the compose file's `mcps:` field.\n",
1023    );
1024    push_mcp_server_table(
1025        &mut s,
1026        "team",
1027        team_mcp_bin,
1028        &team_server_args(compose, h),
1029        &Default::default(),
1030    );
1031    for tool in CODEX_PREAPPROVED_TEAM_TOOLS {
1032        s.push_str(&format!(
1033            "\n[mcp_servers.team.tools.{tool}]\napproval_mode = \"approve\"\n"
1034        ));
1035    }
1036    if !h.spec.mcps.is_empty() && runtime_supports_mcp(compose, h) {
1037        for (name, server) in &h.spec.mcps {
1038            if name == "team" {
1039                continue; // non-clobberable bus; validate rejects this too
1040            }
1041            push_mcp_server_table(&mut s, name, &server.command, &server.args, &server.env);
1042        }
1043    }
1044    // Trust the project cwd so the boot dialog never renders. Codex keys
1045    // the table on the canonical absolute path of the directory it starts
1046    // in; fall back to the plain join when canonicalize fails (cwd not
1047    // yet created — the dialog would then appear and the wrapper's
1048    // auto-confirm backstop handles it).
1049    let project = compose
1050        .projects
1051        .iter()
1052        .find(|p| p.project.id == h.project)
1053        .expect("agent belongs to a loaded project");
1054    let cwd = compose.root.join(&project.project.cwd);
1055    let cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
1056    s.push_str(&format!(
1057        "\n[projects.{}]\ntrust_level = \"trusted\"\n",
1058        toml_str(&cwd.display().to_string())
1059    ));
1060    // Allow network from inside the workspace-write sandbox (the wrapper's
1061    // headless default `-s workspace-write`). Without it codex can't reach
1062    // github.com etc., so agents fall back to ChatGPT connector apps
1063    // (`codex_apps.github.*`) for git work — and those hit a per-tool
1064    // approval prompt teamctl can't pre-seed (the connector id is
1065    // account-specific) and would strand an unattended pane. With network
1066    // on, agents use plain `git`/`gh`/`curl`, the same tooling claude-code
1067    // agents already have. The filesystem stays sandboxed to the workspace
1068    // + temp dirs, so this is still tighter than claude-code (which isn't
1069    // network-sandboxed at all). Inert under `attended`/`--yolo` sandboxes.
1070    s.push_str("\n[sandbox_workspace_write]\nnetwork_access = true\n");
1071    Some(s)
1072}
1073
1074/// Write (or clear) the per-agent Codex `config.toml`. Mirrors
1075/// [`write_subagents_json`]: the scoped + full render paths both call it
1076/// so a `mcps:` edit flows into the agent at the next render. For
1077/// non-codex agents only the managed `config.toml` is removed — never the
1078/// home dir itself, which may hold codex session history worth keeping.
1079pub fn write_codex_config(
1080    compose: &Compose,
1081    h: AgentHandle<'_>,
1082    team_mcp_bin: &str,
1083) -> io::Result<()> {
1084    let dest = codex_home_dir(&compose.root, h.project, h.agent).join("config.toml");
1085    match render_codex_config(compose, h, team_mcp_bin) {
1086        Some(toml) => {
1087            if let Some(parent) = dest.parent() {
1088                std::fs::create_dir_all(parent)?;
1089            }
1090            std::fs::write(&dest, toml)
1091        }
1092        None => match std::fs::remove_file(&dest) {
1093            Ok(()) => Ok(()),
1094            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
1095            Err(e) => Err(e),
1096        },
1097    }
1098}
1099
1100/// Per-agent OpenCode `opencode.json` for `runtime: opencode` agents.
1101/// Returns `None` for every other runtime. OpenCode has no
1102/// `--mcp-config` flag — servers are read from the `mcp` object of the
1103/// config file `OPENCODE_CONFIG` points at — so this is the
1104/// opencode-shaped mirror of [`render_mcp`]'s JSON: the unconditional
1105/// `team` bus plus declared `mcps:` under the same `supports_mcp` gate
1106/// (render_mcp already warns when the gate drops them, so this stays
1107/// quiet). The file also carries:
1108/// - `instructions`: the absolute role-prompt path (opencode has no
1109///   system-prompt flag; absolute-path entries verified to load),
1110/// - `autoupdate: false`: the TUI otherwise upgrades the shared binary
1111///   in place mid-fleet (the wrapper also exports
1112///   `OPENCODE_DISABLE_AUTOUPDATE=1` — defense in depth, upstream has
1113///   open bugs about this key being ignored on some paths),
1114/// - `share: "disabled"`: agents must never leak conversations to
1115///   opencode's public share links.
1116///
1117/// HAZARD (verified on 1.17.13): opencode drops schema violations
1118/// SILENTLY — renaming `command` made the server vanish with no error —
1119/// so the tests pin the exact `type`/`command`/`environment`/`enabled`
1120/// key names.
1121pub fn render_opencode_config(
1122    compose: &Compose,
1123    h: AgentHandle<'_>,
1124    team_mcp_bin: &str,
1125) -> Option<String> {
1126    if h.spec.runtime != "opencode" {
1127        return None;
1128    }
1129    let mut mcp = serde_json::Map::new();
1130    mcp.insert(
1131        "team".into(),
1132        opencode_mcp_server(
1133            team_mcp_bin,
1134            &team_server_args(compose, h),
1135            &Default::default(),
1136        ),
1137    );
1138    if !h.spec.mcps.is_empty() && runtime_supports_mcp(compose, h) {
1139        for (name, server) in &h.spec.mcps {
1140            if name == "team" {
1141                continue; // non-clobberable bus; validate rejects this too
1142            }
1143            mcp.insert(
1144                name.clone(),
1145                opencode_mcp_server(&server.command, &server.args, &server.env),
1146            );
1147        }
1148    }
1149    let mut v = serde_json::json!({
1150        "mcp": mcp,
1151        "autoupdate": false,
1152        "share": "disabled",
1153    });
1154    if let Some(prompt) = system_prompt_path(compose, h) {
1155        v["instructions"] = serde_json::json!([prompt.display().to_string()]);
1156    }
1157    Some(serde_json::to_string_pretty(&v).expect("json"))
1158}
1159
1160/// One entry of opencode's `mcp` config object. Key names are load-
1161/// bearing: opencode silently drops entries that violate its schema,
1162/// so `type`/`command`/`environment`/`enabled` must be spelled exactly.
1163/// `command` is a single argv array (binary first), unlike the
1164/// command/args split of [`render_mcp`]'s JSON; `environment` is
1165/// emitted only when non-empty, values verbatim (no `${VAR}`
1166/// expansion in render).
1167fn opencode_mcp_server(
1168    command: &str,
1169    args: &[String],
1170    env: &std::collections::BTreeMap<String, String>,
1171) -> serde_json::Value {
1172    let mut argv = vec![command.to_string()];
1173    argv.extend(args.iter().cloned());
1174    let mut server = serde_json::json!({
1175        "type": "local",
1176        "command": argv,
1177        "enabled": true,
1178    });
1179    if !env.is_empty() {
1180        server["environment"] = serde_json::to_value(env).expect("json");
1181    }
1182    server
1183}
1184
1185/// Write (or clear) the per-agent OpenCode `opencode.json`. Mirrors
1186/// [`write_codex_config`]: the scoped + full render paths both call it
1187/// so a `mcps:` edit flows into the agent at the next render. For
1188/// non-opencode agents only the managed `opencode.json` is removed —
1189/// never the home dir itself, which holds the agent's session db.
1190pub fn write_opencode_config(
1191    compose: &Compose,
1192    h: AgentHandle<'_>,
1193    team_mcp_bin: &str,
1194) -> io::Result<()> {
1195    let dest = opencode_home_dir(&compose.root, h.project, h.agent).join("opencode.json");
1196    match render_opencode_config(compose, h, team_mcp_bin) {
1197        Some(json) => {
1198            if let Some(parent) = dest.parent() {
1199                std::fs::create_dir_all(parent)?;
1200            }
1201            std::fs::write(&dest, json)
1202        }
1203        None => match std::fs::remove_file(&dest) {
1204            Ok(()) => Ok(()),
1205            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
1206            Err(e) => Err(e),
1207        },
1208    }
1209}
1210
1211/// Append one `[mcp_servers.<name>]` table (plus its `.env` sub-table
1212/// when non-empty) in the shape codex reads from `config.toml`.
1213fn push_mcp_server_table(
1214    buf: &mut String,
1215    name: &str,
1216    command: &str,
1217    args: &[String],
1218    env: &std::collections::BTreeMap<String, String>,
1219) {
1220    buf.push_str(&format!("\n[mcp_servers.{}]\n", toml_key(name)));
1221    buf.push_str(&format!("command = {}\n", toml_str(command)));
1222    let args: Vec<String> = args.iter().map(|a| toml_str(a)).collect();
1223    buf.push_str(&format!("args = [{}]\n", args.join(", ")));
1224    if !env.is_empty() {
1225        buf.push_str(&format!("\n[mcp_servers.{}.env]\n", toml_key(name)));
1226        for (k, v) in env {
1227            buf.push_str(&format!("{} = {}\n", toml_key(k), toml_str(v)));
1228        }
1229    }
1230}
1231
1232/// Render a string as a TOML basic string (double-quoted). Backslash,
1233/// quote, and control characters are the only escapes basic strings
1234/// require; everything else passes through verbatim (values are not
1235/// interpolated, matching render's no-`${VAR}`-expansion rule).
1236fn toml_str(s: &str) -> String {
1237    let mut out = String::with_capacity(s.len() + 2);
1238    out.push('"');
1239    for c in s.chars() {
1240        match c {
1241            '"' => out.push_str("\\\""),
1242            '\\' => out.push_str("\\\\"),
1243            '\n' => out.push_str("\\n"),
1244            '\r' => out.push_str("\\r"),
1245            '\t' => out.push_str("\\t"),
1246            c if (c as u32) < 0x20 || c == '\u{7f}' => {
1247                out.push_str(&format!("\\u{:04X}", c as u32));
1248            }
1249            c => out.push(c),
1250        }
1251    }
1252    out.push('"');
1253    out
1254}
1255
1256/// Render a TOML key: bare when it fits TOML's bare-key charset, quoted
1257/// otherwise (server names and env keys come from operator YAML and
1258/// aren't constrained to bare-safe characters).
1259fn toml_key(k: &str) -> String {
1260    let bare = !k.is_empty()
1261        && k.chars()
1262            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
1263    if bare {
1264        k.to_string()
1265    } else {
1266        toml_str(k)
1267    }
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272    use super::*;
1273    use crate::compose::*;
1274    use std::collections::BTreeMap;
1275    use std::path::PathBuf;
1276
1277    fn fixture() -> Compose {
1278        let mut managers = BTreeMap::new();
1279        managers.insert(
1280            "mgr".into(),
1281            Agent {
1282                runtime: "claude-code".into(),
1283                model: Some("claude-opus-4-8".into()),
1284                role_prompt: Some(RolePrompt::Single(PathBuf::from("roles/mgr.md"))),
1285                permission_mode: Some("auto".into()),
1286                autonomy: "low_risk_only".into(),
1287                can_dm: vec![],
1288                can_broadcast: vec![],
1289                reports_to: None,
1290                on_rate_limit: None,
1291                effort: None,
1292                ultracode: false,
1293                interfaces: None,
1294                display_name: None,
1295                hooks: vec![],
1296                mcps: Default::default(),
1297                subagents: vec![],
1298                skills: vec![],
1299            },
1300        );
1301        Compose {
1302            root: PathBuf::from("/teamctl"),
1303            global: Global {
1304                version: crate::compose::SchemaVersion::new("2.0.0"),
1305                broker: Broker {
1306                    r#type: "sqlite".into(),
1307                    path: PathBuf::from("state/mailbox.db"),
1308                },
1309                supervisor: SupervisorCfg {
1310                    r#type: "tmux".into(),
1311                    tmux_prefix: "a-".into(),
1312                    drain_timeout_secs: 10,
1313                },
1314                budget: Default::default(),
1315                hitl: Default::default(),
1316                rate_limits: Default::default(),
1317                interfaces: vec![],
1318                projects: vec![],
1319                attachments: Default::default(),
1320            },
1321            projects: vec![Project {
1322                version: 2,
1323                project: ProjectMeta {
1324                    id: "hello".into(),
1325                    name: "Hello".into(),
1326                    cwd: PathBuf::from("/teamctl/examples/hello-team"),
1327                },
1328                channels: vec![],
1329                managers,
1330                workers: Default::default(),
1331                interfaces: None,
1332            }],
1333        }
1334    }
1335
1336    #[test]
1337    fn env_contains_agent_id_and_mailbox() {
1338        let c = fixture();
1339        let h = c.agents().next().unwrap();
1340        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1341        assert!(env.contains("AGENT_ID=hello:mgr"));
1342        assert!(env.contains("TEAMCTL_MAILBOX=/teamctl/state/mailbox.db"));
1343        assert!(env.contains("TMUX_SESSION=a-hello-mgr"));
1344    }
1345
1346    #[test]
1347    fn env_emits_claude_session_id_and_name_for_claude_code_runtime() {
1348        // T-118: claude-code agents get deterministic UUIDv5 session
1349        // ids in their env so the wrapper can pass `--session-id` +
1350        // `-n` and resume the conversation across restarts.
1351        let c = fixture();
1352        let h = c.agents().next().unwrap();
1353        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1354        let expected_id = crate::session::derive_session_id(h.project, h.agent);
1355        assert!(
1356            env.contains(&format!("CLAUDE_SESSION_ID={expected_id}\n")),
1357            "env was: {env}"
1358        );
1359        assert!(
1360            env.contains("CLAUDE_SESSION_NAME=teamctl:hello:mgr\n"),
1361            "env was: {env}"
1362        );
1363    }
1364
1365    #[test]
1366    fn env_omits_claude_session_vars_for_non_claude_runtimes() {
1367        // Other runtimes (codex, gemini) don't recognize claude's
1368        // `--session-id` flag — their wrapper arms must not see these
1369        // vars. Pin the gate so a future render refactor can't leak
1370        // them into every runtime.
1371        let mut c = fixture();
1372        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1373        let h = c.agents().next().unwrap();
1374        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1375        assert!(
1376            !env.contains("CLAUDE_SESSION_ID="),
1377            "non-claude runtime must not get session id: {env}"
1378        );
1379        assert!(
1380            !env.contains("CLAUDE_SESSION_NAME="),
1381            "non-claude runtime must not get session name: {env}"
1382        );
1383    }
1384
1385    #[test]
1386    fn env_pins_teamctl_root_to_compose_root() {
1387        // Regression: when project.cwd is a relative path (e.g. `..`),
1388        // the wrapper used to fall back to it for `--root`, which
1389        // resolves against the post-cd cwd and points at the wrong
1390        // directory. Rendering an absolute TEAMCTL_ROOT pins
1391        // `teamctl --root` to the compose root regardless of cwd.
1392        let c = fixture();
1393        let h = c.agents().next().unwrap();
1394        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1395        assert!(env.contains("TEAMCTL_ROOT=/teamctl\n"), "env was: {env}");
1396    }
1397
1398    #[test]
1399    fn env_omits_effort_when_unset() {
1400        let c = fixture();
1401        let h = c.agents().next().unwrap();
1402        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1403        assert!(!env.contains("EFFORT="), "env was: {env}");
1404    }
1405
1406    #[test]
1407    fn env_emits_effort_when_set() {
1408        let mut c = fixture();
1409        c.projects[0].managers.get_mut("mgr").unwrap().effort = Some(EffortLevel::Max);
1410        let h = c.agents().next().unwrap();
1411        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1412        assert!(env.contains("EFFORT=max\n"), "env was: {env}");
1413    }
1414
1415    #[test]
1416    fn mcp_json_parses_back() {
1417        let c = fixture();
1418        let h = c.agents().next().unwrap();
1419        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1420        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
1421        assert_eq!(
1422            v["mcpServers"]["team"]["command"],
1423            "/usr/local/bin/team-mcp"
1424        );
1425        assert_eq!(
1426            v["mcpServers"]["team"]["args"][1].as_str().unwrap(),
1427            "hello:mgr"
1428        );
1429    }
1430
1431    #[test]
1432    fn mcp_json_threads_tmux_prefix_from_compose() {
1433        // T-109: compact_self routes its tmux send-keys to
1434        // `<prefix><project>-<agent>` and reads the prefix from a CLI arg
1435        // (default `t-` only fits a stock team). Render must surface the
1436        // configured prefix so teams overriding it (e.g. `a-` here) get
1437        // their pane resolved correctly.
1438        let c = fixture();
1439        let h = c.agents().next().unwrap();
1440        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1441        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
1442        let args: Vec<&str> = v["mcpServers"]["team"]["args"]
1443            .as_array()
1444            .unwrap()
1445            .iter()
1446            .map(|a| a.as_str().unwrap())
1447            .collect();
1448        let i = args.iter().position(|a| *a == "--tmux-prefix").expect(
1449            "render_mcp must emit --tmux-prefix so compact_self resolves the caller's pane",
1450        );
1451        assert_eq!(
1452            args[i + 1],
1453            "a-",
1454            "prefix must come from compose, not the default"
1455        );
1456    }
1457
1458    /// Build a `McpServer` test value tersely.
1459    fn server(command: &str, args: &[&str]) -> McpServer {
1460        McpServer {
1461            command: command.into(),
1462            args: args.iter().map(|s| s.to_string()).collect(),
1463            env: Default::default(),
1464        }
1465    }
1466
1467    #[test]
1468    fn mcp_json_includes_declared_servers_alongside_team() {
1469        // #383 Phase 4: a declared server lands in `mcpServers` next to
1470        // the built-in `team` server, with command/args/env passed
1471        // through verbatim (no `${VAR}` expansion in render).
1472        let mut c = fixture();
1473        let mut mcps = BTreeMap::new();
1474        let mut gh = server("npx", &["-y", "@modelcontextprotocol/server-github"]);
1475        gh.env
1476            .insert("GITHUB_TOKEN".into(), "${GITHUB_TOKEN}".into());
1477        mcps.insert("github".into(), gh);
1478        c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
1479
1480        let h = c.agents().next().unwrap();
1481        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1482        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
1483
1484        // Built-in team server survives untouched.
1485        assert_eq!(
1486            v["mcpServers"]["team"]["command"],
1487            "/usr/local/bin/team-mcp"
1488        );
1489        // Declared server present with verbatim fields.
1490        assert_eq!(v["mcpServers"]["github"]["command"], "npx");
1491        assert_eq!(v["mcpServers"]["github"]["args"][0], "-y");
1492        assert_eq!(
1493            v["mcpServers"]["github"]["env"]["GITHUB_TOKEN"], "${GITHUB_TOKEN}",
1494            "env values must pass through verbatim — claude expands ${{VAR}} at launch"
1495        );
1496        assert_eq!(v["mcpServers"].as_object().unwrap().len(), 2);
1497    }
1498
1499    #[test]
1500    fn mcp_json_team_server_is_non_clobberable() {
1501        // #383 Phase 4: a declared server literally named `team` must not
1502        // shadow the built-in mailbox bus — render skips it (validate also
1503        // rejects it). The `team` entry keeps the built-in command.
1504        let mut c = fixture();
1505        let mut mcps = BTreeMap::new();
1506        mcps.insert("team".into(), server("evil-team", &[]));
1507        mcps.insert("github".into(), server("npx", &[]));
1508        c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
1509
1510        let h = c.agents().next().unwrap();
1511        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1512        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
1513
1514        assert_eq!(
1515            v["mcpServers"]["team"]["command"], "/usr/local/bin/team-mcp",
1516            "built-in team server must not be clobbered by a declared `team`"
1517        );
1518        assert!(v["mcpServers"]["github"].is_object());
1519        assert_eq!(
1520            v["mcpServers"].as_object().unwrap().len(),
1521            2,
1522            "the declared `team` is dropped, not added as a third entry"
1523        );
1524    }
1525
1526    #[test]
1527    fn mcp_json_unchanged_when_no_servers_declared() {
1528        // #383 Phase 4: empty `mcps` (the default) → only the built-in
1529        // team server, exactly as before this feature.
1530        let c = fixture();
1531        let h = c.agents().next().unwrap();
1532        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1533        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
1534        let servers = v["mcpServers"].as_object().unwrap();
1535        assert_eq!(servers.len(), 1);
1536        assert!(servers.contains_key("team"));
1537    }
1538
1539    #[test]
1540    fn mcp_json_skips_declared_servers_on_runtime_without_mcp_support() {
1541        // #383 Phase 4: declared servers render only for runtimes whose
1542        // descriptor sets `supports_mcp`. A custom runtime that opts out
1543        // gets the team bus (unconditional) but not the declared servers.
1544        let tmp = tempfile::tempdir().unwrap();
1545        std::fs::create_dir_all(tmp.path().join("runtimes")).unwrap();
1546        std::fs::write(
1547            tmp.path().join("runtimes/codex.yaml"),
1548            "binary: codex\nsupports_mcp: false\n",
1549        )
1550        .unwrap();
1551
1552        let mut c = fixture();
1553        c.root = tmp.path().to_path_buf();
1554        {
1555            let m = c.projects[0].managers.get_mut("mgr").unwrap();
1556            m.runtime = "codex".into();
1557            let mut mcps = BTreeMap::new();
1558            mcps.insert("github".into(), server("npx", &[]));
1559            m.mcps = mcps;
1560        }
1561
1562        let h = c.agents().next().unwrap();
1563        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1564        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
1565        let servers = v["mcpServers"].as_object().unwrap();
1566        assert!(servers.contains_key("team"), "team bus stays unconditional");
1567        assert!(
1568            !servers.contains_key("github"),
1569            "declared server skipped when runtime lacks supports_mcp"
1570        );
1571        assert_eq!(servers.len(), 1);
1572    }
1573
1574    #[test]
1575    fn env_emits_codex_home_for_codex_runtime() {
1576        // Codex has no --mcp-config flag; the wrapper's codex arm reads
1577        // CODEX_HOME from the env file and exports it so codex finds the
1578        // rendered [mcp_servers.*] config.toml.
1579        let mut c = fixture();
1580        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1581        let h = c.agents().next().unwrap();
1582        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1583        assert!(
1584            env.contains("CODEX_HOME=/teamctl/state/codex-home/hello-mgr\n"),
1585            "env was: {env}"
1586        );
1587    }
1588
1589    #[test]
1590    fn env_omits_codex_home_for_non_codex_runtimes() {
1591        // Only the codex arm consumes CODEX_HOME; leaking it into other
1592        // runtimes' envs would relocate their state if a same-named knob
1593        // ever appears. Pin the gate for claude-code and gemini both.
1594        for runtime in ["claude-code", "gemini"] {
1595            let mut c = fixture();
1596            c.projects[0].managers.get_mut("mgr").unwrap().runtime = runtime.into();
1597            let h = c.agents().next().unwrap();
1598            let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1599            assert!(
1600                !env.contains("CODEX_HOME="),
1601                "{runtime} must not get CODEX_HOME: {env}"
1602            );
1603        }
1604    }
1605
1606    #[test]
1607    fn codex_config_present_with_team_server_for_codex_runtime() {
1608        let mut c = fixture();
1609        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1610        let h = c.agents().next().unwrap();
1611        let toml = render_codex_config(&c, h, "/usr/local/bin/team-mcp")
1612            .expect("codex agent must get a config.toml");
1613        assert!(toml.contains("[mcp_servers.team]"), "toml was: {toml}");
1614        assert!(
1615            toml.contains("command = \"/usr/local/bin/team-mcp\""),
1616            "toml was: {toml}"
1617        );
1618        // Same args as the JSON transport — the shared team_server_args
1619        // helper is the single source of truth.
1620        assert!(
1621            toml.contains("\"--agent-id\", \"hello:mgr\""),
1622            "toml was: {toml}"
1623        );
1624        assert!(
1625            toml.contains("\"--tmux-prefix\", \"a-\""),
1626            "toml was: {toml}"
1627        );
1628    }
1629
1630    #[test]
1631    fn codex_config_absent_for_non_codex_runtimes() {
1632        // claude/gemini get their MCP servers via the JSON file; a stray
1633        // config.toml would be dead weight on disk.
1634        let c = fixture();
1635        let h = c.agents().next().unwrap();
1636        assert!(render_codex_config(&c, h, "/usr/local/bin/team-mcp").is_none());
1637    }
1638
1639    #[test]
1640    fn codex_config_preapproves_team_tools_and_trusts_cwd() {
1641        // Codex prompts per MCP tool even under `-a never`, and persists
1642        // human answers INTO config.toml — which this render rewrites on
1643        // every up/reload. Without these seeded tables an unattended
1644        // codex pane strands on its first inbox_peek (observed live on
1645        // codex-cli 0.144.3). Shapes verbatim-match what codex itself
1646        // writes.
1647        let mut c = fixture();
1648        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1649        let h = c.agents().next().unwrap();
1650        let toml = render_codex_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1651        for tool in super::CODEX_PREAPPROVED_TEAM_TOOLS {
1652            assert!(
1653                toml.contains(&format!("[mcp_servers.team.tools.{tool}]")),
1654                "missing pre-approval table for {tool}: {toml}"
1655            );
1656        }
1657        assert!(
1658            toml.contains("approval_mode = \"approve\""),
1659            "toml was: {toml}"
1660        );
1661        assert!(toml.contains("[projects."), "toml was: {toml}");
1662        assert!(
1663            toml.contains("trust_level = \"trusted\""),
1664            "toml was: {toml}"
1665        );
1666        // Network is on inside the workspace-write sandbox so agents use
1667        // plain git/gh instead of connector apps that would prompt.
1668        assert!(
1669            toml.contains("[sandbox_workspace_write]\nnetwork_access = true"),
1670            "toml must enable sandbox network: {toml}"
1671        );
1672    }
1673
1674    #[test]
1675    fn codex_config_includes_declared_servers() {
1676        // Declared `mcps:` land as their own [mcp_servers.<name>] tables
1677        // next to the team bus, mirroring the JSON transport's merge.
1678        // The `${GITHUB_TOKEN}` placeholder passes through literally on
1679        // purpose (expanding it here would write the resolved secret to
1680        // disk) — but unlike claude, codex does NOT interpolate it at
1681        // launch, so validate warns (McpEnvInterpolationUnsupported).
1682        let mut c = fixture();
1683        {
1684            let m = c.projects[0].managers.get_mut("mgr").unwrap();
1685            m.runtime = "codex".into();
1686            let mut gh = server("npx", &["-y", "@modelcontextprotocol/server-github"]);
1687            gh.env
1688                .insert("GITHUB_TOKEN".into(), "${GITHUB_TOKEN}".into());
1689            let mut mcps = BTreeMap::new();
1690            mcps.insert("github".into(), gh);
1691            m.mcps = mcps;
1692        }
1693        let h = c.agents().next().unwrap();
1694        let toml = render_codex_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1695        assert!(toml.contains("[mcp_servers.team]"), "toml was: {toml}");
1696        assert!(toml.contains("[mcp_servers.github]"), "toml was: {toml}");
1697        assert!(toml.contains("command = \"npx\""), "toml was: {toml}");
1698        assert!(
1699            toml.contains("args = [\"-y\", \"@modelcontextprotocol/server-github\"]"),
1700            "toml was: {toml}"
1701        );
1702        assert!(
1703            toml.contains("[mcp_servers.github.env]\nGITHUB_TOKEN = \"${GITHUB_TOKEN}\""),
1704            "env must land in a sub-table, verbatim — codex won't expand it: {toml}"
1705        );
1706    }
1707
1708    #[test]
1709    fn codex_config_escapes_toml_strings() {
1710        // A quote or backslash in an env value must not break the TOML —
1711        // basic strings escape both (team-core hand-renders, no toml crate).
1712        let mut c = fixture();
1713        {
1714            let m = c.projects[0].managers.get_mut("mgr").unwrap();
1715            m.runtime = "codex".into();
1716            let mut srv = server("run", &[]);
1717            srv.env
1718                .insert("TRICKY".into(), "say \"hi\" C:\\path".into());
1719            let mut mcps = BTreeMap::new();
1720            mcps.insert("x".into(), srv);
1721            m.mcps = mcps;
1722        }
1723        let h = c.agents().next().unwrap();
1724        let toml = render_codex_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1725        assert!(
1726            toml.contains("TRICKY = \"say \\\"hi\\\" C:\\\\path\""),
1727            "toml was: {toml}"
1728        );
1729    }
1730
1731    #[test]
1732    fn write_codex_config_writes_then_clears_stale() {
1733        let dir = tempfile::tempdir().unwrap();
1734        let mut c = fixture();
1735        c.root = dir.path().to_path_buf();
1736        let dest = codex_home_dir(&c.root, "hello", "mgr").join("config.toml");
1737
1738        // codex runtime → config materialized under the per-agent home.
1739        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1740        let h = c.agents().next().unwrap();
1741        write_codex_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1742        assert!(dest.exists(), "codex config.toml should be written");
1743
1744        // Runtime switched away → the managed config is removed (the home
1745        // dir itself survives: it may hold codex session history).
1746        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "claude-code".into();
1747        let h = c.agents().next().unwrap();
1748        write_codex_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1749        assert!(!dest.exists(), "stale codex config.toml should be removed");
1750    }
1751
1752    #[test]
1753    fn env_emits_opencode_vars_for_opencode_runtime() {
1754        // The wrapper's opencode arm reads OPENCODE_DB (per-agent
1755        // session sqlite db — the resume-probe target) and
1756        // OPENCODE_CONFIG (per-agent json carrying the MCP servers +
1757        // instructions) from the env file.
1758        let mut c = fixture();
1759        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "opencode".into();
1760        let h = c.agents().next().unwrap();
1761        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1762        assert!(
1763            env.contains("OPENCODE_DB=/teamctl/state/opencode-home/hello-mgr/agent.db\n"),
1764            "env was: {env}"
1765        );
1766        assert!(
1767            env.contains("OPENCODE_CONFIG=/teamctl/state/opencode-home/hello-mgr/opencode.json\n"),
1768            "env was: {env}"
1769        );
1770    }
1771
1772    #[test]
1773    fn env_omits_opencode_vars_for_non_opencode_runtimes() {
1774        // Only the opencode arm consumes these; leaking them into other
1775        // runtimes' envs would relocate their state if a same-named
1776        // knob ever appears. Pin the gate for every other runtime.
1777        for runtime in ["claude-code", "codex", "gemini"] {
1778            let mut c = fixture();
1779            c.projects[0].managers.get_mut("mgr").unwrap().runtime = runtime.into();
1780            let h = c.agents().next().unwrap();
1781            let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1782            assert!(
1783                !env.contains("OPENCODE_DB=") && !env.contains("OPENCODE_CONFIG="),
1784                "{runtime} must not get opencode vars: {env}"
1785            );
1786        }
1787    }
1788
1789    #[test]
1790    fn opencode_config_present_with_team_server_for_opencode_runtime() {
1791        // Pin the EXACT mcp entry key names (`type`/`command`/
1792        // `environment`/`enabled`): opencode silently drops entries
1793        // that violate its schema (verified — renaming `command` made
1794        // the server vanish with no error), so a drifted key here would
1795        // sever the mailbox with zero diagnostics.
1796        let mut c = fixture();
1797        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "opencode".into();
1798        let h = c.agents().next().unwrap();
1799        let json = render_opencode_config(&c, h, "/usr/local/bin/team-mcp")
1800            .expect("opencode agent must get an opencode.json");
1801        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1802        let team = &v["mcp"]["team"];
1803        assert_eq!(team["type"], "local");
1804        assert_eq!(team["enabled"], true);
1805        // Single argv array (binary first) — same args as the JSON
1806        // transport, from the shared team_server_args helper.
1807        assert_eq!(team["command"][0], "/usr/local/bin/team-mcp");
1808        assert_eq!(team["command"][1], "--agent-id");
1809        assert_eq!(team["command"][2], "hello:mgr");
1810        let argv: Vec<&str> = team["command"]
1811            .as_array()
1812            .unwrap()
1813            .iter()
1814            .map(|a| a.as_str().unwrap())
1815            .collect();
1816        let i = argv.iter().position(|a| *a == "--tmux-prefix").unwrap();
1817        assert_eq!(argv[i + 1], "a-");
1818        // The opt-outs the wrapper depends on.
1819        assert_eq!(v["autoupdate"], false);
1820        assert_eq!(v["share"], "disabled");
1821        // Role prompt rides `instructions` as an absolute path.
1822        assert_eq!(
1823            v["instructions"],
1824            serde_json::json!(["/teamctl/roles/mgr.md"])
1825        );
1826    }
1827
1828    #[test]
1829    fn opencode_config_absent_for_non_opencode_runtimes() {
1830        // Other runtimes get their MCP servers via the JSON file (or
1831        // the codex TOML); a stray opencode.json would be dead weight.
1832        let c = fixture();
1833        let h = c.agents().next().unwrap();
1834        assert!(render_opencode_config(&c, h, "/usr/local/bin/team-mcp").is_none());
1835    }
1836
1837    #[test]
1838    fn opencode_config_omits_instructions_without_role_prompt() {
1839        // No role_prompt → no `instructions` key at all (an empty list
1840        // or a blank path would be schema noise opencode may silently
1841        // choke on).
1842        let mut c = fixture();
1843        {
1844            let m = c.projects[0].managers.get_mut("mgr").unwrap();
1845            m.runtime = "opencode".into();
1846            m.role_prompt = None;
1847        }
1848        let h = c.agents().next().unwrap();
1849        let json = render_opencode_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1850        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1851        assert!(v.get("instructions").is_none(), "json was: {json}");
1852    }
1853
1854    #[test]
1855    fn opencode_config_includes_declared_servers() {
1856        // Declared `mcps:` land as their own `mcp` entries next to the
1857        // team bus, mirroring the JSON transport's merge. env values
1858        // pass through verbatim under the exact `environment` key.
1859        let mut c = fixture();
1860        {
1861            let m = c.projects[0].managers.get_mut("mgr").unwrap();
1862            m.runtime = "opencode".into();
1863            let mut gh = server("npx", &["-y", "@modelcontextprotocol/server-github"]);
1864            gh.env
1865                .insert("GITHUB_TOKEN".into(), "${GITHUB_TOKEN}".into());
1866            let mut mcps = BTreeMap::new();
1867            mcps.insert("github".into(), gh);
1868            m.mcps = mcps;
1869        }
1870        let h = c.agents().next().unwrap();
1871        let json = render_opencode_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1872        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1873        let gh = &v["mcp"]["github"];
1874        assert_eq!(gh["type"], "local");
1875        assert_eq!(gh["enabled"], true);
1876        assert_eq!(
1877            gh["command"],
1878            serde_json::json!(["npx", "-y", "@modelcontextprotocol/server-github"])
1879        );
1880        assert_eq!(
1881            gh["environment"]["GITHUB_TOKEN"], "${GITHUB_TOKEN}",
1882            "env values must pass through verbatim under `environment`"
1883        );
1884        assert_eq!(v["mcp"].as_object().unwrap().len(), 2);
1885    }
1886
1887    #[test]
1888    fn opencode_config_team_server_is_non_clobberable() {
1889        // Same guarantee as the JSON + TOML transports: a declared
1890        // server literally named `team` must not shadow the mailbox bus.
1891        let mut c = fixture();
1892        {
1893            let m = c.projects[0].managers.get_mut("mgr").unwrap();
1894            m.runtime = "opencode".into();
1895            let mut mcps = BTreeMap::new();
1896            mcps.insert("team".into(), server("evil-team", &[]));
1897            m.mcps = mcps;
1898        }
1899        let h = c.agents().next().unwrap();
1900        let json = render_opencode_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1901        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1902        assert_eq!(
1903            v["mcp"]["team"]["command"][0], "/usr/local/bin/team-mcp",
1904            "built-in team server must not be clobbered by a declared `team`"
1905        );
1906        assert_eq!(v["mcp"].as_object().unwrap().len(), 1);
1907    }
1908
1909    #[test]
1910    fn write_opencode_config_writes_then_clears_stale() {
1911        let dir = tempfile::tempdir().unwrap();
1912        let mut c = fixture();
1913        c.root = dir.path().to_path_buf();
1914        let dest = opencode_home_dir(&c.root, "hello", "mgr").join("opencode.json");
1915
1916        // opencode runtime → config materialized under the per-agent home.
1917        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "opencode".into();
1918        let h = c.agents().next().unwrap();
1919        write_opencode_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1920        assert!(dest.exists(), "opencode.json should be written");
1921
1922        // Runtime switched away → the managed config is removed (the home
1923        // dir itself survives: it holds the agent's session db).
1924        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "claude-code".into();
1925        let h = c.agents().next().unwrap();
1926        write_opencode_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1927        assert!(!dest.exists(), "stale opencode.json should be removed");
1928    }
1929
1930    #[test]
1931    fn env_points_at_source_for_single_role_prompt() {
1932        let c = fixture();
1933        let h = c.agents().next().unwrap();
1934        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1935        assert!(
1936            env.contains("SYSTEM_PROMPT_PATH=/teamctl/roles/mgr.md\n"),
1937            "env was: {env}"
1938        );
1939    }
1940
1941    #[test]
1942    fn env_points_at_concat_path_for_multi_role_prompt() {
1943        let mut c = fixture();
1944        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
1945            Some(RolePrompt::Multiple(vec![
1946                PathBuf::from("roles/_base.md"),
1947                PathBuf::from("roles/mgr.md"),
1948            ]));
1949        let h = c.agents().next().unwrap();
1950        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1951        assert!(
1952            env.contains("SYSTEM_PROMPT_PATH=/teamctl/state/role_prompts/hello-mgr.md\n"),
1953            "env was: {env}"
1954        );
1955    }
1956
1957    #[test]
1958    fn write_role_prompt_concat_is_noop_for_single() {
1959        let dir = tempfile::tempdir().unwrap();
1960        let mut c = fixture();
1961        c.root = dir.path().to_path_buf();
1962        let h = c.agents().next().unwrap();
1963        write_role_prompt_concat(&c, h).unwrap();
1964        assert!(
1965            !role_prompt_concat_path(&c.root, h.project, h.agent).exists(),
1966            "single-form role_prompt should not produce a concat file"
1967        );
1968    }
1969
1970    #[test]
1971    fn write_role_prompt_concat_joins_in_declared_order() {
1972        let dir = tempfile::tempdir().unwrap();
1973        let root = dir.path();
1974        std::fs::create_dir_all(root.join("roles")).unwrap();
1975        std::fs::write(root.join("roles/_base.md"), "BASE").unwrap();
1976        std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
1977
1978        let mut c = fixture();
1979        c.root = root.to_path_buf();
1980        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
1981            Some(RolePrompt::Multiple(vec![
1982                PathBuf::from("roles/_base.md"),
1983                PathBuf::from("roles/mgr.md"),
1984            ]));
1985        let h = c.agents().next().unwrap();
1986        write_role_prompt_concat(&c, h).unwrap();
1987
1988        let dest = role_prompt_concat_path(root, h.project, h.agent);
1989        let got = std::fs::read_to_string(&dest).unwrap();
1990        assert_eq!(got, "BASE\n\n—\n\nMGR");
1991    }
1992
1993    #[test]
1994    fn write_role_prompt_concat_reflects_source_edits() {
1995        // Owner-flagged: editing a source file must show up at the next
1996        // render. We re-write unconditionally rather than caching.
1997        let dir = tempfile::tempdir().unwrap();
1998        let root = dir.path();
1999        std::fs::create_dir_all(root.join("roles")).unwrap();
2000        std::fs::write(root.join("roles/_base.md"), "v1").unwrap();
2001        std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
2002
2003        let mut c = fixture();
2004        c.root = root.to_path_buf();
2005        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
2006            Some(RolePrompt::Multiple(vec![
2007                PathBuf::from("roles/_base.md"),
2008                PathBuf::from("roles/mgr.md"),
2009            ]));
2010        let h = c.agents().next().unwrap();
2011        write_role_prompt_concat(&c, h).unwrap();
2012
2013        std::fs::write(root.join("roles/_base.md"), "v2").unwrap();
2014        let h = c.agents().next().unwrap();
2015        write_role_prompt_concat(&c, h).unwrap();
2016
2017        let dest = role_prompt_concat_path(root, h.project, h.agent);
2018        let got = std::fs::read_to_string(&dest).unwrap();
2019        assert_eq!(got, "v2\n\n—\n\nMGR");
2020    }
2021
2022    #[test]
2023    fn claude_settings_present_for_claude_code() {
2024        // T-189: claude-code agents get a wrapper-managed settings
2025        // file with a PreToolUse deny hook for synchronous-prompt
2026        // tools that would otherwise strand a headless pane.
2027        let c = fixture();
2028        let h = c.agents().next().unwrap();
2029        let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
2030        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
2031        let pre = &v["hooks"]["PreToolUse"][0];
2032        assert_eq!(
2033            pre["matcher"].as_str().unwrap(),
2034            "AskUserQuestion|EnterPlanMode|ExitPlanMode"
2035        );
2036        let cmd = pre["hooks"][0]["command"].as_str().unwrap();
2037        assert!(
2038            cmd.contains(r#""permissionDecision":"deny""#),
2039            "deny verdict missing from hook command: {cmd}"
2040        );
2041        assert!(
2042            cmd.contains("Interactive prompts are disabled"),
2043            "systemMessage missing from hook command: {cmd}"
2044        );
2045    }
2046
2047    #[test]
2048    fn claude_settings_pre_trust_all_project_mcp_servers() {
2049        // #421: the rendered settings carry `enableAllProjectMcpServers: true`
2050        // at the top level so a headless agent never freezes on Claude's "New
2051        // MCP server found in this project" prompt (no human to confirm it).
2052        // Attended sessions skip `--settings` entirely, so this only affects
2053        // unattended panes; non-claude runtimes get no settings file at all
2054        // (covered by `claude_settings_absent_for_non_claude_runtimes`).
2055        let c = fixture();
2056        let h = c.agents().next().unwrap();
2057        let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
2058        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
2059        assert_eq!(
2060            v["enableAllProjectMcpServers"],
2061            serde_json::Value::Bool(true),
2062            "headless settings must pre-trust project MCP servers: {s}"
2063        );
2064    }
2065
2066    #[test]
2067    fn claude_settings_absent_for_non_claude_runtimes() {
2068        // codex/gemini don't read claude settings; the file would be
2069        // dead weight and a confusing artifact on disk.
2070        let mut c = fixture();
2071        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
2072        let h = c.agents().next().unwrap();
2073        assert!(render_claude_settings(&c, h).is_none());
2074    }
2075
2076    #[test]
2077    fn claude_settings_absent_when_non_claude_agent_opts_into_ultracode() {
2078        // #461: a declared `ultracode: true` on a non-claude runtime is a
2079        // no-op — the whole settings file is skipped, so the opt-in must
2080        // never leak into a rendered artifact. Pins that the early-return
2081        // None survives even with the opt-in set (the warn fires; the
2082        // contract is the None).
2083        let mut c = fixture();
2084        {
2085            let m = c.projects[0].managers.get_mut("mgr").unwrap();
2086            m.runtime = "codex".into();
2087            m.ultracode = true;
2088        }
2089        let h = c.agents().next().unwrap();
2090        assert!(render_claude_settings(&c, h).is_none());
2091    }
2092
2093    #[test]
2094    fn declared_hook_merges_alongside_deny_hook() {
2095        // #383 Phase 2 + #428: a per-agent PreToolUse hook is appended
2096        // AFTER the built-ins in the same bucket — the deny hook keeps slot
2097        // 0, the #428 heartbeat touch sits at slot 1, and the declared hook
2098        // lands at slot 2 with its command resolved to an absolute path.
2099        let mut c = fixture();
2100        c.projects[0].managers.get_mut("mgr").unwrap().hooks = vec![HookSpec {
2101            event: "PreToolUse".into(),
2102            matcher: Some("Bash".into()),
2103            command: PathBuf::from("hooks/guard.sh"),
2104        }];
2105        let h = c.agents().next().unwrap();
2106        let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
2107        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
2108        let pre = v["hooks"]["PreToolUse"].as_array().unwrap();
2109        assert_eq!(
2110            pre.len(),
2111            3,
2112            "deny hook + #428 heartbeat touch + declared hook expected"
2113        );
2114        // Built-in deny hook survives in slot 0.
2115        assert_eq!(
2116            pre[0]["matcher"].as_str().unwrap(),
2117            "AskUserQuestion|EnterPlanMode|ExitPlanMode"
2118        );
2119        assert!(pre[0]["hooks"][0]["command"]
2120            .as_str()
2121            .unwrap()
2122            .contains(r#""permissionDecision":"deny""#));
2123        // #428 heartbeat touch at slot 1 (match-all, no matcher).
2124        assert!(
2125            pre[1].get("matcher").is_none(),
2126            "heartbeat touch must be match-all: {}",
2127            pre[1]
2128        );
2129        // Declared hook appended after the built-ins.
2130        assert_eq!(pre[2]["matcher"].as_str().unwrap(), "Bash");
2131        assert_eq!(pre[2]["hooks"][0]["type"].as_str().unwrap(), "command");
2132        assert_eq!(
2133            pre[2]["hooks"][0]["command"].as_str().unwrap(),
2134            "/teamctl/hooks/guard.sh"
2135        );
2136    }
2137
2138    #[test]
2139    fn claude_settings_emits_ultracode_when_set() {
2140        // #461: opting an agent into ultracode renders `"ultracode": true`
2141        // into its Claude Code settings JSON — the file the wrapper passes
2142        // via `--settings`.
2143        let mut c = fixture();
2144        c.projects[0].managers.get_mut("mgr").unwrap().ultracode = true;
2145        let h = c.agents().next().unwrap();
2146        let v: serde_json::Value =
2147            serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
2148        assert_eq!(v["ultracode"], true);
2149    }
2150
2151    #[test]
2152    fn claude_settings_omits_ultracode_when_unset() {
2153        // #461: with ultracode left at its default (`false`), the key is
2154        // absent entirely — not present-and-false — so the settings shape is
2155        // byte-identical for agents that don't opt in.
2156        let c = fixture();
2157        let h = c.agents().next().unwrap();
2158        let v: serde_json::Value =
2159            serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
2160        assert!(v.get("ultracode").is_none());
2161    }
2162
2163    #[test]
2164    fn default_hooks_are_deny_plus_heartbeat_buckets() {
2165        // #383 Phase 2 + #428 + #430 + #431: with no compose-declared hooks,
2166        // the settings file renders exactly the built-in default buckets: the
2167        // `PreToolUse` deny hook, the #428 activity-heartbeat hooks, the #430
2168        // `SessionStart` boot-context hook, and the #431 `StopFailure`
2169        // rate-limit marker, and nothing else. Asserted as an exact key-set
2170        // (not a raw count) so each future built-in extends the set
2171        // deterministically instead of racing on a number.
2172        let c = fixture();
2173        let h = c.agents().next().unwrap();
2174        let v: serde_json::Value =
2175            serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
2176        let hooks = v["hooks"].as_object().unwrap();
2177        let keys: std::collections::BTreeSet<&str> = hooks.keys().map(String::as_str).collect();
2178        assert_eq!(
2179            keys,
2180            [
2181                "PreToolUse",
2182                "SessionStart",
2183                "Stop",
2184                "StopFailure",
2185                "UserPromptSubmit"
2186            ]
2187            .into_iter()
2188            .collect::<std::collections::BTreeSet<_>>(),
2189            "exact set of built-in default hook buckets expected with no declared hooks"
2190        );
2191        // PreToolUse holds the deny hook (slot 0) + the heartbeat touch.
2192        assert_eq!(
2193            hooks["PreToolUse"].as_array().unwrap().len(),
2194            2,
2195            "deny hook + heartbeat touch expected"
2196        );
2197        // StopFailure holds the heartbeat clear (slot 0) + the #431 rate-limit
2198        // marker (slot 1): slot 0 is the match-all `rm -f`, slot 1 carries the
2199        // `rate_limit` matcher and the `rl-hit` command.
2200        let stop_failure = hooks["StopFailure"].as_array().unwrap();
2201        assert_eq!(
2202            stop_failure.len(),
2203            2,
2204            "heartbeat clear + rate-limit marker expected"
2205        );
2206        assert!(
2207            stop_failure[0].get("matcher").is_none(),
2208            "StopFailure slot 0 should be the match-all heartbeat clear"
2209        );
2210        // #439: the heartbeat clear now records LASTSEEN before removing the
2211        // marker, so the command leads with `touch ` and still rm's the marker.
2212        let clear_cmd = stop_failure[0]["hooks"][0]["command"].as_str().unwrap();
2213        assert!(
2214            clear_cmd.starts_with("touch ") && clear_cmd.contains(" && rm -f "),
2215            "StopFailure slot 0 should touch lastseen then rm the marker: {clear_cmd}"
2216        );
2217        assert_eq!(
2218            stop_failure[1]["matcher"].as_str().unwrap(),
2219            "rate_limit",
2220            "StopFailure slot 1 should scope to rate-limit stops"
2221        );
2222        assert!(stop_failure[1]["hooks"][0]["command"]
2223            .as_str()
2224            .unwrap()
2225            .contains("rl-hit"));
2226        // Stop holds the heartbeat clear (slot 0) + the #333 budget cost writer
2227        // (slot 1): both are match-all (no matcher), slot 1 carries the
2228        // `budget-record` command.
2229        let stop = hooks["Stop"].as_array().unwrap();
2230        assert_eq!(
2231            stop.len(),
2232            2,
2233            "heartbeat clear + budget cost writer expected"
2234        );
2235        assert!(
2236            stop[0].get("matcher").is_none(),
2237            "Stop slot 0 should be the match-all heartbeat clear"
2238        );
2239        let stop_clear = stop[0]["hooks"][0]["command"].as_str().unwrap();
2240        assert!(
2241            stop_clear.starts_with("touch ") && stop_clear.contains(" && rm -f "),
2242            "Stop slot 0 should touch lastseen then rm the marker: {stop_clear}"
2243        );
2244        assert!(
2245            stop[1].get("matcher").is_none(),
2246            "Stop slot 1 (budget writer) should be match-all"
2247        );
2248        assert!(stop[1]["hooks"][0]["command"]
2249            .as_str()
2250            .unwrap()
2251            .contains("budget-record"));
2252        // Each remaining single-entry built-in bucket holds exactly its one entry.
2253        for ev in ["UserPromptSubmit", "SessionStart"] {
2254            assert_eq!(
2255                hooks[ev].as_array().unwrap().len(),
2256                1,
2257                "{ev} should hold exactly one built-in entry"
2258            );
2259        }
2260    }
2261
2262    #[test]
2263    fn stop_failure_rate_limit_hook_records_a_hit() {
2264        // #431: the StopFailure bucket's slot-1 entry is the rate-limit marker.
2265        // The canary `default_hooks_are_deny_plus_heartbeat_buckets` pins the
2266        // bucket shape (2 entries, slot 1 matcher `rate_limit` + `rl-hit`); this
2267        // pins the load-bearing details of the emitted command string.
2268        let c = fixture();
2269        let h = c.agents().next().unwrap();
2270        let v: serde_json::Value =
2271            serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
2272        let stop_failure = v["hooks"]["StopFailure"].as_array().unwrap();
2273        let command = stop_failure[1]["hooks"][0]["command"].as_str().unwrap();
2274
2275        // Guard first so a host without `teamctl` on PATH never errors the stop.
2276        assert!(
2277            command.starts_with("command -v teamctl >/dev/null"),
2278            "rl-hit command must lead with the PATH guard: {command}"
2279        );
2280        // The compose root is baked in so the hook needs no env to find the db.
2281        assert!(
2282            command.contains("--root"),
2283            "rl-hit command must pass the compose --root: {command}"
2284        );
2285        // The subcommand and the agent's `<project>:<agent>` id, pulled from the
2286        // fixture handle so the assertion tracks the fixture rather than a
2287        // hard-coded literal.
2288        assert!(
2289            command.contains("rl-hit"),
2290            "rl-hit subcommand missing: {command}"
2291        );
2292        let agent_id = format!("{}:{}", h.project, h.agent);
2293        assert!(
2294            command.contains(&agent_id),
2295            "rl-hit command must target the agent id {agent_id}: {command}"
2296        );
2297        // Trailing `|| true` makes the marker pure fire-and-forget: any rl-hit
2298        // error degrades to a silent exit-0 instead of erroring the stop.
2299        assert!(
2300            command.ends_with("|| true"),
2301            "rl-hit command must end with the fire-and-forget guard: {command}"
2302        );
2303    }
2304
2305    #[test]
2306    fn stop_budget_record_hook_records_cost() {
2307        // #333: the Stop bucket's slot-1 entry is the budget cost writer. The
2308        // canary `default_hooks_are_deny_plus_heartbeat_buckets` pins the bucket
2309        // shape (2 entries, slot 1 match-all + `budget-record`); this pins the
2310        // load-bearing details of the emitted command string, mirroring the #431
2311        // rl-hit canary exactly.
2312        let c = fixture();
2313        let h = c.agents().next().unwrap();
2314        let v: serde_json::Value =
2315            serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
2316        let stop = v["hooks"]["Stop"].as_array().unwrap();
2317        let command = stop[1]["hooks"][0]["command"].as_str().unwrap();
2318
2319        // Guard first so a host without `teamctl` on PATH never errors the stop.
2320        assert!(
2321            command.starts_with("command -v teamctl >/dev/null"),
2322            "budget-record command must lead with the PATH guard: {command}"
2323        );
2324        // The compose root is baked in so the hook needs no env to find the db.
2325        assert!(
2326            command.contains("--root"),
2327            "budget-record command must pass the compose --root: {command}"
2328        );
2329        // The subcommand and the agent's `<project>:<agent>` id, pulled from the
2330        // fixture handle so the assertion tracks the fixture rather than a
2331        // hard-coded literal.
2332        assert!(
2333            command.contains("budget-record"),
2334            "budget-record subcommand missing: {command}"
2335        );
2336        let agent_id = format!("{}:{}", h.project, h.agent);
2337        assert!(
2338            command.contains(&agent_id),
2339            "budget-record command must target the agent id {agent_id}: {command}"
2340        );
2341        // Trailing `|| true` makes the writer pure fire-and-forget: any record
2342        // error degrades to a silent exit-0 instead of erroring the stop.
2343        assert!(
2344            command.ends_with("|| true"),
2345            "budget-record command must end with the fire-and-forget guard: {command}"
2346        );
2347    }
2348
2349    #[test]
2350    fn heartbeat_hooks_touch_and_clear_the_marker() {
2351        // #428: the four activity-heartbeat hooks render with the agent's
2352        // marker path, `type:command`, and no `matcher` (match-all). touch
2353        // on PreToolUse/UserPromptSubmit, rm on Stop/StopFailure.
2354        let c = fixture();
2355        let h = c.agents().next().unwrap();
2356        let path = heartbeat_path(&c.root, h.project, h.agent)
2357            .display()
2358            .to_string();
2359        // Same shlex quoting the renderer uses — pins the exact emitted
2360        // command, so a regression in quoting (or a dropped `touch`/`rm`)
2361        // fails here rather than silently misfiring at runtime.
2362        let q = crate::supervisor::shlex::try_quote(&path).unwrap();
2363        // #439: the turn-end clear also touches the LASTSEEN sibling.
2364        let ls_path = lastseen_path(&c.root, h.project, h.agent)
2365            .display()
2366            .to_string();
2367        let ls = crate::supervisor::shlex::try_quote(&ls_path).unwrap();
2368        let v: serde_json::Value =
2369            serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
2370        let hooks = &v["hooks"];
2371
2372        // PreToolUse: deny stays at slot 0, heartbeat touch appended at slot 1.
2373        let touch_entry = &hooks["PreToolUse"].as_array().unwrap()[1];
2374        assert!(
2375            touch_entry.get("matcher").is_none(),
2376            "heartbeat must be match-all (no matcher): {touch_entry}"
2377        );
2378        assert_eq!(touch_entry["hooks"][0]["type"].as_str().unwrap(), "command");
2379        assert_eq!(
2380            touch_entry["hooks"][0]["command"].as_str().unwrap(),
2381            format!("touch {q}"),
2382            "PreToolUse should touch the quoted marker"
2383        );
2384
2385        // UserPromptSubmit touches the same marker.
2386        assert_eq!(
2387            hooks["UserPromptSubmit"].as_array().unwrap()[0]["hooks"][0]["command"]
2388                .as_str()
2389                .unwrap(),
2390            format!("touch {q}"),
2391            "UserPromptSubmit should touch the quoted marker"
2392        );
2393
2394        // Stop + StopFailure clear it. The heartbeat clear is always slot 0
2395        // (match-all); on StopFailure the #431 rate-limit marker follows at
2396        // slot 1, so the heartbeat entry keeps its slot. #439: the clear first
2397        // touches the LASTSEEN sibling, then rm's the marker, in one command.
2398        for ev in ["Stop", "StopFailure"] {
2399            let entry = &hooks[ev].as_array().unwrap()[0];
2400            assert!(
2401                entry.get("matcher").is_none(),
2402                "{ev} must be match-all (no matcher)"
2403            );
2404            assert_eq!(
2405                entry["hooks"][0]["command"].as_str().unwrap(),
2406                format!("touch {ls} && rm -f {q}"),
2407                "{ev} should touch the quoted lastseen then rm the quoted marker"
2408            );
2409        }
2410    }
2411
2412    #[test]
2413    fn session_start_hook_runs_the_boot_script() {
2414        // #430: the SessionStart boot-context hook renders as a single
2415        // match-all entry whose command is the shlex-quoted absolute path to
2416        // the shared `bin/boot.sh` asset, with a 5s timeout. #439: the command
2417        // now passes two positional argv — the quoted LASTSEEN then MARKER
2418        // paths — so the script can compute downtime. The script (not this
2419        // JSON) emits the REQUIRED `hookEventName` — here we pin the wiring
2420        // that points Claude Code at it, that it carries the per-agent argv,
2421        // and that it fires on every source (no matcher).
2422        let c = fixture();
2423        let h = c.agents().next().unwrap();
2424        let path = boot_script_path(&c.root).display().to_string();
2425        let q = crate::supervisor::shlex::try_quote(&path).unwrap();
2426        let ls_path = lastseen_path(&c.root, h.project, h.agent)
2427            .display()
2428            .to_string();
2429        let ls = crate::supervisor::shlex::try_quote(&ls_path).unwrap();
2430        let marker_path = heartbeat_path(&c.root, h.project, h.agent)
2431            .display()
2432            .to_string();
2433        let marker = crate::supervisor::shlex::try_quote(&marker_path).unwrap();
2434        let v: serde_json::Value =
2435            serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
2436        let bucket = v["hooks"]["SessionStart"].as_array().unwrap();
2437        assert_eq!(
2438            bucket.len(),
2439            1,
2440            "exactly one SessionStart built-in expected"
2441        );
2442        let entry = &bucket[0];
2443        assert!(
2444            entry.get("matcher").is_none(),
2445            "SessionStart must be match-all (fire on every source): {entry}"
2446        );
2447        let inner = &entry["hooks"][0];
2448        assert_eq!(inner["type"].as_str().unwrap(), "command");
2449        assert_eq!(
2450            inner["command"].as_str().unwrap(),
2451            format!("{q} {ls} {marker}"),
2452            "SessionStart should run the quoted boot.sh path with lastseen + marker argv"
2453        );
2454        assert_eq!(inner["timeout"].as_i64().unwrap(), 5, "5s timeout expected");
2455    }
2456
2457    #[test]
2458    fn lastseen_path_is_a_dotlastseen_sibling_of_the_marker() {
2459        // #439: the lastseen marker lives in the same state/heartbeats dir as
2460        // the heartbeat marker, with the same `<project>-<agent>` stem plus a
2461        // `.lastseen` suffix — so it never shadows the marker the TUI stats for
2462        // Working/Idle. The two render tests use this fn on both sides of their
2463        // assertions; this pins the literal shape they rely on.
2464        let root = std::path::Path::new("/srv/.team");
2465        let marker = heartbeat_path(root, "proj", "ada");
2466        let lastseen = lastseen_path(root, "proj", "ada");
2467        assert_eq!(lastseen, root.join("state/heartbeats/proj-ada.lastseen"));
2468        assert_eq!(
2469            lastseen.parent(),
2470            marker.parent(),
2471            "lastseen must sit in the same heartbeats dir as the marker"
2472        );
2473        assert_ne!(
2474            lastseen, marker,
2475            "lastseen must not collide with the marker"
2476        );
2477    }
2478
2479    #[test]
2480    fn declared_hook_without_matcher_opens_new_event_bucket() {
2481        // #383 Phase 2: a hook on a fresh event (no matcher) creates its
2482        // own bucket and omits `matcher` so Claude Code matches all tools;
2483        // PreToolUse keeps only its built-ins (deny + #428 heartbeat) since
2484        // this declared hook targets PostToolUse.
2485        let mut c = fixture();
2486        c.projects[0].managers.get_mut("mgr").unwrap().hooks = vec![HookSpec {
2487            event: "PostToolUse".into(),
2488            matcher: None,
2489            command: PathBuf::from("hooks/log.sh"),
2490        }];
2491        let h = c.agents().next().unwrap();
2492        let v: serde_json::Value =
2493            serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
2494        assert_eq!(
2495            v["hooks"]["PreToolUse"].as_array().unwrap().len(),
2496            2,
2497            "PreToolUse keeps its deny + #428 heartbeat built-ins"
2498        );
2499        let post = &v["hooks"]["PostToolUse"].as_array().unwrap()[0];
2500        assert!(
2501            post.get("matcher").is_none(),
2502            "matcher must be omitted when unset: {post}"
2503        );
2504        assert_eq!(
2505            post["hooks"][0]["command"].as_str().unwrap(),
2506            "/teamctl/hooks/log.sh"
2507        );
2508    }
2509
2510    #[test]
2511    fn declared_hooks_noop_on_non_claude_runtime() {
2512        // #383 Phase 2: hooks are claude-only v1 — declared on codex the
2513        // whole settings file is still skipped (render warns, returns None).
2514        let mut c = fixture();
2515        {
2516            let m = c.projects[0].managers.get_mut("mgr").unwrap();
2517            m.runtime = "codex".into();
2518            m.hooks = vec![HookSpec {
2519                event: "PreToolUse".into(),
2520                matcher: Some("Bash".into()),
2521                command: PathBuf::from("hooks/guard.sh"),
2522            }];
2523        }
2524        let h = c.agents().next().unwrap();
2525        assert!(
2526            render_claude_settings(&c, h).is_none(),
2527            "hooks must not render on non-claude runtimes"
2528        );
2529    }
2530
2531    #[test]
2532    fn env_emits_claude_settings_path_for_claude_code() {
2533        // T-189: wrapper reads CLAUDE_SETTINGS and passes it to claude
2534        // via `--settings`. Path must resolve under the compose root.
2535        let c = fixture();
2536        let h = c.agents().next().unwrap();
2537        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
2538        assert!(
2539            env.contains("CLAUDE_SETTINGS=/teamctl/state/claude/hello-mgr.json\n"),
2540            "env was: {env}"
2541        );
2542    }
2543
2544    #[test]
2545    fn env_omits_claude_settings_for_non_claude_runtimes() {
2546        // Only claude-code reads the settings file; other runtimes
2547        // must not see the env var (avoids confusion if they ever add
2548        // a same-named knob).
2549        let mut c = fixture();
2550        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
2551        let h = c.agents().next().unwrap();
2552        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
2553        assert!(
2554            !env.contains("CLAUDE_SETTINGS="),
2555            "non-claude runtime must not get settings path: {env}"
2556        );
2557    }
2558
2559    #[test]
2560    fn write_role_prompt_concat_errors_on_missing_source() {
2561        let dir = tempfile::tempdir().unwrap();
2562        let mut c = fixture();
2563        c.root = dir.path().to_path_buf();
2564        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(RolePrompt::Multiple(
2565            vec![PathBuf::from("roles/missing.md")],
2566        ));
2567        let h = c.agents().next().unwrap();
2568        let err = write_role_prompt_concat(&c, h).unwrap_err();
2569        assert!(err.to_string().contains("missing.md"), "err was: {err}");
2570    }
2571
2572    // ---- #383 Phase 3a: per-agent sub-agents (`--agents` JSON) ----
2573
2574    fn write_file(root: &std::path::Path, rel: &str, contents: &str) {
2575        let abs = root.join(rel);
2576        std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
2577        std::fs::write(abs, contents).unwrap();
2578    }
2579
2580    fn rooted(write: impl FnOnce(&std::path::Path)) -> (tempfile::TempDir, Compose) {
2581        let dir = tempfile::tempdir().unwrap();
2582        let mut c = fixture();
2583        c.root = dir.path().to_path_buf();
2584        write(dir.path());
2585        (dir, c)
2586    }
2587
2588    #[test]
2589    fn render_subagents_builds_agents_json_from_frontmatter() {
2590        let (_d, mut c) = rooted(|root| {
2591            write_file(
2592                root,
2593                "agents/security-auditor.md",
2594                "---\nname: security-auditor\ndescription: Audits diffs for vulns.\n\
2595                 tools: Read, Grep\nmodel: claude-sonnet-4-6\n---\n\
2596                 You are a security auditor.\nFlag risky patterns.\n",
2597            );
2598        });
2599        c.projects[0].managers.get_mut("mgr").unwrap().subagents =
2600            vec![PathBuf::from("agents/security-auditor.md")];
2601        let h = c.agents().next().unwrap();
2602        let json = render_subagents(&c, h).unwrap().expect("some json");
2603        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
2604        let entry = &v["security-auditor"];
2605        assert_eq!(entry["description"], "Audits diffs for vulns.");
2606        assert_eq!(
2607            entry["prompt"],
2608            "You are a security auditor.\nFlag risky patterns."
2609        );
2610        assert_eq!(entry["tools"], serde_json::json!(["Read", "Grep"]));
2611        assert_eq!(entry["model"], "claude-sonnet-4-6");
2612    }
2613
2614    #[test]
2615    fn render_subagents_name_falls_back_to_file_stem() {
2616        let (_d, mut c) = rooted(|root| {
2617            write_file(
2618                root,
2619                "agents/repo-cartographer.md",
2620                "---\ndescription: Maps the repo.\n---\nMap it.\n",
2621            );
2622        });
2623        c.projects[0].managers.get_mut("mgr").unwrap().subagents =
2624            vec![PathBuf::from("agents/repo-cartographer.md")];
2625        let h = c.agents().next().unwrap();
2626        let json = render_subagents(&c, h).unwrap().unwrap();
2627        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
2628        assert!(
2629            v.get("repo-cartographer").is_some(),
2630            "stem-derived name missing: {json}"
2631        );
2632        // Nothing declared beyond description → optional keys omitted.
2633        assert!(v["repo-cartographer"].get("tools").is_none());
2634        assert!(v["repo-cartographer"].get("model").is_none());
2635    }
2636
2637    #[test]
2638    fn render_subagents_supports_yaml_list_tools() {
2639        let (_d, mut c) = rooted(|root| {
2640            write_file(
2641                root,
2642                "agents/x.md",
2643                "---\nname: x\ndescription: d\ntools: [Read, Bash]\n---\nbody\n",
2644            );
2645        });
2646        c.projects[0].managers.get_mut("mgr").unwrap().subagents =
2647            vec![PathBuf::from("agents/x.md")];
2648        let h = c.agents().next().unwrap();
2649        let json = render_subagents(&c, h).unwrap().unwrap();
2650        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
2651        assert_eq!(v["x"]["tools"], serde_json::json!(["Read", "Bash"]));
2652    }
2653
2654    #[test]
2655    fn render_subagents_isolates_per_agent() {
2656        // Two agents declaring different sub-agents must each get only
2657        // their own — the core per-agent-scope guarantee.
2658        let (_d, mut c) = rooted(|root| {
2659            write_file(
2660                root,
2661                "agents/a.md",
2662                "---\nname: a\ndescription: da\n---\nba\n",
2663            );
2664            write_file(
2665                root,
2666                "agents/b.md",
2667                "---\nname: b\ndescription: db\n---\nbb\n",
2668            );
2669        });
2670        let worker = c.projects[0].managers["mgr"].clone();
2671        c.projects[0].workers.insert("dev".into(), worker);
2672        c.projects[0].managers.get_mut("mgr").unwrap().subagents =
2673            vec![PathBuf::from("agents/a.md")];
2674        c.projects[0].workers.get_mut("dev").unwrap().subagents =
2675            vec![PathBuf::from("agents/b.md")];
2676
2677        for h in c.agents() {
2678            let v: serde_json::Value =
2679                serde_json::from_str(&render_subagents(&c, h).unwrap().unwrap()).unwrap();
2680            match h.agent {
2681                "mgr" => {
2682                    assert!(v.get("a").is_some() && v.get("b").is_none());
2683                }
2684                "dev" => {
2685                    assert!(v.get("b").is_some() && v.get("a").is_none());
2686                }
2687                other => panic!("unexpected agent {other}"),
2688            }
2689        }
2690    }
2691
2692    #[test]
2693    fn render_subagents_none_when_empty() {
2694        let c = fixture();
2695        let h = c.agents().next().unwrap();
2696        assert!(render_subagents(&c, h).unwrap().is_none());
2697    }
2698
2699    #[test]
2700    fn render_subagents_ignored_on_non_claude_runtime() {
2701        let (_d, mut c) = rooted(|root| {
2702            write_file(
2703                root,
2704                "agents/x.md",
2705                "---\nname: x\ndescription: d\n---\nb\n",
2706            );
2707        });
2708        {
2709            let a = c.projects[0].managers.get_mut("mgr").unwrap();
2710            a.runtime = "codex".into();
2711            a.subagents = vec![PathBuf::from("agents/x.md")];
2712        }
2713        let h = c.agents().next().unwrap();
2714        // claude-only v1: codex ignores declared sub-agents (warns).
2715        assert!(render_subagents(&c, h).unwrap().is_none());
2716    }
2717
2718    #[test]
2719    fn render_subagents_errors_on_missing_source() {
2720        let (_d, mut c) = rooted(|_| {});
2721        c.projects[0].managers.get_mut("mgr").unwrap().subagents =
2722            vec![PathBuf::from("agents/nope.md")];
2723        let h = c.agents().next().unwrap();
2724        let err = render_subagents(&c, h).unwrap_err();
2725        assert!(err.to_string().contains("nope.md"), "err was: {err}");
2726    }
2727
2728    #[test]
2729    fn render_subagents_errors_on_unterminated_frontmatter() {
2730        let (_d, mut c) = rooted(|root| {
2731            write_file(
2732                root,
2733                "agents/bad.md",
2734                "---\nname: x\ndescription: d\nno close\n",
2735            );
2736        });
2737        c.projects[0].managers.get_mut("mgr").unwrap().subagents =
2738            vec![PathBuf::from("agents/bad.md")];
2739        let h = c.agents().next().unwrap();
2740        assert!(render_subagents(&c, h).is_err());
2741    }
2742
2743    #[test]
2744    fn env_emits_claude_agents_json_for_claude_code() {
2745        let c = fixture();
2746        let h = c.agents().next().unwrap();
2747        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
2748        assert!(env.contains("CLAUDE_AGENTS_JSON=/teamctl/state/claude/hello-mgr.agents.json"));
2749    }
2750
2751    #[test]
2752    fn write_subagents_json_writes_then_clears_stale() {
2753        let (_d, mut c) = rooted(|root| {
2754            write_file(
2755                root,
2756                "agents/x.md",
2757                "---\nname: x\ndescription: d\n---\nbody\n",
2758            );
2759        });
2760        let dest = subagents_json_path(&c.root, "hello", "mgr");
2761
2762        // Declared → file materialized.
2763        c.projects[0].managers.get_mut("mgr").unwrap().subagents =
2764            vec![PathBuf::from("agents/x.md")];
2765        let h = c.agents().next().unwrap();
2766        write_subagents_json(&c, h).unwrap();
2767        assert!(dest.exists(), "agents json should be written");
2768
2769        // Dropped → stale file removed so old sub-agents don't linger.
2770        c.projects[0].managers.get_mut("mgr").unwrap().subagents = vec![];
2771        let h = c.agents().next().unwrap();
2772        write_subagents_json(&c, h).unwrap();
2773        assert!(!dest.exists(), "stale agents json should be removed");
2774    }
2775
2776    #[test]
2777    fn write_agent_skills_materializes_symlinks() {
2778        let (_d, mut c) = rooted(|root| {
2779            write_file(root, "skills/pr-review/SKILL.md", "# PR review skill\n");
2780        });
2781        c.projects[0].managers.get_mut("mgr").unwrap().skills =
2782            vec![PathBuf::from("skills/pr-review")];
2783        let h = c.agents().next().unwrap();
2784        write_agent_skills(&c, h).unwrap();
2785
2786        let link = agent_scope_dir(&c.root, "hello", "mgr").join(".claude/skills/pr-review");
2787        let meta = std::fs::symlink_metadata(&link).expect("link should exist");
2788        assert!(meta.file_type().is_symlink(), "entry must be a symlink");
2789        // Resolves to the source skill dir (so CC finds its SKILL.md).
2790        assert_eq!(
2791            std::fs::canonicalize(&link).unwrap(),
2792            std::fs::canonicalize(c.root.join("skills/pr-review")).unwrap()
2793        );
2794    }
2795
2796    #[test]
2797    fn write_agent_skills_clear_stale_preserves_source() {
2798        // SAFETY: dropping a skill must unlink only the symlink — never
2799        // recurse into and delete the real skill directory it pointed at.
2800        let (_d, mut c) = rooted(|root| {
2801            write_file(root, "skills/foo/SKILL.md", "# foo\n");
2802        });
2803        let source = c.root.join("skills/foo");
2804        let source_md = source.join("SKILL.md");
2805
2806        // Declare → materialize the link.
2807        c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![PathBuf::from("skills/foo")];
2808        let h = c.agents().next().unwrap();
2809        write_agent_skills(&c, h).unwrap();
2810        let scope = agent_scope_dir(&c.root, "hello", "mgr");
2811        assert!(scope.join(".claude/skills/foo").exists());
2812
2813        // Drop → scope cleared, but the real skill dir + SKILL.md survive.
2814        c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![];
2815        let h = c.agents().next().unwrap();
2816        write_agent_skills(&c, h).unwrap();
2817        assert!(!scope.exists(), "stale scope dir should be removed");
2818        assert!(source.is_dir(), "source skill dir must survive the clear");
2819        assert!(
2820            source_md.is_file(),
2821            "source SKILL.md must survive the clear"
2822        );
2823    }
2824
2825    #[test]
2826    fn write_agent_skills_isolates_per_agent() {
2827        // Two agents declaring different skills must each get only their
2828        // own — the core per-agent-scope guarantee.
2829        let (_d, mut c) = rooted(|root| {
2830            write_file(root, "skills/a/SKILL.md", "# a\n");
2831            write_file(root, "skills/b/SKILL.md", "# b\n");
2832        });
2833        let worker = c.projects[0].managers["mgr"].clone();
2834        c.projects[0].workers.insert("dev".into(), worker);
2835        c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![PathBuf::from("skills/a")];
2836        c.projects[0].workers.get_mut("dev").unwrap().skills = vec![PathBuf::from("skills/b")];
2837
2838        for h in c.agents() {
2839            write_agent_skills(&c, h).unwrap();
2840        }
2841        let mgr_skills = agent_scope_dir(&c.root, "hello", "mgr").join(".claude/skills");
2842        let dev_skills = agent_scope_dir(&c.root, "hello", "dev").join(".claude/skills");
2843        assert!(mgr_skills.join("a").exists() && !mgr_skills.join("b").exists());
2844        assert!(dev_skills.join("b").exists() && !dev_skills.join("a").exists());
2845    }
2846
2847    #[test]
2848    fn write_agent_skills_ignored_on_non_claude_runtime() {
2849        let (_d, mut c) = rooted(|root| {
2850            write_file(root, "skills/x/SKILL.md", "# x\n");
2851        });
2852        {
2853            let a = c.projects[0].managers.get_mut("mgr").unwrap();
2854            a.runtime = "codex".into();
2855            a.skills = vec![PathBuf::from("skills/x")];
2856        }
2857        let h = c.agents().next().unwrap();
2858        // claude-only v1: codex ignores declared skills (warns) and no
2859        // scope dir is created.
2860        write_agent_skills(&c, h).unwrap();
2861        assert!(!agent_scope_dir(&c.root, "hello", "mgr").exists());
2862    }
2863
2864    #[test]
2865    fn env_emits_claude_agent_scope_for_claude_code() {
2866        let c = fixture();
2867        let h = c.agents().next().unwrap();
2868        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
2869        assert!(env.contains("CLAUDE_AGENT_SCOPE=/teamctl/state/agent-scope/hello-mgr"));
2870    }
2871
2872    #[test]
2873    fn env_omits_claude_agent_scope_for_non_claude_runtimes() {
2874        let mut c = fixture();
2875        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
2876        let h = c.agents().next().unwrap();
2877        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
2878        assert!(
2879            !env.contains("CLAUDE_AGENT_SCOPE="),
2880            "non-claude runtime must not get the agent scope: {env}"
2881        );
2882    }
2883}