Files
IdeA/ARCHITECTURE.md
Blomios fdcf16c387 chore(wip): checkpoint P8/C avant chantier Codex inter-agents
Sauvegarde de l'arbre de travail en cours (persistance P8, conversations
C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le
support de la délégation inter-agents pour les profils Codex.

Le round-trip inter-agent question/réponse est couvert sans tokens par
les tests loopback existants (state::mcp_e2e_loopback_tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:42:53 +02:00

2164 lines
235 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<dyn Port>` 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<UseCase>)
┌───────────────────────────────▼───────────────────────────────────────┐
│ 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<DTO, ErrorDTO>`. **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<String>`, `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<String>`, `context_injection: ContextInjection`, `detect: Option<String>`, `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<NodeId>` (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<ManifestEntry { agent_id, md_path, template_id?, synchronized, synced_template_version? }>`.
- 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<bool, RuntimeError>;
fn prepare_invocation(&self, profile: &AgentProfile, ctx: &PreparedContext, cwd: &ProjectPath)
-> Result<SpawnSpec, RuntimeError>; // 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<PtyHandle, PtyError>;
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<ExitStatus, PtyError>;
}
```
- **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<dyn FileSystem>;
fn process_spawner(&self) -> Arc<dyn ProcessSpawner>;
fn pty(&self) -> Arc<dyn PtyPort>;
}
```
- **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<Output, ProcessError>;`
- **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<Vec<u8>, FsError>;
async fn write(&self, p: &RemotePath, data: &[u8]) -> Result<(), FsError>;
async fn exists(&self, p: &RemotePath) -> Result<bool, FsError>;
async fn create_dir_all(&self, p: &RemotePath) -> Result<(), FsError>;
async fn list(&self, p: &RemotePath) -> Result<Vec<DirEntry>, 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<MarkdownDoc, StoreError>;
async fn write_context(&self, project: &Project, agent: &AgentId, md: &MarkdownDoc) -> Result<(), StoreError>;
async fn load_manifest(&self, project: &Project) -> Result<AgentManifest, StoreError>;
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 <distro>` + 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<dyn Port>`, une méthode `execute(input: XxxInput) -> Result<XxxOutput, AppError>`. **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<SessionId>, // 0 ou 1 terminal
}
struct SplitContainer { // découpage simple binaire/n-aire pondéré
id: NodeId,
direction: Direction, // Row (colonnes) | Column (lignes)
children: Vec<WeightedChild>, // 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<f32>, // largeurs de colonnes
row_weights: Vec<f32>, // hauteurs de lignes
cells: Vec<GridCell>, // 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<LayoutTree, LayoutError>` (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)
```
<project_root>/
├── .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
│ │ ├── <slug>.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/
│ ├── <agent-id>/ # 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`).
```
<app_data_dir>/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
│ │ └── <feature>/{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 <crate>` 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/<agent-id>/`, 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** (délégation via `.ideai/requests`, pas via 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), si présent (voir §14.5.4).
**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) : `<app_data>/IdeA/skills/` (store global IDE, réutilisables entre projets).
- Skills de projet : `.ideai/skills/<skill-name>.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/<requester-id>/`. 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/<agent>.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<McpCapability>` 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/<id>`).
#### 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/<slug>.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<dyn MemoryStore>`, `execute(Input) -> Result<Output, AppError>`) :
```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<Memory> }
// 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<MemoryIndexEntry> }
// ResolveMemoryLinks — liens [[slug]] sortants résolus (liens cassés ignorés).
struct ResolveMemoryLinksInput { project_root: ProjectPath, slug: MemorySlug }
struct ResolveMemoryLinksOutput { links: Vec<MemoryLink> }
```
> **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<dyn EventBus>`.
**É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<MemoryError> for AppError`** (à ajouter dans `application/src/error.rs`, calqué sur `From<StoreError>`) :
```rust
impl From<MemoryError> 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<MemoryDto>)`, `MemoryIndexDto(Vec<MemoryIndexEntry>)`, `MemoryLinksDto(Vec<String>)` (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<dyn MemoryStore>`, 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<Vec<MemoryIndexEntry>, 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<dyn MemoryStore>`, 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<MemoryIndexEntry> }`, port `Arc<dyn MemoryRecall>`. 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<Vec<Vec<f32>>, 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<dyn Embedder>` + `Arc<dyn MemoryStore>` + 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<dyn MemoryRecall>` (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<MemoryIndexEntry>` 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<Vec<MemoryIndexEntry>, 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<dyn MemoryRecall>`) ; 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<dyn MemoryRecall>` 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<Vec<MemoryIndexEntry>, 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<dyn MemoryRecall>` via un **helper de composition** `build_memory_recall(fs, memory_store_port, embedder_profile) -> Arc<dyn MemoryRecall>` 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<dyn MemoryRecall>` 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/<agent>.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<TerminalSession>, // 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<TerminalSessionDto> }`. 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<ResumableAgent> }
pub struct ResumableAgent {
pub agent_id: AgentId,
pub name: String,
pub node_id: NodeId, // cellule hôte (où relancer)
pub conversation_id: Option<String>, // 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> }`, `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<ResumableAgent[]>` (+ 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 <agent> "<task>"` 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/<id>/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/<request-id>.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 "<contenu>"`** (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/<correlation>.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/<target>/<correlation>.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<McpCapability>,
}
```
```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 `<correlation>.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<AgentReply, ReplyError>; // 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 `<correlation>.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 <agent> "<task>"` | `.ideai/requests/<caller>/<id>.json` (`type: agent.ask`, `correlation`) | `.ideai/outbox/<correlation>.json` (poll + timeout) | **bloque**, imprime `reply.content` sur stdout (feeling natif type outil `Task`) |
| `idea launch <agent>` | `.ideai/requests/<caller>/<id>.json` (`type: agent.run`) | rien (fire-and-forget) | retourne immédiatement (ACK) |
| `idea reply "<contenu>"` | `.ideai/outbox/<correlation>.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/<self>/` (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 (`<run-dir>/bin/idea`, par symlink/copie du binaire embarqué dans le bundle Tauri) et **préfixe `PATH`** via `SpawnSpec.env` (`PATH=<run-dir>/bin:<PATH hérité>`). 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/<agent>/`, 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<dyn AgentReplyChannel>`) 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/<target>/<corr>.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=<run-dir>/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<Vec<u8>>` 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<dyn Iterator<Item = ReplyEvent> + 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<String>;
/// 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<ReplyStream, AgentSessionError>;
/// 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<Arc<dyn AgentSession>, 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<String, AgentSessionError>;
> ```
> 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<dyn AgentSession>` 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<StructuredAdapter>,
}
```
**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<dyn AgentSession>` 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<dyn AgentSession>` = 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<ReplyChunk>` 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<ReplyChunk> } | () + flux ReplyChunk sur le Channel|
| reattach_agent_chat | { sessionId, onReply: Channel<ReplyChunk> } | 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<dyn AgentSessionFactory>` 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<StructuredAdapter>` (+ 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/<id>/` — 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<ConversationId,Conversation>` + 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** `idea_ask_agent` / `idea_reply` / `idea_list_agents` (`mcp/tools.rs`) mappés 1:1 vers `OrchestratorCommand` ; `dispatch` appelé **à l'identique** par les trois portes (fichier, MCP, UI).
### 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. Cadrage — couche de persistance conversationnelle + handoff cross-profile incrémental (chantier à découper, PAS d'implémentation)
> **Cadrage architecture** (le prochain chantier prioritaire après robustesse : **persistance/reprise → handoff cross-profile**). Produit les **ports**, les **adapters**, les **frontières** et un **découpage en lots testables**. **Aucun code applicatif ici** : la doc est livrable, les lots seront confiés aux binômes Dev/Test (cycle §3).
### 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 <id>`). 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/<conversationId>/
log.jsonl # log canonique append-only (un ReplyTurn/ligne)
handoff.md # résumé cumulatif incrémental (réinjecté au relancement)
providers.json # { "claude": "<resumableId>", "codex": "<id>", ... }
```
**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<TurnId>) -> Vec<ConversationTurn>` — relecture (reprise, recalcul handoff).
- `last(conversation, n) -> Vec<ConversationTurn>` — les N derniers (résumé incrémental).
- **`HandoffStore`** (port driven) :
- `load(conversation) -> Option<Handoff>` / `save(conversation, handoff)` où `Handoff = { summary_md: String, up_to: TurnId, objective: Option<String> }`.
- **`HandoffSummarizer`** (port driving-policy, pur ou délégant) :
- `fold(prev: Option<Handoff>, 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<resumable_id>` — 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<dyn >`), 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 P1P4) | `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/<conversationId>/`). 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<String>` (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<String>, // None ⇒ défaut "\r" appliqué au point d'usage
/// Délai (ms) entre l'écriture du texte et celle de `submit_sequence`.
/// Défaut ~5080 ms. Évite que la TUI absorbe la soumission comme un paste.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submit_delay_ms: Option<u32>, // 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<String>,
submit_delay_ms: Option<u32>,
},
```
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<void>;
/** Ack : la cellule a écrit la délégation `ticket` dans le PTY natif. */
delegationDelivered(projectId: string, agentId: string, ticket: string): Promise<void>;
}
```
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 12 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.
---
*Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.*