# IdeA — Cartographie d'Architecture > Document de référence produit par l'**Agent Architecture**. > Fait autorité sur les frontières, ports, adapters, modules et conventions. > Toute feature DOIT être validée contre ce document avant développement. > Architecture **Hexagonale (Ports & Adapters)** + **SOLID**, stricte. > > Stack non négociable : Tauri v2 (shell) · Rust (cœur hexagonal) · TypeScript + React (UI) · xterm.js + portable-pty (terminaux) · git2/libgit2 · russh/ssh2 · wsl.exe. > > **Principe fondateur** : IdeA est un IDE 100 % IA dont le rôle est de **refléter fidèlement la façon dont on travaille avec des IAs** — sans jamais dépendre des commandes, flags ou conventions d'un modèle en particulier. Les deux abstractions de premier rang sont les **Agents** (instances IA à rôle/contexte définis) et les **Skills** (workflows réutilisables). Ces deux concepts sont gérés par IdeA de façon universelle : un utilisateur qui passe de Claude Code à Gemini CLI ou Codex retrouve exactement les mêmes Agents et Skills — seul le moteur d'exécution change. --- ## 1. Principes : SOLID + Hexagonal, appliqués concrètement ### 1.1 Règle de dépendance (la seule qui compte) ``` ┌─────────────────────────────────────────────┐ │ Le sens des dépendances │ │ │ Présentation ─► Application ─► Domaine ◄─ Infrastructure │ (React/Tauri) (use cases) (pur) (adapters) │ │ │ └─────────────────────────────────────────────┘ ``` - **Le Domaine ne dépend de RIEN** : ni Tauri, ni tokio, ni git2, ni portable-pty, ni serde (le moins possible — voir §1.4). Il ne contient que des entités, value objects, règles métier et **traits = ports**. - **L'Application** dépend du Domaine. Elle orchestre les use cases en parlant **uniquement aux ports** (traits), jamais aux adapters concrets. - **L'Infrastructure** dépend du Domaine et de l'Application (elle implémente les ports). Elle contient tous les détails techniques (PTY, FS, git, SSH, WSL, stores). - **La Présentation** (Tauri commands + React) dépend de l'Application. Les commandes Tauri sont des **adapters entrants (driving adapters)** ; les impl de ports sont des **adapters sortants (driven adapters)**. Aucune flèche ne pointe **vers** la présentation ou l'infrastructure. L'inversion de dépendance (le **D** de SOLID) est matérialisée par les traits définis dans le domaine et implémentés dehors. ### 1.2 SOLID, point par point, traduit IdeA | Principe | Application concrète | |---|---| | **S** — Single Responsibility | Un use case = une intention métier (`LaunchAgent`, `SyncAgentWithTemplate`). Un adapter = une techno (`Git2Repository` ne fait que du git). Le `LayoutNode` ne gère que la topologie, pas le rendu. | | **O** — Open/Closed | Ajouter une IA = ajouter un **profil déclaratif** (donnée), pas du code. Ajouter un mode distant = nouvel adapter `RemoteHost` sans toucher aux use cases. Ajouter une stratégie d'injection de contexte = nouvelle variante d'enum + handler, use case inchangé. | | **L** — Liskov | Tout `RemoteHost` (local, SSH, WSL) est substituable : un use case marche identiquement quelle que soit l'impl. Les contrats (pré/postconditions) des ports sont documentés et respectés par chaque adapter. | | **I** — Interface Segregation | Ports **fins et ciblés** : `ProcessSpawner`, `FileSystem`, `PtyPort` séparés plutôt qu'un `System` fourre-tout. Un use case ne reçoit que les ports qu'il consomme. | | **D** — Dependency Inversion | Domaine définit les traits ; infra les implémente ; l'application reçoit des `Arc` par **injection** (composition root dans la couche Tauri). | ### 1.3 Hexagonal côté Frontend (React aussi) L'hexagonal ne s'arrête pas à Rust. Côté React on applique le même découpage : - **Domaine UI / modèles de vue** : types TS purs (miroir des DTO), logique de présentation pure (ex. calcul de tailles de cellules d'un `LayoutNode`), testable sans React ni Tauri. - **Ports UI** : interfaces TS (`AgentGateway`, `TerminalGateway`, `ProjectGateway`, `LayoutGateway`, `GitGateway`, `RemoteGateway`) décrivant **ce dont l'UI a besoin**, indépendamment du transport. - **Adapters UI** : implémentation des ports via `@tauri-apps/api` (`invoke` pour commands, `listen` pour events). Remplaçables par des **mocks** en test/Storybook. - **Présentation** : composants React, hooks, state (Zustand/Redux) qui consomment les ports UI, jamais `invoke()` en direct. Bénéfice : le frontend est testable et développable sans backend (adapters mock), et la frontière IPC est centralisée en un seul endroit. ### 1.4 Domaine pur vs adapters — règle pratique Rust - Le crate `domain` est **`#![no_std]`-friendly d'esprit** (pas imposé), sans dépendance I/O. Tolérance pragmatique : `serde` est autorisé **uniquement** pour dériver la (dé)sérialisation des entités persistées (manifeste, layout, profils), car c'est une contrainte métier de format, pas un détail technique d'I/O. Les **traits/ports** y vivent. Pas de `tokio`, pas de `std::process`, pas de `std::fs`. - Tout ce qui touche le monde réel (`std::fs`, `Command`, sockets, libgit2, PTY) vit **exclusivement** dans `infrastructure`. --- ## 2. Découpage en couches & frontière Rust ↔ Tauri ↔ React ``` ┌───────────────────────────────────────────────────────────────────────┐ │ PRÉSENTATION (Frontend) — TypeScript + React + xterm.js │ │ features/* · ui-ports (gateways) · tauri-adapters (invoke/listen) │ └───────────────────────────────┬───────────────────────────────────────┘ │ IPC Tauri (commands ⇄ events, JSON) ┌───────────────────────────────▼───────────────────────────────────────┐ │ PRÉSENTATION (Backend) — crate `app-tauri` (DRIVING ADAPTER) │ │ #[tauri::command] handlers · event emitters · COMPOSITION ROOT (DI) │ │ PTY byte-stream bridge ⇄ xterm.js │ └───────────────────────────────┬───────────────────────────────────────┘ │ appels de use cases (Arc) ┌───────────────────────────────▼───────────────────────────────────────┐ │ APPLICATION — crate `application` │ │ Use cases / services · DTOs · orchestration · transactions métier │ │ Dépend UNIQUEMENT des ports (traits) du domaine │ └───────────────────────────────┬───────────────────────────────────────┘ │ implémente / consomme ┌───────────────────────────────▼───────────────────────────────────────┐ │ DOMAINE — crate `domain` (PUR, sans I/O) │ │ Entities · Value Objects · Invariants · PORTS (traits) · DomainEvents │ └───────────────────────────────▲───────────────────────────────────────┘ │ implémentent les ports (DRIVEN ADAPTERS) ┌───────────────────────────────┴───────────────────────────────────────┐ │ INFRASTRUCTURE — crate `infrastructure` │ │ portable-pty · git2 · russh/ssh2 · wsl.exe · fs local · md/json store │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### Frontière IPC Tauri — deux directions - **Commands (Frontend → Backend, request/response)** : `invoke("create_project", {...})`. Le handler `#[tauri::command]` désérialise le DTO, appelle le use case, renvoie un `Result`. **Stateless** côté forme : tout l'état vit dans des services managés via `tauri::State`. - **Events (Backend → Frontend, push)** : flux PTY (octets/base64), changements de statut d'agent, fin de processus, progrès git, drift de template détecté. Émis via `app_handle.emit(...)` / channels Tauri. L'`EventBus` domaine est relayé vers ces events Tauri par un adapter dans `app-tauri`. > **Décision** : le flux PTY haute fréquence passe par des **Tauri Channels** (`tauri::ipc::Channel`) plutôt que des events globaux, pour la perf et l'isolement par session terminal. --- ## 3. Modèle de domaine ### 3.1 Vue d'ensemble (relations) ``` Workspace 1───* Window 1───* Tab 1───1 Project │ │ │ 1 ├──* Agent ─────? AgentTemplate (origine) │ │ │ 1 │ 1 │ └──1 AgentProfile (runtime IA, par réf id) LayoutTree ├──1 GitRepository (LayoutNode récursif) ├──1 RemoteHost (Local | Ssh | Wsl) │ feuilles └──1 AgentManifest (.ideai/agents.json) ▼ TerminalSession 1───? AgentSession 1───1 Agent │ └──? CellBinding (0 ou 1 cellule visible) ``` ### 3.2 Entités & Value Objects (avec invariants) **`ProjectId`, `AgentId`, `TemplateId`, `ProfileId`, `SessionId`, `WindowId`, `TabId`, `NodeId`** — VO `newtype(Uuid)` ou string typée. Invariant : non vide, immuable. **`Project`** (entité, racine d'agrégat projet) - Champs : `id`, `name`, `root: ProjectPath`, `remote: RemoteRef`, `created_at`. - Invariants : `root` doit être un chemin **absolu et valide pour son `RemoteRef`** ; deux projets ne peuvent partager le même `(remote, root)`. **`ProjectPath`** (VO) — chemin absolu normalisé, conscient de la plateforme cible (POSIX vs Windows vs WSL `/mnt/...`). **`Agent`** (entité) - Champs : `id`, `name`, `context: AgentContextRef` (chemin du `.md` dans `.ideai/`), `profile_id: ProfileId`, `origin: AgentOrigin` (`Scratch` | `FromTemplate { template_id, synced_version }`), `synchronized: bool`. - Invariants : `synchronized == true` ⇒ `origin == FromTemplate{..}` (on ne peut pas synchroniser un agent créé from scratch). `context` doit exister à l'activation. `profile_id` doit référencer un `AgentProfile` connu. **`AgentSession`** (entité — exécution active d'un agent IdeA) - Champs : `id`, `agent_id`, `profile_id`, `terminal_session_id`, `requested_by: AgentRequester` (`User` | `Agent { agent_id, session_id? }`), `task: Option`, `visibility: AgentVisibility`, `status` (`Starting|Running|WaitingForUser|Failed|Stopped|Exited{code}`). - Invariants : une session active référence **un seul** `Agent` et **un seul** `AgentProfile` résolu au lancement ; elle peut être visible dans **0 ou 1** cellule. Le profil de l'agent demandeur ne contraint jamais le profil de l'agent cible. **`AgentVisibility` / `CellBinding`** (VO) ``` AgentVisibility = | Background | Visible { node_id: NodeId } ``` - Invariants : une cellule peut afficher 0 ou 1 `AgentSession` active ; une `AgentSession` active peut être attachée à 0 ou 1 cellule visible. Fermer une cellule **détache** la session (`Visible` → `Background`) sans arrêter le process. Ouvrir une cellule sur une session déjà active **réattache** la session et affiche le travail en cours. **`AgentTemplate`** (entité, store global) - Champs : `id`, `name`, `content_md: MarkdownDoc`, `version: TemplateVersion`, `default_profile_id`. - Invariants : `version` **monotone croissante** ; toute modification du `content_md` ⇒ `version + 1` (voir §8). **`AgentProfile`** (entité de config runtime IA — le port `AgentRuntime` est paramétré par elle) - Champs : `id`, `name`, `command: String`, `args: Vec`, `context_injection: ContextInjection`, `detect: Option`, `cwd_template: String` (vaut toujours `"{agentRunDir}"` — voir §9.1 et §14.1). - Invariants : `command` non vide ; cohérence de `ContextInjection` (voir VO ci-dessous). **`ContextInjection`** (VO, enum — cœur du moteur IA flexible) ``` ContextInjection = | ConventionFile { target: String } // ex. "CLAUDE.md" / "AGENTS.md" / "GEMINI.md" | Flag { flag: String } // ex. "--context-file {path}" ou "-f" | Stdin // pipe du contenu md sur stdin | Env { var: String } // ex. "AGENT_CONTEXT_FILE" ``` - Invariants : `ConventionFile.target` est un nom de fichier relatif (pas de `..`, pas absolu) ; `Env.var` est un identifiant d'env valide ; `Flag.flag` non vide. **`TerminalSession`** (entité) - Champs : `id`, `node_id: Option` (cellule visible qui l'héberge, absente si arrière-plan), `cwd: ProjectPath`, `kind: SessionKind` (`Plain` | `Agent { session_id }`), `pty_size: PtySize { rows, cols }`, `status` (`Starting|Running|Exited{code}`). - Invariants : une cellule (feuille de layout) héberge **au plus une** `TerminalSession` active. Une session agent peut conserver son PTY sans cellule visible. `pty_size.rows>0 && cols>0`. **`LayoutNode` / `LayoutTree`** (VO récursif — voir §7 pour le détail complet) - Invariants : poids relatifs strictement positifs ; somme normalisable ; pas de fusion qui chevauche deux conteneurs distincts ; un `Leaf` référence 0 ou 1 `SessionId`. **`RemoteHost`** (VO de stratégie de localisation — abstrait Local/SSH/WSL) ``` RemoteRef = | Local | Ssh { host, port, user, auth: SshAuth, remote_root } | Wsl { distro: String } ``` - Invariants : `Ssh.port` ∈ 1..=65535 ; `Wsl.distro` non vide ; pour `Ssh`/`Wsl`, les chemins projet sont interprétés côté distant. **`GitRepository`** (entité) - Champs : `project_id`, `root`, `current_branch`, `is_dirty`. - Invariants : `root` contient (ou contiendra après init) un `.git`. État dérivé, rafraîchi via le port. **`AgentManifest`** (entité — image en mémoire de `.ideai/agents.json`) - Champs : `entries: Vec`. - Invariants : `synchronized ⇒ template_id.is_some() && synced_template_version.is_some()` ; `md_path` unique ; cohérence avec les `Agent` chargés. **`Workspace` / `Window` / `Tab`** (entités de présentation persistée) - `Workspace` = ensemble des fenêtres d'une session utilisateur. - `Window` = fenêtre OS ; possède un `LayoutTree` **par onglet actif** et une liste de `Tab`. - `Tab` = onglet ⇔ **un `Project`** (1:1). - Invariants : un `Project` ouvert apparaît dans **exactement un** `Tab` à la fois (le drag déplace, ne duplique pas) ; un `Window` a ≥ 1 `Tab` ou est fermée. **`Skill`** (entité) - Champs : `id`, `name`, `content_md: MarkdownDoc`, `scope: SkillScope` (`Global` | `Project`). - Invariants : `name` non vide ; `content_md` non vide. - Un agent référence 0..N skills (dans l'`AgentManifest`). Les skills assignés sont injectés dans son convention file à l'activation. **`DomainEvent`** (enum) — `ProjectCreated`, `AgentLaunched`, `AgentSessionAttached`, `AgentSessionDetached`, `AgentExited`, `TemplateUpdated`, `AgentDriftDetected`, `SkillAssigned`, `LayoutChanged`, `RemoteConnected`, `GitStateChanged`, `PtyOutput{session_id, bytes}`, `OrchestratorRequest{requester_id, action}` (ce dernier souvent court-circuité vers un Channel). --- ## 4. Ports (traits du domaine) > Signatures **conceptuelles** (Rust idiomatique, `async` via `async_trait` ou retours `Future` ; erreurs typées par port). « Consommé par » = use cases. « Implémenté par » = adapters de §5. ### `AgentRuntime` - **Rôle** : lancer/piloter la CLI d'une IA selon un `AgentProfile`, en gérant l'injection du contexte `.md`. - **Signature** : ```rust trait AgentRuntime { fn detect(&self, profile: &AgentProfile) -> Result; fn prepare_invocation(&self, profile: &AgentProfile, ctx: &PreparedContext, cwd: &ProjectPath) -> Result; // commande + args + plan d'injection (fichier/flag/stdin/env) } ``` - **Consommé par** : `LaunchAgent`, `DetectProfilesUseCase` (first-run). - **Implémenté par** : `CliAgentRuntime` (un seul adapter générique piloté par le profil déclaratif — c'est l'**Open/Closed**). La diversité des IA = données, pas code. ### `PtyPort` (alias domaine de `TerminalSessionPort`) - **Rôle** : ouvrir un pseudo-terminal, lire/écrire, redimensionner, tuer. - **Signature** : ```rust trait PtyPort { async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result; fn write(&self, h: &PtyHandle, data: &[u8]) -> Result<(), PtyError>; fn resize(&self, h: &PtyHandle, size: PtySize) -> Result<(), PtyError>; fn subscribe_output(&self, h: &PtyHandle) -> OutputStream; // flux d'octets async fn kill(&self, h: &PtyHandle) -> Result; } ``` - **Consommé par** : `OpenTerminal`, `LaunchAgent`, `CloseTerminal`. - **Implémenté par** : `PortablePtyAdapter` (local), `SshPtyAdapter` (PTY distant via russh exec/shell), `WslPtyAdapter` (PTY via `wsl.exe`). Sélection par stratégie `RemoteRef` (Liskov). ### `RemoteHost` - **Rôle** : abstraction de la **localisation d'exécution** (local / SSH / WSL) : exécuter une commande, ouvrir un PTY, accéder au FS, dans le bon contexte. - **Signature** : ```rust trait RemoteHost { fn kind(&self) -> RemoteKind; async fn connect(&self) -> Result<(), RemoteError>; fn file_system(&self) -> Arc; fn process_spawner(&self) -> Arc; fn pty(&self) -> Arc; } ``` - **Consommé par** : tous les use cases qui touchent un projet (résolvent leurs ports via le `RemoteHost` du projet → **transparence local/distant**). - **Implémenté par** : `LocalHost`, `SshHost` (russh/ssh2), `WslHost` (wsl.exe). C'est la **stratégie** qui unifie les 3 modes. ### `ProcessSpawner` - **Rôle** : lancer un process **non interactif** et récupérer sortie/exit (ex. `detect`, commandes git hors libgit2, scripts). - **Signature** : `async fn run(&self, spec: SpawnSpec) -> Result;` - **Consommé par** : `DetectProfilesUseCase`, services divers. - **Implémenté par** : `LocalProcessSpawner`, `SshProcessSpawner`, `WslProcessSpawner`. ### `FileSystem` - **Rôle** : lecture/écriture/listing/symlink, neutre vis-à-vis de la localisation. - **Signature** : ```rust trait FileSystem { async fn read(&self, p: &RemotePath) -> Result, FsError>; async fn write(&self, p: &RemotePath, data: &[u8]) -> Result<(), FsError>; async fn exists(&self, p: &RemotePath) -> Result; async fn create_dir_all(&self, p: &RemotePath) -> Result<(), FsError>; async fn list(&self, p: &RemotePath) -> Result, FsError>; async fn symlink(&self, src: &RemotePath, dst: &RemotePath) -> Result<(), FsError>; } ``` - **Consommé par** : `AgentContextStore`, `ProjectStore`, injection `conventionFile`, etc. - **Implémenté par** : `LocalFileSystem` (std::fs/tokio::fs), `SshFileSystem` (SFTP), `WslFileSystem` (via `wsl.exe` ou chemins `\\wsl$`). ### `TemplateStore` - **Rôle** : CRUD des `AgentTemplate` dans le store global IDE + versioning. - **Signature** : `list / get / save / delete / bump_version`. - **Consommé par** : `CreateTemplate`, `UpdateTemplate`, `CreateAgentFromTemplate`, `SyncAgentWithTemplate`. - **Implémenté par** : `FsTemplateStore` (md + index json dans le dossier de données app). ### `ProjectStore` - **Rôle** : persistance de la liste des projets connus, workspaces, windows, tabs, layouts. - **Signature** : `list_projects / load_project / save_project / save_workspace / load_workspace`. - **Consommé par** : `CreateProject`, `OpenProject`, persistance fenêtres/onglets/layout. - **Implémenté par** : `FsProjectStore` (json dans données app pour le registre ; layout par projet dans `.ideai/`). ### `AgentContextStore` - **Rôle** : lire/écrire les `.md` d'agents **et** le manifeste `.ideai/agents.json` (au sein du projet, via le `FileSystem` du `RemoteHost`). - **Signature** : ```rust trait AgentContextStore { async fn read_context(&self, project: &Project, agent: &AgentId) -> Result; async fn write_context(&self, project: &Project, agent: &AgentId, md: &MarkdownDoc) -> Result<(), StoreError>; async fn load_manifest(&self, project: &Project) -> Result; async fn save_manifest(&self, project: &Project, m: &AgentManifest) -> Result<(), StoreError>; } ``` - **Consommé par** : `CreateAgent*`, `LaunchAgent`, `SyncAgentWithTemplate`. - **Implémenté par** : `IdeaiContextStore` (compose `FileSystem`, écrit `.ideai/`). ### `GitRepository` - **Rôle** : opérations git du projet. - **Signature** : `status / stage / unstage / commit / branches / checkout / current_branch / diff / log / pull / push / clone / init`. - **Consommé par** : use cases Git. - **Implémenté par** : `Git2Repository` (libgit2, local) ; sur SSH/WSL, `RemoteGitRepository` délègue à git CLI via `ProcessSpawner` quand libgit2 ne peut pas atteindre le FS distant (point ouvert §13). ### `EventBus` - **Rôle** : publier/souscrire les `DomainEvent` (découple émetteurs et présentation). - **Signature** : `fn publish(&self, e: DomainEvent); fn subscribe(&self) -> EventStream;` - **Consommé par** : tous use cases (publient) ; l'adapter Tauri (souscrit → relaye en events/channels IPC). - **Implémenté par** : `TokioBroadcastEventBus` (in-process), relayé par `TauriEventRelay`. ### `Clock` & `IdGenerator` (ports utilitaires — testabilité) - **Rôle** : éliminer le non-déterminisme (`now()`, `uuid`) du domaine/application. - **Implémenté par** : `SystemClock` / `UuidGenerator` (prod), `FixedClock` / `SeqIdGenerator` (tests). --- ## 5. Adapters (impl concrètes par port) | Port | Adapter(s) | Techno | Notes | |---|---|---|---| | `AgentRuntime` | `CliAgentRuntime` | piloté par `AgentProfile` | Construit `SpawnSpec` + plan d'injection. Un seul adapter, N profils. | | `PtyPort` | `PortablePtyAdapter` | portable-pty | Local. Stream octets → Channel Tauri. | | | `SshPtyAdapter` | russh (channel shell/exec + pty req) | Distant SSH. | | | `WslPtyAdapter` | `wsl.exe -d ` + portable-pty | PTY dans la distro. | | `RemoteHost` | `LocalHost` / `SshHost` / `WslHost` | — / russh,ssh2 / wsl.exe | Stratégie ; fabrique FS/Spawner/PTY adaptés. | | `ProcessSpawner` | `LocalProcessSpawner` | std/tokio `Command` | | | | `SshProcessSpawner` | russh exec | | | | `WslProcessSpawner` | `wsl.exe` | | | `FileSystem` | `LocalFileSystem` | tokio::fs | | | | `SshFileSystem` | SFTP (ssh2/russh-sftp) | | | | `WslFileSystem` | `\\wsl$\` / `wsl.exe cat`… | | | `TemplateStore` | `FsTemplateStore` | tokio::fs + serde_json | Dossier données app. | | `ProjectStore` | `FsProjectStore` | tokio::fs + serde_json | Registre projets + workspace. | | `AgentContextStore` | `IdeaiContextStore` | compose `FileSystem` | Écrit `.ideai/`. | | `GitRepository` | `Git2Repository` | git2 | Local. | | | `RemoteGitRepository` | git CLI via `ProcessSpawner` | SSH/WSL fallback. | | `EventBus` | `TokioBroadcastEventBus` (+ `TauriEventRelay`) | tokio::broadcast | Relais vers IPC. | | `Clock`/`IdGenerator` | `SystemClock`/`UuidGenerator` | std/uuid | Mocks en test. | **Adapters entrants (driving)** : handlers `#[tauri::command]` (frontend → app) + `TauriEventRelay` (app → frontend). Côté UI : `tauri-adapters` implémentant les gateways TS. --- ## 6. Use cases / services applicatifs > Chaque use case : un struct `XxxUseCase` portant ses ports en `Arc`, une méthode `execute(input: XxxInput) -> Result`. **Single Responsibility**. Aucune dépendance à Tauri. | Use case | Rôle | Ports consommés | |---|---|---| | `CreateProject` | Crée un projet (project root), init `.ideai/`, registre. | `ProjectStore`, `FileSystem`, `IdGenerator`, `EventBus` | | `OpenProject` | Charge projet, manifeste, layout, résout `RemoteHost`. | `ProjectStore`, `AgentContextStore`, `RemoteHost` | | `CloseProject` / `CloseTab` | Persiste l'état, libère PTYs. | `ProjectStore`, `PtyPort`, `EventBus` | | `DetectProfiles` (first-run) | Teste `detect` de chaque profil candidat. | `AgentRuntime`, `ProcessSpawner` | | `ConfigureProfiles` | Enregistre profils choisis/édités/custom. | `TemplateStore`/profile store, `FileSystem` | | `CreateAgentFromScratch` | Crée agent + `.md`, met à jour manifeste. | `AgentContextStore`, `IdGenerator` | | `CreateAgentFromTemplate` | Copie le `content_md` du template → agent ; lie origine + version + `synchronized`. | `TemplateStore`, `AgentContextStore` | | `UpdateTemplate` | Modifie un template, **bump version**, signale drift aux agents liés. | `TemplateStore`, `EventBus` | | `DetectAgentDrift` | Compare `synced_template_version` vs `template.version`. | `TemplateStore`, `AgentContextStore` | | `SyncAgentWithTemplate` | Applique la MAJ template→agent si `synchronized`. | `TemplateStore`, `AgentContextStore`, `EventBus` | | `RequestAgentWork` | Demande structurée utilisateur/agent pour faire travailler un agent IdeA cible, sans subagent natif fournisseur. | `AgentContextStore`, `AgentSessionStore`, `AgentRequestQueue`, `EventBus` | | `LaunchAgentSession` / `LaunchAgent` | Résout profil+contexte+mémoire, prépare injection, crée ou reprend une session agent, spawn CLI si nécessaire. | `AgentRuntime`, `AgentContextStore`, `AgentSessionStore`, `RemoteHost`→`PtyPort`/`FileSystem`, `EventBus` | | `AttachAgentSessionToCell` / `DetachAgentSessionFromCell` / `MoveAgentSessionToCell` | Rend visible, détache ou déplace une session active dans la grille sans tuer le process. | `AgentSessionStore`, `ProjectStore`, `EventBus` | | `ListAgentSessions` / `ObserveAgentSession` | Liste les sessions visibles/arrière-plan et observe leur état/logs. | `AgentSessionStore`, `EventBus` | | `StopAgentSession` | Arrête explicitement une session agent et son PTY. | `AgentSessionStore`, `PtyPort`, `EventBus` | | `OpenTerminal` | Ouvre un PTY simple dans une cellule. | `RemoteHost`→`PtyPort`, `EventBus` | | `WriteToTerminal` / `ResizeTerminal` / `CloseTerminal` | I/O PTY. | `PtyPort` | | `MutateLayout` (split/merge/resize/move) | Applique une opération sur le `LayoutTree` (logique **pure** dans le domaine, persistée ici). | `ProjectStore` (persistance) | | `ConnectRemote` (SSH/WSL) | Établit la connexion, valide l'accès au root. | `RemoteHost`, `FileSystem` | | `MoveTabToNewWindow` | Détache un onglet → nouvelle fenêtre (réaffectation `WindowId`). | `ProjectStore`, `EventBus` | | Use cases Git | `GitStatus`, `GitCommit`, `GitCheckout`, `GitPush`, … | `GitRepository`, `EventBus` | --- ## 7. Modèle de layout terminal (grille tableur récursive + fusion) ### 7.1 Structure de données La grille « type tableur, lignes/colonnes imbriquées indépendamment + fusion » est modélisée par un **arbre de splits récursif** où chaque conteneur définit son propre découpage. La **fusion** est obtenue nativement : fusionner = ne pas subdiviser une zone (un `Leaf` couvre plusieurs « cellules visuelles » d'un parent voisin). Pour le cas Excel pur (fusion arbitraire chevauchant la grille), on superpose un modèle **GridContainer** avec spans. ```rust enum LayoutNode { Leaf(LeafCell), Split(SplitContainer), Grid(GridContainer), } struct LeafCell { id: NodeId, session: Option, // 0 ou 1 terminal } struct SplitContainer { // découpage simple binaire/n-aire pondéré id: NodeId, direction: Direction, // Row (colonnes) | Column (lignes) children: Vec, // ordre = gauche→droite / haut→bas } struct WeightedChild { node: LayoutNode, weight: f32 } // poids = part redimensionnable struct GridContainer { // grille tableur avec fusion (spans) id: NodeId, col_weights: Vec, // largeurs de colonnes row_weights: Vec, // hauteurs de lignes cells: Vec, // placements avec spans (fusion) } struct GridCell { node: LayoutNode, // récursif : une cellule peut re-contenir un Split/Grid row: u16, col: u16, row_span: u16, // ≥1 ; >1 = cellules fusionnées verticalement col_span: u16, // ≥1 ; >1 = cellules fusionnées horizontalement } ``` - **Lignes/colonnes indépendantes par zone** : chaque `SplitContainer`/`GridContainer` a ses propres poids ⇒ pas de grille uniforme rigide. - **Imbrication** : un enfant peut être un nouveau `Split`/`Grid` ⇒ « N colonnes dans une ligne, M lignes dans une colonne » de façon arbitraire. - **Fusion** : `row_span`/`col_span` dans `GridContainer` (modèle tableur fidèle) **ou** simplement un `Leaf` plus grand via `SplitContainer` (cas courant). Le domaine supporte les deux ; l'UI choisit la représentation selon l'interaction. ### 7.2 Invariants (validés dans le domaine, testables sans I/O) - Tous les `weight > 0`. Les poids sont **relatifs** (l'UI normalise pour le rendu). - Dans un `GridContainer` : aucune superposition de spans ; toute la surface couverte ; `row+row_span ≤ rows`, `col+col_span ≤ cols`. - Un `SessionId` n'apparaît que dans **un seul** `Leaf`. - Pour une session agent, retirer le `SessionId` d'un `Leaf` détache l'affichage seulement ; l'arrêt du process passe par `StopAgentSession`/`CloseTerminal`, jamais par la fermeture visuelle de cellule. - Les opérations `split`, `merge`, `resize`, `move` sont des **fonctions pures** `LayoutTree -> Result` (immutabilité ⇒ testabilité, undo/redo facile). ### 7.3 Sérialisation & persistance - Sérialisé en **JSON** (serde, `tag`/`content` pour l'enum) → `.ideai/layout.json` (par projet, donc voyage avec le projet, y compris distant). - Le `Workspace`/`Window`/`Tab` (organisation des fenêtres OS) est persisté côté **store global IDE** (machine-local, pas dans le projet) car lié à l'écran de l'utilisateur, pas au code. --- ## 8. Synchronisation template → agents ### 8.1 Versioning - `AgentTemplate.version: u64` monotone. **`UpdateTemplate` incrémente** la version à chaque changement de `content_md`. Un hash du contenu (`content_hash`) est aussi stocké pour détecter les éditions hors-app. - Chaque `ManifestEntry` d'agent lié garde `synced_template_version` = version du template **au dernier sync réussi**. ### 8.2 Détection de drift ``` drift(agent) = agent.synchronized && agent.origin == FromTemplate{ template_id, .. } && template_store.get(template_id).version > entry.synced_template_version ``` `DetectAgentDrift` est lancé à `OpenProject` et après chaque `UpdateTemplate` ; émet `AgentDriftDetected { agent_id, from, to }` → badge UI. ### 8.3 Application de la MAJ (`SyncAgentWithTemplate`) ``` 1. Charger template (version courante) + manifeste projet. 2. Pour chaque agent ciblé avec synchronized==true : a. Stratégie de MAJ = REMPLACEMENT du .md par content_md du template (le contexte d'un agent synchronisé est "possédé" par le template). → Variante future : merge 3-way si l'agent a un bloc local marqué. b. write_context(agent, template.content_md) c. entry.synced_template_version = template.version 3. save_manifest. publish(AgentSynced{..}). ``` ### 8.4 Agents non synchronisés - `synchronized == false` : ne reçoivent **jamais** de MAJ auto. Ils gardent leur `.md` libre. On peut afficher « une nouvelle version du template existe » (info) mais aucune écriture n'a lieu sans action explicite (qui basculerait `synchronized` ou ferait un sync ponctuel one-shot). - Agents `Scratch` : aucun lien template, hors périmètre de sync. --- ## 9. Stockage & arborescence des fichiers ### 9.1 Dans le projet — `.ideai/` (voyage avec le code, versionnable) ``` / ├── .ideai/ │ ├── agents.json # AgentManifest (mapping md ↔ template ↔ sync ↔ version) │ ├── layout.json # LayoutTree de l'onglet (sérialisé) │ ├── project.json # méta projet local (nom, profil par défaut, remote ref) │ ├── CONTEXT.md # contexte projet partagé, injecté à tous les agents/profils │ ├── memory/ │ │ ├── MEMORY.md # index dérivé de rappel mémoire │ │ ├── .md # notes mémoire, source de vérité │ │ └── .index/ # index vectoriel dérivé/reconstructible │ ├── agents/ │ │ ├── reviewer.md # contexte d'un agent de projet │ │ ├── backend-dev.md │ │ └── ... │ ├── skills/ │ │ ├── code-review.md # contexte d'un skill (voir §14.2) │ │ ├── simplify.md │ │ └── ... │ └── run/ │ ├── / # cwd isolé par agent actif (créé à l'activation) │ │ └── CLAUDE.md # fichier de convention généré par IdeA (profil-dépendant) │ └── ... └── (aucun CONTEXT.md/CLAUDE.md/AGENTS.md/GEMINI.md utilisé par IdeA à la racine — voir §14.1) ``` **Règle de désinstallation projet** : tout artefact projet utilisé par IdeA vit sous `.ideai/`. Si l'utilisateur ne veut plus utiliser IdeA sur un projet, il supprime `.ideai/` : contexte projet, agents, skills projet, mémoire, layouts, requêtes d'orchestration et dossiers d'exécution disparaissent ensemble. Les stores machine-locaux (profils globaux, templates globaux, registre des projets récents) ne sont pas des artefacts du projet. **Schéma `agents.json`** : ```json { "version": 1, "agents": [ { "id": "a3f1...", "name": "Backend Dev", "md": "agents/backend-dev.md", "profileId": "claude-code", "origin": { "type": "fromTemplate", "templateId": "tpl-backend", "syncedTemplateVersion": 4 }, "synchronized": true }, { "id": "b7c2...", "name": "Ad-hoc", "md": "agents/adhoc.md", "profileId": "codex-cli", "origin": { "type": "scratch" }, "synchronized": false } ] } ``` ### 9.2 Store global IDE (données app, hors projet, machine-local) Emplacement résolu via Tauri path API (`AppData`/`~/.local/share/IdeA`/`~/Library/Application Support/IdeA`). ``` /IdeA/ ├── profiles.json # AgentProfile[] configurés (first-run + custom + édités) ├── settings.json # préférences IDE ├── workspace.json # Workspace/Window/Tab + quel projet dans quel onglet (machine-local) └── templates/ ├── index.json # [{id, name, version, contentHash, defaultProfileId}] └── md/ ├── tpl-backend.md ├── tpl-reviewer.md └── ... ``` **Schéma `profiles.json` (item)** : exactement le profil déclaratif de CONTEXT.md §9 (`id, name, command, args, contextInjection{strategy,target/flag/var}, detect, cwd`). **Formats** : contextes & templates en **Markdown** ; tout le reste en **JSON** (serde). Pas de base de données : fichiers plats, simples, diffables, portables (AppImage friendly). > **Note** : `.ideai/run/` contient des répertoires d'exécution éphémères (créés à l'activation, nettoyés à la fermeture). Leur contenu (convention files générés) ne doit **pas** être versionné dans git — ajouter `.ideai/run/` au `.gitignore` du projet. --- ## 10. Arborescence du repo ### 10.1 Décision : workspace Cargo **multi-crate** **Multi-crate** retenu (vs mono-crate) pour **forcer** la règle de dépendance à la compilation : le crate `domain` ne peut littéralement pas dépendre de `infrastructure` si ce n'est pas dans son `Cargo.toml`. C'est la garantie mécanique de l'hexagonal (mieux qu'une convention). Coût : un peu de cérémonie de workspace — acceptable et même souhaitable ici vu le découpage en lots/agents (§12). ``` IdeA/ ├── Cargo.toml # [workspace] members ├── ARCHITECTURE.md ├── CONTEXT.md ├── crates/ │ ├── domain/ # PUR : entities, VO, ports (traits), domain events, layout logic │ │ └── src/{project,agent,template,profile,terminal,layout,remote,git,ports,events}.rs │ ├── application/ # use cases, DTOs, AppError ; dépend de domain │ │ └── src/{project,agent,template,terminal,layout,remote,git}/ │ ├── infrastructure/ # adapters ; dépend de domain (+ application pour DTO si besoin) │ │ └── src/{pty,fs,process,remote,git,store,runtime,eventbus}/ │ └── app-tauri/ # binaire Tauri : commands, events, COMPOSITION ROOT (DI) │ ├── src/{commands,events,state,main.rs} │ ├── tauri.conf.json │ ├── build.rs │ └── icons/, bundle (NSIS + AppImage) ├── frontend/ # TypeScript + React (Vite) │ ├── package.json, vite.config.ts, index.html │ └── src/ │ ├── domain/ # types & logique de vue purs (miroir DTO, calc layout) │ ├── ports/ # gateways TS (interfaces) : AgentGateway, TerminalGateway, ... │ ├── adapters/ # impl gateways via @tauri-apps/api (invoke/listen/Channel) │ │ └── mock/ # impl mock pour dev/test/storybook │ ├── features/ # par feature : projects, agents, templates, terminals, layout, git, remote, first-run │ │ └── /{components,hooks,store,index.ts} │ ├── shared/ # ui kit, xterm wrapper, design system │ └── app/ # bootstrap, routing, providers (DI des adapters) └── docs/ # ADRs, schémas ``` `app-tauri` = **seul** endroit qui connaît tous les crates : il instancie les adapters concrets et injecte dans les use cases (composition root). Personne d'autre ne fait de `new ConcreteAdapter`. --- ## 11. Stratégie de tests | Couche | Type de test | Comment / où | |---|---|---| | `domain` | **Unitaires purs** (sans I/O, sans async) | `#[cfg(test)] mod tests` par module. Invariants d'entités, opérations de layout (split/merge/resize), détection de drift, validation `ContextInjection`. Déterministe via `FixedClock`/`SeqIdGenerator`. | | `application` | **Unitaires avec ports mockés** | Chaque use case testé avec des **mocks de ports** (`mockall` ou fakes manuels). Ex. `LaunchAgent` vérifie qu'il appelle `prepare_invocation` puis `pty.spawn` avec le bon `cwd` et plan d'injection. **Aucun vrai PTY/FS/git.** | | `infrastructure` | **Tests d'intégration ciblés** | Par adapter : `LocalFileSystem` sur tmpdir, `Git2Repository` sur repo temporaire, `PortablePtyAdapter` lance `echo`. SSH/WSL : tests `#[ignore]` gated derrière feature/env (CI conditionnelle). | | `app-tauri` | Tests des commands (mapping DTO ↔ use case) | Wiring testé avec use cases réels + adapters in-memory. | | Frontend `domain`/`ports` | **Vitest** (unitaires purs) | Logique de vue, calc tailles cellules, réducteurs de state. | | Frontend `features` | **React Testing Library** + **gateways mock** | Composants testés avec adapters mock ⇒ **sans backend**. | | E2E (plus tard) | Playwright / `tauri-driver` | Smoke tests des parcours clés. | **Clé de testabilité** : grâce aux **ports**, le domaine et l'application se testent **100 % sans I/O**. C'est l'argument central de l'hexagonal et le socle du cycle dev↔test (chaque agent dev appairé à un agent test, cf. CONTEXT §3). Règle d'or : une feature n'est verte que quand `cargo test -p ` et `vitest` passent. --- ## 12. Découpage en lots/features livrables > Chaque lot = périmètre autonome, validable par le cycle dev/test, confiable à **un binôme (agent dev + agent test)**. Ordonnés par dépendance. | # | Lot | Contenu | Crates/zones | |---|---|---|---| | L0 | **Socle domaine & ports** | Entities, VO, **tous les traits ports**, domain events, `AppError`. Aucun adapter. | `domain` (+ ports utilitaires) | | L1 | **Composition root & IPC** | `app-tauri` : DI, registre de commands/events, bridge PTY↔Channel, gateways TS + adapters Tauri + mocks. | `app-tauri`, `frontend/ports`+`adapters` | | L2 | **Projets & stockage** | `CreateProject`/`OpenProject`/`CloseProject`, `FsProjectStore`, `LocalFileSystem`, init `.ideai/`. UI projets/onglets. | `application/project`, `infrastructure/{fs,store}`, `frontend/features/projects` | | L3 | **Terminaux & PTY (local)** | `PtyPort` + `PortablePtyAdapter`, use cases terminal, wrapper xterm.js, flux Channel. | `infrastructure/pty`, `application/terminal`, `frontend/features/terminals` | | L4 | **Layout tableur** | Logique pure `LayoutTree` (déjà en L0 partiellement), `MutateLayout`, persistance `layout.json`, UI grille redimensionnable + fusion. | `domain/layout`, `application/layout`, `frontend/features/layout` | | L5 | **Profils IA & runtime** | `AgentProfile`, `CliAgentRuntime`, `DetectProfiles`, first-run wizard, `profiles.json`. | `infrastructure/runtime`, `application/agent`, `frontend/features/first-run` | | L6 | **Agents & contextes** | `AgentContextStore`/`IdeaiContextStore`, CRUD agents, `LaunchAgent` (injection + spawn + cellule). | `application/agent`, `infrastructure/store`, `frontend/features/agents` | | L7 | **Templates & synchro** | `TemplateStore`, versioning, `DetectAgentDrift`, `SyncAgentWithTemplate`. UI templates + badges drift. | `application/template`, `infrastructure/store`, `frontend/features/templates` | | L8 | **Git** | `GitRepository`/`Git2Repository`, use cases git, UI git. | `infrastructure/git`, `application/git`, `frontend/features/git` | | L9 | **Remote (SSH + WSL)** | `RemoteHost` stratégie, `SshHost`/`WslHost`, adapters FS/PTY/Spawner distants, `RemoteGitRepository`. UI connexion. | `infrastructure/remote`, `application/remote`, `frontend/features/remote` | | L10 | **Fenêtres & multi-window** | `Workspace`/`Window`/`Tab`, `MoveTabToNewWindow`, drag d'onglet → nouvelle fenêtre OS Tauri. | `application`, `app-tauri`, `frontend/app` | | L11 | **Packaging & livraison** | Tauri bundle : NSIS `setup.exe`, **AppImage** multi-distro, CI Linux+Windows. | `app-tauri`, CI | | L12 | **Skills** | Entité `Skill`, `SkillStore`, CRUD skills global+projet, assignation agent↔skills, injection dans convention file à l'activation. UI onglet Skills. | `domain/skill`, `application/skill`, `infrastructure/store`, `frontend/features/skills` | | L13 | **OrchestratorApi** | File-watcher `.ideai/requests/`, port `OrchestratorApi`, adapter `FsOrchestratorAdapter`, protocole `agent.run`/`agent.stop`/`agent.attach`/`agent.detach`/`agent.message`, sessions agent visibles ou arrière-plan. | `domain/agent`, `application/agent`, `infrastructure/orchestrator`, `app-tauri`, `frontend/features/agents` | | L14 | **Mémoire** | Base de connaissance projet model-agnostic. Modèle 2 étages : `.md` source de vérité (port `MemoryStore`), rappel adaptatif (port `MemoryRecall`), embeddings déclaratifs (port `Embedder`), bascule auto sur seuil. Découpé en sous-lots **A/B/C** (voir §14.5). | `domain/memory`, `application/memory`, `infrastructure/store`, `frontend/features/memory` | | L15 | **Agent = entité à session persistante** | Hot-swap de l'AI profile d'un agent existant (chantier A) + reprise des sessions au redémarrage d'IdeA / réouverture projet (chantier B). Fondation commune « l'agent porte un cycle de vie de session ». Découpé en sous-lots **A0/A1/A2 + B0/B1/B2** (voir §15). | `domain/agent`, `application/agent`, `application/layout`, `infrastructure/store`, `app-tauri`, `frontend/features/agents`, `frontend/features/layout` | | L16 | **Orchestration v3 — invocation native** | **Voie principale = §17 (LIVRÉE)** : messagerie inter-agents synchrone intrinsèque à `AgentSession::send_blocking` (`AskAgent`, `AgentReplied`), **sans** binaire `idea`/outbox/inbox/`AgentReplyChannel` (abandonnés). **Reste à livrer = surface MCP optionnelle** (§14.3.1) : capacité `mcp` déclarative sur le profil + adapter entrant d'infra (outils `idea_*`) par-dessus le **même** `OrchestratorService::dispatch`, repli homogène fichier+prose sinon. Lots **M0→M3** (+M4 optionnel) — voir §14.3.1 et `.ideai/briefs/orchestration-v3-cadrage.md`. | `domain/profile`, `application/agent`, `infrastructure/orchestrator/mcp`, `app-tauri` | --- ## 14. Décisions d'architecture figées (2026-06-06) ### 14.1 Isolation du cwd par agent — résolution de la collision de contexte **Problème** : plusieurs agents du même profil (ex. deux instances Claude Code) sur le même project root produisaient une collision — le fichier de convention (`CLAUDE.md`, `AGENTS.md`…) est un emplacement fixe unique à la racine. **Décision** : le cwd du PTY d'un agent n'est **jamais** le project root. C'est `.ideai/run//`, un dossier créé par IdeA à l'activation et nettoyé à la fermeture. **Convention file généré par IdeA** : IdeA écrit dans ce dossier le fichier conventionnel attendu par le profil (`CLAUDE.md`, `AGENTS.md`, etc.). Ce fichier contient : 1. Le **chemin absolu du project root** (pour que l'agent sache où opérer). 2. Le contrat d'**orchestration IdeA** + brief capacités (délégation via outils `idea_*` en surface MCP, sinon via `.ideai/requests` — jamais les subagents natifs du fournisseur). 3. Le **contexte projet partagé** (`.ideai/CONTEXT.md`), si présent. 4. La **persona/rôle** de l'agent (son `.md` dans `.ideai/agents/`). 5. Les **skills actifs** assignés à cet agent (voir §14.2). 6. Le **rappel mémoire** du projet (index/hooks — pointeurs), si présent (voir §14.5.4). 7. L'**état du projet** (section `# État du projet`) — projection *live-state* maigre « qui fait quoi maintenant », bornée (cap `LIVE_STATE_INJECT_MAX`, agent lancé exclu, ordre manifeste, vide ⇒ omise) (LS4 ; voir §21 et `docs/LS8`). 8. La **reprise de la conversation** (section `# Reprise de la conversation`) — *handoff* distillé du fil, **borné** (`HANDOFF_SUMMARY_MAX_CHARS=4096`) à l'écriture **et** à l'injection (LS5 ; voir §21). Omise sans handoff. **Avantages** : - Zéro collision entre agents, même N instances du même profil. - Universel : fonctionne pour toute CLI qui lit un fichier de convention depuis son cwd — aucun flag ou commande propre à un modèle. - Zéro dépendance à git (git est optionnel — supprimer un repo ne casse rien). **Impact sur `AgentProfile.cwd_template`** : la valeur est toujours `"{agentRunDir}"`, jamais `"{projectRoot}"`. La connaissance du project root passe par le *contenu* du convention file, pas par le cwd. --- ### 14.2 Skills — abstraction universelle de workflows réutilisables **Définition** : un **Skill** est un workflow/comportement réutilisable qu'on peut assigner à un agent. Exemples : `code-review`, `simplify`, `run-tests`, `explain`. C'est l'équivalent universel des slash-commands de Claude Code — mais sans dépendance à la syntaxe `/command` d'un modèle particulier. **Stockage** : - Skills globaux (templates) : `/IdeA/skills/` (store global IDE, réutilisables entre projets). - Skills de projet : `.ideai/skills/.md` (spécifiques au projet). **Injection** : les skills assignés à un agent sont **inclus dans son convention file** généré par IdeA au moment de l'activation. L'agent reçoit donc ses skills comme du contexte textuel — aucun mécanisme CLI propriétaire. **Entité `Skill`** (à ajouter au domaine) : - Champs : `id`, `name`, `content_md: MarkdownDoc`, `scope: SkillScope` (`Global` | `Project`). - Un agent peut avoir 0..N skills assignés (stocké dans l'`AgentManifest`). **Port `SkillStore`** : CRUD skills globaux + skills projet (compose `FileSystem`/store global selon le scope). --- ### 14.3 OrchestratorApi — IdeA orchestre les agents, pas les CLIs fournisseurs **Objectif** : qu'un agent ou l'utilisateur puisse demander à IdeA de faire travailler un autre agent IdeA, visible dans la grille ou en arrière-plan, sans passer par les subagents natifs d'un fournisseur IA. **Décision** : IdeA est l'**orchestrateur unique** du cycle de vie des agents. Un agent Claude, Codex, Gemini ou custom ne lance jamais directement un subagent natif de son fournisseur. Il écrit une demande d'orchestration IdeA ; IdeA résout l'agent cible, son `AgentProfile`, son contexte, ses skills et sa mémoire, puis lance ou réattache la session via le runtime adapté au profil cible. Conséquence : le profil IA de l'agent demandeur ne contraint pas le profil IA de l'agent cible. Exemple valide : ```text Main = Codex Architect = Claude DevBackend = Codex Ask = Gemini ``` Si `Main` demande à `Architect` de travailler, IdeA lance/réattache `Architect` avec son profil Claude. `Main` ne connaît ni la commande Claude ni son format de contexte. **Mécanisme** : file-watching sur `.ideai/requests//`. L'agent écrit un fichier JSON de requête stable, consommé par IdeA : ```json { "type": "agent.run", "requestedBy": "Main", "targetAgent": "Architect", "task": "Analyser la décision d'architecture multi-agents", "visibility": "background", "attachToCell": null } ``` IdeA détecte le fichier, exécute les use cases d'orchestration, écrit une réponse, puis archive ou supprime la requête traitée. Le même chemin applicatif est utilisé depuis l'UI. **Contrat session/cellule** : - `Agent` = définition stable (contexte `.ideai/agents/.md`, profil IA, origine template). - `AgentSession` = exécution active d'un agent. - `CellBinding` = affichage optionnel d'une session dans une cellule. Invariants : - une `AgentSession` active peut être attachée à **0 ou 1** cellule visible ; - une cellule peut afficher **0 ou 1** session active ; - fermer une cellule détache la session (`Visible` → `Background`) et ne tue jamais l'agent ; - ouvrir une cellule sur une session déjà active réattache la cellule à cette session et montre le travail en cours ; - arrêter une session est une action explicite (`agent.stop`), distincte de la fermeture UI. **Port `OrchestratorApi`** (adapter entrant, driven by file-watcher) : surveille `.ideai/requests/`, désérialise les commandes, les traduit en appels de use cases (`RequestAgentWork`, `LaunchAgentSession`, `AttachAgentSessionToCell`, `DetachAgentSessionFromCell`, `StopAgentSession`…). Implémenté dans `infrastructure/orchestrator`. **Actions supportées (v2 cible)** : - `agent.run` : lance ou reprend une session pour un agent cible ; `visibility` vaut `background` ou `visible`. - `agent.stop` : arrête explicitement une session agent et son PTY. - `agent.attach` : attache une session active à une cellule. - `agent.detach` : détache une session active vers l'arrière-plan. - `agent.message` : transmet une tâche/message à une session existante. - `agent.update_context` : demande une mise à jour de contexte via les use cases IdeA, pas par écriture sauvage hors store. - `skill.create` : crée un skill réutilisable comme l'UI (use case `CreateSkill`). Exemple `skill.create` : ```json { "type": "skill.create", "name": "deploy", "context": "# Étapes de déploiement…", "scope": "project" } ``` **Instruction injectée aux agents** : les convention files générés (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`…) doivent contenir une règle explicite : pour déléguer une tâche, l'agent utilise le protocole IdeA `.ideai/requests` et n'utilise pas les subagents natifs du fournisseur. Cela garantit que l'IDE garde l'identité des agents, leur mémoire, leur contexte et leur observabilité UI. **Impact UI/UX** : le layout n'est pas la source de vérité du travail agent. La grille affiche des vues attachées aux sessions. L'UI doit exposer un registre des sessions visibles et arrière-plan, avec actions ouvrir dans une cellule, détacher, déplacer, arrêter, et afficher la relation `requestedBy`/`targetAgent` quand elle existe. > **Évolution v3 — état réel (révisé 2026-06-10, cf. `.ideai/briefs/orchestration-v3-cadrage.md`)** : ce protocole fichier reste le **contrat partagé / repli universel**. Le manque historique de §14.3 — la **messagerie inter-agents synchrone** (`task` ignoré pour un agent vivant, réponse = simple ACK) — est désormais **comblé par §17** (pivot livré) : `OrchestratorCommand::AskAgent` + `OrchestratorService::ask_agent` transmettent la tâche et **renvoient la réponse de contenu inline** via `AgentSession::send_blocking` (le `Final` du flux *est* la fin de tour déterministe), event `AgentReplied` pour l'observabilité, **sans outbox ni corrélation fichier** (abandonnés). La cible réutilise sa session structurée vivante (invariant « 1 session/agent » §17.4) ; un timeout laisse la cible vivante (erreur typée). > > **Ce qui reste pour v3 = la seule surface MCP optionnelle** (§14.3.1) : un **adapter entrant d'infrastructure** (outils typés `idea_*`) qui appelle le **même** `OrchestratorService::dispatch`, en repli homogène sur ce protocole fichier + prose quand le profil ne déclare pas MCP. **Abandonnés** (ne pas implémenter) : binaire `idea` (`ask/reply/next`), skill built-in auto-rapporté, inbox `.ideai/inbox/`, outbox `.ideai/outbox/`, port `AgentReplyChannel`/`OutboxReplyChannel`, `CorrelationId` (le rendez-vous synchrone est intrinsèque à `send_blocking`, §17.1). §16 est conservé pour mémoire historique uniquement. #### 14.3.1 Surface MCP — invocation native d'agents (adapter entrant OPTIONNEL, par-dessus le même `OrchestratorService`) > Cadrage complet : `.ideai/briefs/orchestration-v3-cadrage.md`. Cette sous-section fige les 4 décisions et le découpage en lots. Elle **ne réécrit ni le domaine ni l'application** : elle ajoute une **capacité déclarative de profil** + un **adapter entrant d'infra**. **Objectif** : rendre l'invocation d'un agent par un autre **aussi native qu'un subagent** (outil typé visible dans la liste d'outils, résultat **inline**), de façon **model-agnostic**, **toujours médiée par IdeA**. On **garde l'interdiction des subagents natifs** (prose) et on offre **la vraie alternative native** (outils `idea_*`). **Décision 1 — Capacité MCP = champ optionnel `mcp: Option` sur `AgentProfile`** (Open/Closed, comme `session`/`structured_adapter` ; `skip_serializing_if = None` ⇒ zéro régression sérialisation). `None` ⇒ **repli fichier `.ideai/requests` + prose** (comportement actuel). `Some(_)` ⇒ IdeA matérialise la conf MCP de cette CLI au lancement et l'agent voit les outils. `McpCapability { config: McpConfigStrategy::{ConfigFile{target}|Flag{flag}|Env{var}}, transport: {Stdio|Socket} }`. Modèle **en couches** : `surface(agent) = if profile.mcp.is_some() { Mcp } else { FileProtocol }` — les deux produisent le **même** `OrchestratorCommand` ; aucun agent n'est jamais bloqué. **Décision 2 — Retour synchrone d'`ask` : RIEN de neuf.** L'outil `idea_ask_agent` appelle le **même** `dispatch(AskAgent{target,task})` qui **renvoie déjà** `OrchestratorOutcome.reply` via `send_blocking` (§17.4) ; l'adapter MCP renvoie ce contenu **inline**. La corrélation requête↔réponse est portée **nativement par JSON-RPC** (côté MCP) et par le sibling `*.response.json` (côté fichier). **Pas** de `CorrelationId`, pas d'outbox. Timeout borné (300 s) ⇒ cible **vivante**, erreur typée. Cible PTY brut ⇒ erreur explicite (jamais d'ACK trompeur). **Décision 3 — Interdiction conservée + injection de la conf MCP par CLI au `LaunchAgent`.** La conf MCP est matérialisée dans le **run dir isolé** (§14.1), **après `apply_injection`, avant le spawn/`factory.start`**, selon `McpConfigStrategy` (`ConfigFile`→write non-clobbering ; `Flag`→`SpawnSpec.args` ; `Env`→`SpawnSpec.env`) — **symétrique** au convention file et au seed de permissions. La prose `compose_convention_file` est **adaptée selon la surface** (outils `idea_*` si MCP, sinon `.ideai/requests`). Le **serveur MCP** est démarré **par projet ouvert**, dans le **même hook** `ensure_orchestrator_watch`, à côté du `FsOrchestratorWatcher`. **Décision 4 — Frontières.** Le serveur MCP est un **driving adapter d'infra** (`infrastructure/src/orchestrator/mcp/`), **pair** du `FsOrchestratorWatcher`, qui traduit un appel d'outil (`idea_ask_agent`/`idea_launch_agent`/`idea_list_agents` + parité `idea_update_context`/`idea_create_skill`/`idea_stop_agent`) en `OrchestratorCommand` et appelle le **même** `OrchestratorService::dispatch`. **Aucun nouveau port** domaine/application. Trois portes d'entrée substituables (fichier, MCP, UI) ⇒ une seule logique applicative. JSON-RPC, stdio/socket, le crate MCP : **confinés à l'adapter** ; le domaine/application ignorent MCP. **Découpage en LOTS (méthode §3)** — MCP uniquement ; le chemin fichier reste vert à chaque lot ; spike S-MCP (crate + transport + format de conf par CLI) **confiné au lot M2** : | Lot | Côté | Périmètre | Tests attendus | |---|---|---|---| | **M0** | back | `McpCapability`/`McpConfigStrategy`/`McpTransport` (domaine validés) + champ `AgentProfile.mcp` (builder `with_mcp`, `new` inchangé) ; catalogue Claude/Codex annotés. | round-trip `mcp=None` **identique à avant** ; `Some(_)` round-trip ; constructeurs valident ; catalogue annoté. | | **M1** | back | `LaunchAgent` injecte la conf MCP (run dir, après `apply_injection`) ; prose adaptée selon `mcp.is_some()`. | `mcp=None` ⇒ aucun write/flag/env MCP (chemin inchangé) ; `ConfigFile` non-clobbering ; `Flag`/`Env` enrichissent `spec` ; prose correcte selon surface. | | **M2** | back | `infrastructure/src/orchestrator/mcp/` : serveur MCP, outils `idea_*`→`OrchestratorCommand`→`dispatch`→résultat inline. Spike S-MCP isolé. | chaque outil mappe la bonne commande ; `idea_ask_agent` renvoie `reply` inline ; timeout typé, cible vivante ; JSON-RPC malformé → erreur, jamais panic ; hors-réseau. | | **M3** | back | Démarrer le serveur MCP par projet dans `ensure_orchestrator_watch` (registre `mcp_servers` jumeau de `orchestrator_watchers`) ; arrêt à la fermeture. | un serveur/projet, idempotent ; arrêt à la fermeture ; coexiste avec le watcher fichier. | | **M4** *(optionnel)* | front | Surfacer la source (`mcp`/`file`) d'une délégation dans l'UI Agents. | badge source ; pas de régression sans event. | **Ordre** : **M0 → M1 → M2 → M3** (→ M4 optionnel). Chantiers adjacents **déjà livrés** (non prérequis) : hot-swap profil (A, §15.1) et reprise auto (B, §15.2) — au relance, `LaunchAgent` (ré)injecte/retire la conf MCP automatiquement puisque la surface suit le profil courant. **État réel post-M3 (2026-06-10)** : M0→M3 livrés, **mais deux verrous restants** empêchent le « IdeA-only natif » et sont tranchés en **§14.3.2 (orchestration v5)** : (1) le **bind transport S-MCP** n'est pas câblé — `McpServer::serve` n'est jamais piloté, le serveur par projet est juste *parqué* (`state.rs::ensure_mcp_server`), donc aucune CLI lancée n'est réellement connectée aux outils `idea_*` ; (2) un **bug de robustesse du registre de session** (mémoire `session-registry-agent-ambiguity`) doit être corrigé pour fiabiliser le routage de `ask`. #### 14.3.2 Orchestration v5 — bind transport S-MCP + fix registre session > **✅ LIVRÉ / FIGÉ 2026-06-12 (commit `eca2ba9`, sur la base de `cf89b3b` M5a-e).** L'ensemble R0→A0→M5a-e est **code-complet, tests verts** ; seule la validation end-to-end réelle en AppImage (CLI Claude/Codex live) reste à faire — ce n'est pas un sujet d'architecture. **Le « verrou M5 ouvert » mentionné dans les anciens passages est PÉRIMÉ** : le transport est réellement vivant (bind loopback + handshake + `.mcp.json` réel). La cartographie nette des ports/adapters livrés est consolidée en **§18**. > Cadrage complet : `.ideai/briefs/orchestration-v5-transport-bind-cadrage.md`. Cette sous-section fige le **dernier kilomètre** (transport réellement vivant) et le **fix de robustesse** prérequis. Elle ne réécrit ni le domaine ni l'application : elle **remplit** le placeholder de conf MCP, **pilote `serve`** par connexion, et **durcit** un invariant existant. **Décision V5-1 — Transport S-MCP = `stdio-spawn` (loopback), socket = TODO.** Une CLI MCP (Claude/Codex) attend une déclaration `{command,args}` et **spawn elle-même** ce process à l'`initialize`. IdeA fournit donc une **sous-commande `mcp-server` du binaire app-tauri existant** (route dans `main.rs` avant init Tauri, **un seul exécutable livré** AppImage/setup.exe) : un **pont** ultraléger `StdioTransport(stdin,stdout)` ↔ **endpoint loopback du projet** (Unix domain socket / Windows named pipe, **sans port réseau** ⇒ AppImage/Windows/SSH-safe). Le `McpServer` (qui tient l'`OrchestratorService`/`Project`) **reste dans le process Tauri** ; `McpServerHandle` **accepte** sur l'endpoint et **spawn une tâche `McpServer::serve(conn)` par pair**. Le **point dur** « comment le process serveur retrouve le bon projet » est résolu par **injection d'identité aux `args`** (`--endpoint`/`--project`/`--requester`), fixée au `LaunchAgent` (projet connu à ce moment). Le socket direct est **rejeté en défaut** (ports/permissions/cross-OS, support CLI inégal) mais reste un **ajout sans toucher `McpServer`** derrière le trait `Transport`. **Décision V5-2 — Cohérence conf↔serveur, source d'endpoint unique.** `apply_mcp_config` (M1) écrit la **déclaration réelle** (fin du placeholder `mcp_server_declaration`) : `command = current_exe()`, `args = ["mcp-server","--endpoint",mcp_endpoint(project),"--project",id,"--requester",agent]`. Le **chemin d'endpoint** vient d'une **fonction unique** `mcp_endpoint(project_id)` partagée par celui qui **écrit** la conf (M1/M5d) et celui qui **écoute** (`ensure_mcp_server`/M5a) ⇒ **zéro chaîne dupliquée**, invariant de cohérence testable. `McpConfigStrategy` inchangé (`ConfigFile` écrit le fichier non-clobbering ; `Flag`/`Env` portent le chemin de conf). L'identité du pair (`--requester`) lève le `requester_id = "mcp"` figé ⇒ observabilité UI exacte (qui délègue à qui). **Décision V5-3 — Fix registre session = lot PRIORITAIRE et indépendant du transport. ✅ RÉSOLU 2026-06-12.** L'ancienne ambiguïté de `session_for_agent` (mémoire `session-registry-agent-ambiguity`) est **PÉRIMÉE** : l'invariant « 1 agent = 1 session vivante » est désormais gardé par les registres `TerminalSessions`/`StructuredSessions` agrégés en `LiveSessions`, avec `session_for_agent` (non ambigu) **+** `sessions_for_agent` (pluriel) et un garde reattach `Rebind`/`Refuse`/`Idempotent` dans `LaunchAgent`. Invariant correct = **« 1 session vivante par agent »** (décision produit verrouillée : un agent est un **singleton**, la cellule est une **vue** §17.6 — *pas* d'identité par cellule à inventer). `session_for_agent` est déterministe **à condition** d'enforcer l'invariant sur **les deux** registres. **Trois fuites** à boucher : (A) le garde de `LaunchAgent` ne lève **jamais** `AgentAlreadyRunning` (rebind/idempotent silencieux qui masque un second lancement) ⇒ distinguer **réattache de vue** (rebind) de **lancement neuf** (refus typé) ; (B) `list_live_agents` est **aveugle aux sessions structurées** (lit seulement `terminal_sessions`) ⇒ lire l'agrégateur `LiveSessions` (PTY+chat) ; (C) les `layouts.json` **à doublons** (N feuilles, même agent) ⇒ **réconciliation à l'ouverture** (garder une hôte, dé-flagger les autres), ce qui supprime le symptôme « une cellule reset au retour d'onglet ». **Décision V5-4 — Robustesse `ask` : sérialisation FIFO par agent.** Au-dessus de l'existant (cible morte ⇒ lancement structuré ; PTY brut ⇒ `Invalid` ; timeout 300 s ⇒ cible vivante + erreur typée), le seul manque est la **concurrence** : deux `ask` simultanés sur la même cible appelleraient `send_blocking` en parallèle sur **une** `AgentSession` ⇒ tours entrelacés (cf. bug accents = writes non sérialisés). `OrchestratorService::ask_agent` **sérialise les tours par `agent_id`** (verrou par agent) : file FIFO naturelle, timeout **par tour**, plafond d'attente borné. Règle **applicative** (vit dans le service/registre, pas dans l'adapter MCP). **Décision V5-5 — Frontières.** `McpServer::serve` est piloté **par connexion** dans l'adapter infra ; `McpServerHandle` (app-tauri) évolue de « parker » à « ouvrir l'endpoint + boucle d'`accept` + spawn serve par pair » (toujours non-bloquant pour open/close projet). Le sous-process `mcp-server` ne connaît que **stdio + loopback + JSON brut** (zéro `OrchestratorService`). `dispatch` est appelé **à l'identique** par les trois portes (fichier, MCP, UI). **Découpage en LOTS (méthode §3)** — **R0 d'abord** (fix registre, indépendant), puis **A0** (concurrence `ask`), puis **M5x** (bind), puis front optionnel : | Lot | Côté | Périmètre | Tests attendus | |---|---|---|---| | **R0a** | back | Garde `LaunchAgent`/`spawn_agent` : lever `AgentAlreadyRunning` pour un lancement **neuf** d'un agent vivant sur un **autre** node ; rebind si node = hôte. | neuf+ailleurs ⇒ `AGENT_ALREADY_RUNNING` ; réattache même node ⇒ rebind sans respawn ; idempotence inchangée. | | **R0b** | back | `list_live_agents` lit `LiveSessions::live_agents()` (PTY+structuré). | un agent **chat** vivant apparaît ; PTY aussi ; pas de doublon. | | **R0c** | back | Réconciliation à l'ouverture : agent sur N feuilles ⇒ garder une hôte, dé-flagger les autres. | layout à doublons ⇒ une seule feuille « en cours » ; sans doublon inchangé ; 2ᵉ ouverture = no-op. | | **R0d** | front | Dropdown leaf : désactiver via R0b (PTY+chat) ; gérer `AGENT_ALREADY_RUNNING` (aller-à/déplacer). | option désactivée + « aller à la cellule » ; erreur mappée clairement. | | **A0** | back | Sérialisation FIFO par agent dans `ask_agent` (verrou par `agent_id`, timeout par tour, plafond d'attente). | 2 `ask` même cible ⇒ séquentiels FIFO ; cibles différentes ⇒ parallèles ; timeout libère la file ; plafond ⇒ `Timeout`. | | **M5a** | back | Endpoint loopback par projet `mcp_endpoint(project_id)` ; ouvert à l'open, fermé au close. | endpoint créé/supprimé ; idempotent (1/projet) ; déterministe ; pas de collision. | | **M5b** | back | Sous-commande `mcp-server` dans `main.rs` : pont stdio↔loopback, handshake `--project`/`--requester`. | mode headless (pas de webview) ; relai requête↔réponse ; EOF ⇒ sortie propre ; endpoint absent ⇒ erreur, jamais de hang. | | **M5c** | back | `McpServerHandle` accepte + `serve(conn)` par pair ; `requester_id` = agent réel (fin du `"mcp"`). | bout-en-bout local `initialize`/`tools/*` ; requester = agent ; déconnexion isolée ; arrêt ferme l'endpoint. | | **M5d** | back | `apply_mcp_config` écrit la déclaration réelle (exe + `mcp_endpoint` partagé) ; non-clobbering. | `mcp=None` inchangé ; `.mcp.json` pointe exe+endpoint exacts ; endpoint identique à `ensure_mcp_server` (cohérence M1↔M3). | | **M5e** | back | Smoke end-to-end loopback (faux pont, sans CLI) : `idea_list_agents`/`idea_ask_agent` → `dispatch` réel. | liste JSON ; `ask` structuré ⇒ `reply` inline ; PTY ⇒ erreur typée ; JSON-RPC malformé ⇒ erreur, jamais panic ; hors réseau. | | **M5-UI** *(opt.)* | front | Badge source (`mcp`/`file`) + requester réel sur une délégation. | badge correct ; requester = agent réel ; pas de régression sans event. | **Ordre** : **R0a→R0b→R0c→R0d → A0 → M5a→M5b→M5c→M5d→M5e → M5-UI**. R0 et A0 sont livrables **sans** toucher MCP ; M5 ne part qu'**après** R0 (sinon on débugge `ask` mal routé + transport neuf simultanément). --- ### 14.4 Git = intégration optionnelle, zéro dépendance fonctionnelle Git est un **outil posé par-dessus l'IDE**, pas un socle. Supprimer le repo git d'un projet ne doit casser aucune feature d'IdeA (agents, terminaux, layout, skills, orchestration). Les use cases git (L8) sont un module indépendant ; rien d'autre n'en dépend. Cette contrainte s'applique à toute future décision de conception. --- ### 14.5 Système de mémoire — base de connaissance projet model-agnostic (L14) **Objectif** : doter chaque projet d'une **mémoire persistante** (préférences, décisions, feedback, références) que les agents lisent et enrichissent, **sans jamais dépendre d'un modèle ou d'une CLI** (principe fondateur). La mémoire est commune au project root, partagée par tous les agents (décision : mémoire au niveau projet, pas par `run/`). #### Modèle 2 étages (hybride) - **Étage 1 — fichiers `.md` (source de vérité)** : chaque note = un `.md` (frontmatter YAML `name`/`description`/`metadata.type` + corps Markdown) sous `.ideai/memory/.md`. Un index agrégé `MEMORY.md` (une ligne `- [Titre](slug.md) — hook` par note) est **dérivé**, reconstructible. Les notes se référencent par liens `[[slug]]`. C'est le **seul** étage qui fait foi. - **Étage 2 — index de rappel sémantique vectoriel (dérivé)** : embeddings des notes, **jamais source de vérité**, reconstructible et supprimable à tout moment. Sert uniquement à accélérer/cibler le rappel quand la mémoire devient trop grosse pour être lue intégralement. #### Bascule étage 1 ↔ étage 2 (automatique, sur seuil objectif) Le **rappel** (port `MemoryRecall`) est adaptatif : tant que la taille de la mémoire reste sous un **budget de tokens** configurable, l'adapter **naïf** lit `MEMORY.md` intégralement (zéro dépendance). Au-dessus du seuil, l'adapter **vectoriel** prend le relais. La bascule est une décision objective (taille vs budget), pas un choix manuel. **Défaut : `none`** (rappel naïf, aucun embedder) — esprit Linux « rien d'imposé, tout fonctionnel ». #### Ports (frontières domaine) - **`MemoryStore`** *(fait — LOT A étage 1)* : CRUD des `.md` + index `MEMORY.md` dérivé + `resolve_links` (ignore les liens cassés). Adapter `FsMemoryStore` (compose `FileSystem`, location-neutral). Erreurs `MemoryError`. - **`MemoryRecall`** *(LOT B)* : rappel adaptatif d'un sous-ensemble pertinent de notes pour une requête. Deux adapters substituables (Liskov) : `NaiveMemoryRecall` (lecture intégrale via `MemoryStore`) et, plus tard, `VectorMemoryRecall` (étage 2). - **`Embedder`** *(LOT C)* : production de vecteurs d'embedding, décrit par des **profils déclaratifs façon CLI LLM** (§9). Stratégies : `localOnnx` / `localServer` / `api` / `none`. `none` = pas d'embedder ⇒ rappel naïf forcé. #### Conformité hexagonale `MemoryStore`/`MemoryRecall`/`Embedder` sont des **traits du domaine** ; les adapters vivent en `infrastructure`. L'application ne parle qu'aux ports. Le domaine `memory` reste I/O-free (le format YAML/index est **possédé par l'adapter** `FsMemoryStore`, pas par le domaine). La mémoire est **indépendante de git** (§14.4) et du moteur IA. #### Découpage en sous-lots (binôme dev+test par sous-lot) - **LOT A — Étage 1 `.md`** : *domaine + adapter faits et verts.* Reste à livrer : **use cases `application/memory`** + **câblage app-tauri** (commandes + DTO ; les events `Memory*` et leurs DTO existent déjà). Voir §14.5.1. - **LOT B — Rappel adaptatif** : port `MemoryRecall`, adapter `NaiveMemoryRecall`, use case `RecallMemory`. Voir §14.5.2. - **LOT C — Étage 2 vectoriel** : port `Embedder`, profils déclaratifs `embedder.json`, adapter `VectorMemoryRecall`, logique de bascule sur seuil. Voir §14.5.3. - **Sous-lot d'intégration L14 ↔ L6** : injection du rappel mémoire dans le convention file à l'activation d'un agent (`LaunchAgent` compose `MemoryRecall`). Voir §14.5.4. ##### 14.5.1 LOT A (fin) — contrats application + app-tauri **Use cases `application/memory`** (miroir de `application/skill`, chacun `Arc`, `execute(Input) -> Result`) : ```rust // CreateMemory — crée/écrit une note + upsert index. Émet MemorySaved. struct CreateMemoryInput { project_root: ProjectPath, name: String, // slug brut → MemorySlug::new description: String, r#type: MemoryType, content: String } struct CreateMemoryOutput { memory: Memory } // UpdateMemory — remplace une note existante (revalide invariants). Émet MemorySaved. struct UpdateMemoryInput { project_root: ProjectPath, slug: MemorySlug, description: String, r#type: MemoryType, content: String } struct UpdateMemoryOutput { memory: Memory } // ListMemories — liste les notes (pilotée par l'index). struct ListMemoriesInput { project_root: ProjectPath } struct ListMemoriesOutput { memories: Vec } // GetMemory — une note par slug. struct GetMemoryInput { project_root: ProjectPath, slug: MemorySlug } struct GetMemoryOutput { memory: Memory } // DeleteMemory — supprime une note (retire la ligne d'index). Émet MemoryDeleted. struct DeleteMemoryInput { project_root: ProjectPath, slug: MemorySlug } // -> () // ReadMemoryIndex — lit MEMORY.md structuré (pour l'affichage graphique de la mémoire). struct ReadMemoryIndexInput { project_root: ProjectPath } struct ReadMemoryIndexOutput { entries: Vec } // ResolveMemoryLinks — liens [[slug]] sortants résolus (liens cassés ignorés). struct ResolveMemoryLinksInput { project_root: ProjectPath, slug: MemorySlug } struct ResolveMemoryLinksOutput { links: Vec } ``` > **Décision** : on expose les **7** use cases (CRUD complet + `ReadMemoryIndex` + `ResolveMemoryLinks`). `ReadMemoryIndex` alimente la vue graphique de la mémoire ; `ResolveMemoryLinks` alimente la navigation par liens. `CreateMemory`/`UpdateMemory` portent en plus `Arc`. **Émission d'events** (ports `MemoryStore` + `EventBus` ; events déjà définis dans `domain::events`) : | Use case | Event émis | |---|---| | `CreateMemory`, `UpdateMemory` | `DomainEvent::MemorySaved { slug }` | | `DeleteMemory` | `DomainEvent::MemoryDeleted { slug }` | | `ListMemories`, `GetMemory`, `ReadMemoryIndex`, `ResolveMemoryLinks` | *(aucun — lectures)* | > `MemoryIndexRebuilt { project_id }` n'est **pas** émis par les use cases CRUD (l'index est réécrit en interne par le store à chaque save/delete, pas un rebuild distinct). Le réserver à un futur use case `RebuildMemoryIndex` (reconstruction explicite depuis les `.md`) — non requis pour clore LOT A. **Mapping `From for AppError`** (à ajouter dans `application/src/error.rs`, calqué sur `From`) : ```rust impl From for AppError { fn from(e: MemoryError) -> Self { match e { MemoryError::NotFound => Self::NotFound("memory note".to_owned()), MemoryError::Frontmatter(m) => Self::Invalid(m), // donnée malformée = invariant other /* Io | Serialization */ => Self::Store(other.to_string()), } } } ``` **Commandes app-tauri ↔ DTO** (miroir des commandes skills ; `resolve_project(project_id)` → `project.root` ; slug parsé via `MemorySlug::new` qui renvoie `INVALID` si invalide) : | Commande Tauri | Request DTO (camelCase) | Réponse | |---|---|---| | `create_memory` | `{ projectId, name, description, type, content }` | `MemoryDto` | | `update_memory` | `{ projectId, slug, description, type, content }` | `MemoryDto` | | `list_memories` | `{ projectId }` | `MemoryListDto` | | `get_memory` | `{ projectId, slug }` | `MemoryDto` | | `delete_memory` | `{ projectId, slug }` | `()` | | `read_memory_index` | `{ projectId }` | `MemoryIndexDto` | | `resolve_memory_links` | `{ projectId, slug }` | `MemoryLinksDto` | DTO de réponse (`Memory`/`MemoryIndexEntry`/`MemoryLink` dérivent déjà `Serialize` côté domaine pour les types persistés ; sinon mapping explicite façon `SkillDto`) : `MemoryDto(Memory)`, `MemoryListDto(Vec)`, `MemoryIndexDto(Vec)`, `MemoryLinksDto(Vec)` (slugs cibles). Enregistrer les 7 commandes dans `tauri::generate_handler!` (`app-tauri/src/lib.rs`). Câbler les 7 use cases dans le composition root (`state.rs`) : `FsMemoryStore` (déjà exporté) injecté en `Arc`, partagé par tous les use cases, `EventBus` pour Create/Update/Delete. > Note d'implémentation : `FsMemoryStore` prend le `root` **par appel** (comme `SkillStore`) ⇒ **une seule** instance store partagée, comme pour les skills. ##### 14.5.2 LOT B — port `MemoryRecall` + adapter naïf ```rust /// Rappel adaptatif d'un sous-ensemble pertinent de notes pour une requête. #[async_trait] pub trait MemoryRecall: Send + Sync { /// Renvoie les notes les plus pertinentes pour `query`, limitées à `budget`. /// Contrat : best-effort, jamais d'erreur bloquante sur mémoire vide /// (renvoie une liste vide) ; l'adapter naïf ignore la pertinence et /// renvoie l'index/les notes dans l'ordre, tronqué au budget. async fn recall( &self, root: &ProjectPath, query: &MemoryQuery, ) -> Result, MemoryError>; } pub struct MemoryQuery { pub text: String, // requête (souvent le contexte courant de l'agent) pub token_budget: usize, // budget au-delà duquel l'étage 2 prendrait le relais } ``` - **Adapter `NaiveMemoryRecall`** (`infrastructure`) : compose `Arc`, lit `read_index` et tronque au budget. Aucune dépendance externe. C'est le **défaut**. - **Use case `RecallMemory`** (`application/memory`) : `RecallMemoryInput { project_root, text, token_budget } -> RecallMemoryOutput { entries: Vec }`, port `Arc`. Pas d'event. - **Contrat de substituabilité (Liskov)** : `NaiveMemoryRecall` et `VectorMemoryRecall` (LOT C) sont interchangeables ; un budget nul ⇒ liste vide ; mémoire absente ⇒ liste vide, jamais d'erreur. ##### 14.5.3 LOT C — port `Embedder` + profils + adapter vectoriel + bascule ```rust /// Produit des vecteurs d'embedding, piloté par un profil déclaratif (façon §9). #[async_trait] pub trait Embedder: Send + Sync { fn id(&self) -> &str; // ex. "local-onnx-minilm" async fn embed(&self, texts: &[String]) -> Result>, EmbedderError>; fn dimension(&self) -> usize; // taille des vecteurs produits } ``` - **Profils déclaratifs** `embedder.json` (store global IDE, comme `profiles.json`) : `{ id, name, strategy: "localOnnx"|"localServer"|"api"|"none", model?, endpoint?, apiKeyEnv?, dimension }`. **Open/Closed** : ajouter un moteur d'embedding = une donnée, pas du code. - **Adapter `VectorMemoryRecall`** (`infrastructure`) : compose `Arc` + `Arc` + un store de vecteurs dérivé sous `.ideai/memory/.index/` (reconstructible, ajouté au `.gitignore` au même titre que `run/`). Implémente `MemoryRecall`. - **Logique de bascule** (dans le use case `RecallMemory` ou un `AdaptiveMemoryRecall` qui compose les deux adapters) : si taille mémoire ≤ `token_budget` **ou** stratégie embedder = `none` ⇒ `NaiveMemoryRecall` ; sinon ⇒ `VectorMemoryRecall`. Décision objective, testable sans I/O (la mesure de taille est une fonction pure de l'index). - **`EmbedderError`** : nouveau type d'erreur par port (façon `MemoryError`), mappé en `AppError` (`none`/indisponible ⇒ dégrade vers naïf, jamais d'échec dur du rappel). - **Garde-fou produit** : défaut `none` ⇒ LOT C **n'impose aucune dépendance** ; un utilisateur sans embedder a une mémoire pleinement fonctionnelle (étage 1 + rappel naïf). ##### 14.5.4 Injection de la mémoire à l'activation d'un agent (intégration L14 ↔ L6) **Problème** : la mémoire (`MemoryStore` + `MemoryRecall`) existe et est consultable par commandes, mais **aucun agent ne la lit**. À l'activation d'un agent, IdeA génère le convention file (`CLAUDE.md`/`AGENTS.md`…) dans le run dir et y injecte la persona + les skills assignés (§14.1/§14.2) ; il faut y ajouter le **rappel mémoire** du projet, exactement comme les skills. **Décision (où injecter)** : le rappel est composé dans la **même fonction pure** `compose_convention_file`, qui devient : ```rust pub(crate) fn compose_convention_file( project_root: &str, agent_md: &str, skills: &[Skill], memory: &[MemoryIndexEntry], // nouveau : rappel mémoire (peut être vide) ) -> String ``` - On passe les **`MemoryIndexEntry`** déjà résolus (pas une `&str` pré-rendue ni le port) : la fonction reste **pure et I/O-free**, donc unit-testable sans fake, cohérent avec le traitement des skills (le port est appelé en amont, pas dans la fonction de composition). - `LaunchAgent` gagne **une nouvelle dépendance port** `recall: Arc` (déjà un port domaine, déjà câblé en `NaiveMemoryRecall` dans `state.rs`). Aucun couplage à un adapter concret : Liskov garantit qu'un `VectorMemoryRecall`/`AdaptiveMemoryRecall` (LOT C) se substitue sans toucher à `LaunchAgent`. - La résolution suit le **modèle `resolve_skills`** : une méthode `resolve_memory(&self, root) -> Vec` best-effort appelée juste avant `apply_injection`, dont le résultat est passé à `apply_injection` puis à `compose_convention_file`. Signature interne : ```rust async fn resolve_memory(&self, root: &ProjectPath) -> Result, AppError>; // puis : async fn apply_injection( &self, project: &Project, context_rel_path: &str, content: &MarkdownDoc, skills: &[Skill], memory: &[MemoryIndexEntry], // nouveau spec: &mut SpawnSpec, ) -> Result<(), AppError>; ``` **Décision (quoi injecter)** : l'**index/les hooks**, pas le corps des notes. On rappelle via `MemoryRecall::recall` (donc `read_index` tronqué au budget pour l'adapter naïf), et on rend une section : ```markdown --- # Mémoire projet - [Titre](slug.md) — hook (type) - … ``` Une ligne par `MemoryIndexEntry` (`title`, `slug`, `hook`, `r#type`). C'est le « léger par défaut » : l'agent reçoit les **pointeurs** vers le savoir projet (et peut lire le `.md` cible via son cwd = project root logique) ; l'étage 2 (corps/sémantique) reste hors convention file. Cohérent avec « léger par défaut, étage 2 seulement au-delà du seuil ». - **Budget** : constante `application` `const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;` (tokens approx.), passée dans `MemoryQuery { text, token_budget }`. Valeur **interne et documentée**, pas encore exposée en config (évolutif : pourra devenir un champ de réglage projet plus tard sans changer le contrat). `text` = la persona de l'agent (`content.as_str()`) : sans pertinence sémantique pour le naïf, mais déjà la bonne requête pour le vectoriel (LOT C) — zéro refactor au passage étage 2. **Décision (best-effort / dégradation)** : mémoire vide, absente, ou budget produisant 0 entrée ⇒ **aucune section `# Mémoire projet`** (omise entièrement, comme `# Skills` quand `skills` est vide) ⇒ document identique à l'actuel. `resolve_memory` ne **bloque jamais** un launch : le contrat de `MemoryRecall` est déjà « mémoire absente ⇒ liste vide, jamais d'erreur » ; une éventuelle `AppError::Store` inattendue est traitée comme les skills (best-effort — on dégrade vers liste vide plutôt que d'échouer l'activation). Confirmé : **un projet sans `.ideai/memory/` lance ses agents exactement comme aujourd'hui**. **Décision (universalité)** : l'injection passe **uniquement par le contenu du convention file** (`ContextInjectionPlan::File`), donc valable pour **toute stratégie `conventionFile`** (Claude/Codex/Gemini…), sans flag ni commande propriétaire. Pour `env`/`stdin`/`args` : **pas d'injection mémoire pour l'instant**, strictement **aligné sur les skills** (lesquels ne sont composés que dans la branche `File` de `apply_injection`). Rationale : l'uniformité avec les skills prime ; étendre aux autres stratégies serait une décision séparée (et pour `env`, la mémoire n'a pas de fichier unique à pointer). À tracer comme point ouvert si un profil non-`conventionFile` devait bénéficier du rappel. **Conformité hexagonale/SOLID** : `LaunchAgent` ne parle qu'à des **ports** (`+ Arc`) ; la composition reste une **fonction pure** ; aucun adapter concret référencé ; ISP respectée (une dépendance de plus, pour la seule tranche « rappel »). Substituabilité LOT C gratuite. **Découpage dev/test (ordre)** : 1. `crates/application/src/agent/lifecycle.rs` — **dev** : - ajouter le champ `recall: Arc` au struct `LaunchAgent` + paramètre dans `new` (en fin de liste, après `ids`) ; - `const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;` ; - `async fn resolve_memory(&self, root: &ProjectPath) -> Result, AppError>` (best-effort, dégrade vers `vec![]`) ; - `execute` : appeler `resolve_memory` après `resolve_skills` et passer le résultat à `apply_injection` ; - `apply_injection` : nouvel argument `memory: &[MemoryIndexEntry]`, transmis à `compose_convention_file` (branche `File` uniquement) ; - `compose_convention_file` : nouvel argument `memory`, section `# Mémoire projet` (omise si vide), rendu d'une ligne par entrée. 2. `crates/application/src/agent/lifecycle.rs` (`#[cfg(test)]`) — **test** : étendre les tests purs de `compose_convention_file` (section présente/ordonnée ; absente si vide ; document inchangé sans mémoire). 3. `crates/app-tauri/src/state.rs` — **dev** : passer `Arc::clone(&memory_recall_port)` (existant l.509-510, à hisser avant la construction de `LaunchAgent` l.379) en dernier argument de `LaunchAgent::new`. 4. **Câblage des tests existants** (`LaunchAgent::new` à 6 sites) — **test/dev** : fournir un fake `MemoryRecall` (un `FakeRecall` renvoyant `vec![]` par défaut, configurable pour un cas non-vide). Sites à mettre à jour : - `crates/application/tests/agent_lifecycle.rs` (×5, dont l'helper de construction l.666) ; - `crates/application/tests/orchestrator_service.rs` (l.407) ; - `crates/infrastructure/tests/orchestrator_watcher.rs` (l.298). 5. **Test d'intégration** (`agent_lifecycle.rs`) — **test** : avec un `FakeRecall` non-vide, asserter que le convention file écrit par `FakeFs` contient la section `# Mémoire projet` et les hooks ; avec recall vide, asserter son absence (document identique au baseline persona+skills). **Note de réutilisation** : la résolution préfère le port `MemoryRecall` (rappel borné) plutôt qu'un `read_index` brut, pour hériter directement de la bascule étage 1/étage 2 (LOT C) sans retoucher `LaunchAgent`. ##### 14.5.5 Câblage final & UI mémoire — clôture du sujet mémoire Deux dernières pièces ferment L14 : le **câblage adaptatif backend** (rendre la bascule étage 1↔2 « live » au composition root, défaut `none` conservé) et le **contrat `MemoryGateway` + panneau mémoire** côté front (miroir du modèle skills L12). **Pièce 1 — wiring `AdaptiveMemoryRecall` dans `app-tauri` (backend).** - Décision : `state.rs` câble désormais `Arc` via un **helper de composition** `build_memory_recall(fs, memory_store_port, embedder_profile) -> Arc` plutôt que `NaiveMemoryRecall` en dur. Le profil embedder est **chargé depuis `embedder.json` global** via `FsEmbedderProfileStore` (déjà existant), avec **fallback `EmbedderProfile::none()`** si le fichier est absent ou vide — esprit « rien d'imposé, zéro dépendance ». - **Défaut strictement identique au naïf** : pour `EmbedderProfile::none()`, `embedder_from_profile` retourne `None`. Dans ce cas le helper renvoie **directement `NaiveMemoryRecall`** (pas d'`AdaptiveMemoryRecall`, pas de `VectorMemoryRecall`, donc `StubEmbedder` jamais instancié). L'`AdaptiveMemoryRecall` n'est construit **que** lorsqu'un embedder concret existe (stratégie ≠ `none`) ; sa logique `should_use_vector` garantit de toute façon le repli naïf tant que la mémoire ne dépasse pas le budget, et le repli best-effort si l'embedder échoue (Liskov). Comportement par défaut = byte-for-byte le naïf actuel (couvert par `adaptive_none_strategy_matches_naive_exactly`). - **Instance unique partagée** : le `Arc` produit est injecté **à l'identique** dans `LaunchAgent` (injection §14.5.4) **et** dans `RecallMemory` (commande `recall_memory`) — une seule instance, donc l'UI et l'activation d'agent voient le même rappel. Aucune régression sur les 7 commandes mémoire ni sur les tests existants : seul le type concret derrière le port change, et il reste `NaiveMemoryRecall` par défaut. - **Chargement async dans un `build` sync** : `embedder.json` est lu via `tauri::async_runtime::block_on` dans `AppState::build` (déjà appelé dans un contexte `setup` Tauri), cohérent avec le `block_on` du hook de shutdown. Le composition root reste le seul endroit qui touche au runtime. - Conformité : `LaunchAgent`/`RecallMemory` ne dépendent que du port `MemoryRecall` ; `build_memory_recall` est la seule fonction qui connaît les adapters concrets (DIP). Zéro dépendance lourde tirée au défaut. **Pièce 2 — contrat `MemoryGateway` + panneau mémoire (frontend).** - Décision : un nouveau port UI `MemoryGateway` (dans `ports/index.ts`), **miroir des 7 commandes backend** (`create_memory`/`update_memory`/`list_memories`/`get_memory`/`delete_memory`/`read_memory_index`/`resolve_memory_links`) + `recall_memory` (optionnel UI). Identité = **slug** (kebab-case), pas d'UUID. Payloads camelCase, `type ∈ user|feedback|project|reference`. Adapter `TauriMemoryGateway` (`adapters/memory.ts`) + `MockMemoryGateway` (`adapters/mock/index.ts`), enregistrés dans `Gateways`. - Types domaine TS ajoutés (`domain/index.ts`) : `MemoryType` (`"user"|"feedback"|"project"|"reference"`), `Memory` (`{ name; description; type; content }`, miroir `MemoryDto`), `MemoryIndexEntry` (`{ slug; title; hook; type }`), `MemoryLink` (alias `string` cible, miroir `MemoryLinksDto`). - UI : feature `features/memory/` calquée sur `features/skills/` — `useMemory.ts` (view-model : liste depuis l'index, create/update/delete, resolve links), `MemoryPanel.tsx` (liste l'index, boutons New/Edit/Delete), `MemoryEditor.tsx` (slug+description+type+contenu en create, contenu+description+type en edit), `index.ts`, `memory.test.tsx`. Montée dans `ProjectsView` via un nouvel onglet sidebar `"memory"`. Périmètre **sobre**, aligné `SkillsPanel`, sans design system avancé. Affichage des liens `[[ ]]` via `resolveLinks` dans l'éditeur (lecture seule). - Conformité : aucun couplage UI↔Tauri hors `TauriMemoryGateway` ; les composants ne consomment que `MemoryGateway` (DIP), testables avec `MockMemoryGateway`. Impacts tests : étendre le mock gateway, ajouter `memory.test.tsx`, et compléter le test `ProjectsView` (nouvel onglet + montage du panneau). > **Sujet mémoire (L14) clos** une fois ces deux pièces livrées et vertes : domaine + adapters (A/B/C), use cases + commandes (LOT A/B), injection à l'activation (§14.5.4), bascule adaptative live (§14.5.5 pièce 1) et UI complète (§14.5.5 pièce 2). Évolutions ultérieures (vrais embedders ONNX/HTTP derrière feature, réglage du budget/seuil en config projet) restent des follow-ups indépendants, hors périmètre de clôture. #### Anomalies de conformité relevées sur l'existant (à traiter par les agents dev) 1. **Doublon `DomainError::MalformedFrontmatter` vs `MemoryError::Frontmatter`** : `error.rs` définit `DomainError::MalformedFrontmatter { reason }`, mais le domaine `memory.rs` ne l'utilise jamais (il lève `EmptyField`/`InvalidSlug`) et l'adapter parse via `MemoryError::Frontmatter`. La variante `DomainError::MalformedFrontmatter` est **morte**. **Action** : la supprimer (le parsing de frontmatter est une responsabilité d'adapter → `MemoryError::Frontmatter`), sauf si un futur parseur de frontmatter *dans le domaine* est prévu (il ne l'est pas — le domaine reste format-neutral, cf. doc-module `memory.rs`). Anomalie mineure, sans impact fonctionnel. 2. **`MemoryError` non encore mappé dans `AppError`** : normal (la couche application mémoire n'existe pas encore) ; couvert par LOT A (§14.5.1). 3. **`MemoryIndexRebuilt` / `MemorySaved` / `MemoryDeleted` déjà câblés bout-en-bout** (domaine event + DTO `app-tauri` + relais) mais **non encore émis** faute de use cases : attendu, résolu par LOT A. RAS sur le sens des dépendances : domaine I/O-free, adapter compose `FileSystem`, application ne référence que les ports. **Conforme hexagonal/SOLID.** --- ## 15. Agent = entité à session persistante (L15 — chantiers A & B) — figé 2026-06-09 > **Fondation commune** : A et B reposent sur le même principe — **un `Agent` est une définition stable (`.ideai/agents/.md` + `profile_id` + origine), et son exécution est une session reprenable**, dont la liaison à une cellule est une simple vue (§14.3). A *change le profil* d'un agent existant ; B *reprend ses sessions* au redémarrage. Les deux manipulent le même triplet d'état : `profile_id` (manifeste), `conversation_id` (cellule), `agent_was_running` (cellule). On les cadre ensemble pour figer ce triplet une fois. > > Cette section **complète** §14.3 (registre de sessions visible/arrière-plan) et §6 (use cases agent). Elle ne réécrit aucun port existant ; elle ajoute deux use cases, deux commandes, un champ de domaine et le câblage frontend. ### 15.0 État du terrain (lu dans le code, pas présumé) | Pièce | Existe ? | Référence code | |---|---|---| | `Agent.profile_id` / `ManifestEntry.profile_id` | ✅ champ, **aucun mutateur** | `domain/src/agent.rs` | | `LeafCell.conversation_id` (persistant) | ✅ + ops pures `set_cell_conversation` | `domain/src/layout.rs` | | `LeafCell.agent_was_running` | ✅ + op pure `set_agent_running` | `domain/src/layout.rs` | | `SnapshotRunningAgents` (gèle `agent_was_running` à la fermeture, T5) | ✅ appelé avant le kill PTY | `application/src/layout/snapshot.rs`, `app-tauri` `CloseRequested` | | `SessionPlan::{None,Assign,Resume}` + `resolve_session_plan` | ✅ pleinement câblé dans `LaunchAgent` | `application/src/agent/lifecycle.rs`, `domain/src/ports.rs` | | `InspectConversation` (T7, enrichit le popup) | ✅ best-effort | `application/src/agent/inspect.rs` | | `ResumeConversationPopup` (popup Reprendre / Nouvelle conversation) | ✅ branché au **mount d'une cellule** dont le PTY est mort | `frontend/.../LayoutGrid.tsx`, `features/terminals/ResumeConversationPopup.tsx` | | **Trigger** qui, à la réouverture, relance/propose la reprise des cellules `agent_was_running` | ❌ **inerte** — rien ne consomme `agent_was_running` à l'ouverture | — | | **Mutation de `profile_id`** (use case / commande / UI) | ❌ totalement absent | — | **Conclusion** : B = **brancher un terrain déjà construit** (un trigger d'ouverture + une commande de query). A = **construire une tranche neuve** (mutation de profil), mais minimale grâce au socle existant. --- ### 15.1 Chantier A — Hot-swap de l'AI profile d'un agent existant #### Décision verrouillée rappelée On **garde** le contexte `.md` + la mémoire projet ; on **abandonne** l'historique de conversation (un `conversation_id` Claude n'a aucun sens pour Codex). Le `.md` est *possédé par l'agent*, indépendant du moteur ; seul le moteur d'exécution change. #### Décision tranchée par l'Agent Architecture : **swap à chaud** (kill + relance) > **Recommandation : à chaud.** Si l'agent a une session vivante au moment du changement de profil, IdeA **arrête** cette session (kill PTY) **puis relance** immédiatement sous le nouveau profil, **dans la même cellule** si elle était visible. Justification : > - **Cohérence produit** (principe fondateur §0/§14.3) : « on ne code pas, on gère des IA » — changer le moteur d'un agent vivant doit *se voir* tout de suite, comme un hot-reload. Un refus « ferme d'abord l'agent » casse le flux. > - **Coût technique nul** : tout l'outillage existe déjà — `StopAgentSession`/`session_for_agent` (kill) + `LaunchAgent` (relance) + `rebind_agent_node` (même cellule). Le swap à chaud = *séquencer* deux use cases existants, pas en écrire de nouveaux pour le PTY. > - **Invariant respecté** : « 1 session vivante par agent » (déjà enforce dans `LaunchAgent`) reste vrai car on tue avant de relancer. > - **Sécurité** : la relance n'est **pas** silencieuse-destructive — le `.md`/mémoire survivent (décision verrouillée) ; seul le process CLI et son `conversation_id` sont jetés. > > **Garde-fou** : si le nouveau `profile_id` == l'actuel, c'est un **no-op** (pas de kill/relance) — le use case court-circuite. Si l'agent n'a **pas** de session vivante, on mute juste le manifeste (pas de relance — l'agent repartira au prochain lancement avec son nouveau profil). #### Abandon du `conversation_id` — où et comment Le `conversation_id` vit sur la **cellule** (`LeafCell`), pas sur l'agent. Changer de profil doit **effacer le `conversation_id` de la (ou des) cellule(s) hébergeant cet agent**, sinon une relance ultérieure tenterait un `SessionPlan::Resume` d'une conversation d'un autre moteur (incohérent). C'est une opération **pure** déjà existante : `LayoutTree::set_cell_conversation(node, None)` + `set_agent_running(node, false)`. Le use case applicatif orchestre ce nettoyage sur les layouts persistés du projet (réutilise le pattern de `SnapshotRunningAgents` : `resolve_doc` → walk `agent_leaves()` filtrés sur l'agent ciblé → `set_cell_conversation(None)` → `persist_doc`). #### Modèle de domaine (ajout minimal) Un **seul** ajout : un mutateur validé sur `Agent` et le miroir sur `ManifestEntry`. ```rust // domain/src/agent.rs — ajout impl Agent { /// Change le profil runtime de l'agent. Le contexte `.md`, l'origine template /// et la synchronisation sont **inchangés** (décision verrouillée : on garde le /// `.md`/mémoire, on ne touche qu'au moteur). Pur, infaillible (un ProfileId est /// déjà un VO validé). #[must_use] pub fn with_profile(mut self, profile_id: ProfileId) -> Self { self.profile_id = profile_id; self } } ``` > Pas de nouvel invariant : `profile_id` doit *référencer un profil connu*, mais c'est une invariante **cross-agrégat** vérifiée par l'application (résolution via `ProfileStore`), exactement comme à l'activation aujourd'hui (cf. doc de `Agent`). `ManifestEntry::from_agent` reporte déjà `profile_id` ⇒ rien à ajouter côté persistance. #### Use case applicatif — `ChangeAgentProfile` Nouveau, dans `application/src/agent/lifecycle.rs` (voisin de `UpdateAgentContext`). **Single Responsibility** : muter le profil, nettoyer la conversation, et (à chaud) re-séquencer la session vivante. ```rust pub struct ChangeAgentProfileInput { pub project: Project, pub agent_id: AgentId, pub profile_id: ProfileId, // nouveau profil pub rows: u16, pub cols: u16, // pour une relance à chaud éventuelle } pub struct ChangeAgentProfileOutput { pub agent: Agent, // agent muté (nouveau profil) pub relaunched: Option, // Some si une session vivante a été relancée } ``` Ports consommés (tous déjà existants — **ISP** : on ne prend que le nécessaire) : `AgentContextStore` (charger/sauver le manifeste), `ProjectStore` + `FileSystem` (nettoyer le `conversation_id` sur les layouts persistés, comme `SnapshotRunningAgents`), `TerminalSessions` (`session_for_agent`/`node_for_agent`/kill via `PtyPort`), et — pour la relance à chaud — **la composition réutilise `LaunchAgent`** (le use case `ChangeAgentProfile` *appelle* `LaunchAgent::execute`, il ne ré-implémente pas le spawn). `EventBus` pour publier. Algorithme : ``` 1. Charger le manifeste, résoudre l'entrée de l'agent (NotFound sinon). 2. Si profile_id == entry.profile_id ⇒ no-op : retourner l'agent inchangé, relaunched=None. 3. Valider que profile_id référence un profil connu (ProfileStore.list) ⇒ NotFound sinon. 4. Muter l'entrée (entry.profile_id = nouveau) + revalider + save_manifest. 5. Nettoyer la conversation : sur chaque layout persisté, pour chaque leaf hébergeant cet agent → set_cell_conversation(None) + set_agent_running(false) ; persist si changé. 6. Détecter une session vivante (sessions.session_for_agent(agent_id)) : a. Aucune ⇒ relaunched=None (l'agent repartira au prochain lancement, nouveau profil). b. Une session vivante en cellule N ⇒ kill PTY (sessions.remove + pty.kill), puis LaunchAgent::execute(node_id=N, conversation_id=None /* jetée */). 7. publish(AgentProfileChanged { agent_id, profile_id }). Retourner (agent, relaunched). ``` > **Note de réutilisation** : étapes 5+6b *sont* déjà couvertes par des opérations existantes — on n'introduit **aucun** nouveau port. Le use case est un **orchestrateur** (cf. `LaunchAgent` lui-même qui orchestre `prepare_invocation`+`pty.spawn`). #### Domaine event (ajout) `DomainEvent::AgentProfileChanged { agent_id: AgentId, profile_id: ProfileId }` (calqué sur `AgentLaunched`). Relayé par `TauriEventRelay` → event front `agentProfileChanged` (l'onglet Agents et la cellule rafraîchissent ; cf. mémoire « Refresh live Agents/Skills »). #### Commande Tauri + DTO ``` | Commande Tauri | Request DTO (camelCase) | Réponse | |-----------------------|--------------------------------------------------|----------------------| | change_agent_profile | { projectId, agentId, profileId, rows, cols } | ChangeAgentProfileDto| ``` `ChangeAgentProfileDto { agent: AgentDto, relaunchedSession: Option }`. Parser `profileId` via `parse_profile_id` ; `resolve_project` comme les autres. Enregistrer dans `generate_handler!` (`app-tauri/src/lib.rs`), câbler `ChangeAgentProfile` dans `state.rs` (réutilise l'instance `LaunchAgent` déjà construite + `terminal_sessions` + `pty` + stores). #### Frontend - **Port UI** `AgentGateway.changeAgentProfile(projectId, agentId, profileId, rows, cols): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }>` (dans `ports/index.ts`). Adapter `TauriAgentGateway` + `MockAgentGateway`. - **UI** : dans l'onglet Agents (`features/agents/`), sur la carte d'un agent, un sélecteur de profil (liste des profils connus via le gateway profils) déclenchant `changeAgentProfile`. À la relance à chaud, le hook `useAgents` écoute `agentProfileChanged` et la cellule rebind via le flux existant (`relaunchedSession`). Si relance à chaud, persister sur la cellule : `setCellConversation(node, null)` (le backend a déjà nettoyé côté layouts persistés ; le front reflète l'état pour la session courante). - Confirmation UX : un dialog « Changer le moteur abandonne l'historique de conversation (le contexte et la mémoire sont conservés). Continuer ? » avant l'appel (la décision est irréversible côté `conversation_id`). --- ### 15.2 Chantier B — Reprise des sessions au redémarrage d'IdeA / réouverture projet #### Le vrai manque (précis) À la réouverture, `OpenProject` recharge le manifeste + les layouts (donc `conversation_id` et `agent_was_running` reviennent sur les cellules). Le **popup de reprise existe déjà** mais il n'est déclenché que **quand une cellule est montée et que son PTY est mort** (flux `terminalOpener` dans `LayoutGrid.tsx`). Il **manque le déclencheur d'ouverture** : aujourd'hui, à la réouverture, les cellules ne ré-ouvrent pas leur PTY automatiquement, donc rien ne relance les agents ni ne propose la reprise tant que l'utilisateur ne clique pas. B = **fournir l'inventaire des cellules reprenables à l'ouverture** et **piloter leur reprise**. #### Décisions tranchées par l'Agent Architecture > **1. Popup de reprise, pas relance automatique aveugle — mais une seule décision groupée.** > **Recommandation : popup de reprise (opt-in), au niveau projet, à l'ouverture.** À l'`OpenProject`, IdeA calcule l'inventaire des cellules d'agent reprenables (cf. use case ci-dessous) et, **s'il y en a**, affiche **un** panneau de reprise listant les agents qui « tournaient » (`agent_was_running == true`) avec, pour chacun, le choix Reprendre / Nouvelle conversation / Ignorer. Justification : > - **Cohérence avec l'existant** : le `ResumeConversationPopup` par cellule est déjà la primitive ; B la *pilote en lot* à l'ouverture au lieu d'attendre un clic. > - **Pas de surprise / pas de coût caché** : relancer automatiquement N agents CLI (coût tokens, processus, fenêtres qui s'animent) sans consentement viole l'esprit « rien d'imposé » (cf. mémoire mémoire/embedder `none` par défaut). L'utilisateur peut avoir fermé volontairement. > - **Granularité** : un agent par ligne ⇒ on reprend ceux qu'on veut. > - **Réglage futur** : un toggle projet « reprendre automatiquement à l'ouverture » pourra court-circuiter le popup plus tard (hors périmètre L15, tracé comme évolution) sans changer les contrats. > > **2. Profil sans `resumeFlag` ⇒ relancer à neuf (pas « ne pas relancer »).** > **Recommandation : repartir à neuf.** Pour un agent dont le profil n'a **pas** de `SessionStrategy` (ou pas de `resume_flag` exploitable), choisir « Reprendre » dans le panneau lance l'agent **sans** `conversation_id` (fresh) — exactement la sémantique `SessionPlan::None`/`Assign` déjà gérée par `resolve_session_plan`. Justification : > - **Universalité (principe fondateur)** : un agent doit *toujours* pouvoir redémarrer quel que soit son moteur ; « ne pas relancer » créerait des agents « morts au démarrage » selon le profil, incohérent. > - **Zéro régression** : `resolve_session_plan` retourne déjà `None` proprement pour un profil sans bloc `session` — on ne fait que *l'autoriser depuis le panneau de reprise*. Le `.md`/mémoire (re-injectés à chaque `LaunchAgent`) garantissent que l'agent retrouve son rôle même sans historique CLI. > - **UI honnête** : pour un tel agent, la ligne du panneau affiche « historique non disponible pour ce moteur — relance à neuf » au lieu de « Reprendre la conversation ». #### Use case applicatif — `ListResumableAgents` Nouveau, lecture seule, dans `application/src/agent/` (voisin de `inspect.rs`). Calcule l'inventaire à l'ouverture **sans** I/O lourde ni spawn. ```rust pub struct ListResumableAgentsInput { pub project: Project } pub struct ListResumableAgentsOutput { pub resumable: Vec } pub struct ResumableAgent { pub agent_id: AgentId, pub name: String, pub node_id: NodeId, // cellule hôte (où relancer) pub conversation_id: Option, // None ⇒ relance à neuf pub was_running: bool, // agent_was_running gelé à la fermeture pub resume_supported: bool, // profil a un SessionStrategy exploitable } ``` Ports : `ProjectStore` + `FileSystem` (charger les layouts persistés — `resolve_doc`), `AgentContextStore` (manifeste → nom + `profile_id`), `ProfileStore` (déterminer `resume_supported`). **Aucun PTY, aucun spawn** : pur inventaire. Algorithme = walk `agent_leaves()` de chaque layout ; pour chaque leaf portant un agent, lire `conversation_id`/`agent_was_running` de la cellule (via une lecture du `LeafCell` — ajouter un petit accessor pur `LayoutTree::leaf(node_id) -> Option<&LeafCell>` au domaine si absent), résoudre nom + `resume_supported`. > **Décision (filtre)** : on ne liste que les leaves dont `agent_was_running == true` **ou** qui portent un `conversation_id` (reprenables au sens strict). Une cellule d'agent jamais lancée (`was_running=false`, pas d'id) n'apparaît pas — elle se lancera normalement au clic, sans popup. #### Reprise pilotée — réutilisation pure du frontend existant La **reprise effective** d'un agent choisi dans le panneau **ne crée aucun nouveau use case backend** : elle appelle `launch_agent` avec le `node_id` et le `conversation_id` de la ligne (Reprendre) ou `conversation_id=None` après `set_cell_conversation(node,None)` (Nouvelle conversation / profil sans resume). C'est **exactement** ce que fait déjà `doLaunch`/`onResume`/`onNewConversation` dans `LayoutGrid.tsx`. B *réutilise* ces handlers, déclenchés depuis un panneau d'ouverture au lieu du mount de cellule. #### Commande Tauri + DTO ``` | Commande Tauri | Request DTO (camelCase) | Réponse | |-------------------------|-------------------------|----------------------| | list_resumable_agents | { projectId } | ResumableAgentListDto| ``` `ResumableAgentListDto { resumable: Vec }`, `ResumableAgentDto { agentId, name, nodeId, conversationId?, wasRunning, resumeSupported }`. Lecture seule, pas d'event. Enregistrer dans `generate_handler!`, câbler `ListResumableAgents` dans `state.rs` (réutilise stores + `ProfileStore` déjà injectés). #### Frontend - **Port UI** `AgentGateway.listResumableAgents(projectId): Promise` (+ adapter Tauri + mock). - **UI** : à l'`open` d'un projet (hook projet, après chargement du layout), appeler `listResumableAgents`. Si non vide ⇒ monter un **`ResumeProjectPanel`** (`features/agents/` ou `features/terminals/`) listant les agents reprenables ; chaque ligne réutilise la logique du `ResumeConversationPopup` (statut « en cours »/« clôt » dérivé de `wasRunning`, et pour `resumeSupported==false` le libellé « relance à neuf »). Boutons par ligne : Reprendre / Nouvelle conversation / Ignorer ; plus un « Tout reprendre » / « Tout ignorer ». Chaque choix invoque le flux `launch_agent` existant sur le `nodeId`. - **Pas de double popup** : une fois le panneau d'ouverture traité pour un agent, le flux par-cellule (`terminalOpener`) reste le fallback naturel pour une ouverture manuelle ultérieure (inchangé). --- ### 15.3 Conformité hexagonale & SOLID (A + B) - **Règle de dépendance** : aucun nouvel accès I/O dans le domaine. Les ajouts domaine sont **purs** (`Agent::with_profile`, accessor `LayoutTree::leaf`, event `AgentProfileChanged`). Toute I/O (kill/spawn/persist) reste dans l'application (orchestration) et l'infrastructure (adapters existants). - **S** : `ChangeAgentProfile` = une intention ; `ListResumableAgents` = une query lecture seule ; aucune fonction fourre-tout. - **O** : aucun nouveau port, aucun nouvel adapter — A et B *composent* l'existant (`LaunchAgent`, `SnapshotRunningAgents`-pattern, `TerminalSessions`, `resolve_session_plan`). Ajouter un moteur reste « une donnée » (profil). - **L** : la reprise « profil sans resumeFlag ⇒ fresh » respecte le contrat déjà documenté de `resolve_session_plan` (substituabilité des profils). - **I** : `ChangeAgentProfile`/`ListResumableAgents` ne reçoivent que les ports consommés. - **D** : les deux use cases parlent aux ports/instances injectés par le composition root (`state.rs`), jamais à un adapter concret. ### 15.4 Découpage en LOTS testables (cycle dev↔QA) — ordonné > Fondation d'abord (le triplet d'état partagé), puis A et B s'entrelacent. Chaque lot = binôme dev+test, vert avant le suivant. | Lot | Périmètre | Crates/dossiers | Contrats (ports/DTO) | Tests attendus | |---|---|---|---|---| | **A0 (fondation)** | Domaine : `Agent::with_profile` (pur), accessor pur `LayoutTree::leaf(node)`, event `DomainEvent::AgentProfileChanged`. | `domain/src/agent.rs`, `domain/src/layout.rs`, `domain/src/events.rs` | mutateur pur, accessor `Option<&LeafCell>`, variante d'event | unit purs : `with_profile` ne touche que `profile_id` ; `leaf` retrouve/loupe un node ; event sérialisé. | | **A1** | Use case `ChangeAgentProfile` (no-op si même profil ; mute manifeste ; nettoie `conversation_id`/`agent_was_running` sur layouts persistés ; relance à chaud via `LaunchAgent` si session vivante ; publie l'event). | `application/src/agent/lifecycle.rs` | `ChangeAgentProfileInput/Output`, ports déjà existants | unit avec mocks/fakes : no-op profil identique ; profil inconnu ⇒ NotFound ; manifeste muté ; conversation nettoyée ; **agent vivant ⇒ kill+relance même cellule** ; agent mort ⇒ pas de relance ; event émis. | | **A2** | Commande `change_agent_profile` + DTO + relais event ; gateway UI + sélecteur de profil + dialog de confirmation. | `app-tauri/src/{commands,dto,events,lib,state}.rs`, `frontend/src/ports`, `frontend/src/adapters`, `frontend/src/features/agents` | `ChangeAgentProfileRequestDto`/`ChangeAgentProfileDto`, `AgentGateway.changeAgentProfile` | app-tauri : mapping DTO↔use case (wiring in-memory). Vitest : gateway mock, sélecteur déclenche l'appel, dialog confirme avant ; refresh sur `agentProfileChanged`. | | **B0 (fondation)** | (Réutilise A0 `LayoutTree::leaf`.) Si A0 non encore livré, B0 livre l'accessor. Sinon B0 est vide → fusion dans B1. | `domain/src/layout.rs` | — | couvert par A0. | | **B1** | Use case `ListResumableAgents` (lecture seule : walk layouts, résout nom + `resume_supported`, filtre `was_running\|\|conversation_id`). | `application/src/agent/` (ex. `resume.rs`) | `ListResumableAgentsInput/Output`, `ResumableAgent` | unit avec fakes : inventaire correct ; filtre (jamais-lancé exclu) ; `resume_supported` selon profil ; mémoire/agent absent ⇒ liste vide, jamais d'erreur. | | **B2** | Commande `list_resumable_agents` + DTO ; déclenchement à l'open projet ; `ResumeProjectPanel` réutilisant le flux `launch_agent` (Reprendre / Nouvelle / Ignorer / Tout). | `app-tauri/src/{commands,dto,lib,state}.rs`, `frontend/src/ports`, `frontend/src/adapters`, `frontend/src/features/{agents,terminals,layout}` | `ResumableAgentListDto`/`ResumableAgentDto`, `AgentGateway.listResumableAgents` | app-tauri : mapping DTO↔use case. Vitest : panneau monté si liste non vide / absent sinon ; Reprendre → `launch_agent(nodeId, convId)` ; Nouvelle → `setCellConversation(null)` puis launch ; `resumeSupported==false` ⇒ libellé « relance à neuf ». | **Ordre conseillé** : A0 → (A1 ∥ B1) → A2 → B2. A0 débloque les deux ; A1 et B1 sont indépendants ; les lots UI ferment chaque chantier. --- ## 16. Orchestration v3 — invocation native d'agents (CLI `idea` universelle + surface MCP optionnelle) — révisé 2026-06-09 > **⚠️ REMPLACÉE COMME VOIE PRINCIPALE par §17 (pivot 2026-06-09).** Le chef d'orchestre a tranché : on abandonne, **comme voie principale**, l'orchestration via TUI brut + binaire `idea`/skill auto-rapporté (fiabilité insuffisante : dépend du bon vouloir du modèle d'appeler `idea reply`/`idea next`). La nouvelle voie principale est **§17 — exécution structurée des agents IA via le port `AgentSession`** (mode programmatique par modèle, capture déterministe de la réponse, rendez-vous synchrone intrinsèque à `send()`). En conséquence : > - **Abandonné/déprécié (voie principale)** : le binaire `idea` (`idea ask/reply/next`), le skill built-in « Orchestration IdeA » comme *mécanisme de délégation auto-rapporté*, l'**inbox** `.ideai/inbox/`, l'**outbox** `.ideai/outbox/`, le port `AgentReplyChannel`/`OutboxReplyChannel`, et le rendez-vous outbox des lots **C0/C1/C-univ-***. Le rendez-vous synchrone est désormais **intrinsèque** à `AgentSession::send() -> Reply` (§17.1) : plus besoin d'outbox ni de corrélation fichier. > - **Conservé (repli/compat)** : le protocole fichier `.ideai/requests/` + `FsOrchestratorWatcher` (§14.3) reste un **adapter entrant de repli** (un agent ou un script qui écrit une requête à la main). `OrchestratorService` route désormais la délégation inter-agents via le port `AgentSession` (§17.4), pas via l'outbox. > - **Non démarré ⇒ supprimé du périmètre** : les lots C0/C1/C-univ-1/C-univ-2 et le bloc MCP (C-mcp-*) **ne sont plus à livrer** tels quels. La « version B » (UI chat / sortie structurée) évoquée en §16.9 comme épic futur **devient la voie principale §17**. > Le reste de §16 est laissé **pour mémoire/historique** (raisonnement, état du terrain) ; ne pas l'implémenter sans relire §17. > **Fondation** : v3 ne réécrit pas §14.3. Elle comble sa lacune fonctionnelle — la **messagerie inter-agents synchrone** (`ask_agent`) — et ajoute des **portes d'entrée** au-dessus du **même** `OrchestratorService`. Une seule logique applicative, plusieurs **adapters entrants** qui se ramènent tous au même `OrchestratorCommand` enrichi. > > **Décision produit verrouillée (révision 2026-06-09, non rediscutée — actée ici)** : la **voie principale et la garantie cross-model** est un **plancher universel** = un **skill built-in « Orchestration IdeA » auto-assigné à TOUT agent** + un **petit binaire CLI `idea`** posé par IdeA sur le `PATH` du sandbox de l'agent. L'agent délègue par une simple commande shell (`idea ask ""` bloque et imprime la réponse inline ; `idea launch`, `idea reply`, `idea list-agents`) — **aucun JSON manipulé par l'agent, aucun parsing de TUI**. Sous le capot, `idea` est un **client mince** qui écrit dans `.ideai/requests/` et attend `.ideai/outbox/` : il s'appuie EXACTEMENT sur `OrchestratorService::dispatch` + le port `AgentReplyChannel` + l'outbox (C0/C1 ci-dessous). Tout agent sachant lancer une commande shell sait déléguer ⇒ **zéro support modèle spécial requis** ; valider avec Claude + Codex garantit le cross-model. > > **MCP est rétrogradé en confort OPTIONNEL** par-dessus le **même** backend : un adapter entrant supplémentaire (outils typés `idea_*`) pour les CLIs qui le supportent, postérieur et non bloquant. Les spikes MCP ne conditionnent plus la garantie cross-model. > > **Hors périmètre C (épic futur séparé, noté pour cohérence)** : une « version B » (UI chat / agent headless à sortie structurée) reste compatible avec ce backend mais n'est pas requise pour le cross-model. Voir §16.9. > > Sémantique `ask` (les **deux** voies, identique) : lance/réveille la cible, transmet la tâche, **attend et renvoie le contenu** de sa réponse inline, corrélé via l'outbox. ### 16.0 État du terrain (lu dans le code, pas présumé) | Pièce | Existe ? | Référence code | |---|---|---| | `OrchestratorRequest`/`OrchestratorCommand` (modèle pur, validé) | ✅ — actions `agent.run/stop/update_context`, `skill.create` | `domain/src/orchestrator.rs` | | `OrchestratorService::dispatch` (un seul chemin applicatif, réutilise les use cases UI) | ✅ | `application/src/orchestrator/service.rs` | | `FsOrchestratorWatcher` (adapter entrant fichier + `*.response.json` ACK) | ✅ | `infrastructure/src/orchestrator/mod.rs` | | Profil déclaratif `AgentProfile` (+ `SessionStrategy` optionnel) | ✅ | `domain/src/profile.rs` | | `SessionInspector` (lecture best-effort d'un transcript CLI, optionnel) | ✅ | `domain/src/ports.rs`, `application/src/agent/inspect.rs` | | Invariant « 1 session vivante par agent » + `rebind_agent_node`/`session_for_agent` | ✅ | `application/src/terminal/registry.rs` | | Prose « # Orchestration IdeA » injectée dans le convention file | ✅ — mais **prose libre**, à transformer en **skill built-in** documentant `idea` | `application/src/agent/lifecycle.rs` (`compose_convention_file`) | | `Skill` (entité, scope `Global`/`Project`, injection convention file) | ✅ — pas de notion `Builtin` ni d'auto-assignation universelle | `domain/src/skill.rs`, `application/src/skill/*`, `compose_convention_file` (param `skills`) | | `SpawnSpec.env: Vec<(String,String)>` (env injectable au lancement CLI) | ✅ — vecteur d'overrides d'env passé au `ProcessSpawner` | `domain/src/ports.rs` (`SpawnSpec`), `infrastructure/src/runtime/mod.rs` | | **Binaire CLI `idea`** (client mince requests→outbox sur le PATH du run dir) | ❌ totalement absent | — | | **Skill built-in « Orchestration IdeA » auto-assigné à tout agent** | ❌ — aujourd'hui prose libre, non modélisée comme skill | — | | **`task` transmis à un agent déjà vivant** | ❌ — ignoré (replié en `context`, utilisé seulement à la création) | `service.rs` `spawn_agent` | | **Réponse de contenu** (réveil du demandeur, corrélation requête↔réponse) | ❌ — la réponse n'est qu'un ACK de cycle de vie (`detail`) | `service.rs` / watcher | | **Capacité MCP sur le profil** | ❌ totalement absent | — | | **Serveur MCP / config MCP par CLI** | ❌ totalement absent | — | **Conclusion** : v3 = **(1)** ajouter une variante de commande qui *transmet une tâche et attend une réponse de contenu* (le vrai trou, port `AgentReplyChannel` + outbox) ; **(2)** le **plancher universel** — un binaire `idea` (client mince requests→outbox, posé sur le PATH du run dir) + le **skill built-in « Orchestration IdeA »** auto-assigné qui le documente — qui devient **la voie principale et la garantie cross-model** ; **(3) optionnel/postérieur** : un adapter entrant MCP qui se branche sur le `OrchestratorService` *exactement* comme le watcher et la CLI, + capacité déclarative sur le profil + injection de la config MCP par CLI. Aucun use case agent/terminal n'est réécrit ; `idea` et MCP partagent le **même** `OrchestratorService::dispatch` et le **même** outbox. ### 16.1 Décisions tranchées (avec justification) 0. **Plancher universel = binaire `idea` + skill built-in, voie PRINCIPALE et garantie cross-model** — la conscience d'orchestration d'un agent ne repose plus sur une prose libre « rappelle-toi d'écrire un JSON » mais sur **deux artefacts concrets** : (a) un **petit binaire CLI `idea`** posé par IdeA sur le `PATH` de l'agent (via son run dir isolé `.ideai/run//bin`, §14.1), et (b) un **skill built-in « Orchestration IdeA »** auto-assigné à **tout** agent, dont le `.md` documente les commandes `idea ask/launch/reply/list-agents`. *Justification* : universalité (principe fondateur) — toute CLI sait lancer une commande shell, donc tout modèle (Claude/Codex/Gemini/custom) sait déléguer **sans support spécial** ; le mécanisme testé (« l'agent exécute `idea` ») est **identique pour tous les modèles**, donc valider sur 2 CLIs garantit le cross-model. `idea` est un **client mince sans logique métier** : il (dé)sérialise vers `.ideai/requests/` et attend `.ideai/outbox/` — la logique vit dans `OrchestratorService` (DRY). 1. **MCP rétrogradé en adapter entrant OPTIONNEL et postérieur** — le serveur MCP reste un **driving adapter d'infrastructure** (`infrastructure/src/orchestrator/mcp/`) qui appelle le **même** `OrchestratorService::dispatch` et lit/écrit le **même** outbox, mais il n'est **plus** la voie principale : c'est un **confort** (outils typés natifs) pour les CLIs qui le déclarent, ajouté **après** le plancher universel. Trois portes d'entrée substituables : **CLI `idea`** (universelle, principale), `FsOrchestratorWatcher` (fichier brut, repli historique §14.3), serveur MCP (optionnel). *Justification* : DRY + hexagonal — cible, identité, mémoire, observabilité UI passent par le seul chemin applicatif ; les spikes MCP (transport par CLI) ne conditionnent plus la garantie cross-model. 2. **`OrchestratorCommand` gagne une variante `AskAgent` (transmission de tâche + attente de réponse)** — distincte de `SpawnAgent` (fire-and-forget). C'est la brique manquante de §14.3. `SpawnAgent` reste l'équivalent de `idea_launch_agent` (fire-and-forget) ; `AskAgent` porte `target`, `task`, et une **corrélation** (`request_id`). *Justification* : `parse, don't validate` — le modèle pur rend explicite « j'attends une réponse » vs « je lance et j'oublie », au lieu de surcharger `task` silencieusement comme aujourd'hui. 3. **Le retour synchrone passe par un nouveau port `AgentReplyChannel` (corrélation requête↔réponse), PAS par `SessionInspector`** — `SessionInspector` lit best-effort un transcript *propre à chaque CLI* (fragile, non universel, déjà « best-effort par construction »). Pour un retour **fiable et model-agnostic**, on ne *devine* pas la fin de tour : on demande à la cible d'**écrire sa réponse dans un outbox** `.ideai/outbox/.json` (instruction injectée + outil MCP `idea_reply`), et l'appelant **attend cette corrélation** (await/poll + timeout). *Justification* : universalité (principe fondateur — marche pour Claude/Codex/Gemini/custom sans parser leur format) et frontière nette (le domaine ne connaît qu'un id de corrélation + un contenu, jamais un transcript). 4. **Capacité MCP = champ optionnel `mcp` sur `AgentProfile`** (descripteur déclaratif), `None` par défaut ⇒ comportement actuel (repli fichier). Ajouter une CLI MCP = **donnée, pas code** (Open/Closed), comme `session`/`contextInjection`. *Justification* : cohérence avec §9 ; zéro régression pour les profils existants (sérialisation `skip_serializing_if = None`). 5. **Repli homogène** — un agent dont le profil n'a **pas** de bloc `mcp` continue d'utiliser le protocole fichier `.ideai/requests` (prose injectée inchangée). Un agent MCP voit les outils typés. Les **deux** routes produisent le même `OrchestratorCommand` et, pour `ask`, écrivent/lisent le **même outbox**. *Justification* : « rien d'imposé, tout fonctionnel » — un runtime sans MCP n'est jamais bloqué ; un runtime MCP gagne la conscience native + arguments validés. 6. **Timeout borné + sémantique d'erreur explicite** — `ask_agent` a un `timeout` (défaut configurable). À l'expiration : la cible **reste vivante** (on ne tue rien), l'outil renvoie une **erreur typée** `Timeout` (l'appelant décide). *Justification* : pas de blocage indéfini d'une conversation appelante ; cohérent avec l'invariant « stop est une action explicite » (§14.3). 7. **Interaction avec « 1 session vivante par agent »** — `ask_agent` sur une cible **déjà vivante** ne relance pas : il **transmet la tâche à la session existante** (write PTY de la consigne + corrélation) et attend l'outbox. Sur une cible **éteinte** : `LaunchAgent` d'abord (même chemin que `SpawnAgent`), puis transmission. *Justification* : respecte l'invariant déjà enforce ; réutilise `session_for_agent`/`rebind_agent_node`. 8. **`idea reply` et l'outbox sont model-agnostic** — la cible rend sa réponse soit par la **commande `idea reply ""`** (voie universelle : `idea` écrit l'outbox corrélé pour elle), soit par l'**outil MCP** `idea_reply(requestId, content)` (si profil MCP). Un seul format d'outbox `.ideai/outbox/.json` lu par l'adapter. *Justification* : symétrie parfaite des routes ⇒ l'appelant attend la même chose quelle que soit la CLI cible ; l'agent cible ne manipule jamais de JSON. 9. **Le skill built-in « Orchestration IdeA » remplace la prose libre** — on introduit un **scope `Builtin`** sur l'entité `Skill` (à côté de `Global`/`Project`, §14.2). Un skill built-in est **fourni par IdeA** (contenu `.md` embarqué, non éditable par l'utilisateur), **auto-assigné à tout agent** à l'activation (pré-pendu à la liste de skills déjà injectée par `compose_convention_file`), et documente la CLI `idea`. *Justification* : cohérence stricte avec le système de skills §14.2 (« abstraction universelle de workflows réutilisables ») et la philosophie « skills intégrés / principe universel IdeA » — la conscience d'orchestration devient un workflow **versionné et testable**, pas un littéral en dur dans `compose_convention_file`. Le bloc prose « # Orchestration IdeA » actuel est **retiré** de `compose_convention_file` et **migré** dans le `.md` du skill built-in (réutilise le canal d'injection existant, zéro mécanisme neuf). 10. **Délivrer une tâche à un agent DÉJÀ VIVANT = inbox relue par la cible, PAS write stdin** — décision tranchée du point dur §16.7. On **n'injecte pas** la consigne par `write` PTY dans le TUI en cours de rendu : on dépose la tâche dans une **inbox** `.ideai/inbox//.json` que la cible **relit elle-même** (le skill built-in lui apprend : « à chaque tour, traite ta prochaine tâche via `idea next` / lis ton inbox »). *Justification* : (a) écrire dans le PTY d'un TUI en train de rendre **corrompt l'affichage et entrelace les frappes** (bug connu « accents / ordre d'écriture » — writes non sérialisés par handle, cf. mémoire `terminal-input-accents-ordering`) ; un TUI plein écran (Claude Code, etc.) **n'a pas de prompt shell** où coller du texte. (b) L'inbox est **durable et corrélée** (survit au redémarrage, sérialise naturellement N `ask` concurrents par cible en une **file FIFO par agent**, §16.7-3). (c) Symétrie avec l'outbox : requête et réponse transitent par le **même médium fichier**, model-agnostic, déjà éprouvé (§14.3 notify+poll). Le `write` PTY reste réservé au **premier lancement** d'une cible éteinte (consigne initiale passée comme argument/contexte au spawn, pas dans un TUI vivant). ### 16.2 Modèle de domaine (ajouts purs, I/O-free) Tout vit dans `domain/src/orchestrator.rs` (modèle) + `domain/src/profile.rs` (capacité) + `domain/src/events.rs` (event). **Aucun accès I/O** : la corrélation est un VO ; l'attente/poll/écriture outbox sont infra. ```rust // domain/src/orchestrator.rs — VO de corrélation (newtype validé, non vide) pub struct CorrelationId(String); // ex. un Uuid stringifié, généré par l'adapter entrant // Nouvelle variante de commande : transmettre une tâche ET attendre une réponse de contenu. pub enum OrchestratorCommand { SpawnAgent { /* … inchangé … */ }, // = idea_launch_agent (fire-and-forget) StopAgent { name: String }, UpdateAgentContext { name: String, context: String }, CreateSkill { /* … inchangé … */ }, /// NOUVEAU : `idea_ask_agent` — lance/réveille `target`, lui transmet `task`, /// et l'appelant attend la réponse corrélée par `correlation`. AskAgent { target: String, task: String, correlation: CorrelationId, visibility: OrchestratorVisibility, // background par défaut }, } // Réponse de CONTENU (distincte de l'ACK de cycle de vie OrchestratorResponse infra). // Pure : ce que la cible a produit, corrélé. L'infra la (dé)sérialise depuis l'outbox. pub struct AgentReply { pub correlation: CorrelationId, pub from_agent: String, // nom de la cible qui répond pub content: String, // sortie inline rendue à l'appelant } ``` `OrchestratorRequest::validate` apprend l'action **`agent.ask`** (et l'alias outil MCP `idea_ask_agent`) ⇒ `AskAgent` (champs requis : `targetAgent`, `task` ; `correlation` injectée par l'adapter si absente du fichier). Les invariants existants (champs requis, scopes, visibility) sont **inchangés** ; on ajoute une branche + ses tests, façon `parse, don't validate`. > **Note (révision)** : la capacité MCP ci-dessous appartient désormais au **bloc MCP optionnel** (lot `C-mcp-0`), **pas** au cœur C0. Elle reste cadrée ici par cohérence, mais n'est plus un prérequis de la voie universelle. ```rust // domain/src/profile.rs — capacité MCP déclarative (Open/Closed, comme SessionStrategy) pub struct McpCapability { /// Comment IdeA déclare son serveur MCP à CETTE CLI. Chaque CLI a sa propre /// conf MCP : on décrit le « où/comment écrire » de façon déclarative. pub config_strategy: McpConfigStrategy, /// Nom logique sous lequel les outils idea_* sont exposés (ex. "idea"). pub server_name: String, } pub enum McpConfigStrategy { /// Écrire un fichier de conf MCP au chemin attendu par la CLI (relatif au cwd /// agent), au format JSON propre à la CLI (ex. .mcp.json pour Claude Code). ConfigFile { target: String }, /// Passer le serveur via un flag de lancement (ex. --mcp-config {path}). Flag { flag: String }, /// Variable d'environnement pointant la conf. Env { var: String }, } pub struct AgentProfile { // … champs existants inchangés … /// Capacité MCP optionnelle. `None` (défaut) ⇒ repli protocole fichier §14.3. pub mcp: Option, } ``` ```rust // domain/src/skill.rs — nouveau scope pour le skill built-in d'orchestration (Open/Closed). pub enum SkillScope { Global, // store global IDE (existant) Project, // .ideai/skills/ (existant) Builtin, // NOUVEAU : fourni par IdeA, non éditable, auto-assigné à tout agent } // Le skill built-in « Orchestration IdeA » (contenu .md embarqué) est exposé par // le SkillStore (scope Builtin) et pré-pendu aux skills d'un agent à l'activation. ``` ```rust // domain/src/events.rs — event de contenu (calqué sur AgentLaunched) DomainEvent::AgentReplied { from_agent: AgentId, // la cible qui a répondu correlation: String, // pour relier la réponse à la demande dans l'UI } ``` > `AgentReplied` est **observabilité** (l'UI montre « Architect a répondu à Main »). Le **retour de valeur** à l'appelant MCP ne passe **pas** par l'EventBus (qui est fire-and-forget) mais par l'attente de l'outbox côté adapter (§16.4) — l'event ne fait que *notifier* l'UI. ### 16.3 Port(s) — frontière domaine Un **seul** nouveau port, fin (ISP), pour le rendez-vous requête↔réponse. Tout le reste réutilise l'existant. ```rust // domain/src/ports.rs #[async_trait] pub trait AgentReplyChannel: Send + Sync { /// Publie la réponse d'une cible (appelé quand l'outbox `.json` /// apparaît, ou par la commande `idea_reply`). Idempotent par corrélation. async fn publish_reply(&self, reply: AgentReply) -> Result<(), ReplyError>; /// Attend (await, borné par `timeout`) la réponse corrélée. C'est ce que /// `idea_ask_agent` bloque dessus. Universel : ne connaît qu'un id + un contenu. async fn await_reply( &self, correlation: &CorrelationId, timeout: Duration, ) -> Result; // ReplyError::Timeout à l'expiration } ``` - **Consommé par** : `OrchestratorService` (côté `AskAgent` : `await_reply`) et l'adapter qui détecte l'outbox / l'outil `idea_reply` (`publish_reply`). - **Implémenté par** : `OutboxReplyChannel` (`infrastructure/src/orchestrator/`) — un registre de `oneshot`/`Notify` en mémoire **adossé** au répertoire `.ideai/outbox/` : l'écriture d'un `.json` (par une cible repli-fichier) **ou** un appel MCP `idea_reply` résolvent la même attente. Pour les cibles distantes/redémarrage, l'outbox fichier est la source durable ; l'in-memory `Notify` est l'optimisation latence (même philosophie que notify+poll du watcher §14.3). > **Pourquoi pas `SessionInspector`** : il est **best-effort** et **par-CLI** ; en faire la brique d'un retour *fiable* violerait l'universalité. `AgentReplyChannel` est *explicite* : la cible *déclare* sa réponse, on n'infère rien. ### 16.3bis Plancher universel — binaire `idea` (adapter entrant principal) + skill built-in **Nouvel artefact : un binaire `idea`.** C'est un **driving adapter entrant**, pair universel du `FsOrchestratorWatcher` et du serveur MCP, mais qui vit dans un **processus séparé** (lancé par l'agent depuis son shell) et qui parle au backend IdeA **par les mêmes fichiers** que §14.3. - **Crate** : nouveau binaire `crates/idea-cli/` (binaire autonome, dépendances minimales). Il **ne** lie **pas** `application`/`infrastructure` ; c'est un **client mince** qui ne connaît que le **protocole fichier** `.ideai/{requests,inbox,outbox}/` (le contrat partagé). Il découvre le project root via une variable d'env injectée (`IDEA_PROJECT_ROOT`) et son identité d'agent appelant via `IDEA_AGENT` (toutes deux posées dans `SpawnSpec.env` au lancement, comme le run dir). *Justification hexagonale* : `idea` est un **adapter entrant out-of-process** ; la frontière entre lui et le cœur est le **protocole fichier**, pas un appel de fonction. Le cœur (`OrchestratorService` + `FsOrchestratorWatcher`) ne sait pas si le fichier de requête vient de `idea`, d'un agent qui l'a écrit à la main, ou d'un test. | Commande `idea` | Écrit | Attend | Effet rendu à l'agent | |---|---|---|---| | `idea ask ""` | `.ideai/requests//.json` (`type: agent.ask`, `correlation`) | `.ideai/outbox/.json` (poll + timeout) | **bloque**, imprime `reply.content` sur stdout (feeling natif type outil `Task`) | | `idea launch ` | `.ideai/requests//.json` (`type: agent.run`) | rien (fire-and-forget) | retourne immédiatement (ACK) | | `idea reply ""` | `.ideai/outbox/.json` (corrélation lue depuis `IDEA_CORRELATION`/inbox courante) | — | la cible rend sa réponse à l'appelant | | `idea list-agents` | requête de découverte | la liste | imprime les agents du projet | | `idea next` *(cible vivante)* | — | lit `.ideai/inbox//` (FIFO) | imprime la prochaine tâche + sa `correlation` (cf. décision 10) | - **Mise sur le PATH (§14.1)** : à l'activation d'un agent, IdeA matérialise `idea` dans le run dir (`/bin/idea`, par symlink/copie du binaire embarqué dans le bundle Tauri) et **préfixe `PATH`** via `SpawnSpec.env` (`PATH=/bin:`). Ainsi la commande `idea` est résolue **sans installation système**, par agent, exactement où vit déjà le convention file. Aucun nouveau port : on réutilise le `env` déjà transporté par `SpawnSpec`. - **Skill built-in** : le `.md` du skill « Orchestration IdeA » (scope `Builtin`, décision 9) documente ces commandes et la consigne « ne jamais utiliser les subagents natifs du fournisseur ; pour traiter une tâche entrante, lis ton inbox via `idea next` ». Il est **auto-assigné à tout agent** et injecté par le canal skills existant de `compose_convention_file`. - **Réutilisation DRY** : la requête `agent.ask` produite par `idea` est **le même** `OrchestratorRequest` que celui du watcher → **même** `validate` → **même** `AskAgent` → **même** `OrchestratorService::dispatch` → **même** `await_reply` sur le **même** `OutboxReplyChannel`. `idea` n'ajoute **aucune** logique métier ; il ne fait que **traduire une ligne de commande en fichier** et **attendre l'outbox**. ### 16.4 Adapter MCP (OPTIONNEL, postérieur) — `infrastructure/src/orchestrator/mcp/` Nouvel adapter **entrant** (driving), strict pair du `FsOrchestratorWatcher` : - **Serveur MCP** (un par projet ouvert, comme un watcher par projet) exposant les outils : | Outil MCP | Mappe vers | Effet | |---|---|---| | `idea_ask_agent(target, task) → reply` | `OrchestratorCommand::AskAgent` | génère `CorrelationId`, `dispatch`, **await_reply** (timeout), renvoie `content` inline | | `idea_launch_agent(target, visibility)` | `OrchestratorCommand::SpawnAgent` | fire-and-forget (équiv. `agent.run`) | | `idea_list_agents() → […]` | `ListAgents` (via service) | découverte | | `idea_reply(requestId, content)` | `AgentReplyChannel::publish_reply` | la **cible** rend sa réponse corrélée | | (déjà couverts) `idea_create_skill`, `idea_update_context`, `idea_stop_agent` | commandes existantes | parité avec §14.3 | - **Transport** : le serveur MCP est lancé par IdeA et **branché à chaque CLI MCP** via la `McpConfigStrategy` du profil cible, au moment du `LaunchAgent` (IdeA matérialise la conf MCP — `ConfigFile`/`Flag`/`Env` — dans le cwd isolé `.ideai/run//`, comme le convention file §14.1). Choix stdio vs socket = détail d'implémentation de l'adapter (point ouvert §16.7), **invisible au domaine/application**. - **Réutilisation** : l'adapter ne contient **aucune** logique de cycle de vie — il (dé)sérialise les appels d'outils → `OrchestratorCommand` → `OrchestratorService::dispatch`, exactement comme `dispatch_file`. La seule logique neuve est l'`await_reply` pour `idea_ask_agent`. **Injection / composition** : `app-tauri/src/state.rs` instancie `OutboxReplyChannel` (port `AgentReplyChannel`), le passe à `OrchestratorService` (nouvelle dépendance) **et** démarre, par projet ouvert, le serveur MCP **à côté** du `FsOrchestratorWatcher` (même hook `ensure_orchestrator_watch`). Aucun autre crate ne connaît MCP. ### 16.5 Évolution de `OrchestratorService` (réutilisation maximale) `OrchestratorService` gagne **un** champ (`reply_channel: Arc`) et **une** branche `AskAgent` : ``` dispatch(AskAgent { target, task, correlation, visibility }): 1. Résoudre l'agent cible (find_agent_id_by_name) — NotFound sinon. 2. Vivant ? (sessions.session_for_agent) a. Oui → déposer la tâche dans l'INBOX de la cible (décision 10) : écrire `.ideai/inbox/{target}/{correlation}.json` { task, correlation }. La cible la relit via `idea next` à son tour suivant — PAS de write PTY dans le TUI vivant (évite la corruption d'affichage / l'entrelacement). N `ask` concurrents ⇒ FIFO naturelle par cible (§16.7-3). b. Non → LaunchAgent (comme SpawnAgent), avec la tâche en consigne initiale (argument/contexte de spawn, pas un write dans un TUI) + la même instruction de réponse corrélée. 3. reply = reply_channel.await_reply(&correlation, timeout).await? // borné, Timeout typé 4. publish(AgentReplied { from_agent, correlation }). 5. Retourner reply.content (l'adapter MCP le renvoie inline ; le watcher l'écrit dans `*.response.json` pour la route fichier). ``` > `SpawnAgent`, `StopAgent`, `UpdateAgentContext`, `CreateSkill` **inchangés**. La transmission de tâche (étape 2a, via inbox) corrige enfin le bug §14.3 « task ignoré pour un agent existant » — et le fait pour **les trois** routes entrantes (`idea`, watcher fichier, MCP optionnel), qui portent toutes `agent.ask`. ### 16.6 Conformité hexagonale & SOLID - **Règle de dépendance** : ajouts domaine **purs** (`CorrelationId`, `AskAgent`, `AgentReply`, `SkillScope::Builtin`, `McpCapability`, event `AgentReplied`) ; le seul port neuf (`AgentReplyChannel`) est un **trait du domaine**, implémenté en infra. Le binaire `idea`, le serveur MCP, l'inbox et l'outbox sont **exclusivement** hors-domaine. Le domaine ignore la CLI `idea`, MCP, stdio, JSON-RPC, l'inbox/outbox FS. - **Le binaire `idea` est un adapter entrant out-of-process** : sa frontière avec le cœur est le **protocole fichier** `.ideai/{requests,inbox,outbox}/` (le contrat), pas un appel de fonction. `crates/idea-cli/` ne lie ni `application` ni `infrastructure` ⇒ pas de fuite de couche. Il est **substituable** au watcher (mêmes fichiers) et au serveur MCP (même `dispatch`/outbox). - **S** : `AgentReplyChannel` = un seul rôle (rendez-vous corrélé). `idea` = une seule techno d'entrée (CLI→fichier). L'adapter MCP = une seule techno d'entrée. `OrchestratorService` garde sa responsabilité (traduire commande → use cases). - **O** : ajouter le plancher universel = données + un binaire client, **sans toucher** au domaine ni aux use cases. Ajouter une CLI MCP = un bloc `mcp` sur le profil (donnée). Le skill built-in = un scope `Builtin` + un `.md` embarqué, injecté par le canal skills existant. - **L** : `idea`, fichier et MCP sont **substituables** comme adapters entrants — `OrchestratorService` se comporte identiquement. Un profil sans `mcp` n'a aucun manque : la CLI `idea` (universelle) reste sa voie de délégation ; MCP n'est qu'un confort en plus. - **I** : `OrchestratorService` ne reçoit que `AgentReplyChannel` en plus ; `idea` ne dépend que du protocole fichier ; l'adapter MCP ne dépend que du service + du port reply. - **D** : tout est injecté au composition root (`state.rs`) ; aucun `new ConcreteAdapter` ailleurs. Le PATH/env de `idea` est posé via `SpawnSpec.env` au `LaunchAgent` (réutilise l'existant). ### 16.7 Points ouverts (spikes v3) 1. **Détection « la cible a répondu » sans outil** : la cible rend sa réponse via la commande **`idea reply`** (voie universelle). Risque résiduel : la cible **oublie** d'appeler `idea reply` ou de traiter son inbox. Mitigation : timeout borné + relance déposée dans l'inbox ; l'outbox reste la seule source fiable (on n'infère jamais la fin de tour). Lot **C-univ-2**. 2. **Boucles d'`ask` (A demande à B qui redemande à A)** : détection de cycle / profondeur max de délégation pour éviter l'interblocage (A bloqué sur B bloqué sur A). Garde-fou applicatif (chaîne de corrélation transportée dans la requête). Lot **C-univ-2**. 3. *(OPTIONNEL, MCP)* **Transport MCP par CLI** : stdio (process enfant) vs serveur local (socket/HTTP) ; chaque CLI déclare sa conf différemment. Spike : matérialiser `.mcp.json` Claude Code, conf Codex, conf Gemini depuis `McpConfigStrategy`. Lot **C-mcp-1**. **Ne conditionne plus le cross-model.** > **Tranché (n'est plus ouvert)** : > - **Tâche à un agent vivant** : **inbox relue par la cible**, pas write stdin (décision 10). Un TUI plein écran n'a pas de prompt shell ; écrire dans le PTY corrompt le rendu et entrelace les frappes (bug « accents/ordre d'écriture »). > - **Concurrence des corrélations** : résolue par l'**inbox FIFO par cible** — N `ask` simultanés s'empilent comme N fichiers ordonnés que la cible draine un par un via `idea next`. Plus besoin d'un mécanisme de sérialisation de writes PTY. ### 16.8 Découpage en LOTS testables (cycle dev↔QA) — réordonné (universel d'abord, MCP optionnel ensuite) > **Principe d'ordonnancement révisé** : la **voie universelle** (plancher : domaine + rendez-vous + binaire `idea` + skill built-in + wiring) est livrée **en premier et en entier** — elle suffit à elle seule à garantir le cross-model et à fermer le chantier C sur le plan produit. La **voie MCP** est un **bloc optionnel postérieur**, livrable plus tard sans rien bloquer. Chaque lot = binôme dev+test, vert avant le suivant. > > **Cœur inchangé** : C0 (fondation domaine) et C1 (port `AgentReplyChannel` + outbox + rendez-vous applicatif) restent le socle commun aux deux voies — **non réécrits**, seulement étendus (C1 délivre désormais par **inbox**, pas par write PTY). **Bloc UNIVERSEL (principal — ferme le cross-model)** | Lot | Périmètre | Crates/dossiers | Contrats (ports/DTO) | Tests attendus | |---|---|---|---|---| | **C0 (fondation domaine)** | `CorrelationId` (VO validé), `OrchestratorCommand::AskAgent`, `AgentReply`, action `agent.ask` dans `validate`, **`SkillScope::Builtin`**, event `DomainEvent::AgentReplied`. *(`McpCapability`/`McpConfigStrategy` repoussés au bloc MCP — plus dans C0.)* | `domain/src/{orchestrator,skill,events}.rs` | variante + VO + scope + event, tous purs/sérialisés | unit purs : `agent.ask` valide (target+task requis) ; `AskAgent` rejette task vide ; `CorrelationId` non vide ; `SkillScope::Builtin` round-trip ; event sérialisé. **Réutilise** le harnais `orchestrator.rs`. | | **C1 (port + rendez-vous, inbox)** | Port `AgentReplyChannel` (domaine) + adapter `OutboxReplyChannel` (infra : Notify in-memory + outbox `.ideai/outbox/`) ; branche `AskAgent` dans `OrchestratorService` : cible vivante ⇒ **dépôt inbox** `.ideai/inbox//.json` (PAS de write PTY), cible morte ⇒ launch + consigne initiale ; `await_reply` + timeout ; publish `AgentReplied`. | `domain/src/ports.rs`, `infrastructure/src/orchestrator/`, `application/src/orchestrator/service.rs` | `AgentReplyChannel`, `OutboxReplyChannel`, `OrchestratorService(+reply_channel)` | unit (fakes) : `await_reply` débloqué par `publish_reply` corrélé ; **timeout** → `ReplyError::Timeout`, cible **non tuée** ; cible vivante ⇒ **inbox écrite, aucun write PTY** ; cible morte ⇒ launch ; 2 `ask` ⇒ 2 fichiers inbox ordonnés (FIFO) ; corrélation croisée ignorée. infra : outbox écrit ⇒ `await_reply` résout. | | **C-univ-1 (binaire `idea` + skill built-in + wiring PATH)** | Nouveau `crates/idea-cli/` (client mince : `ask`/`launch`/`reply`/`list-agents`/`next` ↔ protocole fichier) ; `.md` du skill built-in « Orchestration IdeA » (embarqué, scope `Builtin`) ; **retirer** le bloc prose de `compose_convention_file` et **auto-assigner** le skill built-in à tout agent ; poser `idea` sur le PATH du run dir via `SpawnSpec.env` au `LaunchAgent` (`PATH=/bin:…`, `IDEA_PROJECT_ROOT`, `IDEA_AGENT`). Démarrer le watcher fichier par projet (déjà existant) suffit côté backend. | `crates/idea-cli/`, `application/src/agent/lifecycle.rs`, `domain|application/src/skill/*`, `infrastructure/src/runtime/`, `app-tauri/src/state.rs` | binaire `idea` ↔ `.ideai/{requests,inbox,outbox}/` ; skill `Builtin` injecté ; env PATH/IDEA_* | `idea-cli` : `ask` écrit la requête + bloque jusqu'à l'outbox + imprime `content` ; `reply` écrit l'outbox corrélé ; `next` draine l'inbox FIFO ; timeout → code d'erreur. application : `compose_convention_file` n'a **plus** de prose libre mais le skill built-in est présent en tête des skills ; tout agent reçoit `Builtin`. lifecycle : `SpawnSpec.env` porte `PATH`/`IDEA_*`. e2e (Claude **et** Codex) : `idea ask` rend la réponse inline. | | **C-univ-2 (garde-fous délégation)** | Sérialisation FIFO par cible (déjà naturelle via inbox — tests de non-entrelacement) + chaîne de corrélation transportée ⇒ **profondeur max / détection de cycle** (A→B→A) ; relance inbox sur timeout. | `application/src/orchestrator/service.rs`, `domain/src/orchestrator.rs` (chaîne corr.) | profondeur/anti-cycle dans la commande | unit : 2 `ask` concurrents ⇒ réponses non entrelacées (corrélation correcte) ; boucle A→B→A bloquée à la profondeur max → erreur typée ; timeout ⇒ relance inbox. | **Bloc MCP (OPTIONNEL — confort natif, postérieur, ne bloque rien)** | Lot | Périmètre | Crates/dossiers | Contrats (outils) | Tests attendus | |---|---|---|---|---| | **C-mcp-0 (capacité profil)** | `McpCapability`/`McpConfigStrategy` sur `AgentProfile` (défaut `None` ⇒ comportement universel inchangé). | `domain/src/profile.rs` | capacité déclarative pure | unit : `mcp=None` round-trip inchangé ; `mcp=Some(...)` sérialisé. | | **C-mcp-1 (adapter MCP entrant + conf par CLI)** | Serveur MCP `infrastructure/src/orchestrator/mcp/` exposant `idea_ask_agent`/`idea_launch_agent`/`idea_list_agents`/`idea_reply` (+ parité `stop`/`update_context`/`create_skill`) → **même** `OrchestratorService`/outbox ; matérialiser la conf MCP (`McpConfigStrategy`) au `LaunchAgent` ssi `profile.mcp.is_some()` ; démarrer le serveur MCP par projet dans `state.rs` à côté du watcher ; mention des outils dans le skill built-in pour profils MCP. | `infrastructure/src/orchestrator/mcp/`, `application/src/agent/lifecycle.rs`, `app-tauri/src/state.rs` | outils MCP ↔ `OrchestratorCommand` ; `idea_reply` ↔ `publish_reply` | intégration : `idea_ask_agent` → dispatch → réponse inline corrélée (même outbox que la voie `idea`) ; `idea_launch_agent` fire-and-forget ; conf MCP matérialisée ssi profil MCP ; serveur MCP + watcher démarrés à l'open. | | **C4 (route fichier `agent.ask` + UI observabilité)** | Le `FsOrchestratorWatcher` gère `agent.ask` brut (parité avec `idea`, pour un agent qui écrit le fichier à la main) ⇒ `*.response.json` porte `reply.content` ; relais event `agentReplied` → l'UI montre la relation requête↔réponse (extension du registre §14.3). | `infrastructure/src/orchestrator/mod.rs`, `app-tauri/src/{events,lib}.rs`, `frontend/src/features/agents` | `*.response.json` porte `reply.content` ; event front `agentReplied` | infra : fichier `agent.ask` → réponse contient le contenu corrélé. app-tauri : event relayé. Vitest : l'UI affiche « X a répondu à Y ». | | **C4 (UI observabilité + route fichier `agent.ask`)** | Le `FsOrchestratorWatcher` gère `agent.ask` (écrit la réponse de contenu dans `*.response.json`) ⇒ parité repli ; relais event `agentReplied` → l'UI montre la relation requête↔réponse (extension du registre §14.3). | `infrastructure/src/orchestrator/mod.rs`, `app-tauri/src/{events,lib}.rs`, `frontend/src/features/agents` | `*.response.json` porte `reply.content` ; event front `agentReplied` | infra : fichier `agent.ask` → réponse contient le contenu corrélé. app-tauri : event relayé. Vitest : l'UI affiche « X a répondu à Y ». | **Ordre conseillé** : **C0 → C1 → C-univ-1 → C-univ-2** *(le chantier C est produit-complet et cross-model garanti ici)*, **puis, optionnellement et plus tard** : C-mcp-0 → C-mcp-1, et C4 (route fichier brute + observabilité UI, utile aux deux voies — peut être avancé après C-univ-1 si l'on veut l'UI tôt). C0 débloque tout ; C1 livre le rendez-vous (cœur, inbox) ; C-univ-1 livre la voie principale `idea`+skill ; C-univ-2 durcit la délégation ; le bloc MCP n'ajoute qu'un confort natif sur le même backend. ### 16.9 Situer les chantiers adjacents (hors v3, cohérence) - **Hot-swap de profil** (§15.1, chantier A) : orthogonal — changer le profil d'un agent peut *changer* sa capacité MCP (`mcp`) ; `ChangeAgentProfile` re-matérialisera/retirera la conf MCP à la relance à chaud (réutilise C-mcp-1 au lieu de dupliquer). La voie `idea` (universelle) ne dépend pas du profil et reste disponible quel que soit le hot-swap. - **Reprise des sessions** (§15.2, chantier B) : une session reprise ré-injecte son convention file (donc le skill built-in + le PATH `idea`) et, si profil MCP, sa conf MCP ; aucune interaction nouvelle (C-univ-1 et C-mcp-1 couvrent l'injection au `LaunchAgent`, que la reprise réutilise). - **« Version B » (UI chat / agent headless à sortie structurée)** — **épic futur séparé, hors chantier C.** Une interface où l'utilisateur (ou un agent) dialogue avec un agent via une UI dédiée et reçoit une **sortie structurée** d'un mode headless. Elle se brancherait sur le **même** `OrchestratorService` + `AgentReplyChannel` + outbox (donc compatible, zéro réécriture du cœur). Notée ici pour cohérence ; **non requise** pour la garantie cross-model, qui est entièrement assurée par le bloc universel. --- ## 17. Exécution structurée des agents IA — port `AgentSession` (PIVOT 2026-06-09, voie principale) > **⚠️ RÉCONCILIÉ 2026-06-12 — PIVOT « Option 1 » (chef d'orchestre, acté).** Le port `AgentSession` et les deux adapters structurés (Claude/Codex) **restent la voie principale** d'exécution. **MAIS** : la **vue** d'un agent est désormais un **terminal natif PTY = vue de SORTIE**. Il n'y a **PLUS d'UI chat** : **`AgentChatView` a été supprimée** (`frontend/src/features/chat/` retiré), et toute la sous-section **§17.6 décrivant `AgentChatView`/`ChatBridge`/`cellKind:"chat"` est SUPERSEDED**. L'entrée utilisateur est **médiée par IdeA** (`MediatedInput`/`useAgentBusy` côté front ; modules domaine `input`/`mailbox`/`conversation`/`fileguard`). L'observabilité des délégations vit dans le **modèle terminal/debug**, **pas** dans un fil de chat séparé. Cartographie des modules livrés : **§18**. > > **Pivot verrouillé par le chef d'orchestre (acté, non rediscuté).** IdeA ne lit plus le terminal d'un agent IA et ne lui demande plus de se rapporter. Pour un agent **IA**, IdeA le **pilote via son mode programmatique/structuré** (ex. `claude -p --output-format stream-json` ou l'Agent SDK ; `codex exec` à sortie structurée) et **lit la réponse comme du JSON déterministe** (un message `result` final bien défini). La **plomberie devient 100 % fiable** ; seul reste irréductible le *contenu* de la réponse (propre à tout LLM). Cette section **remplace §16** comme voie principale et **réconcilie** avec §15 (chantiers A « hot-swap profil » et B « reprise session », tous deux LIVRÉS). > > Cette section **complète** §6 (use cases agent), §7 (layout), §9 (profils déclaratifs), §14.1 (run dir isolé), §14.3 (registre visible/arrière-plan) et §15 (agent = entité reprenable). Elle **ajoute un port domaine** (`AgentSession` + sa factory), **deux adapters infra** (Claude/Codex), **un type de cellule** (cellule IA vs terminal brut), **un registre de sessions structurées**, et le câblage frontend (UI chat). Elle **ne casse pas** les terminaux non-IA (PTY + xterm inchangés) ni A/B. ### 17.0 État du terrain (lu dans le code, pas présumé) | Pièce | Existe ? | Référence code | |---|---|---| | Port `AgentRuntime` (prépare un `SpawnSpec`, pur) + `PtyPort` (PTY interactif) | ✅ | `domain/src/ports.rs` | | Adapter unique `CliAgentRuntime` (profil déclaratif → `SpawnSpec`) | ✅ | `infrastructure/src/runtime/mod.rs` | | `LaunchAgent` (résout profil+contexte, injecte, **spawn PTY**, registre) — invariant « 1 session vivante/agent » | ✅ — aujourd'hui **toujours** un PTY, même pour un agent IA | `application/src/agent/lifecycle.rs`, `terminal/registry.rs` | | `TerminalSessions` (registre `SessionId → PtyHandle + TerminalSession`) | ✅ — `session_for_agent`, `rebind_agent_node`, `node_for_agent`, `remove` | `application/src/terminal/registry.rs` | | `SessionKind::{Plain, Agent{agent_id}}` (ce qui tourne dans une cellule) | ✅ — pas de distinction « IA structurée » vs « TUI brut » | `domain/src/terminal.rs` | | `LeafCell { session?, agent?, conversation_id?, agent_was_running }` + ops pures | ✅ — `conversation_id` = id opaque de conversation CLI, persistant | `domain/src/layout.rs` | | `SessionStrategy { assign_flag?, resume_flag }` + `SessionPlan{None,Assign,Resume}` + `resolve_session_plan` | ✅ — pleinement câblé (A/B) | `domain/src/profile.rs`, `domain/src/ports.rs`, `agent/lifecycle.rs` | | `ChangeAgentProfile` (chantier A, hot-swap) | ✅ — compose `LaunchAgent` ; jette `conversation_id` | `application/src/agent/lifecycle.rs` | | `ListResumableAgents` (chantier B, inventaire reprise) | ✅ | `application/src/agent/resume.rs` | | Bridge `PtyBridge` (PTY ↔ `tauri::ipc::Channel>` par session, generation-tracked) | ✅ — **transport incrémental réutilisable** | `app-tauri/src/pty.rs`, `commands.rs` | | Catalogue de profils de référence (Claude, Codex, Gemini, Aider) + profil **custom** | ✅ — first-run wizard propose **tous** | `application/src/agent/catalogue.rs`, `frontend` first-run | | **Port `AgentSession`** (session programmatique persistante par agent IA) | ❌ **totalement absent** | — | | **Adapters `ClaudeSdkSession` / `CodexExecSession`** | ❌ totalement absent | — | | **Notion « adapter structuré supporté » sur le profil** (registre Claude/Codex) | ❌ — n'importe quel `command` est accepté | — | | **Distinction cellule « agent IA » (chat) vs « terminal brut »** côté layout + frontend | ❌ — toute cellule d'agent = TUI dans xterm | — | **Conclusion** : §17 = **(1)** ajouter le port domaine `AgentSession` + sa factory `AgentSessionFactory` (sélection par profil) ; **(2)** deux adapters infra structurés (Claude SDK / Codex exec) qui maintiennent la session et **parsent leur JSON documenté** (zéro scraping de TUI) ; **(3)** router `LaunchAgent` (et donc A/B + l'orchestrateur) vers `AgentSession` **pour les agents IA**, en gardant le PTY pour les terminaux bruts ; **(4)** modéliser **deux types de cellules** (IA/chat vs terminal/xterm) ; **(5)** UI chat alimentée par le **même mécanisme `Channel`** que les PTY ; **(6)** restreindre le menu de profils aux modèles **ayant un adapter** (Claude/Codex) et **retirer le custom** pour l'instant. --- ### 17.1 Le port `AgentSession` (frontière domaine) — signatures tranchées **Décision : `AgentSession` est un port domaine (trait `async`), une instance vivante = une conversation persistante avec UN agent IA.** Il incarne l'invariant « 1 session vivante par agent » au niveau *type* (un agent IA possède au plus une `AgentSession` vivante, dans le registre §17.5). Il ne fuit **aucun** détail Claude/Codex (pas de `stream-json`, pas de `--output-format`, pas de chemin de transcript) : seulement « envoie un prompt, reçois un flux d'événements incrémentaux puis un contenu final déterministe ». #### Décision streaming **vs** bloquant : **les deux, via un flux + un terminal déterministe** `send()` retourne un **flux d'événements de réponse** (`ReplyStream`), exactement comme `PtyPort::subscribe_output` retourne un `OutputStream` — mais **typé** (deltas de texte, événements d'outil, puis **un** événement terminal `Final`), pas des octets bruts. Le **rendez-vous synchrone** dont l'orchestrateur a besoin (§17.4) s'obtient en **drainant le flux jusqu'au `Final`** : c'est un helper applicatif `send_blocking()` au-dessus du même flux (DRY — pas deux chemins). Justification : - **L'UI chat** veut le **rendu incrémental** (deltas live) ⇒ le flux. - **L'orchestrateur** (Main demande à Architect) veut **la réponse complète** ⇒ draine jusqu'à `Final` ⇒ déterministe, sans deviner la fin de tour (le `Final` est **émis par l'adapter** quand il a lu le message `result` documenté de la CLI). - **Une seule primitive** (`send → stream`) sert les deux besoins : zéro duplication, frontière minimale. ```rust // domain/src/ports.rs — nouveau port (frontière domaine, infra l'implémente) use std::time::Duration; /// Un événement incrémental d'un tour de réponse d'un agent IA. Universel : /// l'adapter (Claude/Codex) traduit SON format structuré documenté vers ces /// variantes ; aucun détail propre à une CLI ne franchit cette frontière. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ReplyEvent { /// Un fragment de texte assistant (rendu incrémental côté UI chat). TextDelta { text: String }, /// Une activité d'outil de l'agent (best-effort, pour l'observabilité chat : /// « lit un fichier », « lance une commande »). Le `label` est déjà /// humain-lisible ; le détail brut reste dans l'adapter. ToolActivity { label: String }, /// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a /// lu le message `result` documenté de la CLI. Porte le contenu final agrégé. /// Après `Final`, le flux se termine (plus aucun événement). Final { content: String }, } /// Flux borné d'événements de réponse d'UN tour. Se termine après le `Final` /// (ou sur erreur). Calqué sur `OutputStream`, mais typé. pub type ReplyStream = Box + Send>; /// Erreurs d'une `AgentSession`. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum AgentSessionError { /// La session programmatique n'a pas pu démarrer (CLI introuvable, mode /// structuré indisponible, handshake invalide). #[error("agent session start failed: {0}")] Start(String), /// Échec d'envoi/de communication avec la session vivante. #[error("agent session io failed: {0}")] Io(String), /// La sortie structurée de la CLI n'a pas pu être décodée (JSON cassé, /// schéma inattendu). Frontière nette : on ne propage jamais le JSON brut. #[error("agent session decode failed: {0}")] Decode(String), /// `send_blocking` n'a pas observé de `Final` dans le temps imparti. La session /// **reste vivante** (on ne tue rien) ; l'appelant décide (cf. §16 décision 6). #[error("agent session reply timed out")] Timeout, } /// Une **session programmatique persistante** avec un agent IA : une conversation /// vivante que l'on pilote en mode structuré et dont on lit la réponse de façon /// déterministe. Une instance ⇔ un agent IA (invariant « 1 session vivante/agent »). /// /// Hexagonal : ce trait est **domaine** ; les adapters Claude/Codex (infra) ne /// fuient aucun détail de CLI à travers lui. Substituable (Liskov) : Claude et /// Codex offrent les mêmes garanties (flux d'événements → `Final` déterministe), /// seul le moteur diffère. #[async_trait] pub trait AgentSession: Send + Sync { /// L'id de session IdeA (mappe la cellule/agent, comme un `PtyHandle.session_id`). fn id(&self) -> SessionId; /// L'id de conversation **du moteur** (opaque), persisté sur la cellule pour la /// reprise (§15.2/B). `None` tant que le moteur n'en a pas attribué. Permet à /// `LeafCell.conversation_id` de rester le pivot de reprise, model-agnostic. fn conversation_id(&self) -> Option; /// Transmet `prompt` à la session vivante et retourne le **flux** d'événements /// du tour (deltas → `Final`). Rendu incrémental (UI chat) ET base du rendez-vous /// synchrone (cf. `send_blocking`, helper applicatif). /// /// # Errors /// [`AgentSessionError::Io`]/[`Decode`] sur échec de communication/décodage. async fn send(&self, prompt: &str) -> Result; /// Termine proprement la session (tue le process/SDK sous-jacent). Idempotent. /// /// # Errors /// [`AgentSessionError::Io`] si l'arrêt échoue. async fn shutdown(&self) -> Result<(), AgentSessionError>; } /// **Factory** sélectionnée par le profil : crée/reprend une `AgentSession` pour un /// agent IA. C'est elle qui sait *quel adapter* instancier (Claude/Codex) selon /// `profile.structured_adapter` (§17.3). Open/Closed : ajouter un moteur structuré /// = ajouter un adapter + une variante de registre, sans toucher au cœur. #[async_trait] pub trait AgentSessionFactory: Send + Sync { /// Vrai si cette factory sait piloter `profile` en mode structuré (sert au /// menu de sélection §17.6 : ne proposer que les profils supportés). fn supports(&self, profile: &AgentProfile) -> bool; /// Démarre une session structurée pour `profile` dans `cwd` (run dir isolé /// §14.1), avec le contexte déjà préparé (`PreparedContext`) et l'intention de /// session (`SessionPlan` : neuf / assign / resume — réutilise §15). /// /// # Errors /// [`AgentSessionError::Start`] si la CLI/SDK est indisponible ou le mode /// structuré ne peut s'initialiser. async fn start( &self, profile: &AgentProfile, ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, ) -> Result, AgentSessionError>; } ``` > **Helper applicatif (pas un nouveau port)** — le rendez-vous synchrone de l'orchestrateur : > ```rust > // application/src/agent/structured.rs (ou un util) > /// Draine un ReplyStream jusqu'au `Final` (borné par timeout), agrège les > /// TextDelta de secours si l'adapter n'a pas pré-agrégé. Renvoie le contenu final. > pub async fn send_blocking( > session: &dyn AgentSession, prompt: &str, timeout: Duration, > ) -> Result; > ``` > Ainsi `OrchestratorService` (§17.4) obtient un **rendez-vous synchrone intrinsèque** sans outbox, sans corrélation fichier, sans deviner la fin de tour : le `Final` *est* la fin de tour, **émise par l'adapter** depuis le message `result` documenté de la CLI. --- ### 17.2 Les deux adapters structurés (infra) — Claude & Codex **Emplacement** : `infrastructure/src/session/` (nouveau module), pair de `infrastructure/src/runtime/` et `infrastructure/src/pty/`. Chaque adapter implémente `AgentSession` ; une `AgentSessionFactory` infra (`StructuredSessionFactory`) route un profil vers le bon adapter selon `profile.structured_adapter` (§17.3) et **agrège** les deux (registre interne `{ ClaudeSdkSession, CodexExecSession }`). Aucun de ces types ne franchit la frontière domaine : seuls `Arc` et `ReplyEvent` sortent. #### `ClaudeSdkSession` (Claude) - **Mode** : `claude` en mode **non-interactif structuré** — `claude -p --output-format stream-json --input-format stream-json` (flux JSONL bidirectionnel), ou l'**Agent SDK** Claude si retenu au spike S1. La session **persiste** : le process reste vivant entre les `send()` (on écrit le prompt sur stdin au format documenté, on lit les lignes JSON sur stdout). - **Persistance / reprise** : capte l'`session_id`/`conversation` exposé par le premier message structuré ⇒ `conversation_id()`. Réouverture = relancer avec le flag de reprise (réutilise la sémantique `SessionStrategy.resume_flag` / `SessionPlan::Resume` de §15 ; le profil Claude porte déjà un bloc `session`). - **Détection du `Final`** : la CLI émet un message `{"type":"result", ...}` documenté en fin de tour ⇒ l'adapter émet `ReplyEvent::Final { content }`. Les messages `assistant`/`content_block_delta` ⇒ `TextDelta` ; les `tool_use` ⇒ `ToolActivity`. **Le format exact du `stream-json` est un spike (S1)** mais n'invalide pas l'ossature : le contrat de sortie reste `ReplyEvent`. #### `CodexExecSession` (Codex) - **Mode** : `codex exec` (mode non-interactif/automation) avec sa **sortie structurée** documentée (JSON par tour). Selon que Codex maintient ou non un process long, deux incarnations possibles derrière le **même** trait : (a) process persistant piloté en flux, ou (b) **un `exec` par `send()`** réattaché via l'id de conversation Codex (`resume`). **L'incarnation est un détail d'adapter** ; le port ne change pas. - **Persistance / reprise** : id de conversation Codex ⇒ `conversation_id()` ; reprise via le flag Codex (`SessionStrategy`/`SessionPlan::Resume`, §15). - **Détection du `Final`** : message terminal documenté de `codex exec` ⇒ `ReplyEvent::Final`. **Format exact = spike (S2).** > **Liskov garanti** : un test de **conformité de port** partagé (un harnais `agent_session_contract`) vérifie pour chaque adapter : `send()` émet ≥0 deltas puis **exactement un** `Final` ; après `Final` le flux est clos ; `shutdown()` est idempotent ; `conversation_id()` devient `Some` après le premier tour assignant. Les adapters réels sont testés derrière un **fake CLI** scriptable (un binaire de test qui imprime des lignes JSON canned) pour rester déterministes et hors-réseau en CI. --- ### 17.3 Évolution du modèle `AgentProfile` (adapter structuré supporté, retrait du custom) **Décision : un profil IA déclare quel adapter structuré le pilote.** On ajoute un champ **optionnel** `structured_adapter` sur `AgentProfile` (Open/Closed, comme `session`/`mcp` ; `skip_serializing_if = None` ⇒ zéro régression de sérialisation). Un profil **sans** `structured_adapter` reste un profil **TUI/PTY** (terminal brut) — c'est le cas des profils non encore couverts (Gemini, Aider) et de tout profil legacy. ```rust // domain/src/profile.rs — nouvel enum + champ optionnel sur AgentProfile #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum StructuredAdapter { /// Piloté par `ClaudeSdkSession` (mode `-p --output-format stream-json` / SDK). Claude, /// Piloté par `CodexExecSession` (`codex exec` structuré). Codex, } pub struct AgentProfile { // … champs existants inchangés … /// Adapter d'exécution **structurée** (§17). `None` ⇒ agent **TUI/PTY** (cellule /// terminal brut, comportement historique). `Some(_)` ⇒ agent **IA structuré** /// (cellule chat + port `AgentSession`). Open/Closed : ajouter un moteur = une /// variante + un adapter. #[serde(default, skip_serializing_if = "Option::is_none")] pub structured_adapter: Option, } ``` **Catalogue (`application/src/agent/catalogue.rs`)** — les profils de référence **Claude** et **Codex** portent désormais `structured_adapter: Some(Claude|Codex)`. **Décision produit (verrouillée) : le menu de sélection ne propose QUE les profils ayant un adapter** (donc Claude + Codex) et **le profil custom est retiré pour l'instant**. Concrètement : - Le **catalogue** ne liste plus Gemini/Aider/custom dans le wizard et la création d'agent (ils restent dans le code comme références mais ne sont **pas proposés** tant qu'ils n'ont pas d'adapter structuré). *Alternative pragmatique* : un prédicat `is_selectable(profile) = factory.supports(profile)` filtre la liste exposée à l'UI — **un seul point de vérité** (la factory), pas une liste en dur. - **First-run wizard (§9)** : ne propose que Claude + Codex ; pas de saisie de commande custom. - **UI création/édition d'agent (§17.6)** : sélecteur restreint aux profils sélectionnables ; le bouton « profil custom » est masqué (flag produit, réactivable plus tard sans changer les contrats). > **Réconciliation A (hot-swap, §15.1)** : `ChangeAgentProfile` continue de composer `LaunchAgent` ; comme `LaunchAgent` route maintenant selon `structured_adapter` (§17.4), un swap Claude→Codex **change d'adapter** `AgentSession` (la conversation repart à neuf — décision déjà verrouillée : on jette `conversation_id`, on garde `.md`/mémoire). Aucun nouveau use case. Un swap d'un profil structuré vers un profil PTY (ou l'inverse) **change aussi le type de cellule** (§17.4) — `ChangeAgentProfile` republie l'event de relance et la cellule se reconstruit dans le bon mode. --- ### 17.4 Lancement / reprise / hot-swap via le port — évolution de `LaunchAgent` **Décision : `LaunchAgent` devient le point de routage `IA structuré` vs `terminal brut`.** Il garde **toute** sa logique amont (résolution agent+profil+contexte, run dir isolé §14.1, seed permissions, recall mémoire §14.5.4, composition du convention file, `resolve_session_plan` §15) — **inchangée** — puis **branche** selon le profil : ``` LaunchAgent::execute(input): … (étapes 1→5 actuelles : résoudre agent/profil/contexte, run dir, seed, prepare_invocation OU prepared context, apply_injection) … // INCHANGÉ if profile.structured_adapter.is_some(): // ── AGENT IA STRUCTURÉ ── session = agent_session_factory.start(profile, prepared, run_dir, session_plan) structured_sessions.insert(agent_id, session) // registre §17.5 publish(AgentLaunched { agent_id, session_id: session.id() }) // PAS de pty.spawn ; la cellule est de type « chat » (§17.7) return LaunchAgentOutput { session: TerminalSession{kind: Agent, …}, assigned_conversation_id: session.conversation_id() } else: // ── TERMINAL BRUT (PTY) ── … pty.spawn + TerminalSessions.insert (chemin ACTUEL, INCHANGÉ) … ``` - **Invariant « 1 session vivante/agent »** : la garde existante (`session_for_agent` au début de `execute`) est **généralisée** pour interroger **les deux** registres (PTY + structuré) — un agent est vivant s'il a une session vivante dans l'un OU l'autre. Le `rebind`/idempotence se duplique trivialement côté structuré (même sémantique : une cellule est une **vue**). - **Reprise (B, §15.2)** : `ListResumableAgents` est **inchangé** (lecture pure du layout + manifeste + `resume_supported` via le profil). La reprise effective appelle `LaunchAgent` ⇒ pour un profil structuré, la factory démarre la session avec `SessionPlan::Resume { conversation_id }` (l'adapter passe le flag de reprise du moteur). Le `conversation_id` reste **persisté sur la cellule** (`LeafCell.conversation_id`) : pivot model-agnostic déjà en place. **Précision §19.7** : ce pivot est l'**id de paire IdeA** (stable, indépendant du provider) ; le `--resume` consomme le **resumable moteur** rangé séparément dans `providers.json` (`ProviderSessionStore`), pas l'id de paire — voir §19.7 (corrige la clé P6/P7). - **Hot-swap (A, §15.1)** : `ChangeAgentProfile` **inchangé dans sa structure** ; le « kill PTY » devient « **shutdown de la session** » polymorphe : on résout la session vivante (PTY *ou* structurée), on l'arrête, puis on **rappelle `LaunchAgent`** dans la même cellule avec le nouveau profil ⇒ le bon adapter/type de cellule est re-sélectionné. *Détail d'implémentation* : `relaunch_if_live` interroge les deux registres ; une petite abstraction `LiveSession::shutdown()` (énum interne PTY/structuré) évite de dupliquer la branche. #### Messagerie inter-agents via le port — `OrchestratorService` **Décision : « Main demande à Architect » = `architectSession.send_blocking(task, timeout)` ; routage model-agnostic au-dessus du port.** Le rendez-vous synchrone est **intrinsèque** (§17.1) ⇒ **on supprime, pour la voie principale, l'outbox / `idea reply` / l'inbox** (§16 déprécié). `OrchestratorService` gagne le registre `StructuredSessions` (ou un petit port `AgentMessenger` qui l'enveloppe) et **une** branche `AskAgent` : ``` OrchestratorService::dispatch(AskAgent { target, task, … }): 1. Résoudre l'agent cible (find_agent_id_by_name) — NotFound sinon. 2. Session structurée vivante ? (structured_sessions.session_for_agent) a. Oui → session.send_blocking(task, timeout) // rendez-vous direct b. Non → LaunchAgent(target, structuré) puis send_blocking(task, timeout) 3. publish(AgentReplied { from_agent, … }) // observabilité UI (inchangé esprit §16) 4. return reply.content ``` > **Réconciliation §16** : `AskAgent`/`AgentReply` (modèle pur) **restent utiles** (la commande exprime « j'attends une réponse »), mais leur *réalisation* n'est plus l'outbox : c'est `AgentSession::send_blocking`. Le port `AgentReplyChannel`/`OutboxReplyChannel`, l'inbox et l'outbox **disparaissent de la voie principale**. La cible **doit** être pilotable en mode structuré (profil Claude/Codex) — cohérent avec le retrait du custom et la restriction du menu (§17.3/§17.6). Un agent cible **PTY** (profil sans adapter) n'est **pas** adressable par `ask` synchrone (erreur typée `Start`/`NotFound` explicite) : c'est acceptable car le menu ne crée plus que des agents structurés. --- ### 17.5 Registre des sessions structurées — où il vit **Décision : un registre applicatif `StructuredSessions`, jumeau de `TerminalSessions`, dans `application/src/terminal/` (ou `agent/`).** Il mappe `SessionId → Arc` et expose la **même** surface que `TerminalSessions` côté liveness/agent (`session_for_agent`, `node_for_agent`, `rebind_agent_node`, `remove`, `live_agents`), de sorte que les use cases (garde d'unicité, orchestrateur, snapshot) traitent les deux registres derrière un **trait commun de liveness**. - **Réutilisation maximale** : on **généralise le trait existant `LiveAgentRegistry`** (`is_agent_live`/`is_node_live`) pour qu'il couvre les deux registres. Un agrégateur `LiveSessions { pty: TerminalSessions, structured: StructuredSessions }` répond « cet agent est-il vivant ? » en interrogeant les deux. `LaunchAgent` et `OrchestratorService` dépendent de l'agrégateur (ISP : ils ne voient que la capacité « liveness + résolution »). - **Pourquoi pas dans le domaine** : comme `TerminalSessions` (cf. ses docs), un registre d'instances **vivantes** (avec `Arc` = ressource process/SDK) est un **état d'exécution applicatif**, pas du modèle métier. Le domaine ne tient que des **ids** et des **snapshots** (`TerminalSession`), jamais une poignée de process. - **Pourquoi pas dans l'adapter** : le registre arbitre l'invariant produit « 1 session/agent » (règle applicative) et sert plusieurs use cases ⇒ il vit au-dessus des adapters, injecté au composition root (`state.rs`), exactement comme `TerminalSessions`. --- ### 17.6 Deux types de cellules — modèle de layout & frontend > **⚠️ SUPERSEDED 2026-06-12 (pivot Option 1).** Le `cellKind:"chat"` et le composant `AgentChatView`/`ChatBridge` décrits ci-dessous **ne sont plus la cible** : `AgentChatView` a été **supprimée**, la vue d'un agent (structuré ou non) est un **terminal natif PTY** (vue de SORTIE). L'entrée passe par l'**entrée médiée** (`MediatedInput` + `useAgentBusy`, §18). La partie « la session structurée vit dans le registre backend et ne meurt pas au changement d'onglet » **reste vraie** (invariant 1-session/agent, §18). Conservé ci-dessous pour l'historique. **Décision : la distinction « cellule IA (chat) » vs « cellule terminal brut » est DÉRIVÉE, pas un nouveau champ de layout.** Le modèle `LeafCell` (§7) reste **inchangé** (`session?`, `agent?`, `conversation_id?`, `agent_was_running`). Le **type de rendu** d'une cellule se déduit à l'attache : - cellule **sans agent** ⇒ terminal brut (PTY + xterm), inchangé ; - cellule **avec agent** ⇒ on lit le `structured_adapter` du profil de l'agent : `Some` ⇒ **cellule chat** ; `None` ⇒ **cellule terminal brut** (un agent TUI legacy). Justification : aucune migration de layout persisté, aucune duplication d'invariants, et le type suit **toujours** le profil courant de l'agent (donc un hot-swap A change le rendu automatiquement). Le backend expose le type dérivé dans le DTO de session/cellule (`cellKind: "chat" | "terminal"`), calculé depuis le profil — **un seul point de vérité**. #### Frontend (TypeScript/React) - **Nouveau composant `AgentChatView`** (`features/agents/` ou un nouveau `features/chat/`), pair de `TerminalView` : rend la **conversation** (bulles user/assistant, deltas incrémentaux, badges d'activité d'outil), avec une zone de saisie qui appelle `agentSession.send`. - **`LayoutGrid`** choisit le composant par `cellKind` : `terminal` ⇒ `TerminalView` (xterm, inchangé) ; `chat` ⇒ `AgentChatView`. Les terminaux **non-IA gardent strictement** le chemin actuel. - **Transport incrémental = réutilisation du `Channel`** : le flux `ReplyEvent` est poussé au front par le **même mécanisme** que les octets PTY — un `tauri::ipc::Channel` par session, enregistré dans un **`ChatBridge`** jumeau du `PtyBridge` (generation-tracked, ré-attachable après navigation/layout, **exactement** le pattern §17.5/§terminal-lifecycle). `ReplyChunk` est le DTO sérialisé d'un `ReplyEvent` (`{kind:"textDelta"|"toolActivity"|"final", …}`). Une cellule chat ré-attachée **repaint** depuis un **scrollback de conversation** (les tours déjà rendus), miroir du scrollback PTY. - **Reprise de vue (bug lifecycle, mémoire)** : changer de layout/onglet **ne tue pas** la session structurée (elle vit dans le registre backend, comme un PTY) ; la vue se ré-attache via un `reattach_agent_chat` (jumeau de `reattach`), repeint le scrollback de conversation, re-subscribe au `Channel`. **Même garantie** que les PTY. --- ### 17.7 Commandes Tauri & DTO (app-tauri) Nouvelles commandes (jumelles des commandes PTY existantes ; réutilisent `resolve_project`, le pattern `Channel`, le bridge) : ``` | Commande Tauri | Request (camelCase) | Réponse / Channel | |-------------------------|------------------------------------------------------|-----------------------------------| | agent_send | { sessionId, prompt, onReply: Channel } | () + flux ReplyChunk sur le Channel| | reattach_agent_chat | { sessionId, onReply: Channel } | ReattachChatDto { scrollback } | | close_agent_session | { sessionId } | () (shutdown + unregister) | ``` - `launch_agent` (existant) renvoie déjà `assignedConversationId` et la `TerminalSession` ; on ajoute `cellKind` au DTO de session (dérivé §17.6) pour que le front choisisse le composant. - `ChatBridge` (`app-tauri/src/chat.rs`, jumeau de `pty.rs`) route les `ReplyEvent` du registre `StructuredSessions` vers le bon `Channel`, generation-tracked. La boucle de pompe (drainer le `ReplyStream` d'un `send` et `send_output` chaque event) **calque** la pompe PTY de `commands.rs`. - Events : `AgentReplied` (observabilité) relayé comme aujourd'hui ; `AgentLaunched`/`AgentProfileChanged` inchangés. --- ### 17.8 Conformité hexagonale & SOLID - **Règle de dépendance** : le port `AgentSession`/`AgentSessionFactory` + `ReplyEvent`/`AgentSessionError` sont **domaine** (purs, I/O via trait `async`). Les adapters Claude/Codex, le décodage `stream-json`/`codex exec`, le process/SDK, le `ChatBridge` et le `Channel` sont **exclusivement** hors-domaine. Le domaine ignore `claude`/`codex`, JSON, stdin/stdout, transcripts. - **S** : `AgentSession` = piloter UNE conversation ; `AgentSessionFactory` = créer/reprendre selon profil ; `StructuredSessions` = registre de liveness ; `ChatBridge` = transport. Aucune fonction fourre-tout. - **O** : ajouter un moteur structuré = un adapter + une variante `StructuredAdapter` (donnée) ; **zéro** modification du cœur. Le menu se filtre via `factory.supports` (un point de vérité). - **L** : Claude et Codex sont **substituables** derrière `AgentSession` (contrat partagé `agent_session_contract`). Un profil sans `structured_adapter` reste un terminal brut substituable au comportement historique. - **I** : `LaunchAgent`/`OrchestratorService` ne reçoivent que la capacité « liveness + résolution + factory », pas le détail des adapters. `OrchestratorService` ne gagne qu'un registre/messager, pas l'outbox. - **D** : tout est injecté au composition root (`state.rs`) ; aucun `new ClaudeSdkSession` ailleurs. `LaunchAgent` dépend de `Arc` et de l'agrégateur de registres. --- ### 17.9 Découpage en LOTS testables (cycle dev↔QA) — ordonné > **Principe d'ordonnancement** : fondation domaine d'abord (port + profil), puis adapters derrière un fake CLI (déterministes, hors-réseau), puis le routage `LaunchAgent`, puis le frontend chat, puis la messagerie inter-agents, enfin le durcissement A/B + retrait custom. **Backend et frontend séparés par lot.** Chaque lot = binôme dev+test, vert avant le suivant. Les **spikes S1 (format Claude stream-json) / S2 (format Codex exec)** sont isolés dans les adapters (lot D2) et n'invalident pas l'ossature (le contrat de sortie est `ReplyEvent`). | Lot | Côté | Périmètre | Crates/dossiers | Contrats (port/DTO/gateway) | Tests attendus | |---|---|---|---|---|---| | **D0 (fondation domaine)** | back | Port `AgentSession` + `AgentSessionFactory`, types `ReplyEvent`/`ReplyStream`/`AgentSessionError` ; champ `AgentProfile.structured_adapter: Option` (+ enum). Catalogue : Claude/Codex portent `Some(...)`. | `domain/src/{ports,profile}.rs`, `application/src/agent/catalogue.rs` | trait `AgentSession`, factory, enum, champ optionnel sérialisé | unit purs : `structured_adapter=None` round-trip **inchangé** (zéro régression sérialisation) ; `Some(Claude/Codex)` round-trip ; catalogue Claude/Codex annotés ; signatures compilent (stub). | | **D1 (registre + agrégateur liveness)** | back | `StructuredSessions` (jumeau `TerminalSessions`) ; généraliser `LiveAgentRegistry` ; agrégateur `LiveSessions{pty,structured}` ; helper `send_blocking`. | `application/src/terminal/registry.rs` (ou `agent/`), `application/src/agent/structured.rs` | `StructuredSessions`, `LiveSessions`, `send_blocking` | unit (fakes) : `session_for_agent`/`rebind`/`remove` côté structuré ; agrégateur = vivant si PTY **ou** structuré ; `send_blocking` draine jusqu'au `Final` ; timeout → `Timeout`, session non tuée. | | **D2 (adapters Claude/Codex + contrat)** | back | `ClaudeSdkSession`, `CodexExecSession`, `StructuredSessionFactory` (route par `structured_adapter`). **Spikes S1/S2** isolés ici. Harnais de **conformité de port** + **fake CLI** scriptable. | `infrastructure/src/session/` | impls `AgentSession`/`AgentSessionFactory` | contrat partagé : ≥0 deltas puis **un** `Final` ; flux clos après `Final` ; `shutdown` idempotent ; `conversation_id` `Some` après assign ; `factory.supports` vrai pour Claude/Codex, faux sinon. Décodage JSON cassé → `Decode` (jamais de panic, jamais de JSON brut propagé). **Hors-réseau** (fake CLI). | | **D3 (routage `LaunchAgent` + use cases)** | back | `LaunchAgent` route structuré vs PTY ; garde d'unicité sur l'agrégateur ; `ChangeAgentProfile` (A) et reprise (B) passent par `AgentSession` (shutdown polymorphe, `SessionPlan::Resume`). | `application/src/agent/lifecycle.rs`, `…/resume.rs` | `LaunchAgentOutput(+cellKind dérivé)` | unit (fakes) : profil structuré ⇒ **pas** de `pty.spawn`, session via factory, registre peuplé ; profil PTY ⇒ chemin actuel inchangé ; A : swap Claude→Codex ⇒ shutdown session + relance nouvel adapter, `conversation_id` jeté ; B : `Resume` passe l'id moteur ; invariant 1 session/agent across registres. | | **D4 (commandes + bridge chat)** | back | Commandes `agent_send`/`reattach_agent_chat`/`close_agent_session` ; `ChatBridge` (jumeau `PtyBridge`, generation-tracked) ; `cellKind` au DTO de session ; pompe `ReplyStream → Channel`. | `app-tauri/src/{chat,commands,dto,events,state,lib}.rs` | `ReplyChunk` DTO, `ReattachChatDto`, `cellKind` | app-tauri : `agent_send` pompe les events sur le `Channel` ; ré-attache → scrollback conversation repeint ; `close_agent_session` → `shutdown`+unregister ; generation supersede (pas de double pompe). | | **D5 (frontend chat)** | front | `AgentChatView` (deltas live, activité d'outil, saisie) ; `LayoutGrid` choisit par `cellKind` ; `AgentGateway.sendPrompt/reattachChat/closeAgentSession` + adapter Tauri + mock ; scrollback conversation + ré-attache. | `frontend/src/features/{chat,agents,layout}`, `frontend/src/ports`, `frontend/src/adapters` | `AgentGateway.sendPrompt(sessionId, prompt, onReply)`, `reattachChat`, `closeAgentSession` | Vitest : cellule `chat` rend `AgentChatView`, `terminal` rend `TerminalView` ; deltas s'accumulent → `final` fige le tour ; ré-attache repeint sans re-spawn ; mock gateway streame des `ReplyChunk`. | | **D6 (messagerie inter-agents)** | back | `OrchestratorService` route `AskAgent` via `send_blocking` (registre structuré) ; **retrait voie principale** de l'outbox/inbox/`AgentReplyChannel` ; `AgentReplied` (observabilité) conservé ; cible PTY ⇒ erreur typée. | `application/src/orchestrator/service.rs` | `OrchestratorService(+structured registry/messager)` | unit (fakes) : cible vivante ⇒ `send_blocking` ; cible morte ⇒ launch + send ; timeout → typé, cible vivante ; `AgentReplied` émis ; cible PTY non adressable ⇒ erreur explicite ; **plus aucun accès outbox**. | | **D7 (retrait custom + menu restreint)** | back+front | `is_selectable = factory.supports` filtre la liste exposée (first-run wizard, création/édition agent) ; retrait du profil **custom** ; Gemini/Aider non proposés (pas d'adapter). | `application/src/agent/catalogue.rs`, `app-tauri` (commande de liste de profils sélectionnables), `frontend` first-run + `features/agents` | prédicat `is_selectable` / liste filtrée exposée | unit : seuls Claude/Codex sélectionnables ; custom absent. Vitest : wizard et sélecteur n'affichent que Claude/Codex, bouton custom masqué. | **Ordre conseillé** : **D0 → D1 → D2 → D3 → D4 → D5 → D6 → D7**. D0 débloque tout ; D1/D2 sont parallélisables après D0 (D1 sur fakes, D2 sur fake CLI) ; D3 branche le routage ; D4/D5 livrent le chat (back puis front) ; D6 bascule la messagerie inter-agents sur le port ; D7 ferme le produit (menu restreint). Les terminaux **non-IA** restent verts à chaque lot (chemin PTY jamais modifié). ### 17.10 Spikes restants (n'invalident pas l'ossature) 1. **S1 — format exact du `stream-json` Claude** (`-p --output-format stream-json --input-format stream-json` vs Agent SDK) : schéma des messages `assistant`/`result`/`tool_use`, flag de reprise, propagation de l'`session_id`. Isolé dans `ClaudeSdkSession` (lot D2) ; le contrat `ReplyEvent` ne bouge pas. **À valider en priorité (réf. doc API Claude / Agent SDK).** 2. **S2 — mode structuré exact de Codex** (`codex exec` : process persistant vs `exec` par tour + `resume`, format JSON de fin de tour) : isolé dans `CodexExecSession` (lot D2). 3. **Backpressure du flux chat** (gros tours, throttling/coalescing côté front) : réutilise la mitigation PTY existante (§13.5) ; pas un bloqueur d'ossature. --- ## 13. Risques techniques & points ouverts (spikes) 1. **PTY cross-platform** : portable-pty + xterm.js OK sur les 3 OS, mais signaux/resize/exit codes diffèrent (Windows ConPTY). **Spike** L3. 2. **AppImage multi-distro** : libgit2/openssl/glibc liés dynamiquement → risque de non-portabilité. **Spike** : vendoring statique (`git2` features, `rustls` pour russh au lieu d'OpenSSL), test sur ≥3 distros (Ubuntu/Fedora/Arch). L11. 3. **Drag d'onglet entre fenêtres Tauri** : Tauri v2 multi-webview/multi-window + DnD natif inter-fenêtres est délicat (le DnD HTML ne traverse pas les fenêtres OS). **Spike** : protocole « detach » (créer une `WebviewWindow`, transférer l'état via store + event, fermer l'onglet source). L10. 4. **Git sur FS distant** : libgit2 ne lit pas un FS SSH/WSL directement. Décision : **fallback git CLI** (`RemoteGitRepository`) côté distant via `ProcessSpawner`. À valider (perf, parsing). L9. 5. **Synchro temps réel UI ↔ PTY** : volume d'octets élevé ; backpressure des Channels Tauri, throttling/coalescing côté front. **Spike** L3. 6. ~~**Injection `conventionFile`** : symlink vs copie du `.md` vers `CLAUDE.md`/`AGENTS.md` ; conflits si fichier existant, .gitignore, droits Windows (symlinks).~~ **Résolu (§14.1)** : cwd isolé par agent dans `.ideai/run//` — plus de conflit à la racine, convention file généré par copie simple. 7. **SSH auth** : agent/clé/mot de passe/known_hosts ; choix russh (rustls) vs ssh2 (libssh2/OpenSSL — impacte point 2). Décision à figer début L9. 8. **WSL chemins** : conversion `/mnt/c/...` ↔ `\\wsl$\...`, distros multiples, perf I/O cross-boundary. Spike L9. 9. **Détection d'édition hors-app** des `.md`/templates (content hash) et résolution de conflit lors du sync. L7. --- ## 18. État livré 2026-06-12 — cartographie des ports/adapters réels (conversation · mailbox · entrée médiée · FileGuard · transport MCP) > Section **descriptive** (pas un cadrage à faire) : elle fige la **réalité committée** (`eca2ba9`, base `cf89b3b`) pour que les lots suivants planifient depuis le code, pas depuis l'ancien texte. Tests verts ; validation e2e AppImage hors sujet archi. Les §15/16/17 antérieures restent la **genèse** ; en cas de divergence, **§18 fait foi** sur ces cinq modules. ### 18.1 Conversation par paire (domaine `conversation` + infra `conversation`) - **Port domaine** `domain::conversation::ConversationRegistry` (`crates/domain/src/conversation.rs`) : `resolve(a,b)` lazy get-or-create **par paire non ordonnée** (`resolve(a,b)==resolve(b,a)`), `bind_session`, `suspend(id, resumable_id)`, `get`. Value objects : `ConversationId` (UUID), `ConversationParty` (`User | Agent{agent_id}` — au plus **un** `User`, jamais `x↔x`), `ConversationSession` (`Dormant | Live{handle_ref:SessionRef}`), `Conversation{id,left,right,session,resumable_id}`. Pur (zéro I/O). - **`WaitForGraph`** (même fichier) : graphe wait-for **pur** pour la **prévention de cycle** inter-agents (`would_cycle(from,to)` sans mutation ; refuse self-wait + cycles transitifs). - **Adapter infra** `InMemoryConversationRegistry` (`crates/infrastructure/src/conversation/mod.rs`) : `HashMap` + index `pair_key` normalisé, `Mutex` **synchrone jamais tenu à travers un `.await`**. ### 18.2 Mailbox FIFO inter-agents (domaine `mailbox` + infra `mailbox`) - **Port domaine** `domain::mailbox::AgentMailbox` (`crates/domain/src/mailbox.rs`) : **une FIFO par agent cible**. `enqueue(agent,ticket) -> PendingReply` (future opaque que l'appelant `await`), `resolve(agent,result)` (corrélation **positionnelle** = tête de file), `resolve_ticket(agent,ticket_id,result)` (corrélation **par id** quand l'agent a plusieurs fils — défaut = repli sur la tête), `cancel_head(agent,ticket_id)` (retire la tête sur timeout). `Ticket{id,source:InputSource,conversation:ConversationId,requester,task}` (constructeurs `new`/`from_human`/`from_agent`). `MailboxError::{NoPendingRequest, Cancelled}`. - **Adapter infra** `InMemoryMailbox` (`crates/infrastructure/src/mailbox/mod.rs`) : `VecDeque` par agent + `tokio::sync::oneshot` par ticket ; `Mutex` synchrone, await **hors** du lock. ### 18.3 Entrée médiée (domaine `input` + infra `input`) - **Port domaine** `domain::input::InputMediator` (`crates/domain/src/input.rs`) : **point de convergence unique** de **toute** entrée d'un agent (humain **et** délégation) sur **une FIFO/agent**. `enqueue` (Envoyer, écrit aussi le tour dans le flux), `bind_handle`/`bind_handle_with_prompt` (arme la détection prompt-ready via `AgentProfile::prompt_ready_pattern`), `delivers_turn`, `preempt` (Interrompre ≠ Envoyer, ne corrèle aucun ticket), `mark_idle`, `busy_state`. Value objects : `InputSource` (`Human | Agent{agent_id}` — **source de vérité** du requester), `AgentBusyState` (`Idle | Busy{ticket,since_ms}`). - **Adapter infra** `MediatedInbox` (`crates/infrastructure/src/input/mod.rs`) : **compose** `InMemoryMailbox` (moteur de corrélation) + bookkeeping busy + `preempt` ; **ne crée pas** de 2ᵉ file. Publie `AgentBusyChanged` sur l'`EventBus`. - **Frontend** `MediatedInput.tsx` + `useAgentBusy.ts` (`frontend/src/features/terminals/`) : la zone de saisie **médiée** (Envoyer/Interrompre) au-dessus du terminal de sortie — **pas** un fil de chat. ### 18.4 FileGuard (domaine `fileguard` + infra `fileguard`) - **Port domaine** `domain::fileguard::FileGuard` (`crates/domain/src/fileguard.rs`, `#[async_trait]`) : lock **lecteurs/écrivain par ressource** sur l'ensemble **borné** `GuardedResource::{AgentContext(id), ProjectContext, Memory(slug)}`. `acquire_read`/`acquire_write` rendent des leases RAII (`ReadLease`/`WriteLease`, libèrent au drop). **`ProjectContext` = single-writer orchestrateur** (`GuardError::Forbidden` sinon ; politique pure `may_write_directly`/`is_orchestrator`, l'orchestrateur = `ConversationParty::User`). `GuardError::{Busy, Forbidden}`. - **Portée coopérative** (cadrage §9.5) : corrige les collisions **dans le chemin IdeA** (MCP + UI) ; un agent gardant un shell brut peut contourner — l'étanchéité réelle est un sujet **sandbox OS (Landlock)**, hors périmètre. - **Adapter infra** `RwFileGuard` (`crates/infrastructure/src/fileguard/mod.rs`) : un `tokio::sync::RwLock` par ressource (lazy + `Arc`), registre derrière `Mutex` synchrone, garde `'static` boxée dans les leases. ### 18.5 Transport MCP natif M5 (infra `orchestrator/mcp` + app-tauri) - **Vivant de bout en bout** : `apply_mcp_config` matérialise `.mcp.json` dans le **run dir isolé** de l'agent **AVANT** le split structuré/PTY (`crates/application/src/agent/lifecycle.rs` ~1094) ⇒ Claude/Codex le lisent **nativement**. La déclaration porte l'**exe réel** injecté par `McpRuntime` (`$APPIMAGE` sinon `current_exe`, `crates/app-tauri/src/mcp_endpoint.rs:139` `idea_exe_path`), l'**endpoint loopback** du projet, le `--project` et le `--requester` (agent réel, fin du `"mcp"` figé). - **Endpoint loopback** = **UDS** (Linux/macOS) / **named pipe** (Windows), **zéro port réseau** ; source de vérité unique `mcp_endpoint(project_id)` (`mcp_endpoint.rs`), bindé à l'open / fermé au close (`crates/app-tauri/src/state.rs` `ensure_mcp_server`/`bind_endpoint`). **Fix D1** : cadavre `.sock` (run SIGKILL) **unlinké avant bind** (`state.rs` ~1019-1033) — sinon `EADDRINUSE`. - **Serveur** `McpServer` (`crates/infrastructure/src/orchestrator/mcp/server.rs`) = **jumeau du `FsOrchestratorWatcher`** : autre porte sur le **même** `OrchestratorService::dispatch`, `serve(conn)` **par pair**. Transport `StdioTransport` (JSON Lines stdin/stdout du pont) + `MemoryTransport` (tests, sans socket ni process). Pont = sous-commande `mcp-server` du binaire app-tauri (`mcp_bridge.rs`). - **Outils** (`mcp/tools.rs`) — **catalogue de 14**, tous mappés 1:1 vers `OrchestratorCommand` et servis par le **même** `dispatch` (les trois portes : fichier, MCP, UI) : `idea_list_agents`, `idea_ask_agent`, `idea_reply`, `idea_launch_agent`, `idea_stop_agent`, `idea_update_context`, `idea_create_skill` (7 base) · `idea_context_read`, `idea_context_propose`, `idea_memory_read`, `idea_memory_write` (4 FileGuard C7) · `idea_skill_read` (skill-awareness) · `idea_workstate_read`, `idea_workstate_set` (LS4 ; `set` n'écrit que la ligne de l'agent courant, via l'identité handshake). ### 18.6 Invariant « 1 agent = 1 session vivante » (livré) - Registres `TerminalSessions` + `StructuredSessions` (`crates/application/src/terminal/registry.rs`) agrégés en `LiveSessions` (PTY+structuré). `session_for_agent` (singulier, **non ambigu**) **+** `sessions_for_agent` (pluriel). Garde reattach `Rebind`/`Refuse`/`Idempotent` dans `LaunchAgent` (`lifecycle.rs`). Ancienne ambiguïté `session-registry-agent-ambiguity` = **fermée par construction**. --- ## 19. Persistance conversationnelle + handoff cross-profile incrémental (cadrage — LIVRÉ P1→P8 + LS1→LS7) > **⚠️ STATUT : LIVRÉ (clôture programme live-state/persistance, @ `fd7adbb`).** Ce qui suit était le **cadrage** ; il est désormais **implémenté** (P1→P8 du handoff, puis LS1→LS7 : live-state, borne `summary_md`, rotation/pagination, viewer). La **cartographie de clôture** (4 stores, flux d'injection, chemin chaud vs froid, ce qui reste ouvert) fait foi en **§21** et dans [`docs/LS8-live-state-persistence-closure.md`](docs/LS8-live-state-persistence-closure.md). Précisions sur l'état réel : (1) **P10 (`LlmHandoffSummarizer`)** reste **non activé** — le défaut runtime est l'heuristique borné (LS5, ADR `docs/adr/LS5-handoff-summary-bound-and-llm-seam.md`) ; (2) la **borne `summary_md`** (LS5 : `HANDOFF_SUMMARY_MAX_CHARS=4096`, `bound_handoff_summary`, `TURN_LINE_MAX_CHARS=240`) est appliquée à l'écriture **et** à l'injection ; (3) la **rotation/rétention** du `log.jsonl` (LS6 : archive segmentée, port `ConversationArchive`, lecture paginée, invariant INV-LS6) et le **live-state** (LS1→LS4) ont été ajoutés **au-delà** de ce cadrage initial. Le texte ci-dessous est conservé comme **genèse** ; en cas de divergence, **§21 + `docs/LS8` font foi**. ### 19.0 Problème & objectif produit Aujourd'hui la continuité d'une conversation repose sur le **`resumable_id` CLI** (`Conversation.resumable_id`, profil `SessionStrategy{assign_flag,resume_flag}`) : au redémarrage on **rejoue la session du provider** (`--resume `). Deux trous : 1. **Reprise non garantie / non portable** : si le `resumable_id` est perdu (provider qui ne reprend pas, run nettoyé) l'agent **ne sait plus sur quoi il travaillait**. 2. **Handoff cross-profile impossible** : un swap **Claude→Codex** (chantier §15.1) **kill+relance** ; le `resumable_id` Claude **n'a aucun sens** pour Codex ⇒ le nouveau profil **repart de zéro**. **Objectif** : au redémarrage **et** au swap de profil, le nouvel agent **reprend fidèlement le travail utile** (fidélité **opérationnelle** > illusion de continuité terminale). Pour cela, IdeA tient une **mémoire de conversation propre, indépendante du provider**, en deux couches : un **log canonique** (source durable, par paire) + un **résumé/handoff cumulatif incrémental** (couche compacte de reprise, maintenue **aux checkpoints**, pas seulement au moment du swap). ### 19.1 Décisions tranchées (frontières & invariants) - **D19-1 — Deux couches, pas une.** (a) **Log canonique** = append-only, fidèle, par **conversation** (paire) : source de vérité durable. (b) **Handoff cumulatif** = vue compacte dérivée, **réécrite incrémentalement** à chaque checkpoint (≠ recalcul intégral) : c'est ce qu'on **injecte** au (re)lancement. - **D19-2 — Trois mémoires disjointes.** Cette couche est **distincte** de (i) la **mémoire durable** `.ideai/memory/` (savoir stable, low-noise, §14.5) et (ii) la **mémoire vivante / live-state** (busy, sessions en cours). Le **log de conversation** est **volumineux & bruité** par nature : il ne **pollue jamais** `memory/`. Frontière nette : `memory/` = *ce que le projet sait* ; `conversations/` = *ce qui a été dit dans un fil* ; live-state = *ce qui tourne maintenant*. - **D19-3 — Provider-agnostique.** Le log et le handoff sont en **format IdeA** (Claude/Codex génériques) ; les `resumable_id` par provider sont **rangés à côté** (un par provider), jamais l'unique support de reprise. Un swap réutilise **le handoff**, pas le `resumable_id` de l'ancien provider. - **D19-4 — Stockage sous `.ideai/`, hors git.** Arborescence cible : ``` .ideai/conversations// log.jsonl # log canonique append-only (un ReplyTurn/ligne) handoff.md # résumé cumulatif incrémental (réinjecté au relancement) providers.json # { "claude": "", "codex": "", ... } ``` **Gitignoré** (`.ideai/conversations/` ajouté au `.gitignore` géré par IdeA) : c'est de l'**état d'exécution**, pas une source versionnable ; cohérent avec « zéro dépendance git ». (À l'inverse de `.ideai/memory/` qui, lui, **peut** être versionné — savoir projet.) - **D19-5 — Checkpoint = fin de tour.** Le point d'écriture canonique est la **fin d'un tour** d'agent (un `result`/`final` structuré, OU prompt-ready pour un PTY) — exactement le signal qui fait déjà passer `AgentBusyState`→`Idle` (§18.3). On **réutilise ce signal**, on n'en invente pas. - **D19-6 — Résumé incrémental = port, pas un LLM imposé.** Comprimer le log en `handoff.md` est une **stratégie** derrière un port (`HandoffSummarizer`). Adapter **défaut zéro-dépendance** = troncature/heuristique structurée (derniers N tours + objectif courant), **sans appel modèle** ; un adapter LLM optionnel viendra plus tard (profil déclaratif façon CLI, comme l'embedder §memory-system-design). On **ne bloque pas** la persistance sur la qualité du résumé. ### 19.2 Ports (frontière domaine, purs) — `crates/domain/src/conversation_log.rs` (nouveau) - `ConversationTurn` (value object) : `{ id: TurnId, conversation: ConversationId, at_ms: u64, source: InputSource, role: TurnRole, text: String }` où `TurnRole = Prompt | Response | ToolActivity`. Pur, sérialisable. - **`ConversationLog`** (port driven) : - `append(conversation, turn)` — ajoute un tour au log canonique. - `read(conversation, since: Option) -> Vec` — relecture (reprise, recalcul handoff). - `last(conversation, n) -> Vec` — les N derniers (résumé incrémental). - **`HandoffStore`** (port driven) : - `load(conversation) -> Option` / `save(conversation, handoff)` où `Handoff = { summary_md: String, up_to: TurnId, objective: Option }`. - **`HandoffSummarizer`** (port driving-policy, pur ou délégant) : - `fold(prev: Option, new_turns: &[ConversationTurn]) -> Handoff` — **incrémental** : part du handoff précédent + seulement les tours neufs. (Défaut heuristique ; adapter LLM optionnel.) - **`ProviderSessionStore`** (port driven) : `get/set(conversation, provider_id) -> Option` — range les `resumable_id` **par provider** (remplace le `resumable_id` unique porté par `Conversation` comme support exclusif ; `Conversation.resumable_id` peut rester en cache du provider courant). ### 19.3 Adapters infra — `crates/infrastructure/src/conversation_log/` (nouveau) - `FsConversationLog` : `log.jsonl` append-only (un `ConversationTurn` JSON/ligne), lecture en stream. I/O `tokio::fs`, écriture sérialisée par conversation (réutiliser la discipline FileGuard si le fichier devient une `GuardedResource` — cf. 19.6). - `FsHandoffStore` : `handoff.md` (+ entête front-matter `up_to`/`objective`) read/write atomique (write tmp+rename). - `FsProviderSessionStore` : `providers.json` map provider→id. - `HeuristicHandoffSummarizer` : `fold` = derniers N tours + objectif courant, **sans modèle** (défaut). (`LlmHandoffSummarizer` = lot ultérieur, hors ce chantier.) ### 19.4 Câblage application - **Au checkpoint (fin de tour)** : le chemin qui fait déjà `mark_idle`/publie le `result` (orchestrator/`MediatedInbox`/`launch_structured`) appelle `ConversationLog::append`, puis — **debouncé**/aux checkpoints — `HandoffSummarizer::fold` + `HandoffStore::save`. **Une seule** dépendance ajoutée à l'orchestrateur (les trois ports via `Arc`), zéro logique dupliquée. - **À la reprise** (`ListResumableAgents`/`LaunchAgent`, §15.2) : si un `providers.json[provider_courant]` existe ⇒ `--resume`. **En plus** (et **toujours**, même sans resumable) : injecter `handoff.md` dans le contexte du run (au même endroit que le convention file / le seed permissions, run dir isolé) ⇒ l'agent **sait sur quoi il travaillait** indépendamment du provider. - **Au swap de profil** (§15.1, Claude→Codex) : kill+relance **réutilise `handoff.md`** comme amorce du nouveau provider ; on **n'injecte pas** l'ancien `resumable_id`. La fidélité vient du handoff, pas de la session CLI. ### 19.5 Conformité hexagonale & SOLID - Domaine **pur** (`conversation_log.rs` : value objects + 4 ports, zéro `tokio`/`fs`). Adapters infra isolés. L'orchestrateur dépend de **traits**, jamais de fichiers. `HandoffSummarizer` = **OCP** (heuristique ↔ LLM interchangeables). Frontière franche avec `memory/` (D19-2) et live-state. ### 19.6 Découpage en LOTS testables (cycle §3) — ordonné | Lot | Côté | Objectif | Ports/types | Fichiers cibles (approx.) | Critères de test | Dépend de | |---|---|---|---|---|---|---| | **P1** | domaine | Value objects + port `ConversationLog` | `ConversationTurn`, `TurnId`, `TurnRole`, `ConversationLog` | `crates/domain/src/conversation_log.rs`, `lib.rs` | append→read ordonné ; `since`/`last(n)` corrects ; sérialisation round-trip ; **pur** (compile sans tokio) | — | | **P2** | infra | `FsConversationLog` (jsonl append-only) | impl `ConversationLog` | `crates/infrastructure/src/conversation_log/mod.rs`, `lib.rs` | append persiste 1 ligne/tour ; relecture après « redémarrage » (réouverture fichier) ; conversations disjointes ⇒ fichiers disjoints ; ligne corrompue ⇒ skip, jamais panic | P1 | | **P3** | domaine+infra | `HandoffStore` + `FsHandoffStore` | `Handoff`, `HandoffStore` | `conversation_log.rs`, `infrastructure/src/conversation_log/handoff.rs` | save→load round-trip ; `up_to` conservé ; write atomique (tmp+rename) ; absent ⇒ `None` | P1 | | **P4** | domaine+infra | `HandoffSummarizer` heuristique **incrémental** | `HandoffSummarizer`, `HeuristicHandoffSummarizer` | `conversation_log.rs`, `infrastructure/src/conversation_log/summarizer.rs` | `fold(None, turns)` = base ; `fold(prev, neufs)` n'inclut que l'incrément ; borne N respectée ; **zéro I/O / zéro modèle** | P1, P3 | | **P5** | domaine+infra | `ProviderSessionStore` + `FsProviderSessionStore` | `ProviderSessionStore` | `conversation_log.rs`, `infrastructure/src/conversation_log/providers.rs` | get/set par provider ; providers multiples coexistent ; absent ⇒ `None` ; round-trip disque | P1 | | **P6** | application | Câblage **checkpoint** : append + fold+save aux fins de tour | (réutilise P1–P4) | `crates/application/src/agent/lifecycle.rs`, `orchestrator/service.rs`, `input/` | un tour terminé ⇒ 1 append + handoff réécrit ; debounce (pas N writes/delta) ; profil sans persistance ⇒ no-op (zéro régression) | P2, P3, P4 | | **P7** | application | Câblage **reprise** : injecter `handoff.md` au (re)lancement + `--resume` si `providers.json` présent | (réutilise P3, P5) ; `ListResumableAgents`, `LaunchAgent` | `application/src/agent/{resume,lifecycle}.rs` | resumable présent ⇒ `--resume` + handoff injecté ; resumable absent ⇒ handoff seul injecté ; aucun handoff ⇒ chemin actuel inchangé | P3, P5, P6 | | **P8a** | domaine+app | **Corrige la clé P6/P7** : la cellule porte l'**id de paire IdeA** (pivot logique), distinct de l'id moteur. `LeafCell` gagne un 2e champ `engine_session_id` (resumable provider courant, cache) ; `conversation_id` redevient/reste l'id de **paire**. `launch_structured` persiste l'id de paire sur la cellule (plus l'id moteur) ; l'id moteur part dans `providers.json` (P8b). | `LeafCell` (+`engine_session_id`, wither additif), `LaunchAgentOutput`, `launch_structured`, `resolve_handoff` (déjà OK une fois la clé corrigée) | `domain/src/layout.rs`, `application/src/agent/lifecycle.rs`, `app-tauri/src/dto.rs` | handoff sauvé sous (paire) **retrouvé** au relancement sous la même clé ; `assigned_conversation_id` = id de paire ; round-trip layout du nouveau champ ; defaults `None` ⇒ zéro régression A/B | P7 | | **P8b** | application | **Écriture `providers.json`** : quand une session structurée expose/assigne son id moteur (`session.conversation_id()`), appeler `ProviderSessionStore::set(paire, provider_id, resumable)`. Câblage **provider-pattern** (root par appel) comme P6b/P7. | `ProviderSessionStore` (P5) ; provider `ProviderSessionStore` sur `LaunchAgent` (wither additif) | `application/src/agent/lifecycle.rs`, `app-tauri` (câblage) | id moteur exposé ⇒ `providers.json[provider]` écrit sous la paire ; absent ⇒ no-op ; multi-providers coexistent | P8a, P5 | | **P8c** | application | **Routage `--resume` via `providers.json`** : `resolve_session_plan` consulte `ProviderSessionStore::get(paire, provider_courant)` pour le resumable — **plus** l'id de paire. Présent ⇒ `Resume{resumable}` ; absent ⇒ `None`/`Assign` (le handoff P7 porte la fidélité). | `resolve_session_plan` (devient `async` ou pré-résout le resumable en amont) ; `ProviderSessionStore` | `application/src/agent/lifecycle.rs` | resumable présent pour le provider courant ⇒ `--resume` avec **son** id ; provider sans resumable (post-swap) ⇒ pas de `--resume`, handoff seul ; id de paire **jamais** passé en `--resume` | P8a, P8b | | **P8d** | application | **Swap cross-profile (P8 proprement dit)** : `ChangeAgentProfile` **préserve** l'id de paire de la cellule (ne plus le `clear`), efface **seulement** le lien provider (id moteur), ne passe **pas** l'ancien resumable au nouveau moteur ; la fidélité vient du handoff (déjà injecté par P7). | `ChangeAgentProfile::execute`/`clean_conversation`, `relaunch_if_live` | `application/src/agent/lifecycle.rs` | swap Claude→Codex ⇒ id de paire **conservé** sur la cellule, handoff injecté au nouveau profil, ancien resumable **non** passé ; nouveau provider écrit son **propre** `providers.json[codex]` (P8b) | P8a, P8c | | **P9** *(opt.)* | infra/app | Router le log/handoff sous FileGuard si concurrence d'écriture réelle | `GuardedResource` étendu (ou wrapper) | `domain/src/fileguard.rs`, `infrastructure/src/conversation_log/` | écritures concurrentes même conversation sérialisées ; pas de corruption ; conversations différentes parallèles | P2, P3 | | **P10** *(opt., ultérieur)* | infra | `LlmHandoffSummarizer` (profil déclaratif) | impl `HandoffSummarizer` | `infrastructure/src/conversation_log/summarizer_llm.rs` | substituable à P4 sans toucher l'app (OCP) ; défaut reste l'heuristique | P4 | **Ordre** : **P1→P2→P3→P4→P5** (briques, parallélisables après P1) **→ P6 → P7 → P8a → P8b → P8c → P8d**, puis **P9/P10** optionnels. P6 est le **pivot** (relie le checkpoint existant aux briques) ; **P8a est prioritaire et bloquant** : il corrige l'incohérence de clé P6/P7 (sans lui, la reprise ne retrouve jamais le handoff — cf. §19.7). P8b/P8c branchent le resumable provider ; P8d livre le swap cross-profile. P9/P10 durcissent/enrichissent sans bloquer. ### 19.7 Cohérence des ids — id de paire IdeA vs resumable provider (corrige P6/P7) **Problème.** Deux notions de « conversation id » coexistaient et étaient confondues sur la cellule : 1. **Id de paire IdeA** — déterministe via `OrchestratorService::resolve_conversation(requester, target)` (depuis les `ConversationParty`). C'est la clé sous laquelle **P6b** range le **log canonique** et le **handoff** (`.ideai/conversations//`). Stable, **indépendante du provider**. 2. **Id de session moteur** (resumable Claude/Codex) — exposé par `AgentSession::conversation_id()`, utilisé pour le `--resume` du provider. Propre au moteur, **change** à chaque provider. Avant correction, `launch_structured` persistait l'**id moteur (2)** sur `LeafCell.conversation_id`, alors que P6 sauvait sous l'**id de paire (1)** ⇒ `resolve_handoff` (P7) cherchait le handoff sous (2) et ne le retrouvait **jamais**. Et `ChangeAgentProfile` **effaçait** ce `conversation_id` au swap (id moteur étranger au nouveau moteur), perdant aussi le lien handoff. **Décision (conforme D19-3).** Séparer franchement les deux ids : - **La cellule (`LeafCell`) porte l'id de paire IdeA** comme `conversation_id` — clé **logique** unique de la conversation. C'est lui qui retrouve **log + handoff** au (re)lancement (P7) et **survit** au swap de profil. Le resumable moteur **ne s'écrit plus** sur la cellule. - **L'id de session moteur (resumable) vit séparément**, par provider, dans `providers.json` via `ProviderSessionStore` (P5), clé `(paire, provider_id)`. C'est **lui** — et **jamais** l'id de paire — que `resolve_session_plan` consulte pour le `--resume` du **provider courant**. - **Impact `LeafCell`** : un **2e champ optionnel** `engine_session_id: Option` (cache du resumable du provider courant, additif, default `None`) peut être porté pour l'inspection/popup ; la **source de vérité** du resumable reste `providers.json`. `conversation_id` reste/redevient l'**id de paire**. Les withers sont additifs (`set_cell_conversation` inchangé, nouveau `set_cell_engine_session`), donc la persistance des layouts, `SnapshotRunningAgents` et `ListResumableAgents`/popup restent compatibles (lecture du nouveau champ optionnelle, default `None`). **Acheminement de l'id de paire jusqu'au lancement.** Mécanisme le moins invasif retenu : **la cellule stocke l'id de paire** et le lancement « normal » le lit depuis `LeafCell.conversation_id` (chemin déjà en place : `launch_agent` reçoit `conversation_id` depuis la feuille). Première matérialisation = `resolve_session_plan` branche `Assign{paire}` (UUID minté côté IdeA) sur cellule vierge, **ou** l'orchestrateur fournit l'id de paire calculé par `resolve_conversation` lors d'un `ask`. On **ne** résout **pas** l'id de paire à l'intérieur de `LaunchAgent` à partir de l'agent+interlocuteur (couplage évité) : l'id voyage **comme donnée** sur la cellule / `LaunchAgentInput`, exactement comme aujourd'hui — seul son **contenu** (paire, plus moteur) est corrigé. **Écriture `providers.json` (P8b).** Au moment où `launch_structured` capte `session.conversation_id()` (id moteur assigné/exposé), il appelle `ProviderSessionStore::set(paire, provider_id, resumable)`. Câblage **provider-pattern** (root résolu par appel) comme P6b/P7 ; absence d'id moteur ⇒ no-op. **Swap cross-profile (P8d).** `ChangeAgentProfile` : **préserver** l'id de paire de la cellule (ne plus le `clear` dans `clean_conversation`) ; n'effacer que le **lien provider** (le cache `engine_session_id` de la cellule, l'ancien resumable n'étant pas passé au nouveau moteur). La fidélité vient du **handoff** (injecté par P7 une fois la clé corrigée), pas de la session CLI. Ce qui était « cleared » (l'id de conversation entier) devient « seul le resumable provider est invalidé ». > **Renvoi §15/§17** : le `LeafCell.conversation_id` mentionné en §15.2/§17.4 comme « pivot de reprise » désigne désormais explicitement l'**id de paire IdeA** (pas l'id moteur). Le resumable provider est rangé dans `providers.json` (§19.7), consulté par `resolve_session_plan` pour le `--resume`. --- ## 20. Terminal natif + portail d'écriture unique (cadrage — remplace la barre `MediatedInput`) > **Cadrage architecture** d'une feature **déjà décidée** (cf. décision produit « terminal natif »). Produit la frontière, les contrats (ports/events/DTO/profil), le découpage en lots testables et les risques. **Aucun code applicatif ici.** ### 20.1 Problème & décision produit (rappel, ne pas rediscuter) - **Bug.** En mode agent, l'humain tape dans une barre IdeA séparée (`MediatedInput.tsx`) ; la livraison d'une délégation écrit dans le PTY un texte terminé par `\n` (`MediatedInbox::enqueue`, `infrastructure/src/input/mod.rs:307-311`). Résultat : le texte se dépose dans le prompt de la TUI mais **n'est jamais soumis** (`\n` ≠ Entrée en raw-mode ; la détection de paste absorbe le `\n`). De plus barre IdeA + prompt natif = « double chat ». - **Décision.** La cellule agent héberge la CLI comme **vrai terminal** : **toutes** les frappes (Entrée comprise) vont à la CLI ; on garde le chrome natif. On **supprime** `MediatedInput`. **Pas d'interception d'Entrée** (ambiguë en TUI). IdeA n'**observe** qu'un compteur « ligne humaine en cours » (+1 sur imprimable, reset sur Entrée/Ctrl-C). **Sérialisation par un portail d'écriture unique** vers le PTY : deux écrivains (frappes humaines natives + médiateur IdeA pour les délégations). Une délégation n'est livrée qu'à une **frontière propre = prompt-ready ET ligne humaine vide** ; sinon elle **patiente** (jamais de refus). Invariant inchangé : **1 agent = 1 employé = 1 session CLI** (la « conversation par paire » reste un cloisonnement **logique**, pas des sessions séparées). ### 20.2 Décision d'architecture — où vit le portail, qui écrit **Le portail d'écriture unique vit côté FRONTEND** (le détenteur du terminal). Le backend **décide quand** une délégation est prête et **publie l'intention** ; le frontend, seul détenteur de xterm + des frappes + du compteur de ligne + de l'overlay, **exécute le handshake** (b→e) et **écrit le texte + `\r`** via la **même** `TerminalHandle.write` que les frappes humaines. Ainsi il n'existe **qu'un seul écrivain effectif du PTY** (le front) : pas de course entre deux écrivains physiques, le portail est un mutex **logique** dans la cellule. Le backend conserve son rôle d'**autorité de file/busy** (mailbox FIFO, prompt-ready watcher, `AgentBusyChanged`) mais **n'écrit plus le tour dans le PTY** — c'est le point dur tranché. Justification hexagonale : la frontière reste nette. L'**application** (`OrchestratorService`) reste l'autorité métier de l'orchestration (file, corrélation par ticket, cycle, timeout) ; elle parle **ports** (`InputMediator`, `EventBus`) sans connaître le terminal. La **livraison physique** (octets vers le PTY) est un **détail d'I/O** qui appartient à l'adapter sortant — ici l'adapter **frontend** (la cellule), exactement comme les frappes humaines y vivent déjà. On **retire** au `MediatedInbox` la responsabilité d'écrire le PTY (violation SRP : il était à la fois moteur de file ET écrivain d'I/O brut) ; il redevient pur moteur de file/busy/corrélation. Le « double signal OR » prompt-ready/`idea_reply` et le `wait_for`/timeout restent **inchangés**. ``` ask_agent / idea_ask_agent (MCP) idea_reply (MCP) │ │ ▼ ▼ ┌──────────────────────────── OrchestratorService (application) ─────────────┐ │ enqueue(ticket) → mailbox FIFO (corrélation) resolve_ticket → réveille ask │ │ busy: Idle→Busy → AgentBusyChanged mark_idle (OR signal) │ │ PLUS: publie DelegationReady{agent, ticket, text} ◄── NOUVEAU (ne PTY-écrit │ └───────────────────────────────┬─────────────────────────────── plus le tour)┘ │ EventBus → relais Tauri (event) ▼ ┌──────────────────────── Cellule agent (frontend, détient le terminal) ───────┐ │ TerminalView (agent natif): term.onData → handle.write [frappes humaines] │ │ writePortal (mutex logique de la cellule): │ │ • frappes humaines: write direct + maj compteur ligne (imprimable / reset) │ │ • DelegationReady reçue → si prompt-ready & ligne vide → HANDSHAKE b→e: │ │ (b) couper le relais frappes + overlay grisé « un agent parle » │ │ (c) revérif compteur K ; si K>0 → \x7f ×K (backspaces) │ │ (d) write(texte) puis write(submitSequence) après submitDelayMs │ │ (e) réactiver + retirer overlay, PLANCHER 2 s depuis (b) │ │ • sinon (busy / ligne non vide) → la délégation PATIENTE (file native CLI │ │ empile ; la prochaine frontière propre la libère) │ │ ack: input.deliveredDelegation(ticket) ── confirme la livraison au backend │ └──────────────────────────────────────────────────────────────────────────────┘ ``` **Pourquoi pas le backend ?** Le backend ne connaît ni l'état « ligne humaine en cours » (détenu par xterm côté front) ni l'overlay. Lui faire écrire le PTY impose un round-trip fragile (front→back « ligne vide ? », back→front « j'écris ») et **réintroduit deux écrivains physiques** du même PTY (le back via `PtyPort.write` + le front via `handle.write`) → exactement la classe de course que `terminal-input-accents-ordering` a déjà coûtée. Centraliser l'écriture côté front supprime la race par construction. ### 20.3 Contrats **Profil (`crates/domain/src/profile.rs`) — fix Bug 1, déclaratif & model-agnostic.** Deux champs additifs sur `AgentProfile`, à côté de `prompt_ready_pattern`, sérialisés camelCase, `skip_serializing_if` ⇒ zéro régression : ```rust /// Séquence de soumission écrite APRÈS le texte d'une délégation pour la /// faire valider par la CLI (esquive la détection de paste : texte sans `\n`, /// puis cette séquence seule après un court délai). Défaut `"\r"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub submit_sequence: Option, // None ⇒ défaut "\r" appliqué au point d'usage /// Délai (ms) entre l'écriture du texte et celle de `submit_sequence`. /// Défaut ~50–80 ms. Évite que la TUI absorbe la soumission comme un paste. #[serde(default, skip_serializing_if = "Option::is_none")] pub submit_delay_ms: Option, // None ⇒ défaut (p.ex. 60) au point d'usage ``` Withers additifs `with_submit_sequence`/`with_submit_delay_ms` (comme `with_prompt_ready_pattern`). Le **défaut** (`"\r"`, ~60 ms) est appliqué côté front (DTO `Option` → valeur effective), jamais codé en dur dans le domaine. **Port domaine `InputMediator` (`crates/domain/src/input.rs`) — recentrage.** On **retire la sémantique d'écriture PTY** de `enqueue`/`bind_handle*` (la doc de `enqueue` ne promet plus la livraison physique) ; le médiateur reste autorité **file + busy + corrélation + prompt-watcher**. Pas de nouvelle méthode obligatoire : la livraison passe désormais par un **event** (ci-dessous). `bind_handle_with_prompt` **reste** (le prompt-ready watcher observe toujours le flux de sortie côté infra). `delivers_turn` devient inutile (toujours « le front délivre ») → marqué déprécié/`false`. **Adapter infra `MediatedInbox` (`crates/infrastructure/src/input/mod.rs`).** `enqueue` **ne fait plus** le `pty.write(line)` (suppression du bloc 305-313, donc du `\n` band-aid). À la place, sur la transition qui **démarre le tour** (Idle→Busy) il publie un **nouvel event** `DelegationReady` portant le **texte de la tâche** + ticket + agent (le `BusyTracker`/`enqueue` a déjà l'`EventBus`). Le `preempt` (ESC) **reste** (interruption = octet de contrôle, légitime côté back ; il ne concourt pas avec une écriture de ligne). Le prompt-ready watcher **reste** identique. **Nouvel event domaine `DomainEvent` (`crates/domain/src/events.rs`).** ```rust /// Une délégation est prête à être injectée dans le terminal natif de l'agent /// (le front exécute le handshake b→e et écrit texte + submit_sequence). Le /// backend reste l'autorité de file/busy ; il NE PTY-écrit PLUS le tour. DelegationReady { agent_id: AgentId, ticket: TicketId, text: String, /// Profil de la cible : la cellule applique submit_sequence/submit_delay_ms. submit_sequence: Option, submit_delay_ms: Option, }, ``` Relayé au front comme les autres `DomainEvent` (mapping DTO camelCase déjà en place). Discret, basse fréquence (1/délégation). **Commande Tauri (ack de livraison) — `crates/app-tauri/src/commands.rs` + `dto.rs`.** Le front confirme qu'il a **effectivement écrit** la délégation (clôt la boucle « le tour est parti »), pour distinguer « en file car ligne occupée » d'« écrit » côté observabilité/persistance : ```rust // DTO #[serde(rename_all = "camelCase")] pub struct DeliveredDelegationRequestDto { pub project_id: String, pub agent_id: String, pub ticket: String } // commande #[tauri::command] pub async fn delegation_delivered(request: DeliveredDelegationRequestDto, state: …) -> Result<(), ErrorDto> ``` Mappé vers une méthode applicative best-effort (`OrchestratorService::note_delegation_delivered`) — **ne change pas** la corrélation (le réveil de l'`ask` reste `idea_reply`→`resolve_ticket`) ; sert l'observabilité/log. Les commandes existantes `submit_agent_input`/`reattach_agent_chat` deviennent **mortes** pour les agents (retirées en L5) ; `interrupt_agent` **reste** (Échap → `preempt`), mais l'UI l'invoque désormais depuis le terminal (raccourci), plus depuis la barre. **Ports/adapter frontend (`frontend/src/ports/index.ts`, `adapters/input.ts`).** `InputGateway` perd `submit` (plus de barre) et **gagne** : ```ts export interface InputGateway { /** Interrompre = preempt (Échap/stop). Reste. */ interrupt(projectId: string, agentId: string): Promise; /** Ack : la cellule a écrit la délégation `ticket` dans le PTY natif. */ delegationDelivered(projectId: string, agentId: string, ticket: string): Promise; } ``` Un nouveau port d'**abonnement** aux `DelegationReady` n'est **pas** nécessaire : `SystemGateway.onDomainEvent` les relaie déjà (filtrer `event.type === "delegationReady"`), à l'image de `useAgentBusy`. Côté domaine front, ajouter la variante `DelegationReady` au type `DomainEvent`. **Frontend — le portail.** Un hook `useWritePortal(handle, agentId)` détient : (1) le **compteur de ligne** (incrément/`reset` branchés sur `term.onData` dans `TerminalView`), (2) la **file locale** des `DelegationReady` reçues, (3) l'exécution du **handshake** (b→e) avec **plancher 2 s** et **revérif K + backspaces**, (4) l'**overlay** (état booléen rendu par la cellule). `TerminalView` (agent) écrit les frappes **et** notifie le portail (imprimable/Entrée/Ctrl-C) ; quand le portail injecte, il **coupe** le relais des frappes (flag `suspended`) et écrit via `handle.write`. ### 20.4 Comment le backend connaît « ligne humaine vide » — il ne la connaît PAS La décision frontière l'évite : **la frontière propre est jugée côté front** (seul détenteur du compteur). Le backend publie `DelegationReady` **dès** que le tour démarre ; c'est le **front** qui retient l'injection jusqu'à `prompt-ready ET ligne vide`. Le « prompt-ready » est connu des **deux** : le watcher backend le détecte sur le flux de sortie pour le **busy** ; le front peut soit ré-utiliser un signal (un futur `AgentPromptReady` event, optionnel) soit, plus simplement, considérer la **ligne vide** comme condition front suffisante et s'appuyer sur le fait que la CLI **empile** nativement les soumissions (robustesse : même injectée « tôt », la CLI met en file). **Choix retenu (sobre)** : le front conditionne sur **ligne humaine vide** uniquement ; la nativité de la CLI gère le reste ; aucun round-trip. Si l'expérience montre des injections trop précoces, on ajoute l'event `AgentPromptReady` (additif, sans changer le portail). Aucune des deux variantes ne crée deux écrivains. ### 20.5 Découpage en lots (dev/test séquencés) | Lot | Couche | Contenu | Fichiers | Testable (vert) | |---|---|---|---|---| | **L1** | domaine+infra | Profil `submit_sequence`/`submit_delay_ms` (+ withers) ; `MediatedInbox::enqueue` **cesse** d'écrire le PTY (suppr. bloc `\n`) et **publie** `DelegationReady` ; `DomainEvent::DelegationReady` | `domain/src/profile.rs`, `domain/src/events.rs`, `infrastructure/src/input/mod.rs` | round-trip serde (clés omises si `None`, legacy→`None`) ; `enqueue` ne fait **aucun** `pty.write` ; publie 1 `DelegationReady{text,ticket}` sur Idle→Busy, **0** sur 2ᵉ enqueue busy ; `preempt` inchangé ; prompt-watcher tests intacts | | **L2** | app+app-tauri | `OrchestratorService` : `ask_agent`/`submit_human_input` n'attendent plus du médiateur l'écriture (déjà le cas) ; ajout `note_delegation_delivered` ; commande `delegation_delivered` + DTO ; relais `DelegationReady` au front | `application/src/orchestrator/service.rs`, `app-tauri/src/commands.rs`, `dto.rs`, `lib.rs` (register) | `ask_agent` toujours réveillé par `idea_reply` (timeout/cycle inchangés) ; `delegation_delivered` best-effort (no-op si non câblé) ne casse pas la corrélation ; event mappé camelCase `delegationReady` | | **L3** | frontend | `TerminalView` agent **natif** : frappes → PTY **inconditionnellement** (retrait du drop `agentMode`) ; compteur de ligne (imprimable +1 / Entrée|Ctrl-C reset) exposé au portail ; `InputGateway` (retrait `submit`, ajout `delegationDelivered`) + adapter ; type front `DomainEvent.DelegationReady` | `frontend/src/features/terminals/TerminalView.tsx`, `ports/index.ts`, `adapters/input.ts`, `domain/*`, `app/di.tsx`, mocks | Vitest : en agent, une frappe écrit le PTY ; compteur +1 sur `a`, reset sur `\r` et `\x03` ; multiligne natif n'altère pas le compteur de façon erronée ; adapter appelle `delegation_delivered` avec `{projectId,agentId,ticket}` | | **L4** | frontend | `useWritePortal` + overlay : réception `DelegationReady` → file ; injection **ssi ligne vide** ; handshake (b) couper relais+overlay, (c) revérif K→`\x7f`×K, (d) `write(text)` puis `write(submitSequence??"\r")` après `submitDelayMs??60`, (e) réactiver+overlay off **plancher 2 s** ; ack `delegationDelivered` | `frontend/src/features/terminals/useWritePortal.ts` (nouveau), `LayoutGrid.tsx` (overlay + montage), `features/terminals/index.ts` | Vitest (faux timers + faux handle) : injecte **rien** tant que ligne non vide ; à ligne vide → écrit `text` **sans** `\n` puis `\r` après le délai ; course « K=2 lettres dans le micro-intervalle » → 2 `\x7f` avant le texte ; **plancher 2 s** : overlay maintenu si (e) < 2 s après (b) ; relais frappes coupé pendant l'overlay ; `delegationDelivered` appelé une fois après (d) | | **L5** | frontend+app-tauri | **Retrait** de `MediatedInput` (suppr. composant + montage `LayoutGrid`), de `useAgentBusy` si plus utilisé (ou conservé pour un badge), des commandes mortes `submit_agent_input`/`reattach_agent_chat`/DTO afférents ; `interrupt` rebranché sur un raccourci terminal | `LayoutGrid.tsx`, `MediatedInput.tsx` (suppr.), `useAgentBusy.ts`, `app-tauri/src/commands.rs`/`dto.rs`/`lib.rs`, tests | suite front verte sans `MediatedInput` ; aucune cellule **plain** modifiée (régression nulle) ; `cargo build` sans les commandes retirées ; `interrupt_agent` toujours appelable | **Ordre** : L1→L2 (back prêt) ∥ L3 (front natif) → L4 (portail, dépend L1/L3) → L5 (nettoyage). L1 est le pivot (supprime le `\n`, source du bug, et bascule la livraison sur event). ### 20.6 Plan de tests — cas limites explicites - **Course 1–2 lettres** (étape c) : entre (a) « ligne vide constatée » et (b) « relais coupé », l'humain tape K∈{1,2} ⇒ le portail écrit **exactement** K `\x7f` puis le texte ; **jamais** Ctrl-U. *(Vitest, faux handle enregistrant les writes.)* - **Plancher 2 s** (étape e) : (d) se termine à t<2 s ⇒ overlay + relais coupés **maintenus** jusqu'à t=2 s ; (e) à t≥2 s ⇒ pas d'attente résiduelle. *(faux timers.)* - **Multiligne natif** : l'humain compose une commande multiligne (la CLI gère ses propres `\n` internes) ⇒ le compteur **n'injecte pas** au milieu (ligne « non vide » tant que la frappe est en cours) ; on ne réécrit jamais le contenu humain. - **Ligne non vide à la frontière** : `DelegationReady` reçue alors que compteur>0 ⇒ **mise en file**, **aucune** écriture ; libérée à la prochaine ligne vide. *(pas de refus, pas de perte.)* - **Préemption pendant overlay** : Échap (`interrupt`) pendant l'overlay ⇒ `preempt` (ESC) côté back inchangé ; le portail lève l'overlay au plancher ; la délégation en cours d'écriture n'est pas dupliquée (ack idempotent). - **Back (Rust)** : `enqueue` ne PTY-écrit plus ; 1 `DelegationReady` sur démarrage de tour, 0 en re-enqueue busy ; `preempt`/prompt-watcher/`mark_idle` inchangés ; `ask_agent` réveillé par `idea_reply`, timeout/cycle intacts ; profil serde zéro régression. ### 20.7 Risques résiduels & vigilance - **Frontière « tôt »** : conditionner sur « ligne vide » seule peut injecter avant le tout premier prompt-ready. Mitigation : la CLI empile nativement ; si insuffisant, event additif `AgentPromptReady` (déjà détecté côté back) sans toucher le portail. - **`submit_sequence` par CLI** : `"\r"` + délai esquive la paste-detection de Claude Code ; d'autres TUI pourraient exiger une autre séquence/délai → c'est précisément pourquoi c'est **déclaratif** (profil), pas codé en dur. - **Compteur de ligne vs séquences ANSI** : ne compter que les **imprimables** issus de `term.onData` (frappes), pas la sortie ; ignorer les séquences de contrôle (flèches/échap) pour éviter un faux « ligne non vide » qui bloquerait toute injection. - **Reattach** : à la réouverture d'une cellule, le portail repart d'un **compteur=0** et d'une file vide ; une `DelegationReady` perdue pendant la navigation est rejouée par le `ask` en attente (le back retient le ticket jusqu'au reply/timeout) → pas de perte de corrélation. - **Plain shell strictement inchangé** : tout le mécanisme est gardé par `agentMode`/présence d'agent ; aucune cellule plain ne voit overlay, portail, ni compteur. --- ## 21. Gestion des limites de session des agents — détection hiérarchique + reprise auto annulable (cadrage 2026-06-16) > Cadrage produit verrouillé avec l'utilisateur le 2026-06-16. **Étape 1 du cycle §3 — AUCUN code.** > Besoin : IdeA doit savoir **quand** un agent est en limite de session **et jusqu'à quand**, puis lui > demander de **reprendre où il en était** une fois la limite levée. Priorités : (1) **SOLIDE** (marche > même pour un novice, ~100 % des cas) ; (2) **sans dépendance au modèle** autant que possible. ### 21.0 État du terrain (lu dans le code, pas présumé) - `domain/src/ports.rs` — `ReplyEvent` = `{ TextDelta, ToolActivity, Heartbeat, Final }`. **Aujourd'hui un `rate_limit_event` Claude est réduit à `Heartbeat`** (`infrastructure/src/session/claude.rs:90`, commentaire « fenêtre de limite de débit ») : l'info `rate_limit_info.resetsAt` est **lue puis jetée**. - `domain/src/readiness.rs` — `ReadinessSignal` = `{ TurnEnded, ExplicitReply, PromptReady, Stalled, TimedOut }`. `Stalled`/`TimedOut` sont **réservés au lot 2** (vocabulaire présent, **non produits** par `classify`). - `domain/src/input.rs` — deux axes d'état **orthogonaux** déjà posés : `AgentBusyState{Idle,Busy}` et `AgentLiveness{Alive,Stalled}`. La limite de session sera un **3ᵉ axe orthogonal**. - Reprise déjà câblée et **model-agnostique** : pivot `conversation_id` du moteur (`LeafCell.conversation_id`) + `SessionPlan::{None,Assign,Resume}` (`ports.rs`) + `build_spawn_line` (`session/claude.rs:200`, `--resume`) + `ListResumableAgents` (`application/agent/resume.rs`) + `SessionInspector` (`inspector/claude.rs`, « dernier sujet »). - `domain/src/ports.rs` — `Clock::now_millis() -> i64` (**épochе-millis**). **Il n'existe AUCUN port de minuterie** (pas de « réveille-moi à T »). C'est le seul vrai manque. ### 21.1 Décisions d'architecture (tranchées) 1. **Détecteur HIÉRARCHIQUE** calqué sur la hiérarchie de readiness existante. Trois niveaux, *premier qui matche gagne*, jamais d'inaction silencieuse : - **Niveau 1 — structuré (solide)** : l'adapter structuré extrait limite + reset du flux machine. Pour Claude, lire `rate_limit_info.resetsAt` **au lieu de le jeter**. - **Niveau 2 — déclaratif (configurable)** : champ de profil `rate_limit_pattern` (motif + capture de l'heure) pour agents **PTY/TUI sans adapter structuré**, dans la lignée des profils déclaratifs §9. - **Niveau 3 — filet humain** : rien ne matche **mais** l'agent est `Stalled` (lot 2) ⇒ IdeA **demande** à l'utilisateur. Garantit le « 100 % même pour un novice » : jamais d'inaction silencieuse. 2. **Model-agnostic tenu AU DOMAINE.** Le domaine ne connaît qu'un fait neutre : « limité, reset à T (peut- être) ». Tout savoir spécifique modèle (forme du `rate_limit_event`, regex d'une TUI, parsing d'une heure locale) reste **confiné aux adapters/profils** (philosophie §9). 3. **État EN MÉMOIRE uniquement** (décidé). **Aucune persistance** de `SessionLimit`, **aucun nouveau store**, **aucun schéma `.ideai/` modifié**. Conséquence assumée : le réveil auto ne joue que tant qu'IdeA reste ouvert ; IDE fermé/rouvert après le reset ⇒ le chemin **existant** `ListResumableAgents` (`agent_was_running`/`conversation_id`) prend le relais (popup de reprise, pas d'auto). 4. **Reprise AUTOMATIQUE à l'heure de reset, ANNULABLE** (fenêtre + notification UI). Réutilise `SessionPlan::Resume` + un **prompt de reprise court** ; `--resume` porte tout l'historique (zéro reconstruction manuelle). 5. **Un seul nouveau port** : `Scheduler` (minuterie one-shot annulable). Tout le reste **réutilise** l'existant (`Clock`, `EventBus`, `AgentSession`/`Factory`, `LaunchAgent`, `InputMediator`). ### 21.2 Tensions avec l'archi hexagonale existante — et leur résolution (à lire avant de coder) | # | Tension (proposition initiale) | Résolution retenue | |---|---|---| | **T1** | `RateLimited { until: Option }`. `std::time::Instant` est **monotone, non sérialisable, non horloge murale**, et **absent du domaine** (qui parle `i64` époche-millis via `Clock`). | **Abandonner `Instant`** → `resets_at_ms: Option` (époche-millis), homogène avec `Clock::now_millis` et `AgentBusyState.since_ms`, serde-friendly. **Déviation assumée de la proposition.** | | **T2** | `rate_limit_pattern` = **regex + capture**. Or le domaine est **dépendance-zéro** ; `prompt_ready_pattern` a été délibérément choisi **littéral** pour éviter la dépendance `regex`. | Le **domaine ne stocke que de la donnée** : `RateLimitPattern { pattern, reset_capture, time_format }` (chaînes). Le **moteur regex + le parsing d'heure vivent en infra** (un composant `RateLimitParser` / le watcher PTY). `regex` est ajouté au `Cargo.toml` de **`infrastructure` uniquement**. Domaine pur préservé (§1.4 / §9). | | **T3** | « armer un réveil sur le port `Clock` ». `Clock` ne sait que **donner l'heure**, pas **réveiller**. | Nouveau port **`Scheduler`** (ISP : minuterie fine, une responsabilité). `Clock` reste pour « maintenant ». | | **T4** | `RateLimited` comme événement de tour. Le contrat `ReplyStream` dit **« seul `Final` est terminal »** et `claude.rs` **rompt** la boucle au `Final`. | `ReplyEvent::RateLimited` est **non terminal** (comme `Heartbeat`) : il s'intercale, le flux continue jusqu'à `Final` **ou** clôture. **Point d'intégration** : un tour clos **sans `Final`** *parce que* limité ne doit **pas** devenir `AgentSessionError::Io` (« flux clos sans Final ») — `drain_bounded` doit traiter « clos + `RateLimited` vu » comme une **fin gracieuse limitée**, pas une erreur. | | **T5** | Niveau 3 « si agent `Stalled` ». Or `Stalled` est **réservé** au lot 2 (non produit aujourd'hui). | Le **niveau 3 dépend du lot 2** (détection de stagnation). À livrer **après** ; d'ici là, niveaux 1+2 couvrent le structuré et le PTY configuré. **Dépendance explicitée** dans le découpage. | ### 21.3 Modèle de domaine (ajouts purs, I/O-free) - **`ReplyEvent::RateLimited { resets_at_ms: Option }`** (`ports.rs`) — non terminal, model-agnostique. - **`ReadinessSignal::RateLimited { resets_at_ms: Option }`** (`readiness.rs`) — l'enum **reste `Copy`** (`Option` est `Copy`). `ReadinessPolicy::classify(ReplyEvent::RateLimited{..})` ⇒ `Some(ReadinessSignal::RateLimited{..})` (seul ajout au `match`). - **`domain/src/session_limit.rs` (NOUVEAU)** : - `SessionLimit { resets_at_ms: Option, detected_at_ms: i64, source: RateLimitSource }` (VO). - `RateLimitSource { Structured, Pattern, Human }` (traçabilité du niveau, pour l'UI). - **fonction pure** `plan_resume(now_ms, &SessionLimit) -> ResumePlan` avec `ResumePlan { fire_at_ms: i64 }` (si `resets_at_ms` absent ⇒ pas de plan auto ⇒ filet humain). Toute la logique de calendrier est **pure et testable sans I/O** (cf. `LayoutTree`, §7.2). - **`AgentProfile.rate_limit_pattern: Option`** + `with_rate_limit_pattern` (builder), `#[serde(default, skip_serializing_if = "Option::is_none")]` ⇒ **zéro régression** de sérialisation (mêmes tests que `liveness`/`prompt_ready_pattern`). `RateLimitPattern` = **donnée** (cf. T2), validée a minima (motif non vide) par un constructeur *parse-don't-validate*. - **`events.rs`** : `AgentRateLimited { agent_id, resets_at_ms: Option }`, `AgentResumeScheduled { agent_id, fire_at_ms }`, `AgentResumeCancelled { agent_id }`, `AgentResumed { agent_id }`, `AgentRateLimitSuspected { agent_id }` (niveau 3). Discrets, basse fréquence. ### 21.4 Le port `Scheduler` (frontière domaine — le seul nouveau port) ```rust /// Minuterie one-shot **annulable** (ARCHITECTURE §21). `Clock` dit l'heure ; ce /// port *réveille* à une échéance absolue. In-memory (aucune persistance, §21.1-3). pub trait Scheduler: Send + Sync { /// Arme un réveil à `deadline_ms` (époche-ms) qui, à échéance, **pousse** `task` /// vers le drain applicatif. Renvoie un id annulable. fn arm(&self, deadline_ms: i64, task: ScheduledTask) -> ScheduleId; /// Annule un réveil armé (idempotent ; `false` si déjà tiré/inconnu). fn cancel(&self, id: ScheduleId) -> bool; } /// Intention model-agnostique exécutée à l'échéance (donnée pure, pas de closure /// traversant la frontière). Calqué sur l'esprit du dispatch orchestrateur §14.3. pub enum ScheduledTask { ResumeAgent { agent_id: AgentId, node_id: NodeId, conversation_id: Option }, } ``` - **Pourquoi un port plutôt qu'un `tokio::sleep` direct** : garder l'**application testable sans temps réel** (un `Scheduler` fake déclenche à la demande) et l'inversion de dépendance (§1.2-D). `Clock` **reste** le port d'« heure courante ». - **Adapter** : `TokioScheduler` (`infrastructure/src/scheduler/`) — `tokio::time::sleep_until` + table de `JoinHandle` (abort = `cancel`) ; à l'échéance il **pousse `task`** dans un `mpsc` fourni à la construction (le drain est côté application). **Direction des dépendances respectée** : infra → canal → application (exactement le motif du watcher orchestrateur §14.3, qui draine un dispatch unique). ### 21.5 Application — service de limite & reprise - **`application/src/agent/session_limit.rs` (NOUVEAU)** : `SessionLimitService`, qui compose **uniquement des ports/use-cases existants** + `Scheduler` : - `on_rate_limited(agent, SessionLimit)` : enregistre une entrée **en mémoire**, `plan_resume`, `Scheduler:: arm(fire_at_ms, ResumeAgent{..})`, publie `AgentRateLimited` + `AgentResumeScheduled`. - `cancel_resume(agent)` : `Scheduler::cancel`, publie `AgentResumeCancelled` (la fenêtre annulable UI). - `resume_now(agent)` / drain de `ScheduledTask::ResumeAgent` : **compose `LaunchAgent` / `AgentSessionFactory:: start` avec `SessionPlan::Resume{conversation_id}`** + **prompt de reprise court** ; publie `AgentResumed`. - `confirm_manual(agent, resets_at_ms)` (niveau 3) : même chemin que niveau 1 avec `source = Human`. - **`application/src/agent/structured.rs`** : dans `drain_with_readiness`, réagir à `ReadinessSignal::RateLimited{resets_at_ms}` ⇒ `SessionLimitService::on_rate_limited`. **Réconcilier T4** : un flux clos **sans `Final`** alors qu'un `RateLimited` a été vu ⇒ **pas** d'`Io`, mais une issue « limité » (l'agent reste vivant ; le service arme la reprise). Le `mark_idle`/FIFO reste piloté par `Final`/timeout. - **Niveau 3 (filet humain)** : à brancher **après le lot 2** — quand le détecteur de stagnation passe `Alive→Stalled` et qu'**aucune** `SessionLimit` n'est connue pour l'agent, le service publie `AgentRateLimitSuspected` ⇒ l'UI demande (jamais d'auto sans heure). ### 21.6 Infrastructure — où vit chaque détail modèle - **Niveau 1** — `infrastructure/src/session/claude.rs` `parse_event` : `rate_limit_event` ⇒ lire `rate_limit_info.resetsAt`, le **normaliser en époche-ms** (ISO-8601/époch → `i64`) et émettre `ReplyEvent::RateLimited{resets_at_ms}` (non terminal) **au lieu** du `Heartbeat` actuel. Ajuster la boucle `send` (le `break 'lines` reste sur `Final` ; `RateLimited` ne rompt pas). `session/codex.rs` : mapper l'équivalent Codex **s'il existe**, sinon s'en remettre au niveau 2. - **Niveau 2** — `infrastructure/src/ratelimit/` (NOUVEAU) `RateLimitParser` : compile le `rate_limit_pattern` du profil (**`regex`, dépendance infra seulement**, cf. T2), observe le flux PTY du handle lié (réutilise l'armement du watcher de prompt, `MediatedInbox`), et à un match calcule `resets_at_ms` (capture d'heure + `Clock` + date du jour) puis émet `ReplyEvent::RateLimited` / notifie le service. **Aucune** regex ne franchit la frontière domaine. - **Port `Scheduler`** — `infrastructure/src/scheduler/` `TokioScheduler` (cf. §21.4). - `infrastructure/src/clock/` — **inchangé** (réutilisé pour « maintenant » et le calcul d'heure de reset). ### 21.7 Présentation (app-tauri + frontend) - **`app-tauri`** (composition root) : instancier `TokioScheduler` + `SessionLimitService`, démarrer le **drain des `ScheduledTask`** (boucle de fond, jumelle du watcher orchestrateur). Commandes : `cancel_agent_resume`, `resume_agent_now`, `confirm_agent_rate_limit{resets_at_ms}`. Relais des nouveaux `DomainEvent` en events IPC **camelCase** (`agentRateLimited`, `agentResumeScheduled`, …). - **`frontend`** (`features/agents` + `features/terminals`) : **badge « limité jusqu'à HH:MM »** + compte à rebours + bouton **« Annuler la reprise »** (fenêtre annulable) ; **dialogue du filet humain** (niveau 3 : « limite détectée mais heure inconnue — reprendre à ? »). Étendre un `gateway` UI (port TS) + son adapter Tauri + les mocks (§1.3). Le « dernier sujet » du `SessionInspector` enrichit la notification. ### 21.8 Conformité hexagonale & SOLID - **Domaine pur** : `SessionLimit`/`plan_resume`/`classify` testables **sans I/O ni temps réel** (T1 époche-ms, T2 regex hors domaine). **S** : `SessionLimitService` = une intention (détecter→planifier→reprendre) ; `Scheduler` = une responsabilité (minuter). **O** : ajouter un moteur niveau 1 = un adapter ; ajouter une TUI niveau 2 = **de la donnée** (`rate_limit_pattern`), pas de code. **L** : tout `Scheduler` (réel/fake) substituable. **I** : `Scheduler` minimal (`arm`/`cancel`), distinct de `Clock`. **D** : l'application reçoit `Arc` injecté au composition root. ### 21.9 Découpage en LOTS testables (cycle dev↔QA §3) — ordonné | Lot | Couche | Contenu | Vert quand | |---|---|---|---| | **LS1** | domaine | `ReplyEvent::RateLimited` ; `ReadinessSignal::RateLimited` + `classify` ; `session_limit.rs` (VO + `plan_resume`) ; `events.rs` (5 variantes) ; `profile.rate_limit_pattern` + builder | tests purs : `classify` mappe RateLimited ; `plan_resume` (avec/sans `resets_at`) ; round-trip serde profil (clé omise si `None`, legacy→`None`) ; `ReadinessSignal` reste `Copy` | | **LS2** | infra (niv. 1) | `claude.rs` `parse_event` extrait `resetsAt`→époche-ms→`RateLimited` ; boucle `send` (pas de rupture sur RateLimited) ; idem Codex si applicable | conformance : une ligne `rate_limit_event` ⇒ `RateLimited{Some(ms)}` ; un tour `rate_limit_event`+`result` ⇒ `[…,RateLimited,Final]` ; sans `resetsAt` ⇒ `RateLimited{None}` | | **LS3** | domaine+infra | port `Scheduler` + `ScheduledTask` ; `TokioScheduler` (arm/cancel + push mpsc) | `arm` tire la tâche à l'échéance (deadline courte) ; `cancel` empêche le tir ; `cancel` post-tir = `false` | | **LS4** | application | `SessionLimitService` (on_rate_limited/cancel/resume_now/drain) ; `structured.rs` réconcilie T4 | mocks `Scheduler`+`AgentSession` : `on_rate_limited` arme + publie ; `cancel` annule ; drain compose `SessionPlan::Resume{id}` + prompt ; « clos sans Final + RateLimited » ⇒ **pas** d'`Io` | | **LS5** | infra (niv. 2) | `ratelimit/RateLimitParser` (regex, dép. infra) + armement sur le watcher PTY ; conso `rate_limit_pattern` | sur une sortie TUI échantillon : match ⇒ `resets_at_ms` calculé ; profil sans motif ⇒ aucun faux positif | | **LS6** | app+front | **niveau 3** (après lot 2) : `Stalled` sans limite connue ⇒ `AgentRateLimitSuspected` + `confirm_agent_rate_limit` | mock : transition `Stalled` sans `SessionLimit` ⇒ 1 `AgentRateLimitSuspected` ; `confirm_manual` arme comme niv. 1 | | **LS7** | app-tauri | wiring scheduler+service+drain ; commandes `cancel`/`resume_now`/`confirm` ; relais events camelCase | `cargo build`/tests commands ; events mappés ; drain démarré à open/create_project | | **LS8** | frontend | badge « limité jusqu'à HH:MM » + countdown + Annuler ; dialogue filet humain ; gateway+adapter+mocks | Vitest avec gateway mock : badge sur `agentRateLimited` ; Annuler appelle `cancel_agent_resume` ; dialogue sur `agentRateLimitSuspected` | **Ordre** : LS1 → (LS2 ∥ LS3) → LS4 → LS5 → **LS6 (gate : lot 2 stagnation livré)** → LS7 → LS8. **LS1+LS2+LS4** donnent déjà le niveau 1 de bout en bout (Claude) : le cœur de valeur est atteint tôt. ### 21.10 Points ouverts (spikes) 1. **Format de `resetsAt`** (Claude) : époch vs ISO-8601 vs durée relative — à vérifier sur un vrai `rate_limit_event` (spike LS2). Le domaine ne voit que des **époche-ms** quoi qu'il arrive. 2. **Heure locale → époche** (niveau 2) : une capture « 15:00 » est une **heure murale locale** ⇒ composer avec la date du jour + fuseau via `Clock` ; gérer le passage de minuit (reset « demain »). Confiné infra. 3. **Codex** : existe-t-il un signal structuré de limite dans `codex exec --json` ? Sinon niveau 2 obligatoire pour Codex (spike LS2). 4. **Double détection** : niveau 1 **et** niveau 2 pourraient matcher le même épisode ⇒ le service **dédoublonne par agent** (une `SessionLimit` vivante par agent ; le second signal rafraîchit, n'empile pas). --- ## 21. Clôture du programme live-state / persistance (LS1→LS7) — cartographie des 4 stores > **Référence durable de clôture** (programme livré @ `fd7adbb`). Détail complet, acquis lot par lot et points ouverts : [`docs/LS8-live-state-persistence-closure.md`](docs/LS8-live-state-persistence-closure.md). §21 fait foi sur la séparation des stores et le flux d'injection. ### 21.1 Quatre stores disjoints (frontière gravée) | Store | Fichier(s) | Nature | Surface | Versionné | Injecté agent | |---|---|---|---|---|---| | Mémoire projet | `.ideai/memory/*.md` + `MEMORY.md` | Savoir stable, curé, low-noise | Agent + humain | Oui | Oui (index/hooks — pointeurs) | | Handoff | `.ideai/conversations//handoff.md` | Reprise par fil = dérivé distillé **borné** (≤4096) | Agent (distillé) | Non | Oui (`# Reprise de la conversation`, borné LS5) | | Transcript | `.ideai/conversations//log.jsonl` (+ `log.N.jsonl`) | Journal append-only **riche**, source de vérité | **Humain seul** | cf. point ouvert | **JAMAIS** | | Live-state | `.ideai/live-state.json` | Coordination transitoire **maigre**, keyed LWW, prune TTL+max | Agent (maigre) + humain | Non (gitignoré) | Oui (`# État du projet`, borné LS4) | **Règle** : surface AGENT = borné/distillé/pointeur ; surface HUMAINE = riche. Le **transcript append-only ne franchit JAMAIS** vers un contexte agent ; seul le **handoff distillé** passe la frontière (borné des deux côtés). Le live-state est keyed last-writer-wins (jamais d'append). ### 21.2 Injection au lancement (ordre `compose_convention_file`) `# Project root` → `# Orchestration IdeA` (+ capacités) → `# Skills disponibles` → `# Contexte projet` → persona → `# Mémoire projet` (pointeurs) → **`# État du projet`** (live-state lean, LS4) → **`# Reprise de la conversation`** (handoff borné, LS5). Sections vides omises. ### 21.3 Chemin chaud vs froid - **Chaud** (cheap, jamais ralenti) : `append` (O(1)+fsync), `fold` incrémental + `bound_handoff_summary`, auto-update live-state best-effort sur `ask`/`reply`. **Aucun LLM.** - **Froid** : rotation `RotateConversationLog` (à la reprise, best-effort, idempotente, **INV-LS6** : jamais d'élagage d'un tour d'id ≥ `up_to`), lecture paginée `read_conversation_page` (viewer humain), `GetLiveStateLean` (prune-on-read). ### 21.4 Reste ouvert (1) activation réelle du seam LLM (non activé, défaut heuristique, contrat ADR LS5) ; (2) balayage périodique de rotation (idempotent, non câblé) ; (3) discordance D19-4 vs `.gitignore` sur `.ideai/conversations/` (à trancher Git/Main) ; (4) intégration MCP e2e UX ; (5) évolutions multi-fenêtres du registre de sessions ; (6) auto-update mémoire/contexte *en cours* de session. Détail : `docs/LS8` §7. *Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.*