//! 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::sync::Arc; use domain::ports::{ AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError, }; use domain::{ Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project, ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession, }; use crate::error::AppError; use crate::layout::{persist_doc, resolve_doc}; use crate::project::project_context_path; use crate::terminal::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; // --------------------------------------------------------------------------- // 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, } 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, } } /// 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)))?; // 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). let known = self .profiles .list() .await? .into_iter() .any(|p| p.id == input.profile_id); if !known { return Err(AppError::NotFound(format!("profile {}", input.profile_id))); } // 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. Clean the conversation on every persisted layout: for each leaf // hosting this agent, drop the (now foreign) conversation id and reset // the running flag. Mirrors `SnapshotRunningAgents`: // resolve_doc → walk agent_leaves → mutate → persist_doc (if changed). self.clean_conversation(&input.project, &input.agent_id) .await?; // 6. A live session? Kill its PTY then relaunch in the same cell with the // new profile and a discarded conversation id. let relaunched = self.relaunch_if_live(&input).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 }) } /// Clears the conversation id and resets the running flag on every persisted /// layout leaf hosting `agent_id` (step 5). Persists only when something /// actually changed (a project with no such leaf is a no-op write). async fn clean_conversation( &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; for named in &mut doc.layouts { for (leaf_id, leaf_agent) in named.tree.agent_leaves() { if &leaf_agent != agent_id { continue; } // Pure ops — only NodeNotFound is possible, which cannot happen // since `leaf_id` came from this very tree. named.tree = named .tree .set_cell_conversation(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(()) } /// 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. async fn relaunch_if_live( &self, input: &ChangeAgentProfileInput, ) -> Result, AppError> { let Some(session_id) = self.sessions.session_for_agent(&input.agent_id) else { return Ok(None); }; // The hosting cell of the live session — the relaunch reopens here. let node_id = self.sessions.node_for_agent(&input.agent_id); // Kill the PTY: remove from the registry first (so the relaunch's // one-live-session-per-agent guard sees no live session), then kill the // process. if let Some(handle) = self.sessions.remove(&session_id) { self.pty.kill(&handle).await?; } let output = self .launch .execute(LaunchAgentInput { project: input.project.clone(), agent_id: input.agent_id, rows: input.rows, cols: input.cols, node_id, // Conversation id discarded: the previous one belonged to the old // engine; the new profile starts (or assigns) a fresh one. conversation_id: None, }) .await?; Ok(Some(output.session)) } } // --------------------------------------------------------------------------- // 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, } /// Output of [`LaunchAgent::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LaunchAgentOutput { /// The created agent terminal session. pub session: TerminalSession, /// The conversation id **assigned** by this launch, when the profile supports /// session assignment and the cell had none yet. The caller persists it on the /// hosting leaf (via the layout flow, e.g. `set_cell_conversation`) so the next /// open resumes instead of re-assigning. `None` when nothing new was assigned /// (resume of an existing id, degraded mode, or a profile without a session /// block) — the caller has nothing to persist. pub assigned_conversation_id: Option, } /// 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>, } 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, } } /// 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() } /// 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. if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) { if let Some(node_id) = input.node_id { if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id) { return Ok(LaunchAgentOutput { session, assigned_conversation_id: None, }); } } // Idempotent — 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, }); } } // 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?; // 3b. Seed the CLI's permission config in the run dir so the agent runs // with the project's full autonomy and never blocks on per-command // permission prompts. The agent's cwd is the run dir, so the CLI // writes/reads its permission file there; without a seed, the CLI // accumulates narrow per-command approvals and keeps prompting. // Pragmatic per-CLI seed pending the universal `.ideai/permissions.json` // + OS-sandbox model. Non-clobbering and best-effort. self.seed_cli_permissions(&profile, &run_dir, &input.project.root) .await?; // 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(), }; // 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()); 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; // 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, &mut spec, ) .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, }) } /// 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). fn resolve_session_plan( &self, profile: &AgentProfile, cell_conversation_id: Option, ) -> (SessionPlan, Option) { // No session strategy at all: behave exactly as before. 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) } } /// Seeds the agent's run dir with the CLI permission config matching its /// context-injection convention, so the agent inherits the project's autonomy /// instead of prompting per command. /// /// Conditioned on the CLI convention (only Claude Code — convention file /// `CLAUDE.md` — has a known seed today); a no-op for any other CLI. /// Best-effort and **non-clobbering**: an existing file (possibly user-edited) /// is left untouched. /// /// # Errors /// [`AppError::FileSystem`] if the directory/file cannot be written. async fn seed_cli_permissions( &self, profile: &AgentProfile, run_dir: &ProjectPath, project_root: &ProjectPath, ) -> Result<(), AppError> { 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 Ok(()); } let settings_path = RemotePath::new(format!("{}/.claude/settings.local.json", run_dir.as_str())); if self.fs.exists(&settings_path).await? { return Ok(()); } self.fs .create_dir_all(&RemotePath::new(format!("{}/.claude", run_dir.as_str()))) .await?; self.fs .write( &settings_path, claude_settings_seed(project_root.as_str()).as_bytes(), ) .await?; Ok(()) } /// 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. async fn apply_injection( &self, project: &Project, context_rel_path: &str, content: &MarkdownDoc, project_context: &str, skills: &[Skill], memory: &[MemoryIndexEntry], 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, ); 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(()) } } /// 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}") } /// 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}"))) } /// Builds the Claude Code permission seed (`.claude/settings.local.json`) written /// into an agent's run dir: full project autonomy (`bypassPermissions` + broad /// Read/Edit/Write/Bash) with the project root granted as an additional working /// directory (the cwd is the run dir, the agent works on the root above it), while /// keeping destructive/out-of-project commands denied. `project_root` is embedded /// verbatim; it is JSON-escaped to stay valid for unusual paths. /// /// Pure (no I/O), so it is unit-testable in isolation. #[must_use] fn claude_settings_seed(project_root: &str) -> String { let root = json_escape(project_root); format!( r#"{{ "permissions": {{ "defaultMode": "bypassPermissions", "additionalDirectories": [ "{root}" ], "allow": [ "Read", "Edit", "Write", "Bash" ], "deny": [ "Bash(sudo *)", "Bash(rm -rf /)", "Bash(rm -rf /*)", "Bash(rm -rf ~)", "Bash(rm -rf ~/)", "Bash(rm -rf ~/*)", "Bash(rm -rf $HOME*)", "Bash(mkfs*)", "Bash(dd if=*)", "Bash(shutdown*)", "Bash(reboot*)" ] }}, "skipDangerousModePermissionPrompt": true, "sandbox": {{ "enabled": false }} }} "# ) } /// Minimal JSON string escaper for embedding a filesystem path in the settings /// seed (handles the characters that actually occur in paths: backslash, quote, /// and 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 } /// 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. /// /// Kept as a **pure** function (no I/O) so it is unit-testable in isolation. #[must_use] pub(crate) fn compose_convention_file( project_root: &str, project_context: &str, agent_md: &str, skills: &[Skill], memory: &[MemoryIndexEntry], ) -> 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"); 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(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"); } } 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.", &[], &[]); // 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.", &[], &[], ); 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"), ], &[], ); // 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.", &[], &[]); 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.", &[], &[]) ); } #[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, ), ], ); // 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](slug.md) — hook (type)`. assert!(doc.contains("- [Alpha](alpha-note.md) — the first hook (user)")); assert!(doc.contains("- [Beta](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)], ); // Both sections present. assert!(doc.contains("# Skills")); assert!(doc.contains("REFAC_BODY")); assert!(doc.contains("# Mémoire projet")); assert!(doc.contains("- [Note](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 claude_settings_seed_grants_autonomy_and_keeps_guardrails() { let json = claude_settings_seed("/home/me/proj"); // Full autonomy. assert!(json.contains("\"defaultMode\": \"bypassPermissions\"")); assert!(json.contains("\"Bash\"")); // Project root granted as an additional working directory. assert!(json.contains("\"/home/me/proj\"")); // Destructive guardrails preserved. assert!(json.contains("Bash(sudo *)")); assert!(json.contains("Bash(rm -rf /)")); assert!(json.contains("Bash(mkfs*)")); // Valid JSON. let parsed: serde_json::Value = serde_json::from_str(&json).expect("seed is valid JSON"); assert_eq!(parsed["permissions"]["defaultMode"], "bypassPermissions"); assert_eq!( parsed["permissions"]["additionalDirectories"][0], "/home/me/proj" ); } #[test] fn claude_settings_seed_escapes_paths_for_valid_json() { // A path with a backslash and a quote must not break the JSON. let json = claude_settings_seed(r#"/weird\path"x"#); let parsed: serde_json::Value = serde_json::from_str(&json).expect("seed with odd path is valid JSON"); assert_eq!( parsed["permissions"]["additionalDirectories"][0], r#"/weird\path"x"# ); } }