Files
IdeA/ARCHITECTURE.md
Blomios 2433e173a1 feat(agent): backend hot-swap profil (A0+A1) + inventaire reprise session (B1) — L15
Cadrage Architecture §15 (figé) : « agent = entité à session persistante ».

- A0 (domaine) : Agent::with_profile, LayoutTree::leaf, event AgentProfileChanged
  (+ DTO miroir DomainEventDto camelCase).
- A1 (application) : use case ChangeAgentProfile — no-op si profil identique,
  mutation manifeste, nettoyage conversation_id/agent_was_running sur layouts
  persistés, swap à chaud (kill PTY + relance même cellule via composition de
  LaunchAgent), event AgentProfileChanged. Décision : repartir à neuf (on garde
  .md + mémoire, on jette l'historique de conversation).
- B1 (application) : use case ListResumableAgents (lecture seule) — inventaire des
  cellules was_running||conversation_id, resume_supported selon profil, best-effort.

Aucun nouveau port/adapter (composition de l'existant). Hexagonal strict.
Tests : domaine 11 + app-tauri dto + ChangeAgentProfile 9 + ListResumableAgents 8,
suite application complète verte (0 régression).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:55:44 +02:00

99 KiB
Raw 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

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.


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 { agents: 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.


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.

Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.