//! Subagent coordinator — spawns and tracks hidden child sessions.
//!
//! All subagent-specific types, tracking state, and orchestration logic live here.
//! `MvpAgent` only wires the channel and calls `handle_subagent_request()`.
//!
//! ## Design
//!
//! - `SubagentCoordinator` owns the active-subagent map (stored as a field on `MvpAgent`).
//! - `handle_subagent_request()` is a free async function that receives a
//!   `SubagentSpawnContext` parameter bag — it never borrows `MvpAgent`.
//! - Child sessions share the parent's hunk tracker, filesystem, terminal, and env
//!   so that edits, bash commands, and file reads go through the same backends.
#![allow(unused_imports)]
use crate::extensions::notification::{SessionNotification, SessionUpdate};
use crate::session::{
    self, SessionCommand, SessionHandle, SessionThread,
    commands::{PromptCompletionKind, PromptTurnResult as SubagentPromptTurnResult},
    fs_watch::FsWatchCapabilities,
    info::Info as SessionInfo,
};
use crate::terminal::AsyncTerminalRunner;
use crate::tools::ToolContext;
use agent_client_protocol as acp;
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
use kigi_hunk_tracker::HunkTrackerHandle;
use kigi_tools::implementations::kigi::task::types::*;
use kigi_workspace::file_system::AsyncFileSystem;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[cfg(test)]
use std::sync::OnceLock;
use tokio::sync::{Notify, mpsc, oneshot};
use tokio_util::sync::CancellationToken;
mod coordinator_lifecycle;
mod coordinator_query;
mod handle_request;
pub(crate) use handle_request::handle_subagent_request;
/// How the child session's initial context was bootstrapped.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum InitialContextSource {
    /// Fresh session — no inherited history.
    New,
    /// Parent history as `<background_context>` (harness-only chat-prefix fork).
    Forked,
    /// Resumed from a previously completed peer subagent. The child inherits
    /// the source's raw transcript, tool state, and model. System prompt and
    /// prompt context are freshly rendered from the current agent definition.
    Resumed,
}
/// Tracks a single active subagent for progress polling and cleanup.
pub(crate) struct SubagentTracker {
    pub subagent_id: String,
    pub parent_session_id: String,
    pub parent_prompt_id: Option<String>,
    pub child_session_id: acp::SessionId,
    pub subagent_type: String,
    pub persona: Option<String>,
    pub description: String,
    pub started_at: std::time::Instant,
    pub child_handle: SessionHandle,
    #[expect(
        dead_code,
        reason = "unused in production; remove expect when wired or delete the item"
    )]
    pub child_thread: SessionThread,
    pub cancel_token: CancellationToken,
    /// ID of the source subagent this session was resumed from.
    pub resumed_from: Option<String>,
    /// Effective cwd used by the child session. Retained so
    /// `move_to_completed` can propagate it to `CompletedSubagent`.
    pub child_cwd: String,
    /// Worktree path if the child used `isolation=worktree`.
    pub worktree_path: Option<PathBuf>,
    /// Effective model ID used by the child session.
    pub effective_model_id: String,
    /// Whether the subagent was launched with `run_in_background: true`.
    pub run_in_background: bool,
    /// Mirrors `SubagentRequest::surface_completion`.
    pub surface_completion: bool,
    /// Set when a `block=true` waiter consumed this subagent's result.
    pub block_waited: bool,
    /// Set when the model explicitly killed this subagent via the kill tool.
    pub explicitly_killed: bool,
    #[expect(
        dead_code,
        reason = "unused in production; remove expect when wired or delete the item"
    )]
    pub color: Option<kigi_agent::config::AgentColor>,
}
/// Captured parent-side tier inputs for resolving
/// `auto_compact_threshold_percent` once the subagent's actual model id is
/// known. Stored on [`SubagentSpawnContext`] so the resolver can run at
/// spawn time and the per-model lookup honors the SUBAGENT's model rather
/// than the parent's.
#[derive(Debug, Clone, Default)]
pub(crate) struct AutoCompactThresholdTiers {
    /// `cfg.session.auto_compact_threshold_percent` (user global TOML).
    pub user_session: Option<u8>,
    /// Subset of `cfg.config_models` whose `auto_compact_threshold_percent`
    /// is set, keyed by the model entry's id (the table key in
    /// `[model.<id>]`). Looked up by the subagent's resolved model id at
    /// spawn time so user per-model overrides for the subagent's model are
    /// honored (not just the parent's).
    pub user_per_model: std::collections::HashMap<String, u8>,
    /// `cfg.remote_settings.auto_compact_threshold_percent` (GB global).
    pub remote_global: Option<u8>,
}
impl AutoCompactThresholdTiers {
    /// Slice the parent's `Config` into the four tier inputs we'll resolve
    /// against later. Only fields relevant to the auto-compact threshold
    /// are captured; the parent's `Config` is not held by reference.
    pub fn capture(cfg: &crate::agent::config::Config) -> Self {
        let user_per_model = cfg
            .config_models
            .iter()
            .filter_map(|(k, v)| v.auto_compact_threshold_percent.map(|t| (k.clone(), t)))
            .collect();
        Self {
            user_session: cfg.session.auto_compact_threshold_percent,
            user_per_model,
            remote_global: cfg
                .remote_settings
                .as_ref()
                .and_then(|r| r.auto_compact_threshold_percent),
        }
    }
}
/// Everything the coordinator needs from MvpAgent to spawn a child session.
///
/// Avoids passing `&MvpAgent` (which would require the coordinator to know
/// about the full agent struct). Built by `MvpAgent::build_subagent_spawn_context()`.
pub(crate) struct SubagentSpawnContext {
    /// Parent's LSP runtime — inherited via ToolContext, same as fs/terminal.
    pub lsp: Option<std::sync::Arc<dyn kigi_tools::implementations::lsp::LspBackend>>,
    #[expect(
        dead_code,
        reason = "unused in production; remove expect when wired or delete the item"
    )]
    pub gateway: GatewaySender,
    /// Parent's client-registered hooks, inherited so the subagent's tool calls hit the
    /// same PreToolUse gate and its events fire the same observe hooks over the parent's
    /// connection. Empty when the parent has none. Filled by the coordinator after the
    /// context is built (an async snapshot from the parent session actor).
    pub client_hooks: crate::extensions::hooks::ClientHooks,
    pub sampling_config: kigi_sampler::SamplerConfig,
    /// The staging auth header value propagated from the parent. Used
    /// when materialising subagent `SamplerConfig`s for auth-flow tracking
    /// and for `inject_url_derived_headers` in the construction helpers.
    pub alpha_test_key: Option<String>,
    pub auth_method_id: acp::AuthMethodId,
    pub model_id: acp::ModelId,
    #[expect(
        dead_code,
        reason = "unused in production; remove expect when wired or delete the item"
    )]
    pub storage_mode: crate::config::StorageMode,
    pub auth: Option<crate::auth::KimiAuth>,
    pub parent_cwd: PathBuf,
    pub parent_session_id: String,
    pub yolo_mode: bool,
    pub subagent_event_tx: mpsc::UnboundedSender<SubagentEvent>,
    pub parent_depth: u32,
    /// Inference idle timeout (secs), resolved from the parent's model config at spawn-context creation time.
    pub inference_idle_timeout_secs: u64,
    /// Tier inputs for resolving `auto_compact_threshold_percent` at
    /// spawn time — once the subagent's actual model id is known.
    /// Lazy because the subagent may be assigned a different model from
    /// the parent (via `[subagents.models]` or `AgentDefinition.model`);
    /// we want the resolver's per-model
    /// tiers to be looked up against the SUBAGENT's model, not the
    /// parent's. Call [`Self::resolve_auto_compact_threshold_percent`]
    /// once the subagent's `effective_sampling_config.model` is known.
    pub auto_compact_threshold_tiers: AutoCompactThresholdTiers,
    /// Parent's hunk tracker handle — cheap Clone, backed by an mpsc channel
    /// to the parent's HunkTrackerActor. Subagent edits are attributed to
    /// the same hunk tracker so the parent sees all file changes.
    pub hunk_tracker_handle: HunkTrackerHandle,
    /// Parent's hunk-tracking gate, inherited so a disabled parent's subagent
    /// also skips the per-event forward instead of paying it into a noop handle.
    pub hunk_tracking_enabled: bool,
    /// Parent's filesystem implementation (LocalFs or AcpSessionFs).
    /// Shared so the child reads/writes the same working tree.
    pub fs: Arc<dyn AsyncFileSystem>,
    /// Parent's terminal runner — shared so bash commands run in the
    /// same terminal environment (env vars, cwd, color settings).
    pub terminal: Arc<dyn AsyncTerminalRunner>,
    /// Parent's terminal backend — shared so background tasks, monitors, and
    /// scheduled tasks survive subagent exit. When `Some`, the subagent session
    /// reuses this backend instead of creating a new `LocalTerminalBackend`.
    pub parent_terminal_backend: Option<Arc<dyn kigi_tools::computer::types::TerminalBackend>>,
    /// Parent's notification handle for reparenting on subagent exit.
    /// When a subagent exits, its surviving tasks (monitors, bg commands)
    /// need their notification handles swapped to this so events route
    /// to the parent's notification bridge.
    pub parent_notification_handle: Option<kigi_tools::notification::types::ToolNotificationHandle>,
    /// Parent's scheduler handle. When `Some`, the subagent reuses the
    /// parent's scheduler actor so scheduled tasks survive subagent exit.
    pub parent_scheduler_handle:
        Option<kigi_tools::implementations::kigi::scheduler::types::SchedulerHandle>,
    /// Parent's session environment variables (.envrc + color settings).
    /// Shared so the child inherits the same env without re-loading.
    pub session_env: Arc<HashMap<String, String>>,
    /// Parent's memory config — shared so the child can access the same
    /// cross-session memory store.
    pub memory_config: Option<crate::config::MemoryConfig>,
    /// Resolved sampling config for web_search.
    pub web_search_config: kigi_tools::implementations::WebSearchConfig,
    /// Resolved config for web fetch.
    pub web_fetch_config: kigi_tools::implementations::kigi::web_fetch::WebFetchConfig,
    /// Resolved config for the deploy service.
    pub app_builder_deployer_config:
        kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig,
    /// Whether the write_file tool is enabled.
    pub write_file_enabled: bool,
    /// Whether goal mode (`/goal`) is enabled.
    pub goal_enabled: bool,
    /// Whether the `ask_user_question` tool is exposed to this subagent,
    /// inherited from the parent session (see `build_subagent_spawn_context`).
    pub ask_user_question_enabled: bool,
    /// Parent session command channel. Carries lifecycle notifications the
    /// parent persists (`SubagentSpawned` / `SubagentFinished`) and — when
    /// goal mode is on — transient `SubagentProgress` ticks the parent
    /// consumes for token accounting without persisting.
    pub parent_cmd_tx: Option<mpsc::UnboundedSender<SessionCommand>>,
    /// Parent session info — used to locate parent session directory.
    pub parent_session_info: Option<SessionInfo>,
    /// Subagent roles config for role-based config layering.
    pub subagent_roles:
        std::collections::HashMap<String, kigi_subagent_resolution::config::SubagentRole>,
    /// Subagent personas config for persona/SOUL layering.
    pub subagent_personas:
        std::collections::HashMap<String, kigi_subagent_resolution::config::SubagentPersona>,
    /// Pre-rendered persona IO summaries for the task tool description.
    /// Threaded through to child sessions for recursive persona discovery.
    pub persona_io_summaries: Vec<String>,
    /// Parent session's ChatStateHandle — used to read the actual live
    /// sampling config and credentials from the parent session actor (async).
    /// Cheap Clone (mpsc sender). `None` when parent SessionHandle not found.
    pub parent_chat_state: Option<kigi_chat_state::ChatStateHandle>,
    /// Parent session's resolved turn limit, for subagent inheritance.
    pub parent_max_turns: Option<usize>,
    /// All available models for resolving model IDs from overrides.
    pub available_models: indexmap::IndexMap<String, crate::agent::config::ModelEntry>,
    /// Per-subagent model ID overrides from config.toml `[subagents.models]`.
    pub subagent_model_overrides: std::collections::HashMap<String, String>,
    /// Per-subagent enable/disable toggles from config.toml `[subagents.toggle]`.
    /// Omitted agents default to enabled (`true`).
    pub subagent_toggle: std::collections::HashMap<String, bool>,
    /// Whether web search is force-disabled via `--disable-web-search`.
    /// Inherited from the parent session.
    pub disable_web_search: bool,
    /// Whether the runtime turn-end TodoGate is force-enabled via
    /// `--todo-gate`. Inherited from the parent session.
    pub todo_gate: bool,
    /// Remote settings snapshot from the parent session. Used to resolve
    /// `ReminderPolicy.todo_gate` (CLI > remote > default) for the subagent.
    pub remote_settings: Option<crate::util::config::RemoteSettings>,
    /// Inherited `--laziness-debug-log <path>` from the parent session.
    /// Subagent classifier fires append to the same log file. `None`
    /// when the parent did not enable debug mode.
    pub laziness_debug_log: Option<std::path::PathBuf>,
    pub backend_tools_enabled: bool,
    /// Whether tools should respect `.gitignore` patterns.
    /// Inherited from the parent session.
    pub respect_gitignore: bool,
    /// Whether to enrich path-not-found errors with hints.
    /// Inherited from the parent session.
    pub path_not_found_hints: bool,
    /// Plugin registry for plugin-aware agent lookup.
    pub plugin_registry: Option<std::sync::Arc<kigi_agent::plugins::PluginRegistry>>,
    /// Shared models manager for etag-triggered refresh.
    pub models_manager: crate::agent::models::ModelsManager,
    /// Pre-resolved file tool overrides (hashline vs standard) from the parent.
    /// `None` means use the standard (default) file tools.
    pub file_tool_overrides: Option<Vec<kigi_tools::registry::types::ToolConfig>>,
    /// Parent session's agent config snapshot.
    pub agent_config: Option<crate::agent::config::Config>,
    pub hook_registry: Option<std::sync::Arc<kigi_hooks::discovery::HookRegistry>>,
    #[expect(
        dead_code,
        reason = "unused in production; remove expect when wired or delete the item"
    )]
    pub hook_workspace_root: String,
    pub permission_handle: Option<kigi_workspace::permission::PermissionHandle>,
    pub worktree_type: crate::util::config::WorktreeType,
    pub api_key_provider: Option<kigi_tools::types::SharedApiKeyProvider>,
    pub image_description_model: String,
    /// Dual-mode workspace operations handle.
    pub workspace_ops: kigi_workspace::WorkspaceOps,
    pub auth_manager: std::sync::Arc<crate::auth::AuthManager>,
    /// The parent SessionActor's live
    /// `Auth401AttributionCallback`, captured at spawn time.
    /// Subagents inherit this so the child's `OaiCompatClient` 401
    /// sites emit attribution under the parent's session id, joined
    /// with the parent's live `AuthManager`.
    ///
    /// Note: this is the load-bearing source of the inherited
    /// callback. Reading from `ctx.sampling_config.attribution_callback`
    /// would not work because the baseline `MvpAgent.sampling_config`
    /// goes through `agent/config.rs::sampling_config_for_model`
    /// which always sets that field to `None`.
    pub attribution_callback: Option<kigi_sampler::SharedAttributionCallback>,
    /// Parent session's agent name (e.g. "kigi").
    pub parent_agent_name: Option<String>,
    /// `agent_type` of the parent's current model — the harness-flavor fallback
    /// when `parent_agent_name` is not a recognized harness, e.g. a custom
    /// client profile keeps its own name but runs a strict-harness model.
    /// `None` when the model is not in the catalog.
    pub parent_model_agent_type: Option<String>,
    pub allowed_subagent_types: Option<Vec<String>>,
    /// Parent's MCP server configs for resolving named references in agent mcpServers.
    ///
    /// NOTE: This is a snapshot from `SessionHandle` (populated at spawn_session_actor
    /// time). Servers added later via `UpdateMcpServers` (managed MCPs, plugin reload)
    /// will not appear here. Named references only resolve against the initial config.
    pub parent_mcp_configs: Vec<agent_client_protocol::McpServer>,
    /// Snapshot of the parent session's MCP client pool at spawn time.
    pub parent_mcp_pool: Option<crate::session::mcp_servers::SharedMcpPool>,
    /// Snapshot of the parent session's resolved tool schema at spawn time.
    /// `Some` only when a fork parent's actor answered; threaded to the child so a
    /// verbatim mirror-fork sends the parent's exact tool prefix for cache reuse.
    pub parent_tool_snapshot: Option<Vec<kigi_sampling_types::ToolSpec>>,
    /// Pre-discovered skills from the parent session, captured at spawn time.
    pub parent_skills: Option<Vec<kigi_tools::implementations::skills::types::SkillInfo>>,
    /// Parent's skills config for the child's SkillManager.
    pub parent_skills_config: kigi_agent::prompt::skills::SkillsConfig,
    /// Parent's resolved vendor-compat config, inherited by the child so its
    /// skills / rules / AGENTS.md discovery honors the same vendor toggles.
    pub parent_compat: kigi_tools::types::compat::CompatConfig,
    /// Shared set of IDs delivered via auto-wake synthetic prompts.
    pub auto_wake_delivered: Option<kigi_tools::reminders::task_completion::AutoWakeDeliveredIds>,
    /// Resolved name of the `BackgroundTaskAction` tool in the parent's toolset.
    pub task_output_tool_name: String,
    /// Whether auto-wake is enabled. When `false`, subagent completions
    /// are not injected as synthetic prompts.
    pub auto_wake_enabled: bool,
    /// Parent's live goal-loop gate (shared `Arc`). When set, the subagent
    /// auto-wake synthetic prompt is suppressed so an async completion wake
    /// doesn't derail the parent mid-`/goal`; surfaces 2/3 still drain it.
    pub goal_loop_active: Arc<std::sync::atomic::AtomicBool>,
    /// Parent's `blocking_wait_depth` (same `Arc`). A foreground spawn holds a
    /// `BlockingWaitGuard` on it for the blocking await so `queue_input` routes
    /// a prompt sent during the wait onto send-now; never for background spawns.
    pub parent_blocking_wait_depth: Arc<std::sync::atomic::AtomicUsize>,
}
impl SubagentSpawnContext {
    /// Check if a subagent is enabled via the toggle config.
    /// Returns `true` if the agent is not in the toggle map (default enabled).
    fn is_subagent_enabled(&self, name: &str) -> bool {
        self.subagent_toggle.get(name).copied().unwrap_or(true)
    }
    /// Resolve `auto_compact_threshold_percent` for the subagent's actual
    /// model id (the one selected by `resolve_subagent_sampling_config`,
    /// not the parent's). Walks the same precedence as the main session's
    /// resolver: env > user [model.<id>] > user [session] > GB per-model
    /// > GB global > 85.
    ///
    /// The GB per-model tier is read from `available_models` (the same
    /// catalog used to pick the subagent's `SamplerConfig`); user TOML and
    /// GB global tiers are sourced from the parent's snapshot captured at
    /// spawn-context build time.
    pub fn resolve_auto_compact_threshold_percent(&self, subagent_model_id: &str) -> u8 {
        let gb_per_model =
            crate::agent::config::find_model_by_id(&self.available_models, subagent_model_id)
                .and_then(|e| e.info.auto_compact_threshold_percent);
        crate::util::config::resolve_auto_compact_threshold_percent_from_tiers(
            self.auto_compact_threshold_tiers
                .user_per_model
                .get(subagent_model_id)
                .copied(),
            self.auto_compact_threshold_tiers.user_session,
            gb_per_model,
            self.auto_compact_threshold_tiers.remote_global,
        )
    }
    /// Bind a spawned subagent by the parent session's `--tools`/
    /// `--disallowed-tools`/`--permission-mode` restrictions.
    fn apply_session_cli_overrides(&self, def: &mut kigi_agent::config::AgentDefinition) {
        if let Some(ref cfg) = self.agent_config {
            cfg.cli_agent_overrides.apply_to_subagent_definition(def);
        }
    }
    /// Subagent verbatim-input flag, mirroring `Config::resolve_compaction_verbatim_input` (env > config > remote settings > default `true`).
    pub fn resolve_compaction_verbatim_input(&self) -> bool {
        crate::agent::config::BoolFlag::env("KIGI_COMPACTION_VERBATIM_INPUT")
            .config(
                self.agent_config
                    .as_ref()
                    .and_then(|c| c.features.compaction_verbatim_input),
            )
            .feature_flag(
                self.remote_settings
                    .as_ref()
                    .and_then(|r| r.compaction_verbatim_input),
            )
            .default(true)
            .resolve()
            .value
    }
    /// Whether a completed subagent's worktree is snapshotted into a durable ref
    /// and its directory deleted. Resolution mirrors the other subagent gates
    /// (env > config > remote settings > default). Default `false` so it ships dark;
    /// `managed_config.toml` `[features] subagent_worktree_snapshot` is the
    /// per-deployment rollout lever.
    pub fn resolve_subagent_worktree_snapshot_enabled(&self) -> bool {
        crate::agent::config::BoolFlag::env("KIGI_SUBAGENT_WORKTREE_SNAPSHOT")
            .config(
                self.agent_config
                    .as_ref()
                    .and_then(|c| c.features.subagent_worktree_snapshot),
            )
            .feature_flag(
                self.remote_settings
                    .as_ref()
                    .and_then(|r| r.subagent_worktree_snapshot_enabled),
            )
            .default(false)
            .resolve()
            .value
    }
    /// Per-tool params for the child's spawn. The ask_user_question timeout is
    /// session-level config, so it is resolved from the same tiers as the
    /// parent (requirements/env/user/managed from disk; remote from the
    /// parent's snapshot) and follows the session into subagents. Bash stays
    /// on tool defaults, as before that knob existed.
    pub fn resolve_tool_params_json(
        &self,
    ) -> crate::session::agent_rebuild::ResolvedToolParamsJson {
        let params = crate::util::config::resolve_ask_user_question_params_from_disk(
            self.remote_settings.as_ref(),
        );
        crate::session::agent_rebuild::ResolvedToolParamsJson {
            bash: None,
            ask_user_question: match serde_json::to_value(params) {
                Ok(serde_json::Value::Object(map)) => Some(map),
                _ => None,
            },
        }
    }
}
/// A completed subagent entry retained for `TaskOutputTool` polling
/// and `resume_from` resolution.
pub(crate) struct CompletedSubagent {
    pub subagent_id: String,
    pub parent_session_id: String,
    pub parent_prompt_id: Option<String>,
    pub child_session_id: String,
    pub description: String,
    pub subagent_type: String,
    pub persona: Option<String>,
    pub started_at: std::time::Instant,
    /// When the subagent moved to the completed map. Used for TTL eviction.
    pub completed_at: std::time::Instant,
    pub result: SubagentResult,
    /// ID of the source subagent this session was resumed from.
    pub resumed_from: Option<String>,
    /// Effective cwd used by the child session (worktree path or parent cwd).
    /// Required to reconstruct `SessionInfo` for `resume_from`.
    pub child_cwd: String,
    /// Path to the isolated worktree, if the child used `isolation=worktree`.
    pub worktree_path: Option<PathBuf>,
    /// Durable git ref snapshotting the worktree's working state, if captured.
    pub snapshot_ref: Option<String>,
    /// Effective model ID used by the child session.
    pub effective_model_id: String,
    /// Set when a `block=true` waiter consumed this subagent's result.
    pub block_waited: bool,
    /// Set when the model explicitly killed this subagent via the kill tool.
    pub explicitly_killed: bool,
}
/// Lightweight entry for subagents that have been requested but are still
/// initializing (creating worktree, resolving config, spawning session).
/// Promoted to a full `SubagentTracker` once the child session is ready.
pub(crate) struct PendingSubagent {
    pub subagent_id: String,
    pub subagent_type: String,
    pub description: String,
    pub persona: Option<String>,
    pub parent_prompt_id: Option<String>,
    pub parent_session_id: String,
    pub started_at: std::time::Instant,
    pub run_in_background: bool,
    /// Mirrors `SubagentRequest::surface_completion`.
    pub surface_completion: bool,
    #[expect(
        dead_code,
        reason = "unused in production; remove expect when wired or delete the item"
    )]
    pub color: Option<kigi_agent::config::AgentColor>,
    /// Spawn-future cancel token; firing it aborts the spawn at the promote
    /// checkpoint and emits a cancelled `SubagentFinished`.
    pub cancel_token: CancellationToken,
}
/// Parameter bag for `SubagentCoordinator::record_failure_completion`.
struct FailureCompletion<'a> {
    subagent_id: String,
    subagent_type: String,
    description: String,
    parent_prompt_id: Option<String>,
    parent_session_id: String,
    persona: Option<String>,
    started_at: std::time::Instant,
    error: &'a str,
    surface_completion: bool,
    /// Terminal status `"cancelled"` rather than `"failed"` (pending subagent
    /// killed mid-initialization).
    cancelled: bool,
}
/// Shared reply slot for one live blocking `SubagentQueryRequest`.
///
/// The query poll loop (`subagent_coordinator.rs`) parks the oneshot sender
/// here and delivers through it; the completion handler reads it to verify —
/// at auto-wake decision time — whether a blocking waiter can still receive
/// the result. `Rc` is safe: the coordinator and all its users live on the
/// agent's single-threaded `LocalSet` (see `spawn_local` in
/// `start_subagent_coordinator`).
pub(crate) type BlockWaitSlot =
    std::rc::Rc<std::cell::RefCell<Option<oneshot::Sender<Option<SubagentSnapshot>>>>>;
/// Owns the active-subagent map and completed-result cache.
/// Stored as a field on `MvpAgent`.
///
/// Methods on this struct contain all the orchestration logic so
/// `mvp_agent/mod.rs` stays thin.
pub(crate) struct SubagentCoordinator {
    /// subagent_id → pending entry (initializing subagents)
    pending: HashMap<String, PendingSubagent>,
    /// subagent_id → tracker (running subagents)
    active: HashMap<String, SubagentTracker>,
    /// subagent_id → completed result (finished subagents, kept for polling)
    completed: HashMap<String, CompletedSubagent>,
    /// Notified each time a subagent moves to the completed state.
    /// Multi-wait infrastructure subscribes via `completion_notify()`.
    completion_notify: Arc<Notify>,
    /// Completions buffered for between-turn delivery.
    pending_completions: Vec<SubagentCompletionSummary>,
    /// Whether the model's turn is currently active. Shared with the session
    /// via `Arc` so it can be toggled at turn boundaries from `handle_prompt`.
    is_turn_active: Arc<std::sync::atomic::AtomicBool>,
    /// Gauge of initializing + running subagents (`pending.len() +
    /// active.len()`), kept in sync by [`Self::sync_running_gauge`]. Read by
    /// [`crate::agent::activity::AgentActivity::is_busy`] to defer leader
    /// auto-update shutdown while subagents are in flight.
    running_gauge: Arc<std::sync::atomic::AtomicUsize>,
    /// subagent_id → live blocking-query reply slots. Registered together
    /// with `block_waited` so the completion handler can verify at decision
    /// time that a waiter is actually able to receive the result (a
    /// cancelled turn drops the receiver; the sticky flag alone would
    /// wrongly suppress the completion auto-wake).
    block_wait_slots: HashMap<String, Vec<BlockWaitSlot>>,
    /// Prompts whose subagent usage landed session-only or failed to apply.
    /// A report-level incomplete signal only: not a token sink, and it never
    /// marks ledgers by itself (a true apply-miss marks them at fold time).
    /// Cleared on freeze/cancel. See AGENTS.md rule 3 for the completeness model.
    subagent_usage_not_applied_prompts: std::collections::HashSet<String>,
}
fn tracker_to_summary(t: &SubagentTracker) -> ActiveSubagentSummary {
    ActiveSubagentSummary {
        subagent_id: t.subagent_id.clone(),
        subagent_type: t.subagent_type.clone(),
        description: t.description.clone(),
        elapsed_ms: t.started_at.elapsed().as_millis() as u64,
    }
}
/// Result of `SubagentCoordinator::lookup()`.
///
/// Separates the synchronous map lookup from the async signals query so
/// callers can drop the `RefCell<SubagentCoordinator>` borrow before
/// awaiting.
pub(crate) enum SnapshotLookup {
    /// Subagent is finished — snapshot is fully resolved.
    Ready(SubagentSnapshot),
    /// Subagent is still running — caller must await `resolve_snapshot()`
    /// to populate the live progress fields.
    NeedsSignals(RunningSnapshotSeed),
}
/// Metadata extracted synchronously from an active `SubagentTracker`,
/// plus a cloned `SessionSignalsHandle` for the async progress query.
pub(crate) struct RunningSnapshotSeed {
    pub(crate) subagent_id: String,
    pub(crate) description: String,
    pub(crate) subagent_type: String,
    pub(crate) started_at_epoch_ms: u64,
    pub(crate) duration_ms: u64,
    pub(crate) persona: Option<String>,
    pub(crate) signals_handle: crate::session::signals::SessionSignalsHandle,
}
/// Resolve an `Option<SnapshotLookup>` into `Option<SubagentSnapshot>`.
///
/// - `None` → `None` (subagent not found).
/// - `Ready` → returns the completed snapshot unchanged.
/// - `NeedsSignals` → awaits `signals_handle.snapshot()` to populate the
///   `Running { ... }` fields.
///
/// This is the **single** async helper used by both the immediate query
/// path and the `block=true` polling loop.
pub(crate) async fn resolve_snapshot(lookup: Option<SnapshotLookup>) -> Option<SubagentSnapshot> {
    match lookup? {
        SnapshotLookup::Ready(snap) => Some(snap),
        SnapshotLookup::NeedsSignals(seed) => {
            let signals = seed.signals_handle.snapshot().await.unwrap_or_default();
            Some(SubagentSnapshot {
                subagent_id: seed.subagent_id,
                description: seed.description,
                subagent_type: seed.subagent_type,
                started_at_epoch_ms: seed.started_at_epoch_ms,
                duration_ms: seed.duration_ms,
                persona: seed.persona,
                status: SubagentSnapshotStatus::Running {
                    turn_count: signals.turn_count,
                    tool_call_count: signals.tool_call_count,
                    tokens_used: signals.context_tokens_used,
                    context_window_tokens: signals.context_window_tokens,
                    context_usage_pct: signals.context_window_usage,
                    tools_used: signals.tools_used,
                    error_count: signals.error_count,
                },
            })
        }
    }
}
/// Check whether a resolved snapshot is still in the `Running` state.
pub(crate) fn is_running(snap: &SubagentSnapshot) -> bool {
    matches!(
        snap.status,
        SubagentSnapshotStatus::Running { .. } | SubagentSnapshotStatus::Initializing
    )
}
/// Seed for one running subagent returned by
/// `SubagentCoordinator::list_running_for_parent()`.
///
/// Includes identity fields not present on the single-item
/// `RunningSnapshotSeed` (`parent_session_id`, `child_session_id`).
pub(crate) struct RunningSubagentListSeed {
    pub(crate) subagent_id: String,
    pub(crate) parent_session_id: String,
    pub(crate) child_session_id: String,
    pub(crate) subagent_type: String,
    pub(crate) description: String,
    pub(crate) started_at_epoch_ms: u64,
    pub(crate) duration_ms: u64,
    pub(crate) signals_handle: crate::session::signals::SessionSignalsHandle,
}
/// Resolved running subagent with live progress from `SessionSignals`.
///
/// Produced by `resolve_running_list()` and consumed by the ACP extension
/// layer for DTO conversion.
pub(crate) struct ResolvedRunningSubagent {
    pub(crate) subagent_id: String,
    pub(crate) parent_session_id: String,
    pub(crate) child_session_id: String,
    pub(crate) subagent_type: String,
    pub(crate) description: String,
    pub(crate) started_at_epoch_ms: u64,
    pub(crate) duration_ms: u64,
    pub(crate) turn_count: u32,
    pub(crate) tool_call_count: u32,
    pub(crate) tokens_used: u64,
    pub(crate) context_window_tokens: u64,
    pub(crate) context_usage_pct: u8,
    pub(crate) tools_used: Vec<String>,
    pub(crate) error_count: u32,
}
/// Resolve a list of running subagent seeds concurrently into
/// `ResolvedRunningSubagent` values with live progress data.
///
/// Uses `join_all` to pull signal snapshots in parallel rather than
/// serially awaiting each handle.
pub(crate) async fn resolve_running_list(
    seeds: Vec<RunningSubagentListSeed>,
) -> Vec<ResolvedRunningSubagent> {
    let futs = seeds.into_iter().map(|seed| async move {
        let signals = seed.signals_handle.snapshot().await.unwrap_or_default();
        ResolvedRunningSubagent {
            subagent_id: seed.subagent_id,
            parent_session_id: seed.parent_session_id,
            child_session_id: seed.child_session_id,
            subagent_type: seed.subagent_type,
            description: seed.description,
            started_at_epoch_ms: seed.started_at_epoch_ms,
            duration_ms: seed.duration_ms,
            turn_count: signals.turn_count,
            tool_call_count: signals.tool_call_count,
            tokens_used: signals.context_tokens_used,
            context_window_tokens: signals.context_window_tokens,
            context_usage_pct: signals.context_window_usage,
            tools_used: signals.tools_used,
            error_count: signals.error_count,
        }
    });
    futures::future::join_all(futs).await
}
use kigi_subagent_resolution::ResumeSourceData;
/// Resume provenance metadata for a subagent.
#[derive(Debug, Clone, Default)]
pub(crate) struct SubagentProvenance {
    pub(crate) fork_parent_prompt_id: Option<String>,
    /// ID of the source subagent this session was resumed from.
    pub(crate) resumed_from: Option<String>,
}
/// Convert a `std::time::Instant` to approximate epoch milliseconds.
///
/// `Instant` has no absolute epoch, so we compute the offset from
/// `SystemTime::now()` at the time of the call. This is approximate
/// (a few ms of drift) but sufficient for display purposes.
fn instant_to_epoch_ms(instant: std::time::Instant) -> u64 {
    let now_instant = std::time::Instant::now();
    let now_system = std::time::SystemTime::now();
    let elapsed_since_instant = now_instant.saturating_duration_since(instant);
    let system_at_instant = now_system
        .checked_sub(elapsed_since_instant)
        .unwrap_or(now_system);
    system_at_instant
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as u64
}
use kigi_subagent_resolution::resolve_effective_overrides;
/// Resolve the sampling config and model ID for a subagent.
///
/// Subagents inherit the parent session's model by default. Only an
/// EXPLICIT per-agent pin can override that inheritance; there is no global
/// default model and no parent-model gate. Precedence (highest to lowest):
///
///   1. `config.toml [subagents.models].{agent_name}` override, if it
///      resolves to a known model. Applies unconditionally.
///
///   2. `AgentDefinition.model = Override(id)`, if it resolves to a known
///      model. Applies unconditionally.
///
///   3. Inherit the parent session's actual live sampling config (from
///      `ChatStateHandle`).
///
/// Both explicit pins apply regardless of which model the parent is on. If a
/// pin references an unknown model it is ignored (with a `tracing::warn!`)
/// and resolution falls through to the next priority.
///
/// NOTE: the persona/role/runtime override (`effective_runtime.model`) is
/// applied by the caller (`handle_subagent_request`) BEFORE this function
/// runs, so it is not handled here.
///
/// NOTE: `agent_type` and `use_concise` on the resolved model are
/// intentionally ignored. Subagent prompt/toolset is always determined by
/// the `AgentDefinition`, not the model. See design spec
/// "Behavioral Rules section 3".
async fn resolve_subagent_sampling_config(
    agent_name: &str,
    agent_model: &kigi_agent::config::ModelOverride,
    ctx: &SubagentSpawnContext,
) -> (kigi_sampler::SamplerConfig, acp::ModelId) {
    use kigi_agent::config::ModelOverride;
    let (parent_config, parent_mid) = read_parent_sampling_config(ctx).await;
    let try_pin = |model_id: &str, source: &'static str, unknown_msg: &'static str| {
        match resolve_model_override_to_config(model_id, ctx) {
            Some((config, canonical_id)) => {
                log_subagent_model_resolution(
                    agent_name,
                    source,
                    &config,
                    &canonical_id,
                    &parent_config,
                );
                Some((config, canonical_id))
            }
            None => {
                tracing::warn!(agent = agent_name, model_id, "{unknown_msg}");
                None
            }
        }
    };
    if let Some(model_id) = ctx.subagent_model_overrides.get(agent_name)
        && let Some(resolved) = try_pin(
            model_id,
            "config_override",
            "Subagent model override references unknown model, falling through to inherit",
        )
    {
        return resolved;
    }
    if let ModelOverride::Override(model_id) = agent_model
        && let Some(resolved) = try_pin(
            model_id,
            "agent_definition",
            "Agent definition model references unknown model, falling through to inherit",
        )
    {
        return resolved;
    }
    log_subagent_model_resolution(
        agent_name,
        "inherit_parent",
        &parent_config,
        &parent_mid,
        &parent_config,
    );
    (parent_config, parent_mid)
}
/// Resolve a subagent's effective sampling config + model id, honoring the
/// model-resolution precedence (Key Decision #16).
///
/// An explicit `runtime_override_model` — the goal role model or a persona
/// override carried on `effective_runtime.model` — is resolved HERE, BEFORE
/// [`resolve_subagent_sampling_config`] (where the user `[subagents.models]`
/// pin and `AgentDefinition.model` apply). So a goal/persona override WINS
/// over a user per-agent pin. An override that does not resolve to a known
/// model warns and falls through to the pin path; `None` (inherit) hands
/// precedence back to the pin path entirely (pin > agent-def > inherit).
///
/// Extracted from `handle_subagent_request` so the precedence is unit-testable
/// without spawning a child session.
async fn resolve_effective_model_config(
    runtime_override_model: Option<&str>,
    subagent_type: &str,
    definition_model: &kigi_agent::config::ModelOverride,
    ctx: &SubagentSpawnContext,
) -> (kigi_sampler::SamplerConfig, acp::ModelId) {
    if let Some(model_id) = runtime_override_model {
        if let Some(resolved) = resolve_model_override_to_config(model_id, ctx) {
            return resolved;
        }
        tracing::warn!(
            model_id,
            "Runtime model override references unknown model, falling through"
        );
    }
    resolve_subagent_sampling_config(subagent_type, definition_model, ctx).await
}
/// Truncate an API key to a safe prefix for logging.
fn key_prefix(key: &Option<String>) -> String {
    match key {
        Some(k) => {
            let len = k.len().min(8);
            k[..len].to_string()
        }
        None => "<none>".to_string(),
    }
}
/// Emit a unified log entry recording which model and credentials a subagent
/// resolved to, and how they compare to the parent's.
fn log_subagent_model_resolution(
    agent_name: &str,
    priority: &str,
    resolved: &kigi_sampler::SamplerConfig,
    resolved_id: &acp::ModelId,
    parent: &kigi_sampler::SamplerConfig,
) {
    let child_key = key_prefix(&resolved.api_key);
    let parent_key = key_prefix(&parent.api_key);
    let keys_match = resolved.api_key == parent.api_key;
    kigi_log::unified_log::debug(
        "subagent model resolved",
        None,
        Some(serde_json::json!(
            { "agent" : agent_name, "priority" : priority, "child_model" :
            resolved_id.0.as_ref(), "child_base_url" : & resolved.base_url,
            "child_key_prefix" : child_key, "parent_model" : & parent.model,
            "parent_base_url" : & parent.base_url, "parent_key_prefix" : parent_key,
            "keys_match" : keys_match, }
        )),
    );
}
/// Read the parent session's actual current sampling config.
///
/// Prefers the live state from `ChatStateHandle` (authoritative). Falls back
/// to the baseline on `SubagentSpawnContext` if the actor is unavailable.
/// The returned [`acp::ModelId`] is the parent session catalog id (`ctx.model_id`),
/// not the process-global default or chat-state routing slug.
async fn read_parent_sampling_config(
    ctx: &SubagentSpawnContext,
) -> (kigi_sampler::SamplerConfig, acp::ModelId) {
    if let Some(ref chat_state) = ctx.parent_chat_state {
        if let Some(cfg) = chat_state.get_sampling_config().await {
            let creds = chat_state.get_credentials().await;
            let mut extra_headers = cfg.extra_headers;
            crate::agent::config::inject_url_derived_headers(
                &mut extra_headers,
                creds.alpha_test_key.as_deref(),
                &cfg.base_url,
            );
            let auth_scheme = crate::agent::config::try_resolve_model_credentials(&cfg.model, None)
                .map(|r| r.auth_scheme)
                .unwrap_or_default();
            // Claude Pro/Max OAuth Messages adaptation inherits from the parent
            // model's platform (claude-pro-max → true); every other platform,
            // and BYOK, → false, so the API-key paths stay byte-identical.
            let anthropic_oauth = kigi_models::parse_managed_model_key(ctx.model_id.0.as_ref())
                .is_some_and(|(platform, _)| {
                    platform.oauth().is_some()
                        && platform.wire_api() == kigi_models::PlatformWireApi::Messages
                });
            // GitHub Copilot editor headers inherit from the parent model's
            // platform (github-copilot → true); every other platform / BYOK →
            // false, so the other ChatCompletions paths stay byte-identical.
            let github_copilot = kigi_models::parse_managed_model_key(ctx.model_id.0.as_ref())
                .is_some_and(|(platform, _)| platform.sends_copilot_editor_headers());
            // ChatGPT/Codex headers inherit from the parent model's platform
            // (openai-codex → true); every other platform / BYOK → false, so the
            // API-key openai Responses path stays byte-identical.
            let openai_codex = kigi_models::parse_managed_model_key(ctx.model_id.0.as_ref())
                .is_some_and(|(platform, _)| platform.sends_codex_responses_headers());
            let inherited = kigi_sampler::SamplerConfig {
                api_key: creds.api_key,
                base_url: cfg.base_url,
                model: cfg.model.clone(),
                max_completion_tokens: cfg.max_completion_tokens,
                temperature: cfg.temperature,
                top_p: cfg.top_p,
                api_backend: cfg.api_backend,
                auth_scheme,
                anthropic_oauth,
                github_copilot,
                openai_codex,
                chat_compat: cfg.chat_compat,
                extra_headers,
                context_window: cfg.context_window.get(),
                reasoning_effort: cfg.reasoning_effort,
                force_http1: false,
                max_retries: None,
                stream_tool_calls: cfg.stream_tool_calls.unwrap_or(false),
                idle_timeout_secs: None,
                origin_client: ctx.sampling_config.origin_client.clone(),
                attribution_callback: ctx.attribution_callback.clone(),
                bearer_resolver: None,
                supports_backend_search: ctx
                    .models_manager
                    .model_supports_backend_search(ctx.model_id.0.as_ref()),
                compactions_remaining: ctx
                    .models_manager
                    .model_compactions_remaining(ctx.model_id.0.as_ref()),
                compaction_at_tokens: ctx
                    .models_manager
                    .model_compaction_at_tokens(ctx.model_id.0.as_ref()),
                doom_loop_recovery: ctx.sampling_config.doom_loop_recovery,
                header_injector: ctx.sampling_config.header_injector.clone(),
            };
            let model_id = ctx.model_id.clone();
            let global_model_id = ctx.models_manager.current_model_id();
            kigi_log::unified_log::debug(
                "subagent read parent config (live)",
                None,
                Some(serde_json::json!(
                    { "parent_model" : & inherited.model, "parent_base_url" : &
                    inherited.base_url, "parent_key_prefix" : key_prefix(& inherited
                    .api_key), "session_model_id" : model_id.0.as_ref(),
                    "global_model_id" : global_model_id.0.as_ref(), "source" :
                    "chat_state", }
                )),
            );
            return (inherited, model_id);
        }
        tracing::warn!(
            "Parent chat state actor returned None for sampling config, \
             falling back to spawn context baseline"
        );
    }
    kigi_log::unified_log::warn(
        "subagent read parent config (fallback)",
        None,
        Some(serde_json::json!(
            { "parent_model" : & ctx.sampling_config.model, "parent_base_url" : & ctx
            .sampling_config.base_url, "parent_key_prefix" : key_prefix(& ctx
            .sampling_config.api_key), "source" : "spawn_context_baseline",
            "has_chat_state" : ctx.parent_chat_state.is_some(), }
        )),
    );
    let mut fallback = ctx.sampling_config.clone();
    fallback.supports_backend_search = ctx
        .models_manager
        .model_supports_backend_search(ctx.model_id.0.as_ref());
    fallback.compactions_remaining = ctx
        .models_manager
        .model_compactions_remaining(ctx.model_id.0.as_ref());
    fallback.compaction_at_tokens = ctx
        .models_manager
        .model_compaction_at_tokens(ctx.model_id.0.as_ref());
    (fallback, ctx.model_id.clone())
}
/// `AuthType` for a subagent: BYOK ⇒ `ApiKey` (don't overwrite the BYOK
/// key); session-based ACP method ⇒ `SessionToken` (keep refresh wired);
/// otherwise `ApiKey`.
fn subagent_auth_type(
    model: Option<&crate::agent::config::ModelEntry>,
    auth_method_id: &acp::AuthMethodId,
) -> kigi_chat_state::AuthType {
    if model.is_some_and(|m| m.has_own_credentials()) {
        kigi_chat_state::AuthType::ApiKey
    } else if crate::agent::auth_method::is_session_based_method(auth_method_id) {
        kigi_chat_state::AuthType::SessionToken
    } else {
        kigi_chat_state::AuthType::ApiKey
    }
}
/// Resolve a model override string (config key or model ID) to a
/// `(SamplerConfig, ModelId)` pair.
fn resolve_model_override_to_config(
    model_id: &str,
    ctx: &SubagentSpawnContext,
) -> Option<(kigi_sampler::SamplerConfig, acp::ModelId)> {
    use crate::agent::config::{resolve_credentials, sampling_config_for_model};
    let entry = crate::agent::config::find_model_by_id(&ctx.available_models, model_id).cloned()?;
    let canonical_model_id = if ctx.available_models.contains_key(model_id) {
        acp::ModelId::new(model_id)
    } else {
        acp::ModelId::new(entry.info().model.clone())
    };
    // Resolve the child's session token by the OVERRIDE model's OWN platform
    // AND endpoint, not the parent's primary auth: a grok (oauth-platform)
    // override draws its pooled grok token or `None`, and an API-key registry
    // platform / a third-party `[model.*]` host draws NOTHING — NEVER the
    // primary Kimi session token, which `resolve_credentials` would otherwise
    // stamp onto the child's api.x.ai / api.moonshot.cn credentials. The
    // first-party subscription channel still resolves to the primary
    // (byte-identical).
    let session_key = crate::auth::credential_authority::CredentialAuthority::new(
        ctx.models_manager.endpoints(),
        Some(ctx.auth_manager.clone()),
    )
    .credential_for_model(&entry);
    let has_session_key = session_key.is_some();
    let mut credentials = resolve_credentials(&entry, session_key.as_ref());
    credentials.auth_type = subagent_auth_type(Some(&entry), &ctx.auth_method_id);
    let resolved_auth_type = credentials.auth_type;
    let config = sampling_config_for_model(&entry, credentials, ctx.alpha_test_key.clone());
    kigi_log::unified_log::debug(
        "subagent resolve_model_override_to_config",
        None,
        Some(serde_json::json!(
            { "model_id" : model_id, "canonical_model" : canonical_model_id.0
            .as_ref(), "resolved_model_raw" : & config.model, "base_url" : & config
            .base_url, "key_prefix" : key_prefix(& config.api_key),
            "has_own_credentials" : entry.has_own_credentials(), "has_session_key" :
            has_session_key, "auth_type" : format!("{:?}", resolved_auth_type),
            "auth_method_id" : ctx.auth_method_id.0.as_ref(), }
        )),
    );
    Some((config, canonical_model_id))
}
/// Leading items to preserve across compaction on resume: the System head only, so the
/// resumed body (the child's own work) stays compactable. Returns 0 when there's no
/// leading System; the spawn path then inserts one and bumps the prefix to 1.
pub(crate) fn resume_inherited_prefix_len(
    conversation: &[kigi_sampling_types::conversation::ConversationItem],
) -> usize {
    use kigi_sampling_types::conversation::ConversationItem;
    conversation
        .iter()
        .take_while(|i| matches!(i, ConversationItem::System(_)))
        .count()
}
/// How a subagent's initial conversation was bootstrapped.
struct InitialContext {
    source: InitialContextSource,
    copy_error: Option<String>,
    prefix_len: Option<usize>,
    conversation: Vec<kigi_sampling_types::conversation::ConversationItem>,
    /// True only for a verbatim mirror-fork (parent items copied byte-for-byte).
    /// Gates sending the parent tool snapshot so the child's full request prefix
    /// matches the parent. A summarized-fork fallback leaves this false.
    verbatim_fork: bool,
}
/// Resume bootstrap: preserve only the System head (see `resume_inherited_prefix_len`).
fn resume_initial_context(
    conversation: Vec<kigi_sampling_types::conversation::ConversationItem>,
) -> InitialContext {
    InitialContext {
        source: InitialContextSource::Resumed,
        copy_error: None,
        prefix_len: Some(resume_inherited_prefix_len(&conversation)),
        conversation,
        verbatim_fork: false,
    }
}
/// Apply `fork_filter_chat` then normalize; empty or System-only input (no
/// `<background_context>` produced) fails open to `New`.
fn forked_initial_context(
    mut items: Vec<kigi_sampling_types::conversation::ConversationItem>,
) -> InitialContext {
    crate::session::storage::jsonl::fork_filter_chat(&mut items);
    if items.is_empty() {
        return InitialContext {
            source: InitialContextSource::New,
            copy_error: Some("empty parent conversation".to_string()),
            prefix_len: None,
            conversation: vec![],
            verbatim_fork: false,
        };
    }
    let (conversation, prefix_len) =
        kigi_subagent_resolution::context::normalize_forked_context(items);
    if prefix_len < 2 {
        return InitialContext {
            source: InitialContextSource::New,
            copy_error: Some("no inheritable parent content".to_string()),
            prefix_len: None,
            conversation: vec![],
            verbatim_fork: false,
        };
    }
    InitialContext {
        source: InitialContextSource::Forked,
        copy_error: None,
        prefix_len: Some(prefix_len),
        conversation,
        verbatim_fork: false,
    }
}
/// A verbatim mirror requires a coherent tail: the conversation must end on a
/// plain assistant text response (a clean turn boundary). A dangling assistant
/// (unanswered tool calls), a trailing ToolResult (mid-turn), or a trailing
/// user/reasoning means the prefix would be incoherent, so the caller falls back
/// to the summarized path instead of partial-trimming.
fn conversation_tail_is_complete(
    items: &[kigi_sampling_types::conversation::ConversationItem],
) -> bool {
    use kigi_sampling_types::conversation::ConversationItem;
    matches!(
        items.last(), Some(ConversationItem::Assistant(a)) if a.tool_calls.is_empty()
    )
}
/// Decide the live-fork context.
///
/// Verbatim mirror (the cache-preserving path): when the parent fits the child
/// window (same 80% guard as resume) AND ends at a clean turn boundary, keep the
/// items BYTE-FOR-BYTE. We deliberately do NOT run `fork_filter_chat` here — its
/// step 1 strips synthetic-reason user items (`<system-reminder>`s, drained
/// monitor events, doom-loop warnings) that the parent actually sent and cached;
/// stripping them would diverge the child prefix at the first removed item and
/// cap radix reuse there. At planner spawn the conversation is between turns
/// (the `/goal` user message is not yet pushed), so the tail is already complete
/// and no trimming is needed; an incomplete tail falls back to summarized.
///
/// Summarized fallback (oversize OR incomplete tail): the reasoning-aware
/// `fork_filter_chat` drops synthetics + trims the incomplete tail, then
/// `normalize_forked_context` summarizes. (This is the ONLY path that filters;
/// the verbatim path never does.)
///
/// Input that is empty or only `System` item(s) — before OR after filtering —
/// inherited nothing, so it fails open to `New` rather than a hollow fork.
fn verbatim_or_normalize_fork(
    items: Vec<kigi_sampling_types::conversation::ConversationItem>,
    child_context_window: u64,
) -> InitialContext {
    use kigi_sampling_types::conversation::ConversationItem;
    if !items
        .iter()
        .any(|i| !matches!(i, ConversationItem::System(_)))
    {
        return InitialContext {
            source: InitialContextSource::New,
            copy_error: Some("forked parent conversation has no inheritable content".to_string()),
            prefix_len: None,
            conversation: vec![],
            verbatim_fork: false,
        };
    }
    let estimated_tokens = kigi_chat_state::estimate_conversation_tokens(&items);
    const SAFE_FORK_PERCENT: u64 = 80;
    let threshold = child_context_window * SAFE_FORK_PERCENT / 100;
    if estimated_tokens <= threshold && conversation_tail_is_complete(&items) {
        let prefix_len = items.len();
        return InitialContext {
            source: InitialContextSource::Forked,
            copy_error: None,
            prefix_len: Some(prefix_len),
            conversation: items,
            verbatim_fork: true,
        };
    }
    let mut filtered = items;
    crate::session::storage::jsonl::fork_filter_chat(&mut filtered);
    if !filtered
        .iter()
        .any(|i| !matches!(i, ConversationItem::System(_)))
    {
        return InitialContext {
            source: InitialContextSource::New,
            copy_error: Some("no inheritable parent content after filtering".to_string()),
            prefix_len: None,
            conversation: vec![],
            verbatim_fork: false,
        };
    }
    let (conversation, prefix_len) =
        kigi_subagent_resolution::context::normalize_forked_context(filtered);
    InitialContext {
        source: InitialContextSource::Forked,
        copy_error: None,
        prefix_len: Some(prefix_len),
        conversation,
        verbatim_fork: false,
    }
}
/// `true` only when the fork actually summarized (ran `normalize_forked_context`).
/// A verbatim mirror-fork inherits items as-is and never normalizes, so it reports
/// `false` even though its source is `Forked`.
fn fork_context_normalized(source: &InitialContextSource, verbatim_fork: bool) -> bool {
    matches!(source, InitialContextSource::Forked) && !verbatim_fork
}
/// Stamp `subagent_fork` / `forked` on the child summary (live path; disk copy already stamps).
fn stamp_live_fork_session_metadata(
    child_session_info: &SessionInfo,
    parent_session_id: &str,
    parent_prompt_id: Option<String>,
    model_id: &str,
    inherited_prefix_len: Option<usize>,
    fork_context_source: &str,
) {
    let dir = session::persistence::session_dir(child_session_info);
    if let Err(e) = std::fs::create_dir_all(&dir) {
        tracing::warn!(
            error = % e,
            "live fork: could not create child session dir for metadata stamp"
        );
        return;
    }
    let summary_path = dir.join("summary.json");
    let model = acp::ModelId::new(model_id);
    let mut summary = std::fs::read(&summary_path)
        .ok()
        .and_then(|b| serde_json::from_slice(&b).ok())
        .or_else(|| session::persistence::Summary::new(child_session_info, model).ok());
    let Some(ref mut summary) = summary else {
        tracing::warn!("live fork: could not load or create child summary");
        return;
    };
    summary.session_kind = Some("subagent_fork".to_string());
    summary.fork_context_source = Some(fork_context_source.to_string());
    summary.parent_session_id = Some(parent_session_id.to_string());
    summary.fork_parent_prompt_id = parent_prompt_id;
    summary.inherited_prefix_len = inherited_prefix_len;
    summary.forked_at = Some(chrono::Utc::now());
    if let Ok(bytes) = serde_json::to_vec_pretty(summary)
        && let Err(e) = std::fs::write(&summary_path, bytes)
    {
        tracing::warn!(error = % e, "live fork: failed to write forked session summary");
    }
}
enum BootstrapInitialContext {
    Ready(InitialContext),
    /// Explicit resume_from failed — abort spawn (fail closed).
    ResumeAbort(String),
}
/// Phase 3: resume (fail-closed on copy error) > fork (live then disk, fail-open) > New.
/// Unresolved non-empty resume is aborted by the caller before this runs.
async fn bootstrap_initial_context(
    request: &SubagentRequest,
    resume_source: Option<&ResumeSourceData>,
    ctx: &SubagentSpawnContext,
    child_session_info: &SessionInfo,
    child_session_dir: &std::path::Path,
    effective_model_id: &str,
    child_context_window: u64,
) -> BootstrapInitialContext {
    if request.fork_context && request.resume_from.is_some() {
        tracing::info!(
            subagent_id = % request.id, resume_from = ? request.resume_from,
            resume_resolved = resume_source.is_some(),
            "resume_from and fork_context both set; resolved resume wins (fail-closed on copy error, never forks)"
        );
    }
    if let Some(source) = resume_source {
        let source_session_info = SessionInfo {
            id: acp::SessionId::new(source.child_session_id.clone()),
            cwd: source.child_cwd.clone(),
        };
        let storage = crate::session::storage::jsonl::JsonlStorageAdapter::with_root(
            crate::util::kigi_home::kigi_home(),
        );
        let copy_options = crate::session::storage::CopySessionOptions {
            parent_session_id: Some(source.child_session_id.clone()),
            new_model_id: Some(effective_model_id.to_string()),
            session_kind: Some("subagent_resume".to_string()),
            fork_context_source: Some("resumed".to_string()),
            fork_parent_prompt_id: request.parent_prompt_id.clone(),
            copy_plan_state: false,
            copy_plan_mode_state: false,
            copy_signals: false,
            copy_tool_state: true,
            fork_filter: false,
            ..Default::default()
        };
        return match storage.copy_session_data_sync(
            &source_session_info,
            child_session_info,
            copy_options,
        ) {
            Ok(result) => {
                let conversation = match storage.load_chat_history_from_dir(child_session_dir) {
                    Ok(items) if !items.is_empty() => items,
                    Ok(_) => {
                        return BootstrapInitialContext::ResumeAbort(format!(
                            "Cannot resume from subagent '{}': \
                             copied transcript is empty",
                            source.subagent_id,
                        ));
                    }
                    Err(e) => {
                        return BootstrapInitialContext::ResumeAbort(format!(
                            "Cannot resume from subagent '{}': \
                             failed to load copied transcript: {e}",
                            source.subagent_id,
                        ));
                    }
                };
                let estimated_tokens = kigi_chat_state::estimate_conversation_tokens(&conversation);
                const SAFE_RESUME_PERCENT: u64 = 80;
                let threshold = child_context_window * SAFE_RESUME_PERCENT / 100;
                if estimated_tokens > threshold {
                    return BootstrapInitialContext::ResumeAbort(format!(
                        "Cannot resume from subagent '{}': source transcript \
                         (~{estimated_tokens} tokens) exceeds {SAFE_RESUME_PERCENT}% of \
                         the model's context window ({child_context_window} tokens). \
                         The source conversation is too large for the current model.",
                        source.subagent_id,
                    ));
                }
                tracing::info!(
                    subagent_id = % request.id, source_subagent = % source.subagent_id,
                    chat_messages = result.chat_messages_copied, tool_state = result
                    .tool_state_copied, estimated_tokens,
                    "Resume-copied source child session data into new child"
                );
                BootstrapInitialContext::Ready(resume_initial_context(conversation))
            }
            Err(e) => BootstrapInitialContext::ResumeAbort(format!(
                "Cannot resume from subagent '{}': failed to copy source session data: {e}",
                source.subagent_id,
            )),
        };
    }
    if !request.fork_context {
        return BootstrapInitialContext::Ready(InitialContext {
            source: InitialContextSource::New,
            copy_error: None,
            prefix_len: None,
            conversation: vec![],
            verbatim_fork: false,
        });
    }
    let live_items = match ctx.parent_chat_state.as_ref() {
        Some(chat_state) => {
            let items = chat_state.get_conversation().await;
            if items.is_empty() { None } else { Some(items) }
        }
        None => None,
    };
    if let Some(items) = live_items {
        let ctx_out = verbatim_or_normalize_fork(items, child_context_window);
        tracing::info!(
            subagent_id = % request.id, subagent_type = % request.subagent_type,
            loaded_items = ctx_out.conversation.len(), source = ? ctx_out.source,
            verbatim = ctx_out.verbatim_fork,
            "Forked context from live parent_chat_state"
        );
        if matches!(ctx_out.source, InitialContextSource::Forked) {
            let marker = if ctx_out.verbatim_fork {
                "forked_verbatim"
            } else {
                "forked_summarized"
            };
            stamp_live_fork_session_metadata(
                child_session_info,
                &ctx.parent_session_id,
                request.parent_prompt_id.clone(),
                effective_model_id,
                ctx_out.prefix_len,
                marker,
            );
        }
        return BootstrapInitialContext::Ready(ctx_out);
    }
    if let Some(ref parent_info) = ctx.parent_session_info {
        let storage = crate::session::storage::jsonl::JsonlStorageAdapter::with_root(
            crate::util::kigi_home::kigi_home(),
        );
        let copy_options = crate::session::storage::CopySessionOptions {
            parent_session_id: Some(ctx.parent_session_id.clone()),
            new_model_id: Some(effective_model_id.to_string()),
            session_kind: Some("subagent_fork".to_string()),
            fork_context_source: Some("forked".to_string()),
            fork_parent_prompt_id: request.parent_prompt_id.clone(),
            copy_plan_state: false,
            copy_plan_mode_state: false,
            copy_signals: false,
            copy_tool_state: false,
            fork_filter: true,
            ..Default::default()
        };
        return match storage.copy_session_data_sync(parent_info, child_session_info, copy_options) {
            Ok(result) => {
                tracing::info!(
                    subagent_id = % request.id, subagent_type = % request.subagent_type,
                    chat_messages = result.chat_messages_copied, tool_state = result
                    .tool_state_copied,
                    "Fork-copied parent session data into child (disk fallback)"
                );
                let items = storage
                    .load_chat_history_from_dir(child_session_dir)
                    .unwrap_or_else(|e| {
                        tracing::warn!(
                            error = % e,
                            "Failed to load forked chat history, starting with empty context"
                        );
                        vec![]
                    });
                BootstrapInitialContext::Ready(forked_initial_context(items))
            }
            Err(e) => {
                let err_msg = format!("{e}");
                tracing::warn!(
                    subagent_id = % request.id, subagent_type = % request.subagent_type,
                    error = % e,
                    "Failed to fork-copy parent session, falling back to fresh"
                );
                BootstrapInitialContext::Ready(InitialContext {
                    source: InitialContextSource::New,
                    copy_error: Some(err_msg),
                    prefix_len: None,
                    conversation: vec![],
                    verbatim_fork: false,
                })
            }
        };
    }
    tracing::warn!(
        subagent_id = % request.id, subagent_type = % request.subagent_type,
        "fork_context=true but no live parent conversation or parent_session_info; falling back to fresh"
    );
    BootstrapInitialContext::Ready(InitialContext {
        source: InitialContextSource::New,
        copy_error: Some("parent conversation unavailable".to_string()),
        prefix_len: None,
        conversation: vec![],
        verbatim_fork: false,
    })
}
/// Drop guard that moves a pending coordinator entry to completed-as-failed
/// on early return. Call `defuse()` after promoting to active.
struct PendingGuard<'a> {
    coordinator: &'a std::cell::RefCell<SubagentCoordinator>,
    id: String,
    defused: bool,
    /// Specific error message set by fail_subagent before returning.
    /// Falls back to a generic message if unset.
    error: Option<String>,
}
impl PendingGuard<'_> {
    fn defuse(mut self) {
        self.defused = true;
    }
    fn set_error(&mut self, error: String) {
        self.error = Some(error);
    }
}
impl Drop for PendingGuard<'_> {
    fn drop(&mut self) {
        if !self.defused {
            let error = self
                .error
                .take()
                .unwrap_or_else(|| "Subagent failed during initialization".to_string());
            self.coordinator
                .borrow_mut()
                .move_pending_to_failed(&self.id, &error);
        }
    }
}
/// Resolve the effective working directory for a child session.
///
/// Precedence: worktree path > `override_cwd` (non-empty) > parent cwd. The
/// caller selects `override_cwd`: a resumed child inherits the source's
/// effective cwd, a fresh spawn honors its `request.cwd`.
fn resolve_child_cwd(
    worktree_path: Option<&Path>,
    override_cwd: Option<&str>,
    parent_cwd: &Path,
) -> PathBuf {
    worktree_path
        .map(Path::to_path_buf)
        .or_else(|| override_cwd.filter(|s| !s.is_empty()).map(PathBuf::from))
        .unwrap_or_else(|| parent_cwd.to_path_buf())
}
/// The cwd a resumed child inherits from its source subagent, or `None` when
/// there is nothing to inherit (the caller then falls back to the parent cwd).
///
/// Only non-worktree sources inherit here — worktree-backed sources are reused
/// by the worktree path. The cwd is existence-checked because a source can be
/// pinned into a sibling's worktree that the snapshot stack later disposes;
/// resume otherwise skips cwd validation.
fn resume_inherited_cwd(source: Option<&ResumeSourceData>) -> Option<&str> {
    let source = source?;
    if source.worktree_path.is_some() || source.child_cwd.is_empty() {
        return None;
    }
    if !Path::new(&source.child_cwd).is_dir() {
        tracing::warn!(
            source_subagent_id = % source.subagent_id, child_cwd = % source.child_cwd,
            "Resume source cwd no longer exists; using parent workspace"
        );
        return None;
    }
    Some(source.child_cwd.as_str())
}
/// Select the cwd override for a child: a resume inherits the source's cwd
/// (never its own `request.cwd`); a fresh spawn uses `request.cwd`.
fn select_override_cwd<'a>(
    resume_source: Option<&'a ResumeSourceData>,
    request_cwd: Option<&'a str>,
) -> Option<&'a str> {
    if resume_source.is_some() {
        resume_inherited_cwd(resume_source)
    } else {
        request_cwd
    }
}
/// Apply `McpInheritance` filtering to a parent MCP pool snapshot.
///
/// Returns `None` for `McpInheritance::None` (no pool at all — avoids
/// an empty import call downstream). For `Named`/`Except`, retains or
/// removes the matching server names in-place.
fn filter_pool_by_inheritance(
    mut pool: crate::session::mcp_servers::SharedMcpPool,
    inheritance: &kigi_agent::config::McpInheritance,
) -> Option<crate::session::mcp_servers::SharedMcpPool> {
    use kigi_agent::config::McpInheritance;
    match inheritance {
        McpInheritance::All => Some(pool),
        McpInheritance::None => None,
        McpInheritance::Named(names) => {
            let before = pool.server_names().count();
            pool.retain_clients(|name| names.iter().any(|n| n == name));
            tracing::debug!(
                before,
                after = pool.server_names().count(),
                ?names,
                "MCP inheritance: Named filter applied"
            );
            Some(pool)
        }
        McpInheritance::Except(names) => {
            let before = pool.server_names().count();
            pool.retain_clients(|name| !names.iter().any(|n| n == name));
            tracing::debug!(
                before,
                after = pool.server_names().count(),
                ?names,
                "MCP inheritance: Except filter applied"
            );
            Some(pool)
        }
    }
}
/// Resolve a subagent type name to its `AgentDefinition`, with the parent
/// session's CLI tool/permission overrides already applied (so the spawn path
/// can never obtain a definition that skips them).
fn resolve_agent_definition(
    subagent_type: &str,
    ctx: &SubagentSpawnContext,
) -> Option<kigi_agent::config::AgentDefinition> {
    let mut def = kigi_agent::discovery::by_name_in_cwd_with_plugins(
        subagent_type,
        &ctx.parent_cwd,
        ctx.plugin_registry.as_deref(),
    )
    .or_else(|| {
        ctx.agent_config.as_ref().and_then(|cfg| {
            cfg.cli_agents
                .iter()
                .find(|d| d.name == subagent_type)
                .cloned()
        })
    })?;
    ctx.apply_session_cli_overrides(&mut def);
    Some(def)
}
/// Minimal per-session context for `validate_subagent_type`.
/// Avoids the heavy `SubagentSpawnContext` clone on the validation hot path.
#[derive(Default)]
pub(crate) struct SubagentValidationContext {
    pub parent_cwd: PathBuf,
    pub plugin_registry: Option<Arc<kigi_agent::plugins::PluginRegistry>>,
    pub subagent_toggle: HashMap<String, bool>,
    pub allowed_subagent_types: Option<Vec<String>>,
    pub cli_agent_names: Vec<String>,
}
impl SubagentValidationContext {
    /// Toggle lookup; absent keys default to enabled.
    pub(crate) fn is_subagent_enabled(&self, name: &str) -> bool {
        self.subagent_toggle.get(name).copied().unwrap_or(true)
    }
}
/// Synchronously validate a subagent type against discovery + toggle + allow-list.
/// `Unknown { available }` is sorted by `str::cmp` for stable rendering.
pub(crate) fn validate_subagent_type(
    subagent_type: &str,
    ctx: &SubagentValidationContext,
) -> SubagentValidateTypeOutcome {
    let resolves = ctx.cli_agent_names.iter().any(|n| n == subagent_type)
        || kigi_agent::discovery::by_name_in_cwd_with_plugins(
            subagent_type,
            &ctx.parent_cwd,
            ctx.plugin_registry.as_deref(),
        )
        .is_some();
    if !resolves {
        let mut available: Vec<String> = kigi_agent::discovery::all_subagents_with_plugins(
            &ctx.parent_cwd,
            &ctx.subagent_toggle,
            ctx.plugin_registry.as_deref(),
        )
        .into_iter()
        .map(|e| e.name)
        .collect();
        let mut seen: std::collections::HashSet<String> = available.iter().cloned().collect();
        for name in &ctx.cli_agent_names {
            if !ctx.is_subagent_enabled(name) {
                continue;
            }
            if seen.insert(name.clone()) {
                available.push(name.clone());
            }
        }
        available.sort();
        return SubagentValidateTypeOutcome::Unknown { available };
    }
    if !ctx.is_subagent_enabled(subagent_type) {
        return SubagentValidateTypeOutcome::Disabled;
    }
    if let Some(ref allowed) = ctx.allowed_subagent_types
        && !allowed
            .iter()
            .any(|t| t.eq_ignore_ascii_case(subagent_type))
    {
        return SubagentValidateTypeOutcome::NotAllowed {
            allowed: allowed.clone(),
        };
    }
    SubagentValidateTypeOutcome::Ok
}
/// Gate an already-resolved subagent type against the `[subagents.toggle]`
/// disable map and the parent's allow-list.
///
/// The caller must have already confirmed the type resolves to an
/// `AgentDefinition`; this checks ONLY the toggle + allow-list gates,
/// returning `Ok` when the type may run and `Disabled` / `NotAllowed`
/// otherwise (never `Unknown` / `ValidationUnavailable`). Shared by
/// [`handle_subagent_request`] and [`describe_subagent_type`] so both apply
/// identical gates.
fn gate_subagent_type(
    subagent_type: &str,
    ctx: &SubagentSpawnContext,
) -> SubagentValidateTypeOutcome {
    if !ctx.is_subagent_enabled(subagent_type) {
        return SubagentValidateTypeOutcome::Disabled;
    }
    if let Some(ref allowed) = ctx.allowed_subagent_types
        && !allowed
            .iter()
            .any(|t| t.eq_ignore_ascii_case(subagent_type))
    {
        return SubagentValidateTypeOutcome::NotAllowed {
            allowed: allowed.clone(),
        };
    }
    SubagentValidateTypeOutcome::Ok
}
/// `false` twin: the alternate flavors re-select toolset presets and
/// templates, so none is representable when the optional
/// harness is compiled out. Keeps ungated call sites compiling.
pub(crate) fn subagent_harness_flavor_is_representable(_agent_type: &str) -> bool {
    false
}
/// Apply the harness-dependent toolset/prompt re-selection to a resolved
/// agent definition.
///
/// The harness flavor (alternate vs kigi) normally follows the PARENT
/// agent: `KigiOrchestrator` parents give children
/// the alternate harness; the orchestrator keeps children lean, and other parents
/// inherit the file-tool override (hashline vs standard). A `/goal` role may
/// pass `harness_agent_type` to OVERRIDE that flavor regardless of the parent
/// (so a kigi session can run an alternate-harness verifier and vice-versa);
/// `None` for every non-goal spawn ⇒ the parent decides (unchanged). The base
/// toolset stays role-dependent on `subagent_type` (general-purpose →
/// implementer, else explorer), so the role keeps a capable toolset on the
/// chosen harness.
///
/// Extracted so both [`handle_subagent_request`] (real spawn) and
/// [`describe_subagent_type`] (read-only probe) build the SAME `tool_config`
/// for a given `(subagent_type, harness_agent_type, parent_name)` — no
/// duplication.
fn resolve_subagent_toolset(
    #[allow(unused_variables)] subagent_type: &str,
    harness_agent_type: Option<&str>,
    ctx: &SubagentSpawnContext,
    definition: &mut kigi_agent::config::AgentDefinition,
) {
    let flavor_agent = match harness_agent_type {
        Some(h) => Some(h),
        None => ctx
            .parent_agent_name
            .as_deref()
            .filter(|s| subagent_harness_flavor_is_representable(s))
            .or(ctx.parent_model_agent_type.as_deref()),
    };
    if flavor_agent.is_some_and(subagent_harness_flavor_is_representable) {
    } else if let Some(ref file_tools) = ctx.file_tool_overrides {
        definition.override_file_tools(file_tools.clone());
    }
}
/// Map a resolved `ToolServerConfig` into a [`SubagentTypeSummary`].
///
/// Keys on each entry's `ToolConfig.kind` (first tool per kind wins).
/// Entries with `kind: None` — `from_id`/MCP/custom tools — are SKIPPED, so
/// this is NOT a byte-for-byte equivalent of the finalize-time `kind_to_name`
/// map (which keys on the registry `entry.kind`); the two agree for the
/// builtin goal toolsets, where every tool's kind is populated by
/// `From<&T: Tool>`, but diverge for `kind: None` tools (which carry no
/// capability signal anyway). The client-facing name is
/// `ToolConfig::resolve_client_name(default_id)` where `default_id` is the
/// unqualified tool id (the `"<namespace>:"` prefix on `tc.id` is stripped),
/// so a `name_override` is reflected. The read/search/execute flags are what
/// the per-role capability gates key on.
fn summarize_tool_config(
    config: &kigi_tools::registry::types::ToolServerConfig,
) -> SubagentTypeSummary {
    use kigi_tools::types::tool::ToolKind;
    use std::collections::HashMap;
    let mut tool_names: HashMap<ToolKind, String> = HashMap::new();
    for tc in &config.tools {
        let Some(kind) = tc.kind else { continue };
        let default_id = tc.id.rsplit(':').next().unwrap_or(tc.id.as_str());
        let client_name = tc.resolve_client_name(default_id);
        tool_names.entry(kind).or_insert(client_name);
    }
    SubagentTypeSummary {
        can_read: tool_names.contains_key(&ToolKind::Read),
        can_search: tool_names.contains_key(&ToolKind::Search),
        can_execute: tool_names.contains_key(&ToolKind::Execute),
        tool_names,
    }
}
/// Describe a subagent type's resolved toolset WITHOUT spawning it.
///
/// Runs the same resolution path as [`handle_subagent_request`] —
/// [`resolve_agent_definition`] + [`gate_subagent_type`] +
/// [`resolve_subagent_toolset`] — then summarizes the resulting
/// `tool_config`. Backs the `SubagentEvent::DescribeType` drain arm; the
/// parent uses the summary for the per-role capability gate and prompt
/// rendering before committing a configured `/goal` `{model, agent_type}` pair.
///
/// `harness_agent_type` is the `/goal`-only harness override: when set it must
/// resolve to an `AgentDefinition` via this module's [`resolve_agent_definition`]
/// (name-based project/plugin/builtin lookup — `by_name_in_cwd_with_plugins` +
/// `BuiltinAgentName`). That is equivalent to the main session for builtin
/// harness names but does NOT apply the main session's env / ACP-profile /
/// strict-harness precedence. An unresolvable harness returns `Unknown` so the
/// `/goal` caller fails open to the session harness; otherwise it decides the
/// summarized toolset's flavor. `None` (every non-goal probe) defers the flavor
/// to the parent agent (unchanged).
pub(crate) fn describe_subagent_type(
    subagent_type: &str,
    harness_agent_type: Option<&str>,
    ctx: &SubagentSpawnContext,
) -> SubagentDescribeOutcome {
    if let Some(harness) = harness_agent_type
        && resolve_agent_definition(harness, ctx).is_none()
    {
        let mut available: Vec<String> = kigi_agent::discovery::all_subagents_with_plugins(
            &ctx.parent_cwd,
            &ctx.subagent_toggle,
            ctx.plugin_registry.as_deref(),
        )
        .into_iter()
        .map(|e| e.name)
        .collect();
        available.sort();
        return SubagentDescribeOutcome::Unknown { available };
    }
    let Some(mut definition) = resolve_agent_definition(subagent_type, ctx) else {
        let mut available: Vec<String> = kigi_agent::discovery::all_subagents_with_plugins(
            &ctx.parent_cwd,
            &ctx.subagent_toggle,
            ctx.plugin_registry.as_deref(),
        )
        .into_iter()
        .map(|e| e.name)
        .collect();
        available.sort();
        return SubagentDescribeOutcome::Unknown { available };
    };
    match gate_subagent_type(subagent_type, ctx) {
        SubagentValidateTypeOutcome::Disabled => return SubagentDescribeOutcome::Disabled,
        SubagentValidateTypeOutcome::NotAllowed { allowed } => {
            return SubagentDescribeOutcome::NotAllowed { allowed };
        }
        _ => {}
    }
    resolve_subagent_toolset(subagent_type, harness_agent_type, ctx, &mut definition);
    SubagentDescribeOutcome::Ok(summarize_tool_config(&definition.tool_config))
}
/// Resolve a subagent's turn limit: its own `maxTurns` wins, else inherit the parent's.
fn resolve_subagent_max_turns(
    definition_max_turns: Option<u32>,
    parent_max_turns: Option<usize>,
) -> Option<usize> {
    definition_max_turns
        .map(|v| v as usize)
        .or(parent_max_turns)
}
/// What to do with a resumed subagent's isolated worktree directory.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ResumeWorktreeAction {
    /// Directory on disk and no snapshot ref — reuse it as-is.
    Reuse,
    /// Directory gone but a snapshot ref exists — rehydrate from it.
    Rehydrate,
    /// Directory gone and no snapshot — fall back to the shared workspace.
    Shared,
}
/// Decide how to recover a resumed subagent's worktree from its on-disk state
/// and whether a durable snapshot is available. Pure so the three outcomes are
/// unit-testable without git/async.
fn resume_worktree_action(dir_exists: bool, snapshot_ref: Option<&str>) -> ResumeWorktreeAction {
    if snapshot_ref.is_some() {
        ResumeWorktreeAction::Rehydrate
    } else if dir_exists {
        ResumeWorktreeAction::Reuse
    } else {
        ResumeWorktreeAction::Shared
    }
}
/// The parent session's working directory — the source path for a subagent
/// worktree. Prefers the reconstructed `SessionInfo` cwd, falling back to
/// `parent_cwd`.
fn parent_source_cwd(ctx: &SubagentSpawnContext) -> std::path::PathBuf {
    ctx.parent_session_info
        .as_ref()
        .map(|i| std::path::PathBuf::from(&i.cwd))
        .unwrap_or_else(|| std::path::PathBuf::from(&ctx.parent_cwd))
}
/// Effective permission mode for a spawned subagent. Plugin agents never honor a
/// non-default mode; under the pin, `bypassPermissions` downgrades to `Default`
/// so a repo/profile/`--agents` def can't restore auto-approve. Caller logs it.
fn resolve_subagent_permission_mode(
    requested: kigi_agent::config::PermissionMode,
    is_plugin: bool,
    policy_block: Option<&'static str>,
) -> kigi_agent::config::PermissionMode {
    use kigi_agent::config::PermissionMode;
    if is_plugin {
        return PermissionMode::Default;
    }
    if policy_block.is_some() && requested == PermissionMode::BypassPermissions {
        return PermissionMode::Default;
    }
    requested
}
/// Main repo root for a subagent's source: the durable repo a completion snapshot is transferred into and the repo a resume rehydrates from — both arms MUST resolve this identically.
fn resolve_subagent_source_repo(ctx: &SubagentSpawnContext) -> std::path::PathBuf {
    let source_cwd = parent_source_cwd(ctx);
    kigi_workspace::session::git::find_main_repo_root_from_path(&source_cwd).unwrap_or(source_cwd)
}
enum SubagentWaitOutcome {
    Cancelled,
    TurnResult(Box<Result<SubagentPromptTurnResult, oneshot::error::RecvError>>),
}
async fn await_subagent_turn_or_cancellation(
    prompt_rx: oneshot::Receiver<SubagentPromptTurnResult>,
    cancel_token: CancellationToken,
) -> SubagentWaitOutcome {
    tokio::select! {
        _ = cancel_token.cancelled() => SubagentWaitOutcome::Cancelled, turn_result =
        prompt_rx => SubagentWaitOutcome::TurnResult(Box::new(turn_result)),
    }
}
/// Max time a blocking `spawn_subagent` may hold the turn before it is
/// auto-backgrounded (non-destructively). Env override: `KIGI_SUBAGENT_AWAIT_BUDGET_MS`.
const SUBAGENT_AWAIT_BUDGET: std::time::Duration = std::time::Duration::from_secs(600);
fn subagent_await_budget() -> std::time::Duration {
    std::env::var("KIGI_SUBAGENT_AWAIT_BUDGET_MS")
        .ok()
        .and_then(|v| v.parse::<u64>().ok())
        .filter(|&ms| ms > 0)
        .map(std::time::Duration::from_millis)
        .unwrap_or(SUBAGENT_AWAIT_BUDGET)
}
/// Fallback for cancelled/errored paths where TurnDeltaSnapshot is unavailable.
async fn signals_snapshot_counts(child_handle: &SessionHandle) -> (u32, u32) {
    child_handle
        .signals_handle
        .snapshot()
        .await
        .map(|s| (s.tool_call_count, s.turn_count))
        .unwrap_or((0, 0))
}
fn cancellation_error_message(
    category: Option<kigi_file_utils::events::types::CancellationCategory>,
    context: Option<&crate::session::commands::CancellationContext>,
) -> String {
    use kigi_file_utils::events::types::CancellationCategory;
    let detail = context.and_then(|ctx| {
        let tool = ctx.tool_name.as_deref();
        let reason = ctx.reason.as_deref();
        let hook = ctx.hook_name.as_deref();
        match (tool, reason, hook) {
            (Some(t), Some(r), Some(h)) => Some(format!("{r} for tool `{t}` (hook: {h})")),
            (Some(t), Some(r), None) => Some(format!("{r} for tool `{t}`")),
            (Some(t), None, _) => Some(format!("tool `{t}`")),
            _ => None,
        }
    });
    match (category, &detail) {
        (Some(CancellationCategory::PermissionRejected), Some(d)) => {
            format!("Subagent turn was cancelled: user rejected permission — {d}")
        }
        (Some(CancellationCategory::PermissionRejected), None) => {
            "Subagent turn was cancelled: user rejected a permission prompt".to_string()
        }
        (Some(CancellationCategory::PermissionCancelled), _) => {
            "Subagent turn was cancelled: user cancelled a permission prompt".to_string()
        }
        (Some(CancellationCategory::HookDenied), Some(d)) => {
            format!("Subagent turn was cancelled: hook denied — {d}")
        }
        (Some(CancellationCategory::HookDenied), None) => {
            "Subagent turn was cancelled: blocked by a hook".to_string()
        }
        (Some(CancellationCategory::MidTurnAbort), _) => {
            "Subagent turn was cancelled: aborted mid-turn".to_string()
        }
        _ => "Subagent turn was cancelled".to_string(),
    }
}
/// Whether a completed subagent should trigger an auto-wake synthetic prompt.
///
/// Returns `true` only for background subagents with auto-wake enabled whose
/// result has not already been consumed (via block-wait or explicit kill).
/// Also suppressed while the parent's goal loop is active (mirrors the bash
/// gate in `notification_bridge`); skipping the inject also skips the
/// `auto_wake_delivered.insert`, leaving surfaces 2/3 free to drain it.
/// `parent_channel_open` folds `inject_subagent_completed_prompt`'s own
/// no-channel bail into the decision, so the `will_wake` stamped on the
/// completion notification can never promise a wake the inject won't do.
///
/// `cancelled` results never wake: a child dies cancelled because the user
/// (or parent teardown) killed it — most acutely the Ctrl+C race where
/// `ParentGone` detaches a foreground child to background moments before the
/// in-flight `SubagentEvent::Cancel` lands its token, which would otherwise
/// wake the model right after the user stopped everything. The completion is
/// still recorded, so reminder/drain surfaces can report it later.
fn should_auto_wake_subagent(
    run_in_background: bool,
    cancelled: bool,
    auto_wake_enabled: bool,
    block_waited: bool,
    explicitly_killed: bool,
    goal_loop_active: bool,
    parent_channel_open: bool,
) -> bool {
    run_in_background
        && !cancelled
        && auto_wake_enabled
        && !block_waited
        && !explicitly_killed
        && !goal_loop_active
        && parent_channel_open
}
/// Inject a synthetic prompt into the parent session for a completed background
/// subagent, enabling auto-wake when the agent is idle.
///
/// Only called for background subagents when auto-wake is enabled
/// and the result has not been consumed (via block-wait or explicit kill).
fn inject_subagent_completed_prompt(
    subagent_id: &str,
    result: &SubagentResult,
    request: &SubagentRequest,
    auto_wake_delivered: &Option<kigi_tools::reminders::task_completion::AutoWakeDeliveredIds>,
    parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
    task_output_tool_name: &str,
) {
    let Some(cmd_tx) = parent_cmd_tx else {
        return;
    };
    if let Some(auto_wake) = auto_wake_delivered {
        auto_wake.insert(subagent_id.to_string());
    }
    let summary = SubagentCompletionSummary {
        subagent_id: subagent_id.to_string(),
        subagent_type: request.subagent_type.clone(),
        description: request.description.clone(),
        success: result.success && !result.cancelled,
        duration_ms: result.duration_ms,
        tool_calls: result.tool_calls,
        turns: result.turns,
        output: result.output.clone(),
    };
    let message = kigi_tools::reminders::task_completion::format_subagent_completion(
        &summary,
        Some(task_output_tool_name),
    );
    let wrapped = kigi_tools::reminders::wrap_reminder(&message);
    let prompt_id = format!("subagent-completed-{subagent_id}");
    let (respond_to, _completion_rx) = tokio::sync::oneshot::channel();
    let prompt_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(wrapped))];
    let _ = cmd_tx.send(SessionCommand::Prompt {
        prompt_id,
        prompt_blocks,
        prompt_mode: crate::session::plan_mode::PromptMode::Agent,
        client_identifier: None,
        screen_mode: None,
        verbatim: true,
        traceparent: None,
        json_schema: None,
        send_now: false,
        respond_to,
        persist_ack: None,
    });
}
/// Post-`insert_pending`, pre-`SubagentSpawned` failure: just send via oneshot;
/// `PendingGuard::drop` handles the queue side effects.
fn send_failure(request: SubagentRequest, error: &str) {
    let _ = request.result_tx.send(SubagentResult {
        success: false,
        error: Some(error.to_string()),
        ..Default::default()
    });
}
/// Fail BEFORE `insert_pending`. Sends via oneshot; for background-mode
/// requests also records a synthetic `CompletedSubagent` + emits a
/// `SubagentFinished` notification (persisted + live).
fn send_pre_spawn_failure(
    request: SubagentRequest,
    error: &str,
    coordinator: &std::cell::RefCell<SubagentCoordinator>,
    ctx: &SubagentSpawnContext,
    gateway: &GatewaySender,
) {
    let SubagentRequest {
        id,
        subagent_type,
        description,
        parent_prompt_id,
        result_tx,
        run_in_background,
        surface_completion,
        ..
    } = request;
    if run_in_background {
        let notification_subagent_id = id.clone();
        coordinator.borrow_mut().record_pre_spawn_failure(
            id,
            subagent_type,
            description,
            parent_prompt_id,
            ctx.parent_session_id.clone(),
            error,
            surface_completion,
        );
        emit_subagent_notification(
            gateway,
            &ctx.parent_session_id,
            SessionUpdate::SubagentFinished {
                subagent_id: notification_subagent_id,
                child_session_id: String::new(),
                status: "failed".to_string(),
                error: Some(error.to_string()),
                tool_calls: 0,
                turns: 0,
                duration_ms: 0,
                tokens_used: 0,
                output: None,
                will_wake: false,
            },
            ctx.parent_cmd_tx.as_ref(),
        );
    }
    let _ = result_tx.send(SubagentResult {
        success: false,
        error: Some(error.to_string()),
        ..Default::default()
    });
}
/// Post-`SubagentSpawned` failure: oneshot + `SubagentFinished` + `meta.json` update.
fn fail_subagent(
    request: SubagentRequest,
    error: &str,
    subagent_id: &str,
    child_session_id: &acp::SessionId,
    subagent_meta_dir: &Path,
    gateway: &GatewaySender,
    parent_session_id: &str,
    parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
    duration_ms: u64,
) {
    let result = SubagentResult {
        success: false,
        error: Some(error.to_string()),
        subagent_id: subagent_id.to_string(),
        child_session_id: child_session_id.0.to_string(),
        duration_ms,
        ..Default::default()
    };
    update_subagent_meta_completed(subagent_meta_dir, &result);
    emit_subagent_notification(
        gateway,
        parent_session_id,
        SessionUpdate::SubagentFinished {
            subagent_id: subagent_id.to_string(),
            child_session_id: child_session_id.0.to_string(),
            status: result.status().to_string(),
            error: result.error.clone(),
            tool_calls: 0,
            turns: 0,
            duration_ms,
            tokens_used: 0,
            output: None,
            will_wake: false,
        },
        parent_cmd_tx,
    );
    let _ = request.result_tx.send(result);
}
/// Tear down a subagent killed while pending: shut the idle child, dispose its
/// worktree (only if `worktree_freshly_created` — a resumed subagent's aliases
/// the source's and must survive), persist + emit a single cancelled
/// `SubagentFinished`, move the entry to completed-as-cancelled (stays
/// queryable), and deliver the result. Defuse the `PendingGuard` before calling.
async fn cancel_pending_subagent_at_promote(
    request: SubagentRequest,
    child_handle: &SessionHandle,
    subagent_id: &str,
    child_session_id: &acp::SessionId,
    subagent_meta_dir: &Path,
    coordinator: &std::cell::RefCell<SubagentCoordinator>,
    gateway: &GatewaySender,
    parent_session_id: &str,
    parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
    worktree_path: Option<&Path>,
    worktree_freshly_created: bool,
    duration_ms: u64,
) {
    let _ = child_handle.cmd_tx.send(SessionCommand::Shutdown);
    if worktree_freshly_created
        && let Some(wt_path) = worktree_path
        && let Err(e) = crate::session::worktree::remove_subagent_worktree(wt_path).await
    {
        tracing::warn!(
            subagent_id, worktree_path = % wt_path.display(), error = % e,
            "failed to remove pristine worktree for killed-while-pending subagent"
        );
    }
    let result = SubagentResult {
        success: false,
        cancelled: true,
        error: Some("Subagent was cancelled".to_string()),
        subagent_id: subagent_id.to_string(),
        child_session_id: child_session_id.0.to_string(),
        duration_ms,
        ..Default::default()
    };
    update_subagent_meta_completed(subagent_meta_dir, &result);
    emit_subagent_notification(
        gateway,
        parent_session_id,
        SessionUpdate::SubagentFinished {
            subagent_id: subagent_id.to_string(),
            child_session_id: child_session_id.0.to_string(),
            status: result.status().to_string(),
            error: result.error.clone(),
            tool_calls: 0,
            turns: 0,
            duration_ms,
            tokens_used: 0,
            output: None,
            will_wake: false,
        },
        parent_cmd_tx,
    );
    coordinator
        .borrow_mut()
        .move_pending_to_cancelled(subagent_id, "Subagent was cancelled");
    let _ = request.result_tx.send(result);
}
fn emit_subagent_notification(
    gateway: &GatewaySender,
    parent_session_id: &str,
    update: SessionUpdate,
    parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
) {
    let mut meta = None;
    crate::util::event_id::ensure_event_id_meta(parent_session_id, &mut meta);
    let notification = SessionNotification {
        session_id: acp::SessionId::new(parent_session_id),
        update,
        meta: meta.map(serde_json::Value::Object),
    };
    if let Some(cmd_tx) = parent_cmd_tx {
        let _ = cmd_tx.send(SessionCommand::XaiSessionNotification {
            notification: notification.clone(),
        });
    }
    let params = serde_json::to_value(&notification)
        .and_then(|v| serde_json::value::to_raw_value(&v))
        .ok();
    if let Some(params) = params {
        let ext_notification =
            acp::ExtNotification::new("kigi/session_notification", params.into());
        gateway.forward_fire_and_forget(ext_notification);
    }
}
/// Progress notification emission interval.
const PROGRESS_PUBLISH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
/// Change signature for the progress-publisher dedupe:
/// `(turn_count, tool_call_count, context_usage_pct, error_count, tokens_used)`.
///
/// `tokens_used` is part of the signature so rising child token spend always
/// publishes a tick: goal token accounting (subagent records, live totals,
/// and the turn-end budget check) keys off prompt token movement, which can
/// climb while turn/tool counts and the coarse context-usage *percent* bucket
/// stay flat. Omitting it would stall those updates until the heartbeat or an
/// unrelated field moved.
type ProgressSignature = (u32, u32, u8, u32, u64);
/// Whether a progress tick should be emitted given the previous and current
/// [`ProgressSignature`]s. Emits on any change, or when `heartbeat_due`
/// forces a keep-alive after an idle gap.
fn progress_tick_should_emit(
    prev: ProgressSignature,
    cur: ProgressSignature,
    heartbeat_due: bool,
) -> bool {
    cur != prev || heartbeat_due
}
/// Parent-actor tick channel for [`spawn_progress_publisher`]: goal token
/// accounting is the only consumer, so a goal-disabled session sends no
/// per-tick commands at all.
fn goal_tick_cmd_tx(
    goal_enabled: bool,
    parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
) -> Option<mpsc::UnboundedSender<SessionCommand>> {
    if goal_enabled {
        parent_cmd_tx.cloned()
    } else {
        None
    }
}
/// Spawn a background task that periodically emits `SubagentProgress`
/// notifications on the parent session's notification channel.
///
/// The publisher samples the child's `SessionSignalsHandle` every
/// [`PROGRESS_PUBLISH_INTERVAL`] and emits a `SubagentProgress`
/// notification if the subagent is still running. It stops automatically
/// when `cancel_token` is cancelled (subagent completion/cancellation).
///
/// When `parent_cmd_tx` is `Some`, each tick is also delivered to the
/// parent `SessionActor` so goal mode can advance its live subagent
/// token accounting; the actor's `SubagentProgress` arm never persists
/// these ticks.
///
/// Notifications are **not** persisted to JSONL — they are transient UI
/// hints, not authoritative lifecycle events. The TUI can resync via
/// `kigi/subagent/list_running` on reconnect.
fn spawn_progress_publisher(
    signals_handle: crate::session::signals::SessionSignalsHandle,
    gateway: GatewaySender,
    parent_session_id: String,
    subagent_id: String,
    child_session_id: String,
    started_at: std::time::Instant,
    cancel_token: tokio_util::sync::CancellationToken,
    parent_cmd_tx: Option<mpsc::UnboundedSender<SessionCommand>>,
) {
    tokio::task::spawn_local(async move {
        let mut interval = tokio::time::interval(PROGRESS_PUBLISH_INTERVAL);
        interval.tick().await;
        let mut last_signature: ProgressSignature = (0, 0, 0, 0, 0);
        let mut last_emit_at = tokio::time::Instant::now();
        let heartbeat_max = tokio::time::Duration::from_secs(8);
        loop {
            tokio::select! {
                _ = cancel_token.cancelled() => break, _ = interval.tick() => {}
            }
            let signals = match signals_handle.snapshot().await {
                Some(s) => s,
                None => break,
            };
            let sig: ProgressSignature = (
                signals.turn_count,
                signals.tool_call_count,
                signals.context_window_usage,
                signals.error_count,
                signals.context_tokens_used,
            );
            let heartbeat_due = last_emit_at.elapsed() >= heartbeat_max;
            if !progress_tick_should_emit(last_signature, sig, heartbeat_due) {
                continue;
            }
            last_signature = sig;
            last_emit_at = tokio::time::Instant::now();
            let duration_ms = started_at.elapsed().as_millis() as u64;
            let update = SessionUpdate::SubagentProgress {
                subagent_id: subagent_id.clone(),
                parent_session_id: parent_session_id.clone(),
                child_session_id: child_session_id.clone(),
                duration_ms,
                turn_count: signals.turn_count,
                tool_call_count: signals.tool_call_count,
                tokens_used: signals.context_tokens_used,
                context_window_tokens: signals.context_window_tokens,
                context_usage_pct: signals.context_window_usage,
                tools_used: signals.tools_used,
                error_count: signals.error_count,
            };
            let notification = SessionNotification {
                session_id: acp::SessionId::new(parent_session_id.clone()),
                update,
                meta: None,
            };
            let params = serde_json::to_value(&notification)
                .and_then(|v| serde_json::value::to_raw_value(&v))
                .ok();
            if let Some(ref cmd_tx) = parent_cmd_tx {
                let _ = cmd_tx.send(SessionCommand::XaiSessionNotification { notification });
            }
            if let Some(params) = params {
                let ext_notification =
                    acp::ExtNotification::new("kigi/session_notification", params.into());
                gateway.forward_fire_and_forget(ext_notification);
            }
        }
    });
}
#[cfg(test)]
mod progress_publisher_tests {
    use super::{ProgressSignature, progress_tick_should_emit};
    const BASE: ProgressSignature = (3, 7, 12, 0, 30_000);
    #[test]
    fn token_only_change_emits() {
        let cur: ProgressSignature = (3, 7, 12, 0, 45_000);
        assert!(progress_tick_should_emit(BASE, cur, false));
    }
    #[test]
    fn unchanged_without_heartbeat_skips() {
        assert!(!progress_tick_should_emit(BASE, BASE, false));
    }
    #[test]
    fn heartbeat_forces_emit_when_unchanged() {
        assert!(progress_tick_should_emit(BASE, BASE, true));
    }
}
/// Metadata stored as `meta.json` in the child session directory.
/// Links the child session back to its parent.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct SubagentMeta {
    pub subagent_id: String,
    pub parent_session_id: String,
    pub child_session_id: String,
    pub subagent_type: String,
    pub description: String,
    pub prompt: String,
    /// "running" | "completed" | "failed" | "cancelled"
    pub status: String,
    pub started_at: chrono::DateTime<chrono::Utc>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub duration_ms: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_calls: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub turns: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    /// Effective context source after bootstrap: "new" or "resumed".
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub effective_context_source: Option<String>,
    /// True only for a summarized (normalized) fork; false for verbatim
    /// mirror-forks, resume, and new sessions.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub context_normalized: bool,
    /// Error message if fork-copy failed and fell back to fresh.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fork_copy_error: Option<String>,
    /// Named persona applied to this subagent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub persona: Option<String>,
    /// ID of the source subagent this session was resumed from.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resumed_from: Option<String>,
    /// Effective cwd used by the child session. Persisted for durable
    /// `resume_from` reconstruction after in-memory cache eviction.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub child_cwd: Option<String>,
    /// Worktree path if the child used `isolation=worktree`. Persisted
    /// for durable `resume_from` reconstruction.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub worktree_path: Option<String>,
    /// Durable git ref holding a snapshot of the child's worktree working
    /// state. Persisted so a deleted worktree can be rehydrated on resume.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub snapshot_ref: Option<String>,
    /// Effective model ID used by the child session. Persisted for
    /// durable `resume_from` identity validation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub effective_model_id: Option<String>,
}
/// Write `meta.json`. Returns `true` on success so callers on the resume-pointer
/// path can gate worktree disposal on a durable write.
fn write_subagent_meta(dir: &Path, meta: &SubagentMeta) -> bool {
    if let Err(e) = std::fs::create_dir_all(dir) {
        tracing::warn!(error = % e, "failed to create subagent meta dir");
        return false;
    }
    let meta_path = dir.join("meta.json");
    let json = match serde_json::to_string_pretty(meta) {
        Ok(json) => json,
        Err(e) => {
            tracing::warn!(error = % e, "failed to serialize subagent meta");
            return false;
        }
    };
    if let Err(e) = std::fs::write(&meta_path, json) {
        tracing::warn!(error = % e, "failed to write subagent meta");
        return false;
    }
    true
}
/// Persist the durable worktree `snapshot_ref` into the on-disk `meta.json`
/// after completion, so `resumable_source_for` can rehydrate the disposed
/// worktree on resume. Returns `true` only when the ref is persisted to disk;
/// any read/parse/write failure is `warn!`-logged (this is the critical resume
/// pointer) so the caller keeps the worktree rather than removing it without a
/// recoverable ref. Also re-asserts the terminal `status` so a failed
/// `update_subagent_meta_completed` write can't leave a non-terminal record that
/// `resumable_source_for` rejects after the worktree is removed.
fn update_subagent_meta_snapshot_ref(dir: &Path, snapshot_ref: &str, status: &str) -> bool {
    let meta_path = dir.join("meta.json");
    let mut meta = match std::fs::read_to_string(&meta_path) {
        Ok(data) => match serde_json::from_str::<SubagentMeta>(&data) {
            Ok(meta) => meta,
            Err(e) => {
                tracing::warn!(
                    error = % e,
                    "failed to parse subagent meta; snapshot_ref not persisted (resume pointer lost)"
                );
                return false;
            }
        },
        Err(e) => {
            tracing::warn!(
                error = % e,
                "failed to read subagent meta; snapshot_ref not persisted (resume pointer lost)"
            );
            return false;
        }
    };
    meta.snapshot_ref = Some(snapshot_ref.to_string());
    meta.status = status.to_string();
    write_subagent_meta(dir, &meta)
}
fn update_subagent_meta_completed(dir: &Path, result: &SubagentResult) {
    let meta_path = dir.join("meta.json");
    if let Ok(data) = std::fs::read_to_string(&meta_path)
        && let Ok(mut meta) = serde_json::from_str::<SubagentMeta>(&data)
    {
        meta.status = result.status().to_string();
        meta.completed_at = Some(chrono::Utc::now());
        meta.duration_ms = Some(result.duration_ms);
        meta.tool_calls = Some(result.tool_calls);
        meta.turns = Some(result.turns);
        meta.error = result.error.clone();
        write_subagent_meta(dir, &meta);
    }
}
const ORPHAN_RECONCILE_REASON: &str = "interrupted by process restart";
/// `SubagentFinished` for a force-terminated orphan; interrupt counts are zeroed.
fn cancelled_orphan_finish(
    subagent_id: String,
    child_session_id: String,
    duration_ms: u64,
) -> SessionUpdate {
    SessionUpdate::SubagentFinished {
        subagent_id,
        child_session_id,
        status: "cancelled".to_string(),
        error: Some(ORPHAN_RECONCILE_REASON.to_string()),
        tool_calls: 0,
        turns: 0,
        duration_ms,
        tokens_used: 0,
        output: None,
        will_wake: false,
    }
}
/// Flip a stale `running` meta to `cancelled` and emit the missing finish.
/// On meta-write failure returns `false` and skips the notify, so a reload re-heals.
fn finalize_orphaned_subagent(
    subagent_meta_dir: &Path,
    mut meta: SubagentMeta,
    gateway: &GatewaySender,
    parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
) -> bool {
    let completed_at = chrono::Utc::now();
    let duration_ms = (completed_at - meta.started_at).num_milliseconds().max(0) as u64;
    meta.status = "cancelled".to_string();
    meta.completed_at = Some(completed_at);
    meta.duration_ms = Some(duration_ms);
    meta.tool_calls = Some(0);
    meta.turns = Some(0);
    meta.error = Some(ORPHAN_RECONCILE_REASON.to_string());
    if !write_subagent_meta(subagent_meta_dir, &meta) {
        return false;
    }
    emit_subagent_notification(
        gateway,
        &meta.parent_session_id,
        cancelled_orphan_finish(meta.subagent_id, meta.child_session_id, duration_ms),
        parent_cmd_tx,
    );
    true
}
/// Parse `meta_path` and return it only when it is a stale `running` orphan
/// owned by `parent_session_id` and not tracked live. Malformed metas → `None`.
fn running_orphan_meta(
    meta_path: &Path,
    coordinator: &SubagentCoordinator,
    parent_session_id: &str,
) -> Option<SubagentMeta> {
    let data = std::fs::read_to_string(meta_path).ok()?;
    let meta: SubagentMeta = serde_json::from_str(&data).ok()?;
    if meta.status != "running" || meta.parent_session_id != parent_session_id {
        return None;
    }
    if coordinator.is_active_or_pending(&meta.subagent_id) {
        return None;
    }
    Some(meta)
}
/// Heal subagents stuck "Running" after a dead process: emit exactly one
/// `SubagentFinished` per id, unioning two id-keyed sources (so a crash orphan
/// in both heals once) — `unfinished` replayed spawns whose finish a rewind
/// dropped (or a forked-in subagent with no meta), and on-disk `running` metas.
/// Skipping ids still active or pending: a `running` meta → `cancelled` (unless
/// the coordinator still holds its terminal result, then re-emit that); a terminal
/// meta that survived a rewound finish re-emits its real outcome; a no-meta
/// replayed spawn → `cancelled`. Runs after replay so the finish orders after the spawn.
pub(crate) fn reconcile_orphaned_subagents(
    unfinished: &[(String, String)],
    coordinator: &SubagentCoordinator,
    session_dir: &Path,
    parent_session_id: &str,
    gateway: &GatewaySender,
    parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
) {
    let subagents_dir = session_dir.join("subagents");
    let mut candidates: std::collections::BTreeMap<String, Option<String>> =
        std::collections::BTreeMap::new();
    for (id, child) in unfinished {
        candidates.insert(id.clone(), Some(child.clone()));
    }
    if let Ok(entries) = std::fs::read_dir(&subagents_dir) {
        for entry in entries.flatten() {
            let name = entry.file_name();
            if running_orphan_meta(
                &entry.path().join("meta.json"),
                coordinator,
                parent_session_id,
            )
            .is_some()
                && let Some(id) = name.to_str()
            {
                candidates.entry(id.to_string()).or_insert(None);
            }
        }
    }
    for (subagent_id, spawn_child) in candidates {
        if coordinator.is_active_or_pending(&subagent_id) {
            continue;
        }
        let subagent_dir = subagents_dir.join(&subagent_id);
        let meta = std::fs::read_to_string(subagent_dir.join("meta.json"))
            .ok()
            .and_then(|data| serde_json::from_str::<SubagentMeta>(&data).ok());
        match meta {
            Some(m) if m.parent_session_id != parent_session_id => {}
            Some(m) if m.status == "running" => {
                if let Some(finish) = coordinator.completed_finish(&subagent_id) {
                    tracing::info!(
                        subagent_id = % subagent_id, parent_session_id,
                        "Re-emitting finish for completed subagent with a lost terminal meta write"
                    );
                    emit_subagent_notification(gateway, parent_session_id, finish, parent_cmd_tx);
                } else {
                    tracing::info!(
                        subagent_id = % m.subagent_id, parent_session_id,
                        "Reconciling orphaned subagent left running by a previous process"
                    );
                    finalize_orphaned_subagent(&subagent_dir, m, gateway, parent_cmd_tx);
                }
            }
            Some(m) => {
                tracing::info!(
                    subagent_id = % subagent_id, parent_session_id, status = % m.status,
                    "Re-emitting finish for rewound subagent (terminal meta survived)"
                );
                emit_subagent_notification(
                    gateway,
                    parent_session_id,
                    SessionUpdate::SubagentFinished {
                        subagent_id,
                        child_session_id: m.child_session_id,
                        status: m.status,
                        error: m.error,
                        tool_calls: m.tool_calls.unwrap_or(0),
                        turns: m.turns.unwrap_or(0),
                        duration_ms: m.duration_ms.unwrap_or(0),
                        tokens_used: 0,
                        output: None,
                        will_wake: false,
                    },
                    parent_cmd_tx,
                );
            }
            None => {
                let Some(child_session_id) = spawn_child else {
                    continue;
                };
                tracing::info!(
                    subagent_id = % subagent_id, parent_session_id,
                    "Reconciling inherited subagent with no local meta (cancelled)"
                );
                emit_subagent_notification(
                    gateway,
                    parent_session_id,
                    cancelled_orphan_finish(subagent_id, child_session_id, 0),
                    parent_cmd_tx,
                );
            }
        }
    }
}
#[cfg(test)]
mod tests;
