//! Agent lifecycle use cases (ARCHITECTURE §6, L6). //! //! These own the *project-agent* side (distinct from the profile side in //! [`super::usecases`]): creating agents and their `.md` contexts under //! `.ideai/`, listing/reading/updating them, and — the centrepiece — //! [`LaunchAgent`], which resolves the agent's profile + context, applies the //! profile's context-injection strategy, opens a PTY cell at the right `cwd` and //! spawns the CLI. //! //! Every use case talks **only to ports** ([`AgentContextStore`], [`ProfileStore`], //! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about //! a concrete adapter or Tauri. use std::collections::HashMap; use std::sync::Arc; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus, FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PermissionStore, PreparedContext, ProfileStore, ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError, }; use domain::profile::{McpConfigStrategy, StructuredAdapter}; use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan}; use domain::{ Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions, Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId, Project, ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession, }; use crate::error::AppError; use crate::layout::{persist_doc, resolve_doc}; use crate::project::project_context_path; use crate::terminal::{StructuredSessions, TerminalSessions}; /// Directory (relative to `.ideai/`) under which agent contexts are written. const AGENTS_SUBDIR: &str = "agents"; /// Token budget of the project-memory recall injected into the convention file at /// agent activation (ARCHITECTURE §14.5.4). Bounds the number of index entries /// (étage 1) handed to the agent. Internal and intentionally **not yet exposed in /// config**: it may later become a per-project setting without changing the /// contract. pub const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048; /// Fournit le [`HandoffStore`] **lié au project root** du lancement en cours (lot P7). /// /// [`LaunchAgent`] est une **instance unique partagée par tous les projets** (le /// project root arrive *par lancement* via [`LaunchAgentInput::project`]), alors que /// le handoff conversationnel est **par project root** /// (`/.ideai/conversations/`, comme la mémoire et le log). Les adapters `Fs*` /// fixent leur racine **à la construction** et ne portent pas le root par appel : on /// ne peut donc pas figer un `HandoffStore` global. Ce **port** lève la tension — /// calqué sur [`crate::RecordTurnProvider`] — en matérialisant un store ciblant le /// **bon** dossier à chaque résolution (les adapters `Fs*` ne font que des jointures /// de chemin, leur construction est triviale). /// /// `None` ⇒ aucune injection de reprise (best-effort absente) : zéro régression pour /// les call sites/tests qui ne le branchent pas. Implémenté dans `app-tauri` (seul /// détenteur des adapters `Fs*`). pub trait HandoffProvider: Send + Sync { /// Construit le [`HandoffStore`] dont la persistance cible `root`. Appelé une fois /// par lancement best-effort ; `None` ⇒ on saute silencieusement l'injection. fn handoff_store_for(&self, root: &ProjectPath) -> Option>; } /// Fournit le [`ProviderSessionStore`] **lié au project root** du lancement en cours /// (lot P8b). Jumeau stateless de [`HandoffProvider`] : même tension (instance /// [`LaunchAgent`] partagée vs adapter `Fs*` à racine fixée à la construction), /// même réponse (matérialiser le store ciblant le **bon** dossier à chaque appel). /// /// Sert à ranger, après un lancement structuré, l'id de session **moteur** /// (resumable du provider) sous la clé de paire IdeA dans `providers.json`. `None` /// ⇒ aucune écriture (best-effort absente) : zéro régression pour les call /// sites/tests qui ne le branchent pas. Implémenté dans `app-tauri`. pub trait ProviderSessionProvider: Send + Sync { /// Construit le [`ProviderSessionStore`] dont la persistance cible `root`. Appelé /// une fois par lancement best-effort ; `None` ⇒ on saute silencieusement /// l'écriture du resumable. fn provider_session_store_for( &self, root: &ProjectPath, ) -> Option>; } // --------------------------------------------------------------------------- // CreateAgentFromScratch // --------------------------------------------------------------------------- /// Input for [`CreateAgentFromScratch::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateAgentInput { /// The project that owns the agent. pub project: Project, /// Display name of the agent. pub name: String, /// Runtime profile the agent launches with. pub profile_id: ProfileId, /// Initial `.md` content (empty when `None`). pub initial_content: Option, } /// Output of [`CreateAgentFromScratch::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateAgentOutput { /// The freshly-created agent. pub agent: Agent, } /// Creates a project agent from scratch: mints an id, derives a unique `.md` /// path, records the manifest entry, then writes the (possibly empty) context. pub struct CreateAgentFromScratch { contexts: Arc, ids: Arc, events: Arc, } impl CreateAgentFromScratch { /// Builds the use case from its injected ports. #[must_use] pub fn new( contexts: Arc, ids: Arc, events: Arc, ) -> Self { Self { contexts, ids, events, } } /// Executes creation. /// /// Ordering matters: the manifest entry is persisted **before** the context /// is written, because [`AgentContextStore::write_context`] resolves the /// on-disk path from the manifest. /// /// # Errors /// - [`AppError::Invalid`] if the name is empty or the manifest would become /// inconsistent, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: CreateAgentInput) -> Result { let manifest = self.contexts.load_manifest(&input.project).await?; let id = AgentId::from_uuid(self.ids.new_uuid()); let md_path = unique_md_path(&input.name, &manifest); let agent = Agent::new( id, input.name, md_path, input.profile_id, AgentOrigin::Scratch, false, ) .map_err(|e| AppError::Invalid(e.to_string()))?; // Append the entry and re-validate the whole manifest (unique md_paths). let mut entries = manifest.entries; entries.push(ManifestEntry::from_agent(&agent)); let manifest = AgentManifest::new(manifest.version, entries) .map_err(|e| AppError::Invalid(e.to_string()))?; self.contexts .save_manifest(&input.project, &manifest) .await?; // Now the path resolves: write the initial context. let md = MarkdownDoc::new(input.initial_content.unwrap_or_default()); self.contexts .write_context(&input.project, &agent.id, &md) .await?; self.events.publish(DomainEvent::LayoutChanged { project_id: input.project.id, }); Ok(CreateAgentOutput { agent }) } } // --------------------------------------------------------------------------- // ListAgents // --------------------------------------------------------------------------- /// Input for [`ListAgents::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListAgentsInput { /// The project whose agents to list. pub project: Project, } /// Output of [`ListAgents::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListAgentsOutput { /// The project's agents (reconstructed from the manifest). pub agents: Vec, } /// Lists a project's agents by reconstructing them from the manifest entries. pub struct ListAgents { contexts: Arc, } impl ListAgents { /// Builds the use case from the [`AgentContextStore`] port. #[must_use] pub fn new(contexts: Arc) -> Self { Self { contexts } } /// Loads the manifest and folds each entry back into an [`Agent`]. /// /// # Errors /// - [`AppError::Store`] on persistence failure, /// - [`AppError::Invalid`] if a persisted entry violates an agent invariant. pub async fn execute(&self, input: ListAgentsInput) -> Result { let manifest = self.contexts.load_manifest(&input.project).await?; let agents = manifest .entries .iter() .map(|e| { e.to_agent() .map_err(|err| AppError::Invalid(err.to_string())) }) .collect::, _>>()?; Ok(ListAgentsOutput { agents }) } } // --------------------------------------------------------------------------- // ReadAgentContext / UpdateAgentContext // --------------------------------------------------------------------------- /// Input for [`ReadAgentContext::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReadAgentContextInput { /// The owning project. pub project: Project, /// The agent whose `.md` to read. pub agent_id: AgentId, } /// Output of [`ReadAgentContext::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReadAgentContextOutput { /// The agent's Markdown context. pub content: MarkdownDoc, } /// Reads an agent's `.md` context. pub struct ReadAgentContext { contexts: Arc, } impl ReadAgentContext { /// Builds the use case. #[must_use] pub fn new(contexts: Arc) -> Self { Self { contexts } } /// Reads the context. /// /// # Errors /// - [`AppError::NotFound`] if the agent (or its `.md`) is unknown, /// - [`AppError::Store`] on persistence failure. pub async fn execute( &self, input: ReadAgentContextInput, ) -> Result { let content = self .contexts .read_context(&input.project, &input.agent_id) .await?; Ok(ReadAgentContextOutput { content }) } } /// Input for [`UpdateAgentContext::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateAgentContextInput { /// The owning project. pub project: Project, /// The agent whose `.md` to overwrite. pub agent_id: AgentId, /// New Markdown content. pub content: String, } /// Overwrites an agent's `.md` context. pub struct UpdateAgentContext { contexts: Arc, } impl UpdateAgentContext { /// Builds the use case. #[must_use] pub fn new(contexts: Arc) -> Self { Self { contexts } } /// Writes the new context. /// /// # Errors /// - [`AppError::NotFound`] if the agent is unknown, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: UpdateAgentContextInput) -> Result<(), AppError> { let md = MarkdownDoc::new(input.content); self.contexts .write_context(&input.project, &input.agent_id, &md) .await?; Ok(()) } } // --------------------------------------------------------------------------- // ChangeAgentProfile // --------------------------------------------------------------------------- /// Input for [`ChangeAgentProfile::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChangeAgentProfileInput { /// The owning project. pub project: Project, /// The agent whose runtime profile to hot-swap. pub agent_id: AgentId, /// The new runtime profile. pub profile_id: ProfileId, /// Terminal height in rows for a possible hot relaunch. pub rows: u16, /// Terminal width in columns for a possible hot relaunch. pub cols: u16, } /// Output of [`ChangeAgentProfile::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChangeAgentProfileOutput { /// The mutated agent (now carrying the new profile). pub agent: Agent, /// The freshly relaunched session, when a live session was hot-swapped. pub relaunched: Option, } /// Hot-swaps an existing agent's runtime profile (ARCHITECTURE §15.1). /// /// **Single Responsibility**: mutate the profile in the manifest, clear the now /// foreign conversation id on every persisted layout cell hosting the agent, and /// — if the agent is live — kill its PTY and re-sequence the session in the same /// cell with the new engine. The relaunch is **composed**, not duplicated: this /// use case *calls* [`LaunchAgent::execute`] rather than re-implementing the spawn. /// /// Ports consumed (ISP — only what is needed): [`AgentContextStore`] (manifest), /// [`ProfileStore`] (validate the target profile), [`ProjectStore`] + /// [`FileSystem`] (clean the conversation id on persisted layouts, exactly like /// [`crate::layout::SnapshotRunningAgents`]), [`TerminalSessions`] + [`PtyPort`] /// (detect/kill a live session), an [`Arc`] for the hot relaunch, and /// [`EventBus`] to publish. pub struct ChangeAgentProfile { contexts: Arc, profiles: Arc, projects: Arc, fs: Arc, sessions: Arc, pty: Arc, launch: Arc, events: Arc, /// Registre des sessions **structurées** (§17.5), pour un « kill » polymorphe /// (§17.4) : si l'agent a une session structurée vivante, on la `shutdown()` au /// lieu de tuer un PTY. Injecté au câblage via [`Self::with_structured`] ; `None` /// ⇒ seul le registre PTY est consulté (mode legacy / tests existants). structured: Option>, /// Registre des **projecteurs de permissions** (lot LP3-4), pour nettoyer au swap /// les fichiers `Replace` orphelins possédés par l'ANCIEN profil que le nouveau ne /// réécrira pas. Injecté via [`Self::with_permission_projectors`] (le câblage passe /// le **même** `Arc` que celui donné au `LaunchAgent` interne). `None` ⇒ aucun /// nettoyage (comportement legacy ; la re-projection du nouveau profil, elle, reste /// assurée par le `LaunchAgent` composé). projectors: Option>, } impl ChangeAgentProfile { /// Builds the use case from its injected ports (and the composed launcher). #[must_use] #[allow(clippy::too_many_arguments)] pub fn new( contexts: Arc, profiles: Arc, projects: Arc, fs: Arc, sessions: Arc, pty: Arc, launch: Arc, events: Arc, ) -> Self { Self { contexts, profiles, projects, fs, sessions, pty, launch, events, structured: None, projectors: None, } } /// Branche le registre des sessions **structurées** (§17.4) pour un kill /// polymorphe au hot-swap. Builder additif : signature de [`Self::new`] /// **inchangée** (les tests A existants restent verts), le câblage fait /// `ChangeAgentProfile::new(...).with_structured(registry)`. #[must_use] pub fn with_structured(mut self, structured: Arc) -> Self { self.structured = Some(structured); self } /// Branche le **registre de projecteurs de permissions** (lot LP3-4) pour le /// nettoyage des fichiers orphelins au swap cross-profile. Le câblage passe le /// **même** `Arc` que celui injecté au `LaunchAgent` /// interne (source unique de vérité). Builder additif : signature de [`Self::new`] /// inchangée ; `None` ⇒ pas de nettoyage (legacy). #[must_use] pub fn with_permission_projectors( mut self, projectors: Arc, ) -> Self { self.projectors = Some(projectors); self } /// Executes the hot-swap, following the 7-step algorithm of §15.1. /// /// # Errors /// - [`AppError::NotFound`] if the agent or the target profile is unknown, /// - [`AppError::Invalid`] on a manifest/layout invariant violation, /// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on /// the respective port failures (manifest, layouts, PTY kill, relaunch). pub async fn execute( &self, input: ChangeAgentProfileInput, ) -> Result { // 1. Load the manifest and resolve the agent's entry (NotFound otherwise). let manifest = self.contexts.load_manifest(&input.project).await?; let entry = manifest .entries .iter() .find(|e| e.agent_id == input.agent_id) .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; // Capture the PREVIOUS profile id BEFORE mutation (lot LP3-4): it identifies // the projector whose now-orphan `Replace` files must be cleaned up at the swap. let previous_profile_id = entry.profile_id; // 2. Same profile ⇒ no-op: return the agent unchanged, no kill/relaunch, // no event. if entry.profile_id == input.profile_id { let agent = entry .to_agent() .map_err(|e| AppError::Invalid(e.to_string()))?; return Ok(ChangeAgentProfileOutput { agent, relaunched: None, }); } // 3. Validate that the target profile is a known one (ProfileStore.list). // Capture both the NEW profile (validation) and the PREVIOUS profile (for // the LP3-4 cleanup; absent if it was deleted ⇒ cleanup is skipped). let profiles = self.profiles.list().await?; let Some(new_profile) = profiles.iter().find(|p| p.id == input.profile_id).cloned() else { return Err(AppError::NotFound(format!("profile {}", input.profile_id))); }; let previous_profile = profiles .iter() .find(|p| p.id == previous_profile_id) .cloned(); // 4. Mutate the entry (new profile), re-validate (to_agent + manifest) // and persist. let mut entries = manifest.entries; let mut mutated_agent = None; for e in &mut entries { if e.agent_id == input.agent_id { e.profile_id = input.profile_id; let agent = e .to_agent() .map_err(|err| AppError::Invalid(err.to_string()))?; mutated_agent = Some(agent); } } let agent = mutated_agent.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; let manifest = AgentManifest::new(manifest.version, entries) .map_err(|e| AppError::Invalid(e.to_string()))?; self.contexts .save_manifest(&input.project, &manifest) .await?; // 5. Invalidate the engine link on every persisted layout: for each leaf // hosting this agent, drop the (now foreign) engine resumable cache and // reset the running flag, while **preserving** the stable IdeA pair id. // Mirrors `SnapshotRunningAgents`: resolve_doc → walk agent_leaves → // mutate → persist_doc (if changed). Returns the preserved pair id. let pair_id = self .invalidate_engine_link(&input.project, &input.agent_id) .await?; // 5b. (lot LP3-4) Clean up the PREVIOUS profile's now-orphan permission files // in the agent's (stable) run dir, BEFORE the relaunch re-projects the new // profile — so we never delete a file the new profile is about to write. // Only `Replace`-owned paths of the old projector that the new projector // does NOT also own are removed (best-effort). `MergeToml` files are never // touched. No-op without a projector registry. self.cleanup_swapped_out_files( &input.project.root, &input.agent_id, previous_profile.as_ref(), &new_profile, ) .await; // 6. A live session? Kill its PTY then relaunch in the same cell with the // new profile, carrying the **preserved** pair id (so the handoff is // re-injected and resume routes via providers.json[new provider]). let relaunched = self.relaunch_if_live(&input, pair_id).await?; // 7. Publish the profile change and return. self.events.publish(DomainEvent::AgentProfileChanged { agent_id: input.agent_id, profile_id: input.profile_id, }); Ok(ChangeAgentProfileOutput { agent, relaunched }) } /// Invalidates the **engine link** on every persisted layout leaf hosting /// `agent_id` (step 5), while **preserving** the IdeA pair conversation id. /// /// Post-P8a, `LeafCell::conversation_id` is a stable, provider-independent /// **pair id** (User↔agent) that retrieves the work log + handoff and must /// survive a profile swap. Only the engine-side cache /// (`LeafCell::engine_session_id`, a foreign resumable for the new engine) is /// cleared; the source of truth for resume routing is `providers.json`. The /// running flag is reset too. /// /// Returns the **preserved pair id** of the agent (the first non-`None` leaf; /// every leaf of this agent carries the same User↔agent pair id), or `None` /// when no hosting leaf was found. Persists only when something actually /// changed (a project with no such leaf is a no-op write). async fn invalidate_engine_link( &self, project: &Project, agent_id: &AgentId, ) -> Result, AppError> { let project = self.projects.load_project(project.id).await?; let mut doc = resolve_doc(self.fs.as_ref(), &project).await?; let mut changed = false; let mut pair_id: Option = None; for named in &mut doc.layouts { for (leaf_id, leaf_agent) in named.tree.agent_leaves() { if &leaf_agent != agent_id { continue; } // Capture the preserved pair id (stable across all leaves of this // agent) — used to relaunch with handoff re-injection (P7). if pair_id.is_none() { if let Some(cid) = named .tree .leaf(leaf_id) .and_then(|l| l.conversation_id.clone()) { pair_id = Some(cid); } } // Pure ops — only NodeNotFound is possible, which cannot happen // since `leaf_id` came from this very tree. // // Preserve `conversation_id` (stable pair id); only drop the // engine-side resumable cache (foreign to the new engine). named.tree = named .tree .set_cell_engine_session(leaf_id, None) .map_err(|e| AppError::Invalid(e.to_string()))?; named.tree = named .tree .set_agent_running(leaf_id, false) .map_err(|e| AppError::Invalid(e.to_string()))?; changed = true; } } if changed { persist_doc(self.fs.as_ref(), &project, &doc).await?; } Ok(pair_id) } /// Removes the **previous** profile's now-orphan permission files at the swap /// (lot LP3-4), best-effort. Because the run dir is **stable per agent id** /// (`agent_run_dir`), the old CLI's config (e.g. `.claude/settings.local.json`) /// survives a swap in the very same directory; left in place it is stale. /// /// Cleanup rule (Architect-validated): delete exactly /// `owned_replace_paths(old) − owned_replace_paths(new)` — only the /// **`Replace`-owned** files of the old projector that the new projector will not /// re-own (and thus re-write). `MergeToml` (co-owned, e.g. `.codex/config.toml`) /// files are **never** deleted. Examples: Claude→Codex removes /// `.claude/settings.local.json`; Claude→Claude removes nothing (empty diff); /// Codex→Claude removes nothing on the Replace side (Codex owns no Replace file). /// /// No-op when: no registry is wired, the previous profile is unknown (deleted), /// or it maps to no projector. Every delete is best-effort (`remove_file` is /// idempotent), so a missing file never fails the swap. This does **not** touch /// the stable pair id or the handoff (P8d) — it only removes engine config files. async fn cleanup_swapped_out_files( &self, project_root: &ProjectPath, agent_id: &AgentId, previous_profile: Option<&AgentProfile>, new_profile: &AgentProfile, ) { let Some(registry) = &self.projectors else { return; }; let Some(previous_profile) = previous_profile else { return; }; let Some(old_key) = select_projector_key(previous_profile) else { return; }; let Some(old_projector) = registry.get(old_key) else { return; }; let old_owned = old_projector.owned_replace_paths(); if old_owned.is_empty() { return; } // Paths the NEW projector will re-own (and re-write) ⇒ never delete these. let kept: Vec = select_projector_key(new_profile) .and_then(|key| registry.get(key)) .map(|p| p.owned_replace_paths()) .unwrap_or_default(); let run_dir = match agent_run_dir(project_root, agent_id) { Ok(dir) => dir, Err(_) => return, }; for rel in old_owned { if kept.iter().any(|k| k == &rel) { continue; } let path = RemotePath::new(join(&run_dir, &rel)); // Best-effort: a missing file / delete failure must never fail the swap. let _ = self.fs.remove_file(&path).await; } } /// Kills the agent's live PTY (if any) and relaunches it in the same cell with /// the new profile (step 6), composing [`LaunchAgent::execute`]. Returns the /// relaunched session, or `None` when the agent had no live session. /// /// `pair_id` is the **preserved** IdeA pair id (User↔agent) captured at step 5; /// it is threaded into the relaunch so the handoff (P7) is re-injected into the /// new engine and resume (P8c) is routed via `providers.json[new provider]` — /// the old engine's resumable is **never** replayed (providers.json for the new /// provider is empty ⇒ `SessionPlan::None`, fidelity carried by the handoff). /// When step 5 found no hosting leaf (e.g. a background session without a cell), /// the id is **derived** deterministically via `for_pair(User, agent)`. async fn relaunch_if_live( &self, input: &ChangeAgentProfileInput, pair_id: Option, ) -> Result, AppError> { // Résolution **polymorphe** de la session vivante sur les deux registres // (§17.4) : structuré d'abord, puis PTY. Un agent ne vit que dans un seul des // deux à la fois (invariant « 1 session/agent »). let killed = self.kill_live_session(&input.agent_id).await?; let Some(node_id) = killed else { // Aucune session vivante (ni structurée, ni PTY) ⇒ rien à relancer. return Ok(None); }; // Pair id préservé (step 5) ; en repli (session de fond sans cellule), on // dérive l'id de paire déterministe User↔agent — les cellules sont toujours // User↔agent, donc cette dérivation coïncide avec ce qu'eût porté la feuille. let conversation_id = Some(pair_id.unwrap_or_else(|| { ConversationId::for_pair( ConversationParty::User, ConversationParty::agent(input.agent_id), ) .to_string() })); let output = self .launch .execute(LaunchAgentInput { project: input.project.clone(), agent_id: input.agent_id, rows: input.rows, cols: input.cols, node_id, // Pair id **préservé** (id de paire IdeA stable) : le handoff (P7) est // réinjecté dans le nouveau moteur, et le resume (P8c) est routé via // providers.json[nouveau provider] — l'ancien resumable n'est jamais // repassé (providers.json du nouveau provider vide ⇒ moteur neuf). conversation_id, // Internal relaunch (no app-tauri composition root in scope) ⇒ the // MCP runtime is not injected here; `apply_mcp_config` falls back to // the minimal declaration. A profile-hot-swap that needs the real // endpoint is re-driven through the app-tauri launch path. mcp_runtime: None, }) .await?; Ok(Some(output.session)) } /// Tue la session vivante de `agent_id` de façon **polymorphe** (§17.4) : /// `shutdown()` si elle est structurée, kill PTY sinon. Retire la session de son /// registre **avant** l'arrêt, pour que la garde d'unicité du relance (sur les /// deux registres) ne voie plus de session vivante. /// /// Retourne `Some(node_id_hôte)` quand une session a été tuée (le node où /// relancer), `None` si l'agent n'avait aucune session vivante. La cellule hôte /// peut elle-même être absente (session de fond) ⇒ `Some(None)` est replié sur un /// node neuf côté relance via `LaunchAgentInput.node_id = None`. async fn kill_live_session( &self, agent_id: &AgentId, ) -> Result>, AppError> { // 1. Session structurée vivante ? ⇒ shutdown polymorphe. if let Some(structured) = &self.structured { if let Some(session_id) = structured.session_id_for_agent(agent_id) { let node_id = structured.node_for_agent(agent_id); if let Some(session) = structured.remove(&session_id) { session .shutdown() .await .map_err(|e| AppError::Process(e.to_string()))?; } return Ok(Some(node_id)); } } // 2. Sinon, session PTY vivante ? ⇒ kill PTY (chemin historique). let Some(session_id) = self.sessions.session_for_agent(agent_id) else { return Ok(None); }; let node_id = self.sessions.node_for_agent(agent_id); if let Some(handle) = self.sessions.remove(&session_id) { self.pty.kill(&handle).await?; } Ok(Some(node_id)) } } // --------------------------------------------------------------------------- // DeleteAgent // --------------------------------------------------------------------------- /// Input for [`DeleteAgent::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeleteAgentInput { /// The owning project. pub project: Project, /// The agent to remove. pub agent_id: AgentId, } /// Removes an agent from the project manifest. /// /// The orphaned `.md` file is left on disk: the [`FileSystem`] port exposes no /// delete, and keeping the file is the safe default (the user may want to recover /// the context). Re-creating an agent with the same name reuses a fresh path. pub struct DeleteAgent { contexts: Arc, events: Arc, } impl DeleteAgent { /// Builds the use case. #[must_use] pub fn new(contexts: Arc, events: Arc) -> Self { Self { contexts, events } } /// Drops the manifest entry for the agent. /// /// # Errors /// - [`AppError::NotFound`] if the agent is not in the manifest, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> { let manifest = self.contexts.load_manifest(&input.project).await?; let before = manifest.entries.len(); let entries: Vec = manifest .entries .into_iter() .filter(|e| e.agent_id != input.agent_id) .collect(); if entries.len() == before { return Err(AppError::NotFound(format!("agent {}", input.agent_id))); } let manifest = AgentManifest::new(manifest.version, entries) .map_err(|e| AppError::Invalid(e.to_string()))?; self.contexts .save_manifest(&input.project, &manifest) .await?; self.events.publish(DomainEvent::LayoutChanged { project_id: input.project.id, }); Ok(()) } } // --------------------------------------------------------------------------- // LaunchAgent // --------------------------------------------------------------------------- /// Input for [`LaunchAgent::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LaunchAgentInput { /// The owning project. pub project: Project, /// The agent to launch. pub agent_id: AgentId, /// Initial terminal height in rows. pub rows: u16, /// Initial terminal width in columns. pub cols: u16, /// The layout leaf hosting the session (a fresh node when `None`). pub node_id: Option, /// The persistent CLI conversation id currently recorded on the hosting cell, /// if any. `Some` means a previous conversation exists and the launch should /// **resume** it; `None` means a fresh cell (the launch may *assign* a new id /// when the profile supports it). The caller (which owns the layout) reads this /// from the leaf's [`domain::layout::LeafCell::conversation_id`]. pub conversation_id: Option, /// Runtime facts needed to write the **real** IdeA MCP server declaration /// (M5d). These are **OS/runtime data** (the IdeA executable path, the /// project's loopback endpoint) that live in `app-tauri` — they are *injected /// as data* from the composition root, never computed in `application` (which /// must not depend on `app-tauri`, cadrage v5 §0.3 / §7). /// /// `None` ⇒ no runtime injected (launches issued from inside `application`: /// the orchestrator's `spawn_agent`/`ask_agent`, a profile hot-swap relaunch, /// or tests). In that case [`apply_mcp_config`](LaunchAgent::apply_mcp_config) /// still honours the profile's `McpConfigStrategy`, but falls back to a /// **coherent minimal** declaration (the `idea mcp-server` command without the /// endpoint/project/requester args) rather than a project-bound one — see /// [`mcp_server_declaration`]. pub mcp_runtime: Option, } /// OS/runtime facts injected by the composition root (`app-tauri`) to materialise /// the **real** IdeA MCP server declaration in an agent's `.mcp.json` (cadrage v5 /// §2). Carrying these as **plain data** on [`LaunchAgentInput`] keeps /// `application` free of any `app-tauri` / `current_exe` / `mcp_endpoint` /// dependency: the endpoint stays computed by the *single source of truth* /// (`app-tauri::mcp_endpoint`) and only its **string** crosses the layer boundary. /// /// All four fields are the exact strings the spawned bridge expects on its command /// line (` mcp-server --endpoint --project --requester /// `); the `project_id` must already be in the **hyphen-free 32-hex /// `simple` form** consumed by the M5c handshake guard (`serve_peer`). #[derive(Debug, Clone, PartialEq, Eq)] pub struct McpRuntime { /// Absolute path to the IdeA executable (`std::env::current_exe()`), used as the /// declaration's `command` — the CLI spawns *this* binary in its `mcp-server` /// bridge mode. pub exe: String, /// The project's loopback endpoint string (`mcp_endpoint(project).as_cli_arg()`), /// passed as `--endpoint`. **Same source of truth** as the listener bound by /// `ensure_mcp_server` (cadrage v5 §2 coherence invariant). pub endpoint: String, /// The project id in the **hyphen-free 32-hex `simple` form** consumed by the /// M5c handshake guard, passed as `--project`. pub project_id: String, /// The launching agent's id, passed as `--requester` so the server tags /// `OrchestratorRequestProcessed.requester_id` with the real agent (cadrage v5 /// §1.4) instead of the frozen `"mcp"` placeholder. pub requester: String, } /// Descripteur d'une session **structurée** (IA, cellule chat) démarrée par /// [`LaunchAgent`] (ARCHITECTURE §17.4). Renvoyé en plus du snapshot /// [`TerminalSession`] quand le profil porte un `structured_adapter` : il identifie /// la session structurée vivante (registre [`StructuredSessions`]) sans faire fuiter /// l'`Arc` à travers la frontière de sortie du use case. #[derive(Debug, Clone, PartialEq, Eq)] pub struct StructuredSessionDescriptor { /// L'id de session IdeA de la session structurée (clé du registre). pub session_id: SessionId, /// L'agent IA pilotant cette session. pub agent_id: AgentId, /// La cellule (feuille de layout) qui héberge la vue chat. pub node_id: NodeId, /// L'id de conversation du moteur, s'il a déjà été attribué. pub conversation_id: Option, } /// Output of [`LaunchAgent::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LaunchAgentOutput { /// The created agent terminal session. /// /// **Toujours présent**, y compris pour un agent IA structuré (cellule chat) : /// dans ce cas c'est un snapshot `kind = Agent` portant l'id de la session /// structurée (cf. [`Self::structured`]), conformément à §17.4. Garde la sortie /// **non cassante** pour A/B et le câblage existant. pub session: TerminalSession, /// L'**id de paire IdeA** (pivot **logique** de la conversation, ARCHITECTURE /// §19.7) assigné par ce lancement, quand la cellule n'en portait pas encore. Le /// caller le persiste sur la feuille hôte via `set_cell_conversation` : c'est la /// clé qui retrouve **log + handoff** au (re)lancement (P7) et survit au swap de /// profil. **Ce n'est plus l'id de session moteur** (qui part désormais dans /// [`Self::engine_session_id`]). `None` quand rien de neuf n'a été assigné (reprise /// d'un id existant, mode dégradé, ou profil sans session) — rien à persister. pub assigned_conversation_id: Option, /// L'**id de session moteur** (resumable du provider : id Claude `--resume`, ou /// l'UUID minté pour `--session-id`) exposé/attribué par ce lancement, **distinct** /// de l'id de paire (ARCHITECTURE §19.7, lot P8a). Le caller le range dans le /// **cache** `LeafCell::engine_session_id` (via `set_cell_engine_session`) — jamais /// sur `conversation_id`. La **source de vérité** du resumable reste `providers.json` /// (lot P8b, hors P8a). `None` pour le chemin PTY/legacy ou quand le moteur n'expose /// aucun id : rien à cacher. pub engine_session_id: Option, /// Descripteur de la session **structurée**, présent **uniquement** quand ce /// lancement a routé vers un agent IA structuré (`profile.structured_adapter = /// Some(_)`, ARCHITECTURE §17.4). `None` pour le chemin PTY/terminal brut /// historique. Champ **additionnel et optionnel** : la sortie reste compatible /// avec les use cases A/B et le câblage qui ne lisent que `session` / /// `assigned_conversation_id`. pub structured: Option, /// Le **profil résolu** de l'agent lors d'un (re)lancement *effectif* (LS7, /// ARCHITECTURE §21.10-4). `LaunchAgent::execute` le résout déjà en interne (zéro /// I/O supplémentaire) ; on l'expose pour que le câblage app-tauri décide d'armer — /// ou non — le détecteur de limite **niveau 2** (PTY) via la règle de sélection /// `infrastructure::ratelimit::applies` (anti-double-détection N1/N2), sans /// reconstruire la frontière infra côté domaine. `None` sur une **réattache** /// (rebind/idempotent) où aucun profil n'est re-résolu — le câblage n'arme alors /// rien (best-effort, le tour est sans nouvelle session). pub profile: Option, } /// Registry mapping each [`ProjectorKey`] to its concrete /// [`PermissionProjector`] (lot LP3-3). Injected into [`LaunchAgent`] via the /// optional builder [`LaunchAgent::with_permission_projectors`]; the concrete /// projectors (Claude / Codex) live in `infrastructure` and are inserted at the /// composition root (lot LP3-5). Absent registry ⇒ no projection (legacy /// behaviour preserved). #[derive(Default, Clone)] pub struct PermissionProjectorRegistry { by_key: HashMap>, } impl PermissionProjectorRegistry { /// An empty registry. #[must_use] pub fn new() -> Self { Self::default() } /// Registers a projector under its own [`PermissionProjector::key`] and returns /// `self` (builder style), so a registry can be assembled fluently. #[must_use] pub fn with(mut self, projector: Arc) -> Self { self.insert(projector); self } /// Registers (or replaces) a projector under its own key. pub fn insert(&mut self, projector: Arc) { self.by_key.insert(projector.key(), projector); } /// Returns the projector registered for `key`, if any. #[must_use] pub fn get(&self, key: ProjectorKey) -> Option<&Arc> { self.by_key.get(&key) } } /// Selects the [`ProjectorKey`] for a launched agent's profile (lot LP3-3). /// /// Primary source: the profile's explicit `projector` field. **Migration /// fallback** for legacy profiles (e.g. an old `profiles.json` written before the /// field existed): the historical heuristic — a `CLAUDE.md` convention file ⇒ /// Claude; a `StructuredAdapter::Codex` or a `TomlConfigHome` MCP strategy ⇒ /// Codex. Anything else ⇒ `None` (no projection). fn select_projector_key(profile: &AgentProfile) -> Option { if let Some(key) = profile.projector { return Some(key); } // Legacy fallback: Claude is recognised by its `CLAUDE.md` convention file. let is_claude = matches!( &profile.context_injection, ContextInjection::ConventionFile { target } if target .rsplit(['/', '\\']) .next() .unwrap_or(target) .eq_ignore_ascii_case("CLAUDE.md") ); if is_claude { return Some(ProjectorKey::Claude); } // Legacy fallback: Codex is recognised by its structured adapter or its // CODEX_HOME-isolated TOML MCP strategy. if matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) { return Some(ProjectorKey::Codex); } if let Some(mcp) = &profile.mcp { if matches!(mcp.config, McpConfigStrategy::TomlConfigHome { .. }) { return Some(ProjectorKey::Codex); } } None } /// Launches an agent: resolve profile + context, prepare the invocation, apply /// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI. /// /// This is the orchestrating use case of L6 and therefore consumes several ports /// — each only for the slice it needs (Interface Segregation): the context store /// (agent `.md` + manifest), the profile store (resolve the runtime), the runtime /// (build the [`SpawnSpec`]), the filesystem (materialise a `conventionFile` /// context), and the PTY (spawn + optional stdin injection). pub struct LaunchAgent { contexts: Arc, profiles: Arc, runtime: Arc, fs: Arc, pty: Arc, skills: Arc, sessions: Arc, events: Arc, ids: Arc, /// Bounded recall of the project's memory index, injected into the convention /// file at activation (ARCHITECTURE §14.5.4). Best-effort by contract: an absent /// or empty memory yields an empty list, never blocking a launch. recall: Arc, /// Optional contextual embedder-suggestion check (LOT C3, §14.5.5), run /// best-effort right after the memory recall at activation — the moment an agent /// reads the project memory. `None` keeps the launcher independent of it (legacy /// wiring / tests). A failure here never affects the launch. embedder_suggestion: Option>, /// Optional permissions store (LP1). When absent, the launcher keeps its /// historical hardcoded CLI permission seeds. When present and a project or /// agent policy is configured, the resolved policy is projected to the CLI. permissions: Option>, /// Fabrique des sessions **structurées** (IA, §17). Injectée au câblage /// (composition root) via [`Self::with_structured`]. `None` ⇒ le routage §17.4 /// est désactivé et **tout** profil suit le chemin PTY historique (mode legacy / /// tests existants, qui restent verts sans changement de signature). session_factory: Option>, /// Registre des sessions structurées vivantes (§17.5), jumeau de /// [`TerminalSessions`]. Peuplé quand un agent IA structuré est lancé. `None` en /// même temps que [`Self::session_factory`]. structured: Option>, /// Provider du [`HandoffStore`] **par project root** (lot P7), pour réinjecter /// **best-effort** le résumé de reprise (`summary_md` + objectif) de la /// conversation de la cellule dans le convention file au (re)lancement. Injecté au /// câblage via [`Self::with_handoff_provider`] ; `None` ⇒ aucune injection de /// reprise (zéro régression pour les call sites/tests legacy). Un handoff /// absent/illisible ⇒ lancement normal, jamais d'échec. handoffs: Option>, /// Provider du [`ProviderSessionStore`] **par project root** (lot P8b), pour ranger /// **best-effort** l'id de session moteur (resumable du provider) sous la clé de /// paire IdeA dans `providers.json` après un lancement structuré. Injecté au câblage /// via [`Self::with_provider_session_provider`] ; `None` ⇒ aucune écriture (zéro /// régression pour les call sites/tests legacy). Une écriture en échec ⇒ lancement /// normal, jamais d'échec. provider_sessions: Option>, /// Registre des **projecteurs de permissions** par CLI (lot LP3-3). Injecté au /// câblage via [`Self::with_permission_projectors`] ; `None` ⇒ aucune projection /// (comportement historique préservé pour les call sites/tests legacy). projectors: Option>, } impl LaunchAgent { /// Builds the use case from its injected ports. #[must_use] #[allow(clippy::too_many_arguments)] pub fn new( contexts: Arc, profiles: Arc, runtime: Arc, fs: Arc, pty: Arc, skills: Arc, sessions: Arc, events: Arc, ids: Arc, recall: Arc, embedder_suggestion: Option>, ) -> Self { Self { contexts, profiles, runtime, fs, pty, skills, sessions, events, ids, recall, embedder_suggestion, permissions: None, session_factory: None, structured: None, handoffs: None, provider_sessions: None, projectors: None, } } /// Injects the per-CLI permission **projector registry** (lot LP3-3) used to /// translate the resolved [`EffectivePermissions`] into the launched CLI's /// concrete permission config. Without this call (legacy call sites / tests), /// no projection happens — signature of [`Self::new`] unchanged. #[must_use] pub fn with_permission_projectors( mut self, projectors: Arc, ) -> Self { self.projectors = Some(projectors); self } /// Injects the project permission store used to resolve effective agent /// permissions at launch time. #[must_use] pub fn with_permission_store(mut self, permissions: Arc) -> Self { self.permissions = Some(permissions); self } /// Branche le provider de **store de sessions provider (lot P8b)** sur ce launcher : /// après un lancement structuré exposant un id de session moteur, range /// `(pair_conversation_id, provider_key) → engine_session_id` dans `providers.json` /// du projet. Sans cet appel (cas legacy / tests existants), aucune écriture — /// signature de [`Self::new`] **inchangée**, donc aucun appelant existant ne casse. /// /// Builder additif (consomme `self`, retourne `Self`) : le câblage fait /// `LaunchAgent::new(...).with_provider_session_provider(provider)`. #[must_use] pub fn with_provider_session_provider( mut self, provider_sessions: Arc, ) -> Self { self.provider_sessions = Some(provider_sessions); self } /// Branche le provider de **handoff de reprise (lot P7)** sur ce launcher : au /// (re)lancement, si la cellule porte une `conversation_id` et qu'un [`Handoff`] /// existe pour elle, son `summary_md` (et son objectif) sont injectés comme section /// de contexte dans le convention file, à côté de la mémoire projet. Sans cet appel /// (cas legacy / tests existants), aucune section de reprise n'est injectée — /// signature de [`Self::new`] **inchangée**, donc aucun appelant existant ne casse. /// /// Builder additif (consomme `self`, retourne `Self`) : le câblage fait /// `LaunchAgent::new(...).with_handoff_provider(provider)`. #[must_use] pub fn with_handoff_provider(mut self, handoffs: Arc) -> Self { self.handoffs = Some(handoffs); self } /// Branche le **routage structuré (§17.4)** sur ce launcher : fournit la fabrique /// [`AgentSessionFactory`] et le registre [`StructuredSessions`] injectés au /// composition root. Sans cet appel (cas legacy / tests existants), `execute` /// route **tout** vers le PTY — signature de [`Self::new`] **inchangée**, donc /// aucun appelant existant ne casse. /// /// Builder (consomme `self`, retourne `Self`) pour rester additif : le câblage /// fait `LaunchAgent::new(...).with_structured(factory, registry)`. #[must_use] pub fn with_structured( mut self, session_factory: Arc, structured: Arc, ) -> Self { self.session_factory = Some(session_factory); self.structured = Some(structured); self } /// Resolves the Markdown bodies of an agent's assigned skills, in the /// **manifest order** (deterministic). A skill that no longer exists in its /// store (deleted out from under the assignment) is silently skipped — a /// dangling [`domain::SkillRef`] must not block a launch. /// /// # Errors /// [`AppError::Store`] on any store failure other than a missing skill. async fn resolve_skills( &self, agent: &Agent, root: &ProjectPath, ) -> Result, AppError> { let mut out = Vec::with_capacity(agent.skills.len()); for skill_ref in &agent.skills { match self .skills .get(skill_ref.scope, root, skill_ref.skill_id) .await { Ok(skill) => out.push(skill), Err(StoreError::NotFound) => {} Err(e) => return Err(e.into()), } } Ok(out) } /// Resolves the project's memory recall (index/hooks) to inject into the /// convention file at activation (ARCHITECTURE §14.5.4), mirroring /// [`Self::resolve_skills`]. The query text is the agent's persona `.md` /// (irrelevant to the naïve adapter, but already the right query for the future /// semantic recall — zero refactor at étage 2), bounded by /// [`AGENT_MEMORY_RECALL_BUDGET`]. /// /// **Best-effort, never blocking**: an absent or empty memory yields an empty /// list by the [`MemoryRecall`] contract, and any unexpected error degrades to /// an empty list rather than failing the launch (exactly like a dangling skill). async fn resolve_memory(&self, root: &ProjectPath, persona: &str) -> Vec { let query = MemoryQuery { text: persona.to_owned(), token_budget: AGENT_MEMORY_RECALL_BUDGET, }; self.recall.recall(root, &query).await.unwrap_or_default() } /// Résout **best-effort** le handoff de reprise de la cellule (lot P7), calqué sur /// [`Self::resolve_memory`]. L'injection n'a lieu que si : /// - la cellule porte une `conversation_id` ([`LaunchAgentInput::conversation_id`]), /// - le provider de handoff est câblé ([`Self::with_handoff_provider`]), /// - cette chaîne se parse en [`ConversationId`] (UUID), et /// - un [`Handoff`] existe effectivement pour cette conversation. /// /// **Jamais bloquant** : un parse en échec, l'absence de provider/handoff, ou /// toute erreur du store dégradent vers `None` (pas de section de reprise), exactly /// comme une mémoire absente — un lancement n'est jamais cassé par la reprise. async fn resolve_handoff( &self, root: &ProjectPath, cell_conversation_id: Option<&str>, ) -> Option { let provider = self.handoffs.as_ref()?; let raw = cell_conversation_id?; // Le `conversation_id` d'une cellule est un identifiant de paire (UUID, §C3). // Un id non-UUID (ancien id CLI, donnée corrompue) ⇒ pas d'injection. let conversation = ConversationId::from_uuid(uuid::Uuid::parse_str(raw).ok()?); let store = provider.handoff_store_for(root)?; // Toute erreur de lecture/désérialisation ⇒ pas d'injection (best-effort). store.load(conversation).await.ok().flatten() } /// Reads the shared project context from `.ideai/CONTEXT.md`. /// /// A missing file is normal for existing projects and simply omits the /// project-context section from the generated model context. async fn resolve_project_context(&self, project: &Project) -> Result { match self.fs.read(&project_context_path(project)).await { Ok(bytes) => String::from_utf8(bytes) .map_err(|e| AppError::Store(format!("project context is not UTF-8: {e}"))), Err(FsError::NotFound(_)) => Ok(String::new()), Err(e) => Err(AppError::FileSystem(e.to_string())), } } /// Executes the launch. /// /// Step order is contractually significant (and unit-tested): resolve the /// agent + context, **`prepare_invocation`**, **apply the injection plan** /// (write a `conventionFile` / set an env var), then **`pty.spawn`** at the /// resolved `cwd`, and finally pipe the context on stdin for the `Stdin` /// strategy. /// /// # Errors /// - [`AppError::NotFound`] if the agent or its profile is unknown, /// - [`AppError::Invalid`] for a zero-sized terminal, /// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on /// the respective port failures. pub async fn execute(&self, input: LaunchAgentInput) -> Result { let size = PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?; // 1. Resolve the agent from the manifest (name + profile + md_path). let manifest = self.contexts.load_manifest(&input.project).await?; let entry = manifest .entries .iter() .find(|e| e.agent_id == input.agent_id) .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; let agent = entry .to_agent() .map_err(|e| AppError::Invalid(e.to_string()))?; // 1b. Enforce the "one live session per agent" invariant (decision: an // agent is a singleton that runs in a single cell at a time). This // runs AFTER the NotFound resolution above (so an unknown agent still // errors NotFound) but BEFORE any I/O (run dir, seed, spawn). If the // agent already owns a live session: // - with a requested node → rebind the live session to that cell and // return it without respawning; // - without a requested node → idempotent background/no-op launch: // return the existing session without respawning. // The resume path (agent dead ⇒ no live session) is unaffected. // // Invariant « 1 session vivante/agent » **généralisé aux deux registres** // (§17.4) : un agent est vivant s'il a une session PTY *ou* structurée. // On consulte d'abord le registre PTY (chemin historique), puis le // registre structuré (le cas échéant). // // R0a — discrimination réattache-de-vue vs second lancement (cadrage v5 // §3.2, Trou A). Un agent est un **singleton** : une seule session vivante. // Quand l'agent est déjà vivant, on distingue (cf. [`Self::reattach_decision`]) : // - **réattache de vue** (rebind sans respawn) : le `node_id` demandé est // le node hôte vivant, OU la cellule porte une `conversation_id` // (réattache explicite — la cellule sait que l'agent tournait) ; // - **lancement background/idempotent** : ni node, ni conversation ⇒ no-op // (rend la session existante, pas de respawn) ; // - **second lancement neuf** : on vise un **autre** node, sans signal de // réattache ⇒ refus [`AppError::AgentAlreadyRunning`] (node hôte rapporté). if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) { let host_node = self.sessions.node_for_agent(&input.agent_id); match reattach_decision(input.node_id, host_node, input.conversation_id.as_deref()) { ReattachDecision::Rebind { node_id } => { if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id) { return Ok(LaunchAgentOutput { session, assigned_conversation_id: None, engine_session_id: None, structured: None, // Réattache (rebind de vue) : aucun profil re-résolu. profile: None, }); } } ReattachDecision::Idempotent => {} ReattachDecision::Refuse { node_id } => { return Err(AppError::AgentAlreadyRunning { agent_id: input.agent_id, node_id, }); } } // Idempotent (or a rebind that found no entry to move) — hand back the // already-registered session, no respawn, nothing new to persist. if let Some(session) = self.sessions.session(&existing_id) { return Ok(LaunchAgentOutput { session, assigned_conversation_id: None, engine_session_id: None, structured: None, // Idempotent (pas de respawn) : aucun profil re-résolu. profile: None, }); } } // Garde structurée (§17.4) : même sémantique côté registre IA. R0a appliqué de // façon identique — rebind de la cellule-vue pour une réattache légitime, // idempotence sans node/conversation, refus d'un second lancement neuf ailleurs. if let Some(structured) = &self.structured { if let Some(existing) = structured.session_for_agent(&input.agent_id) { let host_node = structured.node_for_agent(&input.agent_id); let node_id = match reattach_decision( input.node_id, host_node, input.conversation_id.as_deref(), ) { ReattachDecision::Rebind { node_id } => { let _ = structured.rebind_agent_node(&input.agent_id, node_id); node_id } // Idempotent — garder le node hôte courant, sinon un node neuf. ReattachDecision::Idempotent => host_node.unwrap_or_else(NodeId::new_random), ReattachDecision::Refuse { node_id } => { return Err(AppError::AgentAlreadyRunning { agent_id: input.agent_id, node_id, }); } }; return Ok(LaunchAgentOutput { session: structured_snapshot(&existing, input.agent_id, node_id, size), assigned_conversation_id: None, // Rebind de vue : aucune (ré)assignation ⇒ rien de neuf à cacher. engine_session_id: None, structured: Some(StructuredSessionDescriptor { session_id: existing.id(), agent_id: input.agent_id, node_id, conversation_id: existing.conversation_id(), }), // Réattache structurée (rebind de vue) : aucun profil re-résolu. profile: None, }); } } // 2. Read its context and resolve its profile. let content = self .contexts .read_context(&input.project, &agent.id) .await?; let profile = self .profiles .list() .await? .into_iter() .find(|p| p.id == agent.profile_id) .ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?; // 3. Compute and create the agent's isolated run directory // `/.ideai/run//` (ARCHITECTURE §14.1). The PTY cwd is // *never* the project root: each agent gets its own directory so that N // instances of the same profile never collide on a single conventional // file (CLAUDE.md, …). This is the only I/O in the cwd resolution; the // runtime's `prepare_invocation` stays pure. let run_dir = agent_run_dir(&input.project.root, &agent.id) .map_err(|e| AppError::Invalid(e.to_string()))?; self.fs .create_dir_all(&RemotePath::new(run_dir.as_str().to_owned())) .await?; let effective_permissions = self .resolve_effective_permissions(&input.project, agent.id) .await?; // 3b. (Permission projection moved to step 5c, after the convention file and // the MCP config, so both the structured and PTY paths inherit it — see // `apply_permission_projection`.) // 4. Prepare the invocation (pure): command + args + injection plan + cwd. // The run dir is passed as the cwd base; the profile's `{agentRunDir}` // placeholder resolves against it. let prepared = PreparedContext { content: content.clone(), relative_path: agent.context_path.clone(), project_root: input.project.root.as_str().to_owned(), }; // 4a. Resolve the session intention (T4). The conversation id is a property // of the *cell*, not the PTY: the caller (which owns the layout) passes // the cell's current `conversation_id`. Any id this launch *assigns* is // returned in the output so the caller persists it on the leaf. let (session_plan, assigned_conversation_id) = self .resolve_session_plan(&profile, input.conversation_id.clone(), &input.project.root) .await; let mut spec = self.runtime .prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?; // 5. Resolve the agent's assigned skills (their `.md` bodies), then apply // the injection plan side effects *before* spawning. let skills = self.resolve_skills(&agent, &input.project.root).await?; let project_context = self.resolve_project_context(&input.project).await?; let memory = self .resolve_memory(&input.project.root, content.as_str()) .await; // Reprise conversationnelle (lot P7) : best-effort, additif. Si la cellule a une // conversation et qu'un handoff existe, son résumé est injecté dans le convention // file (à côté de la mémoire projet). Indépendant du provider/resumable id. let handoff = self .resolve_handoff(&input.project.root, input.conversation_id.as_deref()) .await; // Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent // has just read the project memory, so this is the moment to check whether a // semantic embedder would now help. Fully isolated from the launch outcome — // an error or absence of the check never affects activation. if let Some(check) = &self.embedder_suggestion { let _ = check .execute(crate::embedder::CheckEmbedderSuggestionInput { project_id: input.project.id, project_root: input.project.root.clone(), }) .await; } self.apply_injection( &input.project, &agent.context_path, &content, &project_context, &skills, &memory, handoff.as_ref(), profile.mcp.is_some(), &mut spec, ) .await?; // 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ── // Strictement APRÈS le convention file (étape 5) et AVANT le spawn / // `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`, // IdeA matérialise SA config MCP au format de CETTE CLI, dans le **même** // run dir isolé que le convention file et le seed de permissions. `None` // ⇒ aucun write/flag/env (chemin actuel inchangé, zéro régression). self.apply_mcp_config( &profile, &run_dir, &input.project.root, input.mcp_runtime.as_ref(), &mut spec, ) .await; // 5c. ── PROJECTION DES PERMISSIONS (lot LP3-3) ── // Strictement APRÈS le convention file (5) ET la conf MCP (5a), donc // AVANT le split structuré/PTY (5b) et le spawn : les DEUX chemins // héritent de la projection (fichiers du plan écrits dans le run dir, // `args`/`env` foldés dans `spec`). `None` registre / profil non // projetable / `eff == None` ⇒ no-op (prompting natif conservé). self.apply_permission_projection( &profile, &run_dir, &input.project.root, effective_permissions.as_ref(), &mut spec, ) .await?; // 5d. ── PLAN DE SANDBOX OS (lot LP4-3) ── // Strictement APRÈS la résolution des permissions (3) et la projection // advisory (5c), AVANT le split structuré/PTY : `compile_sandbox_plan` // est **pur** (domaine) — le launch path ne fait qu'orchestrer // `EffectivePermissions` (déjà lue du store en 3) → `SandboxPlan` et // remplir `spec.sandbox`. `eff == None` (rien posé) ⇒ `None` : aucun plan, // comportement natif conservé (invariant produit). L'enforcer concret // (Landlock/Noop) est injecté côté PTY au composition root. spec.sandbox = compile_sandbox_plan( effective_permissions.as_ref(), &SandboxContext { project_root: input.project.root.as_str(), run_dir: run_dir.as_str(), }, ); // 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ── // Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le // run dir (étape 5) ; la CLI structurée le lira à chaque tour // (incarnation « un run par tour »). Si le profil porte un // `structured_adapter` ET que la fabrique structurée est câblée, on // démarre une `AgentSession` via le port — **pas** de `pty.spawn` — et on // l'enregistre dans `StructuredSessions`. Sinon : chemin PTY inchangé. if let (Some(factory), Some(structured), true) = ( self.session_factory.as_ref(), self.structured.as_ref(), profile.structured_adapter.is_some(), ) { // ── Clé **logique** de la cellule = id de paire IdeA (ARCHITECTURE §19.7, // lot P8a) ── // - cellule porteuse d'un `conversation_id` (resume, ou lancement délégué // qui a déjà injecté l'id de paire A↔B) ⇒ c'est **déjà** l'id de paire, // on le conserve tel quel ; // - cellule structurée **neuve** (lancement direct utilisateur, aucun // requester) ⇒ on **dérive** `pair(User, agent)` via la fonction domaine // pure partagée avec `resolve_conversation` (aucun couplage à // l'orchestrateur). C'est cet id — et **non** l'id de session moteur — // qui retrouve log + handoff au (re)lancement (P7) et survit au swap. let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| { ConversationId::for_pair( ConversationParty::User, ConversationParty::agent(agent.id), ) .to_string() }); return self .launch_structured( factory.as_ref(), structured, &agent, &profile, &prepared, &run_dir, &session_plan, pair_conversation_id, &input.project.root, input.node_id, size, spec.sandbox.as_ref(), ) .await; } // 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere. let handle = self.pty.spawn(spec.clone(), size).await?; let session_id = handle.session_id; // 7. For the Stdin strategy, pipe the context once the PTY is live. if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) { self.pty.write(&handle, content.as_str().as_bytes())?; } let node_id = input.node_id.unwrap_or_else(NodeId::new_random); let mut session = TerminalSession::starting( session_id, node_id, spec.cwd.clone(), SessionKind::Agent { agent_id: agent.id }, size, ); session.status = SessionStatus::Running; self.sessions.insert(handle, session.clone()); self.events.publish(DomainEvent::AgentLaunched { agent_id: agent.id, session_id, }); Ok(LaunchAgentOutput { session, assigned_conversation_id, // Chemin PTY/terminal brut : pas de session moteur structurée à cacher. engine_session_id: None, structured: None, // Lancement effectif : profil résolu exposé pour la sélection du détecteur // de limite niveau 2 (§21.10-4) côté câblage. profile: Some(profile.clone()), }) } /// Démarre un agent IA **structuré** (§17.4) : crée une [`AgentSession`] via la /// fabrique, l'enregistre dans [`StructuredSessions`] avec son `agent_id`/`node_id` /// et publie [`DomainEvent::AgentLaunched`]. **Aucun `pty.spawn`** — la cellule est /// de type chat. /// /// L'`assigned_conversation_id` calculé en amont (`resolve_session_plan`) est /// préservé, mais l'id réellement attribué par le moteur (s'il diffère ou s'il /// apparaît seulement après le démarrage) prime quand il est disponible : c'est /// `session.conversation_id()` qui fait foi pour la persistance sur la cellule. #[allow(clippy::too_many_arguments)] async fn launch_structured( &self, factory: &dyn AgentSessionFactory, structured: &Arc, agent: &Agent, profile: &AgentProfile, prepared: &PreparedContext, run_dir: &ProjectPath, session_plan: &SessionPlan, pair_conversation_id: String, root: &ProjectPath, node_id: Option, size: PtySize, sandbox: Option<&SandboxPlan>, ) -> Result { // Relaie le plan de sandbox OS (lot LP4-4) à la fabrique : `spec.sandbox`, // déjà compilé (pur, domaine) en step 5d. `None` ⇒ exécution native inchangée. let session = factory .start(profile, prepared, run_dir, session_plan, sandbox) .await .map_err(|e| AppError::Process(e.to_string()))?; let session_id = session.id(); let node_id = node_id.unwrap_or_else(NodeId::new_random); // Enregistre la session vivante (invariant « 1 session/agent » : déjà gardé en // amont sur les deux registres). structured.insert(Arc::clone(&session), agent.id, node_id); // ── SÉPARATION DES DEUX CLÉS (ARCHITECTURE §19.7, lot P8a) ── // - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur // `LeafCell.conversation_id`. C'est lui qui retrouve log + handoff (P7) et // survit au swap. Stable, indépendant du provider. // - **id de session moteur** (`session.conversation_id()`) : resumable du // provider (Claude/Codex). Rangé **séparément** dans le cache // `LeafCell.engine_session_id` (via `set_cell_engine_session`) — **jamais** // sur `conversation_id`. `None` si le moteur n'expose encore aucun id. let engine_session_id = session.conversation_id(); // ── P8b — range le resumable moteur par provider dans `providers.json` // (best-effort, jamais bloquant) ── // On persiste `(pair_conversation_id, provider_key) → engine_session_id` // **uniquement** quand le moteur expose un id ET que le provider est câblé. // La clé provider est dérivée de la **famille de moteur** (`structured_adapter`), // pas de l'uuid d'instance du profil : un swap vers la même famille réutilise la // même entrée. Toute défaillance (provider absent, pair_conversation_id non-UUID, // erreur du store) dégrade silencieusement — un lancement n'est **jamais** cassé // par cette persistance (lot P8c lira ce store pour `--resume`). if let Some(engine_id) = engine_session_id.as_deref() { self.persist_provider_session(root, profile, &pair_conversation_id, engine_id) .await; } let snapshot = structured_snapshot(&session, agent.id, node_id, size); self.events.publish(DomainEvent::AgentLaunched { agent_id: agent.id, session_id, }); Ok(LaunchAgentOutput { session: snapshot, // Cellule ⇐ id de **paire** (pivot logique de reprise §15.2 / §19.7). assigned_conversation_id: Some(pair_conversation_id), // Cache moteur ⇐ id resumable du provider (distinct de la paire). engine_session_id: engine_session_id.clone(), structured: Some(StructuredSessionDescriptor { session_id, agent_id: agent.id, node_id, conversation_id: engine_session_id, }), // Lancement structuré effectif : profil résolu exposé (le tap niveau 2 PTY ne // l'arme pas — `applies` est faux pour un profil structuré, §21.10-4). profile: Some(profile.clone()), }) } /// Range **best-effort** (lot P8b) le resumable moteur `engine_id` sous la clé /// `(pair_conversation_id, provider_key)` dans `providers.json` du projet. /// /// `provider_key` est dérivée de la **famille de moteur** du profil /// ([`StructuredAdapter::provider_key`]) ; un profil sans `structured_adapter` (ne /// devrait pas arriver sur ce chemin structuré) ⇒ skip. `pair_conversation_id` est /// une chaîne : un id non-UUID (donnée legacy/corrompue) ⇒ skip. Le provider absent /// ([`Self::with_provider_session_provider`] non appelé) ⇒ skip. Toute erreur du /// store est tracée et avalée : **jamais** d'échec ni de panique — le lancement a /// déjà réussi à ce stade. async fn persist_provider_session( &self, root: &ProjectPath, profile: &AgentProfile, pair_conversation_id: &str, engine_id: &str, ) { let Some(provider) = self.provider_sessions.as_ref() else { return; }; let Some(adapter) = profile.structured_adapter else { return; }; // Le `conversation_id` d'une cellule est un id de paire (UUID, §C3). let Ok(uuid) = uuid::Uuid::parse_str(pair_conversation_id) else { return; }; let conversation = ConversationId::from_uuid(uuid); let Some(store) = provider.provider_session_store_for(root) else { return; }; // Best-effort : toute erreur du store est avalée — le lancement a déjà réussi, // la persistance du resumable ne doit jamais le casser (lot P8c lira ce store). let _ = store .set(conversation, adapter.provider_key(), engine_id) .await; } /// Resolves the [`SessionPlan`] for a launch from the profile's session /// strategy and the cell's current `conversation_id` (T4). /// /// Returns the plan *and* — when this launch mints a fresh id — that id, so the /// caller can persist it on the hosting leaf. The id is only generated for an /// `Assign` (profile has a `session` block with an `assign_flag`, and the cell /// had no id yet); every other branch returns `None` (nothing to persist). /// /// Branches: /// - cell already has an id ⇒ [`SessionPlan::Resume`] (reopen) — no new id; /// - no id, profile has `session.assign_flag` ⇒ mint a UUID, [`SessionPlan::Assign`]; /// - no id, profile has `session` but no `assign_flag` (degraded) ⇒ /// [`SessionPlan::None`] (nothing to resume on a first launch; the adapter /// uses the bare resume flag only on later reopens); /// - profile without a `session` block ⇒ [`SessionPlan::None`] (legacy). async fn resolve_session_plan( &self, profile: &AgentProfile, cell_conversation_id: Option, root: &ProjectPath, ) -> (SessionPlan, Option) { // Profil **structuré** (§17, lot P8c) : le `conversation_id` de la cellule est // un **id de paire IdeA**, jamais le resumable du moteur. Le passer en `--resume` // serait invalide (Claude/Codex attendent *leur* id). On route donc le resume // moteur via `providers.json`, keyé par `(id de paire, provider_key)` (écrit en // P8b). Rien à pré-attribuer ici : l'id de paire est géré en aval (P8a), le // resumable moteur est capté/rapporté par P8b au fil de l'exécution. if let Some(adapter) = profile.structured_adapter { // Store câblé : on lit le resumable moteur rangé pour cette paire+provider. if let Some(provider) = self.provider_sessions.as_ref() { let engine = match &cell_conversation_id { // L'id de paire (UUID) → resumable moteur via providers.json. Some(raw) => match uuid::Uuid::parse_str(raw) { Ok(uuid) => match provider.provider_session_store_for(root) { Some(store) => store .get(ConversationId::from_uuid(uuid), adapter.provider_key()) .await .ok() .flatten(), None => None, }, Err(_) => None, }, None => None, }; return match engine { // Vrai resumable moteur connu ⇒ resume propre du moteur. Some(engine_id) => ( SessionPlan::Resume { conversation_id: engine_id, }, None, ), // Aucun resumable (None / parse KO / store absent / pas d'id de // cellule) ⇒ premier lancement propre. Le moteur attribuera/rapportera // son id (capté par P8b) ; la continuité du travail est assurée par le // handoff (P7). On ne passe **jamais** l'id de paire en `--resume`. None => (SessionPlan::None, None), }; } // Store **non câblé** (tests/legacy) : repli gracieux sur l'ancien // comportement — une conversation sur la cellule ⇒ `Resume`, sinon `None`. // Garantit zéro régression des tests qui ne câblent pas le store. return match cell_conversation_id { Some(conversation_id) => (SessionPlan::Resume { conversation_id }, None), None => (SessionPlan::None, None), }; } // Profils **non structurés** (PTY/TUI) : la cellule porte toujours l'id moteur. // Comportement **inchangé** depuis T4. let Some(session) = &profile.session else { return (SessionPlan::None, None); }; // The cell already carries a conversation: resume it (no new id minted). if let Some(conversation_id) = cell_conversation_id { return (SessionPlan::Resume { conversation_id }, None); } // Fresh cell. Only mint+assign an id when the profile can assign one; // otherwise (degraded mode) the first launch has nothing to resume. if session.assign_flag.is_some() { let conversation_id = self.ids.new_uuid().to_string(); ( SessionPlan::Assign { conversation_id: conversation_id.clone(), }, Some(conversation_id), ) } else { (SessionPlan::None, None) } } /// Projects the resolved [`EffectivePermissions`] into the launched CLI's /// concrete permission config (lot LP3-3), replacing the historical per-CLI /// seeds. Selects the profile's [`PermissionProjector`] (cf. /// [`select_projector_key`]), asks it for a pure [`domain::permission::PermissionProjection`] /// (a plan), then **applies** that plan: materialises its files in the run dir /// and folds its `args`/`env` into `spec`. /// /// File ownership drives the write regime: /// - [`ProjectedFile::Replace`] ⇒ **clobber** (always overwrite). Unlike the old /// non-clobbering seed, this lets a re-projection (e.g. after a profile swap) /// refresh an IdeA-owned file. /// - [`ProjectedFile::MergeToml`] ⇒ merge only the **managed** tables/keys into /// any existing file (via the shared TOML helpers), preserving everything else. /// /// No-op when: no registry is wired, the profile has no matching projector, or /// the resolved permissions are `None` (the projector returns an empty /// projection — native prompting preserved). /// /// # Errors /// [`AppError::FileSystem`] if a plan file cannot be written. async fn apply_permission_projection( &self, profile: &AgentProfile, run_dir: &ProjectPath, project_root: &ProjectPath, permissions: Option<&EffectivePermissions>, spec: &mut SpawnSpec, ) -> Result<(), AppError> { let Some(registry) = &self.projectors else { return Ok(()); }; let Some(key) = select_projector_key(profile) else { return Ok(()); }; let Some(projector) = registry.get(key) else { return Ok(()); }; let ctx = ProjectionContext { project_root: project_root.as_str(), run_dir: run_dir.as_str(), }; let projection = projector.project(permissions, &ctx); for file in &projection.files { match file { ProjectedFile::Replace { rel_path, contents } => { self.ensure_run_dir_parent(run_dir, rel_path).await?; let path = RemotePath::new(join(run_dir, rel_path)); // Clobber: IdeA owns this file; overwriting refreshes it on re-projection. self.fs.write(&path, contents.as_bytes()).await?; } ProjectedFile::MergeToml { rel_path, managed_tables, managed_keys, contents, } => { self.ensure_run_dir_parent(run_dir, rel_path).await?; let path = RemotePath::new(join(run_dir, rel_path)); let existing = match self.fs.read(&path).await { Ok(bytes) => String::from_utf8(bytes).unwrap_or_default(), Err(_) => String::new(), }; let merged = merge_managed_toml(&existing, managed_tables, managed_keys, contents); self.fs.write(&path, merged.as_bytes()).await?; } } } // Fold the plan's launch args/env into the spec, before the structured/PTY // split so both inherit them. spec.args.extend(projection.args.iter().cloned()); spec.env.extend(projection.env.iter().cloned()); Ok(()) } /// Ensures the parent directory of `/` exists (e.g. the /// `.claude/` or `.codex/` subdir), so a projected file write never fails on a /// missing directory. A `rel_path` with no separator needs no extra dir. async fn ensure_run_dir_parent( &self, run_dir: &ProjectPath, rel_path: &str, ) -> Result<(), AppError> { if let Some((parent, _)) = rel_path.rsplit_once(['/', '\\']) { if !parent.is_empty() { self.fs .create_dir_all(&RemotePath::new(format!("{}/{parent}", run_dir.as_str()))) .await?; } } Ok(()) } async fn resolve_effective_permissions( &self, project: &Project, agent_id: AgentId, ) -> Result, AppError> { let Some(store) = &self.permissions else { return Ok(None); }; let doc = store.load_permissions(project).await?; Ok(doc.resolve_for(agent_id)) } /// Applies the context-injection plan that must happen *before* spawn: /// materialising a `conventionFile` context (write the `.md` to `/target`) /// or attaching the on-disk context path to an environment variable. `Args` is /// already folded into the spec by the runtime; `Stdin` is handled post-spawn. #[allow(clippy::too_many_arguments)] async fn apply_injection( &self, project: &Project, context_rel_path: &str, content: &MarkdownDoc, project_context: &str, skills: &[Skill], memory: &[MemoryIndexEntry], handoff: Option<&Handoff>, mcp_enabled: bool, spec: &mut SpawnSpec, ) -> Result<(), AppError> { match spec.context_plan.clone() { Some(ContextInjectionPlan::File { target }) => { // conventionFile (ARCHITECTURE §14.1): IdeA *generates* the // conventional file (e.g. CLAUDE.md) inside the agent's isolated // run directory — `spec.cwd` is that run dir, never the project // root, so there is zero collision between agents. The document is // composed: an absolute project-root header (so the agent knows // where to operate, since its cwd is *not* the root), the agent's // persona `.md`, then the bodies of its assigned skills (§14.2). let document = compose_convention_file( project.root.as_str(), project_context, content.as_str(), skills, memory, handoff, mcp_enabled, ); let path = RemotePath::new(join(&spec.cwd, &target)); self.fs.write(&path, document.as_bytes()).await?; } Some(ContextInjectionPlan::Env { var }) => { // Hand the CLI the absolute path of the agent's `.md` (which lives at // `/.ideai/`) via the environment variable. let abspath = join(&project.root, &format!(".ideai/{context_rel_path}")); spec.env.push((var, abspath)); } // Args were folded into spec.args by prepare_invocation; Stdin is // applied after the PTY is live. Some(ContextInjectionPlan::Args { .. }) | Some(ContextInjectionPlan::Stdin) | None => {} } Ok(()) } /// Materialises the IdeA MCP server config for **this** CLI when the profile /// carries an [`McpCapability`] (cadrage v3, Décision 3). Runs **after** the /// convention file (`apply_injection`) and **before** the spawn / `factory.start`, /// in the **same** isolated run dir as the convention file and the permission seed. /// /// Dispatch by [`McpConfigStrategy`]: /// - `ConfigFile { target }` → write `/` (e.g. `.mcp.json`) with /// the IdeA MCP server declaration. **Best-effort** (any write/exists failure is /// swallowed so it **never** fails the launch), with two clobber regimes: /// * with an injected [`McpRuntime`] (real app-tauri launch) the file is /// **regenerated and clobbered on every (re)launch**, exactly like the /// convention file. The declaration's `command` carries the **stable** IdeA /// exe path (`$APPIMAGE`) and the project's live endpoint — both drift between /// runs (the AppImage mount path changes at each remount/reboot), so a stale /// `.mcp.json` would point the bridge at a dead binary/socket. `.mcp.json` is /// IdeA-managed (not user-edited), so clobbering is safe; /// * without a runtime (orchestrator / hot-swap / tests) only a degraded minimal /// declaration is available, so the write stays **non-clobbering** (mirrors /// [`Self::seed_cli_permissions`]) — never overwriting a real declaration. /// - `Flag { flag }` → append the flag + run-dir config path to [`SpawnSpec::args`]. /// - `Env { var }` → append the variable (run-dir config path) to [`SpawnSpec::env`]. /// /// `profile.mcp == None` ⇒ no-op: no write, no flag, no env (current path, zero /// regression). /// /// MCP runtime (M5d): when the composition root injects a [`McpRuntime`], the /// declaration written for a `ConfigFile` strategy is the **real** one — it points /// the spawned `idea mcp-server` bridge at the project's exact loopback endpoint /// (same source of truth as `ensure_mcp_server`, cadrage v5 §2). When no runtime is /// injected (launches issued from inside `application`), a coherent **minimal** /// declaration is written instead (see [`mcp_server_declaration`]). `Flag`/`Env` /// are unaffected by the runtime: they keep passing the run-dir config path. async fn apply_mcp_config( &self, profile: &AgentProfile, run_dir: &ProjectPath, project_root: &ProjectPath, runtime: Option<&McpRuntime>, spec: &mut SpawnSpec, ) { let Some(mcp) = &profile.mcp else { return; }; match &mcp.config { domain::profile::McpConfigStrategy::ConfigFile { target } => { let path = RemotePath::new(join(run_dir, target)); let declaration = mcp_server_declaration(mcp.transport, runtime); match runtime { // Real launch (app-tauri injected the runtime): the declaration // carries the **stable** IdeA executable path (`$APPIMAGE`, resolved // by `idea_exe_path`) and the project's live loopback endpoint. Both // can drift between runs — the AppImage internal mount path changes // at every remount/reboot — so the file MUST be regenerated and // **clobbered** on every (re)launch, exactly like the convention file // (`apply_injection`). `.mcp.json` is IdeA-managed, not user-edited, // so there is nothing to preserve. Best-effort: a write failure must // never fail the launch. Some(_) => { let _ = self.fs.write(&path, declaration.as_bytes()).await; } // Internal launch (orchestrator / hot-swap / tests): we only have a // degraded **minimal** declaration (no endpoint/project/requester). // Stay non-clobbering — mirrors `seed_cli_permissions` — so we never // overwrite a real declaration previously written by an app-tauri // launch with the minimal one. None => match self.fs.exists(&path).await { Ok(true) => {} Ok(false) => { let _ = self.fs.write(&path, declaration.as_bytes()).await; } Err(_) => {} }, } } domain::profile::McpConfigStrategy::TomlConfigHome { target, home_env } => { // Pendant Codex de `ConfigFile` : on écrit un `config.toml` (table // `[mcp_servers.idea]`, via l'encodeur TOML partagé D2) au chemin // `/`, et on pousse `home_env` (ex. `CODEX_HOME`) vers // le **dossier parent** de ce fichier. Codex lit alors ses serveurs MCP // dans ce `config.toml` ISOLÉ au run dir, jamais le `~/.codex` global. let path = RemotePath::new(join(run_dir, target)); let declaration = mcp_server_wiring(mcp.transport, runtime).to_config_toml(); // Même régime clobber/non-clobber que `.mcp.json` : avec un runtime réel // (lancement app-tauri) on régénère/clobber à chaque (re)lancement (l'exe // `$APPIMAGE` et l'endpoint dérivent entre runs) ; sans runtime // (orchestrateur / hot-swap / tests) on reste non-clobbering pour ne pas // écraser une déclaration réelle par la minimale. match runtime { Some(_) => { let existing = match self.fs.read(&path).await { Ok(bytes) => String::from_utf8(bytes).ok(), Err(_) => None, }; let rendered = codex_config_toml( existing.as_deref(), &declaration, run_dir.as_str(), project_root.as_str(), ); let _ = self.fs.write(&path, rendered.as_bytes()).await; } None => match self.fs.exists(&path).await { Ok(true) => {} Ok(false) => { let rendered = codex_config_toml( None, &declaration, run_dir.as_str(), project_root.as_str(), ); let _ = self.fs.write(&path, rendered.as_bytes()).await; } Err(_) => {} }, } // Permission projection (sandbox_mode/approval_policy + --sandbox/ // --ask-for-approval) is NO LONGER done here — it is decoupled into // `apply_permission_projection` (lot LP3-3), so a Codex profile gets its // sandbox even without an MCP capability. `apply_mcp_config` is now // MCP-only (mcp_servers.idea table + projects trust). // `home_env` pointe sur le DOSSIER PARENT de `target` (ex. // `{runDir}/.codex`), pas sur le fichier — Codex y cherche `config.toml`. let home_dir = parent_dir(run_dir, target); spec.env.push((home_env.clone(), home_dir)); } domain::profile::McpConfigStrategy::Flag { flag } => { // Pass the server via a launch flag (e.g. `--mcp-config {path}`). The // config path is the run dir itself (the CLI's cwd), where the server // declaration / connection is anchored. spec.args.push(flag.clone()); spec.args.push(run_dir.as_str().to_owned()); } domain::profile::McpConfigStrategy::Env { var } => { spec.env.push((var.clone(), run_dir.as_str().to_owned())); } } } } /// Outcome of the R0a discrimination between a legitimate **view reattach** and a /// **fresh second launch** of an already-live agent (cadrage v5 §3.2, Trou A). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ReattachDecision { /// Rebind the live session's view to `node_id` without respawning (the request /// targets the live host node, or carries an explicit reattach signal). Rebind { node_id: NodeId }, /// Background / idempotent no-op: hand back the existing session unchanged /// (neither a node nor a conversation id was supplied). Idempotent, /// Refuse the launch — a genuine second launch of a singleton agent already /// live on `node_id` (the host node, reported in `AgentAlreadyRunning`). Refuse { node_id: NodeId }, } impl ReattachDecision { /// Decides reattach vs refuse for an already-live agent. See [`reattach_decision`]. #[must_use] pub(crate) fn resolve( requested_node: Option, host_node: Option, conversation_id: Option<&str>, ) -> Self { reattach_decision(requested_node, host_node, conversation_id) } } /// Decides whether a launch hitting an already-live agent is a legitimate view /// **reattach** (rebind), a **background/idempotent** no-op, or a **refused** second /// launch (cadrage v5 §3.2, Trou A). Pure (no I/O), unit-testable. /// /// Signals (both already present in the launch flow): /// - `requested_node` — the hosting leaf the caller targets (`None` ⇒ no cell, e.g. /// a background launch); /// - `host_node` — the node currently hosting the agent's live session, if known; /// - `conversation_id` — the conversation id recorded on the hosting cell. Its /// presence means the **cell knows the agent was running** ⇒ it is a reattach of a /// view onto an in-progress session, not a brand-new launch. /// /// Rules: /// - requested node **is** the host node ⇒ `Rebind` (re-open of the same cell); /// - a `conversation_id` is present ⇒ `Rebind` (explicit reattach, even on another /// node — the cell is a rebindable view, §17.6); /// - no node **and** no conversation ⇒ `Idempotent` (background/no-op relaunch); /// - a different node, no reattach signal ⇒ `Refuse` (genuine second launch). fn reattach_decision( requested_node: Option, host_node: Option, conversation_id: Option<&str>, ) -> ReattachDecision { // Explicit reattach: the cell carries a conversation id, so it is re-binding a // view onto an already-running session. Rebind to the requested node, or keep the // current host node when none was supplied. if conversation_id.is_some() { if let Some(node_id) = requested_node.or(host_node) { return ReattachDecision::Rebind { node_id }; } return ReattachDecision::Idempotent; } match requested_node { // Same cell re-opened ⇒ idempotent rebind (no respawn). Some(node) if host_node == Some(node) => ReattachDecision::Rebind { node_id: node }, // A different cell with no reattach signal ⇒ genuine second launch: refuse, // reporting the live host node (falling back to the requested node only if the // host is somehow unknown, so the error always carries a meaningful cell). Some(node) => ReattachDecision::Refuse { node_id: host_node.unwrap_or(node), }, // No node and no reattach signal ⇒ background/idempotent no-op. None => ReattachDecision::Idempotent, } } /// Builds the IdeA MCP server declaration written into a CLI's `ConfigFile` MCP /// config (cadrage v3 D3 ; cadrage v5 §2). Kept pure (no I/O) so it is unit-testable. /// /// Two shapes, by whether the composition root injected a [`McpRuntime`]: /// /// - **`Some(runtime)` — the real declaration (M5d, end of the placeholder)**: /// `command = runtime.exe` (the IdeA executable), and `args` carry the /// `mcp-server` subcommand plus the project's exact loopback `--endpoint`, its /// `--project` (hyphen-free 32-hex form, consumed by the M5c guard) and the /// launching agent as `--requester`. The endpoint string comes verbatim from the /// **single source of truth** (`app-tauri::mcp_endpoint`), so the bridge connects /// to exactly what `ensure_mcp_server` listens on. /// /// - **`None` — coherent minimal declaration**: launches issued from inside /// `application` (orchestrator/hot-swap/tests) have no OS/runtime facts to inject; /// rather than fabricate a project-bound endpoint, we write the `idea mcp-server` /// command without the endpoint/project/requester args. It stays a valid, /// self-consistent `mcpServers/idea` entry (the bridge would resolve the endpoint /// from its own project context), surfacing `transport` for the future socket path. #[must_use] fn mcp_server_declaration( transport: domain::profile::McpTransport, runtime: Option<&McpRuntime>, ) -> String { mcp_server_wiring(transport, runtime).to_mcp_json() } /// Builds the IdeA MCP server **wiring** (`command` + `args` + transport) shared by /// every materialisation format (cadrage Codex D2). It is the **single source of /// truth** for *what* the bridge is launched as; the concrete bytes (`.mcp.json` /// JSON for Claude, `config.toml` TOML for Codex) are produced by the domain /// encoders [`domain::McpServerWiring::to_mcp_json`] / /// [`domain::McpServerWiring::to_config_toml`], so the two sites can never drift. /// /// `Some(runtime)` ⇒ the **real** wiring (this IdeA exe + the project's loopback /// endpoint/project/requester); `None` ⇒ the coherent **minimal** `idea mcp-server` /// fallback (orchestrator / hot-swap / tests). #[must_use] fn mcp_server_wiring( transport: domain::profile::McpTransport, runtime: Option<&McpRuntime>, ) -> domain::McpServerWiring { 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) } /// Builds an absolute path string by joining a [`ProjectPath`] with a relative /// segment using a POSIX separator. fn join(base: &ProjectPath, rel: &str) -> String { let b = base.as_str().trim_end_matches(['/', '\\']); format!("{b}/{rel}") } /// Resolves the **parent directory** (absolute) of `/` — used to point a /// CLI's `home_env` (e.g. `CODEX_HOME`) at the directory *containing* the materialised /// config file (`config.toml`), not the file itself. When `rel` has no separator /// (file directly in the run dir), the parent is the run dir. fn parent_dir(base: &ProjectPath, rel: &str) -> String { let full = join(base, rel); match full.rsplit_once(['/', '\\']) { Some((parent, _)) if !parent.is_empty() => parent.to_owned(), _ => base.as_str().trim_end_matches(['/', '\\']).to_owned(), } } /// Computes an agent's isolated run directory `/.ideai/run//` /// (ARCHITECTURE §14.1). This is the PTY cwd for the agent — never the project /// root — guaranteeing that two distinct agents on the same project root get two /// distinct cwd (the anti-collision contract). /// /// # Errors /// Propagates [`DomainError`](domain::error::DomainError) if the joined path is /// not a valid [`ProjectPath`] (should not happen for an absolute project root). pub(crate) fn agent_run_dir( root: &ProjectPath, agent_id: &AgentId, ) -> Result { ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}"))) } /// Construit le snapshot [`TerminalSession`] qui représente une session /// **structurée** (cellule chat) dans la sortie de [`LaunchAgent`] (§17.4). /// /// Le modèle de sortie reste le même que pour le PTY (non cassant pour A/B) : un /// snapshot `kind = Agent` portant l'id de la session structurée. Le `cwd` n'a pas /// de sens process ici (la cellule chat n'a pas de PTY) ; on réutilise le run dir /// au câblage si besoin, mais le snapshot ne porte qu'un placeholder cohérent. fn structured_snapshot( session: &Arc, agent_id: AgentId, node_id: NodeId, size: PtySize, ) -> TerminalSession { // La cellule chat n'a pas de cwd PTY ; un chemin racine neutre suffit au snapshot // (le run dir réel reste connu de l'adapter). `ProjectPath::new("/")` ne peut pas // échouer (chemin absolu trivial). let cwd = ProjectPath::new("/").expect("/ est un ProjectPath absolu valide"); let mut snapshot = TerminalSession::starting( session.id(), node_id, cwd, SessionKind::Agent { agent_id }, size, ); snapshot.status = SessionStatus::Running; snapshot } /// Minimal JSON string escaper used by [`toml_string`] (and historically by the /// Claude seed, now extracted to the infra projector). Handles the characters that /// actually occur in paths: backslash, quote, control chars. fn json_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '"' => 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 } fn toml_string(s: &str) -> String { format!("\"{}\"", json_escape(s)) } /// Renders Codex's `config.toml` **MCP part only** (lot LP3-3 decoupling): merges /// the `[mcp_servers.idea]` table and ensures the run-dir + project-root trust /// entries. The permission part (`sandbox_mode` / `approval_policy` + the /// `--sandbox` / `--ask-for-approval` args) now lives in the Codex permission /// projector (`apply_permission_projection`), so this function is MCP-only and a /// Codex profile receives its sandbox even without an MCP capability. fn codex_config_toml( existing: Option<&str>, mcp_declaration: &str, run_dir: &str, project_root: &str, ) -> String { let mut text = existing.unwrap_or_default().to_owned(); text = replace_toml_table(&text, "mcp_servers.idea", mcp_declaration.trim_end()); text = ensure_codex_trust(&text, run_dir); text = ensure_codex_trust(&text, project_root); if !text.ends_with('\n') { text.push('\n'); } text } /// Merges a projector's **managed** TOML fragment into an existing `config.toml` /// (lot LP3-3, [`ProjectedFile::MergeToml`]). Only the `managed_tables` and /// `managed_keys` are touched; every other line of `existing` is preserved. /// /// - each managed table is replaced wholesale by its block from `fragment` /// ([`replace_toml_table`]); /// - each managed top-level key takes the value found for it in `fragment` /// ([`set_top_level_toml_line`]). /// /// A managed table/key absent from `fragment` leaves `existing` untouched for it. fn merge_managed_toml( existing: &str, managed_tables: &[String], managed_keys: &[String], fragment: &str, ) -> String { let mut text = existing.to_owned(); for table in managed_tables { if let Some(block) = extract_toml_table(fragment, table) { text = replace_toml_table(&text, table, block.trim_end()); } } for key in managed_keys { if let Some(line) = extract_top_level_toml_line(fragment, key) { text = set_top_level_toml_line(&text, key, line); } } if !text.ends_with('\n') { text.push('\n'); } text } /// Returns the full `key = …` line for `key` found at top level in `fragment` /// (before any `[table]` header), or `None` if absent. fn extract_top_level_toml_line<'a>(fragment: &'a str, key: &str) -> Option<&'a str> { let needle = format!("{key} ="); for line in fragment.lines() { let trimmed = line.trim_start(); if trimmed.starts_with('[') { break; } if trimmed.starts_with(&needle) { return Some(line); } } None } /// Returns the `[table]` block (header + body up to the next header / EOF) for /// `table` in `fragment`, or `None` if the table is absent. fn extract_toml_table(fragment: &str, table: &str) -> Option { let header = format!("[{table}]"); let mut block: Vec<&str> = Vec::new(); let mut capturing = false; for line in fragment.lines() { let trimmed = line.trim(); if trimmed == header { capturing = true; block.push(line); continue; } if capturing { if trimmed.starts_with('[') { break; } block.push(line); } } if capturing { Some(block.join("\n")) } else { None } } fn ensure_codex_trust(input: &str, path: &str) -> String { let header = format!("[projects.{}]", toml_string(path)); if input.lines().any(|line| line.trim() == header) { return input.to_owned(); } append_block(input, &format!("{header}\ntrust_level = \"trusted\"")) } fn replace_toml_table(input: &str, table: &str, block: &str) -> String { let header = format!("[{table}]"); let mut out = Vec::new(); let mut skipping = false; for line in input.lines() { let trimmed = line.trim(); if trimmed == header { skipping = true; continue; } if skipping && trimmed.starts_with('[') { skipping = false; } if !skipping { out.push(line.to_owned()); } } append_block(&out.join("\n"), block) } /// Upserts a top-level `key` with a pre-formatted `replacement` line (already /// `key = `): replaces the existing top-level occurrence if present, /// otherwise prepends it. The line-level core shared by [`merge_managed_toml`]. fn set_top_level_toml_line(input: &str, key: &str, replacement: &str) -> String { let mut out = Vec::new(); let mut replaced = false; let mut in_top_level = true; for line in input.lines() { let trimmed = line.trim_start(); if trimmed.starts_with('[') { in_top_level = false; } if in_top_level && !replaced && trimmed.starts_with(&format!("{key} =")) { out.push(replacement.to_owned()); replaced = true; } else { out.push(line.to_owned()); } } let text = out.join("\n"); if replaced { text } else { prepend_line(&text, replacement) } } fn prepend_line(input: &str, line: &str) -> String { if input.trim().is_empty() { format!("{line}\n") } else { format!("{line}\n{}", input.trim_start_matches('\n')) } } fn append_block(input: &str, block: &str) -> String { let trimmed = input.trim_end(); if trimmed.is_empty() { format!("{}\n", block.trim_end()) } else { format!("{trimmed}\n\n{}\n", block.trim_end()) } } /// Composes the convention file IdeA writes into an agent's run directory: an /// absolute project-root header (the agent's cwd is the run dir, *not* the root, /// so it must be told where to work), the IdeA orchestration contract, the /// agent's persona `.md`, then the bodies of its assigned `skills` under a /// `# Skills` section (ARCHITECTURE §14.2). /// /// Skills are emitted in the order given (the caller passes them in manifest /// order, making the output deterministic); each is introduced by a `##` header /// carrying its name. When `skills` is empty the section is omitted entirely, so /// an agent with no skills gets exactly the previous document. /// /// The project's `memory` recall (index/hooks, ARCHITECTURE §14.5.4) is appended as /// a `# Mémoire projet` section — one `- [Title](slug.md) — hook (type)` line per /// entry, in the order given. When `memory` is empty the section is omitted /// entirely, so an agent with no memory gets exactly the previous document. /// /// The conversation `handoff` (lot P7, ARCHITECTURE §19) is appended **last**, as a /// `# Reprise de la conversation` section carrying the optional objective and the /// cumulative `summary_md`. Placed after the project memory (the most situational, /// "where we left off" context an agent reads), it is omitted entirely when `handoff` /// is `None`, so an agent with no handoff gets exactly the previous document. /// /// Kept as a **pure** function (no I/O) so it is unit-testable in isolation. /// /// The orchestration prose adapts to the agent's **surface** (cadrage v3, Décision /// 3): when `mcp_enabled` is `true` (`profile.mcp.is_some()`), the agent sees the /// native `idea_*` tools, so the prose points to those instead of the file /// protocol — while keeping the **same** ban on the provider's native subagents. /// When `false` (`mcp == None`) the prose is the **current `.ideai/requests` /// wording, unchanged** (zero regression). #[must_use] pub(crate) fn compose_convention_file( project_root: &str, project_context: &str, agent_md: &str, skills: &[Skill], memory: &[MemoryIndexEntry], handoff: Option<&Handoff>, mcp_enabled: bool, ) -> String { let mut out = String::new(); out.push_str("# Project root\n\n"); out.push_str(project_root); out.push_str("\n\nTous tes travaux portent sur ce project root (chemin absolu ci-dessus). "); out.push_str( "Ton répertoire courant est un dossier d'exécution isolé (`.ideai/run//`) ; \ opère sur le project root, pas sur ce dossier.\n\n", ); out.push_str("---\n\n"); out.push_str("# Orchestration IdeA\n\n"); if mcp_enabled { // Surface MCP (cadrage v3, D3) : l'alternative native aux subagents est // exposée comme outils typés `idea_*`. L'interdiction des subagents natifs // du fournisseur est **conservée** ; seule la voie de délégation change. out.push_str( "Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \ natifs du fournisseur IA. Utilise les outils IdeA natifs : \ `idea_ask_agent` (déléguer une tâche et recevoir la réponse), \ `idea_launch_agent` (lancer/réattacher un agent) et `idea_list_agents` \ (lister les agents du projet). IdeA lancera ou réattachera l'agent cible \ avec son propre AI Profile, son contexte et sa mémoire.\n\n", ); // Protocole de délégation (Option 1, B-5) : côté agent SOLLICITÉ. Une tâche // déléguée arrive dans ton terminal préfixée `[IdeA · tâche de … · ticket …]`. // Tu DOIS y répondre via l'outil `idea_reply`, jamais en texte libre — sinon // l'agent qui t'a sollicité reste bloqué (sa réponse ne lui parviendra pas). out.push_str( "Quand tu reçois une tâche déléguée par IdeA (un message préfixé \ `[IdeA · tâche de … · ticket …]`), traite-la puis appelle \ **impérativement** l'outil `idea_reply(result=…)` pour rendre ton \ résultat. Ne réponds **jamais** uniquement en texte : seul `idea_reply` \ débloque l'agent qui t'a sollicité.\n\n", ); } else { out.push_str( "Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \ natifs du fournisseur IA. Écris une requête d'orchestration IdeA dans \ `.ideai/requests//` ; IdeA lancera ou réattachera l'agent cible \ avec son propre AI Profile, son contexte et sa mémoire.\n\n", ); } out.push_str("---\n\n"); if !project_context.trim().is_empty() { out.push_str("# Contexte projet\n\n"); out.push_str(project_context.trim()); out.push_str("\n\n---\n\n"); } out.push_str(agent_md); if !skills.is_empty() { out.push_str("\n\n---\n\n# Skills\n"); for skill in skills { out.push_str("\n## "); out.push_str(&skill.name); out.push_str("\n\n"); out.push_str(skill.content_md.as_str()); out.push('\n'); } } if !memory.is_empty() { out.push_str("\n\n---\n\n# Mémoire projet\n\n"); for entry in memory { out.push_str("- ["); out.push_str(&entry.title); out.push_str("]("); out.push_str(".ideai/memory/"); out.push_str(entry.slug.as_str()); out.push_str(".md) — "); out.push_str(&entry.hook); out.push_str(" ("); out.push_str(memory_type_label(entry.r#type)); out.push_str(")\n"); } } // Reprise conversationnelle (lot P7) : section finale, la plus situationnelle // (« où on en était »), placée après la mémoire projet. Omise sans handoff. if let Some(handoff) = handoff { out.push_str("\n\n---\n\n# Reprise de la conversation\n\n"); if let Some(objective) = &handoff.objective { if !objective.trim().is_empty() { out.push_str("**Objectif :** "); out.push_str(objective.trim()); out.push_str("\n\n"); } } out.push_str(handoff.summary_md.as_str()); out.push('\n'); } out } /// Renders a [`MemoryType`] as its stable lowercase label for the convention-file /// memory section (`user`/`feedback`/`project`/`reference`). #[must_use] fn memory_type_label(kind: MemoryType) -> &'static str { match kind { MemoryType::User => "user", MemoryType::Feedback => "feedback", MemoryType::Project => "project", MemoryType::Reference => "reference", } } /// Derives a unique, filesystem-safe `md_path` (`agents/.md`) for a new /// agent, disambiguating against the manifest's existing paths with a numeric /// suffix when needed. Shared with the template-driven agent creation (L7). pub(crate) fn unique_md_path(name: &str, manifest: &AgentManifest) -> String { let slug = slugify(name); let base = if slug.is_empty() { "agent".to_owned() } else { slug }; let mut candidate = format!("{AGENTS_SUBDIR}/{base}.md"); let mut n = 2; while manifest.entries.iter().any(|e| e.md_path == candidate) { candidate = format!("{AGENTS_SUBDIR}/{base}-{n}.md"); n += 1; } candidate } /// Lowercases and slugifies a display name into a safe file stem /// (`[a-z0-9-]`), collapsing runs of separators. fn slugify(name: &str) -> String { let mut out = String::with_capacity(name.len()); let mut prev_dash = false; for ch in name.trim().chars() { if ch.is_ascii_alphanumeric() { out.push(ch.to_ascii_lowercase()); prev_dash = false; } else if !prev_dash { out.push('-'); prev_dash = true; } } out.trim_matches('-').to_owned() } #[cfg(test)] mod tests { use super::*; #[test] fn agent_run_dir_is_under_ideai_run_and_unique_per_agent() { let root = ProjectPath::new("/home/me/proj").unwrap(); let a = AgentId::from_uuid(uuid::Uuid::from_u128(1)); let b = AgentId::from_uuid(uuid::Uuid::from_u128(2)); let dir_a = agent_run_dir(&root, &a).unwrap(); let dir_b = agent_run_dir(&root, &b).unwrap(); assert_eq!(dir_a.as_str(), format!("/home/me/proj/.ideai/run/{a}")); assert_ne!(dir_a, dir_b, "distinct agents → distinct run dirs"); // Never the project root. assert_ne!(dir_a.as_str(), "/home/me/proj"); } #[test] fn compose_convention_file_carries_root_then_persona() { let doc = compose_convention_file( "/abs/project/root", "", "# Persona\n\nDo things.", &[], &[], None, false, ); // Absolute project root present. assert!(doc.contains("/abs/project/root")); // Persona present. assert!(doc.contains("# Persona")); assert!(doc.contains("Do things.")); // Root header precedes the persona body (ordering of the composition). let root_at = doc.find("/abs/project/root").unwrap(); let persona_at = doc.find("# Persona").unwrap(); assert!(root_at < persona_at, "root header must precede the persona"); // No skills ⇒ no Skills section. assert!(!doc.contains("# Skills")); } #[test] fn compose_convention_file_includes_project_context_before_persona() { let doc = compose_convention_file( "/root", "# Shared project context\n\nUse pnpm.", "# Persona\n\nDo X.", &[], &[], None, false, ); assert!(doc.contains("# Contexte projet")); assert!(doc.contains("Use pnpm.")); let project_context_at = doc.find("# Contexte projet").unwrap(); let persona_at = doc.find("# Persona").unwrap(); assert!( project_context_at < persona_at, "shared project context must precede agent persona" ); } #[test] fn compose_convention_file_appends_assigned_skills_in_order() { let s = |n: u128, name: &str, body: &str| { Skill::new( domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)), name, MarkdownDoc::new(body), domain::SkillScope::Global, ) .unwrap() }; let doc = compose_convention_file( "/root", "", "# Persona", &[ s(1, "refactor", "REFAC_BODY"), s(2, "review", "REVIEW_BODY"), ], &[], None, false, ); // Both skill bodies present, after the persona. assert!(doc.contains("REFAC_BODY")); assert!(doc.contains("REVIEW_BODY")); let persona_at = doc.find("# Persona").unwrap(); let refac_at = doc.find("REFAC_BODY").unwrap(); let review_at = doc.find("REVIEW_BODY").unwrap(); assert!(persona_at < refac_at, "skills come after the persona"); // Deterministic order: first assigned skill precedes the second. assert!(refac_at < review_at, "skills emitted in the given order"); // Skill names surface as sub-headers. assert!(doc.contains("## refactor")); assert!(doc.contains("## review")); } /// Builds a memory index entry for the convention-file composition tests. fn mem(slug_str: &str, title: &str, hook: &str, kind: MemoryType) -> MemoryIndexEntry { MemoryIndexEntry { slug: domain::MemorySlug::new(slug_str).unwrap(), title: title.to_owned(), hook: hook.to_owned(), r#type: kind, } } #[test] fn compose_convention_file_empty_memory_is_identical_to_no_memory() { // An empty `memory` must yield exactly the previous document: no section, // byte-for-byte identical to the no-skills/no-memory composition. let with_empty = compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false); assert!( !with_empty.contains("# Mémoire projet"), "no memory ⇒ no memory section" ); // Same document whether or not we thread an empty slice (it already is the // 4-arg call; this pins the omission contract explicitly). assert_eq!( with_empty, compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false) ); } #[test] fn compose_convention_file_appends_memory_entries_in_order() { let doc = compose_convention_file( "/root", "", "# Persona", &[], &[ mem("alpha-note", "Alpha", "the first hook", MemoryType::User), mem( "beta-note", "Beta", "the second hook", MemoryType::Reference, ), ], None, false, ); // Section present, after the persona. assert!(doc.contains("# Mémoire projet")); let persona_at = doc.find("# Persona").unwrap(); let section_at = doc.find("# Mémoire projet").unwrap(); assert!(persona_at < section_at, "memory comes after the persona"); // Exact line format: `- [Title](.ideai/memory/slug.md) — hook (type)`. assert!(doc.contains("- [Alpha](.ideai/memory/alpha-note.md) — the first hook (user)")); assert!(doc.contains("- [Beta](.ideai/memory/beta-note.md) — the second hook (reference)")); // Deterministic order: first entry precedes the second. let alpha_at = doc.find("[Alpha]").unwrap(); let beta_at = doc.find("[Beta]").unwrap(); assert!( alpha_at < beta_at, "memory entries emitted in the given order" ); } #[test] fn compose_convention_file_memory_and_skills_coexist() { let skill = Skill::new( domain::SkillId::from_uuid(uuid::Uuid::from_u128(1)), "refactor", MarkdownDoc::new("REFAC_BODY"), domain::SkillScope::Global, ) .unwrap(); let doc = compose_convention_file( "/root", "", "# Persona", std::slice::from_ref(&skill), &[mem("note", "Note", "a hook", MemoryType::Project)], None, false, ); // Both sections present. assert!(doc.contains("# Skills")); assert!(doc.contains("REFAC_BODY")); assert!(doc.contains("# Mémoire projet")); assert!(doc.contains("- [Note](.ideai/memory/note.md) — a hook (project)")); // Skills section precedes the memory section (persona → skills → memory). let skills_at = doc.find("# Skills").unwrap(); let memory_at = doc.find("# Mémoire projet").unwrap(); assert!(skills_at < memory_at, "skills come before memory"); } #[test] fn compose_convention_file_mcp_prose_points_to_idea_tools_and_keeps_subagent_ban() { // mcp_enabled = true ⇒ prose exposes the native `idea_*` tools while keeping // the ban on the provider's native subagents (cadrage v3, Décision 3). let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); // Native IdeA orchestration tools surfaced. assert!( doc.contains("idea_ask_agent"), "MCP prose must mention idea_ask_agent" ); assert!(doc.contains("idea_launch_agent")); assert!(doc.contains("idea_list_agents")); // Native-subagent ban preserved. assert!( doc.contains("n'utilise jamais les subagents"), "MCP prose must keep the native-subagent ban" ); // It does NOT fall back to the file protocol wording. assert!( !doc.contains(".ideai/requests"), "MCP prose must not point to the file protocol" ); } #[test] fn compose_convention_file_mcp_prose_carries_the_idea_reply_delegation_protocol() { // B-5 — the solicited-agent side of the protocol: a delegated task arrives as // `[IdeA · tâche …]` and MUST be answered via `idea_reply`, never plain text. let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); assert!( doc.contains("idea_reply"), "MCP prose must instruct answering via idea_reply" ); assert!( doc.contains("[IdeA · tâche"), "MCP prose must describe the delegated-task prefix it answers to" ); // Negative: the non-MCP (file-protocol) prose must NOT carry the idea_reply // instruction (zero regression on the file path). let file_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); assert!(!file_doc.contains("idea_reply")); } #[test] fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() { // mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged, // and crucially WITHOUT the `idea_*` tools (zero regression). let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); assert!( doc.contains(".ideai/requests"), "non-MCP prose must point to the file protocol" ); assert!( doc.contains("n'utilise jamais les subagents"), "non-MCP prose keeps the native-subagent ban" ); // No native MCP tools leaked into the file-protocol prose. assert!( !doc.contains("idea_ask_agent"), "non-MCP prose must not mention the idea_* tools" ); } #[test] fn mcp_declaration_with_runtime_points_the_bridge_at_the_real_endpoint() { // B-0 — a PTY-launched MCP-capable CLI (Claude/Codex REPL) auto-discovers the // `.mcp.json` in its cwd (the run dir). When the composition root injects the // real `McpRuntime`, the declaration written there must spawn *this* IdeA exe // in `mcp-server` mode and dial the project's exact loopback endpoint — the // same source of truth `ensure_mcp_server` binds — so the CLI can call // `idea_list_agents` and get a reply end-to-end. let rt = McpRuntime { exe: "/opt/idea/idea".to_owned(), endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(), project_id: "0123456789abcdef0123456789abcdef".to_owned(), requester: "11112222-3333-4444-5555-666677778888".to_owned(), }; let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, Some(&rt)); // It is valid JSON with the IdeA server under `mcpServers/idea`. let parsed: serde_json::Value = serde_json::from_str(&decl).expect("declaration is valid JSON"); let idea = &parsed["mcpServers"]["idea"]; assert_eq!(idea["command"], "/opt/idea/idea"); let args = idea["args"].as_array().expect("args is an array"); let args: Vec<&str> = args.iter().filter_map(serde_json::Value::as_str).collect(); assert_eq!(args[0], "mcp-server"); // The real endpoint / project / requester are all threaded through. assert!(args.contains(&"--endpoint")); assert!(args.contains(&"/run/user/1000/idea-mcp/proj.sock")); assert!(args.contains(&"--project")); assert!(args.contains(&"0123456789abcdef0123456789abcdef")); assert!(args.contains(&"--requester")); assert_eq!(idea["transport"], "stdio"); } #[test] fn mcp_declaration_without_runtime_is_a_coherent_minimal_fallback() { // Launches issued from inside `application` (orchestrator/hot-swap/tests) have // no OS/runtime facts: the declaration falls back to the bare `idea mcp-server` // command — still a valid, self-consistent `mcpServers/idea` entry. let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, None); let parsed: serde_json::Value = serde_json::from_str(&decl).expect("minimal declaration is valid JSON"); let idea = &parsed["mcpServers"]["idea"]; assert_eq!(idea["command"], "idea"); let args: Vec<&str> = idea["args"] .as_array() .expect("args array") .iter() .filter_map(serde_json::Value::as_str) .collect(); assert_eq!(args, vec!["mcp-server"]); // No endpoint/project/requester when no runtime was injected. assert!(!args.contains(&"--endpoint")); } /// Builds a [`Handoff`] for the pure-section tests (lot P7). fn handoff(summary: &str, objective: Option<&str>) -> Handoff { Handoff::new( summary, domain::TurnId::from_uuid(uuid::Uuid::from_u128(1)), objective.map(ToOwned::to_owned), ) } #[test] fn compose_convention_file_appends_handoff_section_after_memory_with_objective() { // lot P7 — a handoff with an objective ⇒ a final `# Reprise de la conversation` // section carrying the `**Objectif :**` line and the cumulative summary, placed // AFTER the `# Mémoire projet` section. let memory = [mem( "git-optional", "Git optionnel", "git reste un simple tool", MemoryType::Project, )]; let h = handoff("Résumé : on a fini l'étape 2.", Some("Livrer le lot P7")); let doc = compose_convention_file("/root", "", "# Persona", &[], &memory, Some(&h), false); assert!( doc.contains("# Reprise de la conversation"), "resume section present: {doc}" ); assert!( doc.contains("**Objectif :** Livrer le lot P7"), "objective line present: {doc}" ); assert!( doc.contains("Résumé : on a fini l'étape 2."), "summary present: {doc}" ); // Ordering: the resume section comes AFTER the project memory. let memory_at = doc.find("# Mémoire projet").unwrap(); let resume_at = doc.find("# Reprise de la conversation").unwrap(); assert!( memory_at < resume_at, "resume must follow the project memory: {doc}" ); // And the objective line precedes the summary inside the section. let objective_at = doc.find("**Objectif :**").unwrap(); let summary_at = doc.find("Résumé : on a fini").unwrap(); assert!( resume_at < objective_at && objective_at < summary_at, "objective then summary inside the section: {doc}" ); } #[test] fn compose_convention_file_handoff_without_objective_omits_objective_line() { // objective = None ⇒ the section and the summary are present, but NO // `**Objectif :**` line. let h = handoff("Juste le résumé.", None); let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), false); assert!( doc.contains("# Reprise de la conversation"), "section present even without objective: {doc}" ); assert!(doc.contains("Juste le résumé."), "summary present: {doc}"); assert!( !doc.contains("**Objectif :**"), "no objective line when objective is None: {doc}" ); } #[test] fn compose_convention_file_handoff_blank_objective_omits_objective_line() { // objective = Some(" ") (whitespace only) ⇒ trimmed to empty ⇒ no objective // line, but the section + summary still render. let h = handoff("Le résumé.", Some(" ")); let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), false); assert!(doc.contains("# Reprise de la conversation")); assert!(doc.contains("Le résumé.")); assert!( !doc.contains("**Objectif :**"), "blank objective is omitted: {doc}" ); } #[test] fn compose_convention_file_without_handoff_omits_resume_section() { // handoff = None ⇒ no `# Reprise de la conversation` section at all // (zero regression for launches with no resume). let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); assert!( !doc.contains("# Reprise de la conversation"), "no handoff ⇒ no resume section: {doc}" ); assert!( !doc.contains("**Objectif :**"), "no handoff ⇒ no objective line: {doc}" ); } // NB (lot LP3-3): the former `claude_settings_seed_*` unit tests were removed — // that translation now lives in `infrastructure::permission::ClaudePermissionProjector` // and is covered by its own tests (LP3-2). Likewise the Codex sandbox/approval // derivation moved to `CodexPermissionProjector`; `codex_config_toml` here is now // **MCP-only**, so the Codex tests below assert only the MCP/trust merge. #[test] fn codex_config_trusts_run_and_project_and_replaces_only_idea_mcp() { let existing = r#"[projects."/home/me/proj"] trust_level = "trusted" approval_policy = "nested" [mcp_servers.idea] command = "old" [mcp_servers.other] command = "other" "#; // No `permissions` argument anymore: the permission projection is decoupled // (lot LP3-3) into `CodexPermissionProjector`. This function only merges MCP. let rendered = codex_config_toml( Some(existing), "[mcp_servers.idea]\ncommand = \"new\"", "/home/me/proj/.ideai/run/a", "/home/me/proj", ); // MCP table + trust entries are the only things this function touches. assert!(rendered.contains("[projects.\"/home/me/proj\"]")); assert!(rendered.contains("[projects.\"/home/me/proj/.ideai/run/a\"]")); assert!(rendered.contains("approval_policy = \"nested\"")); assert!(rendered.contains("[mcp_servers.idea]\ncommand = \"new\"")); assert!(rendered.contains("[mcp_servers.other]\ncommand = \"other\"")); assert!(!rendered.contains("command = \"old\"")); assert_eq!(rendered.matches("[mcp_servers.idea]").count(), 1); assert_eq!(rendered.matches("[projects.\"/home/me/proj\"]").count(), 1); // Permission keys are NOT added by this MCP-only function. assert!(!rendered.contains("sandbox_mode =")); } #[test] fn codex_config_is_mcp_only_and_adds_no_permission_keys() { let rendered = codex_config_toml( None, "[mcp_servers.idea]\ncommand = \"idea-mcp\"", "/home/me/proj/.ideai/run/a", "/home/me/proj", ); assert!(!rendered.contains("approval_policy =")); assert!(!rendered.contains("sandbox_mode =")); assert!(rendered.contains("[projects.\"/home/me/proj\"]")); assert!(rendered.contains("[projects.\"/home/me/proj/.ideai/run/a\"]")); } #[test] fn merge_managed_toml_upserts_only_managed_keys() { // Existing config (e.g. written by `apply_mcp_config`): MCP + trust + a user key. let existing = r#"user_key = "keep-me" [mcp_servers.idea] command = "idea-mcp" "#; // Codex projector fragment: the two managed permission keys. let fragment = "sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n"; let merged = merge_managed_toml( existing, &[], &["sandbox_mode".to_owned(), "approval_policy".to_owned()], fragment, ); // Managed keys are spliced in… assert!(merged.contains("sandbox_mode = \"workspace-write\"")); assert!(merged.contains("approval_policy = \"never\"")); // …without disturbing the rest of the file. assert!(merged.contains("user_key = \"keep-me\"")); assert!(merged.contains("[mcp_servers.idea]\ncommand = \"idea-mcp\"")); } }