Skip to main content

zeph_subagent/manager/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Sub-agent lifecycle management: spawn, cancel, collect, and resume.
5
6mod collect;
7mod secrets;
8mod spawn;
9mod worktree;
10
11use std::collections::{HashMap, HashSet};
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::time::Instant;
15
16use tokio::sync::{mpsc, watch};
17use tokio::task::JoinSet;
18use tokio_util::sync::CancellationToken;
19use zeph_common::task_supervisor::BlockingHandle;
20use zeph_common::{SkillTrustLevel, TaskSupervisor};
21use zeph_config::{ContentIsolationConfig, McpServerConfig};
22use zeph_llm::provider::Message;
23
24use crate::def::{PermissionMode, SubAgentDef};
25use crate::durable::DurableResolverSeat;
26use crate::error::SubAgentError;
27use crate::fleet::SharedFleetRegistry;
28use crate::forward::ForwardSurfaces;
29use crate::grants::{GrantedSecret, PermissionGrants, SecretRequest};
30use crate::state::SubAgentState;
31
32/// Parent-derived state propagated to a spawned sub-agent at spawn time.
33///
34/// All fields default to empty/`None`, preserving existing behavior when callers
35/// pass `SpawnContext::default()`.
36///
37/// # Constraint propagation
38///
39/// [`max_trust_level`][Self::max_trust_level] and
40/// [`inherited_tool_allowlist`][Self::inherited_tool_allowlist] implement transitive
41/// constraint propagation: safety constraints set at orchestration time are enforced on
42/// every sub-agent in the spawn chain, regardless of nesting depth.
43///
44/// When a sub-agent spawns its own sub-agents it must forward these fields downward so
45/// that grandchild agents cannot silently receive more privileges than the original
46/// orchestration policy allowed.
47///
48/// # Examples
49///
50/// ```rust
51/// use zeph_subagent::manager::SpawnContext;
52///
53/// // Minimal context — all fields use their defaults.
54/// let ctx = SpawnContext::default();
55/// assert!(ctx.parent_messages.is_empty());
56/// assert_eq!(ctx.spawn_depth, 0);
57/// assert!(ctx.max_trust_level.is_none());
58/// assert!(ctx.inherited_tool_allowlist.is_none());
59/// ```
60#[derive(Default)]
61pub struct SpawnContext {
62    /// Recent parent conversation messages (last N turns).
63    pub parent_messages: Vec<Message>,
64    /// Parent's cancellation token for linked cancellation (foreground spawns).
65    pub parent_cancel: Option<CancellationToken>,
66    /// Parent's active provider name (for context propagation).
67    pub parent_provider_name: Option<String>,
68    /// Current spawn depth (0 = top-level agent).
69    pub spawn_depth: u32,
70    /// MCP tool names available in the parent's tool executor (for diagnostics).
71    pub mcp_tool_names: Vec<String>,
72    /// Seeded trajectory risk score from the parent sentinel (spec 050 §4).
73    ///
74    /// When `Some`, the subagent's `TrajectorySentinel` starts with this pre-seeded score
75    /// rather than `0.0`, preventing a subagent spawn from acting as a free risk reset.
76    /// The subagent loop applies this via `TrajectorySentinel::seed_score` after build.
77    pub seed_trajectory_score: Option<f32>,
78    /// Parent's content isolation config, propagated so the subagent loop can run the
79    /// same sanitizer settings on hook-replaced tool output.
80    pub content_isolation: ContentIsolationConfig,
81    /// Name of the orchestrator that spawned this subagent.
82    ///
83    /// When set, the subagent's system prompt includes an identity header naming the
84    /// orchestrator, so the subagent can validate that instructions are consistent with
85    /// the expected authority.
86    pub orchestrator_name: Option<String>,
87    /// Role or task label of the orchestrating agent (e.g., `"planner"`, `"tool-router"`).
88    ///
89    /// Injected alongside [`orchestrator_name`][Self::orchestrator_name] when both are set.
90    /// Omitted from the identity header when only `orchestrator_name` is provided.
91    pub orchestrator_role: Option<String>,
92    /// Per-session MCP servers to inject into this subagent's tool name annotations.
93    ///
94    /// The parent is responsible for connecting these servers and including them in the
95    /// `tool_executor` passed to [`SubAgentManager::spawn`]. This field only carries the
96    /// server metadata so the subagent's system prompt lists the additional tool names.
97    pub session_mcp_servers: Vec<McpServerConfig>,
98    /// Maximum trust level cap inherited from the parent agent or orchestration policy.
99    ///
100    /// When `Some(cap)`, the spawned sub-agent's effective trust level is clamped to
101    /// `min(own_trust, cap)` so that sub-agents can never receive higher privileges than
102    /// the orchestration policy originally allowed.
103    ///
104    /// # Caller responsibility for nested spawns
105    ///
106    /// This field does **not** propagate automatically. When a sub-agent itself spawns a
107    /// grandchild, it must copy this field from its own received `SpawnContext` into the
108    /// grandchild's `SpawnContext`. Passing `None` (the default) at that point means the
109    /// grandchild receives **no cap**, which is a privilege escalation if the parent was
110    /// constrained. Only the top-level session (spawned by `build_spawn_context`) correctly
111    /// leaves this `None` — that represents an unconstrained top-level entry point.
112    ///
113    /// `None` means no cap is imposed by the parent (the sub-agent's own definition
114    /// determines its trust level).
115    pub max_trust_level: Option<SkillTrustLevel>,
116    /// Tool names that this sub-agent is allowed to invoke, inherited from the parent.
117    ///
118    /// When `Some(set)`, the effective tool allowlist for the spawned agent is the
119    /// intersection of `set` and the agent's own definition policy. This prevents a
120    /// sub-agent from accessing tools that the parent is itself not allowed to use.
121    ///
122    /// # Caller responsibility for nested spawns
123    ///
124    /// Like [`max_trust_level`][Self::max_trust_level], this field does **not** propagate
125    /// automatically. When a constrained sub-agent spawns its own children, it must copy
126    /// this field from its received `SpawnContext` into the child's `SpawnContext`.
127    /// Passing `None` at that point would grant the grandchild unrestricted tool access,
128    /// defeating the original orchestration policy.
129    ///
130    /// `None` means no additional allowlist restriction is imposed by the parent
131    /// (the agent's definition policy applies without narrowing).
132    pub inherited_tool_allowlist: Option<HashSet<String>>,
133
134    /// Durable resolver seat for promise-based subagent spawn/await (spec-064 §P4, INV-9).
135    ///
136    /// When `Some`, the spawned background task resolves the parent's durable promise after the
137    /// agent loop terminates. The seat carries the resolver token and MUST NOT be forwarded to
138    /// the child's tool executor or LLM surface — only the background task wrapper consumes it.
139    ///
140    /// `None` when `durable.enabled && durable.subagent` is false (plain spawn/collect path).
141    pub durable_resolver: Option<DurableResolverSeat>,
142
143    /// Deny network egress for this sub-agent's `bash` tool calls.
144    ///
145    /// Set by the orchestration layer when the spawning `TaskNode` carries
146    /// `network_scope: NetworkScope::Deny` (spec `069-threat-model` OQ-1). When `true`,
147    /// `build_filtered_executor` wraps the tool executor with
148    /// [`NetworkDenyToolExecutor`](crate::NetworkDenyToolExecutor), which blocks `bash`
149    /// invocations of `curl`, `wget`, `nc`, `ncat`, and `netcat` for this spawn only —
150    /// sibling tasks and the parent agent's own executor are unaffected.
151    ///
152    /// # Caller responsibility for nested spawns
153    ///
154    /// Like [`max_trust_level`][Self::max_trust_level], this field does **not** propagate
155    /// automatically. A sub-agent that itself spawns a grandchild must copy this field
156    /// from its own received `SpawnContext` into the grandchild's `SpawnContext`, or the
157    /// grandchild spawns with network access regardless of the original task's scope.
158    ///
159    /// `false` (the default) imposes no restriction beyond the executor/global
160    /// `allow_network` default.
161    pub network_denied: bool,
162
163    /// Shared progress heartbeat for idle-timeout detection (issue #6245).
164    ///
165    /// Set by the orchestration driver (`handle_scheduler_spawn_action` in `zeph-core`'s
166    /// `scheduler_loop.rs`) alongside [`network_denied`][Self::network_denied] — same
167    /// post-construction assignment pattern, not part of `build_spawn_context`'s base
168    /// literal. The driver creates the `Arc`, clones it in here, and keeps the original for
169    /// `zeph_orchestration::DagScheduler::record_spawn`'s `last_progress_at` parameter so
170    /// both the running loop and the scheduler observe the same counter.
171    ///
172    /// `None` (the default) for spawns not tracked by a `DagScheduler` — e.g. the standalone
173    /// `/agent run` command — which are never idle-tracked.
174    pub progress_at: Option<Arc<std::sync::atomic::AtomicU64>>,
175
176    /// Cross-crate debug-dump sink, threaded down so sub-agent LLM calls are captured
177    /// through the same pipeline as the top-level agent loop's `--debug-dump` output (#6391).
178    ///
179    /// Set by `zeph-core`'s `build_spawn_context` from `DebugState::debug_dumper`. `None`
180    /// when debug dumps are disabled — no sub-agent dump is written in that case, mirroring
181    /// the top-level `debug_dumper: None` behavior.
182    ///
183    /// # Caller responsibility for nested spawns
184    ///
185    /// Like [`max_trust_level`][Self::max_trust_level], this does **not** propagate
186    /// automatically — a sub-agent that spawns its own children must copy this field from
187    /// its received `SpawnContext` into the child's, or grandchild LLM calls go undumped.
188    pub debug_dump_sink: Option<Arc<dyn zeph_llm::debug_dump::DebugDumpSink>>,
189}
190
191/// Live status snapshot of a running sub-agent.
192///
193/// Values are updated by the background agent loop via a [`tokio::sync::watch`] channel.
194/// Callers receive snapshots via [`SubAgentManager::statuses`].
195#[derive(Debug, Clone)]
196pub struct SubAgentStatus {
197    /// Current lifecycle state of the agent task.
198    pub state: SubAgentState,
199    /// Last message content from the agent (trimmed for display).
200    pub last_message: Option<String>,
201    /// Number of LLM turns consumed so far.
202    pub turns_used: u32,
203    /// Monotonic timestamp recorded at spawn time.
204    pub started_at: Instant,
205}
206
207/// Handle to a spawned sub-agent task, owned by [`SubAgentManager`].
208///
209/// Fields are public to allow test harnesses in downstream crates to construct handles
210/// without going through the full spawn lifecycle. Production code must not mutate
211/// grants or the cancellation state directly — use the [`SubAgentManager`] API instead.
212///
213/// The `Drop` implementation cancels the task and revokes all grants as a safety net.
214pub struct SubAgentHandle {
215    /// Short display ID (same as `task_id` for non-resumed sessions).
216    pub id: String,
217    /// The definition that was used to spawn this agent.
218    pub def: SubAgentDef,
219    /// UUID assigned at spawn time (currently identical to `id`; separated for future use).
220    pub task_id: String,
221    /// Cached state — may lag the background task by one watch broadcast.
222    pub state: SubAgentState,
223    /// Supervised handle for the background agent loop task.
224    pub join_handle: Option<BlockingHandle<Result<String, SubAgentError>>>,
225    /// Cancellation token; cancelled on [`SubAgentManager::cancel`] or drop.
226    pub cancel: CancellationToken,
227    /// Watch receiver for live status updates from the agent loop.
228    pub status_rx: watch::Receiver<SubAgentStatus>,
229    /// Zero-trust TTL-bounded grants for this agent session.
230    pub grants: PermissionGrants,
231    /// Receives secret requests from the sub-agent loop.
232    pub pending_secret_rx: mpsc::Receiver<SecretRequest>,
233    /// Delivers the approval outcome to the sub-agent loop: `None` = denied,
234    /// `Some(value)` = approved, carrying the resolved vault secret value and its
235    /// grant expiry so the loop can re-validate the TTL locally on every tool call.
236    pub secret_tx: mpsc::Sender<Option<GrantedSecret>>,
237    /// ISO 8601 UTC timestamp recorded when the agent was spawned or resumed.
238    pub started_at_str: String,
239    /// Resolved transcript directory at spawn time; `None` if transcripts were disabled.
240    pub transcript_dir: Option<PathBuf>,
241    /// MCP tool names available at spawn time, persisted for transcript meta on collect.
242    pub mcp_tool_names: Vec<String>,
243}
244
245impl SubAgentHandle {
246    /// Construct a minimal [`SubAgentHandle`] for use in unit tests.
247    ///
248    /// The returned handle has a no-op cancel token, closed channels, and no grants.
249    /// It must not be spawned or collected — it is only valid for inspection logic
250    /// that operates on the handle's metadata fields (id, def, state, etc.).
251    #[cfg(test)]
252    pub fn for_test(id: impl Into<String>, def: SubAgentDef) -> Self {
253        let initial_status = SubAgentStatus {
254            state: SubAgentState::Working,
255            last_message: None,
256            turns_used: 0,
257            started_at: Instant::now(),
258        };
259        let (status_tx, status_rx) = watch::channel(initial_status);
260        drop(status_tx);
261        let (pending_secret_rx_tx, pending_secret_rx) = mpsc::channel(1);
262        drop(pending_secret_rx_tx);
263        let (secret_tx, _) = mpsc::channel(1);
264        let id_str = id.into();
265        Self {
266            task_id: id_str.clone(),
267            id: id_str,
268            def,
269            state: SubAgentState::Working,
270            join_handle: None,
271            cancel: CancellationToken::new(),
272            status_rx,
273            grants: PermissionGrants::default(),
274            pending_secret_rx,
275            secret_tx,
276            started_at_str: String::new(),
277            transcript_dir: None,
278            mcp_tool_names: Vec::new(),
279        }
280    }
281}
282
283impl std::fmt::Debug for SubAgentHandle {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        f.debug_struct("SubAgentHandle")
286            .field("id", &self.id)
287            .field("task_id", &self.task_id)
288            .field("state", &self.state)
289            .field("def_name", &self.def.name)
290            .finish_non_exhaustive()
291    }
292}
293
294impl Drop for SubAgentHandle {
295    fn drop(&mut self) {
296        // Defense-in-depth: cancel the task and revoke grants on drop even if
297        // cancel() or collect() was not called (e.g., on panic or early return).
298        self.cancel.cancel();
299        if !self.grants.is_empty_grants() {
300            tracing::warn!(
301                id = %self.id,
302                "SubAgentHandle dropped without explicit cleanup — revoking grants"
303            );
304        }
305        self.grants.revoke_all();
306    }
307}
308
309/// Manages sub-agent lifecycle: definitions, spawning, cancellation, and result collection.
310///
311/// `SubAgentManager` is the central coordinator for all sub-agent tasks. It tracks active
312/// [`SubAgentHandle`]s, enforces the global concurrency limit, and stores loaded
313/// [`SubAgentDef`]s.
314///
315/// # Concurrency model
316///
317/// The concurrency limit counts agents whose [`SubAgentState`] is `Submitted` or `Working`.
318/// Reserved slots (via [`reserve_slots`][Self::reserve_slots]) also count against this limit
319/// to allow orchestration schedulers to guarantee capacity before spawning.
320///
321/// # Examples
322///
323/// ```rust
324/// use zeph_subagent::SubAgentManager;
325///
326/// let manager = SubAgentManager::new(4);
327/// assert_eq!(manager.definitions().len(), 0);
328/// ```
329pub struct SubAgentManager {
330    definitions: Vec<SubAgentDef>,
331    agents: HashMap<String, SubAgentHandle>,
332    max_concurrent: usize,
333    /// Number of slots soft-reserved by the orchestration scheduler.
334    ///
335    /// Reserved slots count against the concurrency limit so that the scheduler can
336    /// guarantee capacity for tasks it is about to spawn, preventing a planning-phase
337    /// sub-agent from exhausting the pool and causing a deadlock.
338    reserved_slots: usize,
339    /// Config-level `SubagentStop` hooks, cached so `cancel()` and `collect()` can fire them.
340    stop_hooks: Vec<super::hooks::HookDef>,
341    /// Directory for JSONL transcripts and meta sidecars.
342    transcript_dir: Option<PathBuf>,
343    /// Maximum number of transcript files to keep (0 = unlimited).
344    transcript_max_files: usize,
345    /// Optional fleet registry for registering sub-agents in the fleet dashboard.
346    ///
347    /// When `None`, fleet registration is skipped silently. Inject via
348    /// [`set_fleet_registry`][Self::set_fleet_registry].
349    fleet_registry: Option<SharedFleetRegistry>,
350    /// Tracks fire-and-forget hook and fleet-registry tasks to prevent silent panic swallowing.
351    ///
352    /// Completed and panicked tasks are drained before each new spawn. On graceful shutdown,
353    /// [`shutdown_all`][Self::shutdown_all] aborts all outstanding tasks via
354    /// [`JoinSet::shutdown`].
355    hook_tasks: JoinSet<()>,
356    /// Maximum number of concurrent hook tasks allowed in [`hook_tasks`][Self::hook_tasks].
357    ///
358    /// When the limit is reached, new fire-and-forget tasks are dropped with a warning instead
359    /// of growing the set unboundedly under high-throughput spawning.
360    max_hook_tasks: usize,
361    /// Optional worktree manager; `Some` iff `worktree.enabled = true` in config.
362    ///
363    /// When set, every [`spawn`][Self::spawn] acquires [`cwd_lock`][Self::cwd_lock] for
364    /// its full run so that plain agents cannot observe a stale cwd mutated by a worktree
365    /// agent (INV-1). Only agents with `permissions.worktree = true` and a non-`None`
366    /// `bg_isolation` actually get a dedicated worktree.
367    ///
368    /// This is the single live instance shared by the running agent's `/worktree`
369    /// slash command (see [`worktree_manager`][Self::worktree_manager]) — distinct from
370    /// the CLI's `zeph worktree list`/`clean`, which constructs its own fresh manager
371    /// per invocation (`src/commands/worktree.rs`).
372    worktree_manager: Option<Arc<zeph_worktree::DefaultWorktreeManager>>,
373    /// Process-level serialisation mutex for working-directory mutations (INV-1).
374    ///
375    /// Acquired by every spawned task when `worktree_manager.is_some()`.  The
376    /// `OwnedMutexGuard` is held for the full duration of `run_agent_loop` via the
377    /// `CwdRestoreGuard` RAII wrapper.
378    cwd_lock: Arc<tokio::sync::Mutex<()>>,
379    /// Optional supervisor for subagent lifecycle tasks.
380    ///
381    /// When set, each spawned agent loop task is registered under its task ID so it is
382    /// visible to TUI status panels and shutdown is coordinated through the supervisor.
383    task_supervisor: Option<TaskSupervisor>,
384    /// Which forwarding consumer surfaces are active for this session (issue #6359).
385    ///
386    /// Fixed at session start via [`set_forward_surfaces`][Self::set_forward_surfaces].
387    /// `ForwardSurfaces::default()` (all `false`) until then, matching "forwarding
388    /// disabled" byte-for-byte (FR-001/NFR-003).
389    forward_surfaces: ForwardSurfaces,
390    /// Ring buffer of recent sanitized display lines per task ID, owned by each task's
391    /// forwarding drain (issue #6359). Read by [`forwarded_tail`][Self::forwarded_tail]
392    /// for the TUI runtime detail view; entries are evicted by the drain itself shortly
393    /// after that task's terminal chunk.
394    forward_buffer: Arc<crate::forward::ForwardBuffer>,
395    /// Optional secret-mask registry applied to forwarded chunks (issue #6359, security
396    /// Finding 1 / NFR-005) — closes the consistency gap with the analogous outbound-LLM
397    /// egress path (`apply_secret_masking`), which the forwarding pipeline would otherwise
398    /// bypass. `None` (the default) means forwarded content is not masked for known vault
399    /// secrets — set via [`set_secret_registry`][Self::set_secret_registry].
400    secret_registry: Option<Arc<zeph_sanitizer::secret_mask::SecretMaskRegistry>>,
401    /// Optional PII filter applied to forwarded chunks (issue #6359, security Finding 1 /
402    /// NFR-005) — mirrors the optional `PiiFilter` layer sub-agent debug dumps get via
403    /// `PiiScrubbingDumpSink` (#6407). `None` (the default) means no PII scrubbing beyond the
404    /// baseline `ContentSanitizer` pass — set via [`set_pii_filter`][Self::set_pii_filter].
405    pii_filter: Option<zeph_sanitizer::pii::PiiFilter>,
406}
407
408impl std::fmt::Debug for SubAgentManager {
409    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410        f.debug_struct("SubAgentManager")
411            .field("definitions_count", &self.definitions.len())
412            .field("active_agents", &self.agents.len())
413            .field("max_concurrent", &self.max_concurrent)
414            .field("reserved_slots", &self.reserved_slots)
415            .field("stop_hooks_count", &self.stop_hooks.len())
416            .field("transcript_dir", &self.transcript_dir)
417            .field("transcript_max_files", &self.transcript_max_files)
418            .field("fleet_registry", &self.fleet_registry.is_some())
419            .field("hook_tasks_len", &self.hook_tasks.len())
420            .field("max_hook_tasks", &self.max_hook_tasks)
421            .field("worktree_manager", &self.worktree_manager.is_some())
422            .field("cwd_lock", &"<Mutex>")
423            .field("task_supervisor", &self.task_supervisor.is_some())
424            .field("forward_surfaces", &self.forward_surfaces)
425            .field("forward_buffer", &"<Mutex>")
426            .field("secret_registry", &self.secret_registry.is_some())
427            .field("pii_filter", &self.pii_filter.is_some())
428            .finish()
429    }
430}
431
432impl SubAgentManager {
433    /// Create a new manager with the given concurrency limit.
434    #[must_use]
435    pub fn new(max_concurrent: usize) -> Self {
436        Self {
437            definitions: Vec::new(),
438            agents: HashMap::new(),
439            max_concurrent,
440            reserved_slots: 0,
441            stop_hooks: Vec::new(),
442            transcript_dir: None,
443            transcript_max_files: 50,
444            fleet_registry: None,
445            hook_tasks: JoinSet::new(),
446            max_hook_tasks: 64,
447            worktree_manager: None,
448            cwd_lock: Arc::new(tokio::sync::Mutex::new(())),
449            task_supervisor: None,
450            forward_surfaces: ForwardSurfaces::default(),
451            forward_buffer: crate::forward::new_buffer(),
452            secret_registry: None,
453            pii_filter: None,
454        }
455    }
456
457    /// Inject a [`TaskSupervisor`] so subagent lifecycle tasks are registered and visible.
458    ///
459    /// Must be called before the first [`spawn`][Self::spawn]. When set, each spawned agent
460    /// loop task is registered under its task ID and is observable in TUI status panels and
461    /// [`TaskSupervisor::snapshot`].
462    pub fn set_task_supervisor(&mut self, supervisor: TaskSupervisor) {
463        self.task_supervisor = Some(supervisor);
464    }
465
466    /// Declare which forwarding consumer surfaces are active for this session (issue #6359).
467    ///
468    /// Fixed at session start — call once during bootstrap, before the first
469    /// [`spawn`][Self::spawn]. When `surfaces.any()` is `false` (the default) or
470    /// `SubAgentConfig::forward_transcript` is `false`, no forwarding sender or drain is ever
471    /// constructed for any subagent (FR-007). A [`TaskSupervisor`] must also be wired via
472    /// [`set_task_supervisor`][Self::set_task_supervisor] — unlike other subagent lifecycle
473    /// tasks, the forward drain never falls back to an untracked spawn (NFR-002).
474    pub fn set_forward_surfaces(&mut self, surfaces: ForwardSurfaces) {
475        self.forward_surfaces = surfaces;
476    }
477
478    /// Wire the bootstrap-level secret-mask registry into the forwarding pipeline (issue
479    /// #6359, security Finding 1 / NFR-005).
480    ///
481    /// When set, every forwarded `Text`/`Thinking` chunk has known vault secrets replaced
482    /// with opaque placeholders before reaching any sink (TUI ring, `--bare` stdout) — the
483    /// same registry already applied to the outbound-LLM path via `apply_secret_masking`.
484    /// Call during bootstrap, before the first [`spawn`][Self::spawn]; has no effect on
485    /// already-spawned drains.
486    pub fn set_secret_registry(
487        &mut self,
488        registry: Arc<zeph_sanitizer::secret_mask::SecretMaskRegistry>,
489    ) {
490        self.secret_registry = Some(registry);
491    }
492
493    /// Wire a PII filter into the forwarding pipeline (issue #6359, security Finding 1 /
494    /// NFR-005).
495    ///
496    /// When set, every forwarded `Text`/`Thinking` chunk is scrubbed for emails, phone
497    /// numbers, SSNs, etc. before reaching any sink — mirroring the optional `PiiFilter`
498    /// layer sub-agent debug dumps already get via `PiiScrubbingDumpSink` (#6407). The filter
499    /// itself is unconditionally constructible and self-gates on its own `enabled` config
500    /// field, matching the top-level agent's own `PiiFilter` construction convention. Call
501    /// during bootstrap, before the first [`spawn`][Self::spawn].
502    pub fn set_pii_filter(&mut self, filter: zeph_sanitizer::pii::PiiFilter) {
503        self.pii_filter = Some(filter);
504    }
505
506    /// Read the current forwarded-transcript tail for `task_id` (up to the last `n` lines).
507    ///
508    /// Returns an empty vector when forwarding is disabled, no surface is active, or the
509    /// task has not forwarded any lines yet. Backing store for
510    /// [`crate::manager::SubAgentManager`]'s TUI runtime detail view integration
511    /// (FR-005) — see `zeph-core`'s `refresh_subagent_metrics`.
512    #[must_use]
513    pub fn forwarded_tail(&self, task_id: &str, n: usize) -> Vec<String> {
514        crate::forward::forwarded_tail(&self.forward_buffer, task_id, n)
515    }
516
517    /// Build a forwarding sender + spawn its per-task drain for `task_id`, if forwarding is
518    /// active for this session.
519    ///
520    /// Returns `None` (no-op) unless `config.forward_transcript` is set, at least one
521    /// consumer surface is active, and a [`TaskSupervisor`] is wired — the forward drain must
522    /// never fall back to an untracked `tokio::spawn` (NFR-002, P-new-2). The returned
523    /// [`crate::forward::ForwardSender`] must be threaded into `AgentLoopArgs::forward` and
524    /// owned exclusively by that subagent's own turn loop for the run's lifetime (P-new-3).
525    pub(crate) fn maybe_spawn_forward(
526        &self,
527        task_id: &str,
528        def_name: &str,
529        forward_transcript: bool,
530        content_isolation: &ContentIsolationConfig,
531    ) -> Option<crate::forward::ForwardSender> {
532        if !forward_transcript || !self.forward_surfaces.any() {
533            return None;
534        }
535        let Some(ref supervisor) = self.task_supervisor else {
536            tracing::warn!(
537                task_id,
538                "subagent transcript forwarding is enabled but no TaskSupervisor is wired — \
539                 skipping forwarding for this run rather than spawning an untracked drain \
540                 (NFR-002)"
541            );
542            return None;
543        };
544
545        let task_id_arc: Arc<str> = Arc::from(task_id);
546        let def_name_arc: Arc<str> = Arc::from(def_name);
547        let (sender, rx) =
548            crate::forward::new_channel(Arc::clone(&task_id_arc), Arc::clone(&def_name_arc));
549
550        let surfaces = self.forward_surfaces;
551        let buffer = Arc::clone(&self.forward_buffer);
552        let layers = crate::forward::SanitizeLayers {
553            sanitizer: zeph_sanitizer::ContentSanitizer::new(content_isolation),
554            secret_registry: self.secret_registry.clone(),
555            pii_filter: self.pii_filter.clone(),
556        };
557        let span = tracing::info_span!(
558            "subagent.forward.drain",
559            task_id = %task_id_arc,
560            tui = surfaces.tui,
561            bare = surfaces.bare,
562        );
563        let drain_task_id = Arc::clone(&task_id_arc);
564        let drain_def_name = Arc::clone(&def_name_arc);
565        let drain_name: Arc<str> = Arc::from(format!("subagent-forward-drain-{task_id}").as_str());
566        let _handle = supervisor.spawn_oneshot(drain_name, move || {
567            use tracing::Instrument as _;
568            crate::forward::run_forward_drain(
569                drain_task_id,
570                drain_def_name,
571                rx,
572                layers,
573                surfaces,
574                buffer,
575            )
576            .instrument(span)
577        });
578
579        Some(sender)
580    }
581
582    /// Inject a [`DefaultWorktreeManager`][zeph_worktree::DefaultWorktreeManager] into the
583    /// manager.
584    ///
585    /// Must be called at most once, before the first [`spawn`][Self::spawn].  When set,
586    /// every spawned task acquires the process-level cwd mutex (INV-1) and agents with
587    /// `permissions.worktree = true` receive a dedicated git worktree.
588    pub fn set_worktree_manager(&mut self, wm: Arc<zeph_worktree::DefaultWorktreeManager>) {
589        self.worktree_manager = Some(wm);
590    }
591
592    /// Returns the live worktree manager, if the worktree subsystem is enabled for this
593    /// session.
594    ///
595    /// This is the same instance [`spawn`][Self::spawn] uses to create per-subagent
596    /// worktrees, so callers (e.g. the `/worktree` slash command) observe this session's
597    /// actual live state rather than a fresh disk scan. Its own
598    /// `prune_branch_on_remove()` reflects `WorktreeConfig::prune_branch_on_remove` — no
599    /// need to retain a separate copy on `SubAgentManager`.
600    #[must_use]
601    pub fn worktree_manager(&self) -> Option<&Arc<zeph_worktree::DefaultWorktreeManager>> {
602        self.worktree_manager.as_ref()
603    }
604
605    /// Drain completed hook tasks and spawn a new one if below the limit.
606    ///
607    /// Polls [`hook_tasks`][Self::hook_tasks] for finished entries so the set does not
608    /// accumulate stale handles. When the set is at capacity, logs a warning and skips
609    /// the spawn rather than growing unboundedly.
610    fn spawn_hook_task<F>(&mut self, future: F)
611    where
612        F: std::future::Future<Output = ()> + Send + 'static,
613    {
614        // Drain completed/panicked tasks before checking capacity.
615        while self.hook_tasks.try_join_next().is_some() {}
616        if self.hook_tasks.len() >= self.max_hook_tasks {
617            tracing::warn!(
618                limit = self.max_hook_tasks,
619                "hook task limit reached — dropping fire-and-forget task"
620            );
621            return;
622        }
623        self.hook_tasks.spawn(future);
624    }
625
626    /// Spawns a named subagent task under the session [`TaskSupervisor`] if one is configured,
627    /// making the task visible in TUI status and abortable on shutdown via
628    /// [`TaskSupervisor::shutdown_all`].
629    ///
630    /// Falls back to a transient local supervisor when no session supervisor has been wired via
631    /// [`SubAgentManager::set_task_supervisor`] — the task runs but is not tracked globally.
632    /// The returned [`BlockingHandle`] type is identical in both cases so call sites are uniform.
633    ///
634    /// Every agent loop task's future resolves to a `Result<T, E>` (in practice always
635    /// `Result<String, SubAgentError>`), so this classifies via
636    /// [`TaskSupervisor::spawn_oneshot_classified`] rather than plain `spawn_oneshot` — an
637    /// `Err` produced by a genuinely completed task (e.g. a worktree-quota or cwd-guard setup
638    /// failure returned before the agent loop ever starts) is thus classified and logged as a
639    /// supervisor-level failure instead of a normal completion (#6257).
640    pub(crate) fn spawn_agent_task<F, Fut, T, E>(
641        &self,
642        name: Arc<str>,
643        factory: F,
644    ) -> BlockingHandle<Result<T, E>>
645    where
646        F: FnOnce() -> Fut + Send + 'static,
647        Fut: std::future::Future<Output = Result<T, E>> + Send + 'static,
648        T: Send + 'static,
649        E: Send + 'static,
650    {
651        if let Some(ref sup) = self.task_supervisor {
652            sup.spawn_oneshot_classified(name, factory, Result::is_ok)
653        } else {
654            let local = TaskSupervisor::new(CancellationToken::new());
655            local.spawn_oneshot_classified(name, factory, Result::is_ok)
656        }
657    }
658
659    /// Reserve `n` concurrency slots for the orchestration scheduler.
660    ///
661    /// Reserved slots count against the concurrency limit in [`spawn`](Self::spawn) so that
662    /// the scheduler can guarantee capacity for tasks it is about to launch. Call
663    /// [`release_reservation`](Self::release_reservation) when the scheduler finishes.
664    pub fn reserve_slots(&mut self, n: usize) {
665        self.reserved_slots = self.reserved_slots.saturating_add(n);
666    }
667
668    /// Release `n` previously reserved concurrency slots.
669    pub fn release_reservation(&mut self, n: usize) {
670        self.reserved_slots = self.reserved_slots.saturating_sub(n);
671    }
672
673    /// Configure transcript storage settings.
674    pub fn set_transcript_config(&mut self, dir: Option<PathBuf>, max_files: usize) {
675        self.transcript_dir = dir;
676        self.transcript_max_files = max_files;
677    }
678
679    /// Set config-level lifecycle stop hooks (fired when any agent finishes or is cancelled).
680    pub fn set_stop_hooks(&mut self, hooks: Vec<super::hooks::HookDef>) {
681        self.stop_hooks = hooks;
682    }
683
684    /// Inject a fleet registry so spawned sub-agents appear in the fleet dashboard.
685    ///
686    /// When set, [`spawn`][Self::spawn] registers the session as `Active` and
687    /// [`collect`][Self::collect] / [`cancel`][Self::cancel] mark it terminal.
688    /// Errors from the registry are logged at `warn` level and never propagate to callers.
689    pub fn set_fleet_registry(&mut self, registry: SharedFleetRegistry) {
690        self.fleet_registry = Some(registry);
691    }
692
693    /// Load sub-agent definitions from the given directories.
694    ///
695    /// Higher-priority directories should appear first. Name conflicts are resolved
696    /// by keeping the first occurrence. Non-existent directories are silently skipped.
697    ///
698    /// # Errors
699    ///
700    /// Returns [`SubAgentError`] if any definition file fails to parse.
701    pub fn load_definitions(&mut self, dirs: &[PathBuf]) -> Result<(), SubAgentError> {
702        let defs = SubAgentDef::load_all(dirs)?;
703
704        // Security gate: non-Default permission_mode is forbidden when the user-level
705        // agents directory (~/.zeph/agents/) is one of the load sources. This prevents
706        // a crafted agent file from escalating its own privileges.
707        // Validation happens here (in the manager) because this is the only place
708        // that has full context about which directories were searched.
709        //
710        // FIX-5: fail-closed — if user_agents_dir is in dirs and a definition has
711        // non-Default permission_mode, we cannot verify it did not originate from the
712        // user-level dir (SubAgentDef no longer stores source_path), so we reject it.
713        let user_agents_dir = dirs::home_dir().map(|h| h.join(".zeph").join("agents"));
714        let loads_user_dir = user_agents_dir.as_ref().is_some_and(|user_dir| {
715            // FIX-8: log and treat as non-user-level if canonicalize fails.
716            match std::fs::canonicalize(user_dir) {
717                Ok(canonical_user) => dirs
718                    .iter()
719                    .filter_map(|d| std::fs::canonicalize(d).ok())
720                    .any(|d| d == canonical_user),
721                Err(e) => {
722                    tracing::warn!(
723                        dir = %user_dir.display(),
724                        error = %e,
725                        "could not canonicalize user agents dir, treating as non-user-level"
726                    );
727                    false
728                }
729            }
730        });
731
732        if loads_user_dir {
733            for def in &defs {
734                if def.permissions.permission_mode != PermissionMode::Default {
735                    return Err(SubAgentError::Invalid(format!(
736                        "sub-agent '{}': non-default permission_mode is not allowed for \
737                         user-level definitions (~/.zeph/agents/)",
738                        def.name
739                    )));
740                }
741            }
742        }
743
744        self.definitions = defs;
745        tracing::info!(
746            count = self.definitions.len(),
747            "sub-agent definitions loaded"
748        );
749        Ok(())
750    }
751
752    /// Load definitions with full scope context for source tracking and security checks.
753    ///
754    /// The blocking filesystem scan runs on a dedicated thread via
755    /// `tokio::task::spawn_blocking` so the tokio worker thread is not stalled (#5108).
756    ///
757    /// # Errors
758    ///
759    /// Returns [`SubAgentError`] if a CLI-sourced definition file fails to parse.
760    #[tracing::instrument(name = "subagent.manager.load_definitions_with_sources", skip_all)]
761    pub async fn load_definitions_with_sources(
762        &mut self,
763        ordered_paths: &[PathBuf],
764        cli_agents: &[PathBuf],
765        config_user_dir: Option<&PathBuf>,
766        extra_dirs: &[PathBuf],
767    ) -> Result<(), SubAgentError> {
768        // Clone inputs so they can be moved into spawn_blocking ('static bound).
769        let ordered = ordered_paths.to_vec();
770        let cli = cli_agents.to_vec();
771        let user_dir = config_user_dir.cloned();
772        let extra = extra_dirs.to_vec();
773
774        let defs = tokio::task::spawn_blocking(move || {
775            SubAgentDef::load_all_with_sources(&ordered, &cli, user_dir.as_ref(), &extra)
776        })
777        .await
778        .map_err(|e| SubAgentError::TaskPanic(format!("load_definitions_with_sources: {e}")))?;
779
780        self.definitions = defs?;
781        tracing::info!(
782            count = self.definitions.len(),
783            "sub-agent definitions loaded"
784        );
785        Ok(())
786    }
787
788    /// Return all loaded definitions.
789    #[must_use]
790    pub fn definitions(&self) -> &[SubAgentDef] {
791        &self.definitions
792    }
793
794    /// Return mutable access to the loaded definitions list.
795    ///
796    /// Intended for test harnesses and dynamic definition registration. Production code
797    /// should prefer [`load_definitions`][Self::load_definitions].
798    pub fn definitions_mut(&mut self) -> &mut Vec<SubAgentDef> {
799        &mut self.definitions
800    }
801
802    /// Insert a pre-built handle directly into the active agents map.
803    ///
804    /// Used in tests to simulate an agent that has already run and left a pending secret
805    /// request in its channel without going through the full spawn lifecycle.
806    pub fn insert_handle_for_test(&mut self, id: String, handle: SubAgentHandle) {
807        self.agents.insert(id, handle);
808    }
809}
810
811#[cfg(test)]
812mod tests;