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

235 KiB
Raw Permalink Blame History

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 == trueorigin == 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 (VisibleBackground) 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_mdversion + 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 :
    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 :
    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 :
    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 :
    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 :
    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, RemoteHostPtyPort/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. RemoteHostPtyPort, 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.

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 :

{
  "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 :

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 :

{
  "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 (VisibleBackground) 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 :

{ "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). Nonerepli 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 ; FlagSpawnSpec.args ; EnvSpawnSpec.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_*OrchestratorCommanddispatch→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_agentdispatch 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>) :

// 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>) :

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
/// 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
/// 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 = noneNaiveMemoryRecall ; 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 :

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 :
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 :

---

# 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.rsdev :
    • 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.rsdev : 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.

// 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.

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.

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)

  1. 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).

  2. 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.

  3. 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.

  4. Le retour synchrone passe par un nouveau port AgentReplyChannel (corrélation requête↔réponse), PAS par SessionInspectorSessionInspector 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).

  5. 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).

  6. 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.

  7. Timeout borné + sémantique d'erreur expliciteask_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).

  8. 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.

  9. 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.

  10. 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).

  11. 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.

// 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.

// 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>,
}
// 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.
// 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.

// 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 validatemême AskAgentmême OrchestratorService::dispatchmê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 → OrchestratorCommandOrchestratorService::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é ; timeoutReplyError::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_*
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_replypublish_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.
// 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 :

// 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_deltaTextDelta ; les tool_useToolActivity. 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 execReplyEvent::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.

// 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 : Somecellule chat ; Nonecellule 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 : terminalTerminalView (xterm, inchangé) ; chatAgentChatView. 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_sessionshutdown+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 AgentBusyStateIdle (§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 }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)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]) -> Handoffincré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 :

/// 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).

/// 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 :

// 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_replyresolve_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 :

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
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.