//! Shared backend core: the product of the transport-neutral composition root.
//!
//! The composition root ([`BackendCore::build`]) is the *single* place that
//! constructs concrete adapters (`new ConcreteAdapter`) and injects them as
//! `Arc<dyn Port>` into the use cases (ARCHITECTURE §1.1, §10). The use cases
//! are then exposed to driving adapters through `BackendCore`.

use std::collections::{HashMap, HashSet};
use std::ffi::OsString;
use std::path::Path;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};

use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
use application::{
    AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent,
    AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession,
    BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus,
    CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
    CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal,
    CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
    CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
    CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
    DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
    DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
    DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState,
    GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
    GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
    GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation,
    InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent,
    LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
    ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
    ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
    ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
    LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
    MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
    MutateLayout, OnnxModelView, OpenPluginLayoutWindow, OpenProject, OpenTerminal,
    OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
    PermissionProjectorRegistry, PluginCommandTasks, PluginConfigDocuments,
    PluginEventSubscriptions, PluginStorageAccess, PluginToolchainDiagnostics,
    PluginWorkspaceAccess, ProposeContext, QueryProjectStructure, ReadAgentContext, ReadContext,
    ReadConversationPage, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions,
    ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory,
    ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers,
    RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
    ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions,
    ResolveAgentSystemPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
    ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog,
    SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
    SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows,
    SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode,
    StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
    UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
    UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
    UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
    UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions,
    UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
    WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
};
use async_trait::async_trait;
use domain::ports::{
    AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
    AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
    BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
    EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, FileSystem, GitPort,
    IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall,
    MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
    PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, PluginStorageStore,
    ProcessSpawner, ProfileStore, ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe,
    ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
    StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
    WakeError, WakeReason, WindowStateStore,
};
use domain::profile::{
    AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
    CODEX_CODE_MODE_FEATURES_TABLE, CODEX_CODE_MODE_FEATURES_TOML,
};
use domain::remote::RemoteKind;
use domain::{
    AgentId, AgentInbox, BackgroundTask, BackgroundTaskResult, BackgroundTaskWakePolicy,
    DomainEvent, EmbedderProfile, InboxError, InboxItem, InboxItemKind, InboxReceiptStatus,
    InboxSource, Project, ProjectId, TaskId, TicketId,
};
use serde_json::{json, Map, Value};
use uuid::Uuid;

use infrastructure::{
    embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
    BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
    CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner,
    EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor,
    FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore,
    FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
    FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
    FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
    FsPluginStorageStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore,
    FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore,
    Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
    HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
    InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
    LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer,
    MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter,
    ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory,
    SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
    TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
    VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
    VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
};

pub mod dto;
pub mod events;
pub mod mcp_endpoint;
pub mod openai_tools;
pub mod stream;
mod ticket_dto;

pub use infrastructure::{acquire_app_data_dir_lock, AppDataDirLock, AppDataDirLockError};

use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker};

use infrastructure::StdioTransport;

use interprocess::local_socket::tokio::Listener as LocalSocketListener;
use interprocess::local_socket::traits::tokio::Listener as _;
use interprocess::local_socket::{GenericFilePath, ListenerOptions, ToFsName};
use tokio::io::{AsyncBufReadExt, BufReader};

fn spawn_detached(future: impl std::future::Future<Output = ()> + Send + 'static) {
    std::thread::spawn(move || {
        let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
        else {
            return;
        };
        runtime.block_on(future);
    });
}

#[cfg(test)]
mod backend_core_build_tests {
    use super::BackendCore;

    #[test]
    fn backend_core_builds_without_desktop_runtime() {
        let dir =
            std::env::temp_dir().join(format!("idea-backend-core-build-{}", uuid::Uuid::new_v4()));
        let core = BackendCore::build(dir.clone());

        assert!(!core.home_dir.is_empty());

        let _ = std::fs::remove_dir_all(dir);
    }
}

#[derive(Clone, Default)]
pub struct LateBoundTicketToolProvider {
    inner: Arc<Mutex<Option<Arc<dyn TicketToolProvider>>>>,
}

impl LateBoundTicketToolProvider {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    pub fn bind(&self, inner: Arc<dyn TicketToolProvider>) {
        *self.inner.lock().expect("ticket tool mutex poisoned") = Some(inner);
    }
}

#[async_trait]
impl TicketToolProvider for LateBoundTicketToolProvider {
    async fn handle_ticket_tool(
        &self,
        project: &Project,
        requester: &str,
        name: &str,
        arguments: Value,
    ) -> Result<Value, infrastructure::TicketToolError> {
        let inner = self
            .inner
            .lock()
            .expect("ticket tool mutex poisoned")
            .clone()
            .ok_or_else(|| {
                infrastructure::TicketToolError::new(
                    "UNAVAILABLE",
                    "ticket tool provider not bound".to_owned(),
                )
            })?;
        inner
            .handle_ticket_tool(project, requester, name, arguments)
            .await
    }
}

#[derive(Clone, Default)]
pub struct LateBoundTemplateToolProvider {
    inner: Arc<Mutex<Option<Arc<dyn TemplateToolProvider>>>>,
}

impl LateBoundTemplateToolProvider {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    pub fn bind(&self, inner: Arc<dyn TemplateToolProvider>) {
        *self.inner.lock().expect("template tool mutex poisoned") = Some(inner);
    }
}

#[async_trait]
impl TemplateToolProvider for LateBoundTemplateToolProvider {
    async fn handle_template_tool(
        &self,
        project: &Project,
        requester: &str,
        name: &str,
        arguments: Value,
    ) -> Result<Value, infrastructure::TemplateToolError> {
        let inner = self
            .inner
            .lock()
            .expect("template tool mutex poisoned")
            .clone()
            .ok_or_else(|| {
                infrastructure::TemplateToolError::new(
                    "UNAVAILABLE",
                    "template tool provider not bound".to_owned(),
                )
            })?;
        inner
            .handle_template_tool(project, requester, name, arguments)
            .await
    }
}

/// Implémente [`RecordTurnProvider`] (lot P6b) en matérialisant un [`RecordTurn`]
/// ciblant le **project root** du tour en cours.
///
/// L'[`OrchestratorService`] est unique pour tous les projets, alors que le log/handoff
/// conversationnel est **par project root** (`<root>/.ideai/conversations/`). Les
/// adapters `Fs*` fixent leur racine à la construction et ne sont que des jointures de
/// chemin : on en construit donc un jeu frais par tour, ciblant le bon dossier. Sans
/// état (zéro champ), partagé via un simple `Arc`.
struct AppRecordTurnProvider;

impl RecordTurnProvider for AppRecordTurnProvider {
    fn record_turn_for(&self, root: &domain::project::ProjectPath) -> Option<Arc<RecordTurn>> {
        let log = Arc::new(FsConversationLog::new(root));
        let handoffs = Arc::new(FsHandoffStore::new(root));
        let summarizer = Arc::new(HeuristicHandoffSummarizer::new());
        Some(Arc::new(RecordTurn::new(log, handoffs, summarizer)))
    }
}

/// Implémente [`ConversationArchiveProvider`](application::ConversationArchiveProvider)
/// (lot LS6) en matérialisant un [`FsConversationLog`] (qui implémente aussi
/// [`domain::ConversationArchive`]) ciblant le **project root** courant.
///
/// Même raison d'être que [`AppRecordTurnProvider`] : les use cases d'archivage/pagination
/// sont uniques pour tous les projets, alors que les logs sont **par project root**. On
/// construit donc un archive frais par appel, ciblant le bon dossier. Sans état.
struct AppConversationArchiveProvider;

impl application::ConversationArchiveProvider for AppConversationArchiveProvider {
    fn conversation_archive_for(
        &self,
        root: &domain::project::ProjectPath,
    ) -> Option<Arc<dyn domain::ConversationArchive>> {
        Some(Arc::new(FsConversationLog::new(root)) as Arc<dyn domain::ConversationArchive>)
    }
}

/// Implémente [`HandoffProvider`](application::HandoffProvider) (lot P7) en
/// matérialisant un [`FsHandoffStore`] ciblant le **project root** du lancement en
/// cours.
///
/// Même raison d'être que [`AppRecordTurnProvider`] : [`LaunchAgent`] est unique pour
/// tous les projets, alors que le handoff est **par project root**
/// (`<root>/.ideai/conversations/`). On construit donc un store frais par lancement,
/// ciblant le bon dossier. Sans état (zéro champ), partagé via un simple `Arc`.
struct AppHandoffProvider;

impl application::HandoffProvider for AppHandoffProvider {
    fn handoff_store_for(
        &self,
        root: &domain::project::ProjectPath,
    ) -> Option<Arc<dyn domain::HandoffStore>> {
        Some(Arc::new(FsHandoffStore::new(root)) as Arc<dyn domain::HandoffStore>)
    }
}

/// Implémente [`ConversationLogProvider`](application::ConversationLogProvider) (Lot C)
/// en matérialisant un [`FsConversationLog`] ciblant le **project root** courant.
///
/// Jumeau stateless de [`AppHandoffProvider`] : le read-model work-state est unique
/// pour tous les projets, alors que le log est **par project root**
/// (`<root>/.ideai/conversations/`). On construit donc un log frais par appel, ciblant
/// le bon dossier. Sert de **repli** lecture seule (`last(_, 3)`) aux résumés de
/// conversation. Sans état (zéro champ), partagé via un simple `Arc`.
struct AppConversationLogProvider;

impl application::ConversationLogProvider for AppConversationLogProvider {
    fn conversation_log_for(
        &self,
        root: &domain::project::ProjectPath,
    ) -> Option<Arc<dyn domain::ConversationLog>> {
        Some(Arc::new(FsConversationLog::new(root)) as Arc<dyn domain::ConversationLog>)
    }
}

/// Implémente [`LiveStateProvider`] (programme live-state, lot LS3) en matérialisant un
/// [`UpdateLiveState`] dont le [`FsLiveStateStore`] cible le **project root** du tour.
///
/// Même raison d'être que [`AppRecordTurnProvider`] : l'[`OrchestratorService`] est
/// unique pour tous les projets, alors que le live-state est **par project root**
/// (`<root>/.ideai/live-state.json`) et le store fixe sa racine à la construction. On
/// construit donc un `UpdateLiveState` frais par transition, ciblant le bon dossier.
/// Porte l'horloge (port [`Clock`]) injectée au composition root — `UpdateLiveState`
/// l'utilise pour estampiller `updated_at_ms`.
struct AppLiveStateProvider {
    clock: Arc<dyn Clock>,
}

impl LiveStateProvider for AppLiveStateProvider {
    fn live_state_for(&self, root: &domain::project::ProjectPath) -> Option<Arc<UpdateLiveState>> {
        let store = Arc::new(FsLiveStateStore::new(root));
        Some(Arc::new(UpdateLiveState::new(
            store,
            Arc::clone(&self.clock),
        )))
    }
}

/// Implémente [`LiveStateLeanProvider`] (injection au lancement, lot LS4) **et**
/// [`LiveStateReadProvider`] (outil `idea_workstate_read`) en matérialisant un
/// [`GetLiveStateLean`] dont le [`FsLiveStateStore`] cible le **project root** courant.
///
/// Même raison d'être que [`AppLiveStateProvider`] (côté écriture) : [`LaunchAgent`] et
/// l'[`OrchestratorService`] sont uniques pour tous les projets, alors que le live-state
/// est **par project root** (`<root>/.ideai/live-state.json`) et le store fixe sa racine
/// à la construction. On construit donc un `GetLiveStateLean` frais par appel, ciblant le
/// bon dossier (prune-on-read + snapshot lean). Porte l'horloge (port [`Clock`]).
struct AppLiveStateLeanProvider {
    clock: Arc<dyn Clock>,
}

impl AppLiveStateLeanProvider {
    fn getter(&self, root: &domain::project::ProjectPath) -> Arc<GetLiveStateLean> {
        let store = Arc::new(FsLiveStateStore::new(root));
        Arc::new(GetLiveStateLean::new(store, Arc::clone(&self.clock)))
    }
}

impl LiveStateLeanProvider for AppLiveStateLeanProvider {
    fn live_state_lean_for(
        &self,
        root: &domain::project::ProjectPath,
    ) -> Option<Arc<GetLiveStateLean>> {
        Some(self.getter(root))
    }
}

impl LiveStateReadProvider for AppLiveStateLeanProvider {
    fn live_state_lean_for(
        &self,
        root: &domain::project::ProjectPath,
    ) -> Option<Arc<GetLiveStateLean>> {
        Some(self.getter(root))
    }
}

/// Provider par-root de la réconciliation du live-state au reboot (jumeau de
/// [`AppLiveStateProvider`] côté système). Résout le **project root** depuis le
/// `project_id` via le [`ProjectStore`], matérialise un [`FsLiveStateStore`] ciblant
/// `<root>/.ideai/live-state.json`, puis exécute le use case applicatif
/// [`ReconcileLiveState`] (qui croise chaque ligne avec la liveness réelle de
/// [`LiveSessions`] et ré-upsert les fantômes en `idle`).
///
/// **Acte système** : appelle le port [`domain::ports::LiveStateStore`]
/// **directement**, jamais via `OrchestratorCommand::SetWorkState` ⇒ le self-only de
/// `idea_workstate_set` reste préservé par construction. Best-effort : le call site
/// (`open_project`) ignore le résultat.
pub struct AppReconcileLiveState {
    projects: Arc<dyn ProjectStore>,
    registry: Arc<LiveSessions>,
    clock: Arc<dyn Clock>,
}

impl AppReconcileLiveState {
    /// Résout la racine du projet, lie le store par-root et lance la réconciliation.
    ///
    /// # Errors
    /// [`AppError`] si le projet est inconnu (registre) ou si le store échoue.
    pub async fn execute(&self, input: ReconcileLiveStateInput) -> Result<(), AppError> {
        let project = self.projects.load_project(input.project_id).await?;
        let store = Arc::new(FsLiveStateStore::new(&project.root));
        let uc = ReconcileLiveState::new(
            store,
            Arc::clone(&self.registry) as Arc<dyn LiveAgentRegistry>,
            Arc::clone(&self.clock),
        );
        uc.execute(input).await
    }
}

/// Store global injecté à l'[`OrchestratorService`] qui route chaque opération vers
/// un [`FsBackgroundTaskStore`] lié au root du projet concerné.
///
/// Le service orchestrateur est unique et multi-projets, alors que
/// `FsBackgroundTaskStore` fixe `<root>/.ideai/background-tasks` à la construction.
/// Ce routeur garde donc la frontière per-root sans instancier un store global sur
/// une racine arbitraire.
struct AppBackgroundTaskStore {
    projects: Arc<dyn ProjectStore>,
    stores: Mutex<HashMap<ProjectId, Arc<FsBackgroundTaskStore>>>,
    task_projects: Mutex<HashMap<TaskId, ProjectId>>,
}

impl AppBackgroundTaskStore {
    fn new(projects: Arc<dyn ProjectStore>) -> Self {
        Self {
            projects,
            stores: Mutex::new(HashMap::new()),
            task_projects: Mutex::new(HashMap::new()),
        }
    }

    async fn store_for_project(
        &self,
        project_id: ProjectId,
    ) -> Result<Arc<FsBackgroundTaskStore>, BackgroundTaskPortError> {
        if let Some(store) = self
            .stores
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .get(&project_id)
            .cloned()
        {
            return Ok(store);
        }

        let project = self
            .projects
            .load_project(project_id)
            .await
            .map_err(|err| BackgroundTaskPortError::Store(err.to_string()))?;
        let store = Arc::new(FsBackgroundTaskStore::new(&project.root));
        self.stores
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .insert(project_id, Arc::clone(&store));
        Ok(store)
    }

    fn remember(&self, task_id: TaskId, project_id: ProjectId) {
        self.task_projects
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .insert(task_id, project_id);
    }

    async fn project_for_task(
        &self,
        task_id: TaskId,
    ) -> Result<Option<ProjectId>, BackgroundTaskPortError> {
        if let Some(project_id) = self
            .task_projects
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .get(&task_id)
            .copied()
        {
            return Ok(Some(project_id));
        }

        for project in self
            .projects
            .list_projects()
            .await
            .map_err(|err| BackgroundTaskPortError::Store(err.to_string()))?
        {
            let store = self.store_for_project(project.id).await?;
            if store.get(task_id).await?.is_some() {
                self.remember(task_id, project.id);
                return Ok(Some(project.id));
            }
        }
        Ok(None)
    }

    async fn list_undelivered_for_project(
        &self,
        project_id: ProjectId,
    ) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
        let store = self.store_for_project(project_id).await?;
        let tasks = store.list_undelivered_completions().await?;
        for task in &tasks {
            self.remember(task.id, task.project_id);
        }
        Ok(tasks)
    }

    async fn reconcile_project_boot(
        &self,
        project_id: ProjectId,
        live_task_ids: &[TaskId],
        now_ms: u64,
    ) -> Result<(), BackgroundTaskPortError> {
        let store = self.store_for_project(project_id).await?;
        let report = store.reconcile_boot(live_task_ids, now_ms).await?;
        for task_id in report
            .failed_task_ids
            .into_iter()
            .chain(report.delivery_pending_task_ids.into_iter())
        {
            self.remember(task_id, project_id);
        }
        Ok(())
    }
}

#[async_trait]
impl BackgroundTaskStore for AppBackgroundTaskStore {
    async fn create(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> {
        let store = self.store_for_project(task.project_id).await?;
        store.create(task).await?;
        self.remember(task.id, task.project_id);
        Ok(())
    }

    async fn get(&self, id: TaskId) -> Result<Option<BackgroundTask>, BackgroundTaskPortError> {
        let Some(project_id) = self.project_for_task(id).await? else {
            return Ok(None);
        };
        let task = self.store_for_project(project_id).await?.get(id).await?;
        if let Some(task) = &task {
            self.remember(task.id, task.project_id);
        }
        Ok(task)
    }

    async fn save(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> {
        let store = self.store_for_project(task.project_id).await?;
        store.save(task).await?;
        self.remember(task.id, task.project_id);
        Ok(())
    }

    async fn list_open_for_agent(
        &self,
        agent_id: AgentId,
    ) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
        let mut tasks = Vec::new();
        for project in self
            .projects
            .list_projects()
            .await
            .map_err(|err| BackgroundTaskPortError::Store(err.to_string()))?
        {
            let store = self.store_for_project(project.id).await?;
            tasks.extend(store.list_open_for_agent(agent_id).await?);
        }
        for task in &tasks {
            self.remember(task.id, task.project_id);
        }
        tasks.sort_by_key(|task| (task.created_at_ms, task.id));
        Ok(tasks)
    }

    async fn list_undelivered_completions(
        &self,
    ) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
        let mut tasks = Vec::new();
        for project in self
            .projects
            .list_projects()
            .await
            .map_err(|err| BackgroundTaskPortError::Store(err.to_string()))?
        {
            tasks.extend(self.list_undelivered_for_project(project.id).await?);
        }
        tasks.sort_by_key(|task| (task.updated_at_ms, task.id));
        Ok(tasks)
    }

    async fn mark_completion_delivered(
        &self,
        task_id: TaskId,
    ) -> Result<(), BackgroundTaskPortError> {
        let Some(project_id) = self.project_for_task(task_id).await? else {
            return Err(BackgroundTaskPortError::NotFound);
        };
        self.store_for_project(project_id)
            .await?
            .mark_completion_delivered(task_id)
            .await
    }
}

pub struct AppReconcileBackgroundTasks {
    projects: Arc<dyn ProjectStore>,
    store: Arc<AppBackgroundTaskStore>,
    clock: Arc<dyn Clock>,
    ready: tokio::sync::mpsc::UnboundedSender<BackgroundTaskReadyToDeliver>,
}

impl AppReconcileBackgroundTasks {
    pub async fn execute(&self, project_id: ProjectId) -> Result<(), AppError> {
        let project = self.projects.load_project(project_id).await?;
        self.store
            .reconcile_project_boot(project_id, &[], self.clock.now_millis().max(0) as u64)
            .await
            .map_err(|err| AppError::Store(err.to_string()))?;

        let tasks = self
            .store
            .list_undelivered_for_project(project_id)
            .await
            .map_err(|err| AppError::Store(err.to_string()))?;
        for task in tasks {
            match task.wake_policy {
                BackgroundTaskWakePolicy::WakeOwner => {
                    let ready = BackgroundTaskReadyToDeliver {
                        task_id: task.id,
                        project_id: task.project_id,
                        owner_agent_id: task.owner_agent_id,
                    };
                    if self.ready.send(ready).is_err() {
                        return Err(AppError::Internal(
                            "background task delivery channel is closed".to_owned(),
                        ));
                    }
                }
                BackgroundTaskWakePolicy::RecordOnly => {
                    self.store
                        .mark_completion_delivered(task.id)
                        .await
                        .map_err(|err| AppError::Store(err.to_string()))?;
                }
            }
        }

        application::diag!(
            "[background-task] boot reconcile complete: project={} root={}",
            project.id,
            project.root.as_str()
        );
        Ok(())
    }
}

fn schedule_background_ready_retry(
    ready_tx: tokio::sync::mpsc::UnboundedSender<BackgroundTaskReadyToDeliver>,
    ready: BackgroundTaskReadyToDeliver,
) {
    spawn_detached(async move {
        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
        let _ = ready_tx.send(ready);
    });
}

fn schedule_background_wake_retry(
    wake: Arc<dyn AgentWakePort>,
    project: Project,
    owner_agent_id: AgentId,
    task_id: TaskId,
) {
    spawn_detached(async move {
        loop {
            tokio::time::sleep(std::time::Duration::from_secs(1)).await;
            match wake
                .wake_agent(
                    &project,
                    owner_agent_id,
                    WakeReason::BackgroundCompletion { task_id },
                )
                .await
            {
                Ok(()) => break,
                Err(WakeError::AgentBusy { .. }) => continue,
                Err(err) => {
                    application::diag!(
                        "[background-task] completion wake retry failed: task={} owner={} err={err}",
                        task_id,
                        owner_agent_id
                    );
                    break;
                }
            }
        }
    });
}

struct AppWakeSessionProvider {
    launch_agent: Arc<LaunchAgent>,
    structured_sessions: Arc<StructuredSessions>,
}

#[async_trait]
impl WakeSessionProvider for AppWakeSessionProvider {
    async fn session_for_wake(
        &self,
        project: &Project,
        agent: AgentId,
    ) -> Result<Arc<dyn AgentSession>, WakeError> {
        if let Some(session) = self
            .structured_sessions
            .session_for_agent_in_project(project.id, &agent)
        {
            return Ok(session);
        }

        self.launch_agent
            .execute(LaunchAgentInput {
                project: project.clone(),
                agent_id: agent,
                rows: 24,
                cols: 80,
                node_id: None,
                conversation_id: None,
                mcp_runtime: None,
                allow_structured_alongside_pty: true,
            })
            .await
            .map_err(|err| WakeError::Session(err.to_string()))?;

        self.structured_sessions
            .session_for_agent_in_project(project.id, &agent)
            .ok_or_else(|| {
                WakeError::Session(format!(
                    "agent {agent} has no structured session after background wake launch"
                ))
            })
    }
}

/// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot
/// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du
/// lancement en cours.
///
/// Jumeau stateless de [`AppHandoffProvider`] : [`LaunchAgent`] est unique pour tous
/// les projets, alors que `providers.json` est **par project root**
/// (`<root>/.ideai/conversations/providers.json`). On construit donc un store frais
/// par lancement, ciblant le bon dossier. Sans état (zéro champ), partagé via `Arc`.
struct AppProviderSessionProvider;

impl application::ProviderSessionProvider for AppProviderSessionProvider {
    fn provider_session_store_for(
        &self,
        root: &domain::project::ProjectPath,
    ) -> Option<Arc<dyn domain::ProviderSessionStore>> {
        Some(Arc::new(FsProviderSessionStore::new(root)) as Arc<dyn domain::ProviderSessionStore>)
    }
}

/// Contexte minimal de **relance** d'un agent (LS7, ARCHITECTURE §21.5).
///
/// [`AgentResumer::resume`] ne porte pas le `Project` complet ni la taille de la
/// cellule, alors que [`LaunchAgentInput`] les exige. La commande `launch_agent`
/// (seul endroit où ces faits sont en main) alimente ce contexte par clé runtime
/// `(project_id, agent_id)` ; [`AppAgentResumer`] le relit à l'échéance pour
/// recomposer un lancement complet sans collision entre projets.
#[derive(Clone)]
pub struct ResumeContext {
    /// Le projet hôte de l'agent (pour recomposer `LaunchAgentInput`).
    pub project: Project,
    /// Hauteur de la cellule au dernier lancement (lignes PTY).
    pub rows: u16,
    /// Largeur de la cellule au dernier lancement (colonnes PTY).
    pub cols: u16,
}

/// Registre partagé `(project_id, agent_id) → ResumeContext` (composition root ↔ commande
/// `launch_agent`). Le **même** `Arc` est injecté dans [`AppAgentResumer`] et conservé
/// sur [`BackendCore`] pour que la commande l'alimente à chaque lancement.
pub type ResumeContexts = Arc<Mutex<HashMap<domain::RuntimeAgentKey, ResumeContext>>>;

/// Implémente le port applicatif [`AgentResumer`] (LS7) **par-dessus** le mécanisme de
/// lancement existant ([`LaunchAgent`]).
///
/// À l'échéance d'une limite de session, [`SessionLimitService::execute_resume`]
/// délègue ici : on relit le [`ResumeContext`] alimenté par `launch_agent`, on
/// recompose un [`LaunchAgentInput`] (avec le `conversation_id` de la cellule ⇒
/// `LaunchAgent` applique [`domain::ports::SessionPlan::Resume`]), puis on transmet le
/// `resume_prompt` comme **premier tour** via le portail d'entrée unique
/// ([`InputMediator`](domain::input::InputMediator)) — jamais un write PTY brut
/// (ARCHITECTURE §20). Sans contexte connu (resume « à l'aveugle »), on échoue
/// proprement (l'erreur empêche `AgentResumed` d'être publié), jamais de panique.
struct AppAgentResumer {
    /// Le **même** `Arc<LaunchAgent>` que la commande `launch_agent` (relance/réattache).
    launch_agent: Arc<LaunchAgent>,
    /// Portail d'entrée unique : injecte le prompt de reprise comme premier tour.
    input_mediator: Arc<dyn domain::input::InputMediator>,
    /// Contexte de relance alimenté par la commande `launch_agent`.
    contexts: ResumeContexts,
}

#[async_trait::async_trait]
impl AgentResumer for AppAgentResumer {
    async fn resume(
        &self,
        project_id: ProjectId,
        agent_id: AgentId,
        node_id: domain::NodeId,
        conversation_id: Option<String>,
        resume_prompt: &str,
    ) -> Result<(), AppError> {
        // Repli propre (jamais de panique) : sans contexte de relance connu, on ne
        // reprend pas à l'aveugle. L'erreur remonte ⇒ `AgentResumed` n'est pas publié.
        let ctx = self.contexts.lock().ok().and_then(|m| {
            m.get(&domain::RuntimeAgentKey::new(project_id, agent_id))
                .cloned()
        });
        let Some(ctx) = ctx else {
            return Err(AppError::NotFound(format!(
                "resume context for agent {agent_id}"
            )));
        };

        // Recompose la déclaration MCP réelle (même recette que la commande
        // `launch_agent`) pour que l'agent repris retrouve ses outils `idea_*`.
        let mcp_runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
            exe,
            endpoint: crate::mcp_endpoint::mcp_endpoint(&ctx.project.id)
                .as_cli_arg()
                .to_owned(),
            project_id: ctx.project.id.as_uuid().simple().to_string(),
            requester: agent_id.to_string(),
        });

        let project_id = ctx.project.id;
        self.launch_agent
            .execute(LaunchAgentInput {
                project: ctx.project,
                agent_id,
                rows: ctx.rows,
                cols: ctx.cols,
                node_id: Some(node_id),
                conversation_id,
                mcp_runtime,
                allow_structured_alongside_pty: false,
            })
            .await?;

        // Premier tour de reprise par le **portail d'entrée** (pas de write brut, §20) :
        // l'enqueue publie `DelegationReady`, la cellule l'écrit quand le prompt est prêt.
        // On ne corrèle aucune réponse (reprise, pas une délégation) ⇒ on lâche le
        // `PendingReply`.
        let ticket = domain::mailbox::Ticket::new(
            domain::mailbox::TicketId::new_random(),
            "IdeA",
            resume_prompt,
        );
        let _ = self
            .input_mediator
            .enqueue(domain::RuntimeAgentKey::new(project_id, agent_id), ticket);

        Ok(())
    }
}

/// Everything a driving adapter needs at runtime.
///
/// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete
/// adapters are owned here and never leak past the composition root as concrete
/// types — downstream code only sees the `Arc<dyn Port>` held inside the use
/// cases.
pub struct BackendCore {
    /// Trivial health use case validating the end-to-end wiring.
    pub health: Arc<HealthUseCase>,
    /// Pair a persistent device session.
    pub pair_device: Arc<PairDevice>,
    /// Limits failed pairing attempts.
    pub pair_attempt_limiter: Arc<dyn PairAttemptLimiter>,
    /// Authenticate a persistent device session.
    pub authenticate_session: Arc<AuthenticateSession>,
    /// Throttled update of the authenticated device last-seen timestamp.
    pub touch_device: Arc<TouchDevice>,
    /// List paired devices.
    pub list_devices: Arc<ListDevices>,
    /// Rename a paired device.
    pub rename_device: Arc<RenameDevice>,
    /// Revoke one paired device.
    pub revoke_device: Arc<RevokeDevice>,
    /// Revoke every paired device.
    pub revoke_all_devices: Arc<RevokeAllDevices>,
    /// Paired-device store port, exposed for legacy logout revocation.
    pub device_session_store: Arc<dyn DeviceSessionStore>,
    /// Create a project (init `.ideai/`, register it).
    pub create_project: Arc<CreateProject>,
    /// Open a project (load meta + manifest).
    pub open_project: Arc<OpenProject>,
    /// Close a project (persist state).
    pub close_project: Arc<CloseProject>,
    /// Close a tab.
    pub close_tab: Arc<CloseTab>,
    /// List known projects.
    pub list_projects: Arc<ListProjects>,
    /// Read `.ideai/CONTEXT.md`.
    pub read_project_context: Arc<ReadProjectContext>,
    /// Overwrite `.ideai/CONTEXT.md`.
    pub update_project_context: Arc<UpdateProjectContext>,
    /// Open a terminal (spawn PTY, register session).
    pub open_terminal: Arc<OpenTerminal>,
    /// Write keystrokes to a terminal.
    pub write_terminal: Arc<WriteToTerminal>,
    /// Resize a terminal.
    pub resize_terminal: Arc<ResizeTerminal>,
    /// Close a terminal (kill PTY).
    pub close_terminal: Arc<CloseTerminal>,
    /// Load a project's persisted layout tree.
    pub load_layout: Arc<LoadLayout>,
    /// Mutate + persist a project's layout tree.
    pub mutate_layout: Arc<MutateLayout>,
    /// List all named layouts for a project (#4).
    pub list_layouts: Arc<ListLayouts>,
    /// Create a new named layout (#4).
    pub create_layout: Arc<CreateLayout>,
    /// Rename a named layout (#4).
    pub rename_layout: Arc<RenameLayout>,
    /// Delete a named layout (#4).
    pub delete_layout: Arc<DeleteLayout>,
    /// Set the active named layout (#4).
    pub set_active_layout: Arc<SetActiveLayout>,
    /// Validate/open a plugin layout in a detached OS window.
    pub open_plugin_layout_window: Arc<OpenPluginLayoutWindow>,
    /// Freeze `agent_was_running` on every agent leaf before a PTY kill (T5).
    pub snapshot_running_agents: Arc<SnapshotRunningAgents>,
    /// Dé-doublonne, à l'ouverture, les feuilles d'agent en double d'un même
    /// agent dans `layouts.json` (R0c). Idempotent : no-op sans doublon.
    pub reconcile_layouts: Arc<ReconcileLayouts>,
    /// Réconcilie, à l'ouverture, les lignes de live-state fantômes (agent
    /// `working`/`waiting`/`blocked` dont la session n'est plus vivante) : les
    /// repasse en `idle`. Acte système best-effort, jamais via la surface MCP.
    pub reconcile_live_state: Arc<AppReconcileLiveState>,
    /// Réconcilie, à l'ouverture, les tâches de fond persistées : marque les
    /// `Running` sans handle vivant comme perdues, ré-enqueue les complétions
    /// `WakeOwner` non livrées et ferme les `RecordOnly`.
    pub reconcile_background_tasks: Arc<AppReconcileBackgroundTasks>,
    /// Detect which candidate profiles' CLIs are installed (first-run).
    pub detect_profiles: Arc<DetectProfiles>,
    /// List configured profiles.
    pub list_profiles: Arc<ListProfiles>,
    /// Save (upsert) a profile.
    pub save_profile: Arc<SaveProfile>,
    /// Save (upsert) an OpenCode profile backed by a cloud provider, sealing its
    /// literal API key into the [`SecretStore`] (ticket #92, lot B3).
    pub save_opencode_provider_profile: Arc<SaveOpenCodeProviderProfile>,
    /// Static catalogue of OpenCode cloud providers (ticket #92, lot B3).
    pub list_opencode_providers: Arc<ListOpenCodeProviders>,
    /// Static curated Claude model catalogue.
    pub list_claude_models: Arc<ListClaudeModels>,
    /// Static curated Codex model catalogue.
    pub list_codex_models: Arc<ListCodexModels>,
    /// Create a new profile instance from a persisted/reference seed.
    pub clone_profile_from_seed: Arc<CloneProfileFromSeed>,
    /// Create a new OpenCode profile instance from the canonical seed.
    pub clone_opencode_profile_from_seed: Arc<CloneOpenCodeProfileFromSeed>,
    /// Delete a profile.
    pub delete_profile: Arc<DeleteProfile>,
    /// Persist the batch of chosen profiles (closes the first run).
    pub configure_profiles: Arc<ConfigureProfiles>,
    /// Expose the pre-filled reference catalogue.
    pub reference_profiles: Arc<ReferenceProfiles>,
    /// Whether the first-run wizard should show + the reference catalogue.
    pub first_run_state: Arc<FirstRunState>,
    /// Ensures local llama.cpp model servers for OpenCode profiles.
    pub ensure_local_model_server: Arc<EnsureLocalModelServer>,
    /// Lists local model server configurations.
    pub list_model_servers: Arc<ListModelServers>,
    /// Saves local model server configurations.
    pub save_model_server: Arc<SaveModelServer>,
    /// Deletes local model server configurations when unused.
    pub delete_model_server: Arc<DeleteModelServer>,
    /// Deletes managed local model artifacts without deleting server configs.
    pub delete_model_artifact: Arc<DeleteModelArtifact>,
    /// The local PTY adapter, kept port-typed so driving adapters can subscribe
    /// output and route it through their own transport bridge.
    pub pty_port: Arc<dyn PtyPort>,
    /// Active-terminal registry shared by the terminal use cases.
    pub terminal_sessions: Arc<TerminalSessions>,
    /// The domain event bus (also handed to the event relay).
    pub event_bus: Arc<TokioBroadcastEventBus>,
    /// Create an issue-backed public ticket.
    pub create_issue: Arc<CreateIssue>,
    /// Read an issue-backed public ticket.
    pub read_issue: Arc<ReadIssue>,
    /// Delete an issue-backed public ticket.
    pub delete_issue: Arc<DeleteIssue>,
    /// List issue-backed public tickets.
    pub list_issues: Arc<ListIssues>,
    /// Update an issue-backed public ticket.
    pub update_issue: Arc<UpdateIssue>,
    /// Update public ticket statuses in batch.
    pub bulk_update_issue_status: Arc<BulkUpdateIssueStatus>,
    /// Update public ticket priorities in batch.
    pub bulk_update_issue_priority: Arc<BulkUpdateIssuePriority>,
    /// Delete public tickets in batch.
    pub bulk_delete_issues: Arc<BulkDeleteIssues>,
    /// Read a ticket carnet.
    pub read_issue_carnet: Arc<ReadIssueCarnet>,
    /// Update a ticket carnet.
    pub update_issue_carnet: Arc<UpdateIssueCarnet>,
    /// Attach a local file to a ticket.
    pub add_issue_attachment: Arc<AddIssueAttachment>,
    /// Read one ticket attachment.
    pub read_issue_attachment: Arc<ReadIssueAttachment>,
    /// Mark a ticket attachment as summarized in the carnet.
    pub mark_issue_attachment_summarized: Arc<MarkIssueAttachmentSummarized>,
    /// Link two public tickets.
    pub link_issues: Arc<LinkIssues>,
    /// Unlink public tickets.
    pub unlink_issues: Arc<UnlinkIssues>,
    /// Assign or unassign an agent on a public ticket.
    pub assign_issue_agent: Arc<AssignIssueAgent>,
    /// Create a sprint.
    pub create_sprint: Arc<CreateSprint>,
    /// List sprints.
    pub list_sprints: Arc<ListSprints>,
    /// Rename a sprint.
    pub rename_sprint: Arc<RenameSprint>,
    /// Reorder sprints.
    pub reorder_sprints: Arc<ReorderSprints>,
    /// Delete a sprint.
    pub delete_sprint: Arc<DeleteSprint>,
    /// Assign a ticket to a sprint.
    pub assign_ticket_to_sprint: Arc<AssignTicketToSprint>,
    /// Unassign a ticket from its sprint.
    pub unassign_ticket_from_sprint: Arc<UnassignTicketFromSprint>,
    /// MCP provider for the public `idea_ticket_*` tools.
    pub(crate) ticket_tool_provider: Arc<dyn TicketToolProvider>,
    /// MCP provider for the public `idea_template_*` tools.
    pub(crate) template_tool_provider: Arc<dyn TemplateToolProvider>,
    /// Open an ephemeral AI assistant chat bound to one ticket.
    pub open_ticket_assistant: Arc<OpenTicketAssistant>,
    /// Close an ephemeral AI assistant chat bound to one ticket.
    pub close_ticket_assistant: Arc<CloseTicketAssistant>,
    /// Shared MCP tool policy registry used by ticket assistant sessions.
    pub tool_policy_registry: Arc<ToolPolicyRegistry>,
    /// Durable per-agent MCP tool permission store.
    pub mcp_tool_permission_store: Arc<dyn McpToolPermissionStore>,
    /// Read durable MCP tool permissions plus canonical catalogue classification.
    pub read_mcp_tool_permissions: Arc<ReadMcpToolPermissions>,
    /// Replace or clear the project-wide MCP tool policy.
    pub update_project_mcp_tool_permissions: Arc<UpdateProjectMcpToolPermissions>,
    /// Replace or clear one agent MCP tool policy override.
    pub update_agent_mcp_tool_permissions: Arc<UpdateAgentMcpToolPermissions>,
    /// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec
    /// `LaunchAgent`/`ChangeAgentProfile` ; consommé par les commandes de chat (D4)
    /// pour résoudre la session vivante d'un `sessionId` et l'arrêter à la fermeture.
    pub structured_sessions: Arc<StructuredSessions>,
    // --- Agents (L6) ---
    /// Create a project agent from scratch.
    pub create_agent: Arc<CreateAgentFromScratch>,
    /// List a project's agents.
    pub list_agents: Arc<ListAgents>,
    /// Read an agent's Markdown context.
    pub read_agent_context: Arc<ReadAgentContext>,
    /// Overwrite an agent's Markdown context.
    pub update_agent_context: Arc<UpdateAgentContext>,
    /// Delete an agent from the manifest.
    pub delete_agent: Arc<DeleteAgent>,
    /// Launch an agent (spawn PTY, apply injection strategy).
    pub launch_agent: Arc<LaunchAgent>,
    /// Hot-swap an agent's runtime profile, relaunching its live session in place (§15.1).
    pub change_agent_profile: Arc<ChangeAgentProfile>,
    /// Read-only inventory of a project's resumable agent cells, for the reopen
    /// panel (§15.2).
    pub list_resumable_agents: Arc<ListResumableAgents>,
    /// Read-only live/busy state for the project's manifest agents.
    pub get_project_work_state: Arc<GetProjectWorkState>,
    /// App-wide work-in-progress guard used before confirmed application exit.
    pub get_app_exit_work_guard_state: Arc<GetAppExitWorkGuardState>,
    /// Human paginated read of a conversation's full transcript (lot LS6).
    pub read_conversation_page: Arc<ReadConversationPage>,
    /// Best-effort log rotation, triggered off the hot path at thread resume/open (lot LS6).
    pub rotate_conversation_log: Arc<RotateConversationLog>,
    /// Rebinds an already-running agent's live session to a visible cell (Lot D).
    pub attach_live_agent: Arc<AttachLiveAgent>,
    /// Tears down an already-running agent's live session by agent id (Lot D).
    pub stop_live_agent: Arc<StopLiveAgent>,
    /// Best-effort inspection of a conversation (last topic + token indicator)
    /// for the resume popup (T7). Optional/extensible: backed by a `Vec` of
    /// [`domain::ports::SessionInspector`]s; an empty/missing match yields empty
    /// details, never an error.
    pub inspect_conversation: Arc<InspectConversation>,
    /// Project registry — used by agent commands to resolve a `Project` from an id.
    pub project_store: Arc<dyn ProjectStore>,
    /// Read the project permission document.
    pub get_project_permissions: Arc<GetProjectPermissions>,
    /// Update project-level default permissions.
    pub update_project_permissions: Arc<UpdateProjectPermissions>,
    /// Update one agent permission override.
    pub update_agent_permissions: Arc<UpdateAgentPermissions>,
    /// Update one agent effort override.
    pub update_agent_effort: Arc<UpdateAgentEffort>,
    /// Resolve effective permissions for one agent.
    pub resolve_agent_permissions: Arc<ResolveAgentPermissions>,
    /// Read the project system permission document.
    pub get_project_system_permissions: Arc<GetProjectSystemPermissions>,
    /// Update project-level default system permissions.
    pub update_project_system_permissions: Arc<UpdateProjectSystemPermissions>,
    /// Update one agent system permission override.
    pub update_agent_system_permissions: Arc<UpdateAgentSystemPermissions>,
    /// Resolve effective system permissions for one agent.
    pub resolve_agent_system_permissions: Arc<ResolveAgentSystemPermissions>,
    // --- Windows (L10) ---
    /// Detach a tab into a new OS window (persists the workspace topology).
    pub move_tab: Arc<MoveTabToNewWindow>,
    /// Persist open desktop window state on main-window shutdown.
    pub snapshot_open_windows: Arc<SnapshotOpenWindows>,
    /// Load restorable desktop window state on startup.
    pub restore_open_windows: Arc<RestoreOpenWindows>,
    // --- Background tasks (B8) ---
    /// Spawn a command-backed first-class background task.
    pub spawn_background_command: Arc<SpawnBackgroundCommand>,
    /// Cancel a running background task.
    pub cancel_background_task: Arc<CancelBackgroundTask>,
    /// Retry a terminal command task under a fresh task id.
    pub retry_background_task: Arc<RetryBackgroundTask>,
    /// Concrete command runner retained for infrastructure-only live PTY attach.
    pub background_command_runner: Arc<CommandBackgroundRunner>,
    /// Store handle used by `list_background_tasks` to read the task read-model.
    pub background_task_store: Arc<dyn BackgroundTaskStore>,
    // --- Plugins (#43) ---
    /// Review a local plugin package before install.
    pub review_plugin_package: Arc<ReviewPluginPackage>,
    /// Install a plugin archive.
    pub install_plugin_from_archive: Arc<InstallPluginFromArchive>,
    /// Install a plugin directory snapshot.
    pub install_plugin_from_directory: Arc<InstallPluginFromDirectory>,
    /// List installed plugins for admin UI.
    pub list_plugins: Arc<ListPlugins>,
    /// Enable or disable a plugin.
    pub set_plugin_enabled: Arc<SetPluginEnabled>,
    /// Uninstall a plugin.
    pub uninstall_plugin: Arc<UninstallPlugin>,
    /// List runtime contributions for UI bootstrap.
    pub list_plugin_runtime_contributions: Arc<ListPluginRuntimeContributions>,
    /// Reconcile external MCP plugin servers.
    pub reconcile_plugin_mcp_servers: Arc<ReconcilePluginMcpServers>,
    /// Public plugin workspace/file access facade.
    pub plugin_workspace_access: Arc<PluginWorkspaceAccess>,
    /// Public plugin structured config document facade.
    pub plugin_config_documents: Arc<PluginConfigDocuments>,
    /// Public plugin-owned storage facade.
    pub plugin_storage_access: Arc<PluginStorageAccess>,
    /// Public plugin project-structure query use case.
    pub query_project_structure: Arc<QueryProjectStructure>,
    /// Public plugin command/task facade.
    pub plugin_command_tasks: Arc<PluginCommandTasks>,
    /// Public plugin external-toolchain diagnostic facade.
    pub plugin_toolchain_diagnostics: Arc<PluginToolchainDiagnostics>,
    /// Public plugin event subscription facade.
    pub plugin_event_subscriptions: Arc<PluginEventSubscriptions>,
    /// Package store exposed for the Tauri asset protocol adapter.
    pub plugin_package_store: Arc<FsPluginPackageStore>,
    /// Registry store exposed for the Tauri asset protocol adapter.
    pub plugin_registry_store: Arc<dyn PluginRegistryStore>,
    /// Manifest validator exposed for the Tauri asset protocol adapter.
    pub plugin_manifest_validator: Arc<dyn PluginManifestValidator>,
    // --- Templates & sync (L7) ---
    /// Create a template in the global store.
    pub create_template: Arc<CreateTemplate>,
    /// Read a template in the global store.
    pub read_template: Arc<ReadTemplate>,
    /// Update a template's content (bumps version).
    pub update_template: Arc<UpdateTemplate>,
    /// List all templates in the global store.
    pub list_templates: Arc<ListTemplates>,
    /// Delete a template from the global store.
    pub delete_template: Arc<DeleteTemplate>,
    /// Create an agent from a template.
    pub create_agent_from_template: Arc<CreateAgentFromTemplate>,
    /// Detect which synchronized agents are behind their template.
    pub detect_agent_drift: Arc<DetectAgentDrift>,
    /// Apply a template update to a synchronized agent.
    pub sync_agent_with_template: Arc<SyncAgentWithTemplate>,
    // --- Git (L8) ---
    /// Report the working-tree status of a repository.
    pub git_status: Arc<GitStatus>,
    /// Stage a path.
    pub git_stage: Arc<GitStage>,
    /// Unstage a path.
    pub git_unstage: Arc<GitUnstage>,
    /// Create a commit.
    pub git_commit: Arc<GitCommit>,
    /// List branches.
    pub git_branches: Arc<GitBranches>,
    /// Check out a branch.
    pub git_checkout: Arc<GitCheckout>,
    /// Return the recent commit log.
    pub git_log: Arc<GitLog>,
    /// Initialise a repository.
    pub git_init: Arc<GitInit>,
    /// Return the commit graph for all local branches.
    pub git_graph: Arc<GitGraph>,
    // --- Skills (L12) ---
    /// Create a skill in a scope's store.
    pub create_skill: Arc<CreateSkill>,
    /// Update a skill's content.
    pub update_skill: Arc<UpdateSkill>,
    /// List skills in a scope.
    pub list_skills: Arc<ListSkills>,
    /// Delete a skill from its scope's store.
    pub delete_skill: Arc<DeleteSkill>,
    /// Assign a skill to an agent (records a `SkillRef`).
    pub assign_skill: Arc<AssignSkillToAgent>,
    /// Unassign a skill from an agent.
    pub unassign_skill: Arc<UnassignSkillFromAgent>,

    // --- Memory (LOT A — §14.5.1) ---
    /// Create a memory note in the project's store.
    pub create_memory: Arc<CreateMemory>,
    /// Replace an existing memory note.
    pub update_memory: Arc<UpdateMemory>,
    /// List the project's memory notes.
    pub list_memories: Arc<ListMemories>,
    /// Read one memory note by slug.
    pub get_memory: Arc<GetMemory>,
    /// Delete a memory note.
    pub delete_memory: Arc<DeleteMemory>,
    /// Read the structured `MEMORY.md` index.
    pub read_memory_index: Arc<ReadMemoryIndex>,
    /// Resolve a note's outgoing `[[slug]]` links.
    pub resolve_memory_links: Arc<ResolveMemoryLinks>,
    /// Recall the most relevant memory entries for a query within a budget
    /// (LOT B — §14.5.2).
    pub recall_memory: Arc<RecallMemory>,
    // --- Embedder config (LOT C2 — §14.5.3) ---
    /// List the configured embedder profiles (`embedder.json`).
    pub list_embedder_profiles: Arc<ListEmbedderProfiles>,
    /// Save (upsert, validating) an embedder profile.
    pub save_embedder_profile: Arc<SaveEmbedderProfile>,
    /// Delete an embedder profile by id.
    pub delete_embedder_profile: Arc<DeleteEmbedderProfile>,
    /// Describe the embedding engines available to the configuration UI (catalogue
    /// + detected local environment + compiled-in capabilities).
    pub describe_embedder_engines: Arc<DescribeEmbedderEngines>,
    // --- Embedder suggestion (LOT C3 — §14.5.5) ---
    /// Persist the user's response to the embedder suggestion (`later`/`never`).
    pub dismiss_embedder_suggestion: Arc<DismissEmbedderSuggestion>,
    // --- Orchestrator (§14.3) ---
    /// Dispatches validated orchestrator requests to the agent/skill use cases.
    /// Shared by every per-project filesystem watcher.
    pub orchestrator_service: Arc<OrchestratorService>,
    /// Live orchestrator request watchers, keyed by project. One watcher per open
    /// project tails its `.ideai/requests/` tree; dropping the handle stops it.
    /// Guarded by a `Mutex` so the open/close commands can register/unregister
    /// watchers concurrently.
    pub orchestrator_watchers: Mutex<HashMap<ProjectId, OrchestratorWatchHandle>>,
    /// Live IdeA MCP servers, keyed by project — the **twin** of
    /// [`orchestrator_watchers`](Self::orchestrator_watchers). One server per open
    /// project is the MCP entry door onto the *same* [`OrchestratorService`] the
    /// file watcher feeds; both coexist (Décision 4). Started on open/create and
    /// stopped on close, exactly like the watcher, via the same `Mutex`-guarded
    /// per-project registry.
    pub mcp_servers: Mutex<HashMap<ProjectId, McpServerHandle>>,
    /// Service de gestion des **limites de session** des agents (ARCHITECTURE §21) :
    /// détecte → planifie → reprend, et annule. Alimenté par les taps niveau 1
    /// (structuré, `agent_send`) et niveau 2 (PTY, `launch_agent`) ; sa reprise auto est
    /// annulable via la commande `cancel_resume`.
    pub session_limit_service: Arc<SessionLimitService>,
    /// Registre `(project_id, agent_id) → ResumeContext` (LS7) partagé avec [`AppAgentResumer`] :
    /// la commande `launch_agent` y dépose le `Project`/taille du dernier lancement pour
    /// que la reprise auto puisse recomposer un `LaunchAgentInput` complet.
    pub resume_contexts: ResumeContexts,
    /// Observateur de **fin de tour** (backstop no-reply) : lit le transcript on-disk de
    /// l'agent (Claude `turn_duration`) et appelle `InputMediator::turn_ended`. Remplace
    /// le watcher prompt-ready PTY mort. Armé par agent supporté au lancement.
    pub turn_watcher: Arc<dyn domain::ports::TurnWatcher>,
    /// Médiateur d'entrée partagé, capturé pour câbler le callback `turn_ended` du
    /// [`turn_watcher`](Self::turn_watcher) à l'armement.
    pub turn_watch_input: Arc<dyn domain::input::InputMediator>,
    /// Handles des watches de fin-de-tour vivants, par clé runtime. (Re)lancer un agent
    /// dans un projet **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ;
    /// fermer/arrêter l'agent le retire. `Mutex` car launch/stop y accèdent concurremment.
    pub turn_watch_handles:
        Mutex<HashMap<domain::RuntimeAgentKey, Box<dyn domain::ports::TurnWatchHandle>>>,
    /// Port `FileSystem` partagé, conservé pour bâtir la **sonde d'activité** du rendez-vous
    /// `idea_ask_agent` (octets cumulés des transcripts de la cible). Même port que
    /// l'inspecteur et le turn-watcher.
    pub fs_port: Arc<dyn FileSystem>,
    /// Répertoire home (`$HOME`) servant à dériver `<home>/.claude/projects/<encoded-run-dir>`
    /// pour la sonde d'activité du rendez-vous.
    pub home_dir: String,
    /// Late-bound concrete ticket MCP provider supplied by the driving adapter.
    pub ticket_tool_binder: Arc<LateBoundTicketToolProvider>,
    /// Late-bound concrete template MCP provider supplied by the driving adapter.
    pub template_tool_binder: Arc<LateBoundTemplateToolProvider>,
}

/// Backend decision for attaching a UI subscriber to a background task.
pub struct BackgroundTaskAttachPlan {
    /// Attached task id.
    pub task_id: TaskId,
    /// Bytes to repaint immediately.
    pub scrollback: Vec<u8>,
    /// Live PTY handle to subscribe to. `None` means the task is terminal and
    /// the persisted tail is the whole attach payload.
    pub live_handle: Option<PtyHandle>,
}

fn map_background_task_port_error(err: BackgroundTaskPortError) -> AppError {
    match err {
        BackgroundTaskPortError::NotFound => AppError::NotFound("background task".to_owned()),
        BackgroundTaskPortError::AlreadyExists => {
            AppError::Invalid("background task already exists".to_owned())
        }
        BackgroundTaskPortError::Invalid(msg) => AppError::Invalid(msg),
        BackgroundTaskPortError::Runner(msg) => AppError::Process(msg),
        BackgroundTaskPortError::Store(msg) => AppError::Store(msg),
    }
}

fn background_task_tail_bytes(task: &BackgroundTask) -> Vec<u8> {
    match &task.result {
        Some(BackgroundTaskResult::Success {
            stdout_tail,
            stderr_tail,
            ..
        })
        | Some(BackgroundTaskResult::Failure {
            stdout_tail,
            stderr_tail,
            ..
        }) => [stdout_tail.as_deref(), stderr_tail.as_deref()]
            .into_iter()
            .flatten()
            .collect::<Vec<_>>()
            .join("")
            .into_bytes(),
        Some(BackgroundTaskResult::Cancelled { reason, .. })
        | Some(BackgroundTaskResult::Expired { reason, .. }) => reason.clone().into_bytes(),
        None => Vec::new(),
    }
}

impl BackendCore {
    /// Builds an attach plan for a background task output view.
    ///
    /// A running task uses the runner's live PTY handle plus PTY scrollback.
    /// A completed task intentionally returns only the persisted output tail and
    /// no handle, so callers do not subscribe to a dead PTY.
    ///
    /// # Errors
    /// Returns [`AppError::NotFound`] when the task is neither live in the
    /// runner nor terminal in the store, and [`AppError::Process`] /
    /// [`AppError::Store`] for PTY/store failures.
    pub async fn background_task_attach_plan(
        &self,
        task_id: TaskId,
    ) -> Result<BackgroundTaskAttachPlan, AppError> {
        if let Some(handle) = self.background_command_runner.pty_handle_for(task_id) {
            let scrollback = self.pty_port.scrollback(&handle).map_err(AppError::from)?;
            return Ok(BackgroundTaskAttachPlan {
                task_id,
                scrollback,
                live_handle: Some(handle),
            });
        }

        let task = self
            .background_task_store
            .get(task_id)
            .await
            .map_err(map_background_task_port_error)?
            .ok_or_else(|| AppError::NotFound("background task".to_owned()))?;
        if task.is_terminal() {
            return Ok(BackgroundTaskAttachPlan {
                task_id,
                scrollback: background_task_tail_bytes(&task),
                live_handle: None,
            });
        }

        Err(AppError::NotFound("background task live handle".to_owned()))
    }

    /// **Composition root.** Builds all adapters and use cases.
    ///
    /// `app_data_dir` is the machine-local IDE data directory (ARCHITECTURE
    /// §9.2), resolved by the caller and injected here so stores never touch the
    /// driving adapter themselves (Dependency Inversion).
    ///
    /// This is the only function that constructs concrete adapters; every other
    /// layer depends on ports. Adapters added in later lots (PTY, git, remote)
    /// are wired in here.
    #[must_use]
    pub fn build(app_data_dir: PathBuf) -> Self {
        // --- Concrete adapters (driven adapters) ---
        let event_bus = Arc::new(TokioBroadcastEventBus::new());
        let clock = Arc::new(SystemClock::new());
        let ids = Arc::new(UuidGenerator::new());
        let fs = Arc::new(LocalFileSystem::new());
        let store = Arc::new(FsProjectStore::new(
            Arc::clone(&fs) as Arc<dyn FileSystem>,
            app_data_dir.to_string_lossy().into_owned(),
        ));
        let window_state_store = Arc::new(FsWindowStateStore::new(
            Arc::clone(&fs) as Arc<dyn FileSystem>,
            app_data_dir.to_string_lossy().into_owned(),
        ));
        let device_session_store = Arc::new(FsDeviceSessionStore::new(
            Arc::clone(&fs) as Arc<dyn FileSystem>,
            app_data_dir.to_string_lossy().into_owned(),
        ));
        let pair_attempt_limiter = Arc::new(InMemoryPairAttemptLimiter::new(
            Arc::clone(&clock) as Arc<dyn Clock>
        ));

        // Port-typed handles for injection.
        let fs_port = Arc::clone(&fs) as Arc<dyn FileSystem>;
        let store_port = Arc::clone(&store) as Arc<dyn ProjectStore>;
        let window_state_port = Arc::clone(&window_state_store) as Arc<dyn WindowStateStore>;
        let device_session_port = Arc::clone(&device_session_store) as Arc<dyn DeviceSessionStore>;
        let pair_attempt_limiter_port =
            Arc::clone(&pair_attempt_limiter) as Arc<dyn PairAttemptLimiter>;
        let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
        let plugin_packages = Arc::new(FsPluginPackageStore::new(app_data_dir.clone()));
        let plugin_registry = Arc::new(FsPluginRegistryStore::new(app_data_dir.clone()));
        let plugin_storage = Arc::new(FsPluginStorageStore::new(app_data_dir.clone()));
        let plugin_validator =
            Arc::new(JsonPluginManifestValidator::new(env!("CARGO_PKG_VERSION")));
        let plugin_mcp_supervisor = Arc::new(ExternalMcpPluginSupervisor::new());
        let plugin_package_store = Arc::clone(&plugin_packages) as Arc<dyn PluginPackageStore>;
        let plugin_registry_store = Arc::clone(&plugin_registry) as Arc<dyn PluginRegistryStore>;
        let plugin_storage_store = Arc::clone(&plugin_storage) as Arc<dyn PluginStorageStore>;
        let plugin_manifest_validator =
            Arc::clone(&plugin_validator) as Arc<dyn PluginManifestValidator>;
        let plugin_mcp_supervisor_port =
            Arc::clone(&plugin_mcp_supervisor) as Arc<dyn PluginMcpSupervisor>;

        // --- Use cases (ports injected as Arc<dyn Port>) ---
        let health = Arc::new(HealthUseCase::new(
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&ids) as Arc<dyn IdGenerator>,
            Arc::clone(&events_port),
        ));
        let pair_device = Arc::new(PairDevice::new(
            Arc::clone(&device_session_port),
            Arc::clone(&ids) as Arc<dyn IdGenerator>,
            Arc::clone(&clock) as Arc<dyn Clock>,
        ));
        let authenticate_session =
            Arc::new(AuthenticateSession::new(Arc::clone(&device_session_port)));
        let touch_device = Arc::new(TouchDevice::new(
            Arc::clone(&device_session_port),
            Arc::clone(&clock) as Arc<dyn Clock>,
        ));
        let list_devices = Arc::new(ListDevices::new(Arc::clone(&device_session_port)));
        let rename_device = Arc::new(RenameDevice::new(Arc::clone(&device_session_port)));
        let revoke_device = Arc::new(RevokeDevice::new(
            Arc::clone(&device_session_port),
            Arc::clone(&events_port),
        ));
        let revoke_all_devices = Arc::new(RevokeAllDevices::new(
            Arc::clone(&device_session_port),
            Arc::clone(&events_port),
        ));

        let create_project = Arc::new(CreateProject::new(
            Arc::clone(&store_port),
            Arc::clone(&fs_port),
            Arc::clone(&ids) as Arc<dyn IdGenerator>,
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let open_project = Arc::new(OpenProject::new(
            Arc::clone(&store_port),
            Arc::clone(&fs_port),
        ));
        let close_project = Arc::new(CloseProject::new(Arc::clone(&store_port)));
        let close_tab = Arc::new(CloseTab::new(Arc::clone(&store_port)));
        let list_projects = Arc::new(ListProjects::new(Arc::clone(&store_port)));
        let read_project_context = Arc::new(ReadProjectContext::new(Arc::clone(&fs_port)));
        let update_project_context = Arc::new(UpdateProjectContext::new(Arc::clone(&fs_port)));

        // --- PTY adapter + terminal use cases (L3) ---
        let pty = Arc::new(
            PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer()),
        );
        let pty_port = Arc::clone(&pty) as Arc<dyn PtyPort>;
        let terminal_sessions = Arc::new(TerminalSessions::new());

        // --- Sessions structurées (IA / cellules chat, §17) ---
        // Registre jumeau de TerminalSessions + fabrique infra routée par
        // `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et
        // ChangeAgentProfile (shutdown polymorphe au hot-swap).
        let structured_sessions = Arc::new(StructuredSessions::new());
        let openai_tool_invoker = Arc::new(LateBoundOpenAiToolInvoker::new());
        let openai_tool_invoker_port = Arc::clone(&openai_tool_invoker) as Arc<dyn ToolInvoker>;
        let session_factory = Arc::new(
            StructuredSessionFactory::new()
                .with_sandbox_enforcer(infrastructure::default_enforcer())
                .with_tool_invoker(openai_tool_invoker_port),
        ) as Arc<dyn AgentSessionFactory>;

        let open_terminal = Arc::new(OpenTerminal::new(
            Arc::clone(&pty_port),
            Arc::clone(&terminal_sessions),
            Arc::clone(&events_port),
        ));
        let write_terminal = Arc::new(WriteToTerminal::new(
            Arc::clone(&pty_port),
            Arc::clone(&terminal_sessions),
        ));
        let resize_terminal = Arc::new(ResizeTerminal::new(
            Arc::clone(&pty_port),
            Arc::clone(&terminal_sessions),
        ));
        let close_terminal = Arc::new(CloseTerminal::new(
            Arc::clone(&pty_port),
            Arc::clone(&terminal_sessions),
        ));

        // --- Layout use cases (L4 + #4) ---
        let load_layout = Arc::new(LoadLayout::new(
            Arc::clone(&store_port),
            Arc::clone(&fs_port),
        ));
        let mutate_layout = Arc::new(MutateLayout::new(
            Arc::clone(&store_port),
            Arc::clone(&fs_port),
            Arc::clone(&events_port),
        ));
        let list_layouts = Arc::new(ListLayouts::new(
            Arc::clone(&store_port),
            Arc::clone(&fs_port),
        ));
        let create_layout = Arc::new(CreateLayout::new(
            Arc::clone(&store_port),
            Arc::clone(&fs_port),
            Arc::clone(&ids) as Arc<dyn IdGenerator>,
            Arc::clone(&events_port),
            Arc::clone(&plugin_package_store),
            Arc::clone(&plugin_registry_store),
            Arc::clone(&plugin_manifest_validator),
        ));
        let rename_layout = Arc::new(RenameLayout::new(
            Arc::clone(&store_port),
            Arc::clone(&fs_port),
            Arc::clone(&events_port),
        ));
        let delete_layout = Arc::new(DeleteLayout::new(
            Arc::clone(&store_port),
            Arc::clone(&fs_port),
            Arc::clone(&events_port),
        ));
        let set_active_layout = Arc::new(SetActiveLayout::new(
            Arc::clone(&store_port),
            Arc::clone(&fs_port),
            Arc::clone(&events_port),
        ));
        // Close-time snapshot of running agents (T5). Shares the SAME live-session
        // registry as the terminal/agent use cases, so its liveness check reflects
        // the very PTYs the shutdown hook is about to kill — it must run *before*.
        let snapshot_running_agents = Arc::new(SnapshotRunningAgents::new(
            Arc::clone(&store_port),
            Arc::clone(&fs_port),
            Arc::clone(&terminal_sessions) as Arc<dyn LiveAgentRegistry>,
        ));

        // Twin of the snapshot above, but at *open* time: dé-doublonne les
        // `layouts.json` portant plusieurs feuilles sur le même agent (R0c, §3.4
        // « Trou C »). Idempotent : aucun doublon ⇒ aucune écriture.
        let reconcile_layouts = Arc::new(ReconcileLayouts::new(
            Arc::clone(&store_port),
            Arc::clone(&fs_port),
        ));

        // --- Profiles & AI runtime (L5) ---
        // One generic, profile-driven runtime adapter (Open/Closed): it holds the
        // process spawner used for detection. The profile store persists
        // `profiles.json` in the same machine-local app-data dir as the project
        // registry.
        let spawner = Arc::new(LocalProcessSpawner::new());
        let spawner_port = Arc::clone(&spawner) as Arc<dyn ProcessSpawner>;
        let environment_reader = Arc::new(LocalEnvironmentReader::new());
        let environment_reader_port = Arc::clone(&environment_reader) as Arc<dyn EnvironmentReader>;
        let runtime = Arc::new(CliAgentRuntime::new(Arc::clone(&spawner_port)));
        let runtime_port = Arc::clone(&runtime) as Arc<dyn AgentRuntime>;

        let profile_store = Arc::new(FsProfileStore::new(
            Arc::clone(&fs_port),
            app_data_dir.to_string_lossy().into_owned(),
        ));
        let profile_store_port: Arc<dyn ProfileStore> =
            Arc::clone(&profile_store) as Arc<dyn ProfileStore>;

        // Secret store (ticket #92, lot B2/B3): same app-data dir as `profiles.json`,
        // but its own encrypted-at-rest files (`secrets.json` + `secret.key`) so an
        // OpenCode cloud provider's API key never lands in plain JSON.
        let secret_store = Arc::new(FsSecretStore::new(
            Arc::clone(&fs_port),
            app_data_dir.to_string_lossy().into_owned(),
        ));
        let secret_store_port: Arc<dyn SecretStore> =
            Arc::clone(&secret_store) as Arc<dyn SecretStore>;

        let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port)));
        let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port)));
        let save_profile = Arc::new(SaveProfile::new(Arc::clone(&profile_store_port)));
        let save_opencode_provider_profile = Arc::new(SaveOpenCodeProviderProfile::new(
            Arc::clone(&profile_store_port),
            Arc::clone(&secret_store_port),
            Arc::clone(&ids) as Arc<dyn IdGenerator>,
        ));
        let list_opencode_providers = Arc::new(ListOpenCodeProviders::new());
        let cli_version_reader = Arc::new(ProcessCliVersionReader::new(Arc::clone(&spawner_port)));
        let provider_model_catalogue = Arc::new(HttpProviderModelCatalogue::new());
        let compatibility_matrix = Arc::new(EmbeddedCompatibilityMatrix::with_app_data_dir(
            app_data_dir.clone(),
        ));
        let list_claude_models = Arc::new(ListClaudeModels::new(
            Arc::clone(&cli_version_reader) as Arc<dyn domain::ports::CliVersionReader>,
            Arc::clone(&provider_model_catalogue) as Arc<dyn domain::ports::ProviderModelCatalogue>,
            Arc::clone(&compatibility_matrix) as Arc<dyn domain::ports::CompatibilityMatrixSource>,
        ));
        let list_codex_models = Arc::new(ListCodexModels::new(
            cli_version_reader as Arc<dyn domain::ports::CliVersionReader>,
            provider_model_catalogue as Arc<dyn domain::ports::ProviderModelCatalogue>,
            compatibility_matrix as Arc<dyn domain::ports::CompatibilityMatrixSource>,
        ));
        let clone_profile_from_seed = Arc::new(CloneProfileFromSeed::new(
            Arc::clone(&profile_store_port),
            Arc::clone(&ids) as Arc<dyn IdGenerator>,
        ));
        let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new(
            Arc::clone(&profile_store_port),
            Arc::clone(&ids) as Arc<dyn IdGenerator>,
        ));
        let delete_profile = Arc::new(DeleteProfile::new(
            Arc::clone(&profile_store_port),
            Arc::clone(&secret_store_port),
        ));
        let configure_profiles = Arc::new(ConfigureProfiles::new(Arc::clone(&profile_store_port)));
        let reference_profiles = Arc::new(ReferenceProfiles::new());
        let first_run_state = Arc::new(FirstRunState::new(Arc::clone(&profile_store_port)));

        let model_server_registry = Arc::new(FsModelServerRegistry::new(
            Arc::clone(&fs_port),
            app_data_dir.to_string_lossy().into_owned(),
        ));
        let model_artifact_downloader = Arc::new(HfModelArtifactDownloader::new(
            app_data_dir.join("hf-model-artifacts"),
        ));
        let model_server_probe_port = Arc::new(HttpOpenAiCompatibleProbe::default())
            as Arc<dyn domain::ports::ModelServerProbe>;
        let ensure_local_model_server = Arc::new(
            EnsureLocalModelServer::new(
                Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>,
                Arc::clone(&model_server_probe_port),
                Arc::new(LocalManagedProcess::new()) as Arc<dyn domain::ports::ManagedProcess>,
                Arc::new(LlamaCppRuntime::new()) as Arc<dyn domain::ports::ModelServerRuntime>,
                Arc::clone(&fs_port),
                Arc::clone(&events_port),
            )
            .with_model_artifact_downloader(Arc::clone(&model_artifact_downloader)
                as Arc<dyn domain::ports::ModelArtifactDownloader>),
        );
        let model_server_registry_port =
            Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>;
        let model_artifact_downloader_port =
            Arc::clone(&model_artifact_downloader) as Arc<dyn ModelArtifactDownloader>;
        let model_artifact_download_tracker = Arc::clone(&ensure_local_model_server)
            as Arc<dyn application::ModelArtifactDownloadTracker>;
        let list_model_servers = Arc::new(
            ListModelServers::new(Arc::clone(&model_server_registry_port))
                .with_model_artifact_downloader(Arc::clone(&model_artifact_downloader_port))
                .with_download_tracker(Arc::clone(&model_artifact_download_tracker)),
        );
        let save_model_server = Arc::new(SaveModelServer::new(Arc::clone(
            &model_server_registry_port,
        )));
        let delete_model_server = Arc::new(DeleteModelServer::new(
            Arc::clone(&model_server_registry_port),
            Arc::clone(&profile_store_port),
        ));

        // --- Agent context store + use cases (L6) ---
        let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port)));
        let contexts_port = Arc::clone(&contexts) as Arc<dyn AgentContextStore>;

        // --- Public tickets (Issue domain) ---
        // One stateless FS store/allocator serves every project; the project root is
        // passed on each port call and resolves to `<root>/.ideai/tickets/`.
        let issue_store = Arc::new(FsIssueStore::new());
        let issue_store_port = Arc::clone(&issue_store) as Arc<dyn IssueStore>;
        let issue_allocator = Arc::new(FsIssueNumberAllocator::new());
        let issue_allocator_port = Arc::clone(&issue_allocator) as Arc<dyn IssueNumberAllocator>;
        let create_issue = Arc::new(CreateIssue::new(
            Arc::clone(&issue_store_port),
            Arc::clone(&issue_allocator_port),
            Arc::clone(&contexts_port),
            Arc::clone(&ids) as Arc<dyn IdGenerator>,
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let read_issue = Arc::new(ReadIssue::new(Arc::clone(&issue_store_port)));
        let delete_issue = Arc::new(DeleteIssue::new(
            Arc::clone(&issue_store_port),
            Arc::clone(&events_port),
        ));
        let list_issues = Arc::new(ListIssues::new(Arc::clone(&issue_store_port)));
        let update_issue = Arc::new(UpdateIssue::new(
            Arc::clone(&issue_store_port),
            Arc::clone(&contexts_port),
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let bulk_update_issue_status = Arc::new(BulkUpdateIssueStatus::new(
            Arc::clone(&issue_store_port),
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let bulk_update_issue_priority = Arc::new(BulkUpdateIssuePriority::new(
            Arc::clone(&issue_store_port),
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let bulk_delete_issues = Arc::new(BulkDeleteIssues::new(
            Arc::clone(&issue_store_port),
            Arc::clone(&events_port),
        ));
        let read_issue_carnet = Arc::new(ReadIssueCarnet::new(Arc::clone(&issue_store_port)));
        let update_issue_carnet = Arc::new(UpdateIssueCarnet::new(
            Arc::clone(&issue_store_port),
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let add_issue_attachment = Arc::new(AddIssueAttachment::new(
            Arc::clone(&issue_store_port),
            Arc::clone(&ids) as Arc<dyn IdGenerator>,
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let read_issue_attachment =
            Arc::new(ReadIssueAttachment::new(Arc::clone(&issue_store_port)));
        let mark_issue_attachment_summarized = Arc::new(MarkIssueAttachmentSummarized::new(
            Arc::clone(&issue_store_port),
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let link_issues = Arc::new(LinkIssues::new(
            Arc::clone(&issue_store_port),
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let unlink_issues = Arc::new(UnlinkIssues::new(
            Arc::clone(&issue_store_port),
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let assign_issue_agent = Arc::new(AssignIssueAgent::new(
            Arc::clone(&issue_store_port),
            Arc::clone(&contexts_port),
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let sprint_store = Arc::new(FsSprintStore::new());
        let sprint_store_port = Arc::clone(&sprint_store) as Arc<dyn SprintStore>;
        let create_sprint = Arc::new(CreateSprint::new(
            Arc::clone(&sprint_store_port),
            Arc::clone(&ids) as Arc<dyn IdGenerator>,
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let list_sprints = Arc::new(ListSprints::new(
            Arc::clone(&sprint_store_port),
            Arc::clone(&issue_store_port),
        ));
        let rename_sprint = Arc::new(RenameSprint::new(
            Arc::clone(&sprint_store_port),
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let reorder_sprints = Arc::new(ReorderSprints::new(
            Arc::clone(&sprint_store_port),
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let delete_sprint = Arc::new(DeleteSprint::new(
            Arc::clone(&sprint_store_port),
            Arc::clone(&issue_store_port),
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let assign_ticket_to_sprint = Arc::new(AssignTicketToSprint::new(
            Arc::clone(&sprint_store_port),
            Arc::clone(&issue_store_port),
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let unassign_ticket_from_sprint = Arc::new(UnassignTicketFromSprint::new(
            Arc::clone(&issue_store_port),
            Arc::clone(&clock) as Arc<dyn Clock>,
            Arc::clone(&events_port),
        ));
        let ticket_tool_binder = Arc::new(LateBoundTicketToolProvider::new());
        let ticket_tool_provider: Arc<dyn TicketToolProvider> = ticket_tool_binder.clone();
        let template_tool_binder = Arc::new(LateBoundTemplateToolProvider::new());
        let template_tool_provider: Arc<dyn TemplateToolProvider> = template_tool_binder.clone();
        let tool_policy_registry = Arc::new(ToolPolicyRegistry::new());
        let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>;
        let system_permission_store = Arc::new(FsSystemPermissionStore::new(Arc::clone(&fs_port)));
        let system_permission_store_port =
            Arc::clone(&system_permission_store) as Arc<dyn SystemPermissionStore>;
        let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port)));
        let permission_store_port = Arc::clone(&permission_store) as Arc<dyn PermissionStore>;
        let assistant_context_provider = Arc::new(FsAssistantContextStore::new(
            Arc::clone(&fs_port),
            app_data_dir.to_string_lossy().into_owned(),
        )) as Arc<dyn AssistantContextProvider>;
        let assistant_environment = Arc::new(TicketAssistantEnvironmentPreparer::new(
            Arc::clone(&fs_port),
            app_data_dir.to_string_lossy().into_owned(),
            Arc::clone(&runtime_port),
            Arc::new(|project: &Project, requester: &str| {
                Some(McpRuntime {
                    exe: crate::mcp_endpoint::idea_exe_path()?,
                    endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id)
                        .as_cli_arg()
                        .to_owned(),
                    project_id: project.id.as_uuid().simple().to_string(),
                    requester: requester.to_owned(),
                })
            }),
            Arc::clone(&secret_store_port),
        )) as Arc<dyn StructuredSessionEnvironmentPreparer>;
        let open_ticket_assistant = Arc::new(
            OpenTicketAssistant::new(
                Arc::clone(&issue_store_port),
                Arc::clone(&profile_store_port),
                assistant_context_provider,
                assistant_environment,
                Arc::clone(&session_factory),
                Arc::clone(&structured_sessions),
                Arc::clone(&tool_policy_store),
                Arc::clone(&events_port),
            )
            .with_permission_store(Arc::clone(&permission_store_port))
            .with_system_permission_store(Arc::clone(&system_permission_store_port)),
        );
        let close_ticket_assistant = Arc::new(CloseTicketAssistant::new(
            Arc::clone(&structured_sessions),
            tool_policy_store,
            Arc::clone(&events_port),
        ));

        // --- Project permissions (LP1) ---
        let runtime_permission_probe =
            Arc::new(ReadOnlyRuntimePermissionProbe) as Arc<dyn RuntimePermissionProbe>;
        let get_project_system_permissions = Arc::new(GetProjectSystemPermissions::new(
            Arc::clone(&system_permission_store_port),
        ));
        let update_project_system_permissions = Arc::new(UpdateProjectSystemPermissions::new(
            Arc::clone(&system_permission_store_port),
        ));
        let update_agent_system_permissions = Arc::new(UpdateAgentSystemPermissions::new(
            Arc::clone(&system_permission_store_port),
        ));
        let resolve_agent_system_permissions = Arc::new(ResolveAgentSystemPermissions::new(
            Arc::clone(&system_permission_store_port),
            Arc::clone(&runtime_permission_probe),
        ));
        let mcp_tool_permission_store =
            Arc::new(FsMcpToolPermissionStore::new(Arc::clone(&fs_port)));
        let mcp_tool_permission_store_port =
            Arc::clone(&mcp_tool_permission_store) as Arc<dyn McpToolPermissionStore>;
        let mcp_tool_catalogue = McpToolPermissionCatalogue::new(
            infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS
                .iter()
                .map(|tool| (*tool).to_owned())
                .collect(),
            infrastructure::orchestrator::mcp::tools::WRITE_ACTION_TOOLS
                .iter()
                .map(|tool| (*tool).to_owned())
                .collect(),
        )
        .expect("MCP tool catalogue classification is valid");
        let read_mcp_tool_permissions = Arc::new(ReadMcpToolPermissions::new(
            Arc::clone(&mcp_tool_permission_store_port),
            mcp_tool_catalogue.clone(),
        ));
        let update_project_mcp_tool_permissions = Arc::new(UpdateProjectMcpToolPermissions::new(
            Arc::clone(&mcp_tool_permission_store_port),
            mcp_tool_catalogue.clone(),
        ));
        let update_agent_mcp_tool_permissions = Arc::new(UpdateAgentMcpToolPermissions::new(
            Arc::clone(&mcp_tool_permission_store_port),
            mcp_tool_catalogue,
        ));

        // --- Skill store (L12) ---
        // Global skills live in the machine-local app-data dir; project skills are
        // resolved per call from each project's `.ideai/` (so one store serves all
        // open projects). Shared by the skill use cases and the agent launcher
        // (assigned-skill injection into the convention file, §14.2).
        let skill_store = Arc::new(FsSkillStore::new(
            Arc::clone(&fs_port),
            app_data_dir.to_string_lossy().into_owned(),
        ));
        let skill_store_port = Arc::clone(&skill_store) as Arc<dyn SkillStore>;

        // Memory store + naïve recall (LOT A/B — §14.5.1/§14.5.4). Built here so the
        // recall port can be injected into LaunchAgent below (it composes the project
        // memory recall into the convention file at activation); the memory use cases
        // are wired further down from these same instances.
        let memory_store = Arc::new(FsMemoryStore::new(Arc::clone(&fs_port)));
        let memory_store_port = Arc::clone(&memory_store) as Arc<dyn MemoryStore>;
        // Load the configured embedder profile (mono-profile for now: take the last
        // listed, fall back to `none`). A multi-profile selector is a follow-up; the
        // `none` default keeps recall strictly naïve and dependency-free.
        let embedder_store = Arc::new(FsEmbedderProfileStore::new(
            Arc::clone(&fs_port),
            app_data_dir.to_string_lossy().into_owned(),
        ));
        let embedder_store_port = Arc::clone(&embedder_store) as Arc<dyn EmbedderProfileStore>;
        // `build` may run inside an ambient async runtime (desktop setup, or
        // `#[tokio::test]`), so blocking the current thread on a future panics.
        // Drive the one-shot load on a dedicated thread with its own runtime.
        let embedder_profile = std::thread::scope(|s| {
            s.spawn(|| {
                tokio::runtime::Builder::new_current_thread()
                    .enable_all()
                    .build()
                    .ok()
                    .and_then(|rt| rt.block_on(embedder_store.list()).ok())
                    .and_then(|mut v| v.pop())
            })
            .join()
            .ok()
            .flatten()
        })
        .unwrap_or_else(EmbedderProfile::none);
        let onnx_cache_dir = app_data_dir.join(ONNX_CACHE_SUBDIR);
        let memory_recall_port = build_memory_recall(
            Arc::clone(&fs_port),
            Arc::clone(&memory_store_port),
            &embedder_profile,
            &onnx_cache_dir,
        );

        // --- Embedder configuration use cases (LOT C2 — §14.5.3) ---
        // CRUD over `embedder.json` (port-typed store, built above) + a read-only
        // description of the available engines. The environment probe shares the SAME
        // `onnx_cache_dir` the recall uses, so "is this model cached?" stays coherent.
        // The static ONNX catalogue and the compiled-capability flags are owned by
        // infrastructure and injected here (the application stays infra-free, DIP).
        let env_inspector = Arc::new(EmbedderEnvProbe::new(
            onnx_cache_dir.clone(),
            DEFAULT_OLLAMA_BASE_URL,
        )) as Arc<dyn EmbedderEnvInspector>;
        // Cloned for the suggestion check below (the original is moved into
        // `describe_embedder_engines`). Both share the same probe behaviour.
        let env_inspector_for_suggestion = Arc::clone(&env_inspector);
        let recommended_onnx: Vec<OnnxModelView> = RECOMMENDED_ONNX_MODELS
            .iter()
            .map(|m| OnnxModelView {
                id: m.id.to_owned(),
                display_name: m.display_name.to_owned(),
                dimension: m.dimension,
                approx_size_mb: m.approx_size_mb,
                recommended: m.recommended,
            })
            .collect();
        let list_embedder_profiles =
            Arc::new(ListEmbedderProfiles::new(Arc::clone(&embedder_store_port)));
        let save_embedder_profile =
            Arc::new(SaveEmbedderProfile::new(Arc::clone(&embedder_store_port)));
        let delete_embedder_profile =
            Arc::new(DeleteEmbedderProfile::new(Arc::clone(&embedder_store_port)));
        let describe_embedder_engines = Arc::new(DescribeEmbedderEngines::new(
            env_inspector,
            recommended_onnx,
            VECTOR_HTTP_ENABLED,
            VECTOR_ONNX_ENABLED,
        ));

        // --- Embedder suggestion (LOT C3 — §14.5.5) ---
        // Per-project dismissal state (`.ideai/memory/.embedder-prompt.json`) +
        // the in-memory "already suggested this session" guard, shared with the
        // launcher's best-effort check. The check publishes EmbedderSuggested at
        // most once per session per project, only while strategy is `none` and the
        // memory has outgrown the recall budget.
        let prompt_store = Arc::new(FsEmbedderPromptStore::new(Arc::clone(&fs_port)));
        let prompt_store_port = Arc::clone(&prompt_store) as Arc<dyn EmbedderPromptStore>;
        let suggested_this_session: SuggestedThisSession = SuggestedThisSession::default();
        let check_embedder_suggestion = Arc::new(CheckEmbedderSuggestion::new(
            Arc::clone(&embedder_store_port),
            Arc::clone(&memory_store_port),
            Arc::clone(&prompt_store_port),
            env_inspector_for_suggestion,
            Arc::clone(&events_port),
            Arc::clone(&suggested_this_session),
            AGENT_MEMORY_RECALL_BUDGET,
            VECTOR_HTTP_ENABLED,
            VECTOR_ONNX_ENABLED,
        ));
        let dismiss_embedder_suggestion = Arc::new(DismissEmbedderSuggestion::new(Arc::clone(
            &prompt_store_port,
        )));

        let agent_capabilities =
            Arc::new(ResolveAgentCapabilities::new(Arc::clone(&skill_store_port)));

        let create_agent = Arc::new(CreateAgentFromScratch::new(
            Arc::clone(&contexts_port),
            Arc::clone(&ids) as Arc<dyn IdGenerator>,
            Arc::clone(&events_port),
        ));
        let list_agents = Arc::new(ListAgents::with_capabilities(
            Arc::clone(&contexts_port),
            Arc::clone(&agent_capabilities),
        ));
        let read_agent_context = Arc::new(ReadAgentContext::new(Arc::clone(&contexts_port)));
        let update_agent_context = Arc::new(UpdateAgentContext::new(Arc::clone(&contexts_port)));
        let delete_agent = Arc::new(DeleteAgent::new(
            Arc::clone(&contexts_port),
            Arc::clone(&events_port),
        ));
        // LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal
        // use cases — indispensable for transport bridges to work correctly.
        //
        // The human-facing launcher intentionally stays PTY-only: when the user opens an
        // agent cell, they keep the native Claude/Codex CLI and its commands. Inter-agent
        // delegation gets its own launcher below, wired to structured/headless sessions.

        // --- Permission projectors (lot LP3-5) ---
        // UN seul registre, source unique de vérité, injecté à l'identique dans
        // `LaunchAgent` (projection au lancement) ET `ChangeAgentProfile` (nettoyage des
        // fichiers orphelins au swap). Les deux projecteurs concrets vivent dans
        // l'infrastructure, keyés par leur `ProjectorKey` via `with(...)`.
        let permission_projectors = Arc::new(
            PermissionProjectorRegistry::new()
                .with(Arc::new(ClaudePermissionProjector) as Arc<dyn domain::PermissionProjector>)
                .with(Arc::new(CodexPermissionProjector) as Arc<dyn domain::PermissionProjector>),
        );

        let launch_agent = Arc::new(
            LaunchAgent::new(
                Arc::clone(&contexts_port),
                Arc::clone(&profile_store_port),
                Arc::clone(&runtime_port),
                Arc::clone(&fs_port),
                Arc::clone(&pty_port),
                Arc::clone(&skill_store_port),
                Arc::clone(&terminal_sessions),
                Arc::clone(&events_port),
                Arc::clone(&ids) as Arc<dyn IdGenerator>,
                Arc::clone(&memory_recall_port),
                Some(Arc::clone(&check_embedder_suggestion)),
            )
            .with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback)
            .with_permission_store(Arc::clone(&permission_store_port))
            .with_system_permission_store(Arc::clone(&system_permission_store_port))
            // Reprise conversationnelle (lot P7) : à chaque (re)lancement, si la cellule
            // porte une conversation et qu'un handoff existe (`<root>/.ideai/conversations/`),
            // son résumé est réinjecté dans le convention file. Best-effort, additif :
            // un handoff absent/illisible ⇒ lancement normal sans section.
            .with_handoff_provider(
                Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>
            )
            // Resumable moteur par provider (lot P8b) : après un lancement structuré
            // exposant un id de session moteur, rangé sous la clé de paire dans
            // `<root>/.ideai/conversations/providers.json`. Best-effort, additif : une
            // écriture en échec / pas d'id moteur ⇒ lancement normal, aucune écriture.
            .with_provider_session_provider(Arc::new(AppProviderSessionProvider)
                as Arc<dyn application::ProviderSessionProvider>)
            // Projection des permissions au (re)lancement (lot LP3-5) : avec le
            // permission store câblé ci-dessus, `resolve` a une source ⇒ le projecteur
            // du profil matérialise la config de permission de la CLI dans le run dir.
            .with_permission_projectors(Arc::clone(&permission_projectors))
            // Aperçu live-state des autres agents (lot LS4) : section `# État du projet`
            // injectée au lancement. Best-effort strict : provider absent / erreur /
            // parse ⇒ section omise, jamais d'échec de lancement.
            .with_live_state_lean(Arc::new(AppLiveStateLeanProvider {
                clock: Arc::clone(&clock) as Arc<dyn Clock>,
            }) as Arc<dyn LiveStateLeanProvider>)
            .with_local_model_server(Arc::clone(&ensure_local_model_server))
            .with_secret_store(Arc::clone(&secret_store_port)),
        );

        // Inter-agent launcher: same context, memory, permissions and live-state
        // injection as the human launcher, but with the structured factory wired. It is
        // used only by OrchestratorService::ask_agent when a delegated target must be
        // started headlessly; UI launches still go through `launch_agent` above.
        let orchestrator_launch_agent = Arc::new(
            LaunchAgent::new(
                Arc::clone(&contexts_port),
                Arc::clone(&profile_store_port),
                Arc::clone(&runtime_port),
                Arc::clone(&fs_port),
                Arc::clone(&pty_port),
                Arc::clone(&skill_store_port),
                Arc::clone(&terminal_sessions),
                Arc::clone(&events_port),
                Arc::clone(&ids) as Arc<dyn IdGenerator>,
                Arc::clone(&memory_recall_port),
                Some(Arc::clone(&check_embedder_suggestion)),
            )
            .with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
            .with_permission_store(Arc::clone(&permission_store_port))
            .with_system_permission_store(Arc::clone(&system_permission_store_port))
            .with_handoff_provider(
                Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>
            )
            .with_provider_session_provider(Arc::new(AppProviderSessionProvider)
                as Arc<dyn application::ProviderSessionProvider>)
            .with_permission_projectors(Arc::clone(&permission_projectors))
            .with_live_state_lean(Arc::new(AppLiveStateLeanProvider {
                clock: Arc::clone(&clock) as Arc<dyn Clock>,
            }) as Arc<dyn LiveStateLeanProvider>)
            .with_local_model_server(Arc::clone(&ensure_local_model_server))
            .with_secret_store(Arc::clone(&secret_store_port))
            .with_structured(
                Arc::clone(&session_factory),
                Arc::clone(&structured_sessions),
            ),
        );

        // Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/
        // profile/project/fs stores, the live-session registry and PTY port, and
        // *composes* the launcher above for the in-place relaunch (no duplication).
        // Voit aussi le registre structuré pour un « kill » polymorphe (§17.4).
        let change_agent_profile = Arc::new(
            ChangeAgentProfile::new(
                Arc::clone(&contexts_port),
                Arc::clone(&profile_store_port),
                Arc::clone(&store_port),
                Arc::clone(&fs_port),
                Arc::clone(&terminal_sessions),
                Arc::clone(&pty_port),
                Arc::clone(&launch_agent),
                Arc::clone(&events_port),
            )
            .with_structured(Arc::clone(&structured_sessions))
            // Même registre que `LaunchAgent` (lot LP3-5) : au swap cross-profile, on
            // nettoie les fichiers `Replace` orphelins de l'ancien profil avant relance.
            .with_permission_projectors(Arc::clone(&permission_projectors)),
        );

        // Read-only inventory of resumable agent cells (§15.2). Reuses the shared
        // project/fs/context/profile stores already injected above — no new port.
        let list_resumable_agents = Arc::new(ListResumableAgents::new(
            Arc::clone(&store_port),
            Arc::clone(&fs_port),
            Arc::clone(&contexts_port),
            Arc::clone(&profile_store_port),
        ));

        // --- Conversation inspection (T7) ---
        // Best-effort, optional, extensible: a `Vec` of SessionInspectors routed
        // by profile. Adding an inspectable CLI = pushing one more adapter here.
        // The Claude inspector reads `<home>/.claude/projects/<cwd>/<id>.jsonl`;
        // `$HOME` is resolved from the environment (empty string if unset — the
        // inspector then simply finds nothing and yields empty details).
        let home_dir = std::env::var("HOME")
            .or_else(|_| std::env::var("USERPROFILE"))
            .unwrap_or_default();
        let inspectors: Vec<Arc<dyn domain::ports::SessionInspector>> = vec![Arc::new(
            ClaudeTranscriptInspector::new(Arc::clone(&fs_port), home_dir.clone()),
        )];
        // Backstop no-reply : observateur de fin de tour transcript (Claude `turn_duration`)
        // — lit le même `<home>/.claude/projects/<encoded-run-dir>/` que l'inspecteur, via
        // le même `FileSystem`. Armé par agent supporté au lancement (cf. `arm_turn_watch`).
        let turn_watcher: Arc<dyn domain::ports::TurnWatcher> =
            Arc::new(infrastructure::ClaudeTranscriptTurnWatcher::new(
                Arc::clone(&fs_port),
                home_dir.clone(),
            ));
        let inspect_conversation = Arc::new(InspectConversation::new(
            Arc::clone(&contexts_port),
            Arc::clone(&profile_store_port),
            inspectors,
        ));

        let project_store = Arc::clone(&store_port);
        let get_project_permissions = Arc::new(GetProjectPermissions::new(Arc::clone(
            &permission_store_port,
        )));
        let update_project_permissions = Arc::new(UpdateProjectPermissions::new(Arc::clone(
            &permission_store_port,
        )));
        let update_agent_permissions = Arc::new(UpdateAgentPermissions::new(Arc::clone(
            &permission_store_port,
        )));
        let update_agent_effort = Arc::new(UpdateAgentEffort::new(Arc::clone(&contexts_port)));
        let resolve_agent_permissions = Arc::new(ResolveAgentPermissions::new(Arc::clone(
            &permission_store_port,
        )));

        // --- Template store + use cases (L7) ---
        let template_store = Arc::new(FsTemplateStore::new(
            Arc::clone(&fs_port),
            app_data_dir.to_string_lossy().into_owned(),
        ));
        let template_store_port = Arc::clone(&template_store) as Arc<dyn TemplateStore>;

        let create_template = Arc::new(CreateTemplate::new(
            Arc::clone(&template_store_port),
            Arc::clone(&ids) as Arc<dyn IdGenerator>,
        ));
        let read_template = Arc::new(ReadTemplate::new(Arc::clone(&template_store_port)));
        let update_template = Arc::new(UpdateTemplate::new(
            Arc::clone(&template_store_port),
            Arc::clone(&events_port),
        ));
        let list_templates = Arc::new(ListTemplates::new(Arc::clone(&template_store_port)));
        let delete_template = Arc::new(DeleteTemplate::new(Arc::clone(&template_store_port)));
        let create_agent_from_template = Arc::new(CreateAgentFromTemplate::new(
            Arc::clone(&template_store_port),
            Arc::clone(&contexts_port),
            Arc::clone(&ids) as Arc<dyn IdGenerator>,
            Arc::clone(&events_port),
        ));
        let detect_agent_drift = Arc::new(DetectAgentDrift::new(
            Arc::clone(&template_store_port),
            Arc::clone(&contexts_port),
            Arc::clone(&events_port),
        ));
        let sync_agent_with_template = Arc::new(SyncAgentWithTemplate::new(
            Arc::clone(&template_store_port),
            Arc::clone(&contexts_port),
            Arc::clone(&events_port),
        ));

        // --- Git adapter + use cases (L8) ---
        let git = Arc::new(Git2Repository::new());
        let git_port = Arc::clone(&git) as Arc<dyn GitPort>;

        let git_status = Arc::new(GitStatus::new(Arc::clone(&git_port)));
        let git_stage = Arc::new(GitStage::new(Arc::clone(&git_port)));
        let git_unstage = Arc::new(GitUnstage::new(Arc::clone(&git_port)));
        let git_commit = Arc::new(GitCommit::new(
            Arc::clone(&git_port),
            Arc::clone(&events_port),
        ));
        let git_branches = Arc::new(GitBranches::new(Arc::clone(&git_port)));
        let git_checkout = Arc::new(GitCheckout::new(
            Arc::clone(&git_port),
            Arc::clone(&events_port),
        ));
        let git_log = Arc::new(GitLog::new(Arc::clone(&git_port)));
        let git_init = Arc::new(GitInit::new(
            Arc::clone(&git_port),
            Arc::clone(&events_port),
        ));
        let git_graph = Arc::new(GitGraph::new(Arc::clone(&git_port)));

        // --- Skill use cases (L12) ---
        // Reuse the skill store (built above for the launcher) and the shared
        // agent context store for the agent↔skill assignment.
        let create_skill = Arc::new(CreateSkill::new(
            Arc::clone(&skill_store_port),
            Arc::clone(&ids) as Arc<dyn IdGenerator>,
        ));
        let update_skill = Arc::new(UpdateSkill::new(Arc::clone(&skill_store_port)));
        let list_skills = Arc::new(ListSkills::new(Arc::clone(&skill_store_port)));
        let delete_skill = Arc::new(DeleteSkill::new(Arc::clone(&skill_store_port)));
        // Lecture d'un skill par nom pour l'outil MCP `idea_skill_read` : compose
        // le SkillStore et le manifeste agent afin d'autoriser uniquement les
        // skills assignés au requester courant.
        let read_skill = Arc::new(ReadSkill::new(
            Arc::clone(&contexts_port),
            Arc::clone(&skill_store_port),
        ));
        let assign_skill = Arc::new(AssignSkillToAgent::new(
            Arc::clone(&contexts_port),
            Arc::clone(&events_port),
        ));
        let unassign_skill = Arc::new(UnassignSkillFromAgent::new(
            Arc::clone(&contexts_port),
            Arc::clone(&events_port),
        ));

        // --- Memory use cases (LOT A — §14.5.1) ---
        // `memory_store` / `memory_store_port` / `memory_recall_port` are built
        // earlier (above LaunchAgent, which needs the recall port). A single
        // FsMemoryStore takes the project root per call (like the skill store), so
        // one instance serves every open project. The mutating use cases share the
        // event bus to announce Memory{Saved,Deleted}.
        let create_memory = Arc::new(CreateMemory::new(
            Arc::clone(&memory_store_port),
            Arc::clone(&events_port),
        ));
        let update_memory = Arc::new(UpdateMemory::new(
            Arc::clone(&memory_store_port),
            Arc::clone(&events_port),
        ));
        let list_memories = Arc::new(ListMemories::new(Arc::clone(&memory_store_port)));
        let get_memory = Arc::new(GetMemory::new(Arc::clone(&memory_store_port)));
        let delete_memory = Arc::new(DeleteMemory::new(
            Arc::clone(&memory_store_port),
            Arc::clone(&events_port),
        ));
        let read_memory_index = Arc::new(ReadMemoryIndex::new(Arc::clone(&memory_store_port)));
        let resolve_memory_links =
            Arc::new(ResolveMemoryLinks::new(Arc::clone(&memory_store_port)));
        // Naïve recall (LOT B): the same instance injected into LaunchAgent above —
        // composes the store, returns index entries in order truncated to the token
        // budget. The default, dependency-free MemoryRecall; substitutable by a
        // VectorMemoryRecall (LOT C).
        let recall_memory = Arc::new(RecallMemory::new(Arc::clone(&memory_recall_port)));

        // --- Orchestrator service (§14.3) ---
        // Dispatches file-based orchestrator requests through the *same* use cases
        // the UI drives (IdeA stays the single source of truth for the agent/skill
        // lifecycle). The per-project watcher that feeds it is started lazily when
        // a project is opened (see `ensure_orchestrator_watch`).
        // File inter-agents (Option 1 « Terminal + MCP », B-3) : un ticket par tâche
        // déléguée, résolu par `idea_reply`. Une instance par AppState ⇒ partagée par
        // tous les projets (clé interne = AgentId, jamais de collision cross-projet).
        // Médiateur d'entrée (cadrage C3) : enveloppe l'`InMemoryMailbox` (moteur de
        // corrélation par ticket) + porte la **livraison** du tour dans le PTY de la
        // cible (écriture sérialisée, une seule voie d'entrée). Le mailbox concret est
        // partagé pour `resolve`/`resolve_ticket`/`cancel_head` côté orchestrateur.
        let inmemory_mailbox = Arc::new(InMemoryMailbox::new());
        let mailbox = Arc::clone(&inmemory_mailbox) as Arc<dyn domain::mailbox::AgentMailbox>;
        // Same concrete mailbox, second (read-only) port view for the work-state
        // read model: lists pending tickets without touching the mutating surface.
        let queue_snapshot =
            Arc::clone(&inmemory_mailbox) as Arc<dyn domain::mailbox::AgentQueueSnapshot>;
        let mediated_inbox = Arc::new(
            MediatedInbox::with_pty(
                Arc::clone(&inmemory_mailbox),
                Arc::new(SystemMillisClock),
                Arc::clone(&pty_port),
            )
            // Émet `AgentBusyChanged` à la source (Busy à l'enqueue qui démarre un
            // tour, Idle au mark_idle) ⇒ relayé au front en live event (cadrage C4).
            .with_events(Arc::clone(&events_port)),
        );
        // Détection de stagnation (lot 2, readiness/heartbeat) : une tâche périodique
        // détenue au composition root appelle `sweep_stalled` (logique de décision pure,
        // `now` fourni par l'horloge injectée). Bascule `Alive→Stalled` les agents `Busy`
        // sans battement depuis `stall_after_ms` (profil) et émet `AgentLivenessChanged`
        // une fois par transition. Tick d'1 s : largement assez fin pour des seuils en
        // dizaines de secondes, négligeable en charge. Détaché ⇒ vit autant que l'app.
        {
            let sweeper = Arc::clone(&mediated_inbox);
            // `build` may run in a desktop setup hook (main thread, no ambient Tokio
            // runtime) — `tokio::spawn` would panic with "there is no reactor running".
            // Use the core detached spawner instead of assuming an ambient runtime.
            spawn_detached(async move {
                let mut tick = tokio::time::interval(std::time::Duration::from_secs(1));
                loop {
                    tick.tick().await;
                    sweeper.sweep_stalled();
                }
            });
        }
        let input_mediator = Arc::clone(&mediated_inbox) as Arc<dyn domain::input::InputMediator>;
        let background_tasks = Arc::new(AppBackgroundTaskStore::new(Arc::clone(&store_port)));
        let background_tasks_port = Arc::clone(&background_tasks) as Arc<dyn BackgroundTaskStore>;
        let (background_ready_tx, mut background_ready_rx) =
            tokio::sync::mpsc::unbounded_channel::<BackgroundTaskReadyToDeliver>();

        // --- B8 : runner de commandes + fermeture de la boucle du sink ---
        // Le runner concret exécute les tâches command-backed sur le `PtyPort` composé
        // (local ici ; SSH/WSL via `RemoteHost` quand ils atterriront — Liskov). Il
        // émet UNE complétion par tâche ; le sink (single-writer) persiste l'état
        // terminal AVANT de signaler la livraison (persist-avant-signal), puis le pont
        // ready→inbox ci-dessous réveille l'agent propriétaire.
        let background_runner = Arc::new(CommandBackgroundRunner::new(
            Arc::clone(&pty_port),
            Arc::clone(&clock) as Arc<dyn Clock>,
        ));
        let background_runner_port =
            Arc::clone(&background_runner) as Arc<dyn BackgroundTaskRunner>;
        {
            let sink = Arc::new(BackgroundCompletionSink::new(
                Arc::clone(&background_tasks_port),
                background_ready_tx.clone(),
            ));
            let runner = Arc::clone(&background_runner_port);
            // `start_from_runner` exige un runtime Tokio ambiant (`Handle::current`) :
            // on l'appelle donc DANS la tâche async. Le `JoinHandle` du drain est gardé
            // vivant en l'attendant (il ne se termine qu'à la fermeture du flux de
            // complétions), ce qui maintient sink + runner en vie pour la session.
            spawn_detached(async move {
                let drain = sink.start_from_runner(runner);
                let _ = drain.await;
            });
        }
        let spawn_background_command = Arc::new(
            SpawnBackgroundCommand::new(
                Arc::clone(&background_tasks_port),
                Arc::clone(&background_runner_port),
                Arc::clone(&clock) as Arc<dyn Clock>,
                Arc::clone(&ids) as Arc<dyn IdGenerator>,
            )
            .with_events(Arc::clone(&events_port)),
        );
        let cancel_background_task = Arc::new(CancelBackgroundTask::new(
            Arc::clone(&background_tasks_port),
            Arc::clone(&background_runner_port),
        ));
        let retry_background_task = Arc::new(RetryBackgroundTask::new(
            Arc::clone(&background_tasks_port),
            Arc::clone(&background_runner) as Arc<dyn BackgroundCommandArchive>,
            Arc::clone(&spawn_background_command),
        ));
        let review_plugin_package = Arc::new(ReviewPluginPackage::new(
            Arc::clone(&plugin_package_store),
            Arc::clone(&plugin_manifest_validator),
        ));
        let list_plugins = Arc::new(ListPlugins::new(
            Arc::clone(&plugin_package_store),
            Arc::clone(&plugin_registry_store),
            Arc::clone(&plugin_manifest_validator),
        ));
        let install_plugin_from_archive = Arc::new(InstallPluginFromArchive::new(
            Arc::clone(&plugin_package_store),
            Arc::clone(&plugin_registry_store),
            Arc::clone(&plugin_manifest_validator),
            Arc::clone(&events_port),
            Arc::clone(&plugin_mcp_supervisor_port),
        ));
        let install_plugin_from_directory = Arc::new(InstallPluginFromDirectory::new(
            Arc::clone(&plugin_package_store),
            Arc::clone(&plugin_registry_store),
            Arc::clone(&plugin_manifest_validator),
            Arc::clone(&events_port),
            Arc::clone(&plugin_mcp_supervisor_port),
        ));
        let set_plugin_enabled = Arc::new(SetPluginEnabled::new(
            Arc::clone(&plugin_package_store),
            Arc::clone(&plugin_registry_store),
            Arc::clone(&plugin_manifest_validator),
            Arc::clone(&events_port),
            Arc::clone(&plugin_mcp_supervisor_port),
        ));
        let uninstall_plugin = Arc::new(UninstallPlugin::new(
            Arc::clone(&plugin_package_store),
            Arc::clone(&plugin_storage_store),
            Arc::clone(&plugin_registry_store),
            Arc::clone(&events_port),
            Arc::clone(&plugin_mcp_supervisor_port),
        ));
        let list_plugin_runtime_contributions = Arc::new(ListPluginRuntimeContributions::new(
            Arc::clone(&plugin_package_store),
            Arc::clone(&plugin_registry_store),
            Arc::clone(&plugin_manifest_validator),
        ));
        let reconcile_plugin_mcp_servers = Arc::new(ReconcilePluginMcpServers::new(
            Arc::clone(&plugin_package_store),
            Arc::clone(&plugin_registry_store),
            Arc::clone(&plugin_manifest_validator),
            Arc::clone(&plugin_mcp_supervisor_port),
        ));
        let plugin_workspace_access = Arc::new(
            PluginWorkspaceAccess::new(Arc::clone(&store_port), Arc::clone(&fs_port))
                .with_events(Arc::clone(&events_port)),
        );
        let plugin_config_documents = Arc::new(
            PluginConfigDocuments::new(Arc::clone(&store_port), Arc::clone(&fs_port))
                .with_events(Arc::clone(&events_port)),
        );
        let plugin_storage_access = Arc::new(PluginStorageAccess::new(
            Arc::clone(&plugin_storage_store),
            Arc::clone(&plugin_registry_store),
        ));
        let query_project_structure = Arc::new(QueryProjectStructure::new(
            Arc::clone(&store_port),
            Arc::clone(&fs_port),
        ));
        let plugin_command_tasks = Arc::new(PluginCommandTasks::new(
            Arc::clone(&store_port),
            Arc::clone(&background_tasks_port),
            Arc::clone(&spawn_background_command),
        ));
        let plugin_toolchain_diagnostics = Arc::new(PluginToolchainDiagnostics::new(
            Arc::clone(&store_port),
            Arc::clone(&fs_port),
            Arc::clone(&spawner_port),
            Arc::clone(&environment_reader_port),
        ));
        let plugin_event_subscriptions = Arc::new(PluginEventSubscriptions::new(
            Arc::clone(&store_port),
            Arc::clone(&ids) as Arc<dyn IdGenerator>,
            Arc::clone(&clock) as Arc<dyn Clock>,
        ));
        {
            let mut rx = event_bus.raw_receiver();
            let subscriptions = Arc::clone(&plugin_event_subscriptions);
            spawn_detached(async move {
                loop {
                    match rx.recv().await {
                        Ok(event) => subscriptions.record_domain_event(&event),
                        Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
                        Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
                    }
                }
            });
        }
        let background_wake = Arc::new(AgentWakeService::new(
            Arc::clone(&mediated_inbox) as Arc<dyn AgentInbox>,
            Arc::clone(&input_mediator),
            Arc::clone(&mailbox),
            Arc::clone(&background_tasks_port),
            Arc::new(AppWakeSessionProvider {
                launch_agent: Arc::clone(&orchestrator_launch_agent),
                structured_sessions: Arc::clone(&structured_sessions),
            }),
            Some(Arc::clone(&events_port)),
        )) as Arc<dyn AgentWakePort>;
        {
            let inbox = Arc::clone(&mediated_inbox);
            let wake = Arc::clone(&background_wake);
            let projects = Arc::clone(&store_port);
            let clock_for_items = Arc::clone(&clock) as Arc<dyn Clock>;
            let retry_ready = background_ready_tx.clone();
            spawn_detached(async move {
                while let Some(ready) = background_ready_rx.recv().await {
                    let item = InboxItem {
                        id: TicketId::new_random(),
                        agent_id: ready.owner_agent_id,
                        source: InboxSource::BackgroundTask {
                            task_id: ready.task_id,
                        },
                        kind: InboxItemKind::BackgroundCompletion,
                        body: format!("Background task {} completed.", ready.task_id),
                        created_at_ms: clock_for_items.now_millis().max(0) as u64,
                        correlation_id: Some(ready.task_id.to_string()),
                    };
                    match inbox.enqueue_message(
                        domain::RuntimeAgentKey::new(ready.project_id, ready.owner_agent_id),
                        item,
                    ) {
                        Ok(receipt) if receipt.status == InboxReceiptStatus::Deferred => {
                            application::diag!(
                                "[background-task] completion deferred: task={} owner={} \
                                 queue_depth={}",
                                ready.task_id,
                                ready.owner_agent_id,
                                receipt.depth
                            );
                            schedule_background_ready_retry(retry_ready.clone(), ready);
                            continue;
                        }
                        Ok(_) => {}
                        Err(InboxError::InboxFull { .. }) => {
                            application::diag!(
                                "[background-task] completion inbox full but durable: task={} \
                                 owner={}",
                                ready.task_id,
                                ready.owner_agent_id
                            );
                            schedule_background_ready_retry(retry_ready.clone(), ready);
                            continue;
                        }
                        Err(err) => {
                            application::diag!(
                                "[background-task] completion inbox enqueue failed: task={} \
                                 owner={} err={err}",
                                ready.task_id,
                                ready.owner_agent_id
                            );
                            continue;
                        }
                    }

                    let project = match projects.load_project(ready.project_id).await {
                        Ok(project) => project,
                        Err(err) => {
                            application::diag!(
                                "[background-task] completion wake skipped: project={} task={} \
                                 owner={} err={err}",
                                ready.project_id,
                                ready.task_id,
                                ready.owner_agent_id
                            );
                            continue;
                        }
                    };
                    if let Err(err) = wake
                        .wake_agent(
                            &project,
                            ready.owner_agent_id,
                            WakeReason::BackgroundCompletion {
                                task_id: ready.task_id,
                            },
                        )
                        .await
                    {
                        if matches!(err, WakeError::AgentBusy { .. }) {
                            application::diag!(
                                "[background-task] completion wake postponed: task={} owner={}",
                                ready.task_id,
                                ready.owner_agent_id
                            );
                            schedule_background_wake_retry(
                                Arc::clone(&wake),
                                project,
                                ready.owner_agent_id,
                                ready.task_id,
                            );
                            continue;
                        }
                        application::diag!(
                            "[background-task] completion wake failed: task={} owner={} err={err}",
                            ready.task_id,
                            ready.owner_agent_id
                        );
                    }
                }
            });
        }
        let live_sessions = Arc::new(LiveSessions::new(
            Arc::clone(&terminal_sessions),
            Arc::clone(&structured_sessions),
        ));
        let delete_model_artifact = Arc::new(DeleteModelArtifact::new(
            Arc::clone(&model_server_registry_port),
            Arc::clone(&model_server_probe_port),
            Arc::clone(&model_artifact_downloader_port),
            Arc::clone(&model_artifact_download_tracker),
            Arc::clone(&profile_store_port),
            Arc::clone(&store_port),
            Arc::clone(&contexts_port),
            Arc::clone(&live_sessions) as Arc<dyn LiveAgentRegistry>,
        ));
        // Réconciliation du live-state au reboot : repasse en `idle` les lignes
        // fantômes (working/waiting/blocked) dont la session n'est plus vivante,
        // selon le MÊME registre de liveness que `GetProjectWorkState`. Provider
        // par root (le store fixe sa racine à la construction). Câblé dans
        // `open_project`, best-effort (cf. `reconcile_layouts`).
        let reconcile_live_state = Arc::new(AppReconcileLiveState {
            projects: Arc::clone(&store_port),
            registry: Arc::clone(&live_sessions),
            clock: Arc::clone(&clock) as Arc<dyn Clock>,
        });
        let reconcile_background_tasks = Arc::new(AppReconcileBackgroundTasks {
            projects: Arc::clone(&store_port),
            store: Arc::clone(&background_tasks),
            clock: Arc::clone(&clock) as Arc<dyn Clock>,
            ready: background_ready_tx,
        });
        // Lot C — résumés de conversation best-effort : on câble les sources par
        // project root (handoff = primaire, log = repli `last(_, 3)`). Lecture seule,
        // aucune persistance ; un échec de preview ne bloque ni live/busy ni tickets.
        let get_project_work_state = Arc::new(
            GetProjectWorkState::new(
                Arc::clone(&contexts_port),
                Arc::clone(&live_sessions),
                Arc::clone(&input_mediator),
                queue_snapshot,
            )
            .with_conversation_sources(
                Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>,
                Arc::new(AppConversationLogProvider)
                    as Arc<dyn application::ConversationLogProvider>,
            )
            .with_background_tasks(Arc::clone(&background_tasks_port)),
        );
        let get_app_exit_work_guard_state = Arc::new(GetAppExitWorkGuardState::new(Arc::clone(
            &get_project_work_state,
        )));
        // Lot LS6 — rotation (hors chemin chaud) + lecture humaine paginée. Tous deux
        // composent le provider d'archive par root ; la rotation lit aussi le handoff
        // (plancher `up_to`, INV-LS6). Aucune persistance déclenchée par un `append`.
        let archive_provider = Arc::new(AppConversationArchiveProvider)
            as Arc<dyn application::ConversationArchiveProvider>;
        let read_conversation_page =
            Arc::new(ReadConversationPage::new(Arc::clone(&archive_provider)));
        let rotate_conversation_log = Arc::new(RotateConversationLog::new(
            Arc::clone(&archive_provider),
            Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>,
        ));
        // Lot D — actions contrôlées agent-level sur les sessions vivantes : attach
        // (rebind de la cellule-vue, zéro spawn) et stop (kill PTY via la primitive
        // `CloseTerminal` existante / shutdown structuré). Aucune création de session.
        let attach_live_agent = Arc::new(AttachLiveAgent::new(Arc::clone(&live_sessions)));

        // --- Limites de session des agents (ARCHITECTURE §21, LS7) ---
        // Service pur-ports « détecter → planifier → reprendre » câblé sur l'existant :
        // l'horloge système, le bus partagé, un `TokioScheduler` (minuterie one-shot
        // annulable) dont les tâches échues sont drainées plus bas, et un `AppAgentResumer`
        // qui relance via le *même* `LaunchAgent` que la commande `launch_agent`.
        let resume_contexts: ResumeContexts = Arc::new(Mutex::new(HashMap::new()));
        let (resume_tx, mut resume_rx) = tokio::sync::mpsc::unbounded_channel::<ScheduledTask>();
        let scheduler = Arc::new(TokioScheduler::new(
            resume_tx,
            Arc::clone(&clock) as Arc<dyn Clock>,
        )) as Arc<dyn Scheduler>;
        let resumer = Arc::new(AppAgentResumer {
            launch_agent: Arc::clone(&launch_agent),
            input_mediator: Arc::clone(&input_mediator),
            contexts: Arc::clone(&resume_contexts),
        }) as Arc<dyn AgentResumer>;
        let session_limit_service = Arc::new(SessionLimitService::new(
            Arc::clone(&clock) as Arc<dyn Clock>,
            scheduler,
            Arc::clone(&events_port),
            resumer,
        ));
        // Drain du scheduler (§21.5-b) : à chaque réveil tiré, `TokioScheduler` pousse une
        // `ScheduledTask::ResumeAgent` ; on l'exécute via le service (relance + AgentResumed).
        // Patron `sweep_stalled` : spawner detache (pas `tokio::spawn` — `build`
        // peut tourner sans runtime ambiant). Detache ⇒ vit autant que l'app.
        {
            let service = Arc::clone(&session_limit_service);
            spawn_detached(async move {
                while let Some(task) = resume_rx.recv().await {
                    if let Err(e) = service.execute_resume(task).await {
                        // Best-effort : une relance qui échoue ne fige pas le drain.
                        eprintln!("[session-limit] reprise auto échouée : {e}");
                    }
                }
            });
        }

        // Registre des conversations par paire (cadrage C3) : un fil par paire, session
        // vivante keyée par conversation (lève l'ambiguïté session/agent).
        let conversation_registry = Arc::new(InMemoryConversationRegistry::new())
            as Arc<dyn domain::conversation::ConversationRegistry>;
        // Garde FileGuard partagé (cadrage C7) : UN SEUL `RwFileGuard` casté une fois en
        // `Arc<dyn FileGuard>` puis cloné dans les quatre use cases, pour que les leases
        // se coordonnent entre eux et que l'invariant single-writer du contexte projet tienne.
        let file_guard = Arc::new(RwFileGuard::new()) as Arc<dyn domain::fileguard::FileGuard>;
        let context_guard = Arc::new(ContextGuardUseCases {
            read_context: Arc::new(ReadContext::new(
                Arc::clone(&file_guard),
                Arc::clone(&contexts_port),
                Arc::clone(&fs_port),
            )),
            propose_context: Arc::new(ProposeContext::new(
                Arc::clone(&file_guard),
                Arc::clone(&contexts_port),
                Arc::clone(&fs_port),
                Arc::clone(&clock) as Arc<dyn Clock>,
            )),
            update_project_context: Arc::new(GuardedUpdateProjectContext::new(
                Arc::clone(&file_guard),
                Arc::clone(&contexts_port),
                Arc::clone(&fs_port),
                Arc::clone(&events_port),
                Arc::clone(&clock) as Arc<dyn Clock>,
            )),
            read_memory: Arc::new(ReadMemory::new(
                Arc::clone(&file_guard),
                Arc::clone(&memory_store_port),
            )),
            write_memory: Arc::new(WriteMemory::new(
                Arc::clone(&file_guard),
                Arc::clone(&memory_store_port),
            )),
        });
        let orchestrator_service = Arc::new(
            OrchestratorService::new(
                Arc::clone(&create_agent),
                Arc::clone(&orchestrator_launch_agent),
                Arc::clone(&list_agents),
                Arc::clone(&close_terminal),
                Arc::clone(&update_agent_context),
                Arc::clone(&create_skill),
                Arc::clone(&profile_store_port),
                Arc::clone(&terminal_sessions),
            )
            // Messagerie inter-agents (cadrage C3) : médiateur d'entrée (file + livraison
            // PTV sérialisée) + registre de conversations + bus pour AgentReplied.
            .with_input_mediator(Arc::clone(&input_mediator), Arc::clone(&mailbox))
            .with_conversations(Arc::clone(&conversation_registry))
            .with_events(Arc::clone(&events_port))
            // Faits OS/runtime (exe $APPIMAGE/current_exe + endpoint loopback) pour
            // que les (re)lancements issus du chemin `ask` écrivent la déclaration MCP
            // réelle ⇒ le pont MCP de la cible se spawne et `idea_reply` débloque le round-trip.
            .with_mcp_runtime_provider(
                Arc::new(AppMcpRuntimeProvider) as Arc<dyn application::McpRuntimeProvider>
            )
            // Persistance conversationnelle best-effort (lot P6b) : Prompt + Response de
            // chaque paire déléguée écrits dans `<root>/.ideai/conversations/<conv>` via
            // le provider per-root + l'horloge millis (port Clock) déjà câblée. Un échec
            // n'affecte jamais la délégation live.
            .with_record_turn(
                Arc::new(AppRecordTurnProvider) as Arc<dyn RecordTurnProvider>,
                Arc::clone(&clock) as Arc<dyn Clock>,
            )
            // FileGuard context/memory (cadrage C7) : branche les quatre use cases
            // ReadContext/ProposeContext/ReadMemory/WriteMemory derrière le garde partagé.
            // Sans ça, `require_context_guard()` reste `None` ⇒ les outils MCP
            // `idea_context_*`/`idea_memory_*` échouent pour tous les agents.
            .with_context_guard(context_guard)
            // Auto-memory harvest (Lot E1) : après le checkpoint Response d'un `ask`
            // réussi, parse les blocs ` ```idea-memory ` de la réponse et persiste les
            // notes valides via le `MemoryStore` (root par appel). Best-effort strict :
            // un échec n'altère jamais la réponse/le ticket/le handoff.
            .with_memory_harvest(Arc::new(HarvestMemoryFromTurn::new(
                Arc::clone(&memory_store_port),
                Arc::clone(&events_port),
            )))
            // Outil MCP `idea_skill_read` (feature « skills à la MCP ») : sans ça,
            // `skill.read` renverrait « not configured ». Compose le SkillStore existant.
            .with_read_skill(Arc::clone(&read_skill))
            // Auto-update du live-state (programme live-state, lot LS3) : dérivé des
            // transitions de délégation (ask→Working, reply→Done), zéro token agent.
            // Provider par root (`<root>/.ideai/live-state.json`) car le port fixe sa
            // racine à la construction. Best-effort strict : un échec n'altère jamais la
            // délégation.
            .with_live_state(Arc::new(AppLiveStateProvider {
                clock: Arc::clone(&clock) as Arc<dyn Clock>,
            }) as Arc<dyn LiveStateProvider>)
            // Lecture du live-state (lot LS4) pour l'outil `idea_workstate_read` : snapshot
            // lean (prune-on-read) enrichi du nom d'agent. Provider par root (le store fixe
            // sa racine à la construction). Sans ça, l'outil renverrait « not configured ».
            .with_live_state_read(Arc::new(AppLiveStateLeanProvider {
                clock: Arc::clone(&clock) as Arc<dyn Clock>,
            }) as Arc<dyn LiveStateReadProvider>)
            // Fenêtre d'inactivité réarmable (signe de vie) du rendez-vous délégué : la
            // borne de tour dans le service n'est plus un timeout plat mais une fenêtre
            // réarmée à chaque progrès observé de la cible (octets cumulés de son
            // transcript Claude), sous le plafond ci-dessous. SANS cette sonde, la borne
            // dégrade vers un timeout plat (fallback, zéro régression) et coupe un long
            // tour unique à 600 s — c'est précisément la sonde qui rend le fix effectif.
            // Keyée par (project_root, agent_id) : le run-dir transcript = `<root>/.ideai/
            // run/<agent_id>`, encodé par Claude sous `<home>/.claude/projects/...`.
            .with_ask_liveness_probe({
                let fs = Arc::clone(&fs_port);
                let home = home_dir.clone();
                Arc::new(move |root: domain::project::ProjectPath, agent_id| {
                    let fs = Arc::clone(&fs);
                    let home = home.clone();
                    Box::pin(async move {
                        let run_dir = format!(
                            "{}/.ideai/run/{agent_id}",
                            root.as_str().trim_end_matches(['/', '\\'])
                        );
                        let cwd = domain::project::ProjectPath::new(run_dir).ok()?;
                        infrastructure::transcript_activity_token(fs.as_ref(), &home, &cwd).await
                    })
                        as std::pin::Pin<Box<dyn std::future::Future<Output = Option<u64>> + Send>>
                }) as application::AskLivenessProbe
            })
            // Plafond absolu du rendez-vous délégué (réglage projet via
            // `IDEA_ASK_RENDEZVOUS_CEILING_MS`, défaut 4 h) : la fenêtre réarmée ne parque
            // jamais un `ask` au-delà, même contre une cible perpétuellement active.
            .with_ask_ceiling(application::resolve_rendezvous_ceiling(
                std::env::var("IDEA_ASK_RENDEZVOUS_CEILING_MS")
                    .ok()
                    .and_then(|v| v.trim().parse::<u32>().ok()),
            ))
            // Conversation inter-agent headless : le service voit le même registre que le
            // launcher orchestrateur ci-dessus. Une cible à `structured_adapter` est donc
            // démarrée/drainée via `AgentSession::send` et son `Final`, sans dépendre de
            // `idea_reply`; les autres outils MCP restent câblés par ailleurs.
            .with_structured(Arc::clone(&structured_sessions))
            // Tâches de fond de 1re classe (B6/B7) : le port injecté est global mais
            // route chaque opération vers un `FsBackgroundTaskStore` lié au root du
            // projet concerné. Active notamment le rendez-vous headless tracé comme
            // BackgroundTask, sans figer un store unique sur un root arbitraire.
            .with_background_tasks(
                Arc::clone(&background_tasks_port),
                Arc::clone(&clock) as Arc<dyn Clock>,
            )
            // Limites de session sur le chemin délégué headless : quand le drain
            // structuré retourne `RateLimited`, l'orchestrateur arme la reprise pour
            // la cible limitée via le même service que les chemins directs.
            .with_session_limits(Arc::clone(&session_limit_service))
            // Producteur B8 côté agent MCP : `idea_run_in_background` passe par le
            // même use case que la commande desktop, sans aller-retour frontend.
            .with_spawn_background_command(Arc::clone(&spawn_background_command)),
        );
        openai_tool_invoker.bind(Arc::new(AppOpenAiToolInvoker::new(
            Arc::clone(&orchestrator_service),
            Arc::clone(&store_port),
            Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>,
            Arc::clone(&mcp_tool_permission_store_port),
            Arc::clone(&ticket_tool_provider),
            Arc::clone(&template_tool_provider),
        )) as Arc<dyn ToolInvoker>);

        let stop_live_agent = Arc::new(
            StopLiveAgent::new(Arc::clone(&live_sessions), Arc::clone(&close_terminal))
                .with_cascade(
                    Arc::clone(&input_mediator),
                    Arc::clone(&orchestrator_service),
                ),
        );

        // --- Windows (L10) ---
        let move_tab = Arc::new(MoveTabToNewWindow::new(
            Arc::clone(&store_port),
            Arc::clone(&ids) as Arc<dyn IdGenerator>,
        ));
        let snapshot_open_windows =
            Arc::new(SnapshotOpenWindows::new(Arc::clone(&window_state_port)));
        let restore_open_windows = Arc::new(RestoreOpenWindows::new(
            Arc::clone(&window_state_port),
            Arc::clone(&store_port),
            Arc::clone(&plugin_package_store),
            Arc::clone(&plugin_registry_store),
            Arc::clone(&plugin_manifest_validator),
        ));
        let open_plugin_layout_window = Arc::new(OpenPluginLayoutWindow::new(
            Arc::clone(&plugin_package_store),
            Arc::clone(&plugin_registry_store),
            Arc::clone(&plugin_manifest_validator),
        ));

        Self {
            health,
            pair_device,
            pair_attempt_limiter: Arc::clone(&pair_attempt_limiter_port),
            authenticate_session,
            touch_device,
            list_devices,
            rename_device,
            revoke_device,
            revoke_all_devices,
            device_session_store: Arc::clone(&device_session_port),
            create_project,
            open_project,
            close_project,
            close_tab,
            list_projects,
            read_project_context,
            update_project_context,
            open_terminal,
            write_terminal,
            resize_terminal,
            close_terminal,
            load_layout,
            mutate_layout,
            list_layouts,
            create_layout,
            rename_layout,
            delete_layout,
            set_active_layout,
            open_plugin_layout_window,
            snapshot_running_agents,
            reconcile_layouts,
            reconcile_live_state,
            reconcile_background_tasks,
            detect_profiles,
            list_profiles,
            save_profile,
            save_opencode_provider_profile,
            list_opencode_providers,
            list_claude_models,
            list_codex_models,
            clone_profile_from_seed,
            clone_opencode_profile_from_seed,
            delete_profile,
            configure_profiles,
            reference_profiles,
            first_run_state,
            ensure_local_model_server,
            list_model_servers,
            save_model_server,
            delete_model_server,
            delete_model_artifact,
            pty_port,
            terminal_sessions,
            event_bus,
            create_issue,
            read_issue,
            delete_issue,
            list_issues,
            update_issue,
            bulk_update_issue_status,
            bulk_update_issue_priority,
            bulk_delete_issues,
            read_issue_carnet,
            update_issue_carnet,
            add_issue_attachment,
            read_issue_attachment,
            mark_issue_attachment_summarized,
            link_issues,
            unlink_issues,
            assign_issue_agent,
            create_sprint,
            list_sprints,
            rename_sprint,
            reorder_sprints,
            delete_sprint,
            assign_ticket_to_sprint,
            unassign_ticket_from_sprint,
            ticket_tool_provider,
            template_tool_provider,
            open_ticket_assistant,
            close_ticket_assistant,
            tool_policy_registry,
            mcp_tool_permission_store: mcp_tool_permission_store_port,
            read_mcp_tool_permissions,
            update_project_mcp_tool_permissions,
            update_agent_mcp_tool_permissions,
            structured_sessions,
            create_agent,
            list_agents,
            read_agent_context,
            update_agent_context,
            delete_agent,
            launch_agent,
            change_agent_profile,
            list_resumable_agents,
            get_project_work_state,
            get_app_exit_work_guard_state,
            read_conversation_page,
            rotate_conversation_log,
            attach_live_agent,
            stop_live_agent,
            inspect_conversation,
            project_store,
            get_project_permissions,
            update_project_permissions,
            update_agent_permissions,
            update_agent_effort,
            resolve_agent_permissions,
            get_project_system_permissions,
            update_project_system_permissions,
            update_agent_system_permissions,
            resolve_agent_system_permissions,
            create_template,
            read_template,
            update_template,
            list_templates,
            delete_template,
            create_agent_from_template,
            detect_agent_drift,
            sync_agent_with_template,
            git_status,
            git_stage,
            git_unstage,
            git_commit,
            git_branches,
            git_checkout,
            git_log,
            git_init,
            git_graph,
            create_skill,
            update_skill,
            list_skills,
            delete_skill,
            assign_skill,
            unassign_skill,
            create_memory,
            update_memory,
            list_memories,
            get_memory,
            delete_memory,
            read_memory_index,
            resolve_memory_links,
            recall_memory,
            list_embedder_profiles,
            save_embedder_profile,
            delete_embedder_profile,
            describe_embedder_engines,
            dismiss_embedder_suggestion,
            orchestrator_service,
            orchestrator_watchers: Mutex::new(HashMap::new()),
            mcp_servers: Mutex::new(HashMap::new()),
            session_limit_service,
            resume_contexts,
            turn_watcher,
            turn_watch_input: Arc::clone(&input_mediator),
            turn_watch_handles: Mutex::new(HashMap::new()),
            fs_port: Arc::clone(&fs_port),
            home_dir,
            move_tab,
            snapshot_open_windows,
            restore_open_windows,
            spawn_background_command,
            cancel_background_task,
            retry_background_task,
            background_command_runner: Arc::clone(&background_runner),
            background_task_store: Arc::clone(&background_tasks_port),
            review_plugin_package,
            install_plugin_from_archive,
            install_plugin_from_directory,
            list_plugins,
            set_plugin_enabled,
            uninstall_plugin,
            list_plugin_runtime_contributions,
            reconcile_plugin_mcp_servers,
            plugin_workspace_access,
            plugin_config_documents,
            plugin_storage_access,
            query_project_structure,
            plugin_command_tasks,
            plugin_toolchain_diagnostics,
            plugin_event_subscriptions,
            plugin_package_store: Arc::clone(&plugin_packages),
            plugin_registry_store: Arc::clone(&plugin_registry_store),
            plugin_manifest_validator: Arc::clone(&plugin_manifest_validator),
            ticket_tool_binder,
            template_tool_binder,
        }
    }

    /// Starts a [`FsOrchestratorWatcher`] for `project` if one is not already
    /// running for it (idempotent). The watcher tails the project's
    /// `.ideai/requests/` tree and dispatches each request through the shared
    /// [`OrchestratorService`]; processed-request events are republished on the
    /// domain event bus so the relay surfaces them to the frontend.
    ///
    /// Called from the `open_project` / `create_project` commands. Must run inside
    /// the Tokio runtime (the watcher spawns a background task) — async driving
    /// adapters satisfy this.
    pub fn ensure_orchestrator_watch(&self, project: &Project) {
        let mut watchers = self
            .orchestrator_watchers
            .lock()
            .expect("orchestrator watcher registry poisoned");
        if watchers.contains_key(&project.id) {
            return;
        }
        let bus = Arc::clone(&self.event_bus);
        let events: Arc<dyn Fn(DomainEvent) + Send + Sync> =
            Arc::new(move |event| bus.publish(event));
        let handle = FsOrchestratorWatcher::start(
            project.clone(),
            Arc::clone(&self.orchestrator_service),
            events,
        );
        watchers.insert(project.id, handle);
        drop(watchers);

        // Start the MCP server for this project, beside (and in parallel with) the
        // file watcher — both are entry doors onto the *same* OrchestratorService
        // (Décision 4). Idempotent like the watcher above.
        self.ensure_mcp_server(project);
    }

    /// Starts an [`McpServer`] for `project` if one is not already registered
    /// (idempotent), the **twin** of [`ensure_orchestrator_watch`]. Registers its
    /// lifecycle handle in [`mcp_servers`](Self::mcp_servers); dropping/stopping the
    /// handle tears down the supervision task.
    ///
    /// ## Transport decision (S-MCP, cadrage v5 §1) & endpoint lifecycle (M5a)
    ///
    /// At project-open time there is **no CLI connected yet**: a peer only appears
    /// once an MCP-capable agent is launched with the injected MCP config (M5d) and
    /// its spawned `idea mcp-server` bridge dials this project's loopback endpoint.
    /// We therefore do **not** run a blocking `McpServer::serve`/accept loop here
    /// (that must never figer the open/close of a project). M5a's job is narrower:
    /// **bind the project's loopback endpoint** ([`mcp_endpoint`], the single source
    /// of truth) so it is ready when a bridge connects, and **hold** the listener in
    /// the handle. The actual accept-and-serve-per-peer is M5b/M5c.
    ///
    /// Binding is **non-blocking** (create the listener, then park on the stop
    /// signal) and **idempotent** (one endpoint per project: a second open returns
    /// early without rebinding). On close the handle is dropped, which on Unix
    /// unlinks the socket file (interprocess reclaim guard) — no leak.
    fn ensure_mcp_server(&self, project: &Project) {
        let mut servers = self
            .mcp_servers
            .lock()
            .expect("mcp server registry poisoned");
        if servers.contains_key(&project.id) {
            return;
        }
        let bus = Arc::clone(&self.event_bus);
        let events: Arc<dyn Fn(DomainEvent) + Send + Sync> =
            Arc::new(move |event| bus.publish(event));
        let endpoint = mcp_endpoint(&project.id);
        let listener = bind_endpoint(&endpoint);
        // The project-id string the handshake guard compares against: the same
        // hyphen-free hex form the endpoint encodes, which M5d's `--project` reuses.
        let project_id = project.id.as_uuid().simple().to_string();
        // Readiness de démarrage : quand le pont MCP d'un agent se connecte (initialize),
        // libère son éventuel 1er tour différé (fix race cold-launch via signal MCP).
        // L'id arrive en hex (handshake `requester`) ⇒ on le parse en AgentId ici (la
        // composition root est la seule à connaître la frontière infra↔domaine).
        let service_for_ready = Arc::clone(&self.orchestrator_service);
        let ready_project = project.clone();
        let ready_sink: Arc<dyn Fn(&str) + Send + Sync> = Arc::new(move |requester: &str| {
            if let Ok(uuid) = Uuid::parse_str(requester) {
                service_for_ready
                    .release_agent_cold_start(&ready_project, AgentId::from_uuid(uuid));
            }
        });
        let handle = McpServerHandle::start(
            McpServer::new(Arc::clone(&self.orchestrator_service), project.clone())
                .with_events(events)
                .with_ready_sink(ready_sink)
                .with_ticket_tools(Arc::clone(&self.ticket_tool_provider))
                .with_template_tools(Arc::clone(&self.template_tool_provider))
                .with_tool_policies(Arc::clone(&self.tool_policy_registry))
                .with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)),
            endpoint,
            listener,
            project_id,
        );
        servers.insert(project.id, handle);
    }

    /// Returns the ids of every currently-open project.
    ///
    /// Derived from the orchestrator watcher registry, which holds exactly one
    /// entry per open project (started on open/create, dropped on close). Used by
    /// the shutdown hook to snapshot running agents across all open projects
    /// before the global PTY kill.
    #[must_use]
    pub fn open_project_ids(&self) -> Vec<ProjectId> {
        self.orchestrator_watchers
            .lock()
            .map(|w| w.keys().copied().collect())
            .unwrap_or_default()
    }

    /// Repairs the persisted Claude run-dir artefacts of `project` so a freshly
    /// launched AppImage does not inherit stale MCP/settings state from older runs.
    ///
    /// Best-effort and local-only: remote SSH/WSL projects are skipped because this
    /// migration rewrites the local run-dir files the AppImage owns. Launch-time
    /// repair still remains available on the normal activation path.
    pub async fn reconcile_claude_run_dirs(&self, project: &Project) {
        self.migrate_claude_run_dirs(project).await;
    }

    /// Arms the **end-of-turn watcher** (no-reply backstop) for `agent_id` if its
    /// `profile` is supported (Claude). The watcher tails the agent's isolated run-dir
    /// transcript folder (`<root>/.ideai/run/<agent>`) and routes each detected turn end
    /// to [`InputMediator::turn_ended`](domain::input::InputMediator::turn_ended). Called
    /// at launch; **idempotent by replacement** — a relaunch drops the previous handle
    /// (stopping its polling task) and arms a fresh one. A non-supported profile is a
    /// no-op. `conversation_id` is diagnostic only.
    pub fn arm_turn_watch(
        &self,
        project_id: ProjectId,
        project_root: &domain::project::ProjectPath,
        agent_id: AgentId,
        profile: &AgentProfile,
        conversation_id: Option<String>,
    ) {
        if !self.turn_watcher.supports(profile) {
            return;
        }
        // cwd = the agent's isolated run dir (matches the profile `{agentRunDir}` cwd that
        // Claude runs in, hence the `<home>/.claude/projects/<encoded-run-dir>/` folder).
        let run_dir = format!(
            "{}/.ideai/run/{agent_id}",
            project_root.as_str().trim_end_matches(['/', '\\'])
        );
        let Ok(cwd) = domain::project::ProjectPath::new(run_dir) else {
            return;
        };
        let input = Arc::clone(&self.turn_watch_input);
        // Callback invoked from the watcher's polling task (no lock held here).
        let on_turn_end: domain::ports::OnTurnEnd =
            Arc::new(move |a| input.turn_ended(domain::RuntimeAgentKey::new(project_id, a)));
        let handle = self
            .turn_watcher
            .watch(agent_id, conversation_id, cwd, on_turn_end);
        if let Ok(mut map) = self.turn_watch_handles.lock() {
            // Insert replaces (and drops) any prior handle ⇒ its polling task stops.
            map.insert(domain::RuntimeAgentKey::new(project_id, agent_id), handle);
        }
    }

    /// Stops and removes the end-of-turn watcher of `agent_id` in `project_id` (close /
    /// stop). Dropping the stored handle stops its polling task. No-op if none is armed.
    pub fn stop_turn_watch(&self, project_id: ProjectId, agent_id: AgentId) {
        if let Ok(mut map) = self.turn_watch_handles.lock() {
            map.remove(&domain::RuntimeAgentKey::new(project_id, agent_id));
        }
    }

    /// Stops and removes the orchestrator watcher for `project_id`, if any.
    /// Called from `close_project` so a closed project stops consuming requests.
    /// Symmetrically stops the project's MCP server (its twin) so both entry doors
    /// are torn down together.
    pub fn stop_orchestrator_watch(&self, project_id: &ProjectId) {
        if let Some(handle) = self
            .orchestrator_watchers
            .lock()
            .expect("orchestrator watcher registry poisoned")
            .remove(project_id)
        {
            handle.stop();
        }
        if let Some(handle) = self
            .mcp_servers
            .lock()
            .expect("mcp server registry poisoned")
            .remove(project_id)
        {
            handle.stop();
        }
    }

    /// Best-effort migration of persisted Claude run dirs at project-open time:
    /// repairs stale `.mcp.json` declarations and missing
    /// `enabledMcpjsonServers` entries in `.claude/settings.local.json`.
    pub async fn migrate_claude_run_dirs(&self, project: &Project) {
        if project.remote.kind() != RemoteKind::Local {
            return;
        }
        let agents = match self
            .list_agents
            .execute(ListAgentsInput {
                project: project.clone(),
            })
            .await
        {
            Ok(output) => output.agents,
            Err(_) => return,
        };
        let profiles = match self.list_profiles.execute().await {
            Ok(output) => output.profiles,
            Err(_) => return,
        };
        let profile_by_id: HashMap<_, _> = profiles.into_iter().map(|p| (p.id, p)).collect();
        for agent in agents {
            let Some(profile) = profile_by_id.get(&agent.profile_id) else {
                continue;
            };
            // Dispatch par profil : Claude (`.mcp.json` + settings) ou Codex
            // (`config.toml` isolé via `CODEX_HOME`). Tout autre profil ⇒ rien à migrer.
            if is_claude_mcp_profile(profile) {
                let _ = migrate_claude_run_dir(project, &agent.id, profile).await;
            } else if is_codex_mcp_profile(profile) {
                let _ = migrate_codex_run_dir(project, &agent.id, profile).await;
            }
        }
    }
}

async fn migrate_claude_run_dir(
    project: &Project,
    agent_id: &AgentId,
    profile: &AgentProfile,
) -> Result<(), std::io::Error> {
    let run_dir = project
        .root
        .as_str()
        .trim_end_matches(['/', '\\'])
        .to_owned()
        + &format!("/.ideai/run/{agent_id}");
    let run_dir_path = Path::new(&run_dir);
    if tokio::fs::metadata(run_dir_path).await.is_err() {
        return Ok(());
    }

    migrate_claude_settings(run_dir_path, project.root.as_str()).await?;

    let runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
        exe,
        endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id)
            .as_cli_arg()
            .to_owned(),
        project_id: project.id.as_uuid().simple().to_string(),
        requester: agent_id.to_string(),
    });
    migrate_claude_mcp_config(run_dir_path, profile, runtime.as_ref()).await?;
    Ok(())
}

async fn migrate_claude_settings(run_dir: &Path, project_root: &str) -> Result<(), std::io::Error> {
    let claude_dir = run_dir.join(".claude");
    let settings_path = claude_dir.join("settings.local.json");
    let next = match tokio::fs::read_to_string(&settings_path).await {
        Ok(existing) => merge_claude_settings_json(&existing, project_root),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            Some(claude_settings_seed_value(project_root))
        }
        Err(err) => return Err(err),
    };
    let Some(next) = next else {
        return Ok(());
    };
    tokio::fs::create_dir_all(&claude_dir).await?;
    let mut body = serde_json::to_string_pretty(&next).map_err(std::io::Error::other)?;
    body.push('\n');
    write_atomically(&settings_path, &body)
}

async fn migrate_claude_mcp_config(
    run_dir: &Path,
    profile: &AgentProfile,
    runtime: Option<&McpRuntime>,
) -> Result<(), std::io::Error> {
    let Some(target) = claude_mcp_config_target(profile) else {
        return Ok(());
    };
    let Some(runtime) = runtime else {
        return Ok(());
    };
    let mcp_path = run_dir.join(target);
    let desired_server = mcp_server_entry(profile, Some(runtime));

    let next = match tokio::fs::read_to_string(&mcp_path).await {
        Ok(existing) => merge_mcp_json(&existing, desired_server),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            Some(wrap_idea_mcp_server(desired_server))
        }
        Err(err) => return Err(err),
    };
    let Some(next) = next else {
        return Ok(());
    };
    if let Some(parent) = mcp_path.parent() {
        tokio::fs::create_dir_all(parent).await?;
    }
    let mut body = serde_json::to_string_pretty(&next).map_err(std::io::Error::other)?;
    body.push('\n');
    write_atomically(&mcp_path, &body)
}

fn is_claude_mcp_profile(profile: &AgentProfile) -> bool {
    let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude)
        || matches!(
            &profile.context_injection,
            ContextInjection::ConventionFile { target }
                if target
                    .rsplit(['/', '\\'])
                    .next()
                    .unwrap_or(target)
                    .eq_ignore_ascii_case("CLAUDE.md")
        );
    is_claude && claude_mcp_config_target(profile).is_some()
}

fn claude_mcp_config_target(profile: &AgentProfile) -> Option<&str> {
    match profile.mcp.as_ref().map(|mcp| &mcp.config) {
        Some(McpConfigStrategy::ConfigFile { target }) => Some(target.as_str()),
        _ => None,
    }
}

/// Pendant Codex de [`migrate_claude_run_dir`] : répare le `config.toml` MCP isolé
/// du run dir (table `[mcp_servers.idea]`) pour que Codex, qui lit ses serveurs MCP
/// dans `$CODEX_HOME/config.toml`, voie les outils `idea_*` après un redémarrage.
/// `CODEX_HOME` est isolé au run dir au lancement (jamais le `~/.codex` global).
/// Le fichier reste co-géré : la migration répare la partie MCP/trust sans effacer
/// les clés de permission (`approval_policy`, `sandbox_mode`) écrites par le
/// projecteur Codex. Best-effort, idempotent.
async fn migrate_codex_run_dir(
    project: &Project,
    agent_id: &AgentId,
    profile: &AgentProfile,
) -> Result<(), std::io::Error> {
    let run_dir = project
        .root
        .as_str()
        .trim_end_matches(['/', '\\'])
        .to_owned()
        + &format!("/.ideai/run/{agent_id}");
    let run_dir_path = Path::new(&run_dir);
    if tokio::fs::metadata(run_dir_path).await.is_err() {
        return Ok(());
    }

    let runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
        exe,
        endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id)
            .as_cli_arg()
            .to_owned(),
        project_id: project.id.as_uuid().simple().to_string(),
        requester: agent_id.to_string(),
    });
    migrate_codex_mcp_config(
        run_dir_path,
        project.root.as_str(),
        profile,
        runtime.as_ref(),
    )
    .await
}

async fn migrate_codex_mcp_config(
    run_dir: &Path,
    project_root: &str,
    profile: &AgentProfile,
    runtime: Option<&McpRuntime>,
) -> Result<(), std::io::Error> {
    let Some((target, _home_env)) = codex_mcp_config_target(profile) else {
        return Ok(());
    };
    // Sans runtime réel, seule une déclaration minimale est disponible : on ne
    // régénère pas (mirror de `migrate_claude_mcp_config`).
    let Some(runtime) = runtime else {
        return Ok(());
    };
    let toml_path = run_dir.join(target);
    let declaration = mcp_server_entry_toml(profile, Some(runtime));

    // Ne jamais clobber le fichier complet ici : il porte aussi la projection de
    // permissions Codex. On répare seulement la table MCP et les entrées trust.
    let existing = match tokio::fs::read_to_string(&toml_path).await {
        Ok(existing) => Some(existing),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
        Err(err) => return Err(err),
    };
    let desired = codex_config_toml_for_migration(
        existing.as_deref(),
        &declaration,
        run_dir.to_string_lossy().as_ref(),
        project_root,
    );
    if existing.as_deref() == Some(desired.as_str()) {
        return Ok(());
    }
    if let Some(parent) = toml_path.parent() {
        tokio::fs::create_dir_all(parent).await?;
    }
    write_atomically(&toml_path, &desired)
}

fn is_codex_mcp_profile(profile: &AgentProfile) -> bool {
    let is_codex = profile.structured_adapter == Some(StructuredAdapter::Codex)
        || matches!(
            &profile.context_injection,
            ContextInjection::ConventionFile { target }
                if target
                    .rsplit(['/', '\\'])
                    .next()
                    .unwrap_or(target)
                    .eq_ignore_ascii_case("AGENTS.md")
        );
    is_codex && codex_mcp_config_target(profile).is_some()
}

fn codex_mcp_config_target(profile: &AgentProfile) -> Option<(&str, &str)> {
    match profile.mcp.as_ref().map(|mcp| &mcp.config) {
        Some(McpConfigStrategy::TomlConfigHome { target, home_env }) => {
            Some((target.as_str(), home_env.as_str()))
        }
        _ => None,
    }
}

/// Rend le contenu du `config.toml` Codex (table `[mcp_servers.idea]`) en réutilisant
/// l'encodeur TOML partagé [`domain::McpServerWiring::to_config_toml`] (D2) — même
/// source de wiring que la déclaration `.mcp.json` Claude, donc zéro dérive.
fn mcp_server_entry_toml(profile: &AgentProfile, runtime: Option<&McpRuntime>) -> String {
    let transport = profile
        .mcp
        .as_ref()
        .map_or(McpTransport::Stdio, |m| m.transport);
    let (command, args) = match runtime {
        Some(rt) => (
            rt.exe.clone(),
            vec![
                "mcp-server".to_owned(),
                "--endpoint".to_owned(),
                rt.endpoint.clone(),
                "--project".to_owned(),
                rt.project_id.clone(),
                "--requester".to_owned(),
                rt.requester.clone(),
            ],
        ),
        None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
    };
    domain::McpServerWiring::new(command, args, transport).to_config_toml()
}

fn codex_config_toml_for_migration(
    existing: Option<&str>,
    mcp_declaration: &str,
    run_dir: &str,
    project_root: &str,
) -> String {
    let mut text = replace_toml_table_block(
        existing.unwrap_or_default(),
        "mcp_servers.idea",
        mcp_declaration.trim_end(),
    );
    text = replace_toml_table_block(
        &text,
        CODEX_CODE_MODE_FEATURES_TABLE,
        CODEX_CODE_MODE_FEATURES_TOML.trim_end(),
    );
    text = ensure_codex_project_trust(&text, run_dir);
    text = ensure_codex_project_trust(&text, project_root);
    if !text.ends_with('\n') {
        text.push('\n');
    }
    text
}

fn replace_toml_table_block(existing: &str, table: &str, replacement: &str) -> String {
    let header = format!("[{table}]");
    let mut out = Vec::new();
    let mut skipping = false;
    let mut inserted = false;

    for line in existing.lines() {
        let trimmed = line.trim();
        if trimmed == header {
            if !inserted {
                push_toml_block(&mut out, replacement);
                inserted = true;
            }
            skipping = true;
            continue;
        }
        if skipping && trimmed.starts_with('[') && trimmed.ends_with(']') {
            skipping = false;
        }
        if !skipping {
            out.push(line.to_owned());
        }
    }

    if !inserted {
        if !out.is_empty() && !out.last().is_some_and(|line| line.is_empty()) {
            out.push(String::new());
        }
        push_toml_block(&mut out, replacement);
    }

    out.join("\n")
}

fn push_toml_block(out: &mut Vec<String>, block: &str) {
    out.extend(block.lines().map(ToOwned::to_owned));
}

fn ensure_codex_project_trust(existing: &str, path: &str) -> String {
    if path.is_empty() {
        return existing.to_owned();
    }
    let header = format!(r#"[projects.{}]"#, toml_quoted(path));
    if existing.lines().any(|line| line.trim() == header) {
        return existing.to_owned();
    }
    let mut text = existing.trim_end().to_owned();
    if !text.is_empty() {
        text.push_str("\n\n");
    }
    text.push_str(&header);
    text.push_str("\ntrust_level = \"trusted\"\n");
    text
}

fn toml_quoted(value: &str) -> String {
    let mut out = String::with_capacity(value.len() + 2);
    out.push('"');
    for ch in value.chars() {
        match ch {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

fn merge_claude_settings_json(existing: &str, project_root: &str) -> Option<Value> {
    let mut doc = match serde_json::from_str::<Value>(existing) {
        Ok(Value::Object(map)) => Value::Object(map),
        Ok(_) | Err(_) => return Some(claude_settings_seed_value(project_root)),
    };
    let before = doc.clone();
    let root = doc.as_object_mut().expect("object preserved above");
    let permissions = root
        .entry("permissions".to_owned())
        .or_insert_with(|| Value::Object(Map::new()));
    if !permissions.is_object() {
        *permissions = Value::Object(Map::new());
    }
    let permissions = permissions
        .as_object_mut()
        .expect("permissions forced to object");
    permissions.insert(
        "defaultMode".to_owned(),
        Value::String("bypassPermissions".to_owned()),
    );
    permissions.insert(
        "additionalDirectories".to_owned(),
        Value::Array(merge_string_array(
            permissions.get("additionalDirectories"),
            [project_root],
        )),
    );
    permissions.insert(
        "allow".to_owned(),
        Value::Array(merge_string_array(
            permissions.get("allow"),
            ["Read", "Edit", "Write", "Bash"],
        )),
    );
    permissions.insert(
        "deny".to_owned(),
        Value::Array(merge_string_array(
            permissions.get("deny"),
            [
                "Bash(sudo *)",
                "Bash(rm -rf /)",
                "Bash(rm -rf /*)",
                "Bash(rm -rf ~)",
                "Bash(rm -rf ~/)",
                "Bash(rm -rf ~/*)",
                "Bash(rm -rf $HOME*)",
                "Bash(mkfs*)",
                "Bash(dd if=*)",
                "Bash(shutdown*)",
                "Bash(reboot*)",
            ],
        )),
    );
    root.insert(
        "skipDangerousModePermissionPrompt".to_owned(),
        Value::Bool(true),
    );
    root.insert(
        "enabledMcpjsonServers".to_owned(),
        Value::Array(merge_string_array(
            root.get("enabledMcpjsonServers"),
            ["idea"],
        )),
    );
    let sandbox = root
        .entry("sandbox".to_owned())
        .or_insert_with(|| Value::Object(Map::new()));
    if !sandbox.is_object() {
        *sandbox = Value::Object(Map::new());
    }
    let sandbox = sandbox.as_object_mut().expect("sandbox forced to object");
    sandbox.insert("enabled".to_owned(), Value::Bool(false));

    if doc != before {
        Some(doc)
    } else {
        None
    }
}

fn merge_mcp_json(existing: &str, desired_server: Value) -> Option<Value> {
    let mut doc = match serde_json::from_str::<Value>(existing) {
        Ok(Value::Object(map)) => map,
        Ok(_) | Err(_) => return Some(wrap_idea_mcp_server(desired_server)),
    };
    let mcp_servers = doc
        .entry("mcpServers".to_owned())
        .or_insert_with(|| Value::Object(Map::new()));
    if !mcp_servers.is_object() {
        *mcp_servers = Value::Object(Map::new());
    }
    let changed = mcp_servers.get("idea") != Some(&desired_server);
    if !changed {
        return None;
    }
    match mcp_servers {
        Value::Object(servers) => {
            servers.insert("idea".to_owned(), desired_server);
            Some(Value::Object(doc))
        }
        _ => None,
    }
}

fn wrap_idea_mcp_server(server: Value) -> Value {
    let mut servers = Map::new();
    servers.insert("idea".to_owned(), server);
    let mut root = Map::new();
    root.insert("mcpServers".to_owned(), Value::Object(servers));
    Value::Object(root)
}

fn mcp_server_entry(profile: &AgentProfile, runtime: Option<&McpRuntime>) -> Value {
    let transport = match profile.mcp.as_ref().map(|mcp| mcp.transport) {
        Some(McpTransport::Socket) => "socket",
        Some(McpTransport::Stdio) | None => "stdio",
    };
    let (command, args) = match runtime {
        Some(rt) => (
            rt.exe.clone(),
            vec![
                Value::String("mcp-server".to_owned()),
                Value::String("--endpoint".to_owned()),
                Value::String(rt.endpoint.clone()),
                Value::String("--project".to_owned()),
                Value::String(rt.project_id.clone()),
                Value::String("--requester".to_owned()),
                Value::String(rt.requester.clone()),
            ],
        ),
        None => (
            "idea".to_owned(),
            vec![Value::String("mcp-server".to_owned())],
        ),
    };
    let mut server = Map::new();
    server.insert("command".to_owned(), Value::String(command));
    server.insert("args".to_owned(), Value::Array(args));
    server.insert("transport".to_owned(), Value::String(transport.to_owned()));
    Value::Object(server)
}

fn claude_settings_seed_value(project_root: &str) -> Value {
    json!({
        "permissions": {
            "defaultMode": "bypassPermissions",
            "additionalDirectories": [project_root],
            "allow": ["Read", "Edit", "Write", "Bash"],
            "deny": [
                "Bash(sudo *)",
                "Bash(rm -rf /)",
                "Bash(rm -rf /*)",
                "Bash(rm -rf ~)",
                "Bash(rm -rf ~/)",
                "Bash(rm -rf ~/*)",
                "Bash(rm -rf $HOME*)",
                "Bash(mkfs*)",
                "Bash(dd if=*)",
                "Bash(shutdown*)",
                "Bash(reboot*)"
            ]
        },
        "skipDangerousModePermissionPrompt": true,
        "enabledMcpjsonServers": ["idea"],
        "sandbox": { "enabled": false }
    })
}

fn merge_string_array<'a>(
    existing: Option<&Value>,
    required: impl IntoIterator<Item = &'a str>,
) -> Vec<Value> {
    let mut seen = HashSet::<String>::new();
    let mut out = Vec::new();

    if let Some(existing) = existing.and_then(Value::as_array) {
        for item in existing.iter().filter_map(Value::as_str) {
            if seen.insert(item.to_owned()) {
                out.push(Value::String(item.to_owned()));
            }
        }
    }

    for item in required {
        if seen.insert(item.to_owned()) {
            out.push(Value::String(item.to_owned()));
        }
    }

    out
}

fn write_atomically(path: &Path, content: &str) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }

    let file_name = path
        .file_name()
        .map(OsString::from)
        .unwrap_or_else(|| OsString::from("tmp"));
    let tmp_name = format!(".{}.{}.tmp", file_name.to_string_lossy(), Uuid::new_v4());
    let tmp_path = path.with_file_name(tmp_name);

    std::fs::write(&tmp_path, content.as_bytes())?;
    #[cfg(windows)]
    if path.exists() {
        let _ = std::fs::remove_file(path);
    }
    std::fs::rename(&tmp_path, path)?;
    Ok(())
}

#[cfg(test)]
mod run_dir_migration_tests {
    use super::{
        claude_settings_seed_value, is_claude_mcp_profile, mcp_server_entry,
        merge_claude_settings_json, merge_mcp_json, migrate_claude_run_dir, migrate_codex_run_dir,
    };
    use application::McpRuntimeProvider;
    use domain::ids::{AgentId, ProfileId, ProjectId};
    use domain::profile::{
        AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
        StructuredAdapter,
    };
    use serde_json::json;
    use uuid::Uuid;

    fn claude_profile() -> AgentProfile {
        AgentProfile::new(
            ProfileId::from_uuid(Uuid::from_u128(9)),
            "Claude Code",
            "claude",
            Vec::new(),
            ContextInjection::convention_file("CLAUDE.md").unwrap(),
            None,
            "{agentRunDir}",
            None,
        )
        .unwrap()
        .with_structured_adapter(StructuredAdapter::Claude)
        .with_mcp(McpCapability::new(
            McpConfigStrategy::config_file(".mcp.json").unwrap(),
            McpTransport::Stdio,
        ))
    }

    fn codex_profile() -> AgentProfile {
        AgentProfile::new(
            ProfileId::from_uuid(Uuid::from_u128(10)),
            "OpenAI Codex CLI",
            "codex",
            Vec::new(),
            ContextInjection::convention_file("AGENTS.md").unwrap(),
            None,
            "{agentRunDir}",
            None,
        )
        .unwrap()
        .with_structured_adapter(StructuredAdapter::Codex)
        .with_mcp(McpCapability::new(
            McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME").unwrap(),
            McpTransport::Stdio,
        ))
    }

    fn runtime(agent_id: AgentId) -> application::McpRuntime {
        application::McpRuntime {
            exe: "/opt/IdeA.AppImage".to_owned(),
            endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(),
            project_id: ProjectId::from_uuid(Uuid::from_u128(1234))
                .as_uuid()
                .simple()
                .to_string(),
            requester: agent_id.to_string(),
        }
    }

    #[test]
    fn merge_claude_settings_adds_idea_and_preserves_existing_entries() {
        let merged = merge_claude_settings_json(
            r#"{
  "permissions": {
    "additionalDirectories": ["/tmp/custom"],
    "allow": ["Read"],
    "deny": ["Bash(custom)"]
  },
  "enabledMcpjsonServers": ["other"],
  "extra": true
}"#,
            "/home/me/proj",
        )
        .unwrap();

        let parsed = merged;
        assert_eq!(parsed["extra"], json!(true));
        assert_eq!(
            parsed["permissions"]["defaultMode"],
            json!("bypassPermissions")
        );
        assert!(parsed["permissions"]["additionalDirectories"]
            .as_array()
            .unwrap()
            .contains(&json!("/tmp/custom")));
        assert!(parsed["permissions"]["additionalDirectories"]
            .as_array()
            .unwrap()
            .contains(&json!("/home/me/proj")));
        assert!(parsed["enabledMcpjsonServers"]
            .as_array()
            .unwrap()
            .contains(&json!("other")));
        assert!(parsed["enabledMcpjsonServers"]
            .as_array()
            .unwrap()
            .contains(&json!("idea")));
    }

    #[test]
    fn merge_idea_mcp_json_rewrites_idea_and_preserves_other_servers() {
        let agent_id = AgentId::from_uuid(Uuid::from_u128(77));
        let desired = mcp_server_entry(&claude_profile(), Some(&runtime(agent_id)));
        let merged = merge_mcp_json(
            r#"{
  "mcpServers": {
    "idea": { "command": "idea", "args": ["mcp-server"], "transport": "stdio" },
    "other": { "command": "keep-me", "args": [] }
  }
}"#,
            desired.clone(),
        )
        .unwrap();

        let parsed = merged;
        assert_eq!(parsed["mcpServers"]["other"]["command"], json!("keep-me"));
        assert_eq!(parsed["mcpServers"]["idea"], desired);
    }

    #[test]
    fn reconcile_claude_run_dir_repairs_legacy_files_on_disk() {
        let agent_id = AgentId::from_uuid(Uuid::from_u128(88));
        let temp = std::env::temp_dir().join(format!("idea-run-migrate-{}", Uuid::new_v4()));
        let project_root = temp.join("project");
        let run_dir = project_root.join(".ideai/run").join(agent_id.to_string());
        std::fs::create_dir_all(run_dir.join(".claude")).unwrap();
        std::fs::write(
            run_dir.join(".claude/settings.local.json"),
            r#"{"permissions":{"additionalDirectories":["/tmp/old"]}}"#,
        )
        .unwrap();
        std::fs::write(
            run_dir.join(".mcp.json"),
            r#"{"mcpServers":{"idea":{"command":"idea","args":["mcp-server"],"transport":"stdio"}}}"#,
        )
        .unwrap();

        let project = domain::Project::new(
            ProjectId::from_uuid(Uuid::from_u128(1234)),
            "demo",
            domain::project::ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(),
            domain::remote::RemoteRef::local(),
            1,
        )
        .unwrap();
        let profile = claude_profile();
        tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap()
            .block_on(async {
                migrate_claude_run_dir(&project, &agent_id, &profile)
                    .await
                    .unwrap();
            });

        let settings: serde_json::Value = serde_json::from_str(
            &std::fs::read_to_string(run_dir.join(".claude/settings.local.json")).unwrap(),
        )
        .unwrap();
        assert!(settings["enabledMcpjsonServers"]
            .as_array()
            .unwrap()
            .contains(&json!("idea")));
        assert!(settings["permissions"]["additionalDirectories"]
            .as_array()
            .unwrap()
            .contains(&json!(project.root.as_str())));

        let mcp: serde_json::Value =
            serde_json::from_str(&std::fs::read_to_string(run_dir.join(".mcp.json")).unwrap())
                .unwrap();
        let expected_runtime = crate::mcp_endpoint::AppMcpRuntimeProvider
            .runtime_for(&project, agent_id)
            .unwrap();
        assert_eq!(
            mcp["mcpServers"]["idea"],
            mcp_server_entry(&claude_profile(), Some(&expected_runtime))
        );

        let _ = std::fs::remove_dir_all(temp);
    }

    #[test]
    fn reconcile_codex_run_dir_preserves_permissions_and_repairs_mcp_trust() {
        let agent_id = AgentId::from_uuid(Uuid::from_u128(89));
        let temp = std::env::temp_dir().join(format!("idea-codex-run-migrate-{}", Uuid::new_v4()));
        let project_root = temp.join("project");
        let run_dir = project_root.join(".ideai/run").join(agent_id.to_string());
        std::fs::create_dir_all(run_dir.join(".codex")).unwrap();
        std::fs::write(
            run_dir.join(".codex/config.toml"),
            r#"approval_policy = "never"
sandbox_mode = "workspace-write"
user_key = "keep-me"

[mcp_servers.idea]
command = "stale"
args = ["mcp-server"]
transport = "stdio"

[features.code_mode]
direct_only_tool_namespaces = ["old"]

[features.preview]
enabled = true
"#,
        )
        .unwrap();

        let project = domain::Project::new(
            ProjectId::from_uuid(Uuid::from_u128(1234)),
            "demo",
            domain::project::ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(),
            domain::remote::RemoteRef::local(),
            1,
        )
        .unwrap();
        let profile = codex_profile();
        tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap()
            .block_on(async {
                migrate_codex_run_dir(&project, &agent_id, &profile)
                    .await
                    .unwrap();
            });

        let config = std::fs::read_to_string(run_dir.join(".codex/config.toml")).unwrap();
        assert!(config.contains(r#"approval_policy = "never""#));
        assert!(config.contains(r#"sandbox_mode = "workspace-write""#));
        assert!(config.contains(r#"user_key = "keep-me""#));
        assert!(config.contains("[mcp_servers.idea]"));
        assert!(config.contains(r#"default_tools_approval_mode = "approve""#));
        assert!(config.contains("tool_timeout_sec = 86400"));
        assert!(
            config.contains("[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]")
        );
        assert_eq!(config.matches("[features.code_mode]").count(), 1);
        assert!(!config.contains(r#"command = "stale""#));
        assert!(!config.contains(r#"direct_only_tool_namespaces = ["old"]"#));
        assert!(config.contains("[features.preview]\nenabled = true"));
        assert!(config.contains(&format!(r#"[projects."{}"]"#, run_dir.to_string_lossy())));
        assert!(config.contains(&format!(r#"[projects."{}"]"#, project.root.as_str())));

        let _ = std::fs::remove_dir_all(temp);
    }

    #[test]
    fn claude_profile_guard_matches_only_claude_mcp_profiles() {
        assert!(is_claude_mcp_profile(&claude_profile()));
    }

    #[test]
    fn invalid_settings_fall_back_to_seed() {
        let merged = merge_claude_settings_json("{not-json", "/home/me/proj").unwrap();
        assert_eq!(merged, claude_settings_seed_value("/home/me/proj"));
    }

    #[test]
    fn malformed_typed_settings_are_repaired_without_panicking() {
        let merged = merge_claude_settings_json(
            r#"{
  "permissions": [],
  "sandbox": false,
  "enabledMcpjsonServers": "idea"
}"#,
            "/home/me/proj",
        )
        .unwrap();

        assert_eq!(
            merged["permissions"]["defaultMode"],
            json!("bypassPermissions")
        );
        assert_eq!(merged["sandbox"]["enabled"], json!(false));
        assert_eq!(merged["enabledMcpjsonServers"], json!(["idea"]));
    }
}

/// Binds the project's loopback endpoint listener (M5a). Best-effort: returns the
/// bound [`LocalSocketListener`] or `None` if the bind fails (e.g. a stale socket
/// file from a crashed run). A `None` never figes open/close — the registry entry
/// is still created so the lifecycle stays idempotent; the real accept/serve (M5c)
/// will surface a hard failure if it actually needs the listener.
///
/// On Unix this binds a filesystem-path UDS under the per-user runtime dir; the
/// parent directory is created if missing, and a corpse socket from a previous run
/// is replaced (`reclaim_name` so the file is unlinked on drop). On Windows it binds
/// the named pipe — no filesystem entry to manage.
#[must_use]
fn bind_endpoint(endpoint: &McpEndpoint) -> Option<LocalSocketListener> {
    // Ensure the runtime dir exists (Unix path sockets need their parent dir).
    if let Some(path) = endpoint.socket_path() {
        if let Some(parent) = path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        // D1 — reclaim the **corpse** socket left by a SIGKILL'd run BEFORE binding.
        // `reclaim_name(true)` (below) only unlinks the socket on *drop*; in
        // `interprocess` 2.4 it does **not** clear a pre-existing inode at bind time,
        // so a stale socket file makes the bind fail with `EADDRINUSE` (the D1 test
        // proves this). We therefore unlink it ourselves first — but only when the
        // path is actually a **socket** (never clobber a real file we don't own).
        #[cfg(unix)]
        {
            use std::os::unix::fs::FileTypeExt;
            if let Ok(meta) = std::fs::symlink_metadata(&path) {
                if meta.file_type().is_socket() {
                    let _ = std::fs::remove_file(&path);
                }
            }
        }
    }
    let name = endpoint.as_cli_arg().to_fs_name::<GenericFilePath>().ok()?;
    ListenerOptions::new()
        .name(name)
        // Reclaim (unlink) on drop so a clean close leaves no socket behind.
        .reclaim_name(true)
        .create_tokio()
        .ok()
}

/// Drives one accepted loopback peer (= one `idea mcp-server` bridge = one agent):
/// reads its **handshake line**, then serves the rest of the stream as JSON-RPC.
///
/// Sequence (cadrage v5 §1.4, M5b handshake format):
/// 1. Split the duplex connection so reads and writes are independent.
/// 2. Read **one** newline-terminated handshake line
///    (`{"project":"…","requester":"…"}`) off a `BufReader` over the read half.
/// 3. If the handshake's `project` is present and **mismatches** this server's
///    project, close the connection (return) without serving — a defensive guard, it
///    logs nothing and never crashes the accept loop.
/// 4. Wrap the **same** `BufReader` (carrying any bytes already buffered past the
///    handshake) + the write half in a [`StdioTransport`] and hand it to
///    [`McpServer::serve_as`] tagged with the handshake's `requester` — so
///    `OrchestratorRequestProcessed.requester_id` becomes the real agent id.
///
/// Generic over the stream type so it never names the `interprocess` connection
/// type and stays unit-testable over an in-memory duplex.
async fn serve_peer<S>(server: Arc<McpServer>, expected_project: &str, conn: S)
where
    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static,
{
    let (read_half, write_half) = tokio::io::split(conn);
    let mut reader = BufReader::new(read_half);

    // 1 handshake line. A read error or immediate EOF ⇒ no peer to serve.
    let mut handshake = String::new();
    match reader.read_line(&mut handshake).await {
        Ok(0) | Err(_) => return,
        Ok(_) => {}
    }

    let (handshake_project, requester) = parse_handshake(&handshake);

    // Defensive: a bridge dialed the wrong project's endpoint. Drop it cleanly.
    if !handshake_project.is_empty()
        && !expected_project.is_empty()
        && handshake_project != expected_project
    {
        return;
    }

    // The same BufReader keeps any bytes already buffered past the handshake, so no
    // JSON-RPC line is lost between the handshake and the serve loop.
    let mut transport = StdioTransport::from_buffered(reader, write_half);
    server.serve_as(requester, &mut transport).await;
}

/// Parses the M5b handshake line into `(project, requester)`. Tolerant: a malformed
/// or partial line yields empty strings (⇒ legacy `"mcp"` requester, no project
/// guard), never an error — the serve loop must never crash on a bad handshake.
fn parse_handshake(line: &str) -> (String, String) {
    serde_json::from_str::<serde_json::Value>(line.trim())
        .ok()
        .map(|v| {
            let project = v
                .get("project")
                .and_then(serde_json::Value::as_str)
                .unwrap_or_default()
                .to_owned();
            let requester = v
                .get("requester")
                .and_then(serde_json::Value::as_str)
                .unwrap_or_default()
                .to_owned();
            (project, requester)
        })
        .unwrap_or_default()
}

/// Lifecycle handle for a per-project [`McpServer`] supervision task — the **twin**
/// of [`OrchestratorWatchHandle`](infrastructure::OrchestratorWatchHandle).
///
/// It carries the **same** stop mechanism as the watcher (a one-shot
/// `mpsc::Sender<()>`): [`stop`](Self::stop) signals the task to exit, and dropping
/// the handle stops it too (the channel closes).
///
/// ## Accept-and-serve-per-peer (M5c)
///
/// The supervision task runs an **`accept` loop** on the bound loopback `listener`,
/// arbitrated against the stop signal by `tokio::select!`:
/// - **`accept`** is async and parks on the absence of a peer, so the loop never
///   figes the open/close of a project — at project-open time no CLI is connected
///   yet (a peer only appears once an MCP-capable agent is launched, M5d).
/// - **each accepted connection** = one `idea mcp-server` bridge = one agent. The
///   task reads the **handshake line** (`{"project","requester"}`, cadrage v5 §1.4),
///   then spawns an isolated task running [`McpServer::serve_as`] over the rest of
///   the stream, so one peer's disconnection/error never affects the others or the
///   accept loop.
/// - **stop** breaks the loop, **aborts** the in-flight serve tasks (`JoinSet`),
///   and drops the listener — which on Unix unlinks the socket file via
///   interprocess' reclaim guard, so closing a project leaves no socket behind.
pub struct McpServerHandle {
    stop: tokio::sync::mpsc::Sender<()>,
    /// The loopback address this server listens on — the single source of truth
    /// ([`mcp_endpoint`]) shared with the CLI-declaration writer (M5d).
    endpoint: McpEndpoint,
}

impl McpServerHandle {
    /// Spawns the supervision task that owns `server` and the bound `listener`, runs
    /// the accept-and-serve-per-peer loop, and stops cleanly on signal. Must run
    /// inside the ambient Tokio runtime (async driving adapters satisfy this), exactly
    /// like [`FsOrchestratorWatcher::start`](infrastructure::FsOrchestratorWatcher).
    #[must_use]
    fn start(
        server: McpServer,
        endpoint: McpEndpoint,
        listener: Option<LocalSocketListener>,
        project_id: String,
    ) -> Self {
        let (stop_tx, mut stop_rx) = tokio::sync::mpsc::channel::<()>(1);
        // The base server is shared (Arc) so each accepted peer derives its own
        // requester-tagged clone (McpServer::serve_as) without contending.
        let server = Arc::new(server);
        tokio::spawn(async move {
            // No listener (bind failed, e.g. stale socket): nothing to accept. Park
            // on stop so the lifecycle stays idempotent and non-blocking.
            let Some(listener) = listener else {
                let _ = stop_rx.recv().await;
                return;
            };
            // In-flight per-peer serve tasks; aborted en masse on stop so no serve
            // outlives the project's close.
            let mut serves: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
            loop {
                tokio::select! {
                    // Stop requested (or the handle dropped, closing the channel).
                    _ = stop_rx.recv() => break,
                    accepted = listener.accept() => {
                        match accepted {
                            Ok(conn) => {
                                let server = Arc::clone(&server);
                                let expected_project = project_id.clone();
                                serves.spawn(async move {
                                    serve_peer(server, &expected_project, conn).await;
                                });
                            }
                            // A transient accept error must not kill the loop; the
                            // listener stays bound and keeps accepting the next peer.
                            Err(_) => continue,
                        }
                    }
                }
            }
            // Stop: terminate every in-flight serve, then drop the listener (unlinks
            // the Unix socket file). Pending peers die with their owning CLI anyway.
            serves.abort_all();
            drop(serves);
            drop(listener);
        });
        Self {
            stop: stop_tx,
            endpoint,
        }
    }

    /// The loopback endpoint this project's server is bound to (M5a source of truth).
    #[must_use]
    pub fn endpoint(&self) -> &McpEndpoint {
        &self.endpoint
    }

    /// Signals the supervision task to stop (best-effort; dropping the handle also
    /// stops it). Mirrors [`OrchestratorWatchHandle::stop`](infrastructure::OrchestratorWatchHandle::stop).
    pub fn stop(&self) {
        let _ = self.stop.try_send(());
    }
}

/// Build the project memory recall port from an embedder profile.
///
/// This is the only place that knows the concrete recall adapters (DIP): callers
/// only ever see `Arc<dyn MemoryRecall>`. With the default `none` profile,
/// `embedder_from_profile` returns `None` and we hand back the plain
/// `NaiveMemoryRecall` — strictly identical, dependency-free behaviour. When an
/// embedder is configured, we wrap naïve + vector recall in an
/// `AdaptiveMemoryRecall` that switches stages live per the profile strategy.
pub(crate) fn build_memory_recall(
    fs: Arc<dyn FileSystem>,
    store: Arc<dyn MemoryStore>,
    profile: &EmbedderProfile,
    onnx_cache_dir: &std::path::Path,
) -> Arc<dyn MemoryRecall> {
    let naive = Arc::new(NaiveMemoryRecall::new(Arc::clone(&store))) as Arc<dyn MemoryRecall>;
    match embedder_from_profile(profile, onnx_cache_dir) {
        None => naive,
        Some(embedder) => {
            let embedder: Arc<dyn Embedder> = Arc::from(embedder);
            let vector: Arc<dyn MemoryRecall> = Arc::new(VectorMemoryRecall::new(
                embedder,
                Arc::clone(&store),
                Arc::clone(&fs),
            ));
            Arc::new(AdaptiveMemoryRecall::new(
                naive,
                vector,
                Arc::clone(&store),
                profile.strategy,
            ))
        }
    }
}

#[cfg(test)]
mod background_tasks_b7_tests {
    use std::collections::HashMap;
    use std::path::PathBuf;
    use std::sync::{Arc, Mutex};

    use async_trait::async_trait;
    use domain::ports::{BackgroundTaskStore, ProjectStore, StoreError};
    use domain::{
        AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState,
        BackgroundTaskWakePolicy, Project, ProjectId, ProjectPath, RemoteRef, TaskId,
    };
    use tokio::sync::mpsc::unbounded_channel;
    use uuid::Uuid;

    use super::{AppBackgroundTaskStore, AppReconcileBackgroundTasks};

    struct TempDir(PathBuf);

    impl TempDir {
        fn new() -> Self {
            let path = std::env::temp_dir().join(format!("idea-b7-reconcile-{}", Uuid::new_v4()));
            std::fs::create_dir_all(&path).unwrap();
            Self(path)
        }

        fn project_path(&self) -> ProjectPath {
            ProjectPath::new(self.0.to_string_lossy().into_owned()).unwrap()
        }
    }

    impl Drop for TempDir {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }

    #[derive(Default)]
    struct FakeProjects {
        projects: Mutex<HashMap<ProjectId, Project>>,
    }

    impl FakeProjects {
        fn insert(&self, project: Project) {
            self.projects.lock().unwrap().insert(project.id, project);
        }
    }

    #[async_trait]
    impl ProjectStore for FakeProjects {
        async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
            Ok(self.projects.lock().unwrap().values().cloned().collect())
        }

        async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
            self.projects
                .lock()
                .unwrap()
                .get(&id)
                .cloned()
                .ok_or(StoreError::NotFound)
        }

        async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
            self.insert(project.clone());
            Ok(())
        }

        async fn save_workspace(&self, _workspace: &domain::Workspace) -> Result<(), StoreError> {
            Ok(())
        }

        async fn load_workspace(&self) -> Result<domain::Workspace, StoreError> {
            Ok(domain::Workspace::default())
        }
    }

    struct FixedClock(i64);

    impl domain::ports::Clock for FixedClock {
        fn now_millis(&self) -> i64 {
            self.0
        }
    }

    fn project_id(n: u128) -> ProjectId {
        ProjectId::from_uuid(Uuid::from_u128(n))
    }

    fn agent_id(n: u128) -> AgentId {
        AgentId::from_uuid(Uuid::from_u128(n))
    }

    fn task_id(n: u128) -> TaskId {
        TaskId::from_uuid(Uuid::from_u128(n))
    }

    fn project(id: ProjectId, root: ProjectPath) -> Project {
        Project::new(id, "demo", root, RemoteRef::local(), 1_000).unwrap()
    }

    fn completed_task(
        id: u128,
        project_id: ProjectId,
        owner: AgentId,
        wake_policy: BackgroundTaskWakePolicy,
    ) -> BackgroundTask {
        BackgroundTask::new(
            task_id(id),
            project_id,
            owner,
            BackgroundTaskKind::Command {
                label: format!("task-{id}"),
            },
            wake_policy,
            1_000,
            None,
        )
        .unwrap()
        .transition(BackgroundTaskState::Running, 1_010)
        .unwrap()
        .complete(BackgroundTaskResult::Success {
            finished_at_ms: 1_020,
            exit_code: Some(0),
            summary: "ok".to_owned(),
            stdout_tail: None,
            stderr_tail: None,
        })
        .unwrap()
    }

    fn running_task(id: u128, project_id: ProjectId, owner: AgentId) -> BackgroundTask {
        BackgroundTask::new(
            task_id(id),
            project_id,
            owner,
            BackgroundTaskKind::Command {
                label: format!("task-{id}"),
            },
            BackgroundTaskWakePolicy::WakeOwner,
            1_000,
            None,
        )
        .unwrap()
        .transition(BackgroundTaskState::Running, 1_010)
        .unwrap()
    }

    #[tokio::test]
    async fn app_reconcile_requeues_wake_owner_marks_record_only_and_repairs_orphan_running() {
        let tmp = TempDir::new();
        let project_id = project_id(10);
        let owner = agent_id(100);
        let projects = Arc::new(FakeProjects::default());
        projects.insert(project(project_id, tmp.project_path()));

        let store = Arc::new(AppBackgroundTaskStore::new(
            Arc::clone(&projects) as Arc<dyn ProjectStore>
        ));
        let wake_owner = completed_task(1, project_id, owner, BackgroundTaskWakePolicy::WakeOwner);
        let record_only =
            completed_task(2, project_id, owner, BackgroundTaskWakePolicy::RecordOnly);
        let orphan = running_task(3, project_id, owner);
        store.create(&wake_owner).await.unwrap();
        store.create(&record_only).await.unwrap();
        store.create(&orphan).await.unwrap();

        let (ready_tx, mut ready_rx) = unbounded_channel();
        let reconcile = AppReconcileBackgroundTasks {
            projects: Arc::clone(&projects) as Arc<dyn ProjectStore>,
            store: Arc::clone(&store),
            clock: Arc::new(FixedClock(2_000)),
            ready: ready_tx,
        };

        reconcile.execute(project_id).await.unwrap();

        let mut ready = Vec::new();
        while let Ok(item) = ready_rx.try_recv() {
            ready.push(item.task_id);
        }
        assert_eq!(ready, vec![wake_owner.id, orphan.id]);

        let wake_owner_after = store.get(wake_owner.id).await.unwrap().unwrap();
        assert!(!wake_owner_after.completion_delivered);

        let record_only_after = store.get(record_only.id).await.unwrap().unwrap();
        assert!(record_only_after.completion_delivered);

        let orphan_after = store.get(orphan.id).await.unwrap().unwrap();
        assert_eq!(orphan_after.state, BackgroundTaskState::Failed);
        assert!(orphan_after.has_pending_completion_delivery());
        assert!(matches!(
            orphan_after.result,
            Some(BackgroundTaskResult::Failure {
                finished_at_ms: 2_000,
                ..
            })
        ));
    }
}

#[cfg(test)]
mod mcp_serve_peer_tests {
    //! M5c — server side of the bind transport: [`serve_peer`] + [`parse_handshake`].
    //!
    //! These drive the **private** `serve_peer` free function over an in-memory
    //! `tokio::io::duplex` (no socket, no child process), the test seam the prod
    //! code was made generic for (`serve_peer<S>` over any `AsyncRead+AsyncWrite`).
    //! The `OrchestratorService` is wired over the **same** in-memory fakes the
    //! infrastructure MCP tests use (`infrastructure/tests/mcp_server.rs`), so MCP
    //! behaviour is asserted against a real service with zero I/O.
    //!
    //! GARDE-FOU : every `await` that could block on a peer that never speaks is
    //! bounded by `tokio::time::timeout`; a hung accept/serve fails fast instead of
    //! hanging the suite.

    use std::collections::HashMap;
    use std::sync::{Arc, Mutex};
    use std::time::Duration;

    use async_trait::async_trait;
    use tokio::io::{AsyncReadExt, AsyncWriteExt};

    use application::{
        CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
        OrchestratorService, TerminalSessions, UpdateAgentContext,
    };
    use domain::agent::{AgentManifest, ManifestEntry};
    use domain::events::{DomainEvent, OrchestrationSource};
    use domain::ids::{AgentId, ProfileId, ProjectId, SkillId};
    use domain::markdown::MarkdownDoc;
    use domain::ports::{
        AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
        ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
        PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
        StoreError,
    };
    use domain::profile::{
        AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
        StructuredAdapter,
    };
    use domain::project::{Project, ProjectPath};
    use domain::remote::RemoteRef;
    use domain::skill::{Skill, SkillScope};
    use domain::{PtySize, SessionId};
    use serde_json::{json, Value};
    use uuid::Uuid;

    use super::serve_peer;
    use infrastructure::McpServer;

    /// Test timeout for any single peer interaction. Generous but finite: a correct
    /// duplex round-trip is sub-millisecond, so this only ever fires on a real hang.
    const TIMEOUT: Duration = Duration::from_secs(5);

    // -----------------------------------------------------------------------
    // Fakes — mirrored from `infrastructure/tests/mcp_server.rs` (the established
    // MCP harness), trimmed to exactly what `OrchestratorService::new` needs.
    // -----------------------------------------------------------------------

    #[derive(Default)]
    struct ContextsInner {
        manifest: AgentManifest,
        contents: HashMap<String, String>,
    }
    #[derive(Clone)]
    struct FakeContexts(Arc<Mutex<ContextsInner>>);
    impl FakeContexts {
        fn new() -> Self {
            Self(Arc::new(Mutex::new(ContextsInner {
                manifest: AgentManifest {
                    version: 1,
                    entries: Vec::new(),
                    orchestrator: None,
                },
                contents: HashMap::new(),
            })))
        }
        fn seed_agent(&self, name: &str) -> AgentId {
            let id = AgentId::from_uuid(Uuid::new_v4());
            let mut inner = self.0.lock().unwrap();
            inner.manifest.entries.push(ManifestEntry {
                agent_id: id,
                name: name.to_owned(),
                md_path: format!("agents/{name}.md"),
                profile_id: ProfileId::from_uuid(Uuid::from_u128(9)),
                template_id: None,
                synchronized: false,
                synced_template_version: None,
                skills: Vec::new(),
                effort: None,
            });
            id
        }
        fn md_path_of(&self, agent: &AgentId) -> Option<String> {
            self.0
                .lock()
                .unwrap()
                .manifest
                .entries
                .iter()
                .find(|e| &e.agent_id == agent)
                .map(|e| e.md_path.clone())
        }
    }
    #[async_trait]
    impl AgentContextStore for FakeContexts {
        async fn read_context(
            &self,
            _project: &Project,
            agent: &AgentId,
        ) -> Result<MarkdownDoc, StoreError> {
            let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
            Ok(MarkdownDoc::new(
                self.0
                    .lock()
                    .unwrap()
                    .contents
                    .get(&md)
                    .cloned()
                    .unwrap_or_default(),
            ))
        }
        async fn write_context(
            &self,
            _project: &Project,
            agent: &AgentId,
            md: &MarkdownDoc,
        ) -> Result<(), StoreError> {
            let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
            self.0
                .lock()
                .unwrap()
                .contents
                .insert(path, md.as_str().to_owned());
            Ok(())
        }
        async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
            Ok(self.0.lock().unwrap().manifest.clone())
        }
        async fn save_manifest(
            &self,
            _project: &Project,
            manifest: &AgentManifest,
        ) -> Result<(), StoreError> {
            self.0.lock().unwrap().manifest = manifest.clone();
            Ok(())
        }
    }

    #[derive(Clone)]
    struct FakeProfiles(Arc<Vec<AgentProfile>>);
    #[async_trait]
    impl ProfileStore for FakeProfiles {
        async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
            Ok((*self.0).clone())
        }
        async fn save(&self, _p: &AgentProfile) -> Result<(), StoreError> {
            Ok(())
        }
        async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
            Ok(())
        }
        async fn is_configured(&self) -> Result<bool, StoreError> {
            Ok(true)
        }
        async fn mark_configured(&self) -> Result<(), StoreError> {
            Ok(())
        }
    }

    #[derive(Default)]
    struct FakeSkills;
    #[async_trait]
    impl SkillStore for FakeSkills {
        async fn list(
            &self,
            _scope: SkillScope,
            _root: &ProjectPath,
        ) -> Result<Vec<Skill>, StoreError> {
            Ok(Vec::new())
        }
        async fn get(
            &self,
            _scope: SkillScope,
            _root: &ProjectPath,
            _id: SkillId,
        ) -> Result<Skill, StoreError> {
            Err(StoreError::NotFound)
        }
        async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
            Ok(())
        }
        async fn delete(
            &self,
            _scope: SkillScope,
            _root: &ProjectPath,
            _id: SkillId,
        ) -> Result<(), StoreError> {
            Ok(())
        }
    }

    #[derive(Default)]
    struct FakeRecall;
    #[async_trait]
    impl domain::ports::MemoryRecall for FakeRecall {
        async fn recall(
            &self,
            _root: &ProjectPath,
            _query: &domain::ports::MemoryQuery,
        ) -> Result<Vec<domain::MemoryIndexEntry>, domain::ports::MemoryError> {
            Ok(Vec::new())
        }
    }

    struct FakeRuntime;
    #[async_trait]
    impl AgentRuntime for FakeRuntime {
        async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
            Ok(true)
        }
        fn prepare_invocation(
            &self,
            profile: &AgentProfile,
            _ctx: &PreparedContext,
            cwd: &ProjectPath,
            _session: &SessionPlan,
        ) -> Result<SpawnSpec, RuntimeError> {
            Ok(SpawnSpec {
                command: profile.command.clone(),
                args: profile.args.clone(),
                cwd: cwd.clone(),
                env: Vec::new(),
                context_plan: Some(ContextInjectionPlan::Stdin),
                sandbox: None,
            })
        }
    }

    #[derive(Clone, Default)]
    struct FakeFs;
    #[async_trait]
    impl FileSystem for FakeFs {
        async fn read(&self, p: &RemotePath) -> Result<Vec<u8>, FsError> {
            Err(FsError::NotFound(p.as_str().to_owned()))
        }
        async fn write(&self, _p: &RemotePath, _d: &[u8]) -> Result<(), FsError> {
            Ok(())
        }
        async fn exists(&self, _p: &RemotePath) -> Result<bool, FsError> {
            Ok(false)
        }
        async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> {
            Ok(())
        }
        async fn list(&self, _p: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
            Ok(Vec::new())
        }
        async fn symlink(&self, _s: &RemotePath, _d: &RemotePath) -> Result<(), FsError> {
            Ok(())
        }
    }

    #[derive(Clone)]
    struct FakePty;
    #[async_trait]
    impl PtyPort for FakePty {
        async fn spawn(&self, _s: SpawnSpec, _z: PtySize) -> Result<PtyHandle, PtyError> {
            Ok(PtyHandle {
                session_id: SessionId::from_uuid(Uuid::from_u128(777)),
            })
        }
        fn write(&self, _h: &PtyHandle, _d: &[u8]) -> Result<(), PtyError> {
            Ok(())
        }
        fn resize(&self, _h: &PtyHandle, _z: PtySize) -> Result<(), PtyError> {
            Ok(())
        }
        fn subscribe_output(&self, _h: &PtyHandle) -> Result<OutputStream, PtyError> {
            Ok(Box::new(std::iter::empty()))
        }
        fn scrollback(&self, _h: &PtyHandle) -> Result<Vec<u8>, PtyError> {
            Ok(Vec::new())
        }
        async fn wait(&self, _h: &PtyHandle) -> Result<ExitStatus, PtyError> {
            Ok(ExitStatus { code: Some(0) })
        }
        fn try_wait(&self, _h: &PtyHandle) -> Result<Option<ExitStatus>, PtyError> {
            Ok(Some(ExitStatus { code: Some(0) }))
        }
        async fn kill(&self, _h: &PtyHandle) -> Result<ExitStatus, PtyError> {
            Ok(ExitStatus { code: Some(0) })
        }
    }

    #[derive(Default, Clone)]
    struct NoopBus;
    impl EventBus for NoopBus {
        fn publish(&self, _e: DomainEvent) {}
        fn subscribe(&self) -> EventStream {
            Box::new(std::iter::empty())
        }
    }

    struct SeqIds(Mutex<u128>);
    impl IdGenerator for SeqIds {
        fn new_uuid(&self) -> Uuid {
            let mut n = self.0.lock().unwrap();
            let id = Uuid::from_u128(*n);
            *n += 1;
            id
        }
    }

    fn project() -> Project {
        Project::new(
            ProjectId::from_uuid(Uuid::from_u128(1000)),
            "demo",
            ProjectPath::new("/home/me/proj").unwrap(),
            RemoteRef::local(),
            1_700_000_000_000,
        )
        .unwrap()
    }

    /// The hyphen-free hex project-id the handshake guard compares against — the
    /// exact form `ensure_mcp_server` derives and M5d's `--project` reuses.
    fn project_id_arg(p: &Project) -> String {
        p.id.as_uuid().simple().to_string()
    }

    /// A capturing event sink (the MCP twin of the file watcher's publish closure):
    /// records every [`DomainEvent`] so a test can assert `requester_id`.
    fn capturing_events() -> (
        Arc<dyn Fn(DomainEvent) + Send + Sync>,
        Arc<Mutex<Vec<DomainEvent>>>,
    ) {
        let captured = Arc::new(Mutex::new(Vec::new()));
        let sink = captured.clone();
        let publish: Arc<dyn Fn(DomainEvent) + Send + Sync> =
            Arc::new(move |e: DomainEvent| sink.lock().unwrap().push(e));
        (publish, captured)
    }

    /// Builds an `OrchestratorService` over the in-memory fakes (no structured
    /// registry), mirroring `infrastructure/tests/mcp_server.rs::build_service`.
    fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
        // Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) :
        // seul profil que la garde F2 (`guard_mcp_bridge_supported`) laisse passer pour
        // `idea_ask_agent`, car seul Claude consomme réellement le pont `.mcp.json`.
        let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
            ProfileId::from_uuid(Uuid::from_u128(9)),
            "Claude Code",
            "claude",
            Vec::new(),
            ContextInjection::stdin(),
            None,
            "{agentRunDir}",
            None,
        )
        .unwrap()
        .with_structured_adapter(StructuredAdapter::Claude)
        .with_mcp(McpCapability::new(
            McpConfigStrategy::config_file(".mcp.json").unwrap(),
            McpTransport::Stdio,
        ))])));
        let sessions = Arc::new(TerminalSessions::new());
        let bus = Arc::new(NoopBus);
        let create = Arc::new(CreateAgentFromScratch::new(
            Arc::new(contexts.clone()),
            Arc::new(SeqIds(Mutex::new(1))),
            bus.clone(),
        ));
        let launch = Arc::new(LaunchAgent::new(
            Arc::new(contexts.clone()),
            Arc::clone(&profiles) as Arc<dyn ProfileStore>,
            Arc::new(FakeRuntime),
            Arc::new(FakeFs),
            Arc::new(FakePty),
            Arc::new(FakeSkills),
            Arc::clone(&sessions),
            bus.clone(),
            Arc::new(SeqIds(Mutex::new(1))),
            Arc::new(FakeRecall),
            None,
        ));
        let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
        let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
        let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts)));
        let create_skill = Arc::new(CreateSkill::new(
            Arc::new(FakeSkills) as Arc<dyn SkillStore>,
            Arc::new(SeqIds(Mutex::new(1))),
        ));
        Arc::new(OrchestratorService::new(
            create,
            launch,
            list,
            close,
            update,
            create_skill,
            Arc::clone(&profiles) as Arc<dyn ProfileStore>,
            Arc::clone(&sessions),
        ))
    }

    // --- duplex client helpers ---------------------------------------------

    /// Frames a `tools/call` request line (newline-terminated, as the transport
    /// expects per `StdioTransport::recv`).
    fn tools_call_line(id: i64, tool: &str, arguments: Value) -> String {
        let mut s = serde_json::to_string(&json!({
            "jsonrpc": "2.0",
            "id": id,
            "method": "tools/call",
            "params": { "name": tool, "arguments": arguments }
        }))
        .unwrap();
        s.push('\n');
        s
    }

    /// A `tools/list` request line.
    fn tools_list_line(id: i64) -> String {
        let mut s = serde_json::to_string(&json!({
            "jsonrpc": "2.0", "id": id, "method": "tools/list"
        }))
        .unwrap();
        s.push('\n');
        s
    }

    /// A handshake line `{"project":..,"requester":..}` followed by `\n`.
    fn handshake_line(project: &str, requester: &str) -> String {
        format!("{{\"project\":\"{project}\",\"requester\":\"{requester}\"}}\n")
    }

    /// Reads exactly one newline-delimited JSON-RPC response off the client side of
    /// the duplex, bounded by [`TIMEOUT`]. Returns `None` on EOF/timeout (the peer
    /// closed without replying — used by the project-guard test).
    async fn read_one_response<R>(client: &mut R) -> Option<Value>
    where
        R: tokio::io::AsyncRead + Unpin,
    {
        let mut buf = Vec::new();
        let mut byte = [0u8; 1];
        loop {
            match tokio::time::timeout(TIMEOUT, client.read(&mut byte)).await {
                Ok(Ok(0)) => return None, // EOF before a full line
                Ok(Ok(_)) => {
                    if byte[0] == b'\n' {
                        break;
                    }
                    buf.push(byte[0]);
                }
                Ok(Err(_)) => return None,
                Err(_) => return None, // GARDE-FOU: timed out waiting for a reply
            }
        }
        serde_json::from_slice(&buf).ok()
    }

    /// Spawns `serve_peer` over the server half of a fresh duplex and returns the
    /// client half plus the join handle. The peer is bounded by the test's reads.
    fn spawn_peer(
        server: Arc<McpServer>,
        expected_project: String,
    ) -> (tokio::io::DuplexStream, tokio::task::JoinHandle<()>) {
        let (client, server_side) = tokio::io::duplex(64 * 1024);
        let handle = tokio::spawn(async move {
            serve_peer(server, &expected_project, server_side).await;
        });
        (client, handle)
    }

    // -----------------------------------------------------------------------
    // 1. Handshake + tools/list end-to-end over the duplex.
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn handshake_then_tools_list_round_trips_over_duplex() {
        let proj = project();
        let service = build_service(FakeContexts::new());
        let server = Arc::new(McpServer::new(service, proj.clone()));
        let (mut client, peer) = spawn_peer(server, project_id_arg(&proj));

        // Write the handshake line, then a tools/list request.
        client
            .write_all(handshake_line(&project_id_arg(&proj), "agent-1").as_bytes())
            .await
            .unwrap();
        client
            .write_all(tools_list_line(1).as_bytes())
            .await
            .unwrap();
        client.flush().await.unwrap();

        let resp = tokio::time::timeout(TIMEOUT, read_one_response(&mut client))
            .await
            .expect("GARDE-FOU: tools/list timed out")
            .expect("a tools/list response line");

        assert_eq!(resp["id"], json!(1));
        let tools = resp["result"]["tools"].as_array().expect("tools array");
        let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
        let expected_names: Vec<&str> = infrastructure::orchestrator::mcp::catalogue()
            .into_iter()
            .map(|tool| tool.name)
            .collect();
        assert!(!names.contains(&"idea_reply"));
        assert_eq!(
            names, expected_names,
            "tools/list must expose exactly the canonical MCP catalogue"
        );

        drop(client); // EOF ⇒ serve loop ends
        tokio::time::timeout(TIMEOUT, peer)
            .await
            .expect("GARDE-FOU: peer task did not finish")
            .unwrap();
    }

    // -----------------------------------------------------------------------
    // 2. The handshake's requester is propagated to the processed event.
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn handshake_requester_propagates_to_processed_event() {
        let proj = project();
        let contexts = FakeContexts::new();
        contexts.seed_agent("architect");
        let service = build_service(contexts);
        let (publish, captured) = capturing_events();
        let server = Arc::new(McpServer::new(service, proj.clone()).with_events(publish));
        let (mut client, peer) = spawn_peer(server, project_id_arg(&proj));

        client
            .write_all(handshake_line(&project_id_arg(&proj), "agent-42").as_bytes())
            .await
            .unwrap();
        client
            .write_all(tools_call_line(1, "idea_list_agents", json!({})).as_bytes())
            .await
            .unwrap();
        client.flush().await.unwrap();

        let resp = read_one_response(&mut client)
            .await
            .expect("a tools/call response");
        assert_eq!(resp["result"]["isError"], json!(false), "got {resp}");

        drop(client);
        tokio::time::timeout(TIMEOUT, peer)
            .await
            .expect("GARDE-FOU: peer did not finish")
            .unwrap();

        let events = captured.lock().unwrap();
        let processed: Vec<&DomainEvent> = events
            .iter()
            .filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. }))
            .collect();
        assert_eq!(
            processed.len(),
            1,
            "exactly one processed event; got {events:?}"
        );
        match processed[0] {
            DomainEvent::OrchestratorRequestProcessed {
                requester_id,
                action,
                source,
                ..
            } => {
                assert_eq!(
                    requester_id, "agent-42",
                    "the real handshake requester must be propagated (not 'mcp')"
                );
                assert_eq!(action, "idea_list_agents");
                assert_eq!(*source, OrchestrationSource::Mcp);
            }
            other => panic!("expected OrchestratorRequestProcessed, got {other:?}"),
        }
    }

    // -----------------------------------------------------------------------
    // 3. Empty requester in the handshake ⇒ legacy "mcp" label (back-compat).
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn empty_requester_handshake_falls_back_to_legacy_mcp_label() {
        let proj = project();
        let contexts = FakeContexts::new();
        contexts.seed_agent("architect");
        let service = build_service(contexts);
        let (publish, captured) = capturing_events();
        let server = Arc::new(McpServer::new(service, proj.clone()).with_events(publish));
        // Empty requester ("") in the handshake.
        let (mut client, peer) = spawn_peer(server, project_id_arg(&proj));

        client
            .write_all(handshake_line(&project_id_arg(&proj), "").as_bytes())
            .await
            .unwrap();
        client
            .write_all(tools_call_line(1, "idea_list_agents", json!({})).as_bytes())
            .await
            .unwrap();
        client.flush().await.unwrap();

        let _ = read_one_response(&mut client).await.expect("a response");
        drop(client);
        tokio::time::timeout(TIMEOUT, peer)
            .await
            .expect("GARDE-FOU: peer did not finish")
            .unwrap();

        let events = captured.lock().unwrap();
        let processed = events
            .iter()
            .find_map(|e| match e {
                DomainEvent::OrchestratorRequestProcessed { requester_id, .. } => {
                    Some(requester_id.clone())
                }
                _ => None,
            })
            .expect("a processed event");
        assert_eq!(
            processed, "mcp",
            "empty handshake requester must keep the legacy 'mcp' label"
        );
    }

    // -----------------------------------------------------------------------
    // 4. JSON-RPC bytes glued onto the handshake's write are not lost
    //    (proves `StdioTransport::from_buffered` preserves buffered bytes).
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn jsonrpc_request_glued_to_handshake_is_served() {
        let proj = project();
        let service = build_service(FakeContexts::new());
        let server = Arc::new(McpServer::new(service, proj.clone()));
        let (mut client, peer) = spawn_peer(server, project_id_arg(&proj));

        // ONE write carrying the handshake line AND the tools/list line, back to back.
        let mut glued = handshake_line(&project_id_arg(&proj), "agent-1");
        glued.push_str(&tools_list_line(7));
        client.write_all(glued.as_bytes()).await.unwrap();
        client.flush().await.unwrap();

        let resp = read_one_response(&mut client)
            .await
            .expect("the glued tools/list must still be served");
        assert_eq!(
            resp["id"],
            json!(7),
            "the buffered request id must come through"
        );
        assert!(
            resp["result"]["tools"].is_array(),
            "buffered request produced a real tools/list result; got {resp}"
        );

        drop(client);
        tokio::time::timeout(TIMEOUT, peer)
            .await
            .expect("GARDE-FOU: peer did not finish")
            .unwrap();
    }

    // -----------------------------------------------------------------------
    // 5a. Project guard: a mismatching handshake project ⇒ closed without serving.
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn mismatched_project_handshake_is_closed_without_serving() {
        let proj = project();
        let contexts = FakeContexts::new();
        contexts.seed_agent("architect");
        let service = build_service(contexts);
        let (publish, captured) = capturing_events();
        let server = Arc::new(McpServer::new(service, proj.clone()).with_events(publish));
        // serve_peer is told to expect this project's id...
        let (mut client, peer) = spawn_peer(server, project_id_arg(&proj));

        // ...but the handshake claims a DIFFERENT project.
        client
            .write_all(handshake_line("ffffffffffffffffffffffffffffffff", "intruder").as_bytes())
            .await
            .unwrap();
        client
            .write_all(tools_call_line(1, "idea_list_agents", json!({})).as_bytes())
            .await
            .unwrap();
        client.flush().await.unwrap();

        // No reply must come: the peer returned before serving. read returns EOF.
        let resp = read_one_response(&mut client).await;
        assert!(
            resp.is_none(),
            "mismatched project must be closed WITHOUT a response; got {resp:?}"
        );

        tokio::time::timeout(TIMEOUT, peer)
            .await
            .expect("GARDE-FOU: peer did not finish")
            .unwrap();

        // And nothing was dispatched ⇒ no processed event.
        let events = captured.lock().unwrap();
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. })),
            "a rejected peer must not dispatch anything; got {events:?}"
        );
    }

    // -----------------------------------------------------------------------
    // 5b. Empty handshake project ⇒ served normally (guard does not reject).
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn empty_handshake_project_is_served_normally() {
        let proj = project();
        let service = build_service(FakeContexts::new());
        let server = Arc::new(McpServer::new(service, proj.clone()));
        let (mut client, peer) = spawn_peer(server, project_id_arg(&proj));

        // Empty project in the handshake — the guard must NOT reject it.
        client
            .write_all(handshake_line("", "agent-1").as_bytes())
            .await
            .unwrap();
        client
            .write_all(tools_list_line(1).as_bytes())
            .await
            .unwrap();
        client.flush().await.unwrap();

        let resp = read_one_response(&mut client)
            .await
            .expect("empty-project handshake must still be served");
        assert!(resp["result"]["tools"].is_array(), "got {resp}");

        drop(client);
        tokio::time::timeout(TIMEOUT, peer)
            .await
            .expect("GARDE-FOU: peer did not finish")
            .unwrap();
    }

    // -----------------------------------------------------------------------
    // 6. Peer isolation: one peer closing/erroring does not stop another.
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn one_peer_failure_does_not_affect_a_concurrent_peer() {
        let proj = project();
        let service = build_service(FakeContexts::new());
        let server = Arc::new(McpServer::new(service, proj.clone()));

        // Peer A: a broken peer that immediately closes after a partial handshake
        // (no newline) — its serve_peer must end without crashing the runtime.
        let (mut client_a, peer_a) = spawn_peer(Arc::clone(&server), project_id_arg(&proj));
        client_a.write_all(b"{\"project\":").await.unwrap(); // partial, no newline
        client_a.flush().await.unwrap();
        drop(client_a); // abrupt close mid-handshake

        // Peer B: a healthy peer, served concurrently, must still get its reply.
        let (mut client_b, peer_b) = spawn_peer(Arc::clone(&server), project_id_arg(&proj));
        client_b
            .write_all(handshake_line(&project_id_arg(&proj), "agent-b").as_bytes())
            .await
            .unwrap();
        client_b
            .write_all(tools_list_line(2).as_bytes())
            .await
            .unwrap();
        client_b.flush().await.unwrap();

        let resp = read_one_response(&mut client_b)
            .await
            .expect("healthy peer B must be served despite peer A failing");
        assert_eq!(resp["id"], json!(2));
        assert!(resp["result"]["tools"].is_array(), "got {resp}");

        drop(client_b);
        // Both peer tasks must terminate cleanly within the bound.
        tokio::time::timeout(TIMEOUT, peer_a)
            .await
            .expect("GARDE-FOU: peer A did not finish")
            .unwrap();
        tokio::time::timeout(TIMEOUT, peer_b)
            .await
            .expect("GARDE-FOU: peer B did not finish")
            .unwrap();
    }

    // -----------------------------------------------------------------------
    // Bonus — parse_handshake unit behaviour (tolerant parsing contract).
    // -----------------------------------------------------------------------

    #[test]
    fn parse_handshake_is_tolerant() {
        use super::parse_handshake;
        // Well-formed.
        assert_eq!(
            parse_handshake("{\"project\":\"p1\",\"requester\":\"a1\"}\n"),
            ("p1".to_owned(), "a1".to_owned())
        );
        // Malformed JSON ⇒ empty strings, never a panic.
        assert_eq!(parse_handshake("{not json"), (String::new(), String::new()));
        // Missing fields ⇒ empty strings.
        assert_eq!(parse_handshake("{}"), (String::new(), String::new()));
    }

    // -----------------------------------------------------------------------
    // C7 — câblage du FileGuard context/memory au composition root.
    //
    // Régression visée (fix `fix/wire-context-guard`) : si quelqu'un oublie à
    // nouveau `.with_context_guard(...)`, `require_context_guard()` retombe sur
    // `None` et toute commande `context.*`/`memory.*` échoue avec
    // `AppError::Invalid("FileGuard context/memory tools are not configured")`.
    // Ces tests prouvent que, branché comme dans `App::build`, le service
    // traite ces commandes sans cette erreur — et le réfute sans le câblage.
    // -----------------------------------------------------------------------

    use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
    use application::{ContextGuardUseCases, ProposeContext, ReadContext, ReadMemory, WriteMemory};
    use domain::conversation::ConversationParty;
    use domain::memory::{
        Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
    };
    use domain::ports::{Clock, MemoryError, MemoryStore};
    use domain::OrchestratorCommand;
    use infrastructure::RwFileGuard;

    /// In-memory [`MemoryStore`] (slug → body), the lightest fake that lets the
    /// `memory.write` → `memory.read` round-trip exercise the real use cases
    /// behind the shared guard. Mirrors the one in `context_guard.rs`'s tests.
    #[derive(Default)]
    struct FakeMemory {
        notes: Mutex<HashMap<String, String>>,
    }
    #[async_trait]
    impl MemoryStore for FakeMemory {
        async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
            Ok(Vec::new())
        }
        async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
            let body = self
                .notes
                .lock()
                .unwrap()
                .get(slug.as_str())
                .cloned()
                .ok_or(MemoryError::NotFound)?;
            Memory::new(
                MemoryFrontmatter {
                    name: slug.clone(),
                    description: "d".to_owned(),
                    r#type: MemoryType::Project,
                },
                MarkdownDoc::new(body),
            )
            .map_err(|e| MemoryError::Frontmatter(e.to_string()))
        }
        async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
            self.notes
                .lock()
                .unwrap()
                .insert(memory.slug().to_string(), memory.body.as_str().to_owned());
            Ok(())
        }
        async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
            Ok(())
        }
        async fn read_index(
            &self,
            _root: &ProjectPath,
        ) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
            Ok(Vec::new())
        }
        async fn resolve_links(
            &self,
            _root: &ProjectPath,
            _slug: &MemorySlug,
        ) -> Result<Vec<MemoryLink>, MemoryError> {
            Ok(Vec::new())
        }
    }

    /// Fixed millis clock — `ProposeContext` needs a [`Clock`], unused on the
    /// paths these tests drive.
    struct FixedClock;
    impl Clock for FixedClock {
        fn now_millis(&self) -> i64 {
            1_700_000_000_000
        }
    }

    /// Builds the **same wiring as `App::build`** (state.rs:1070-1129): one
    /// shared `RwFileGuard` cast once, cloned into the four C7 use cases, handed
    /// to the service via `.with_context_guard(...)`. Returns the service plus
    /// the shared `FakeMemory` so a test can assert the persisted note.
    fn build_service_with_guard(
        contexts: FakeContexts,
    ) -> (Arc<OrchestratorService>, Arc<FakeMemory>) {
        let memory = Arc::new(FakeMemory::default());
        let file_guard = Arc::new(RwFileGuard::new()) as Arc<dyn domain::fileguard::FileGuard>;
        let context_guard = Arc::new(ContextGuardUseCases {
            read_context: Arc::new(ReadContext::new(
                Arc::clone(&file_guard),
                Arc::new(contexts.clone()),
                Arc::new(FakeFs),
            )),
            propose_context: Arc::new(ProposeContext::new(
                Arc::clone(&file_guard),
                Arc::new(contexts.clone()),
                Arc::new(FakeFs),
                Arc::new(FixedClock),
            )),
            update_project_context: Arc::new(GuardedUpdateProjectContext::new(
                Arc::clone(&file_guard),
                Arc::new(contexts.clone()),
                Arc::new(FakeFs),
                Arc::new(NoopBus),
                Arc::new(FixedClock),
            )),
            read_memory: Arc::new(ReadMemory::new(
                Arc::clone(&file_guard),
                Arc::clone(&memory) as Arc<dyn MemoryStore>,
            )),
            write_memory: Arc::new(WriteMemory::new(
                Arc::clone(&file_guard),
                Arc::clone(&memory) as Arc<dyn MemoryStore>,
            )),
        });
        // Rewrap `build_service`'s service with the guard. `build_service`
        // already produces a fully-wired `OrchestratorService` over the same
        // fakes; `.with_context_guard` is additive, exactly the prod builder.
        let service = build_service(contexts);
        let service = Arc::try_unwrap(service)
            .map_err(|_| ())
            .expect("freshly built service is uniquely owned")
            .with_context_guard(context_guard);
        (Arc::new(service), memory)
    }

    /// The party an MCP agent presents to the guard — an agent (not the
    /// orchestrator), the realistic caller of `idea_memory_*`/`idea_context_*`.
    fn agent_party(n: u128) -> ConversationParty {
        ConversationParty::agent(AgentId::from_uuid(Uuid::from_u128(n)))
    }

    // -----------------------------------------------------------------------
    // LS4 — live-state round-trip through the wired service.
    // -----------------------------------------------------------------------

    /// In-memory [`LiveStateStore`] (keyed LWW + prune), no I/O — so the LS4 round-trip
    /// exercises the real `UpdateLiveState`/`GetLiveStateLean` use cases behind the
    /// service's write/read providers without touching the filesystem.
    #[derive(Default)]
    struct InMemLiveStore {
        state: Mutex<domain::live_state::LiveState>,
    }
    #[async_trait]
    impl domain::ports::LiveStateStore for InMemLiveStore {
        async fn load(&self) -> Result<domain::live_state::LiveState, StoreError> {
            Ok(self.state.lock().unwrap().clone())
        }
        async fn upsert(&self, entry: domain::live_state::LiveEntry) -> Result<(), StoreError> {
            self.state.lock().unwrap().upsert(entry);
            Ok(())
        }
        async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> {
            self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n);
            Ok(())
        }
    }

    struct WsWriteProvider {
        update: Arc<application::UpdateLiveState>,
    }
    impl application::LiveStateProvider for WsWriteProvider {
        fn live_state_for(&self, _root: &ProjectPath) -> Option<Arc<application::UpdateLiveState>> {
            Some(Arc::clone(&self.update))
        }
    }
    struct WsReadProvider {
        getter: Arc<application::GetLiveStateLean>,
    }
    impl application::LiveStateReadProvider for WsReadProvider {
        fn live_state_lean_for(
            &self,
            _root: &ProjectPath,
        ) -> Option<Arc<application::GetLiveStateLean>> {
            Some(Arc::clone(&self.getter))
        }
    }

    /// Like [`build_service_with_guard`] but additively wires the LS4 write+read
    /// providers over a SHARED in-memory store, exactly the prod builder's
    /// `.with_live_state(...).with_live_state_read(...)`.
    fn build_service_with_live_state(contexts: FakeContexts) -> Arc<OrchestratorService> {
        let store = Arc::new(InMemLiveStore::default()) as Arc<dyn domain::ports::LiveStateStore>;
        let clock = Arc::new(FixedClock) as Arc<dyn Clock>;
        let write = Arc::new(WsWriteProvider {
            update: Arc::new(application::UpdateLiveState::new(
                Arc::clone(&store),
                Arc::clone(&clock),
            )),
        });
        let read = Arc::new(WsReadProvider {
            getter: Arc::new(application::GetLiveStateLean::new(
                Arc::clone(&store),
                Arc::clone(&clock),
            )),
        });
        let service = build_service(contexts);
        let service = Arc::try_unwrap(service)
            .map_err(|_| ())
            .expect("freshly built service is uniquely owned")
            .with_live_state(write as Arc<dyn application::LiveStateProvider>)
            .with_live_state_read(read as Arc<dyn application::LiveStateReadProvider>);
        Arc::new(service)
    }

    /// END-TO-END (lot LS4) — through the wired `OrchestratorService`,
    /// `idea_workstate_set` then `idea_workstate_read` round-trips: the current agent's
    /// row comes back with the declared status/intent and the **resolved display name**
    /// (via `ListAgents`), and `progress` never surfaces.
    #[tokio::test]
    async fn wired_serves_workstate_set_then_read_round_trip() {
        let proj = project();
        let contexts = FakeContexts::new();
        let agent = contexts.seed_agent("architect");
        let service = build_service_with_live_state(contexts);

        // set — keyed on the agent identity; a progress note is supplied (must not leak).
        let ack = service
            .dispatch(
                &proj,
                domain::OrchestratorCommand::SetWorkState {
                    agent,
                    status: domain::live_state::WorkStatus::Working,
                    intent: Some("ship LS4".to_owned()),
                    progress: Some("hidden note".to_owned()),
                    ticket: None,
                    last_delegation: None,
                },
            )
            .await
            .expect("set must succeed when live-state is wired");
        assert!(ack.reply.is_none(), "set is ACK only: {ack:?}");

        // read — over the SAME store, the architect's row comes back resolved.
        let out = service
            .dispatch(
                &proj,
                domain::OrchestratorCommand::ReadWorkState {
                    requester: ConversationParty::User,
                },
            )
            .await
            .expect("read must succeed when live-state is wired");
        let json: Value = serde_json::from_str(out.reply.as_deref().expect("read reply")).unwrap();
        let rows = json.as_array().expect("array");
        assert_eq!(rows.len(), 1, "exactly the current agent's row: {json}");
        assert_eq!(
            rows[0]["agent"],
            json!("architect"),
            "name resolved: {json}"
        );
        assert_eq!(rows[0]["status"], json!("working"));
        assert_eq!(rows[0]["intent"], json!("ship LS4"));
        assert!(
            rows[0].get("progress").is_none(),
            "progress must never surface on read: {json}"
        );
    }

    /// WIRING — with `.with_context_guard(...)`, `memory.write` then
    /// `memory.read` round-trips the content instead of erroring "not
    /// configured". Proves the four use cases reached the dispatch.
    #[tokio::test]
    async fn wired_guard_serves_memory_read_write_round_trip() {
        let proj = project();
        let (service, _memory) = build_service_with_guard(FakeContexts::new());

        // memory.write — must not return the "not configured" sentinel.
        let write = service
            .dispatch(
                &proj,
                OrchestratorCommand::WriteMemory {
                    slug: "wiring-note".to_owned(),
                    content: "guard is wired".to_owned(),
                    requester: agent_party(1),
                },
            )
            .await
            .expect("memory.write must succeed when the guard is wired");
        assert_eq!(write.detail, "wrote memory wiring-note");

        // memory.read on the SAME shared guard/store — returns the body.
        let read = service
            .dispatch(
                &proj,
                OrchestratorCommand::ReadMemory {
                    slug: Some("wiring-note".to_owned()),
                    requester: agent_party(2),
                },
            )
            .await
            .expect("memory.read must succeed when the guard is wired");
        assert_eq!(read.reply.as_deref(), Some("guard is wired"));
    }

    /// WIRING — `context.read` on a seeded agent target returns its `.md` body
    /// (not the "not configured" error), exercising `ReadContext` end-to-end.
    #[tokio::test]
    async fn wired_guard_serves_context_read() {
        let proj = project();
        let contexts = FakeContexts::new();
        let agent = contexts.seed_agent("dev-backend");
        // Seed the agent's context body via the store the use case reads from.
        {
            let md = contexts.md_path_of(&agent).unwrap();
            contexts
                .0
                .lock()
                .unwrap()
                .contents
                .insert(md, "# Dev Backend context".to_owned());
        }
        let (service, _memory) = build_service_with_guard(contexts);

        let out = service
            .dispatch(
                &proj,
                OrchestratorCommand::ReadContext {
                    target: Some("dev-backend".to_owned()),
                    requester: agent_party(3),
                },
            )
            .await
            .expect("context.read must succeed when the guard is wired");
        assert_eq!(out.reply.as_deref(), Some("# Dev Backend context"));
        assert_eq!(out.detail, "read dev-backend context");
    }

    /// SYMMETRY — without the guard, the very same command yields the typed
    /// `AppError::Invalid("FileGuard … not configured")`. Documents the contract
    /// of `require_context_guard` and pins the exact regression message.
    #[tokio::test]
    async fn unwired_service_rejects_memory_write_with_typed_error() {
        let proj = project();
        // `build_service` does NOT call `.with_context_guard`.
        let service = build_service(FakeContexts::new());

        let err = service
            .dispatch(
                &proj,
                OrchestratorCommand::WriteMemory {
                    slug: "wiring-note".to_owned(),
                    content: "x".to_owned(),
                    requester: agent_party(1),
                },
            )
            .await
            .expect_err("memory.write must fail without the guard wired");
        assert_eq!(
            err,
            application::AppError::Invalid(
                "FileGuard context/memory tools are not configured".to_owned()
            )
        );
    }
}

#[cfg(test)]
mod tests {
    //! Wiring contract for [`build_memory_recall`] (Pièce 1, §14.5.5).
    //!
    //! The *behaviour* of `NaiveMemoryRecall` / `VectorMemoryRecall` /
    //! `AdaptiveMemoryRecall` is covered exhaustively in
    //! `infrastructure/tests/vector_recall.rs`. Here we only pin the **composition
    //! root contract**: with the default `EmbedderProfile::none()` profile (the
    //! dependency-free default), `build_memory_recall` must hand back a recall whose
    //! observable behaviour is *identical* to a bare `NaiveMemoryRecall` over the
    //! same store — same index ordering, same budget truncation.
    //!
    //! `build_memory_recall` is `pub(crate)`, so this lives in `state.rs` (it is not
    //! reachable from the `tests/` integration crate). Everything is in-memory and
    //! deterministic.

    use std::collections::HashMap;
    use std::sync::Mutex;

    use super::*;
    use async_trait::async_trait;
    use domain::markdown::MarkdownDoc;
    use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
    use domain::ports::{DirEntry, FsError, MemoryQuery, RemotePath};
    use domain::project::ProjectPath;

    // In-memory FileSystem (same minimal shape as the infrastructure test fixtures).
    #[derive(Default)]
    struct MemFs {
        files: Mutex<HashMap<String, Vec<u8>>>,
    }

    #[async_trait]
    impl FileSystem for MemFs {
        async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
            self.files
                .lock()
                .unwrap()
                .get(path.as_str())
                .cloned()
                .ok_or_else(|| FsError::NotFound(path.as_str().to_string()))
        }
        async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
            self.files
                .lock()
                .unwrap()
                .insert(path.as_str().to_string(), data.to_vec());
            Ok(())
        }
        async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
            Ok(self.files.lock().unwrap().contains_key(path.as_str()))
        }
        async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
            Ok(())
        }
        async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
            Ok(Vec::new())
        }
        async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
            Ok(())
        }
    }

    fn root() -> ProjectPath {
        ProjectPath::new("/proj").unwrap()
    }

    fn note(slug: &str, hook: &str) -> Memory {
        Memory::new(
            MemoryFrontmatter {
                name: MemorySlug::new(slug).unwrap(),
                description: hook.to_string(),
                r#type: MemoryType::Project,
            },
            MarkdownDoc::new("# body"),
        )
        .unwrap()
    }

    fn query(text: &str, budget: usize) -> MemoryQuery {
        MemoryQuery {
            text: text.to_string(),
            token_budget: budget,
        }
    }

    async fn seed_store() -> (Arc<dyn FileSystem>, Arc<dyn MemoryStore>) {
        let fs: Arc<dyn FileSystem> = Arc::new(MemFs::default());
        let store_concrete = Arc::new(FsMemoryStore::new(Arc::clone(&fs)));
        for n in [
            note("alpha", "apple orange grape"),
            note("beta", "kiwi mango papaya"),
            note("gamma", "carrot potato onion"),
        ] {
            store_concrete.save(&root(), &n).await.unwrap();
        }
        let store: Arc<dyn MemoryStore> = store_concrete;
        (fs, store)
    }

    /// With the default `none` profile, `build_memory_recall` is observationally a
    /// plain `NaiveMemoryRecall`: same index ordering and same budget truncation,
    /// entry-for-entry, across a representative spread of budgets.
    #[tokio::test]
    async fn build_memory_recall_none_profile_matches_naive_recall() {
        let (fs, store) = seed_store().await;

        let wired = build_memory_recall(
            Arc::clone(&fs),
            Arc::clone(&store),
            &EmbedderProfile::none(),
            std::path::Path::new("/unused-onnx-cache"),
        );
        let naive = NaiveMemoryRecall::new(Arc::clone(&store));

        // 0 ⇒ empty; mid budgets ⇒ partial truncation; huge ⇒ full index order.
        for budget in [0usize, 3, 6, 100, 100_000] {
            let q = query("kiwi mango papaya", budget);
            let expected = naive.recall(&root(), &q).await.unwrap();
            let got = wired.recall(&root(), &q).await.unwrap();
            assert_eq!(
                got, expected,
                "none profile must equal bare NaiveMemoryRecall at budget {budget}"
            );
        }
    }
}

// ===========================================================================
// M5e — Smoke end-to-end over the REAL loopback (no real CLI, no network).
// ===========================================================================

#[cfg(test)]
mod mcp_e2e_loopback_tests {
    //! M5e — **smoke end-to-end** of the bind transport over a **real**
    //! `interprocess` loopback (cadrage v5 §6 row M5e, §2 contract).
    //!
    //! ## Chosen e2e level — the **real accept loop**, justified
    //!
    //! Unlike M5c (which drove the *private* `serve_peer` over an in-memory
    //! `tokio::io::duplex`), M5e proves the chain across an **actual** local socket.
    //! Two options were on the table (cadrage M5e):
    //!
    //! 1. the **full `McpServerHandle::start` accept loop** on a real
    //!    `mcp_endpoint`, dialed by a real `interprocess` client, or
    //! 2. a single `serve_peer` over one accepted real socket.
    //!
    //! We pick **(1) the real accept loop**. It is the *most faithful* slice — it
    //! exercises exactly the production path a launched CLI's `idea mcp-server`
    //! bridge takes: `bind_endpoint` → `McpServerHandle::start` → `listener.accept()`
    //! → `serve_peer` (handshake + project guard) → `StdioTransport` →
    //! `McpServer::serve_as` → the **real `dispatch`** (over the same in-memory fakes
    //! as M2/M5c). And it is **safely bounded**: the accept loop is already async and
    //! parks on the absence of a peer, while the *client* side is a plain
    //! request/response exchange we wrap in `tokio::time::timeout`. Option (2) would
    //! skip the bind/accept/lifecycle wiring for no extra safety, since the risk
    //! (a peer that never speaks) is bounded identically by the client-side timeout.
    //!
    //! No production test seam was added: `McpServerHandle::start`, `bind_endpoint`
    //! and `mcp_endpoint` are all reachable from this in-crate module as-is. The
    //! client uses the same `interprocess` `Stream` the real bridge uses.
    //!
    //! GARDE-FOU : the listener bind, every client connect, and every response read
    //! is wrapped in `tokio::time::timeout`; a hung accept/serve/handshake fails the
    //! test fast instead of hanging the suite.

    use std::collections::HashMap;
    use std::sync::{Arc, Mutex};
    use std::time::Duration;

    use async_trait::async_trait;
    use interprocess::local_socket::tokio::Stream as LocalSocketStream;
    use interprocess::local_socket::traits::tokio::Stream as _;
    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
    use uuid::Uuid;

    use application::{
        CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
        OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
    };
    use domain::agent::{AgentManifest, ManifestEntry};
    use domain::events::{DomainEvent, OrchestrationSource};
    use domain::ids::{AgentId, NodeId, ProfileId, ProjectId, SkillId};
    use domain::markdown::MarkdownDoc;
    use domain::ports::{
        AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, ContextInjectionPlan,
        DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator,
        OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath,
        ReplyEvent, ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
    };
    use domain::profile::{
        AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
        StructuredAdapter,
    };
    use domain::project::{Project, ProjectPath};
    use domain::remote::RemoteRef;
    use domain::skill::{Skill, SkillScope};
    use domain::{PtySize, SessionId};
    use serde_json::{json, Value};

    use super::{bind_endpoint, mcp_endpoint, McpServerHandle};
    use crate::mcp_endpoint::McpEndpoint;
    use infrastructure::{
        InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, SystemMillisClock,
    };

    /// Test timeout for any single loopback interaction. Generous but finite: a
    /// correct round-trip is sub-millisecond, so this only ever fires on a real hang.
    const TIMEOUT: Duration = Duration::from_secs(5);

    // -----------------------------------------------------------------------
    // Fakes — mirrored from `infrastructure/tests/mcp_server.rs` (the established
    // MCP harness). Includes a `FakeSession` so `idea_ask_agent` resolves inline.
    // -----------------------------------------------------------------------

    #[derive(Default)]
    struct ContextsInner {
        manifest: AgentManifest,
        contents: HashMap<String, String>,
    }
    #[derive(Clone)]
    struct FakeContexts(Arc<Mutex<ContextsInner>>);
    impl FakeContexts {
        fn new() -> Self {
            Self(Arc::new(Mutex::new(ContextsInner {
                manifest: AgentManifest {
                    version: 1,
                    entries: Vec::new(),
                    orchestrator: None,
                },
                contents: HashMap::new(),
            })))
        }
        fn seed_agent(&self, name: &str) -> AgentId {
            let id = AgentId::from_uuid(Uuid::new_v4());
            let mut inner = self.0.lock().unwrap();
            inner.manifest.entries.push(ManifestEntry {
                agent_id: id,
                name: name.to_owned(),
                md_path: format!("agents/{name}.md"),
                profile_id: ProfileId::from_uuid(Uuid::from_u128(9)),
                template_id: None,
                synchronized: false,
                synced_template_version: None,
                skills: Vec::new(),
                effort: None,
            });
            id
        }
        fn md_path_of(&self, agent: &AgentId) -> Option<String> {
            self.0
                .lock()
                .unwrap()
                .manifest
                .entries
                .iter()
                .find(|e| &e.agent_id == agent)
                .map(|e| e.md_path.clone())
        }
    }
    #[async_trait]
    impl AgentContextStore for FakeContexts {
        async fn read_context(
            &self,
            _project: &Project,
            agent: &AgentId,
        ) -> Result<MarkdownDoc, StoreError> {
            let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
            Ok(MarkdownDoc::new(
                self.0
                    .lock()
                    .unwrap()
                    .contents
                    .get(&md)
                    .cloned()
                    .unwrap_or_default(),
            ))
        }
        async fn write_context(
            &self,
            _project: &Project,
            agent: &AgentId,
            md: &MarkdownDoc,
        ) -> Result<(), StoreError> {
            let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
            self.0
                .lock()
                .unwrap()
                .contents
                .insert(path, md.as_str().to_owned());
            Ok(())
        }
        async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
            Ok(self.0.lock().unwrap().manifest.clone())
        }
        async fn save_manifest(
            &self,
            _project: &Project,
            manifest: &AgentManifest,
        ) -> Result<(), StoreError> {
            self.0.lock().unwrap().manifest = manifest.clone();
            Ok(())
        }
    }

    #[derive(Clone)]
    struct FakeProfiles(Arc<Vec<AgentProfile>>);
    #[async_trait]
    impl ProfileStore for FakeProfiles {
        async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
            Ok((*self.0).clone())
        }
        async fn save(&self, _p: &AgentProfile) -> Result<(), StoreError> {
            Ok(())
        }
        async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
            Ok(())
        }
        async fn is_configured(&self) -> Result<bool, StoreError> {
            Ok(true)
        }
        async fn mark_configured(&self) -> Result<(), StoreError> {
            Ok(())
        }
    }

    #[derive(Default)]
    struct FakeSkills;
    #[async_trait]
    impl SkillStore for FakeSkills {
        async fn list(
            &self,
            _scope: SkillScope,
            _root: &ProjectPath,
        ) -> Result<Vec<Skill>, StoreError> {
            Ok(Vec::new())
        }
        async fn get(
            &self,
            _scope: SkillScope,
            _root: &ProjectPath,
            _id: SkillId,
        ) -> Result<Skill, StoreError> {
            Err(StoreError::NotFound)
        }
        async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
            Ok(())
        }
        async fn delete(
            &self,
            _scope: SkillScope,
            _root: &ProjectPath,
            _id: SkillId,
        ) -> Result<(), StoreError> {
            Ok(())
        }
    }

    #[derive(Default)]
    struct FakeRecall;
    #[async_trait]
    impl domain::ports::MemoryRecall for FakeRecall {
        async fn recall(
            &self,
            _root: &ProjectPath,
            _query: &domain::ports::MemoryQuery,
        ) -> Result<Vec<domain::MemoryIndexEntry>, domain::ports::MemoryError> {
            Ok(Vec::new())
        }
    }

    struct FakeSession {
        id: SessionId,
        reply: String,
    }

    #[async_trait]
    impl AgentSession for FakeSession {
        fn id(&self) -> SessionId {
            self.id
        }

        fn conversation_id(&self) -> Option<String> {
            Some(self.id.to_string())
        }

        async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
            Ok(Box::new(std::iter::once(ReplyEvent::Final {
                content: self.reply.clone(),
            })))
        }

        async fn shutdown(&self) -> Result<(), AgentSessionError> {
            Ok(())
        }
    }

    struct FakeRuntime;
    #[async_trait]
    impl AgentRuntime for FakeRuntime {
        async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
            Ok(true)
        }
        fn prepare_invocation(
            &self,
            profile: &AgentProfile,
            _ctx: &PreparedContext,
            cwd: &ProjectPath,
            _session: &SessionPlan,
        ) -> Result<SpawnSpec, RuntimeError> {
            Ok(SpawnSpec {
                command: profile.command.clone(),
                args: profile.args.clone(),
                cwd: cwd.clone(),
                env: Vec::new(),
                context_plan: Some(ContextInjectionPlan::Stdin),
                sandbox: None,
            })
        }
    }

    #[derive(Clone, Default)]
    struct FakeFs;
    #[async_trait]
    impl FileSystem for FakeFs {
        async fn read(&self, p: &RemotePath) -> Result<Vec<u8>, FsError> {
            Err(FsError::NotFound(p.as_str().to_owned()))
        }
        async fn write(&self, _p: &RemotePath, _d: &[u8]) -> Result<(), FsError> {
            Ok(())
        }
        async fn exists(&self, _p: &RemotePath) -> Result<bool, FsError> {
            Ok(false)
        }
        async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> {
            Ok(())
        }
        async fn list(&self, _p: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
            Ok(Vec::new())
        }
        async fn symlink(&self, _s: &RemotePath, _d: &RemotePath) -> Result<(), FsError> {
            Ok(())
        }
    }

    #[derive(Clone)]
    struct FakePty;
    #[async_trait]
    impl PtyPort for FakePty {
        async fn spawn(&self, _s: SpawnSpec, _z: PtySize) -> Result<PtyHandle, PtyError> {
            Ok(PtyHandle {
                session_id: SessionId::from_uuid(Uuid::from_u128(777)),
            })
        }
        fn write(&self, _h: &PtyHandle, _d: &[u8]) -> Result<(), PtyError> {
            Ok(())
        }
        fn resize(&self, _h: &PtyHandle, _z: PtySize) -> Result<(), PtyError> {
            Ok(())
        }
        fn subscribe_output(&self, _h: &PtyHandle) -> Result<OutputStream, PtyError> {
            Ok(Box::new(std::iter::empty()))
        }
        fn scrollback(&self, _h: &PtyHandle) -> Result<Vec<u8>, PtyError> {
            Ok(Vec::new())
        }
        async fn wait(&self, _h: &PtyHandle) -> Result<ExitStatus, PtyError> {
            Ok(ExitStatus { code: Some(0) })
        }
        fn try_wait(&self, _h: &PtyHandle) -> Result<Option<ExitStatus>, PtyError> {
            Ok(Some(ExitStatus { code: Some(0) }))
        }
        async fn kill(&self, _h: &PtyHandle) -> Result<ExitStatus, PtyError> {
            Ok(ExitStatus { code: Some(0) })
        }
    }

    #[derive(Default, Clone)]
    struct NoopBus;
    impl EventBus for NoopBus {
        fn publish(&self, _e: DomainEvent) {}
        fn subscribe(&self) -> EventStream {
            Box::new(std::iter::empty())
        }
    }

    struct SeqIds(Mutex<u128>);
    impl IdGenerator for SeqIds {
        fn new_uuid(&self) -> Uuid {
            let mut n = self.0.lock().unwrap();
            let id = Uuid::from_u128(*n);
            *n += 1;
            id
        }
    }

    fn project() -> Project {
        // A fresh project id per call ⇒ a distinct endpoint per test (no socket-file
        // collision when tests run in parallel).
        Project::new(
            ProjectId::from_uuid(Uuid::new_v4()),
            "demo",
            ProjectPath::new("/home/me/proj").unwrap(),
            RemoteRef::local(),
            1_700_000_000_000,
        )
        .unwrap()
    }

    fn project_id_arg(p: &Project) -> String {
        p.id.as_uuid().simple().to_string()
    }

    fn capturing_events() -> (
        Arc<dyn Fn(DomainEvent) + Send + Sync>,
        Arc<Mutex<Vec<DomainEvent>>>,
    ) {
        let captured = Arc::new(Mutex::new(Vec::new()));
        let sink = captured.clone();
        let publish: Arc<dyn Fn(DomainEvent) + Send + Sync> =
            Arc::new(move |e: DomainEvent| sink.lock().unwrap().push(e));
        (publish, captured)
    }

    /// Builds an `OrchestratorService` over the in-memory fakes, returning the
    /// shared `TerminalSessions` (so a test can pre-bind a live PTY target for an
    /// `idea_ask_agent`) and the `InMemoryMailbox` (so a test can observe pending
    /// tickets). Mirrors the established harness; no use case is re-invented.
    fn build_service(
        contexts: FakeContexts,
    ) -> (
        Arc<OrchestratorService>,
        Arc<TerminalSessions>,
        Arc<InMemoryMailbox>,
    ) {
        // Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) :
        // seul profil que la garde F2 (`guard_mcp_bridge_supported`) laisse passer pour
        // `idea_ask_agent` — le round-trip e2e testé ici suppose une cible éligible.
        let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
            ProfileId::from_uuid(Uuid::from_u128(9)),
            "Claude Code",
            "claude",
            Vec::new(),
            ContextInjection::stdin(),
            None,
            "{agentRunDir}",
            None,
        )
        .unwrap()
        .with_structured_adapter(StructuredAdapter::Claude)
        .with_mcp(McpCapability::new(
            McpConfigStrategy::config_file(".mcp.json").unwrap(),
            McpTransport::Stdio,
        ))])));
        let sessions = Arc::new(TerminalSessions::new());
        let structured = Arc::new(StructuredSessions::new());
        seed_structured_sessions(&structured, &contexts);
        let mailbox = Arc::new(InMemoryMailbox::new());
        let bus = Arc::new(NoopBus);
        let create = Arc::new(CreateAgentFromScratch::new(
            Arc::new(contexts.clone()),
            Arc::new(SeqIds(Mutex::new(1))),
            bus.clone(),
        ));
        let launch = Arc::new(LaunchAgent::new(
            Arc::new(contexts.clone()),
            Arc::clone(&profiles) as Arc<dyn ProfileStore>,
            Arc::new(FakeRuntime),
            Arc::new(FakeFs),
            Arc::new(FakePty),
            Arc::new(FakeSkills),
            Arc::clone(&sessions),
            bus.clone(),
            Arc::new(SeqIds(Mutex::new(1))),
            Arc::new(FakeRecall),
            None,
        ));
        let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
        let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
        let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts)));
        let create_skill = Arc::new(CreateSkill::new(
            Arc::new(FakeSkills) as Arc<dyn SkillStore>,
            Arc::new(SeqIds(Mutex::new(1))),
        ));
        let input = Arc::new(MediatedInbox::with_pty(
            Arc::clone(&mailbox),
            Arc::new(SystemMillisClock),
            Arc::new(FakePty) as Arc<dyn PtyPort>,
        )) as Arc<dyn domain::input::InputMediator>;
        let conversations = Arc::new(InMemoryConversationRegistry::new())
            as Arc<dyn domain::conversation::ConversationRegistry>;
        let service = OrchestratorService::new(
            create,
            launch,
            list,
            close,
            update,
            create_skill,
            Arc::clone(&profiles) as Arc<dyn ProfileStore>,
            Arc::clone(&sessions),
        )
        .with_input_mediator(
            input,
            Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
        )
        .with_conversations(conversations)
        .with_structured(structured);
        (Arc::new(service), sessions, mailbox)
    }

    /// Codex twin of [`build_service`]: identical wiring, but the single profile
    /// targets the **Codex** structured adapter with a `TomlConfigHome` MCP strategy
    /// (`$CODEX_HOME/config.toml`). This is the couple the bridge guard
    /// (`materializes_idea_bridge`) must now let through — proving Codex is eligible
    /// for `idea_ask_agent`, exactly like Claude. No real `codex` binary is ever
    /// spawned: the runtime/PTY are the same in-memory fakes.
    fn build_service_codex(
        contexts: FakeContexts,
    ) -> (
        Arc<OrchestratorService>,
        Arc<TerminalSessions>,
        Arc<InMemoryMailbox>,
    ) {
        let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
            ProfileId::from_uuid(Uuid::from_u128(9)),
            "Codex CLI",
            "codex",
            Vec::new(),
            ContextInjection::stdin(),
            None,
            "{agentRunDir}",
            None,
        )
        .unwrap()
        .with_structured_adapter(StructuredAdapter::Codex)
        .with_mcp(McpCapability::new(
            McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME").unwrap(),
            McpTransport::Stdio,
        ))])));
        let sessions = Arc::new(TerminalSessions::new());
        let structured = Arc::new(StructuredSessions::new());
        seed_structured_sessions(&structured, &contexts);
        let mailbox = Arc::new(InMemoryMailbox::new());
        let bus = Arc::new(NoopBus);
        let create = Arc::new(CreateAgentFromScratch::new(
            Arc::new(contexts.clone()),
            Arc::new(SeqIds(Mutex::new(1))),
            bus.clone(),
        ));
        let launch = Arc::new(LaunchAgent::new(
            Arc::new(contexts.clone()),
            Arc::clone(&profiles) as Arc<dyn ProfileStore>,
            Arc::new(FakeRuntime),
            Arc::new(FakeFs),
            Arc::new(FakePty),
            Arc::new(FakeSkills),
            Arc::clone(&sessions),
            bus.clone(),
            Arc::new(SeqIds(Mutex::new(1))),
            Arc::new(FakeRecall),
            None,
        ));
        let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
        let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
        let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts)));
        let create_skill = Arc::new(CreateSkill::new(
            Arc::new(FakeSkills) as Arc<dyn SkillStore>,
            Arc::new(SeqIds(Mutex::new(1))),
        ));
        let input = Arc::new(MediatedInbox::with_pty(
            Arc::clone(&mailbox),
            Arc::new(SystemMillisClock),
            Arc::new(FakePty) as Arc<dyn PtyPort>,
        )) as Arc<dyn domain::input::InputMediator>;
        let conversations = Arc::new(InMemoryConversationRegistry::new())
            as Arc<dyn domain::conversation::ConversationRegistry>;
        let service = OrchestratorService::new(
            create,
            launch,
            list,
            close,
            update,
            create_skill,
            Arc::clone(&profiles) as Arc<dyn ProfileStore>,
            Arc::clone(&sessions),
        )
        .with_input_mediator(
            input,
            Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
        )
        .with_conversations(conversations)
        .with_structured(structured);
        (Arc::new(service), sessions, mailbox)
    }

    fn seed_structured_sessions(sessions: &StructuredSessions, contexts: &FakeContexts) {
        let entries = contexts.0.lock().unwrap().manifest.entries.clone();
        for (index, entry) in entries.into_iter().enumerate() {
            let session = Arc::new(FakeSession {
                id: SessionId::from_uuid(Uuid::from_u128(10_000 + index as u128)),
                reply: "the answer is 42".to_owned(),
            });
            sessions.insert(
                session,
                entry.agent_id,
                NodeId::from_uuid(Uuid::from_u128(20_000 + index as u128)),
            );
        }
    }

    // --- real loopback harness ---------------------------------------------

    /// Stands up the **real** production accept-and-serve path on a real
    /// `interprocess` loopback for `project`: binds the endpoint via the production
    /// `bind_endpoint`, starts `McpServerHandle::start` (the M5c accept loop), and
    /// returns the live handle plus the endpoint to dial.
    ///
    /// Asserts the bind actually succeeded (a `None` listener would make the e2e
    /// vacuous) — if the per-user runtime dir is unwritable the test fails loudly
    /// rather than silently testing nothing.
    fn start_real_server(
        service: Arc<OrchestratorService>,
        project: &Project,
        events: Option<Arc<dyn Fn(DomainEvent) + Send + Sync>>,
    ) -> (McpServerHandle, McpEndpoint) {
        let endpoint = mcp_endpoint(&project.id);
        let listener = bind_endpoint(&endpoint);
        assert!(
            listener.is_some(),
            "M5e needs a real bound listener; bind_endpoint returned None for {:?}",
            endpoint.as_cli_arg()
        );
        let mut server = McpServer::new(service, project.clone());
        if let Some(publish) = events {
            server = server.with_events(publish);
        }
        let handle =
            McpServerHandle::start(server, endpoint.clone(), listener, project_id_arg(project));
        (handle, endpoint)
    }

    /// Connects a **real** `interprocess` client to `endpoint`, bounded by [`TIMEOUT`]
    /// so an endpoint that never accepts fails fast (GARDE-FOU).
    async fn connect_client(endpoint: &McpEndpoint) -> LocalSocketStream {
        use interprocess::local_socket::{GenericFilePath, ToFsName as _};
        let name = endpoint
            .as_cli_arg()
            .to_fs_name::<GenericFilePath>()
            .expect("valid endpoint name");
        tokio::time::timeout(TIMEOUT, LocalSocketStream::connect(name))
            .await
            .expect("GARDE-FOU: connect to endpoint timed out")
            .expect("connect to a live endpoint")
    }

    fn handshake_line(project: &str, requester: &str) -> String {
        format!("{{\"project\":\"{project}\",\"requester\":\"{requester}\"}}\n")
    }

    fn tools_call_line(id: i64, tool: &str, arguments: Value) -> String {
        let mut s = serde_json::to_string(&json!({
            "jsonrpc": "2.0",
            "id": id,
            "method": "tools/call",
            "params": { "name": tool, "arguments": arguments }
        }))
        .unwrap();
        s.push('\n');
        s
    }

    /// Reads exactly one newline-delimited JSON-RPC response off the client side of
    /// the real socket, bounded by [`TIMEOUT`]. `None` on EOF/timeout.
    async fn read_one_response<R>(reader: &mut R) -> Option<Value>
    where
        R: AsyncBufReadExt + Unpin,
    {
        let mut line = String::new();
        match tokio::time::timeout(TIMEOUT, reader.read_line(&mut line)).await {
            Ok(Ok(0)) => None, // EOF before a full line
            Ok(Ok(_)) => serde_json::from_str(line.trim_end()).ok(),
            Ok(Err(_)) => None,
            Err(_) => None, // GARDE-FOU: timed out waiting for a reply
        }
    }

    // -----------------------------------------------------------------------
    // 1. idea_list_agents e2e over the real loopback ⇒ JSON array of agents.
    // -----------------------------------------------------------------------

    #[ignore = "requires local socket bind permission"]
    #[tokio::test]
    async fn list_agents_round_trips_over_real_loopback() {
        let contexts = FakeContexts::new();
        contexts.seed_agent("architect");
        contexts.seed_agent("dev-backend");
        let (service, _sessions, _mailbox) = build_service(contexts);
        let proj = project();
        let (handle, endpoint) = start_real_server(service, &proj, None);

        let conn = connect_client(&endpoint).await;
        let (read_half, mut write_half) = tokio::io::split(conn);
        let mut reader = BufReader::new(read_half);

        // Handshake (real project id) + a tools/call idea_list_agents.
        write_half
            .write_all(handshake_line(&project_id_arg(&proj), "agent-1").as_bytes())
            .await
            .unwrap();
        write_half
            .write_all(tools_call_line(1, "idea_list_agents", json!({})).as_bytes())
            .await
            .unwrap();
        write_half.flush().await.unwrap();

        let resp = read_one_response(&mut reader)
            .await
            .expect("a list_agents response line");
        assert_eq!(resp["id"], json!(1));
        assert!(resp["error"].is_null(), "transport error: {resp}");
        let result = &resp["result"];
        assert_eq!(result["isError"], json!(false), "got {result}");

        // The inline text is the JSON array of the project's agents.
        let text = result["content"][0]["text"].as_str().expect("text block");
        let agents: Value = serde_json::from_str(text).expect("reply must be a JSON array");
        let arr = agents.as_array().expect("array");
        assert_eq!(arr.len(), 2, "two seeded agents expected; got {text}");
        let names: Vec<&str> = arr.iter().map(|a| a["name"].as_str().unwrap()).collect();
        assert!(names.contains(&"architect"), "got {names:?}");
        assert!(names.contains(&"dev-backend"), "got {names:?}");

        drop(write_half); // EOF ⇒ serve loop ends cleanly
        handle.stop();
    }

    // -----------------------------------------------------------------------
    // 2. `idea_ask_agent` e2e over the real loopback under the current headless
    //    protocol: the orchestrator drives the target's structured session directly
    //    and returns the captured `Final` inline. No target-side `idea_reply` tool is
    //    involved.
    // -----------------------------------------------------------------------

    #[ignore = "requires local socket bind permission"]
    #[tokio::test]
    async fn ask_then_reply_round_trips_inline_over_real_loopback() {
        let contexts = FakeContexts::new();
        let agent_id = contexts.seed_agent("architect");
        let (service, _sessions, mailbox) = build_service(contexts);
        let proj = project();
        let (handle, endpoint) = start_real_server(service, &proj, None);

        // Connection A: the asker.
        let conn_a = connect_client(&endpoint).await;
        let (read_a, mut write_a) = tokio::io::split(conn_a);
        let mut reader_a = BufReader::new(read_a);
        write_a
            .write_all(handshake_line(&project_id_arg(&proj), "agent-asker").as_bytes())
            .await
            .unwrap();
        write_a
            .write_all(
                tools_call_line(
                    7,
                    "idea_ask_agent",
                    json!({ "target": "architect", "task": "What is the answer?" }),
                )
                .as_bytes(),
            )
            .await
            .unwrap();
        write_a.flush().await.unwrap();

        // The asker receives the structured target's Final inline.
        let resp = read_one_response(&mut reader_a)
            .await
            .expect("an ask response line");
        assert_eq!(resp["id"], json!(7));
        assert!(resp["error"].is_null(), "transport error: {resp}");
        let result = &resp["result"];
        assert_eq!(result["isError"], json!(false), "got {result}");
        assert_eq!(
            result["content"][0]["text"].as_str().expect("text block"),
            "the answer is 42",
            "ask reply must be returned inline over the real loopback; got {result}"
        );
        assert_eq!(
            mailbox.pending(&domain::RuntimeAgentKey::new(proj.id, agent_id)),
            0,
            "structured ask must drain its accounting ticket"
        );

        drop(write_a);
        handle.stop();
    }

    // -----------------------------------------------------------------------
    // 2b. Codex twin of the round-trip above: the *only* difference is the target
    //     profile (Codex + TomlConfigHome MCP). It proves the bridge guard now lets
    //     a Codex target through AND that the inline ask→reply loop still completes.
    // -----------------------------------------------------------------------

    #[ignore = "requires local socket bind permission"]
    #[tokio::test]
    async fn ask_then_reply_round_trips_inline_over_real_loopback_codex() {
        let contexts = FakeContexts::new();
        let agent_id = contexts.seed_agent("architect");
        let (service, _sessions, mailbox) = build_service_codex(contexts);
        let proj = project();
        let (handle, endpoint) = start_real_server(service, &proj, None);

        // Connection A: the asker.
        let conn_a = connect_client(&endpoint).await;
        let (read_a, mut write_a) = tokio::io::split(conn_a);
        let mut reader_a = BufReader::new(read_a);
        write_a
            .write_all(handshake_line(&project_id_arg(&proj), "agent-asker").as_bytes())
            .await
            .unwrap();
        write_a
            .write_all(
                tools_call_line(
                    7,
                    "idea_ask_agent",
                    json!({ "target": "architect", "task": "What is the answer?" }),
                )
                .as_bytes(),
            )
            .await
            .unwrap();
        write_a.flush().await.unwrap();

        // The asker receives the structured target's Final inline.
        let resp = read_one_response(&mut reader_a)
            .await
            .expect("an ask response line");
        assert_eq!(resp["id"], json!(7));
        assert!(resp["error"].is_null(), "transport error: {resp}");
        let result = &resp["result"];
        assert_eq!(result["isError"], json!(false), "got {result}");
        assert_eq!(
            result["content"][0]["text"].as_str().expect("text block"),
            "the answer is 42",
            "ask reply must be returned inline over the real loopback (Codex target); got {result}"
        );
        assert_eq!(
            mailbox.pending(&domain::RuntimeAgentKey::new(proj.id, agent_id)),
            0,
            "structured ask must drain its accounting ticket"
        );

        drop(write_a);
        handle.stop();
    }

    // -----------------------------------------------------------------------
    // 3. `idea_reply` is no longer part of the MCP surface. Calling it returns a
    //    JSON-RPC unknown-tool error and the connection stays healthy for a follow-up
    //    call.
    // -----------------------------------------------------------------------

    #[ignore = "requires local socket bind permission"]
    #[tokio::test]
    async fn removed_reply_tool_is_unknown_over_real_loopback() {
        let contexts = FakeContexts::new();
        let agent_id = contexts.seed_agent("dev");
        let (service, _sessions, _mailbox) = build_service(contexts);
        let proj = project();
        let (handle, endpoint) = start_real_server(service, &proj, None);

        let conn = connect_client(&endpoint).await;
        let (read_half, mut write_half) = tokio::io::split(conn);
        let mut reader = BufReader::new(read_half);

        // The peer identifies as `agent_id`; `idea_reply` itself is no longer mapped.
        write_half
            .write_all(handshake_line(&project_id_arg(&proj), &agent_id.to_string()).as_bytes())
            .await
            .unwrap();
        write_half
            .write_all(tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes())
            .await
            .unwrap();
        write_half.flush().await.unwrap();

        let resp = read_one_response(&mut reader)
            .await
            .expect("a reply response line");
        assert_eq!(resp["id"], json!(3));
        assert_eq!(resp["error"]["code"], json!(-32601), "got {resp}");
        assert_eq!(
            resp["error"]["message"],
            json!("unknown tool: idea_reply"),
            "got {resp}"
        );
        assert!(resp.get("result").is_none(), "got {resp}");

        // The connection is still healthy: a follow-up tools/list still answers.
        write_half
            .write_all(
                serde_json::to_string(&json!({
                    "jsonrpc": "2.0", "id": 4, "method": "tools/list"
                }))
                .map(|mut s| {
                    s.push('\n');
                    s
                })
                .unwrap()
                .as_bytes(),
            )
            .await
            .unwrap();
        write_half.flush().await.unwrap();
        let again = read_one_response(&mut reader)
            .await
            .expect("server still serves after a tool error");
        assert_eq!(again["id"], json!(4));
        assert!(again["result"]["tools"].is_array(), "got {again}");

        drop(write_half);
        handle.stop();
    }

    // -----------------------------------------------------------------------
    // 4. Malformed JSON-RPC after the handshake ⇒ JSON-RPC error, never a panic,
    //    the server stays alive for a subsequent valid call.
    // -----------------------------------------------------------------------

    #[ignore = "requires local socket bind permission"]
    #[tokio::test]
    async fn malformed_jsonrpc_after_handshake_errors_and_server_survives() {
        let contexts = FakeContexts::new();
        contexts.seed_agent("architect");
        let (service, _sessions, _mailbox) = build_service(contexts);
        let proj = project();
        let (handle, endpoint) = start_real_server(service, &proj, None);

        let conn = connect_client(&endpoint).await;
        let (read_half, mut write_half) = tokio::io::split(conn);
        let mut reader = BufReader::new(read_half);

        write_half
            .write_all(handshake_line(&project_id_arg(&proj), "agent-1").as_bytes())
            .await
            .unwrap();
        // A malformed JSON-RPC line (not valid JSON) — must NOT crash the serve loop.
        write_half.write_all(b"{ this is not json\n").await.unwrap();
        write_half.flush().await.unwrap();

        let resp = read_one_response(&mut reader)
            .await
            .expect("a parse error still owes a response");
        let error = &resp["error"];
        assert!(
            !error.is_null(),
            "malformed input must yield a JSON-RPC error: {resp}"
        );
        // JSON-RPC mandates a null id when the request could not be correlated.
        assert_eq!(resp["id"], Value::Null, "got {resp}");
        assert!(
            resp["result"].is_null(),
            "no result on parse error; got {resp}"
        );

        // The server survived: a subsequent valid call still answers.
        write_half
            .write_all(
                serde_json::to_string(&json!({
                    "jsonrpc": "2.0", "id": 2, "method": "tools/list"
                }))
                .map(|mut s| {
                    s.push('\n');
                    s
                })
                .unwrap()
                .as_bytes(),
            )
            .await
            .unwrap();
        write_half.flush().await.unwrap();
        let again = read_one_response(&mut reader)
            .await
            .expect("server must survive malformed input");
        assert_eq!(again["id"], json!(2));
        assert!(again["result"]["tools"].is_array(), "got {again}");

        drop(write_half);
        handle.stop();
    }

    // -----------------------------------------------------------------------
    // 5. Bonus — the handshake requester crosses the REAL loopback and tags the
    //    OrchestratorRequestProcessed event with the real agent id (not "mcp").
    // -----------------------------------------------------------------------

    #[ignore = "requires local socket bind permission"]
    #[tokio::test]
    async fn handshake_requester_propagates_over_real_loopback() {
        let contexts = FakeContexts::new();
        let (service, _sessions, _mailbox) = build_service(contexts);
        let (publish, captured) = capturing_events();
        let proj = project();
        let (handle, endpoint) = start_real_server(service, &proj, Some(publish));

        let conn = connect_client(&endpoint).await;
        let (read_half, mut write_half) = tokio::io::split(conn);
        let mut reader = BufReader::new(read_half);

        // Handshake carries the real requesting agent id.
        write_half
            .write_all(handshake_line(&project_id_arg(&proj), "agent-42").as_bytes())
            .await
            .unwrap();
        // A successful launch (creates + launches an agent from scratch) ⇒ a
        // processed beacon is emitted, tagged with the handshake requester.
        write_half
            .write_all(
                tools_call_line(
                    1,
                    "idea_launch_agent",
                    json!({ "target": "dev-backend", "profile": "claude-code" }),
                )
                .as_bytes(),
            )
            .await
            .unwrap();
        write_half.flush().await.unwrap();

        let resp = read_one_response(&mut reader)
            .await
            .expect("a launch response line");
        assert!(resp["error"].is_null(), "transport error: {resp}");
        assert_eq!(resp["result"]["isError"], json!(false), "got {resp}");

        // Drain the connection so the serve task has surely published the event.
        drop(write_half);
        // Give the spawned serve task a beat to flush its publish (bounded).
        let _ = tokio::time::timeout(TIMEOUT, async {
            loop {
                if captured
                    .lock()
                    .unwrap()
                    .iter()
                    .any(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. }))
                {
                    break;
                }
                tokio::task::yield_now().await;
            }
        })
        .await;

        let events = captured.lock().unwrap();
        let processed: Vec<&DomainEvent> = events
            .iter()
            .filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. }))
            .collect();
        assert_eq!(
            processed.len(),
            1,
            "expected exactly one processed event; got {events:?}"
        );
        match processed[0] {
            DomainEvent::OrchestratorRequestProcessed {
                requester_id,
                action,
                ok,
                source,
            } => {
                assert_eq!(*source, OrchestrationSource::Mcp, "MCP door must tag Mcp");
                assert_eq!(action, "idea_launch_agent");
                assert!(*ok, "the launch succeeded");
                assert_eq!(
                    requester_id, "agent-42",
                    "the real handshake requester must cross the loopback (not 'mcp')"
                );
            }
            other => panic!("expected OrchestratorRequestProcessed, got {other:?}"),
        }
        drop(events);

        handle.stop();
    }
}

#[cfg(test)]
mod bind_endpoint_d1_tests {
    //! D1 — non-regression lock on [`bind_endpoint`]'s corpse-socket reclaim
    //! (cadrage §7 row D1, §5.2 "verrouille `reclaim_name(true)`").
    //!
    //! A crashed run (SIGKILL) leaves the Unix socket **file** behind: a plain
    //! `bind` would then fail with `EADDRINUSE`. The production path passes
    //! `reclaim_name(true)`, which **unlinks the corpse** before binding. These tests
    //! pin that behaviour so a future refactor cannot silently drop the flag and
    //! resurrect the "address already in use" failure on restart.
    //!
    //! Unix-only: on Windows the endpoint is a named pipe with no filesystem corpse to
    //! reclaim, so there is nothing to assert.

    #![cfg(unix)]

    use super::{bind_endpoint, mcp_endpoint};
    use domain::ProjectId;
    use uuid::Uuid;

    /// Rebinding after a **corpse** socket (file left in place, as after a SIGKILL)
    /// succeeds — no `EADDRINUSE` — because `reclaim_name(true)` unlinks it first.
    ///
    /// The corpse is reproduced **faithfully**: a `std::os::unix::net::UnixListener`
    /// binds the path then is dropped — std does **not** unlink on drop, so the socket
    /// inode is left on the filesystem with **no live listener**, exactly the state a
    /// SIGKILL'd run leaves behind (the fd is gone, the inode lingers). A plain `bind`
    /// over that inode would return `EADDRINUSE`; `bind_endpoint`'s `reclaim_name(true)`
    /// must unlink it first.
    #[ignore = "requires local socket bind permission"]
    #[tokio::test]
    async fn rebind_after_corpse_socket_succeeds() {
        use std::os::unix::net::UnixListener;

        let ep = mcp_endpoint(&ProjectId::from_uuid(Uuid::from_u128(0xD1_0001)));
        let path = ep
            .socket_path()
            .expect("unix endpoint exposes a socket path");

        // Clean any leftover from a previous run of this very test.
        let _ = std::fs::remove_file(&path);
        if let Some(parent) = path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }

        // 1) Lay down a CORPSE: bind with std (which leaves the inode on drop) and
        //    drop it ⇒ socket file remains with no live listener (SIGKILL aftermath).
        {
            let corpse = UnixListener::bind(&path).expect("lay corpse socket");
            drop(corpse);
        }
        assert!(
            path.exists(),
            "corpse socket inode remains (no live listener)"
        );

        // 2) Rebind over the corpse: must succeed (reclaim unlinks then binds), NOT
        //    fail with EADDRINUSE.
        let first = bind_endpoint(&ep);
        assert!(
            first.is_some(),
            "rebind over the corpse socket must succeed (reclaim_name unlinks it)"
        );
        assert!(path.exists(), "a fresh live socket now sits at the path");

        // 3) Idempotent over a live socket: a second bind also reclaims + succeeds.
        let second = bind_endpoint(&ep);
        assert!(second.is_some(), "bind is idempotent across a live socket");

        // 4) No file leak after a clean close: dropping the live listener(s) unlinks
        //    the socket (interprocess reclaim guard on drop).
        drop(first);
        drop(second);
        assert!(
            !path.exists(),
            "socket file unlinked on clean close (no leak)"
        );
    }
}
