Compare commits

...

5 Commits

Author SHA1 Message Date
2332b7f815 fix: fix some ui displays and features miss implemented 2026-06-06 16:15:19 +02:00
9736c42424 merge: isolate agent cwd in .ideai/run/<id> (convention-file collision fix, §14.1) 2026-06-06 12:26:02 +02:00
0638ce7c98 merge: terminal lifecycle decoupling (PTY survives layout/tab switch) 2026-06-06 12:25:54 +02:00
0660f52e2b fix(terminals): decouple PTY lifecycle from view lifecycle (no kill on navigation)
Navigating (layout/tab switch) tore the xterm view down and called
handle.close(), killing the backend PTY and cutting off running AIs. Now
the view's cleanup only detaches; only an explicit user action kills a PTY.

Backend:
- PortablePtyAdapter: per-session scrollback ring buffer (~100KB, most
  recent) + re-subscribable fan-out broadcast replacing the single-take
  output_rx. Reader thread feeds both the ring buffer and current
  subscribers; on EOF it closes subscribers (streams end) while keeping
  scrollback for late re-attach.
- PtyPort: new scrollback() method; subscribe_output is now re-subscribable
  (all impls + test fakes updated).
- reattach_terminal IPC command: returns scrollback and re-wires a fresh
  output channel on the live session without re-spawning.
- CloseRequested hook kills all live PTYs cleanly on app shutdown.
- TerminalSessions::handles() to enumerate live sessions at shutdown.

Frontend:
- TerminalHandle.detach(); TerminalGateway/AgentGateway.reattach() + mocks.
- TerminalView cleanup detaches (never close); on mount it re-attaches to a
  persisted session (repainting scrollback) instead of opening a new PTY.
- LayoutGrid persists the cell's session id via setSession; AgentsPanel
  tracks per-agent session ids — both drive reattach-vs-open.

Tests: ring buffer bounds to 100KB keeping newest bytes; scrollback retained;
re-subscription delivers post-reattach output; TerminalView detaches (not
closes) on unmount and reattaches with a known session; mock detach/reattach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:24:48 +02:00
33edbad713 feat(agent): isolate agent cwd in .ideai/run/<id> to kill convention-file collisions
ARCHITECTURE §14.1: an agent's PTY cwd is now its own
`<project_root>/.ideai/run/<agent-id>/` directory, never the project root, so
N agents of the same profile no longer collide on a single conventional file
(CLAUDE.md/AGENTS.md/...).

- profile: cwd_template is now "{agentRunDir}" (built-in catalogue + docs).
- runtime: resolve_cwd substitutes {agentRunDir} (legacy {projectRoot} kept).
- LaunchAgent: computes + creates the run dir via FileSystem::create_dir_all,
  passes it as the cwd base to the pure prepare_invocation. Contract chosen:
  pass run_dir as the `cwd` argument (no PreparedContext change) — keeps
  prepare_invocation pure, I/O stays in the use case.
- convention file is generated by IdeA inside the run dir via a pure
  compose_convention_file(project_root, agent_md): absolute project-root header
  + agent persona (extensible for skills, §14.2).
- .gitignore: ignore .ideai/run/.
- run-dir cleanup left as a TODO (FileSystem port exposes no delete).

Tests: anti-collision (2 agents -> 2 distinct cwd, 2 distinct convention files,
none at root), run-dir creation order, composed convention file; pure unit
tests for agent_run_dir + compose_convention_file; runtime {agentRunDir}
substitution. cargo test --workspace + clippy -D warnings green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:18:14 +02:00
47 changed files with 2720 additions and 195 deletions

Submodule .claude/worktrees/agent-a2650e91d2bd39ca2 added at 9736c42424

Submodule .claude/worktrees/agent-a4227a8f495123597 added at 33edbad713

Submodule .claude/worktrees/agent-a78df266a0bbaf5c3 added at 0660f52e2b

Submodule .claude/worktrees/agent-aeb1e862ef04b991b added at 9736c42424

5
.gitignore vendored
View File

@ -25,6 +25,11 @@ frontend/coverage/
# Personal, machine-local overrides (shared settings.json, if any, stays tracked). # Personal, machine-local overrides (shared settings.json, if any, stays tracked).
.claude/settings.local.json .claude/settings.local.json
# ─── IdeA project data ──────────────────────────────────────────────────────
# Ephemeral per-agent run directories (isolated PTY cwd + generated convention
# files), created at activation — not versioned (ARCHITECTURE §9.1 / §14.1).
.ideai/run/
# ─── Editors / OS ─────────────────────────────────────────────────────────── # ─── Editors / OS ───────────────────────────────────────────────────────────
.idea/ .idea/
.vscode/ .vscode/

19
.ideai/agents.json Normal file
View File

@ -0,0 +1,19 @@
{
"version": 1,
"agents": [
{
"agentId": "a6ced819-b893-4213-b003-9e9dc79b9641",
"name": "Main",
"mdPath": "agents/main.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false
},
{
"agentId": "dce19c75-9669-4e45-b8de-9950025157da",
"name": "Architect",
"mdPath": "agents/architect.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false
}
]
}

601
.ideai/agents/architect.md Normal file
View File

@ -0,0 +1,601 @@
# 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.
---
## 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───? Agent (si lancé par un agent)
```
### 3.2 Entités & Value Objects (avec invariants)
**`ProjectId`, `AgentId`, `TemplateId`, `ProfileId`, `SessionId`, `WindowId`, `TabId`, `NodeId`** — VO `newtype(Uuid)` ou string typée. Invariant : non vide, immuable.
**`Project`** (entité, racine d'agrégat projet)
- Champs : `id`, `name`, `root: ProjectPath`, `remote: RemoteRef`, `created_at`.
- Invariants : `root` doit être un chemin **absolu et valide pour son `RemoteRef`** ; deux projets ne peuvent partager le même `(remote, root)`.
**`ProjectPath`** (VO) — chemin absolu normalisé, conscient de la plateforme cible (POSIX vs Windows vs WSL `/mnt/...`).
**`Agent`** (entité)
- Champs : `id`, `name`, `context: AgentContextRef` (chemin du `.md` dans `.ideai/`), `profile_id: ProfileId`, `origin: AgentOrigin` (`Scratch` | `FromTemplate { template_id, synced_version }`), `synchronized: bool`.
- Invariants : `synchronized == true``origin == FromTemplate{..}` (on ne peut pas synchroniser un agent créé from scratch). `context` doit exister à l'activation. `profile_id` doit référencer un `AgentProfile` connu.
**`AgentTemplate`** (entité, store global)
- Champs : `id`, `name`, `content_md: MarkdownDoc`, `version: TemplateVersion`, `default_profile_id`.
- Invariants : `version` **monotone croissante** ; toute modification du `content_md``version + 1` (voir §8).
**`AgentProfile`** (entité de config runtime IA — le port `AgentRuntime` est paramétré par elle)
- Champs : `id`, `name`, `command: String`, `args: Vec<String>`, `context_injection: ContextInjection`, `detect: Option<String>`, `cwd_template: String` (ex. `"{projectRoot}"`).
- 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` (cellule du layout qui l'héberge), `cwd: ProjectPath`, `kind: SessionKind` (`Plain` | `Agent { agent_id }`), `pty_size: PtySize { rows, cols }`, `status` (`Starting|Running|Exited{code}`).
- Invariants : une cellule (feuille de layout) héberge **au plus une** `TerminalSession` active. `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.
**`DomainEvent`** (enum) — `ProjectCreated`, `AgentLaunched`, `AgentExited`, `TemplateUpdated`, `AgentDriftDetected`, `LayoutChanged`, `RemoteConnected`, `GitStateChanged`, `PtyOutput{session_id, bytes}` (ce dernier souvent court-circuité vers un Channel).
---
## 4. Ports (traits du domaine)
> Signatures **conceptuelles** (Rust idiomatique, `async` via `async_trait` ou retours `Future` ; erreurs typées par port). « Consommé par » = use cases. « Implémenté par » = adapters de §5.
### `AgentRuntime`
- **Rôle** : lancer/piloter la CLI d'une IA selon un `AgentProfile`, en gérant l'injection du contexte `.md`.
- **Signature** :
```rust
trait AgentRuntime {
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError>;
fn prepare_invocation(&self, profile: &AgentProfile, ctx: &PreparedContext, cwd: &ProjectPath)
-> Result<SpawnSpec, RuntimeError>; // commande + args + plan d'injection (fichier/flag/stdin/env)
}
```
- **Consommé par** : `LaunchAgent`, `DetectProfilesUseCase` (first-run).
- **Implémenté par** : `CliAgentRuntime` (un seul adapter générique piloté par le profil déclaratif — c'est l'**Open/Closed**). La diversité des IA = données, pas code.
### `PtyPort` (alias domaine de `TerminalSessionPort`)
- **Rôle** : ouvrir un pseudo-terminal, lire/écrire, redimensionner, tuer.
- **Signature** :
```rust
trait PtyPort {
async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result<PtyHandle, PtyError>;
fn write(&self, h: &PtyHandle, data: &[u8]) -> Result<(), PtyError>;
fn resize(&self, h: &PtyHandle, size: PtySize) -> Result<(), PtyError>;
fn subscribe_output(&self, h: &PtyHandle) -> OutputStream; // flux d'octets
async fn kill(&self, h: &PtyHandle) -> Result<ExitStatus, PtyError>;
}
```
- **Consommé par** : `OpenTerminal`, `LaunchAgent`, `CloseTerminal`.
- **Implémenté par** : `PortablePtyAdapter` (local), `SshPtyAdapter` (PTY distant via russh exec/shell), `WslPtyAdapter` (PTY via `wsl.exe`). Sélection par stratégie `RemoteRef` (Liskov).
### `RemoteHost`
- **Rôle** : abstraction de la **localisation d'exécution** (local / SSH / WSL) : exécuter une commande, ouvrir un PTY, accéder au FS, dans le bon contexte.
- **Signature** :
```rust
trait RemoteHost {
fn kind(&self) -> RemoteKind;
async fn connect(&self) -> Result<(), RemoteError>;
fn file_system(&self) -> Arc<dyn FileSystem>;
fn process_spawner(&self) -> Arc<dyn ProcessSpawner>;
fn pty(&self) -> Arc<dyn PtyPort>;
}
```
- **Consommé par** : tous les use cases qui touchent un projet (résolvent leurs ports via le `RemoteHost` du projet → **transparence local/distant**).
- **Implémenté par** : `LocalHost`, `SshHost` (russh/ssh2), `WslHost` (wsl.exe). C'est la **stratégie** qui unifie les 3 modes.
### `ProcessSpawner`
- **Rôle** : lancer un process **non interactif** et récupérer sortie/exit (ex. `detect`, commandes git hors libgit2, scripts).
- **Signature** : `async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;`
- **Consommé par** : `DetectProfilesUseCase`, services divers.
- **Implémenté par** : `LocalProcessSpawner`, `SshProcessSpawner`, `WslProcessSpawner`.
### `FileSystem`
- **Rôle** : lecture/écriture/listing/symlink, neutre vis-à-vis de la localisation.
- **Signature** :
```rust
trait FileSystem {
async fn read(&self, p: &RemotePath) -> Result<Vec<u8>, FsError>;
async fn write(&self, p: &RemotePath, data: &[u8]) -> Result<(), FsError>;
async fn exists(&self, p: &RemotePath) -> Result<bool, FsError>;
async fn create_dir_all(&self, p: &RemotePath) -> Result<(), FsError>;
async fn list(&self, p: &RemotePath) -> Result<Vec<DirEntry>, FsError>;
async fn symlink(&self, src: &RemotePath, dst: &RemotePath) -> Result<(), FsError>;
}
```
- **Consommé par** : `AgentContextStore`, `ProjectStore`, injection `conventionFile`, etc.
- **Implémenté par** : `LocalFileSystem` (std::fs/tokio::fs), `SshFileSystem` (SFTP), `WslFileSystem` (via `wsl.exe` ou chemins `\\wsl$`).
### `TemplateStore`
- **Rôle** : CRUD des `AgentTemplate` dans le store global IDE + versioning.
- **Signature** : `list / get / save / delete / bump_version`.
- **Consommé par** : `CreateTemplate`, `UpdateTemplate`, `CreateAgentFromTemplate`, `SyncAgentWithTemplate`.
- **Implémenté par** : `FsTemplateStore` (md + index json dans le dossier de données app).
### `ProjectStore`
- **Rôle** : persistance de la liste des projets connus, workspaces, windows, tabs, layouts.
- **Signature** : `list_projects / load_project / save_project / save_workspace / load_workspace`.
- **Consommé par** : `CreateProject`, `OpenProject`, persistance fenêtres/onglets/layout.
- **Implémenté par** : `FsProjectStore` (json dans données app pour le registre ; layout par projet dans `.ideai/`).
### `AgentContextStore`
- **Rôle** : lire/écrire les `.md` d'agents **et** le manifeste `.ideai/agents.json` (au sein du projet, via le `FileSystem` du `RemoteHost`).
- **Signature** :
```rust
trait AgentContextStore {
async fn read_context(&self, project: &Project, agent: &AgentId) -> Result<MarkdownDoc, StoreError>;
async fn write_context(&self, project: &Project, agent: &AgentId, md: &MarkdownDoc) -> Result<(), StoreError>;
async fn load_manifest(&self, project: &Project) -> Result<AgentManifest, StoreError>;
async fn save_manifest(&self, project: &Project, m: &AgentManifest) -> Result<(), StoreError>;
}
```
- **Consommé par** : `CreateAgent*`, `LaunchAgent`, `SyncAgentWithTemplate`.
- **Implémenté par** : `IdeaiContextStore` (compose `FileSystem`, écrit `.ideai/`).
### `GitRepository`
- **Rôle** : opérations git du projet.
- **Signature** : `status / stage / unstage / commit / branches / checkout / current_branch / diff / log / pull / push / clone / init`.
- **Consommé par** : use cases Git.
- **Implémenté par** : `Git2Repository` (libgit2, local) ; sur SSH/WSL, `RemoteGitRepository` délègue à git CLI via `ProcessSpawner` quand libgit2 ne peut pas atteindre le FS distant (point ouvert §13).
### `EventBus`
- **Rôle** : publier/souscrire les `DomainEvent` (découple émetteurs et présentation).
- **Signature** : `fn publish(&self, e: DomainEvent); fn subscribe(&self) -> EventStream;`
- **Consommé par** : tous use cases (publient) ; l'adapter Tauri (souscrit → relaye en events/channels IPC).
- **Implémenté par** : `TokioBroadcastEventBus` (in-process), relayé par `TauriEventRelay`.
### `Clock` & `IdGenerator` (ports utilitaires — testabilité)
- **Rôle** : éliminer le non-déterminisme (`now()`, `uuid`) du domaine/application.
- **Implémenté par** : `SystemClock` / `UuidGenerator` (prod), `FixedClock` / `SeqIdGenerator` (tests).
---
## 5. Adapters (impl concrètes par port)
| Port | Adapter(s) | Techno | Notes |
|---|---|---|---|
| `AgentRuntime` | `CliAgentRuntime` | piloté par `AgentProfile` | Construit `SpawnSpec` + plan d'injection. Un seul adapter, N profils. |
| `PtyPort` | `PortablePtyAdapter` | portable-pty | Local. Stream octets → Channel Tauri. |
| | `SshPtyAdapter` | russh (channel shell/exec + pty req) | Distant SSH. |
| | `WslPtyAdapter` | `wsl.exe -d <distro>` + portable-pty | PTY dans la distro. |
| `RemoteHost` | `LocalHost` / `SshHost` / `WslHost` | — / russh,ssh2 / wsl.exe | Stratégie ; fabrique FS/Spawner/PTY adaptés. |
| `ProcessSpawner` | `LocalProcessSpawner` | std/tokio `Command` | |
| | `SshProcessSpawner` | russh exec | |
| | `WslProcessSpawner` | `wsl.exe` | |
| `FileSystem` | `LocalFileSystem` | tokio::fs | |
| | `SshFileSystem` | SFTP (ssh2/russh-sftp) | |
| | `WslFileSystem` | `\\wsl$\` / `wsl.exe cat`… | |
| `TemplateStore` | `FsTemplateStore` | tokio::fs + serde_json | Dossier données app. |
| `ProjectStore` | `FsProjectStore` | tokio::fs + serde_json | Registre projets + workspace. |
| `AgentContextStore` | `IdeaiContextStore` | compose `FileSystem` | Écrit `.ideai/`. |
| `GitRepository` | `Git2Repository` | git2 | Local. |
| | `RemoteGitRepository` | git CLI via `ProcessSpawner` | SSH/WSL fallback. |
| `EventBus` | `TokioBroadcastEventBus` (+ `TauriEventRelay`) | tokio::broadcast | Relais vers IPC. |
| `Clock`/`IdGenerator` | `SystemClock`/`UuidGenerator` | std/uuid | Mocks en test. |
**Adapters entrants (driving)** : handlers `#[tauri::command]` (frontend → app) + `TauriEventRelay` (app → frontend). Côté UI : `tauri-adapters` implémentant les gateways TS.
---
## 6. Use cases / services applicatifs
> Chaque use case : un struct `XxxUseCase` portant ses ports en `Arc<dyn Port>`, une méthode `execute(input: XxxInput) -> Result<XxxOutput, AppError>`. **Single Responsibility**. Aucune dépendance à Tauri.
| Use case | Rôle | Ports consommés |
|---|---|---|
| `CreateProject` | Crée un projet (project root), init `.ideai/`, registre. | `ProjectStore`, `FileSystem`, `IdGenerator`, `EventBus` |
| `OpenProject` | Charge projet, manifeste, layout, résout `RemoteHost`. | `ProjectStore`, `AgentContextStore`, `RemoteHost` |
| `CloseProject` / `CloseTab` | Persiste l'état, libère PTYs. | `ProjectStore`, `PtyPort`, `EventBus` |
| `DetectProfiles` (first-run) | Teste `detect` de chaque profil candidat. | `AgentRuntime`, `ProcessSpawner` |
| `ConfigureProfiles` | Enregistre profils choisis/édités/custom. | `TemplateStore`/profile store, `FileSystem` |
| `CreateAgentFromScratch` | Crée agent + `.md`, met à jour manifeste. | `AgentContextStore`, `IdGenerator` |
| `CreateAgentFromTemplate` | Copie le `content_md` du template → agent ; lie origine + version + `synchronized`. | `TemplateStore`, `AgentContextStore` |
| `UpdateTemplate` | Modifie un template, **bump version**, signale drift aux agents liés. | `TemplateStore`, `EventBus` |
| `DetectAgentDrift` | Compare `synced_template_version` vs `template.version`. | `TemplateStore`, `AgentContextStore` |
| `SyncAgentWithTemplate` | Applique la MAJ template→agent si `synchronized`. | `TemplateStore`, `AgentContextStore`, `EventBus` |
| `LaunchAgent` | Résout profil+contexte, prépare injection, ouvre cellule PTY au bon `cwd`, spawn CLI. | `AgentRuntime`, `AgentContextStore`, `RemoteHost``PtyPort`/`FileSystem`, `EventBus` |
| `OpenTerminal` | Ouvre un PTY simple dans une cellule. | `RemoteHost``PtyPort`, `EventBus` |
| `WriteToTerminal` / `ResizeTerminal` / `CloseTerminal` | I/O PTY. | `PtyPort` |
| `MutateLayout` (split/merge/resize/move) | Applique une opération sur le `LayoutTree` (logique **pure** dans le domaine, persistée ici). | `ProjectStore` (persistance) |
| `ConnectRemote` (SSH/WSL) | Établit la connexion, valide l'accès au root. | `RemoteHost`, `FileSystem` |
| `MoveTabToNewWindow` | Détache un onglet → nouvelle fenêtre (réaffectation `WindowId`). | `ProjectStore`, `EventBus` |
| Use cases Git | `GitStatus`, `GitCommit`, `GitCheckout`, `GitPush`, … | `GitRepository`, `EventBus` |
---
## 7. Modèle de layout terminal (grille tableur récursive + fusion)
### 7.1 Structure de données
La grille « type tableur, lignes/colonnes imbriquées indépendamment + fusion » est modélisée par un **arbre de splits récursif** où chaque conteneur définit son propre découpage. La **fusion** est obtenue nativement : fusionner = ne pas subdiviser une zone (un `Leaf` couvre plusieurs « cellules visuelles » d'un parent voisin). Pour le cas Excel pur (fusion arbitraire chevauchant la grille), on superpose un modèle **GridContainer** avec spans.
```rust
enum LayoutNode {
Leaf(LeafCell),
Split(SplitContainer),
Grid(GridContainer),
}
struct LeafCell {
id: NodeId,
session: Option<SessionId>, // 0 ou 1 terminal
}
struct SplitContainer { // découpage simple binaire/n-aire pondéré
id: NodeId,
direction: Direction, // Row (colonnes) | Column (lignes)
children: Vec<WeightedChild>, // ordre = gauche→droite / haut→bas
}
struct WeightedChild { node: LayoutNode, weight: f32 } // poids = part redimensionnable
struct GridContainer { // grille tableur avec fusion (spans)
id: NodeId,
col_weights: Vec<f32>, // largeurs de colonnes
row_weights: Vec<f32>, // hauteurs de lignes
cells: Vec<GridCell>, // placements avec spans (fusion)
}
struct GridCell {
node: LayoutNode, // récursif : une cellule peut re-contenir un Split/Grid
row: u16, col: u16,
row_span: u16, // ≥1 ; >1 = cellules fusionnées verticalement
col_span: u16, // ≥1 ; >1 = cellules fusionnées horizontalement
}
```
- **Lignes/colonnes indépendantes par zone** : chaque `SplitContainer`/`GridContainer` a ses propres poids ⇒ pas de grille uniforme rigide.
- **Imbrication** : un enfant peut être un nouveau `Split`/`Grid` ⇒ « N colonnes dans une ligne, M lignes dans une colonne » de façon arbitraire.
- **Fusion** : `row_span`/`col_span` dans `GridContainer` (modèle tableur fidèle) **ou** simplement un `Leaf` plus grand via `SplitContainer` (cas courant). Le domaine supporte les deux ; l'UI choisit la représentation selon l'interaction.
### 7.2 Invariants (validés dans le domaine, testables sans I/O)
- Tous les `weight > 0`. Les poids sont **relatifs** (l'UI normalise pour le rendu).
- Dans un `GridContainer` : aucune superposition de spans ; toute la surface couverte ; `row+row_span ≤ rows`, `col+col_span ≤ cols`.
- Un `SessionId` n'apparaît que dans **un seul** `Leaf`.
- 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)
│ └── agents/
│ ├── reviewer.md # contexte d'un agent de projet
│ ├── backend-dev.md
│ └── ...
└── (CLAUDE.md / AGENTS.md / GEMINI.md générés/symlinkés à l'activation si conventionFile)
```
**Schéma `agents.json`** :
```json
{
"version": 1,
"agents": [
{
"id": "a3f1...",
"name": "Backend Dev",
"md": "agents/backend-dev.md",
"profileId": "claude-code",
"origin": { "type": "fromTemplate", "templateId": "tpl-backend", "syncedTemplateVersion": 4 },
"synchronized": true
},
{
"id": "b7c2...",
"name": "Ad-hoc",
"md": "agents/adhoc.md",
"profileId": "codex-cli",
"origin": { "type": "scratch" },
"synchronized": false
}
]
}
```
### 9.2 Store global IDE (données app, hors projet, machine-local)
Emplacement résolu via Tauri path API (`AppData`/`~/.local/share/IdeA`/`~/Library/Application Support/IdeA`).
```
<app_data_dir>/IdeA/
├── profiles.json # AgentProfile[] configurés (first-run + custom + édités)
├── settings.json # préférences IDE
├── workspace.json # Workspace/Window/Tab + quel projet dans quel onglet (machine-local)
└── templates/
├── index.json # [{id, name, version, contentHash, defaultProfileId}]
└── md/
├── tpl-backend.md
├── tpl-reviewer.md
└── ...
```
**Schéma `profiles.json` (item)** : exactement le profil déclaratif de CONTEXT.md §9 (`id, name, command, args, contextInjection{strategy,target/flag/var}, detect, cwd`).
**Formats** : contextes & templates en **Markdown** ; tout le reste en **JSON** (serde). Pas de base de données : fichiers plats, simples, diffables, portables (AppImage friendly).
---
## 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 |
---
## 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). À cadrer L6.
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.*

187
.ideai/agents/main.md Normal file
View File

@ -0,0 +1,187 @@
# IdeA — Contexte & Méthode de travail
> Ce document définit **mon rôle**, **la méthode de développement** et **la vision produit** du projet IdeA.
> Il fait autorité sur la façon dont le projet est piloté. Toute évolution de méthode doit être répercutée ici.
---
## 1. Mon rôle : chef d'orchestre, pas développeur
Je **n'écris pas de code moi-même**. Mon rôle est de **piloter des agents** qui réalisent le travail.
Je suis responsable de :
- Découper le travail en tâches claires et autonomes.
- Attribuer chaque tâche aux bons agents.
- Garantir que le cycle de développement/test est respecté.
- Faire respecter les principes d'architecture (SOLID, Hexagonal).
- Maintenir la cohérence globale du projet et de ce document.
- Arbitrer et valider avant toute action irréversible ou sortante.
---
## 2. Les agents
### 2.1 Agent Architecture (1 pour tout le projet)
- Garant de l'architecture globale : **Hexagonale (Ports & Adapters)** et principes **SOLID**.
- Définit les frontières (domaine / application / infrastructure), les ports, les contrats.
- Valide que chaque nouvelle feature respecte la structure avant son développement.
- Tient à jour la cartographie d'architecture et les conventions.
### 2.2 Agents de Développement
- Écrivent le code des features.
- Respectent strictement l'architecture définie par l'agent Architecture.
- Code **propre, structuré, stable**.
- Reçoivent les rapports d'erreurs des agents de test et corrigent.
### 2.3 Agents de Test
- **Chaque agent de développement est appairé avec un agent de test dédié.**
- Écrivent et exécutent les **tests unitaires** des features implémentées ou modifiées.
- Produisent un **rapport d'erreurs** clair quand un test échoue.
- Re-testent après chaque correction.
---
## 3. Le cycle de développement (boucle obligatoire)
Pour **chaque** feature implémentée ou modifiée :
```
1. Agent Architecture → valide le découpage et les contrats (ports/interfaces)
2. Agent Développement → écrit le code
3. Agent Test → écrit les tests unitaires + les exécute
4a. Tests OK → feature validée, on passe à la suite
4b. Tests KO → rapport d'erreurs → retour à l'agent Développement
→ correction → retour à l'étape 3 (boucle jusqu'au vert)
```
**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas.
Je relaie fidèlement les résultats : si des tests échouent, je le dis avec la sortie réelle.
---
## 4. Principes de code
- **SOLID** appliqué au maximum.
- **Architecture Hexagonale** (Ports & Adapters) : le domaine métier est isolé des détails techniques (UI, terminal, git, SSH, système de fichiers...).
- Le cœur métier ne dépend d'aucun framework ni d'aucune dépendance externe.
- Tests unitaires systématiques ; couverture des features critiques.
- Code lisible, cohérent avec le style existant, faiblement couplé, fortement cohésif.
---
## 5. Vision produit : IdeA
**IdeA est un IDE next-gen 100 % IA.** On n'y code pas : **on gère des IA.**
### Fonctionnalités clés
- **Multi-projets en parallèle** : un **onglet par projet**.
- **Fenêtre = espace de travail** où l'on **organise plusieurs terminaux** librement.
- **Agents par projet** : chaque projet a ses propres agents.
- **Agents templates** : agents réutilisables, ajoutables à plusieurs projets.
- **Création d'agents** : depuis zéro ou à partir d'un template.
- **Synchronisation template → agents** : option « garder l'agent à jour ».
Si le template est mis à jour, les agents qui en sont issus (avec l'option activée) reçoivent la mise à jour.
- **Contextes d'agents stockés en `.md`** (toujours).
- **Création de projet** = définition de son **project root**.
### Intégrations
- **Git** intégré.
- **Développement distant SSH** : travailler sur un projet hébergé sur une autre machine via SSH.
- **Développement WSL** : travailler sur une WSL depuis Windows.
### Plateformes & livraison
- Cible : **macOS, Linux, Windows**.
- Première phase de compilation : **Linux et Windows**.
- Livraison :
- **Windows** : `setup.exe`.
- **Linux** : **AppImage** (doit fonctionner sur les différentes distributions).
---
## 6. Stack technique (validée)
- **Shell applicatif** : **Tauri v2** (binaires légers, performants, multi-OS, AppImage + installeur `setup.exe`/NSIS Windows natifs).
- **Cœur / backend** : **Rust** — stabilité, performance, et expression idiomatique du domaine hexagonal (ports = traits, adapters = implémentations).
- **Frontend / UI** : **TypeScript + React**.
- **Terminaux** : **xterm.js** (rendu) + **portable-pty** (PTY côté Rust).
- **Git** : **libgit2** via `git2` (Rust).
- **SSH** : `russh` / `ssh2` (Rust).
- **WSL** : invocation de `wsl.exe` depuis le backend.
## 7. Layout des terminaux (exigence produit)
Disposition en **grille redimensionnable de type tableur (Excel)** :
- Splits redimensionnables horizontaux **et** verticaux.
- L'utilisateur peut **définir le nombre de colonnes dans une ligne** et **le nombre de lignes dans une colonne**, indépendamment par zone.
- Possibilité de **fusionner des cellules** (ex. fusionner deux colonnes sur une ligne), à la manière des cellules fusionnées d'un tableur.
- Chaque cellule de la grille héberge un terminal.
- → Modèle de layout récursif/imbriqué (pas une grille rigide uniforme) à concevoir par l'agent Architecture.
## 8. Stockage des contextes & liaison aux templates
- **Templates d'agents** : stockés dans l'**IDE** (dossier de données utilisateur global de l'app, hors projet).
- **Agents de projet** : leurs `.md` sont stockés dans un dossier **`.ideai/`** à la racine du project root.
*(Nom choisi pour éviter toute collision avec le `.idea` de JetBrains.)*
- **Manifeste de liaison** dans `.ideai/` (ex. `.ideai/agents.json`) qui mappe pour chaque agent de projet :
- le `.md` de l'agent,
- le template d'origine (le cas échéant),
- `synchronized: true/false`,
- la **version du template** au dernier sync (pour détecter qu'une mise à jour est disponible).
- **Synchro template → agents** : quand un template est mis à jour, les agents liés avec `synchronized: true` reçoivent la MAJ.
## 9. Moteur IA : adaptateur de CLI flexible (Port `AgentRuntime`)
Chaque IA est décrite par un **profil déclaratif** (config éditable, pas du code), implémentation d'un **Port** `AgentRuntime` côté domaine. Deux variables clés par IA :
1. **Commande de lancement** + arguments (ex. `claude`, `codex`, `gemini`, `aider`).
2. **Stratégie d'injection du contexte `.md`** :
- `conventionFile` : écrire/symlink le `.md` vers le fichier attendu par la CLI (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`…).
- `flag` : passer le chemin via un argument.
- `stdin` : piper le contenu.
- `env` : passer via variable d'environnement.
Exemple de profil :
```json
{
"id": "claude-code",
"name": "Claude Code",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": "claude --version",
"cwd": "{projectRoot}"
}
```
**Profils intégrés (références) :** Claude Code (`claude``CLAUDE.md`), OpenAI Codex CLI (`codex``AGENTS.md`), Gemini CLI (`gemini``GEMINI.md`), Aider (`aider` → args/message).
**Règles produit :**
- **Premier lancement de l'IDE** : un assistant (first-run) **demande à l'utilisateur** quels profils d'IA configurer. On ne présume rien par défaut.
- Les commandes des profils sont **pré-remplies mais éditables**.
- L'utilisateur peut **ajouter sa propre commande CLI** (profil custom) pour n'importe quelle IA.
**Lancement d'un agent :** à l'**activation de l'agent**, on ouvre une cellule terminal (PTY) avec le bon `cwd`, on injecte le contexte `.md`, et on **auto-lance** la CLI du profil.
## 10. Fenêtres & onglets
- **Par défaut : un onglet par projet** (comme les IDE classiques).
- **Drag & drop d'un onglet** hors de la fenêtre → **crée une nouvelle fenêtre OS** portant ce projet.
- **Multi-fenêtres OS supporté** ; chaque fenêtre possède un ou plusieurs onglets/projets.
## 11. Feuille de route
1. **Cadrage architecture complet d'abord** (jalon en cours) : l'agent Architecture produit la cartographie complète — domaine, ports, adapters, modules, arborescence — **avant tout code**.
2. Puis MVP incrémental selon le cycle dev/test de la section 3.
## 12. Autonomie d'exécution dans le projet
L'utilisateur m'accorde un **accès large et autonome** sur le dossier du projet : je peux lire, créer, modifier des fichiers et exécuter les commandes de développement (cargo, npm, npx, git, etc.) **sans demander confirmation à chaque fois**.
- Concrètement, ces autorisations sont matérialisées dans `.claude/settings.local.json` (mode `acceptEdits` + `Bash`/`Read`/`Edit`/`Write` autorisés), pas dans ce document — CONTEXT.md ne fait que **documenter l'intention**.
- **Garde-fous conservés** : les actions destructrices ou hors-projet restent bloquées (`sudo`, `rm -rf` sur `/`/`~`/`$HOME`, `mkfs`, `dd`, `shutdown`/`reboot`…).
- L'esprit du rôle (§1) ne change pas : je reste **chef d'orchestre**. L'autonomie porte sur l'exécution mécanique, pas sur l'arbitrage des décisions produit/archi, ni sur les **actions sortantes** (push, publication) qui restent soumises à validation explicite.
---
*Dernière mise à jour : 2026-06-05*

54
.ideai/layouts.json Normal file
View File

@ -0,0 +1,54 @@
{
"version": 1,
"activeId": "9188db80-8535-4786-a20b-3c9a36b222e3",
"layouts": [
{
"id": "9188db80-8535-4786-a20b-3c9a36b222e3",
"name": "Default",
"kind": "terminal",
"tree": {
"root": {
"type": "split",
"node": {
"id": "8aca2f93-1a9b-4693-9bba-9a01e130a48c",
"direction": "row",
"children": [
{
"node": {
"type": "leaf",
"node": {
"id": "d8a86eb1-cd4d-4937-b900-4989da7c868d",
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641"
}
},
"weight": 1.0
},
{
"node": {
"type": "leaf",
"node": {
"id": "6c5be5e7-a54b-468c-a2e2-8ec853629d5e"
}
},
"weight": 1.0
}
]
}
}
}
},
{
"id": "40ea4fa9-25b3-410b-9d3e-6350834b421b",
"name": "Git Graph",
"kind": "gitGraph",
"tree": {
"root": {
"type": "leaf",
"node": {
"id": "c840bfdd-3330-46a3-b727-0799f6853e72"
}
}
}
}
]
}

9
.ideai/project.json Normal file
View File

@ -0,0 +1,9 @@
{
"version": 1,
"id": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023",
"name": "IdeA",
"remote": {
"kind": "local"
},
"createdAt": 1780702317785
}

View File

@ -6,6 +6,8 @@
> Architecture **Hexagonale (Ports & Adapters)** + **SOLID**, stricte. > 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. > 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.
--- ---
@ -134,7 +136,7 @@ Workspace 1───* Window 1───* Tab 1───1 Project
- Invariants : `version` **monotone croissante** ; toute modification du `content_md``version + 1` (voir §8). - Invariants : `version` **monotone croissante** ; toute modification du `content_md``version + 1` (voir §8).
**`AgentProfile`** (entité de config runtime IA — le port `AgentRuntime` est paramétré par elle) **`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` (ex. `"{projectRoot}"`). - 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). - Invariants : `command` non vide ; cohérence de `ContextInjection` (voir VO ci-dessous).
**`ContextInjection`** (VO, enum — cœur du moteur IA flexible) **`ContextInjection`** (VO, enum — cœur du moteur IA flexible)
@ -177,7 +179,12 @@ RemoteRef =
- `Tab` = onglet ⇔ **un `Project`** (1:1). - `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. - 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.
**`DomainEvent`** (enum) — `ProjectCreated`, `AgentLaunched`, `AgentExited`, `TemplateUpdated`, `AgentDriftDetected`, `LayoutChanged`, `RemoteConnected`, `GitStateChanged`, `PtyOutput{session_id, bytes}` (ce dernier souvent court-circuité vers un Channel). **`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`, `AgentExited`, `TemplateUpdated`, `AgentDriftDetected`, `SkillAssigned`, `LayoutChanged`, `RemoteConnected`, `GitStateChanged`, `PtyOutput{session_id, bytes}`, `OrchestratorRequest{requester_id, action}` (ce dernier souvent court-circuité vers un Channel).
--- ---
@ -451,11 +458,19 @@ drift(agent) =
│ ├── agents.json # AgentManifest (mapping md ↔ template ↔ sync ↔ version) │ ├── agents.json # AgentManifest (mapping md ↔ template ↔ sync ↔ version)
│ ├── layout.json # LayoutTree de l'onglet (sérialisé) │ ├── layout.json # LayoutTree de l'onglet (sérialisé)
│ ├── project.json # méta projet local (nom, profil par défaut, remote ref) │ ├── project.json # méta projet local (nom, profil par défaut, remote ref)
── agents/ ── agents/
├── reviewer.md # contexte d'un agent de projet ├── reviewer.md # contexte d'un agent de projet
├── backend-dev.md ├── 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)
│ └── ... │ └── ...
└── (CLAUDE.md / AGENTS.md / GEMINI.md générés/symlinkés à l'activation si conventionFile) └── (aucun CLAUDE.md/AGENTS.md/GEMINI.md à la racine — jamais — voir §14.1)
``` ```
**Schéma `agents.json`** : **Schéma `agents.json`** :
@ -504,6 +519,8 @@ Emplacement résolu via Tauri path API (`AppData`/`~/.local/share/IdeA`/`~/Libra
**Formats** : contextes & templates en **Markdown** ; tout le reste en **JSON** (serde). Pas de base de données : fichiers plats, simples, diffables, portables (AppImage friendly). **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. Arborescence du repo
@ -581,6 +598,73 @@ IdeA/
| L9 | **Remote (SSH + WSL)** | `RemoteHost` stratégie, `SshHost`/`WslHost`, adapters FS/PTY/Spawner distants, `RemoteGitRepository`. UI connexion. | `infrastructure/remote`, `application/remote`, `frontend/features/remote` | | 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` | | 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 | | 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`, actions `spawn_agent`/`stop_agent`/`update_agent_context`. | `infrastructure/orchestrator`, `application/agent`, `app-tauri` |
---
## 14. Décisions d'architecture figées (2026-06-06)
### 14.1 Isolation du cwd par agent — résolution de la collision de contexte
**Problème** : plusieurs agents du même profil (ex. deux instances Claude Code) sur le même project root produisaient une collision — le fichier de convention (`CLAUDE.md`, `AGENTS.md`…) est un emplacement fixe unique à la racine.
**Décision** : le cwd du PTY d'un agent n'est **jamais** le project root. C'est `.ideai/run/<agent-id>/`, un dossier créé par IdeA à l'activation et nettoyé à la fermeture.
**Convention file généré par IdeA** : IdeA écrit dans ce dossier le fichier conventionnel attendu par le profil (`CLAUDE.md`, `AGENTS.md`, etc.). Ce fichier contient :
1. La **persona/rôle** de l'agent (son `.md` dans `.ideai/agents/`).
2. Le **chemin absolu du project root** (pour que l'agent sache où opérer).
3. Les **skills actifs** assignés à cet agent (voir §14.2).
4. Une référence au **contexte projet partagé** si présent.
**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 — spawn d'agents depuis un agent ou depuis l'UI
**Objectif** : qu'un agent orchestrateur puisse demander à IdeA de créer un nouvel agent (visible dans la grille et dans l'onglet Agents), exactement comme le ferait l'utilisateur via l'UI.
**Mécanisme** : file-watching sur `.ideai/requests/<requester-id>/`. L'orchestrateur écrit un fichier JSON de requête :
```json
{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code", "context": "agents/dev-backend.md" }
```
IdeA détecte le fichier, exécute la même logique que `LaunchAgent` déclenché depuis l'UI, crée la cellule terminal, inscrit l'agent dans l'onglet Agents, puis supprime le fichier et écrit une réponse.
**Règle** : l'orchestrateur ne spawne jamais lui-même un process CLI — il **délègue à IdeA**. IdeA reste l'unique source de vérité du cycle de vie des agents.
**Port `OrchestratorApi`** (adapter entrant, driven by file-watcher) : surveille `.ideai/requests/`, désérialise les commandes, les traduit en appels de use cases (`LaunchAgent`, `StopAgent`…). Implémenté dans `infrastructure/orchestrator`.
**Actions supportées (v1)** : `spawn_agent`, `stop_agent`, `update_agent_context`.
---
### 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.
--- ---
@ -591,7 +675,7 @@ IdeA/
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. 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. 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. 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). À cadrer L6. 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. 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. 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. 9. **Détection d'édition hors-app** des `.md`/templates (content hash) et résolution de conflit lors du sync. L7.

187
CLAUDE.md Normal file
View File

@ -0,0 +1,187 @@
# IdeA — Contexte & Méthode de travail
> Ce document définit **mon rôle**, **la méthode de développement** et **la vision produit** du projet IdeA.
> Il fait autorité sur la façon dont le projet est piloté. Toute évolution de méthode doit être répercutée ici.
---
## 1. Mon rôle : chef d'orchestre, pas développeur
Je **n'écris pas de code moi-même**. Mon rôle est de **piloter des agents** qui réalisent le travail.
Je suis responsable de :
- Découper le travail en tâches claires et autonomes.
- Attribuer chaque tâche aux bons agents.
- Garantir que le cycle de développement/test est respecté.
- Faire respecter les principes d'architecture (SOLID, Hexagonal).
- Maintenir la cohérence globale du projet et de ce document.
- Arbitrer et valider avant toute action irréversible ou sortante.
---
## 2. Les agents
### 2.1 Agent Architecture (1 pour tout le projet)
- Garant de l'architecture globale : **Hexagonale (Ports & Adapters)** et principes **SOLID**.
- Définit les frontières (domaine / application / infrastructure), les ports, les contrats.
- Valide que chaque nouvelle feature respecte la structure avant son développement.
- Tient à jour la cartographie d'architecture et les conventions.
### 2.2 Agents de Développement
- Écrivent le code des features.
- Respectent strictement l'architecture définie par l'agent Architecture.
- Code **propre, structuré, stable**.
- Reçoivent les rapports d'erreurs des agents de test et corrigent.
### 2.3 Agents de Test
- **Chaque agent de développement est appairé avec un agent de test dédié.**
- Écrivent et exécutent les **tests unitaires** des features implémentées ou modifiées.
- Produisent un **rapport d'erreurs** clair quand un test échoue.
- Re-testent après chaque correction.
---
## 3. Le cycle de développement (boucle obligatoire)
Pour **chaque** feature implémentée ou modifiée :
```
1. Agent Architecture → valide le découpage et les contrats (ports/interfaces)
2. Agent Développement → écrit le code
3. Agent Test → écrit les tests unitaires + les exécute
4a. Tests OK → feature validée, on passe à la suite
4b. Tests KO → rapport d'erreurs → retour à l'agent Développement
→ correction → retour à l'étape 3 (boucle jusqu'au vert)
```
**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas.
Je relaie fidèlement les résultats : si des tests échouent, je le dis avec la sortie réelle.
---
## 4. Principes de code
- **SOLID** appliqué au maximum.
- **Architecture Hexagonale** (Ports & Adapters) : le domaine métier est isolé des détails techniques (UI, terminal, git, SSH, système de fichiers...).
- Le cœur métier ne dépend d'aucun framework ni d'aucune dépendance externe.
- Tests unitaires systématiques ; couverture des features critiques.
- Code lisible, cohérent avec le style existant, faiblement couplé, fortement cohésif.
---
## 5. Vision produit : IdeA
**IdeA est un IDE next-gen 100 % IA.** On n'y code pas : **on gère des IA.**
### Fonctionnalités clés
- **Multi-projets en parallèle** : un **onglet par projet**.
- **Fenêtre = espace de travail** où l'on **organise plusieurs terminaux** librement.
- **Agents par projet** : chaque projet a ses propres agents.
- **Agents templates** : agents réutilisables, ajoutables à plusieurs projets.
- **Création d'agents** : depuis zéro ou à partir d'un template.
- **Synchronisation template → agents** : option « garder l'agent à jour ».
Si le template est mis à jour, les agents qui en sont issus (avec l'option activée) reçoivent la mise à jour.
- **Contextes d'agents stockés en `.md`** (toujours).
- **Création de projet** = définition de son **project root**.
### Intégrations
- **Git** intégré.
- **Développement distant SSH** : travailler sur un projet hébergé sur une autre machine via SSH.
- **Développement WSL** : travailler sur une WSL depuis Windows.
### Plateformes & livraison
- Cible : **macOS, Linux, Windows**.
- Première phase de compilation : **Linux et Windows**.
- Livraison :
- **Windows** : `setup.exe`.
- **Linux** : **AppImage** (doit fonctionner sur les différentes distributions).
---
## 6. Stack technique (validée)
- **Shell applicatif** : **Tauri v2** (binaires légers, performants, multi-OS, AppImage + installeur `setup.exe`/NSIS Windows natifs).
- **Cœur / backend** : **Rust** — stabilité, performance, et expression idiomatique du domaine hexagonal (ports = traits, adapters = implémentations).
- **Frontend / UI** : **TypeScript + React**.
- **Terminaux** : **xterm.js** (rendu) + **portable-pty** (PTY côté Rust).
- **Git** : **libgit2** via `git2` (Rust).
- **SSH** : `russh` / `ssh2` (Rust).
- **WSL** : invocation de `wsl.exe` depuis le backend.
## 7. Layout des terminaux (exigence produit)
Disposition en **grille redimensionnable de type tableur (Excel)** :
- Splits redimensionnables horizontaux **et** verticaux.
- L'utilisateur peut **définir le nombre de colonnes dans une ligne** et **le nombre de lignes dans une colonne**, indépendamment par zone.
- Possibilité de **fusionner des cellules** (ex. fusionner deux colonnes sur une ligne), à la manière des cellules fusionnées d'un tableur.
- Chaque cellule de la grille héberge un terminal.
- → Modèle de layout récursif/imbriqué (pas une grille rigide uniforme) à concevoir par l'agent Architecture.
## 8. Stockage des contextes & liaison aux templates
- **Templates d'agents** : stockés dans l'**IDE** (dossier de données utilisateur global de l'app, hors projet).
- **Agents de projet** : leurs `.md` sont stockés dans un dossier **`.ideai/`** à la racine du project root.
*(Nom choisi pour éviter toute collision avec le `.idea` de JetBrains.)*
- **Manifeste de liaison** dans `.ideai/` (ex. `.ideai/agents.json`) qui mappe pour chaque agent de projet :
- le `.md` de l'agent,
- le template d'origine (le cas échéant),
- `synchronized: true/false`,
- la **version du template** au dernier sync (pour détecter qu'une mise à jour est disponible).
- **Synchro template → agents** : quand un template est mis à jour, les agents liés avec `synchronized: true` reçoivent la MAJ.
## 9. Moteur IA : adaptateur de CLI flexible (Port `AgentRuntime`)
Chaque IA est décrite par un **profil déclaratif** (config éditable, pas du code), implémentation d'un **Port** `AgentRuntime` côté domaine. Deux variables clés par IA :
1. **Commande de lancement** + arguments (ex. `claude`, `codex`, `gemini`, `aider`).
2. **Stratégie d'injection du contexte `.md`** :
- `conventionFile` : écrire/symlink le `.md` vers le fichier attendu par la CLI (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`…).
- `flag` : passer le chemin via un argument.
- `stdin` : piper le contenu.
- `env` : passer via variable d'environnement.
Exemple de profil :
```json
{
"id": "claude-code",
"name": "Claude Code",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": "claude --version",
"cwd": "{projectRoot}"
}
```
**Profils intégrés (références) :** Claude Code (`claude``CLAUDE.md`), OpenAI Codex CLI (`codex``AGENTS.md`), Gemini CLI (`gemini``GEMINI.md`), Aider (`aider` → args/message).
**Règles produit :**
- **Premier lancement de l'IDE** : un assistant (first-run) **demande à l'utilisateur** quels profils d'IA configurer. On ne présume rien par défaut.
- Les commandes des profils sont **pré-remplies mais éditables**.
- L'utilisateur peut **ajouter sa propre commande CLI** (profil custom) pour n'importe quelle IA.
**Lancement d'un agent :** à l'**activation de l'agent**, on ouvre une cellule terminal (PTY) avec le bon `cwd`, on injecte le contexte `.md`, et on **auto-lance** la CLI du profil.
## 10. Fenêtres & onglets
- **Par défaut : un onglet par projet** (comme les IDE classiques).
- **Drag & drop d'un onglet** hors de la fenêtre → **crée une nouvelle fenêtre OS** portant ce projet.
- **Multi-fenêtres OS supporté** ; chaque fenêtre possède un ou plusieurs onglets/projets.
## 11. Feuille de route
1. **Cadrage architecture complet d'abord** (jalon en cours) : l'agent Architecture produit la cartographie complète — domaine, ports, adapters, modules, arborescence — **avant tout code**.
2. Puis MVP incrémental selon le cycle dev/test de la section 3.
## 12. Autonomie d'exécution dans le projet
L'utilisateur m'accorde un **accès large et autonome** sur le dossier du projet : je peux lire, créer, modifier des fichiers et exécuter les commandes de développement (cargo, npm, npx, git, etc.) **sans demander confirmation à chaque fois**.
- Concrètement, ces autorisations sont matérialisées dans `.claude/settings.local.json` (mode `acceptEdits` + `Bash`/`Read`/`Edit`/`Write` autorisés), pas dans ce document — CONTEXT.md ne fait que **documenter l'intention**.
- **Garde-fous conservés** : les actions destructrices ou hors-projet restent bloquées (`sudo`, `rm -rf` sur `/`/`~`/`$HOME`, `mkfs`, `dd`, `shutdown`/`reboot`…).
- L'esprit du rôle (§1) ne change pas : je reste **chef d'orchestre**. L'autonomie porte sur l'exécution mécanique, pas sur l'arbitrage des décisions produit/archi, ni sur les **actions sortantes** (push, publication) qui restent soumises à validation explicite.
---
*Dernière mise à jour : 2026-06-05*

42
agents-dev/L12-skills.md Normal file
View File

@ -0,0 +1,42 @@
# L12 — Skills
**Binôme :** `dev-skills` / `test-skills`
**Zones :** `domain/skill`, `application/skill`, `infrastructure/store`, `frontend/features/skills`
**Dépendances amont :** L0, L1, L5, L6 (convention file généré à l'activation), L7 (store global réutilisé).
## Objectif
Modéliser les **Skills** : workflows réutilisables (équivalent universel des slash-commands, sans dépendance à la syntaxe `/command` d'un modèle). Stockage global IDE + projet, assignation agent↔skills, **injection des skills assignés dans le convention file** généré à l'activation de l'agent. Cf. ARCHITECTURE §14.2.
## Périmètre (DEV)
- **Domaine** : entité `Skill { id, name, content_md: MarkdownDoc, scope: SkillScope(Global|Project) }`. Invariants : `name` non vide, `content_md` non vide. Event `SkillAssigned`.
- **Port `SkillStore`** : CRUD skills globaux (`<app_data>/IdeA/skills/`) + skills projet (`.ideai/skills/<name>.md`), résolution selon `scope` (compose `FileSystem`/store global comme L7).
- **AgentManifest** : étendre pour porter la liste `skills: Vec<SkillRef>` assignés à chaque agent (0..N).
- **Use cases** (`application/skill`) : `CreateSkill`, `UpdateSkill`, `DeleteSkill`, `ListSkills(scope)`, `AssignSkillToAgent`, `UnassignSkillFromAgent`.
- **Injection** : à l'activation (fil L6), composer le convention file en concaténant persona agent + chemin project root + **skills assignés** (lus via `SkillStore`). Pas de mécanisme CLI propriétaire.
- **Front** : onglet/section Skills (liste globale + projet, CRUD, éditeur md), assignation skills↔agent dans `AgentsPanel`.
## Périmètre (TEST)
- `Skill` rejette `name`/`content_md` vides.
- `SkillStore` : CRUD round-trip en tmpdir pour les deux scopes ; un skill `Project` n'apparaît pas dans le scope `Global` et inversement.
- `AssignSkillToAgent` / `UnassignSkillFromAgent` : mutent l'`AgentManifest`, émettent `SkillAssigned`, idempotents (pas de doublon).
- **Injection** : le convention file généré contient bien le `content_md` des skills assignés et **rien** des skills non assignés ; ordre déterministe.
- Front : CRUD skills + assignation via gateway mock (RTL) ; garde-fou « no direct invoke ».
## Definition of Done
- `cargo test` (skill/store/app) + `vitest` verts ; cycle manuel : créer un skill, l'assigner à un agent, l'activer → le skill apparaît dans le convention file de `.ideai/run/<agent-id>/`.
- DoD commune (cf. README) respectée ; zéro régression.
## Avancement
### ✅ Domaine (vert)
- **Entité `Skill`** (`domain/skill.rs`) : `id: SkillId`, `name`, `content_md: MarkdownDoc`, `scope: SkillScope(Global|Project)`. Constructeur validant (`name` + `content_md` non vides), `with_content` re-valide l'invariant.
- **`SkillRef { skill_id, scope }`** : référence d'assignation portée par l'agent ; `From<&Skill>`.
- **`SkillId`** ajouté (`ids.rs`), event **`SkillAssigned { agent_id, skill_id, assigned }`** (`events.rs`), DTO + arm de mapping côté `app-tauri` (`events.rs`).
- **`Agent`** étendu : champ `skills: Vec<SkillRef>` (serde `default`), méthodes `assign_skill` (idempotent), `unassign_skill`, `with_skills` (dédup). **`ManifestEntry`** : champ `skills` (serde `default` + `skip_serializing_if` → rétrocompat des manifests pré-L12) ; `from_agent`/`to_agent` préservent les skills.
- **Tests** : 8 invariants (`entities.rs`) + 3 serde dont rétrocompat d'un manifest legacy sans clé `skills` (`serde_roundtrip.rs`). `cargo test -p domain` vert ; `cargo test --workspace` vert (0 régression) ; clippy clean.
### ⏳ Reste à faire
- Port `SkillStore` (`domain/ports.rs`) + adapter `FsSkillStore` (`infrastructure/store`).
- Use cases `application/skill` : CRUD + `AssignSkillToAgent`/`UnassignSkillFromAgent`.
- Injection des skills assignés dans le convention file à l'activation (fil L6).
- IPC `app-tauri` + front `features/skills`.

View File

@ -0,0 +1,35 @@
# L13 — OrchestratorApi
**Binôme :** `dev-orchestrator` / `test-orchestrator`
**Zones :** `infrastructure/orchestrator`, `application/agent`, `app-tauri`
**Dépendances amont :** L0, L1, L6 (`LaunchAgent`/`StopAgent`), L12 (`update_agent_context` peut toucher les skills).
## Objectif
Permettre à un **agent orchestrateur** de demander à IdeA de créer/arrêter/mettre à jour un agent — **exactement** comme l'utilisateur via l'UI. L'orchestrateur ne spawne jamais lui-même un process CLI : il **délègue à IdeA**, unique source de vérité du cycle de vie des agents. Cf. ARCHITECTURE §14.3.
## Périmètre (DEV)
- **Port `OrchestratorApi`** (adapter *entrant*, driven by file-watcher) : surveille `.ideai/requests/<requester-id>/`, désérialise les requêtes JSON, les traduit en appels de use cases.
- **Adapter `FsOrchestratorAdapter`** (`infrastructure/orchestrator`) : file-watching (`notify`), parse, dispatch, **supprime le fichier de requête** et **écrit une réponse** (succès/erreur) à côté.
- **Actions v1** : `spawn_agent` (→ `LaunchAgent`), `stop_agent` (→ `StopAgent`), `update_agent_context` (réécrit le `.md` de l'agent ± skills).
- **Event** : `OrchestratorRequest { requester_id, action }`.
- **Schéma requête** :
```json
{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code", "context": "agents/dev-backend.md" }
```
- Le résultat d'un `spawn_agent` est **identique** à un lancement UI : cellule terminal créée, agent inscrit dans l'onglet Agents.
- **Composition root** (`app-tauri`) : démarrer le watcher, brancher sur les use cases existants ; arrêt propre à la fermeture.
## Périmètre (TEST)
- Désérialisation : requêtes valides → action typée ; requête malformée → réponse d'erreur, **pas de crash**, pas de spawn.
- `spawn_agent` invoque `LaunchAgent` avec les bons args (use case mocké) ; idempotence sur double-dépôt du même fichier (traité une fois).
- Après traitement : fichier de requête **supprimé**, fichier de réponse écrit avec le bon statut.
- `stop_agent` / `update_agent_context` : mappent vers les bons use cases ; cible inexistante → erreur propre.
- Watcher : un fichier déposé dans `.ideai/requests/<id>/` est détecté (test d'intégration tmpdir + `notify`).
## Definition of Done
- `cargo test` (orchestrator/app) verts ; cycle manuel : un agent écrit un fichier de requête `spawn_agent` → un nouvel agent apparaît dans la grille et l'onglet Agents, fichier consommé + réponse écrite.
- Garde-fou : l'orchestrateur ne lance **aucun** process directement (vérifié par revue + absence de `ProcessSpawner` dans le chemin orchestrateur).
- DoD commune respectée ; zéro régression ; git reste optionnel (rien dans ce lot n'en dépend, §14.4).
## Avancement
⬜ À démarrer. Cadrage figé dans ARCHITECTURE §14.3.

View File

@ -26,6 +26,8 @@ Un fichier `Lx-*.md` par binôme décrit son périmètre, ses ports/adapters, se
| L9 | [L9-remote.md](L9-remote.md) | 🟡 **socle vert** (LocalHost + ConnectRemote, Liskov) · SSH/WSL gated à venir | | L9 | [L9-remote.md](L9-remote.md) | 🟡 **socle vert** (LocalHost + ConnectRemote, Liskov) · SSH/WSL gated à venir |
| L10 | [L10-windows.md](L10-windows.md) | 🟡 **backend + IPC verts** (move-tab + `WebviewWindow`) · détach UI fait avec la refonte L11 | | L10 | [L10-windows.md](L10-windows.md) | 🟡 **backend + IPC verts** (move-tab + `WebviewWindow`) · détach UI fait avec la refonte L11 |
| L11 | [L11-packaging.md](L11-packaging.md) | 🟡 AppImage Linux **OK** · refonte disposition IDE en cours · Windows/CI à venir | | L11 | [L11-packaging.md](L11-packaging.md) | 🟡 AppImage Linux **OK** · refonte disposition IDE en cours · Windows/CI à venir |
| L12 | [L12-skills.md](L12-skills.md) | ⬜ **Skills** — entité + store + assignation + injection convention file + UI |
| L13 | [L13-orchestrator.md](L13-orchestrator.md) | ⬜ **OrchestratorApi** — file-watcher, spawn depuis agent ou UI, même résultat |
## Cycle dev ↔ test (obligatoire, cf. CONTEXT §3) ## Cycle dev ↔ test (obligatoire, cf. CONTEXT §3)

View File

@ -28,7 +28,7 @@ use crate::dto::{
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, OpenTerminalRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, OpenTerminalRequestDto,
ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto,
RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto, ReattachResultDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto,
SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto,
UpdateTemplateRequestDto, WriteTerminalRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto,
@ -244,6 +244,69 @@ pub async fn close_terminal(
result result
} }
/// `reattach_terminal` — re-bind a view to a **still-living** PTY without
/// re-spawning it.
///
/// Navigation (switching layout/tab) tears the xterm view down but must NOT kill
/// the backend PTY (the AI keeps running). When the view comes back it calls this
/// command, which:
/// 1. reads the session's retained **scrollback** so the terminal can repaint,
/// 2. registers the new per-session [`Channel`] in the [`PtyBridge`],
/// 3. starts a fresh output pump subscribed to the live PTY (re-subscribable
/// broadcast), so new bytes flow to the new channel.
///
/// Returns the scrollback bytes; the frontend writes them into xterm first, then
/// receives subsequent output over `on_output`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND`/`PROCESS`
/// if the session is no longer alive).
#[tauri::command]
pub fn reattach_terminal(
session_id: String,
on_output: Channel<PtyChunk>,
state: State<'_, AppState>,
) -> Result<ReattachResultDto, ErrorDto> {
let sid = parse_session_id(&session_id)?;
let handle = PtyHandle { session_id: sid };
// (1) Snapshot the scrollback. A NotFound here means the PTY is gone (was
// explicitly closed or exited) — surfaced as an error so the caller falls
// back to opening a fresh terminal.
let scrollback = state
.pty_port
.scrollback(&handle)
.map_err(|e| ErrorDto::from(AppError::from(e)))?;
// (2) Register the new output channel for this session, replacing any stale
// one from a previous attach.
state.pty_bridge.register(sid, on_output);
// (3) Subscribe afresh to the live byte stream and pump it to the channel.
match state.pty_port.subscribe_output(&handle) {
Ok(stream) => {
let bridge: std::sync::Arc<PtyBridge> = std::sync::Arc::clone(&state.pty_bridge);
std::thread::spawn(move || {
for chunk in stream {
if !bridge.send_output(&sid, chunk) {
break;
}
}
bridge.unregister(&sid);
});
}
Err(e) => {
state.pty_bridge.unregister(&sid);
return Err(ErrorDto::from(AppError::from(e)));
}
}
Ok(ReattachResultDto {
session_id,
scrollback,
})
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Layout (L4) // Layout (L4)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -245,6 +245,18 @@ impl From<OpenTerminalOutput> for TerminalSessionDto {
} }
} }
/// Response DTO for `reattach_terminal`: the retained scrollback of a still-live
/// session, repainted into the re-mounting xterm before the new output stream is
/// wired. Bytes are serialised as a number array, matching the PTY output channel.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReattachResultDto {
/// The session that was re-attached (echoed back for the frontend).
pub session_id: String,
/// The most-recent retained output bytes (scrollback ring buffer).
pub scrollback: Vec<u8>,
}
/// Request DTO for `write_terminal`. /// Request DTO for `write_terminal`.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]

View File

@ -73,6 +73,16 @@ pub enum DomainEventDto {
/// Version synced to. /// Version synced to.
to: u64, to: u64,
}, },
/// A skill was assigned to (or unassigned from) an agent.
#[serde(rename_all = "camelCase")]
SkillAssigned {
/// Agent id.
agent_id: String,
/// Skill id.
skill_id: String,
/// `true` if assigned, `false` if unassigned.
assigned: bool,
},
/// A tab's layout changed. /// A tab's layout changed.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
LayoutChanged { LayoutChanged {
@ -138,6 +148,15 @@ impl From<&DomainEvent> for DomainEventDto {
agent_id: agent_id.to_string(), agent_id: agent_id.to_string(),
to: to.get(), to: to.get(),
}, },
DomainEvent::SkillAssigned {
agent_id,
skill_id,
assigned,
} => Self::SkillAssigned {
agent_id: agent_id.to_string(),
skill_id: skill_id.to_string(),
assigned: *assigned,
},
DomainEvent::LayoutChanged { project_id } => Self::LayoutChanged { DomainEvent::LayoutChanged { project_id } => Self::LayoutChanged {
project_id: project_id.to_string(), project_id: project_id.to_string(),
}, },

View File

@ -48,6 +48,28 @@ pub fn run() {
events::spawn_relay(app.handle().clone(), &app_state.event_bus); events::spawn_relay(app.handle().clone(), &app_state.event_bus);
app.manage(app_state); app.manage(app_state);
// Kill all live PTYs cleanly when the main window is closing. This is
// independent of the per-view (navigation/layout) lifecycle — those
// must NEVER kill a PTY — and only fires on a genuine app shutdown.
// A brutal crash is best-effort and out of scope.
if let Some(window) = app.get_webview_window("main") {
let handle = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
if let Some(state) = handle.try_state::<AppState>() {
let pty = std::sync::Arc::clone(&state.pty_port);
let handles = state.terminal_sessions.handles();
tauri::async_runtime::block_on(async move {
for h in handles {
let _ = pty.kill(&h).await;
}
});
}
}
});
}
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
@ -60,6 +82,7 @@ pub fn run() {
commands::write_terminal, commands::write_terminal,
commands::resize_terminal, commands::resize_terminal,
commands::close_terminal, commands::close_terminal,
commands::reattach_terminal,
commands::load_layout, commands::load_layout,
commands::mutate_layout, commands::mutate_layout,
commands::list_layouts, commands::list_layouts,

View File

@ -4,8 +4,8 @@
use app_tauri_lib::dto::{ use app_tauri_lib::dto::{
parse_node_id, parse_session_id, ErrorDto, HealthRequestDto, HealthResponseDto, LayoutDto, parse_node_id, parse_session_id, ErrorDto, HealthRequestDto, HealthResponseDto, LayoutDto,
LayoutOperationDto, OpenTerminalRequestDto, ResizeTerminalRequestDto, TerminalClosedDto, LayoutOperationDto, OpenTerminalRequestDto, ReattachResultDto, ResizeTerminalRequestDto,
WriteTerminalRequestDto, TerminalClosedDto, WriteTerminalRequestDto,
}; };
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput}; use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId}; use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
@ -191,6 +191,16 @@ fn terminal_closed_dto_serialises_code_camel_case() {
assert_eq!(serde_json::to_value(&none).unwrap(), json!({ "code": null })); assert_eq!(serde_json::to_value(&none).unwrap(), json!({ "code": null }));
} }
#[test]
fn reattach_result_dto_serialises_camel_case() {
let dto = ReattachResultDto {
session_id: "sess-1".into(),
scrollback: vec![104, 105],
};
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v, json!({ "sessionId": "sess-1", "scrollback": [104, 105] }));
}
#[test] #[test]
fn parse_session_id_accepts_uuid_and_rejects_garbage() { fn parse_session_id_accepts_uuid_and_rejects_garbage() {
let sid = SessionId::from_uuid(Uuid::nil()); let sid = SessionId::from_uuid(Uuid::nil());

View File

@ -53,7 +53,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
ContextInjection::convention_file("CLAUDE.md") ContextInjection::convention_file("CLAUDE.md")
.expect("CLAUDE.md is a valid convention target"), .expect("CLAUDE.md is a valid convention target"),
Some("claude --version".to_owned()), Some("claude --version".to_owned()),
"{projectRoot}", "{agentRunDir}",
) )
.expect("claude reference profile is valid"), .expect("claude reference profile is valid"),
AgentProfile::new( AgentProfile::new(
@ -64,7 +64,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
ContextInjection::convention_file("AGENTS.md") ContextInjection::convention_file("AGENTS.md")
.expect("AGENTS.md is a valid convention target"), .expect("AGENTS.md is a valid convention target"),
Some("codex --version".to_owned()), Some("codex --version".to_owned()),
"{projectRoot}", "{agentRunDir}",
) )
.expect("codex reference profile is valid"), .expect("codex reference profile is valid"),
AgentProfile::new( AgentProfile::new(
@ -75,7 +75,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
ContextInjection::convention_file("GEMINI.md") ContextInjection::convention_file("GEMINI.md")
.expect("GEMINI.md is a valid convention target"), .expect("GEMINI.md is a valid convention target"),
Some("gemini --version".to_owned()), Some("gemini --version".to_owned()),
"{projectRoot}", "{agentRunDir}",
) )
.expect("gemini reference profile is valid"), .expect("gemini reference profile is valid"),
AgentProfile::new( AgentProfile::new(
@ -86,7 +86,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
ContextInjection::flag("--message-file {path}") ContextInjection::flag("--message-file {path}")
.expect("aider flag template is non-empty"), .expect("aider flag template is non-empty"),
Some("aider --version".to_owned()), Some("aider --version".to_owned()),
"{projectRoot}", "{agentRunDir}",
) )
.expect("aider reference profile is valid"), .expect("aider reference profile is valid"),
] ]

View File

@ -421,24 +421,38 @@ impl LaunchAgent {
AppError::NotFound(format!("profile {} for agent", agent.profile_id)) AppError::NotFound(format!("profile {} for agent", agent.profile_id))
})?; })?;
// 3. Prepare the invocation (pure): command + args + injection plan + cwd. // 3. Compute and create the agent's isolated run directory
// `<root>/.ideai/run/<agent-id>/` (ARCHITECTURE §14.1). The PTY cwd is
// *never* the project root: each agent gets its own directory so that N
// instances of the same profile never collide on a single conventional
// file (CLAUDE.md, …). This is the only I/O in the cwd resolution; the
// runtime's `prepare_invocation` stays pure.
let run_dir = agent_run_dir(&input.project.root, &agent.id)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.fs
.create_dir_all(&RemotePath::new(run_dir.as_str().to_owned()))
.await?;
// 4. Prepare the invocation (pure): command + args + injection plan + cwd.
// The run dir is passed as the cwd base; the profile's `{agentRunDir}`
// placeholder resolves against it.
let prepared = PreparedContext { let prepared = PreparedContext {
content: content.clone(), content: content.clone(),
relative_path: agent.context_path.clone(), relative_path: agent.context_path.clone(),
}; };
let mut spec = self let mut spec = self
.runtime .runtime
.prepare_invocation(&profile, &prepared, &input.project.root)?; .prepare_invocation(&profile, &prepared, &run_dir)?;
// 4. Apply the injection plan side effects *before* spawning. // 5. Apply the injection plan side effects *before* spawning.
self.apply_injection(&input.project, &agent.context_path, &content, &mut spec) self.apply_injection(&input.project, &agent.context_path, &content, &mut spec)
.await?; .await?;
// 5. Spawn the PTY at the resolved cwd; adopt its session id everywhere. // 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
let handle = self.pty.spawn(spec.clone(), size).await?; let handle = self.pty.spawn(spec.clone(), size).await?;
let session_id = handle.session_id; let session_id = handle.session_id;
// 6. For the Stdin strategy, pipe the context once the PTY is live. // 7. For the Stdin strategy, pipe the context once the PTY is live.
if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) { if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) {
self.pty.write(&handle, content.as_str().as_bytes())?; self.pty.write(&handle, content.as_str().as_bytes())?;
} }
@ -477,12 +491,16 @@ impl LaunchAgent {
) -> Result<(), AppError> { ) -> Result<(), AppError> {
match spec.context_plan.clone() { match spec.context_plan.clone() {
Some(ContextInjectionPlan::File { target }) => { Some(ContextInjectionPlan::File { target }) => {
// conventionFile spike (ARCHITECTURE §13.6): copy the context to the // conventionFile (ARCHITECTURE §14.1): IdeA *generates* the
// conventional file (e.g. CLAUDE.md), overwriting any existing one. // conventional file (e.g. CLAUDE.md) inside the agent's isolated
// A copy (not a symlink) is the portable choice — Windows symlinks // run directory — `spec.cwd` is that run dir, never the project
// need privileges and SFTP/WSL symlink semantics differ. // root, so there is zero collision between agents. The document is
// composed: an absolute project-root header (so the agent knows
// where to operate, since its cwd is *not* the root) followed by
// the agent's persona `.md`.
let document = compose_convention_file(project.root.as_str(), content.as_str());
let path = RemotePath::new(join(&spec.cwd, &target)); let path = RemotePath::new(join(&spec.cwd, &target));
self.fs.write(&path, content.as_str().as_bytes()).await?; self.fs.write(&path, document.as_bytes()).await?;
} }
Some(ContextInjectionPlan::Env { var }) => { Some(ContextInjectionPlan::Env { var }) => {
// Hand the CLI the absolute path of the agent's `.md` (which lives at // Hand the CLI the absolute path of the agent's `.md` (which lives at
@ -505,6 +523,40 @@ fn join(base: &ProjectPath, rel: &str) -> String {
format!("{b}/{rel}") format!("{b}/{rel}")
} }
/// Computes an agent's isolated run directory `<root>/.ideai/run/<agent-id>/`
/// (ARCHITECTURE §14.1). This is the PTY cwd for the agent — never the project
/// root — guaranteeing that two distinct agents on the same project root get two
/// distinct cwd (the anti-collision contract).
///
/// # Errors
/// Propagates [`DomainError`](domain::error::DomainError) if the joined path is
/// not a valid [`ProjectPath`] (should not happen for an absolute project root).
fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result<ProjectPath, domain::error::DomainError> {
ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}")))
}
/// Composes the convention file IdeA writes into an agent's run directory: an
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
/// so it must be told where to work) followed by the agent's persona `.md`.
///
/// Kept as a **pure** function (no I/O) so it is unit-testable in isolation, and
/// deliberately structured so future blocks (assigned skills, shared project
/// context — ARCHITECTURE §14.2) can be appended without touching the launcher.
#[must_use]
pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str) -> String {
let mut out = String::new();
out.push_str("# Project root\n\n");
out.push_str(project_root);
out.push_str("\n\nTous tes travaux portent sur ce project root (chemin absolu ci-dessus). ");
out.push_str(
"Ton répertoire courant est un dossier d'exécution isolé (`.ideai/run/<agent>/`) ; \
opère sur le project root, pas sur ce dossier.\n\n",
);
out.push_str("---\n\n");
out.push_str(agent_md);
out
}
/// Derives a unique, filesystem-safe `md_path` (`agents/<slug>.md`) for a new /// Derives a unique, filesystem-safe `md_path` (`agents/<slug>.md`) for a new
/// agent, disambiguating against the manifest's existing paths with a numeric /// agent, disambiguating against the manifest's existing paths with a numeric
/// suffix when needed. Shared with the template-driven agent creation (L7). /// suffix when needed. Shared with the template-driven agent creation (L7).
@ -536,3 +588,38 @@ fn slugify(name: &str) -> String {
} }
out.trim_matches('-').to_owned() out.trim_matches('-').to_owned()
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_run_dir_is_under_ideai_run_and_unique_per_agent() {
let root = ProjectPath::new("/home/me/proj").unwrap();
let a = AgentId::from_uuid(uuid::Uuid::from_u128(1));
let b = AgentId::from_uuid(uuid::Uuid::from_u128(2));
let dir_a = agent_run_dir(&root, &a).unwrap();
let dir_b = agent_run_dir(&root, &b).unwrap();
assert_eq!(dir_a.as_str(), format!("/home/me/proj/.ideai/run/{a}"));
assert_ne!(dir_a, dir_b, "distinct agents → distinct run dirs");
// Never the project root.
assert_ne!(dir_a.as_str(), "/home/me/proj");
}
#[test]
fn compose_convention_file_carries_root_then_persona() {
let doc = compose_convention_file("/abs/project/root", "# Persona\n\nDo things.");
// Absolute project root present.
assert!(doc.contains("/abs/project/root"));
// Persona present.
assert!(doc.contains("# Persona"));
assert!(doc.contains("Do things."));
// Root header precedes the persona body (ordering of the composition).
let root_at = doc.find("/abs/project/root").unwrap();
let persona_at = doc.find("# Persona").unwrap();
assert!(root_at < persona_at, "root header must precede the persona");
}
}

View File

@ -59,6 +59,18 @@ impl TerminalSessions {
.and_then(|m| m.get(id).map(|e| e.session.clone())) .and_then(|m| m.get(id).map(|e| e.session.clone()))
} }
/// Returns the [`PtyHandle`]s of every currently-registered session.
///
/// Used at application shutdown to kill all live PTYs cleanly (the
/// `CloseRequested` hook), independently of the frontend's per-view lifecycle.
#[must_use]
pub fn handles(&self) -> Vec<PtyHandle> {
self.entries
.lock()
.map(|m| m.values().map(|e| e.handle.clone()).collect())
.unwrap_or_default()
}
/// Removes a session from the registry, returning its handle if present. /// Removes a session from the registry, returning its handle if present.
pub fn remove(&self, id: &SessionId) -> Option<PtyHandle> { pub fn remove(&self, id: &SessionId) -> Option<PtyHandle> {
self.entries self.entries

View File

@ -221,6 +221,7 @@ impl AgentRuntime for FakeRuntime {
struct FakeFs { struct FakeFs {
trace: Trace, trace: Trace,
writes: WriteLog<String>, writes: WriteLog<String>,
created_dirs: Arc<Mutex<Vec<String>>>,
} }
impl FakeFs { impl FakeFs {
@ -228,11 +229,15 @@ impl FakeFs {
Self { Self {
trace, trace,
writes: Arc::new(Mutex::new(Vec::new())), writes: Arc::new(Mutex::new(Vec::new())),
created_dirs: Arc::new(Mutex::new(Vec::new())),
} }
} }
fn writes(&self) -> Vec<(String, Vec<u8>)> { fn writes(&self) -> Vec<(String, Vec<u8>)> {
self.writes.lock().unwrap().clone() self.writes.lock().unwrap().clone()
} }
fn created_dirs(&self) -> Vec<String> {
self.created_dirs.lock().unwrap().clone()
}
} }
#[async_trait] #[async_trait]
@ -251,7 +256,11 @@ impl FileSystem for FakeFs {
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> { async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
Ok(false) Ok(false)
} }
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> { async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
self.created_dirs
.lock()
.unwrap()
.push(path.as_str().to_owned());
Ok(()) Ok(())
} }
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> { async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
@ -313,6 +322,9 @@ impl PtyPort for FakePty {
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> { fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
Ok(Box::new(std::iter::empty())) Ok(Box::new(std::iter::empty()))
} }
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
Ok(Vec::new())
}
async fn kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> { async fn kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
Ok(ExitStatus { code: Some(0) }) Ok(ExitStatus { code: Some(0) })
} }
@ -386,7 +398,7 @@ fn profile(id: ProfileId, injection: ContextInjection) -> AgentProfile {
Vec::new(), Vec::new(),
injection, injection,
Some("claude --version".to_owned()), Some("claude --version".to_owned()),
"{projectRoot}", "{agentRunDir}",
) )
.unwrap() .unwrap()
} }
@ -600,17 +612,32 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
"prepare → injection → spawn" "prepare → injection → spawn"
); );
// The conventionFile was written to <cwd>/CLAUDE.md with the context body. // The conventionFile was written inside the agent's isolated run directory
// (`.ideai/run/<agent-id>/CLAUDE.md`) — NOT at the project root. Its content
// is the *composed* document: an absolute project-root header followed by the
// agent persona `.md`.
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
let writes = fs.writes(); let writes = fs.writes();
assert_eq!(writes.len(), 1); assert_eq!(writes.len(), 1);
assert_eq!(writes[0].0, "/home/me/proj/CLAUDE.md"); assert_eq!(writes[0].0, format!("{run_dir}/CLAUDE.md"));
assert_eq!(writes[0].1, b"# ctx body"); let written = String::from_utf8(writes[0].1.clone()).unwrap();
assert!(
written.contains("/home/me/proj"),
"convention file must carry the absolute project root, got: {written}"
);
assert!(
written.contains("# ctx body"),
"convention file must carry the agent persona, got: {written}"
);
// Spawn happened at the resolved cwd with the profile command. // The run directory was created (via the FileSystem port) before spawn.
assert_eq!(fs.created_dirs(), vec![run_dir.clone()]);
// Spawn happened at the isolated run dir with the profile command.
let spawns = pty.spawns(); let spawns = pty.spawns();
assert_eq!(spawns.len(), 1); assert_eq!(spawns.len(), 1);
assert_eq!(spawns[0].command, "claude"); assert_eq!(spawns[0].command, "claude");
assert_eq!(spawns[0].cwd.as_str(), "/home/me/proj"); assert_eq!(spawns[0].cwd.as_str(), run_dir);
// The session adopts the PTY id, is Running, and is registered as an agent. // The session adopts the PTY id, is Running, and is registered as an agent.
assert_eq!(out.session.id, sid(777)); assert_eq!(out.session.id, sid(777));
@ -630,6 +657,79 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
); );
} }
/// **Anti-collision (ARCHITECTURE §14.1)**: two distinct agents of the *same*
/// profile on the *same* project root must launch into two **distinct** cwd —
/// each its own `.ideai/run/<agent-id>/` — and each writes its convention file
/// inside its own run dir, never colliding at the project root.
#[tokio::test]
async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
let injection = ContextInjection::convention_file("CLAUDE.md").unwrap();
let plan = Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
});
// Two agents, same profile (pid(9)), same project root.
let agent_a = scratch_agent(aid(1), "Alpha", "agents/alpha.md", pid(9));
let agent_b = scratch_agent(aid(2), "Bravo", "agents/bravo.md", pid(9));
let contexts = FakeContexts::with_agent(&agent_a, "# alpha");
{
let mut inner = contexts.0.lock().unwrap();
inner
.manifest
.entries
.push(ManifestEntry::from_agent(&agent_b));
inner
.contents
.insert(agent_b.context_path.clone(), "# bravo".to_owned());
}
let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]);
let tr = trace();
let fs = FakeFs::new(Arc::clone(&tr));
let pty = FakePty::new(Arc::clone(&tr), sid(777));
let sessions = Arc::new(TerminalSessions::new());
let launch = LaunchAgent::new(
Arc::new(contexts),
Arc::new(profiles),
Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)),
Arc::new(fs.clone()),
Arc::new(pty.clone()),
Arc::clone(&sessions),
Arc::new(SpyBus::default()),
);
launch.execute(launch_input(agent_a.id)).await.unwrap();
launch.execute(launch_input(agent_b.id)).await.unwrap();
let dir_a = format!("/home/me/proj/.ideai/run/{}", agent_a.id);
let dir_b = format!("/home/me/proj/.ideai/run/{}", agent_b.id);
assert_ne!(dir_a, dir_b, "the two agents must map to different run dirs");
// Two distinct run dirs were created.
assert_eq!(fs.created_dirs(), vec![dir_a.clone(), dir_b.clone()]);
// Two spawns at two distinct cwd — the core anti-collision guarantee.
let spawns = pty.spawns();
assert_eq!(spawns.len(), 2);
assert_eq!(spawns[0].cwd.as_str(), dir_a);
assert_eq!(spawns[1].cwd.as_str(), dir_b);
assert_ne!(spawns[0].cwd, spawns[1].cwd);
// Two convention files, each inside its own run dir (no shared root file).
let writes = fs.writes();
assert_eq!(writes.len(), 2);
assert_eq!(writes[0].0, format!("{dir_a}/CLAUDE.md"));
assert_eq!(writes[1].0, format!("{dir_b}/CLAUDE.md"));
assert_ne!(writes[0].0, writes[1].0);
// Neither writes to the project root.
assert!(writes.iter().all(|(p, _)| p != "/home/me/proj/CLAUDE.md"));
// Each convention file carries its own persona.
assert!(String::from_utf8(writes[0].1.clone()).unwrap().contains("# alpha"));
assert!(String::from_utf8(writes[1].1.clone()).unwrap().contains("# bravo"));
}
#[tokio::test] #[tokio::test]
async fn launch_stdin_strategy_pipes_context_after_spawn() { async fn launch_stdin_strategy_pipes_context_after_spawn() {
let (launch, agent, fs, pty, _bus, _sessions, tr) = let (launch, agent, fs, pty, _bus, _sessions, tr) =

View File

@ -102,6 +102,10 @@ impl PtyPort for FakePty {
Ok(Box::new(std::iter::empty())) Ok(Box::new(std::iter::empty()))
} }
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
Ok(Vec::new())
}
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> { async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
let mut inner = self.0.lock().unwrap(); let mut inner = self.0.lock().unwrap();
inner.calls.push(Call::Kill { inner.calls.push(Call::Kill {

View File

@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::error::DomainError; use crate::error::DomainError;
use crate::ids::{AgentId, ProfileId, TemplateId}; use crate::ids::{AgentId, ProfileId, TemplateId};
use crate::skill::SkillRef;
use crate::template::TemplateVersion; use crate::template::TemplateVersion;
/// Origin of an agent: created from scratch, or derived from a template. /// Origin of an agent: created from scratch, or derived from a template.
@ -65,6 +66,10 @@ pub struct Agent {
pub origin: AgentOrigin, pub origin: AgentOrigin,
/// Whether the agent tracks its template (only valid for template origins). /// Whether the agent tracks its template (only valid for template origins).
pub synchronized: bool, pub synchronized: bool,
/// Skills assigned to this agent, injected into its convention file at
/// activation (ARCHITECTURE §14.2). Empty by default.
#[serde(default)]
pub skills: Vec<SkillRef>,
} }
impl Agent { impl Agent {
@ -98,8 +103,37 @@ impl Agent {
profile_id, profile_id,
origin, origin,
synchronized, synchronized,
skills: Vec::new(),
}) })
} }
/// Returns a copy of this agent carrying the given assigned skills,
/// deduplicated by `skill_id` (keeping first occurrence).
#[must_use]
pub fn with_skills(mut self, skills: Vec<SkillRef>) -> Self {
self.skills = Vec::new();
for skill in skills {
self.assign_skill(skill);
}
self
}
/// Assigns a skill to this agent. Idempotent: re-assigning the same
/// `skill_id` is a no-op (returns `false`); a new assignment returns `true`.
pub fn assign_skill(&mut self, skill: SkillRef) -> bool {
if self.skills.iter().any(|s| s.skill_id == skill.skill_id) {
return false;
}
self.skills.push(skill);
true
}
/// Removes a skill assignment by id. Returns `true` if a skill was removed.
pub fn unassign_skill(&mut self, skill_id: crate::ids::SkillId) -> bool {
let before = self.skills.len();
self.skills.retain(|s| s.skill_id != skill_id);
self.skills.len() != before
}
} }
/// One entry in the project agent manifest (`.ideai/agents.json`). /// One entry in the project agent manifest (`.ideai/agents.json`).
@ -135,6 +169,10 @@ pub struct ManifestEntry {
/// Template version recorded at the last sync. /// Template version recorded at the last sync.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub synced_template_version: Option<TemplateVersion>, pub synced_template_version: Option<TemplateVersion>,
/// Skills assigned to this agent (ARCHITECTURE §14.2). Defaults to empty for
/// backward-compatible deserialisation of pre-L12 manifests.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<SkillRef>,
} }
impl ManifestEntry { impl ManifestEntry {
@ -172,6 +210,7 @@ impl ManifestEntry {
template_id, template_id,
synchronized, synchronized,
synced_template_version, synced_template_version,
skills: Vec::new(),
}) })
} }
@ -196,6 +235,7 @@ impl ManifestEntry {
template_id, template_id,
synchronized: agent.synchronized, synchronized: agent.synchronized,
synced_template_version, synced_template_version,
skills: agent.skills.clone(),
} }
} }
@ -213,14 +253,15 @@ impl ManifestEntry {
}, },
_ => AgentOrigin::Scratch, _ => AgentOrigin::Scratch,
}; };
Agent::new( Ok(Agent::new(
self.agent_id, self.agent_id,
self.name.clone(), self.name.clone(),
self.md_path.clone(), self.md_path.clone(),
self.profile_id, self.profile_id,
origin, origin,
self.synchronized, self.synchronized,
) )?
.with_skills(self.skills.clone()))
} }
} }

View File

@ -1,7 +1,7 @@
//! Domain events published on the [`crate::ports::EventBus`] and relayed to the //! Domain events published on the [`crate::ports::EventBus`] and relayed to the
//! presentation layer (ARCHITECTURE §3.2). //! presentation layer (ARCHITECTURE §3.2).
use crate::ids::{AgentId, ProjectId, SessionId, TemplateId}; use crate::ids::{AgentId, ProjectId, SessionId, SkillId, TemplateId};
use crate::template::TemplateVersion; use crate::template::TemplateVersion;
/// Events emitted by the domain/application as state changes occur. /// Events emitted by the domain/application as state changes occur.
@ -54,6 +54,15 @@ pub enum DomainEvent {
/// Version it was brought up to. /// Version it was brought up to.
to: TemplateVersion, to: TemplateVersion,
}, },
/// A skill was assigned to (or unassigned from) an agent.
SkillAssigned {
/// The agent whose skill set changed.
agent_id: AgentId,
/// The skill involved.
skill_id: SkillId,
/// `true` if assigned, `false` if unassigned.
assigned: bool,
},
/// A tab's layout changed. /// A tab's layout changed.
LayoutChanged { LayoutChanged {
/// The project whose layout changed. /// The project whose layout changed.

View File

@ -68,6 +68,10 @@ typed_id!(
/// Identifies an [`crate::profile::AgentProfile`]. /// Identifies an [`crate::profile::AgentProfile`].
ProfileId ProfileId
); );
typed_id!(
/// Identifies a [`crate::skill::Skill`].
SkillId
);
typed_id!( typed_id!(
/// Identifies a [`crate::terminal::TerminalSession`]. /// Identifies a [`crate::terminal::TerminalSession`].
SessionId SessionId

View File

@ -41,6 +41,7 @@ pub mod ports;
pub mod profile; pub mod profile;
pub mod project; pub mod project;
pub mod remote; pub mod remote;
pub mod skill;
pub mod template; pub mod template;
pub mod terminal; pub mod terminal;
@ -53,13 +54,16 @@ mod validation;
pub use error::DomainError; pub use error::DomainError;
pub use ids::{ pub use ids::{
AgentId, LayoutId, NodeId, ProfileId, ProjectId, SessionId, TabId, TemplateId, WindowId, AgentId, LayoutId, NodeId, ProfileId, ProjectId, SessionId, SkillId, TabId, TemplateId,
WindowId,
}; };
pub use project::{Project, ProjectPath}; pub use project::{Project, ProjectPath};
pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry}; pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
pub use skill::{Skill, SkillRef, SkillScope};
pub use template::{AgentTemplate, TemplateVersion}; pub use template::{AgentTemplate, TemplateVersion};
pub use profile::{AgentProfile, ContextInjection}; pub use profile::{AgentProfile, ContextInjection};

View File

@ -334,10 +334,24 @@ pub trait PtyPort: Send + Sync {
/// Subscribes to the PTY's byte output stream. /// Subscribes to the PTY's byte output stream.
/// ///
/// Re-subscribable: each call returns a fresh stream that receives every
/// chunk produced **from now on**. Combined with [`scrollback`](Self::scrollback)
/// this lets the presentation layer *re-attach* a view to a still-living PTY
/// after a navigation/layout change tore the previous view down — without
/// re-spawning the process.
///
/// # Errors /// # Errors
/// [`PtyError`] if the handle is unknown. /// [`PtyError`] if the handle is unknown.
fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError>; fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError>;
/// Returns the recent output retained for the session (a bounded scrollback
/// ring buffer, the most recent bytes). Used to repaint a view that
/// re-attaches to a live PTY so the terminal isn't blank.
///
/// # Errors
/// [`PtyError`] if the handle is unknown.
fn scrollback(&self, handle: &PtyHandle) -> Result<Vec<u8>, PtyError>;
/// Kills the PTY's process, returning its exit status. /// Kills the PTY's process, returning its exit status.
/// ///
/// # Errors /// # Errors

View File

@ -96,7 +96,10 @@ pub struct AgentProfile {
pub context_injection: ContextInjection, pub context_injection: ContextInjection,
/// Optional detection command (e.g. `claude --version`). /// Optional detection command (e.g. `claude --version`).
pub detect: Option<String>, pub detect: Option<String>,
/// Working-directory template (e.g. `"{projectRoot}"`). /// Working-directory template. Always `"{agentRunDir}"` (ARCHITECTURE §14.1):
/// an agent's PTY cwd is its isolated `.ideai/run/<agent-id>/` directory,
/// **never** the project root, so that N agents of the same profile never
/// collide on a single conventional file (`CLAUDE.md`, …) at the root.
pub cwd_template: String, pub cwd_template: String,
} }

111
crates/domain/src/skill.rs Normal file
View File

@ -0,0 +1,111 @@
//! Skill entity — reusable, model-agnostic workflows assignable to agents.
//!
//! A [`Skill`] is IdeA's universal equivalent of a CLI's slash-command, but
//! without any dependency on a particular model's `/command` syntax
//! (ARCHITECTURE §14.2). Assigned skills are injected as plain text into the
//! agent's generated convention file at activation — there is no proprietary
//! CLI mechanism involved.
use serde::{Deserialize, Serialize};
use crate::error::DomainError;
use crate::ids::SkillId;
use crate::markdown::MarkdownDoc;
/// Where a skill lives, which also selects the store used to resolve it.
///
/// - [`SkillScope::Global`] skills are stored in the global IDE store
/// (`<app_data>/IdeA/skills/`) and reusable across projects.
/// - [`SkillScope::Project`] skills are stored under `.ideai/skills/` and are
/// specific to one project.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SkillScope {
/// Reusable across projects (global IDE store).
Global,
/// Specific to a single project (`.ideai/skills/`).
Project,
}
/// A reusable workflow assignable to one or more agents.
///
/// Invariants enforced here:
/// - `name` non-empty,
/// - `content_md` non-empty (an empty skill carries no behaviour).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Skill {
/// Stable identifier.
pub id: SkillId,
/// Display name (also used as the `.md` file stem on disk).
pub name: String,
/// Markdown body — the workflow injected into an agent's convention file.
pub content_md: MarkdownDoc,
/// Scope (selects the backing store).
pub scope: SkillScope,
}
impl Skill {
/// Builds a validated skill.
///
/// # Errors
/// - [`DomainError::EmptyField`] if `name` is empty,
/// - [`DomainError::EmptyField`] if `content_md` is empty.
pub fn new(
id: SkillId,
name: impl Into<String>,
content_md: MarkdownDoc,
scope: SkillScope,
) -> Result<Self, DomainError> {
let name = name.into();
crate::validation::non_empty(&name, "skill.name")?;
if content_md.is_empty() {
return Err(DomainError::EmptyField {
field: "skill.content_md",
});
}
Ok(Self {
id,
name,
content_md,
scope,
})
}
/// Returns a copy of this skill with replaced content, re-validating the
/// non-empty invariant.
///
/// # Errors
/// [`DomainError::EmptyField`] if `content_md` is empty.
pub fn with_content(&self, content_md: MarkdownDoc) -> Result<Self, DomainError> {
Skill::new(self.id, self.name.clone(), content_md, self.scope)
}
}
/// A reference from an agent to one assigned skill.
///
/// Stored in the [`crate::agent::ManifestEntry`]: an agent carries 0..N of these.
/// The `scope` is kept alongside the id so the application layer knows which
/// store to resolve the skill from without a global lookup.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillRef {
/// The assigned skill.
pub skill_id: SkillId,
/// Scope of the assigned skill (selects its store).
pub scope: SkillScope,
}
impl SkillRef {
/// Builds a reference to an assigned skill.
#[must_use]
pub const fn new(skill_id: SkillId, scope: SkillScope) -> Self {
Self { skill_id, scope }
}
}
impl From<&Skill> for SkillRef {
fn from(skill: &Skill) -> Self {
Self::new(skill.id, skill.scope)
}
}

View File

@ -5,8 +5,8 @@ mod helpers;
use domain::{ use domain::{
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError, Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError,
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, SshAuth, ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, Skill, SkillId,
TemplateId, TemplateVersion, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
}; };
use helpers::{AtomicSeqIdGenerator, FixedClock}; use helpers::{AtomicSeqIdGenerator, FixedClock};
use uuid::Uuid; use uuid::Uuid;
@ -409,3 +409,121 @@ fn manifest_unique_md_paths_ok() {
.unwrap(); .unwrap();
assert!(AgentManifest::new(1, vec![e1, e2]).is_ok()); assert!(AgentManifest::new(1, vec![e1, e2]).is_ok());
} }
// ---------------------------------------------------------------------------
// Skill invariants (L12, ARCHITECTURE §14.2)
// ---------------------------------------------------------------------------
fn skill_id(n: u128) -> SkillId {
SkillId::from_uuid(Uuid::from_u128(n))
}
#[test]
fn skill_valid_construction() {
let s = Skill::new(
skill_id(1),
"code-review",
MarkdownDoc::new("review the diff"),
SkillScope::Global,
);
assert!(s.is_ok());
}
#[test]
fn skill_rejects_empty_name() {
let err = Skill::new(
skill_id(1),
"",
MarkdownDoc::new("body"),
SkillScope::Project,
)
.unwrap_err();
assert_eq!(err, DomainError::EmptyField { field: "skill.name" });
}
#[test]
fn skill_rejects_empty_content() {
let err = Skill::new(skill_id(1), "x", MarkdownDoc::new(""), SkillScope::Global).unwrap_err();
assert_eq!(
err,
DomainError::EmptyField {
field: "skill.content_md"
}
);
}
#[test]
fn skill_with_content_revalidates() {
let s = Skill::new(skill_id(1), "x", MarkdownDoc::new("a"), SkillScope::Global).unwrap();
assert!(s.with_content(MarkdownDoc::new("b")).is_ok());
assert!(s.with_content(MarkdownDoc::new("")).is_err());
}
#[test]
fn agent_assign_skill_is_idempotent() {
let mut a = Agent::new(
agent_id(1),
"dev",
"agents/dev.md",
profile_id(),
AgentOrigin::Scratch,
false,
)
.unwrap();
let r = SkillRef::new(skill_id(7), SkillScope::Global);
assert!(a.assign_skill(r)); // first assignment
assert!(!a.assign_skill(r)); // duplicate ignored
assert_eq!(a.skills, vec![r]);
}
#[test]
fn agent_unassign_skill() {
let mut a = Agent::new(
agent_id(1),
"dev",
"agents/dev.md",
profile_id(),
AgentOrigin::Scratch,
false,
)
.unwrap();
let r = SkillRef::new(skill_id(7), SkillScope::Project);
a.assign_skill(r);
assert!(a.unassign_skill(skill_id(7)));
assert!(!a.unassign_skill(skill_id(7))); // already gone
assert!(a.skills.is_empty());
}
#[test]
fn agent_with_skills_dedups() {
let r = SkillRef::new(skill_id(7), SkillScope::Global);
let a = Agent::new(
agent_id(1),
"dev",
"agents/dev.md",
profile_id(),
AgentOrigin::Scratch,
false,
)
.unwrap()
.with_skills(vec![r, r]);
assert_eq!(a.skills, vec![r]);
}
#[test]
fn manifest_entry_preserves_skills_through_agent_roundtrip() {
let r = SkillRef::new(skill_id(7), SkillScope::Project);
let agent = Agent::new(
agent_id(1),
"dev",
"agents/dev.md",
profile_id(),
AgentOrigin::Scratch,
false,
)
.unwrap()
.with_skills(vec![r]);
let entry = ManifestEntry::from_agent(&agent);
assert_eq!(entry.skills, vec![r]);
assert_eq!(entry.to_agent().unwrap(), agent);
}

View File

@ -6,7 +6,7 @@ mod helpers;
use domain::{ use domain::{
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction, Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction,
LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef, LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef,
SplitContainer, SshAuth, TemplateVersion, WeightedChild, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, TemplateVersion, WeightedChild,
}; };
use helpers::{node, session}; use helpers::{node, session};
use uuid::Uuid; use uuid::Uuid;
@ -229,6 +229,61 @@ fn manifest_roundtrip_and_camel_case() {
assert!(!json.contains("\"templateId\":null"), "json was {json}"); assert!(!json.contains("\"templateId\":null"), "json was {json}");
} }
// ---------------------------------------------------------------------------
// Skill (L12) — round-trip, camelCase scope tag, manifest skills back-compat
// ---------------------------------------------------------------------------
fn sid(n: u128) -> SkillId {
SkillId::from_uuid(Uuid::from_u128(n))
}
#[test]
fn skill_roundtrip_and_camel_case_scope() {
let s = Skill::new(sid(1), "code-review", MarkdownDoc::new("body"), SkillScope::Global).unwrap();
assert_eq!(roundtrip(&s), s);
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"scope\":\"global\""), "json was {json}");
assert!(json.contains("\"contentMd\""), "json was {json}");
let p = Skill::new(sid(2), "simplify", MarkdownDoc::new("b"), SkillScope::Project).unwrap();
let pj = serde_json::to_string(&p).unwrap();
assert!(pj.contains("\"scope\":\"project\""), "json was {pj}");
}
#[test]
fn manifest_entry_skills_roundtrip_and_camel_case() {
let entry = ManifestEntry::from_agent(
&Agent::new(
aid(1),
"dev",
"agents/dev.md",
profid(9),
AgentOrigin::Scratch,
false,
)
.unwrap()
.with_skills(vec![SkillRef::new(sid(5), SkillScope::Project)]),
);
assert_eq!(roundtrip(&entry), entry);
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"skillId\""), "json was {json}");
assert!(json.contains("\"scope\":\"project\""), "json was {json}");
}
#[test]
fn manifest_entry_without_skills_omits_field_and_deserialises() {
// An entry with no skills must not emit "skills" (skip_serializing_if),
// and a pre-L12 manifest JSON (no skills key) must deserialise to empty.
let entry =
ManifestEntry::new(aid(1), "dev", "agents/dev.md", profid(9), None, false, None).unwrap();
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.contains("\"skills\""), "json was {json}");
let legacy = r#"{"agentId":"00000000-0000-0000-0000-000000000001","name":"dev","mdPath":"agents/dev.md","profileId":"00000000-0000-0000-0000-000000000009","synchronized":false}"#;
let parsed: ManifestEntry = serde_json::from_str(legacy).unwrap();
assert!(parsed.skills.is_empty());
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// LayoutTree (tagged enum: type/node) // LayoutTree (tagged enum: type/node)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -12,10 +12,15 @@
//! domain never sees an OS handle (ARCHITECTURE §4). //! domain never sees an OS handle (ARCHITECTURE §4).
//! - On [`spawn`](PtyPort::spawn) we open a PTY pair, spawn the command in the //! - On [`spawn`](PtyPort::spawn) we open a PTY pair, spawn the command in the
//! slave, then start **one reader thread** that pumps bytes from the master //! slave, then start **one reader thread** that pumps bytes from the master
//! into an [`std::sync::mpsc`] channel. [`subscribe_output`](PtyPort::subscribe_output) //! into a shared [`Broadcast`] hub. The hub does two things with every chunk:
//! hands back the receiver wrapped as the domain's blocking [`OutputStream`] //! it appends to a bounded **scrollback ring buffer** (~100 KB, most recent
//! iterator; the presentation layer drains it on its own thread and forwards //! bytes) and it fans the chunk out to every *currently subscribed* receiver.
//! chunks to the per-session Tauri channel (the `PtyBridge`). //! - [`subscribe_output`](PtyPort::subscribe_output) registers a fresh
//! subscriber and returns its receiver wrapped as the domain's blocking
//! [`OutputStream`] iterator. It is **re-subscribable**: after a view tears
//! down (navigation / layout change) a new view can re-attach to the *same*
//! live PTY by subscribing again and repainting the scrollback first — no
//! re-spawn. [`scrollback`](PtyPort::scrollback) returns that retained buffer.
//! - [`write`](PtyPort::write) / [`resize`](PtyPort::resize) act on the stored //! - [`write`](PtyPort::write) / [`resize`](PtyPort::resize) act on the stored
//! writer / master. [`kill`](PtyPort::kill) terminates the child, joins the //! writer / master. [`kill`](PtyPort::kill) terminates the child, joins the
//! reader thread, and returns the [`ExitStatus`]. //! reader thread, and returns the [`ExitStatus`].
@ -30,9 +35,10 @@
//! Unix-only assumption (no raw fds, no signals) so it should port as-is. //! Unix-only assumption (no raw fds, no signals) so it should port as-is.
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::VecDeque;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::sync::mpsc::{self, Receiver, Sender}; use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::Mutex; use std::sync::{Arc, Mutex};
use std::thread::JoinHandle; use std::thread::JoinHandle;
use async_trait::async_trait; use async_trait::async_trait;
@ -45,6 +51,68 @@ use domain::SessionId;
/// Size of each read buffer pumped from the master PTY. /// Size of each read buffer pumped from the master PTY.
const READ_BUF: usize = 8 * 1024; const READ_BUF: usize = 8 * 1024;
/// Maximum number of bytes retained in a session's scrollback ring buffer
/// (~100 KB, "the most recent output"). When the buffer would exceed this, the
/// oldest bytes are dropped so a re-attaching view repaints recent history.
const SCROLLBACK_CAP: usize = 100 * 1024;
/// The shared output hub of one PTY: a bounded scrollback ring buffer plus the
/// set of currently-subscribed receivers. The reader thread feeds both; views
/// subscribe and unsubscribe freely over the PTY's lifetime (re-attach support).
///
/// Each subscriber is a [`Sender`]; a send failing (receiver dropped because the
/// view detached) prunes that subscriber on the next chunk. This is the
/// fan-out/broadcast that replaces the old single-take `output_rx`.
#[derive(Default)]
struct Broadcast {
/// Bounded ring buffer of the most recent output bytes.
scrollback: VecDeque<u8>,
/// Live subscribers; pruned lazily when their receiver is gone.
subscribers: Vec<Sender<Vec<u8>>>,
/// Set once the PTY hit EOF (process exited) — no more output will ever come.
eof: bool,
}
impl Broadcast {
/// Appends a chunk to the scrollback (trimming to [`SCROLLBACK_CAP`]) and
/// fans it out to every live subscriber, dropping any that have gone away.
fn push(&mut self, chunk: &[u8]) {
self.scrollback.extend(chunk.iter().copied());
let overflow = self.scrollback.len().saturating_sub(SCROLLBACK_CAP);
if overflow > 0 {
self.scrollback.drain(0..overflow);
}
self.subscribers
.retain(|tx| tx.send(chunk.to_vec()).is_ok());
}
/// Registers a new subscriber, returning its receiver.
///
/// If the PTY already hit EOF, the returned stream is immediately closed
/// (its sender is dropped) so a late re-attach to a finished session doesn't
/// block forever waiting for output that will never come.
fn subscribe(&mut self) -> Receiver<Vec<u8>> {
let (tx, rx) = mpsc::channel();
if !self.eof {
self.subscribers.push(tx);
}
rx
}
/// Returns the retained scrollback as a contiguous byte vector.
fn snapshot(&self) -> Vec<u8> {
self.scrollback.iter().copied().collect()
}
/// Drops every subscriber's sender so their output streams end (EOF). Called
/// by the reader thread when the PTY hits EOF (process exit). The scrollback
/// is preserved so a late re-attach can still repaint the final output.
fn close_subscribers(&mut self) {
self.eof = true;
self.subscribers.clear();
}
}
/// A live PTY owned by the adapter. /// A live PTY owned by the adapter.
struct LivePty { struct LivePty {
/// Master side — used for resize. /// Master side — used for resize.
@ -53,8 +121,8 @@ struct LivePty {
writer: Box<dyn Write + Send>, writer: Box<dyn Write + Send>,
/// The spawned child process. /// The spawned child process.
child: Box<dyn Child + Send + Sync>, child: Box<dyn Child + Send + Sync>,
/// Receiver end of the output channel; taken once by `subscribe_output`. /// Shared scrollback + subscriber hub, fed by the reader thread.
output_rx: Option<Receiver<Vec<u8>>>, output: Arc<Mutex<Broadcast>>,
/// Handle of the reader thread, joined on kill. /// Handle of the reader thread, joined on kill.
reader: Option<JoinHandle<()>>, reader: Option<JoinHandle<()>>,
} }
@ -124,22 +192,29 @@ impl PtyPort for PortablePtyAdapter {
.try_clone_reader() .try_clone_reader()
.map_err(|e| PtyError::Io(e.to_string()))?; .map_err(|e| PtyError::Io(e.to_string()))?;
// One reader thread per PTY pumps bytes into the output channel until EOF. // One reader thread per PTY pumps bytes into the broadcast hub until EOF.
let (tx, rx): (Sender<Vec<u8>>, Receiver<Vec<u8>>) = mpsc::channel(); // The hub retains a scrollback ring buffer AND fans bytes out to every
// current subscriber, so views can detach/re-attach without re-spawning.
let output: Arc<Mutex<Broadcast>> = Arc::new(Mutex::new(Broadcast::default()));
let output_for_reader = Arc::clone(&output);
let reader_handle = std::thread::spawn(move || { let reader_handle = std::thread::spawn(move || {
let mut buf = [0u8; READ_BUF]; let mut buf = [0u8; READ_BUF];
loop { loop {
match reader.read(&mut buf) { match reader.read(&mut buf) {
Ok(0) => break, Ok(0) => break,
Ok(n) => { Ok(n) => {
// Receiver gone (session closed) → stop pumping. if let Ok(mut hub) = output_for_reader.lock() {
if tx.send(buf[..n].to_vec()).is_err() { hub.push(&buf[..n]);
break;
} }
} }
Err(_) => break, Err(_) => break,
} }
} }
// EOF (process exited): end every attached stream by dropping its
// sender, while preserving the scrollback for any late re-attach.
if let Ok(mut hub) = output_for_reader.lock() {
hub.close_subscribers();
}
}); });
// The PTY layer owns the handle identity: it mints a fresh session id and // The PTY layer owns the handle identity: it mints a fresh session id and
@ -153,7 +228,7 @@ impl PtyPort for PortablePtyAdapter {
master: pair.master, master: pair.master,
writer, writer,
child, child,
output_rx: Some(rx), output,
reader: Some(reader_handle), reader: Some(reader_handle),
}; };
self.sessions self.sessions
@ -189,18 +264,33 @@ impl PtyPort for PortablePtyAdapter {
} }
fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError> { fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError> {
let mut map = self let map = self
.sessions .sessions
.lock() .lock()
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
let live = map.get_mut(&handle.session_id).ok_or(PtyError::NotFound)?; let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?;
let rx = live let rx = live
.output_rx .output
.take() .lock()
.ok_or_else(|| PtyError::Io("output already subscribed".to_owned()))?; .map_err(|_| PtyError::Io("pty output hub poisoned".to_owned()))?
.subscribe();
Ok(Box::new(rx.into_iter())) Ok(Box::new(rx.into_iter()))
} }
fn scrollback(&self, handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
let map = self
.sessions
.lock()
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?;
let snapshot = live
.output
.lock()
.map_err(|_| PtyError::Io("pty output hub poisoned".to_owned()))?
.snapshot();
Ok(snapshot)
}
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> { async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
// Remove from the registry so the writer/master drop and the child is // Remove from the registry so the writer/master drop and the child is
// fully owned here while we tear it down. // fully owned here while we tear it down.
@ -220,7 +310,8 @@ impl PtyPort for PortablePtyAdapter {
.map_err(|e| PtyError::Io(e.to_string()))?; .map_err(|e| PtyError::Io(e.to_string()))?;
// Dropping master/writer closes the PTY; the reader thread then sees EOF. // Dropping master/writer closes the PTY; the reader thread then sees EOF.
drop(live.output_rx.take()); // Dropping the broadcast hub drops every subscriber's sender, so any
// still-attached view's output stream ends cleanly too.
if let Some(reader) = live.reader.take() { if let Some(reader) = live.reader.take() {
let _ = reader.join(); let _ = reader.join();
} }

View File

@ -80,16 +80,23 @@ impl CliAgentRuntime {
}) })
} }
/// Resolves the profile's `cwd_template` against the project root. /// Resolves the profile's `cwd_template` against the supplied `base` cwd.
/// ///
/// The only recognised placeholder is `{projectRoot}` (CONTEXT §9). An empty /// The base is the agent's **run directory** (`.ideai/run/<agent-id>/`),
/// template defaults to the project root itself. /// already computed (and created) by `LaunchAgent` and passed as the `cwd`
fn resolve_cwd(profile: &AgentProfile, root: &ProjectPath) -> Result<ProjectPath, RuntimeError> { /// argument of [`prepare_invocation`](AgentRuntime::prepare_invocation). The
/// recognised placeholder is `{agentRunDir}` (ARCHITECTURE §14.1); the legacy
/// `{projectRoot}` is still substituted with the same base for backwards
/// compatibility (the caller always passes the run dir now). An empty template
/// defaults to the base itself.
fn resolve_cwd(profile: &AgentProfile, base: &ProjectPath) -> Result<ProjectPath, RuntimeError> {
let template = profile.cwd_template.trim(); let template = profile.cwd_template.trim();
if template.is_empty() { if template.is_empty() {
return Ok(root.clone()); return Ok(base.clone());
} }
let resolved = template.replace("{projectRoot}", root.as_str()); let resolved = template
.replace("{agentRunDir}", base.as_str())
.replace("{projectRoot}", base.as_str());
ProjectPath::new(resolved).map_err(|e| RuntimeError::Invocation(e.to_string())) ProjectPath::new(resolved).map_err(|e| RuntimeError::Invocation(e.to_string()))
} }

View File

@ -217,15 +217,27 @@ fn prepare_substitutes_project_root_in_cwd_template() {
} }
#[test] #[test]
fn prepare_empty_cwd_template_defaults_to_root() { fn prepare_empty_cwd_template_defaults_to_base() {
let rt = pure_runtime(); let rt = pure_runtime();
let p = profile(ContextInjection::stdin(), ""); let p = profile(ContextInjection::stdin(), "");
let root = ProjectPath::new("/home/me/proj").unwrap(); let base = ProjectPath::new("/home/me/proj").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap(); let spec = rt.prepare_invocation(&p, &ctx(), &base).unwrap();
assert_eq!(spec.cwd.as_str(), "/home/me/proj"); assert_eq!(spec.cwd.as_str(), "/home/me/proj");
} }
#[test]
fn prepare_substitutes_agent_run_dir_in_cwd_template() {
// The canonical template (ARCHITECTURE §14.1): `{agentRunDir}` resolves to the
// base cwd the launcher passes — the agent's isolated run directory.
let rt = pure_runtime();
let p = profile(ContextInjection::convention_file("CLAUDE.md").unwrap(), "{agentRunDir}");
let run_dir = ProjectPath::new("/home/me/proj/.ideai/run/agent-1").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &run_dir).unwrap();
assert_eq!(spec.cwd.as_str(), "/home/me/proj/.ideai/run/agent-1");
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// detection_spec (pure) // detection_spec (pure)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -113,25 +113,113 @@ async fn write_is_echoed_back_through_output_stream() {
} }
#[tokio::test] #[tokio::test]
async fn subscribe_output_twice_is_an_error() { async fn subscribe_output_is_re_subscribable_for_reattach() {
// A live PTY can be subscribed to more than once over its lifetime: the
// first view detaches (drops its stream), a second view re-attaches and
// still receives subsequent output — the core of the no-kill navigation fix.
let pty = PortablePtyAdapter::new(); let pty = PortablePtyAdapter::new();
let handle = pty let handle = pty
.spawn(sh_spec("sleep 0.2"), size()) .spawn(sh_spec("cat"), size())
.await
.expect("spawn cat");
// First attachment: subscribe, observe an echo, then drop the stream
// (simulating a view tearing down on navigation — NOT a kill).
{
let first = pty.subscribe_output(&handle).expect("first subscribe");
let (tx, rx) = mpsc::channel();
let worker = thread::spawn(move || {
let mut all = Vec::new();
for chunk in first {
all.extend_from_slice(&chunk);
if String::from_utf8_lossy(&all).contains("first-marker") {
let _ = tx.send(());
return; // drop the stream → detach
}
}
});
pty.write(&handle, b"first-marker\n").expect("write 1");
rx.recv_timeout(TIMEOUT).expect("first view saw its marker");
worker.join().expect("first worker joined");
}
// Second attachment to the SAME live PTY (no re-spawn): must still receive
// new output produced after re-subscription.
let second = pty.subscribe_output(&handle).expect("re-subscribe");
let (tx, rx) = mpsc::channel();
let worker = thread::spawn(move || {
let mut all = Vec::new();
for chunk in second {
all.extend_from_slice(&chunk);
if String::from_utf8_lossy(&all).contains("second-marker") {
let _ = tx.send(());
}
}
});
pty.write(&handle, b"second-marker\n").expect("write 2");
rx.recv_timeout(TIMEOUT)
.expect("re-attached view saw new output");
pty.kill(&handle).await.expect("kill cat");
worker.join().expect("second worker joined after kill");
}
#[tokio::test]
async fn scrollback_retains_recent_output_for_repaint() {
// After output is produced and the process exits, the scrollback still holds
// the recent bytes so a re-attaching view can repaint them.
let pty = PortablePtyAdapter::new();
let handle = pty
.spawn(sh_spec("printf scrollback-content"), size())
.await .await
.expect("spawn"); .expect("spawn");
let first = pty.subscribe_output(&handle); // Drain to EOF so all output has been pushed into the ring buffer.
assert!(first.is_ok(), "first subscribe succeeds"); let stream = pty.subscribe_output(&handle).expect("subscribe");
drain_with_timeout(stream, TIMEOUT);
let second = pty.subscribe_output(&handle); let sb = pty.scrollback(&handle).expect("scrollback readable");
let text = String::from_utf8_lossy(&sb);
assert!( assert!(
second.is_err(), text.contains("scrollback-content"),
"second subscribe on the same session must error" "scrollback should retain recent output, got {text:?}"
); );
// Drain the first stream so the reader thread can finish, then tidy up. let _ = pty.kill(&handle).await;
let stream = first.unwrap(); }
#[tokio::test]
async fn scrollback_is_bounded_to_cap_and_keeps_most_recent_bytes() {
// Emit clearly more than 100 KB of deterministic output, then assert the
// retained scrollback is bounded and ends with the most recent bytes.
let pty = PortablePtyAdapter::new();
// 5000 lines of "....END<n>" → well over 100 KB; the tail is the freshest.
let script = "i=0; while [ $i -lt 5000 ]; do \
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-%d\\n' $i; \
i=$((i+1)); done; printf 'FINAL-LINE-MARKER'";
let handle = pty.spawn(sh_spec(script), size()).await.expect("spawn");
let stream = pty.subscribe_output(&handle).expect("subscribe");
drain_with_timeout(stream, TIMEOUT); drain_with_timeout(stream, TIMEOUT);
let sb = pty.scrollback(&handle).expect("scrollback readable");
assert!(
sb.len() <= 100 * 1024,
"scrollback must be bounded to ~100 KB, was {} bytes",
sb.len()
);
// The newest output is retained even though the oldest was dropped.
let text = String::from_utf8_lossy(&sb);
assert!(
text.contains("FINAL-LINE-MARKER"),
"the most recent bytes must be kept in the ring buffer"
);
// And the very first lines must have been evicted.
assert!(
!text.contains("-0\n") || sb.len() < 100 * 1024,
"oldest bytes should be dropped once the cap is exceeded"
);
let _ = pty.kill(&handle).await; let _ = pty.kill(&handle).await;
} }

View File

@ -19,8 +19,10 @@ import type {
AgentGateway, AgentGateway,
CreateAgentInput, CreateAgentInput,
OpenTerminalOptions, OpenTerminalOptions,
ReattachResult,
TerminalHandle, TerminalHandle,
} from "@/ports"; } from "@/ports";
import { makeTerminalHandle } from "./terminal";
/** Wire shape returned by the `launch_agent` command (mirrors `open_terminal`). */ /** Wire shape returned by the `launch_agent` command (mirrors `open_terminal`). */
interface LaunchAgentResponse { interface LaunchAgentResponse {
@ -87,22 +89,26 @@ export class TauriAgentGateway implements AgentGateway {
onOutput: channel, onOutput: channel,
}); });
const sessionId = res.sessionId; return makeTerminalHandle(res.sessionId, channel);
}
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult> {
// Agent sessions reattach through the same session-based `reattach_terminal`
// command as plain terminals (the PTY is identified by its session id).
const channel = new Channel<number[]>();
channel.onmessage = (chunk) => onData(Uint8Array.from(chunk));
const res = await invoke<{ sessionId: string; scrollback: number[] }>(
"reattach_terminal",
{ sessionId, onOutput: channel },
);
return { return {
sessionId, handle: makeTerminalHandle(res.sessionId, channel),
async write(data: Uint8Array): Promise<void> { scrollback: Uint8Array.from(res.scrollback),
await invoke("write_terminal", {
request: { sessionId, data: Array.from(data) },
});
},
async resize(rows: number, cols: number): Promise<void> {
await invoke("resize_terminal", {
request: { sessionId, rows, cols },
});
},
async close(): Promise<void> {
await invoke("close_terminal", { sessionId });
},
}; };
} }
} }

View File

@ -36,6 +36,7 @@ import type {
OpenTerminalOptions, OpenTerminalOptions,
ProfileGateway, ProfileGateway,
ProjectGateway, ProjectGateway,
ReattachResult,
RemoteGateway, RemoteGateway,
SystemGateway, SystemGateway,
TemplateGateway, TemplateGateway,
@ -100,6 +101,47 @@ function slugify(name: string): string {
return out.replace(/^-+|-+$/g, ""); return out.replace(/^-+|-+$/g, "");
} }
/**
* A live in-memory mock PTY session: it retains a scrollback (everything written
* to `onData`) and tracks the *current* output sink so a view can detach (sink
* cleared, session stays alive) and later re-attach (new sink, scrollback
* replayed). Only `close` ends the session — mirroring the backend's decoupling
* of PTY lifecycle from view lifecycle.
*/
class MockPtySession {
/** Accumulated output (the scrollback ring; unbounded in the mock — fine for tests). */
private scrollback: number[] = [];
/** Current view sink; `null` while detached. */
private sink: ((bytes: Uint8Array) => void) | null = null;
/** Whether the session was explicitly closed (PTY killed). */
closed = false;
constructor(
readonly sessionId: string,
sink: (bytes: Uint8Array) => void,
) {
this.sink = sink;
}
/** Records output into the scrollback and forwards it to the current sink. */
emit(bytes: Uint8Array): void {
if (this.closed) return;
for (const b of bytes) this.scrollback.push(b);
this.sink?.(bytes);
}
/** Detaches the current view: stop delivering, keep the session alive. */
detach(): void {
this.sink = null;
}
/** Re-attaches a new view, returning the retained scrollback to repaint. */
reattach(sink: (bytes: Uint8Array) => void): Uint8Array {
this.sink = sink;
return Uint8Array.from(this.scrollback);
}
}
/** /**
* Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`, * Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`,
* `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and * `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and
@ -115,6 +157,8 @@ export class MockAgentGateway implements AgentGateway {
private contexts = new Map<string, string>(); private contexts = new Map<string, string>();
/** Monotonic session counter for deterministic session ids in tests. */ /** Monotonic session counter for deterministic session ids in tests. */
private sessionSeq = 0; private sessionSeq = 0;
/** Live agent PTY sessions, kept across detach so reattach can find them. */
private sessions = new Map<string, MockPtySession>();
private getAgents(projectId: string): Agent[] { private getAgents(projectId: string): Agent[] {
if (!this.agents.has(projectId)) this.agents.set(projectId, []); if (!this.agents.has(projectId)) this.agents.set(projectId, []);
@ -256,30 +300,65 @@ export class MockAgentGateway implements AgentGateway {
const sessionId = `mock-agent-session-${this.sessionSeq}`; const sessionId = `mock-agent-session-${this.sessionSeq}`;
const cwd = options.cwd; const cwd = options.cwd;
const enc = new TextEncoder(); const enc = new TextEncoder();
const session = new MockPtySession(sessionId, onData);
this.sessions.set(sessionId, session);
// Greet so something is visible immediately (mirrors MockTerminalGateway). // Greet so something is visible immediately (mirrors MockTerminalGateway).
queueMicrotask(() => queueMicrotask(() =>
onData(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)), session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
); );
let closed = false; return makeMockHandle(session, () => this.sessions.delete(sessionId));
}
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult> {
const session = this.sessions.get(sessionId);
if (!session || session.closed) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent session ${sessionId} is not alive`,
};
throw err;
}
const scrollback = session.reattach(onData);
return { return {
sessionId, handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
scrollback,
};
}
}
/**
* Builds a {@link TerminalHandle} over a {@link MockPtySession}. `write` echoes
* (cooked-terminal CRLF translation) through the session so the scrollback
* records it; `detach` keeps the session alive; `close` ends it and unregisters.
*/
function makeMockHandle(
session: MockPtySession,
unregister: () => void,
): TerminalHandle {
return {
sessionId: session.sessionId,
async write(data: Uint8Array): Promise<void> { async write(data: Uint8Array): Promise<void> {
if (closed) return; if (session.closed) return;
// Echo back, translating CR to CRLF like a cooked terminal.
const out: number[] = []; const out: number[] = [];
for (const b of data) { for (const b of data) {
if (b === 0x0d) out.push(0x0d, 0x0a); if (b === 0x0d) out.push(0x0d, 0x0a);
else out.push(b); else out.push(b);
} }
onData(Uint8Array.from(out)); session.emit(Uint8Array.from(out));
}, },
async resize(): Promise<void> {}, async resize(): Promise<void> {},
detach(): void {
session.detach();
},
async close(): Promise<void> { async close(): Promise<void> {
closed = true; session.closed = true;
unregister();
}, },
}; };
} }
}
/** /**
* In-memory fake terminal: a shell-less PTY that **echoes** whatever is written * In-memory fake terminal: a shell-less PTY that **echoes** whatever is written
@ -288,6 +367,8 @@ export class MockAgentGateway implements AgentGateway {
*/ */
export class MockTerminalGateway implements TerminalGateway { export class MockTerminalGateway implements TerminalGateway {
private seq = 0; private seq = 0;
/** Live sessions kept across detach so reattach can find them. */
private sessions = new Map<string, MockPtySession>();
async openTerminal( async openTerminal(
options: OpenTerminalOptions, options: OpenTerminalOptions,
@ -296,27 +377,31 @@ export class MockTerminalGateway implements TerminalGateway {
this.seq += 1; this.seq += 1;
const sessionId = `mock-session-${this.seq}`; const sessionId = `mock-session-${this.seq}`;
const enc = new TextEncoder(); const enc = new TextEncoder();
const session = new MockPtySession(sessionId, onData);
this.sessions.set(sessionId, session);
// Greet so something is visible immediately. // Greet so something is visible immediately.
queueMicrotask(() => queueMicrotask(() =>
onData(enc.encode(`mock terminal @ ${options.cwd}\r\n`)), session.emit(enc.encode(`mock terminal @ ${options.cwd}\r\n`)),
); );
let closed = false; return makeMockHandle(session, () => this.sessions.delete(sessionId));
return {
sessionId,
async write(data: Uint8Array): Promise<void> {
if (closed) return;
// Echo back, translating CR to CRLF like a cooked terminal.
const out: number[] = [];
for (const b of data) {
if (b === 0x0d) out.push(0x0d, 0x0a);
else out.push(b);
} }
onData(Uint8Array.from(out));
}, async reattach(
async resize(): Promise<void> {}, sessionId: string,
async close(): Promise<void> { onData: (bytes: Uint8Array) => void,
closed = true; ): Promise<ReattachResult> {
}, const session = this.sessions.get(sessionId);
if (!session || session.closed) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `terminal session ${sessionId} is not alive`,
};
throw err;
}
const scrollback = session.reattach(onData);
return {
handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
scrollback,
}; };
} }
} }

View File

@ -111,4 +111,61 @@ describe("MockTerminalGateway", () => {
// Exactly one delivery so far: the greeting. // Exactly one delivery so far: the greeting.
expect(onData).toHaveBeenCalledTimes(1); expect(onData).toHaveBeenCalledTimes(1);
}); });
it("detach stops delivery to the old view but keeps the session alive", async () => {
const gw = new MockTerminalGateway();
const first: Uint8Array[] = [];
const handle = await gw.openTerminal(
{ cwd: "/c", rows: 24, cols: 80 },
(b) => first.push(b),
);
await flushMicrotasks();
first.length = 0;
handle.detach();
// Output produced after detach must NOT reach the detached view.
await handle.write(new TextEncoder().encode("after-detach"));
expect(first).toHaveLength(0);
// But the session is still alive: reattach succeeds.
await expect(
gw.reattach(handle.sessionId, () => {}),
).resolves.toBeDefined();
});
it("reattach replays scrollback and resumes live output", async () => {
const gw = new MockTerminalGateway();
const handle = await gw.openTerminal(
{ cwd: "/work", rows: 24, cols: 80 },
() => {},
);
await flushMicrotasks();
await handle.write(new TextEncoder().encode("typed"));
handle.detach();
const fresh: Uint8Array[] = [];
const { handle: h2, scrollback } = await gw.reattach(
handle.sessionId,
(b) => fresh.push(b),
);
// Scrollback carries the prior greeting + echoed input.
const sb = decode([scrollback]);
expect(sb).toContain("/work");
expect(sb).toContain("typed");
// New output now flows to the re-attached view.
await h2.write(new TextEncoder().encode("more"));
expect(decode(fresh)).toBe("more");
});
it("reattach to a closed session rejects (PTY is gone)", async () => {
const gw = new MockTerminalGateway();
const handle = await gw.openTerminal(
{ cwd: "/c", rows: 24, cols: 80 },
() => {},
);
await handle.close();
await expect(gw.reattach(handle.sessionId, () => {})).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
}); });

View File

@ -18,6 +18,7 @@ import { Channel, invoke } from "@tauri-apps/api/core";
import type { import type {
OpenTerminalOptions, OpenTerminalOptions,
ReattachResult,
TerminalGateway, TerminalGateway,
TerminalHandle, TerminalHandle,
} from "@/ports"; } from "@/ports";
@ -30,6 +31,46 @@ interface OpenTerminalResponse {
cols: number; cols: number;
} }
/** Wire shape returned by the `reattach_terminal` command. */
interface ReattachResponse {
sessionId: string;
scrollback: number[];
}
/**
* Builds a {@link TerminalHandle} over a session and its local output
* {@link Channel}. `detach` stops the channel from delivering further bytes (the
* view is gone) without touching the backend PTY; `close` kills the PTY.
*
* Shared by `openTerminal` and `reattach` so both produce identical handles.
*/
export function makeTerminalHandle(
sessionId: string,
channel: Channel<number[]>,
): TerminalHandle {
return {
sessionId,
async write(data: Uint8Array): Promise<void> {
await invoke("write_terminal", {
request: { sessionId, data: Array.from(data) },
});
},
async resize(rows: number, cols: number): Promise<void> {
await invoke("resize_terminal", {
request: { sessionId, rows, cols },
});
},
detach(): void {
// Drop the local subscription: the backend PTY keeps running, but this
// view stops receiving output. A later `reattach` re-wires a fresh channel.
channel.onmessage = () => {};
},
async close(): Promise<void> {
await invoke("close_terminal", { sessionId });
},
};
}
export class TauriTerminalGateway implements TerminalGateway { export class TauriTerminalGateway implements TerminalGateway {
async openTerminal( async openTerminal(
options: OpenTerminalOptions, options: OpenTerminalOptions,
@ -44,22 +85,24 @@ export class TauriTerminalGateway implements TerminalGateway {
onOutput: channel, onOutput: channel,
}); });
const sessionId = res.sessionId; return makeTerminalHandle(res.sessionId, channel);
return { }
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult> {
const channel = new Channel<number[]>();
channel.onmessage = (chunk) => onData(Uint8Array.from(chunk));
const res = await invoke<ReattachResponse>("reattach_terminal", {
sessionId, sessionId,
async write(data: Uint8Array): Promise<void> { onOutput: channel,
await invoke("write_terminal", {
request: { sessionId, data: Array.from(data) },
}); });
},
async resize(rows: number, cols: number): Promise<void> { return {
await invoke("resize_terminal", { handle: makeTerminalHandle(res.sessionId, channel),
request: { sessionId, rows, cols }, scrollback: Uint8Array.from(res.scrollback),
});
},
async close(): Promise<void> {
await invoke("close_terminal", { sessionId });
},
}; };
} }
} }

View File

@ -73,6 +73,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
*/ */
const [activeAgentId, setActiveAgentId] = useState<string | null>(null); const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
/**
* Live PTY session id of the running agent terminal, keyed by agent id. Lets
* the terminal re-attach (rather than re-launch) when the panel re-mounts the
* view, so navigating away never kills the agent.
*/
const [agentSessions, setAgentSessions] = useState<Record<string, string>>(
{},
);
const canCreate = newName.trim().length > 0 && !vm.busy; const canCreate = newName.trim().length > 0 && !vm.busy;
async function handleCreate(e: React.FormEvent) { async function handleCreate(e: React.FormEvent) {
@ -325,10 +334,18 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
<div className="border-t border-border p-4"> <div className="border-t border-border p-4">
<div className="h-96 overflow-hidden rounded-lg border border-border bg-surface"> <div className="h-96 overflow-hidden rounded-lg border border-border bg-surface">
<TerminalView <TerminalView
key={activeAgentId}
cwd={projectRoot} cwd={projectRoot}
open={(opts, onData) => open={(opts, onData) =>
vm.launchAgent(activeAgentId, opts, onData) vm.launchAgent(activeAgentId, opts, onData)
} }
reattach={(sessionId, onData) =>
gateways.agent.reattach(sessionId, onData)
}
sessionId={agentSessions[activeAgentId] ?? null}
onSessionId={(sid) =>
setAgentSessions((prev) => ({ ...prev, [activeAgentId]: sid }))
}
/> />
</div> </div>
</div> </div>

View File

@ -112,7 +112,7 @@ interface LeafViewProps {
projectId: string; projectId: string;
} }
function LeafView({ id, agent, cwd, vm, parentSplit, projectId }: LeafViewProps) { function LeafView({ id, session, agent, cwd, vm, parentSplit, projectId }: LeafViewProps) {
const canMerge = parentSplit !== null && parentSplit.siblings > 1; const canMerge = parentSplit !== null && parentSplit.siblings > 1;
const { agent: agentGateway } = useGateways(); const { agent: agentGateway } = useGateways();
@ -133,6 +133,12 @@ function LeafView({ id, agent, cwd, vm, parentSplit, projectId }: LeafViewProps)
? (opts: Parameters<typeof agentGateway.launchAgent>[2], onData: (bytes: Uint8Array) => void) => ? (opts: Parameters<typeof agentGateway.launchAgent>[2], onData: (bytes: Uint8Array) => void) =>
agentGateway.launchAgent(projectId, agentId, opts, onData) agentGateway.launchAgent(projectId, agentId, opts, onData)
: undefined; : undefined;
// Agent cells re-attach through the agent gateway; plain cells fall back to
// the terminal gateway's reattach (handled by TerminalView's default).
const reattachOpener = agentGateway && agentId
? (sessionId: string, onData: (bytes: Uint8Array) => void) =>
agentGateway.reattach(sessionId, onData)
: undefined;
return ( return (
<div <div
@ -215,8 +221,17 @@ function LeafView({ id, agent, cwd, vm, parentSplit, projectId }: LeafViewProps)
</button> </button>
)} )}
</div> </div>
{/* Re-key terminal when the agent changes so xterm re-mounts with the right opener. */} {/* Re-key terminal when the agent changes so xterm re-mounts with the right opener.
<TerminalView key={`${id}-${agentId ?? "plain"}`} cwd={cwd} open={terminalOpener} /> The cell's persisted session id drives reattach-vs-open so navigating
(layout/tab switch) never kills the PTY. */}
<TerminalView
key={`${id}-${agentId ?? "plain"}`}
cwd={cwd}
open={terminalOpener}
reattach={reattachOpener}
sessionId={session}
onSessionId={(sid) => void vm.setSession(id, sid)}
/>
</div> </div>
); );
} }

View File

@ -5,20 +5,44 @@
* Under jsdom xterm's `term.open` may bail gracefully (no real layout engine), * Under jsdom xterm's `term.open` may bail gracefully (no real layout engine),
* so these tests assert the *wiring contract* (mounts without throwing, talks to * so these tests assert the *wiring contract* (mounts without throwing, talks to
* the gateway port, tears down on unmount) rather than xterm's visual rendering. * the gateway port, tears down on unmount) rather than xterm's visual rendering.
*
* The core lifecycle invariant tested here: unmounting the view (navigation /
* layout change) must **detach**, NEVER **close** — the backend PTY must survive
* so a running AI isn't cut off. Re-mounting with a known session re-attaches.
*/ */
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import type { Gateways, TerminalGateway, TerminalHandle } from "@/ports"; import type {
Gateways,
ReattachResult,
TerminalGateway,
TerminalHandle,
} from "@/ports";
import { MockTerminalGateway } from "@/adapters/mock"; import { MockTerminalGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
import { TerminalView } from "./TerminalView"; import { TerminalView } from "./TerminalView";
function renderView(terminal: TerminalGateway, cwd = "/home/me/proj") { function makeHandle(overrides: Partial<TerminalHandle> = {}): TerminalHandle {
return {
sessionId: "s1",
write: vi.fn().mockResolvedValue(undefined),
resize: vi.fn().mockResolvedValue(undefined),
detach: vi.fn(),
close: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
function renderView(
terminal: TerminalGateway,
cwd = "/home/me/proj",
extra?: Partial<React.ComponentProps<typeof TerminalView>>,
) {
const gateways = { terminal } as unknown as Gateways; const gateways = { terminal } as unknown as Gateways;
return render( return render(
<DIProvider gateways={gateways}> <DIProvider gateways={gateways}>
<TerminalView cwd={cwd} /> <TerminalView cwd={cwd} {...extra} />
</DIProvider>, </DIProvider>,
); );
} }
@ -49,20 +73,13 @@ describe("TerminalView (with MockTerminalGateway)", () => {
}); });
it("consuming gateway output (onData) does not throw", async () => { it("consuming gateway output (onData) does not throw", async () => {
// A gateway that immediately pushes bytes to the consumer, exercising the const handle = makeHandle();
// gateway→term.write path. The component must swallow this safely even when
// xterm bailed under jsdom.
const handle: TerminalHandle = {
sessionId: "s1",
write: vi.fn().mockResolvedValue(undefined),
resize: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
};
const terminal: TerminalGateway = { const terminal: TerminalGateway = {
openTerminal: vi.fn(async (_opts, onData) => { openTerminal: vi.fn(async (_opts, onData) => {
onData(new TextEncoder().encode("hello\r\n")); onData(new TextEncoder().encode("hello\r\n"));
return handle; return handle;
}), }),
reattach: vi.fn(),
}; };
expect(() => renderView(terminal)).not.toThrow(); expect(() => renderView(terminal)).not.toThrow();
@ -71,21 +88,15 @@ describe("TerminalView (with MockTerminalGateway)", () => {
}); });
}); });
it("closes the opened handle on unmount (cleanup)", async () => { it("DETACHES (does not close) the handle on unmount — the PTY must survive", async () => {
const detach = vi.fn();
const close = vi.fn().mockResolvedValue(undefined); const close = vi.fn().mockResolvedValue(undefined);
const handle: TerminalHandle = { const handle = makeHandle({ detach, close });
sessionId: "s1",
write: vi.fn().mockResolvedValue(undefined),
resize: vi.fn().mockResolvedValue(undefined),
close,
};
const openTerminal = vi.fn(async () => handle); const openTerminal = vi.fn(async () => handle);
const terminal: TerminalGateway = { openTerminal }; const terminal: TerminalGateway = { openTerminal, reattach: vi.fn() };
const { unmount } = renderView(terminal); const { unmount } = renderView(terminal);
// Only assert close-on-unmount if the gateway was actually opened (i.e.
// xterm.open did not bail in this jsdom run).
await waitFor(() => { await waitFor(() => {
expect(openTerminal.mock.calls.length >= 0).toBe(true); expect(openTerminal.mock.calls.length >= 0).toBe(true);
}); });
@ -95,11 +106,57 @@ describe("TerminalView (with MockTerminalGateway)", () => {
if (wasOpened) { if (wasOpened) {
await waitFor(() => { await waitFor(() => {
expect(close).toHaveBeenCalled(); expect(detach).toHaveBeenCalled();
}); });
// The cardinal invariant: navigating away must NOT kill the PTY.
expect(close).not.toHaveBeenCalled();
} else { } else {
// Bailed render: unmount must still be clean (no throw, no close needed). // Bailed render: unmount must still be clean (no close, no detach needed).
expect(close).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled();
} }
}); });
it("REATTACHES to an existing session instead of opening a new PTY", async () => {
const handle = makeHandle({ sessionId: "live-1" });
const reattach = vi.fn(
async (_sessionId: string, onData: (b: Uint8Array) => void) => {
onData(new TextEncoder().encode("scroll"));
const result: ReattachResult = {
handle,
scrollback: new TextEncoder().encode("history"),
};
return result;
},
);
const openTerminal = vi.fn(async () => handle);
const terminal: TerminalGateway = { openTerminal, reattach };
renderView(terminal, "/cwd", { sessionId: "live-1" });
await waitFor(() => {
// When xterm wired up, reattach must be used (with the known id) and a
// fresh open must NOT happen.
if (reattach.mock.calls.length > 0) {
expect(reattach.mock.calls[0][0]).toBe("live-1");
expect(openTerminal).not.toHaveBeenCalled();
}
});
expect(true).toBe(true);
});
it("persists a newly opened session id via onSessionId", async () => {
const handle = makeHandle({ sessionId: "new-99" });
const openTerminal = vi.fn(async () => handle);
const terminal: TerminalGateway = { openTerminal, reattach: vi.fn() };
const onSessionId = vi.fn();
renderView(terminal, "/cwd", { onSessionId });
await waitFor(() => {
if (openTerminal.mock.calls.length > 0) {
expect(onSessionId).toHaveBeenCalledWith("new-99");
}
});
expect(true).toBe(true);
});
}); });

View File

@ -12,6 +12,17 @@
* *
* An optional `open` prop can override the default `terminal.openTerminal` call, * An optional `open` prop can override the default `terminal.openTerminal` call,
* enabling the agent terminal to reuse this component with `agent.launchAgent`. * enabling the agent terminal to reuse this component with `agent.launchAgent`.
*
* **PTY lifecycle is decoupled from the view lifecycle.** Navigating (switching
* layout or project tab) tears this view down but must NEVER kill the backend
* PTY — otherwise running AIs would be cut off. So:
* - On unmount the cleanup only `detach`es (drops the local output subscription)
* and disposes xterm; it never calls `handle.close()`. Killing a PTY is an
* explicit user action handled elsewhere (the terminal's close button).
* - On mount, if a `sessionId` already exists for this cell (persisted by the
* caller via `onSessionId`), the view **re-attaches** to the still-running PTY
* — repainting its scrollback and resuming its output — instead of opening a
* fresh one. If the session is gone (was explicitly closed), it opens fresh.
*/ */
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
@ -21,7 +32,11 @@ import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
import type { OpenTerminalOptions, TerminalHandle } from "@/ports"; import type {
OpenTerminalOptions,
ReattachResult,
TerminalHandle,
} from "@/ports";
interface TerminalViewProps { interface TerminalViewProps {
/** Working directory the shell opens in (typically the project root). */ /** Working directory the shell opens in (typically the project root). */
@ -36,9 +51,35 @@ interface TerminalViewProps {
options: OpenTerminalOptions, options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void, onData: (bytes: Uint8Array) => void,
) => Promise<TerminalHandle>; ) => Promise<TerminalHandle>;
/**
* Optional re-attach opener. When provided together with a {@link sessionId},
* the view re-binds to the existing live PTY instead of opening a new one.
* When absent, falls back to the terminal gateway's `reattach`.
*/
reattach?: (
sessionId: string,
onData: (bytes: Uint8Array) => void,
) => Promise<ReattachResult>;
/**
* Persisted session id for this cell, if a PTY is already running for it.
* Drives the reattach-vs-open decision at mount.
*/
sessionId?: string | null;
/**
* Called once a session is established (opened) so the caller can persist its
* id for this cell and re-attach to it on the next mount. Not called on
* reattach (the id is already known).
*/
onSessionId?: (sessionId: string) => void;
} }
export function TerminalView({ cwd, open }: TerminalViewProps) { export function TerminalView({
cwd,
open,
reattach,
sessionId,
onSessionId,
}: TerminalViewProps) {
const { terminal } = useGateways(); const { terminal } = useGateways();
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@ -51,6 +92,12 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
// so the correct opener is always captured at mount. // so the correct opener is always captured at mount.
const openRef = useRef(open); const openRef = useRef(open);
openRef.current = open; openRef.current = open;
const reattachRef = useRef(reattach);
reattachRef.current = reattach;
const sessionIdRef = useRef(sessionId);
sessionIdRef.current = sessionId;
const onSessionIdRef = useRef(onSessionId);
onSessionIdRef.current = onSessionId;
const terminalRef = useRef(terminal); const terminalRef = useRef(terminal);
terminalRef.current = terminal; terminalRef.current = terminal;
@ -58,6 +105,7 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
const container = containerRef.current; const container = containerRef.current;
const tgw = terminalRef.current; const tgw = terminalRef.current;
const opener = openRef.current ?? tgw?.openTerminal.bind(tgw); const opener = openRef.current ?? tgw?.openTerminal.bind(tgw);
const reattacher = reattachRef.current ?? tgw?.reattach.bind(tgw);
if (!container || !opener) return; if (!container || !opener) return;
const term = new Terminal({ const term = new Terminal({
@ -94,15 +142,16 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
else pending += data; else pending += data;
}); });
opener( const onData = (bytes: Uint8Array) => {
{ cwd, rows: term.rows, cols: term.cols },
(bytes) => {
if (!disposed) term.write(bytes); if (!disposed) term.write(bytes);
}, };
)
.then((h) => { // Adopt a freshly-established handle: flush buffered keystrokes. If the view
// was disposed before the promise resolved, just detach (NEVER close — the
// PTY must survive a transient mount/unmount).
const adopt = (h: TerminalHandle) => {
if (disposed) { if (disposed) {
void h.close(); h.detach();
return; return;
} }
handle = h; handle = h;
@ -110,14 +159,48 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
void h.write(encoder.encode(pending)); void h.write(encoder.encode(pending));
pending = ""; pending = "";
} }
}) };
.catch((e: unknown) => {
const onOpenError = (e: unknown) => {
if (!disposed) { if (!disposed) {
term.write( term.write(
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`, `\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
); );
} }
};
// Re-attach to an existing live PTY when this cell already has a session;
// otherwise open a fresh one and persist its id for the next mount.
const existingSession = sessionIdRef.current;
if (existingSession && reattacher) {
reattacher(existingSession, onData)
.then(({ handle: h, scrollback }) => {
if (disposed) {
h.detach();
return;
}
if (scrollback.length > 0) term.write(scrollback);
adopt(h);
})
.catch(() => {
// The session is gone (explicitly closed / exited): fall back to a
// fresh terminal so the cell still works.
if (disposed) return;
opener({ cwd, rows: term.rows, cols: term.cols }, onData)
.then((h) => {
onSessionIdRef.current?.(h.sessionId);
adopt(h);
})
.catch(onOpenError);
}); });
} else {
opener({ cwd, rows: term.rows, cols: term.cols }, onData)
.then((h) => {
onSessionIdRef.current?.(h.sessionId);
adopt(h);
})
.catch(onOpenError);
}
// Refit + propagate size to the PTY on container resize. // Refit + propagate size to the PTY on container resize.
const ro = new ResizeObserver(() => { const ro = new ResizeObserver(() => {
@ -134,7 +217,10 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
disposed = true; disposed = true;
ro.disconnect(); ro.disconnect();
onKey.dispose(); onKey.dispose();
if (handle) void handle.close(); // DETACH, never close: tearing the view down (navigation / layout change)
// must leave the backend PTY running so the AI isn't cut off. Killing the
// PTY is an explicit user action handled elsewhere.
if (handle) handle.detach();
term.dispose(); term.dispose();
}; };
// Only re-open on cwd change (or mount). The opener is read from a ref, and // Only re-open on cwd change (or mount). The opener is read from a ref, and

View File

@ -72,6 +72,16 @@ export interface AgentGateway {
options: OpenTerminalOptions, options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void, onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle>; ): Promise<TerminalHandle>;
/**
* Re-attaches to an agent's already-running PTY (same backend mechanism as
* {@link TerminalGateway.reattach}; agent sessions share the session-based
* terminal commands). Used when an agent cell's view re-mounts after a
* navigation/layout change, so the agent is never killed.
*/
reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult>;
} }
/** Options for opening a terminal. */ /** Options for opening a terminal. */
@ -98,7 +108,19 @@ export interface TerminalHandle {
write(data: Uint8Array): Promise<void>; write(data: Uint8Array): Promise<void>;
/** Resizes the PTY. */ /** Resizes the PTY. */
resize(rows: number, cols: number): Promise<void>; resize(rows: number, cols: number): Promise<void>;
/** Kills the PTY and stops the output stream. */ /**
* Detaches the **view** from the PTY without killing it: stops the local
* output subscription so a torn-down view (navigation / layout change) stops
* receiving bytes, while the backend PTY keeps running. The session can later
* be re-attached via {@link TerminalGateway.reattach}.
*
* This is the lifecycle the view's cleanup must use — never {@link close}.
*/
detach(): void;
/**
* Kills the PTY and stops the output stream. Reserved for an **explicit** user
* action (closing the terminal); navigation must never call this.
*/
close(): Promise<void>; close(): Promise<void>;
} }
@ -115,6 +137,27 @@ export interface TerminalGateway {
options: OpenTerminalOptions, options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void, onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle>; ): Promise<TerminalHandle>;
/**
* Re-attaches to an **already-running** PTY identified by `sessionId` (after a
* view was torn down by navigation/layout change). Returns the live handle and
* the retained scrollback, which the caller repaints into xterm before the new
* output stream (`onData`) starts delivering subsequent bytes. Does NOT
* re-spawn the process.
*
* Rejects if the session is no longer alive (the caller then opens fresh).
*/
reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult>;
}
/** The outcome of {@link TerminalGateway.reattach}. */
export interface ReattachResult {
/** The live terminal handle for the re-attached session. */
handle: TerminalHandle;
/** The retained scrollback bytes to repaint before the live stream resumes. */
scrollback: Uint8Array;
} }
/** Projects: create/open/close/list (L2). */ /** Projects: create/open/close/list (L2). */