Files
IdeA/.ideai/conversations/7a2f446c-2efa-0c56-08dd-07cdc5cac19b/handoff.md
Blomios 6969dc7988 chore(wip): état runtime .ideai (conversations, layouts, mémoire, skills)
Snapshot de l'état runtime accumulé sur develop : logs/handoffs de
conversations, layouts, notes mémoire (dont git-owns-commit-merge-decisions)
et catalogue de skills.

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

101 KiB
Raw Blame History

upTo, objective
upTo objective
267ce6cf-bd44-464d-aea5-c4455652a79e CADRAGE ARCHITECTURE (pas de code) — Support de la délégation inter-agents (idea_ask_agent/idea_reply) pour les profils CODEX.

Objectif : CADRAGE ARCHITECTURE (pas de code) — Support de la délégation inter-agents (idea_ask_agent/idea_reply) pour les profils CODEX. ## Contexte / gap déjà localisé Le round-trip inter-agent fonctionne pour

  • Prompt: Test de la conversation inter-agents IdeA. Peux-tu confirmer que tu reçois bien ce message et répondre via idea_reply avec un court statut de santé (qui tu es, ton rôle, et confirmation que le pont fonctionne) ?
  • Response: Message bien reçu — le pont inter-agents IdeA fonctionne. Qui je suis : codextest, un agent du projet IdeA. Rôle : agent de test/validation rattaché au project root IdeA. Santé du pont : opérationnel. J'ai reçu ta tâche (ticket 6ee3458a-a0c9-4adc-9d52-219023af626c) et je te réponds via idea_reply comme prévu. Le cycle délégation → réception → réponse est donc validé de bout en bout. Prêt pour la suite quand tu veux.
  • Prompt: CADRAGE ARCHITECTURE — Système de permissions projet + agents (avant tout code). CONTEXTE PRODUIT (validé par l'utilisateur) : On veut gérer les permissions des agents IA à deux niveaux : 1. Niveau PROJET : permissions par défaut héritées par tous les agents du projet. 2. Niveau AGENT : surcharge fine, agent par agent. Les permissions couvrent (a) les opérations FICHIERS (lecture, écriture/modification, suppression…) et (b) le droit d'exécuter des COMMANDES BASH. Objectif final : quand une permission est définie dans IdeA, l'agent (qu'il soit Claude Code OU Codex) NE REDEMANDE PLUS la permission au run. DÉCISIONS PRODUIT DÉJÀ ARBITRÉES (à respecter, ne pas re-débattre) : - Modèle neutre = CAPABILITIES + SCOPES : capacités typées (read / write / delete / execute-bash), chacune scopable par chemin (globs) et, pour bash, par allowlist/denylist de commandes. - Combinaison projet→agent = HÉRITAGE + OVERRIDE, avec DENY PRIORITAIRE en cas de conflit. - Posture par défaut = si AUCUNE permission n'est définie (ni projet ni agent), on garde le comportement actuel (le moteur demande au run) ; IdeA n'écrit aucune config tant que rien n'est posé. - Le modèle de domaine doit être NEUTRE vis-à-vis du moteur ; chaque runtime PROJETTE ce modèle dans sa config native pour supprimer les prompts au run : * Claude Code → .claude/settings.local.json (permissions.allow/deny + defaultMode acceptEdits/bypassPermissions). * Codex → config.toml (approval_policy + sandbox_mode). CE QUE J'ATTENDS DE TOI (contrat d'architecture, PAS du code) : 1. Le modèle de domaine : entités/VO (ex. PermissionSet, Capability, PathScope, CommandRule…), invariants, et la fonction PURE de résolution (project defaults ⊕ agent overrides → PermissionSet effectif, deny prioritaire). 2. Le ou les PORTS nécessaires (ex. un PermissionProjector/PermissionRenderer par runtime ? un PermissionStore ?) et où ils vivent (domain/application/infra), cohérent avec ta cartographie existante (ARCHITECTURE.md, crates domain/application/infrastructure/app-tauri). 3. La stratégie de PROJECTION par runtime : comment le PermissionSet effectif se traduit concrètement en settings.local.json (Claude) et config.toml (Codex) pour ne plus prompter — y compris les limites/risques (ex. granularité bash de Codex vs Claude, sandbox). 4. Le STOCKAGE/format : où persiste-t-on les permissions (.ideai/ ? agents.json ? un permissions.json ?), schéma JSON proposé, et l'impact sur le manifeste existant. 5. Le découpage en LOT(S) livrable(s) façon §12 (binôme dev+test), avec ordre et dépendances. 6. Points ouverts / spikes (notamment ce qui n'est pas mappable proprement entre Claude et Codex). Réponds via idea_reply avec ce contrat structuré. Sois concret et aligné sur l'hexagonal strict que tu maintiens.
  • Response: # CONTRAT D'ARCHITECTURE — Permissions projet + agents Cadrage hexagonal strict, aligné sur ARCHITECTURE.md et sur le pattern déjà éprouvé McpConfigStrategy / McpServerWiring::to_config_toml (domaine = rendu pur du fichier de conf ; application = écriture dans le run dir). On réutilise ce gabarit au lieu d'inventer un nouveau mécanisme. --- ## 1. Modèle de domaine (crate domain, module permission) Pur, sans I/O, serde autorisé (format persisté = contrainte métier, cf. §1.4 archi). ### Value Objects / entités Capability (enum) = Read | Write | Delete | ExecuteBash Effect (enum) = Allow | Deny PathScope (VO) = { globs: Vec<Glob> } // globs relatifs au project root, jamais absolus hors-root CommandRule (VO) = { matcher: CommandMatcher, effect: Effect } CommandMatcher (VO) = Exact(String) | Prefix(String) | Glob(Glob) // ex. "git", "npm *", "rm -rf *" PermissionRule (VO) = { capability: Capability, effect: Effect, paths: PathScope, // applicable à read/write/delete (ignoré pour bash) commands: Vec<CommandRule>, // applicable seulement à ExecuteBash } PermissionSet (entité-valeur) = { rules: Vec<PermissionRule>, // posture explicite quand rien ne matche une capacité : fallback: Posture, // Ask (défaut) | Allow | Deny } Posture (enum) = Ask | Allow | Deny ### Invariants (testables sans I/O) - PathScope.globs : relatifs, pas de .., pas de chemin absolu sortant du root (réutiliser la garde de ConventionFile.target). - ExecuteBash est la seule capacité portant des CommandRule ; les autres portent un PathScope. Une règle bash avec paths non vide = erreur ; une règle fichier avec commands = erreur. - Glob non vide et compilable. - Un PermissionSet peut contenir Allow ET Deny sur la même capacité (scopes différents) — c'est légal, résolu au run par spécificité + priorité deny. - Posture par défaut produit : PermissionSet ABSENT (Option::None) ≠ PermissionSet vide. None ⇒ IdeA n'écrit RIEN, comportement actuel préservé. C'est porté par l'option au niveau résolution, pas par un set vide. ### Fonction PURE de résolution rust // domain::permission pub fn resolve( project: Option<&PermissionSet>, // défauts projet agent: Option<&PermissionSet>, // overrides agent ) -> Option<EffectivePermissions> Règles : 1. project == None && agent == NoneNone (rien posé → on ne projette rien, le moteur prompte au run). C'est l'invariant produit clé. 2. Sinon, fusion héritage + override : - On part des règles projet, on superpose les règles agent. - DENY PRIORITAIRE : pour une capacité+scope donnés, si un Deny matche (projet ou agent), il gagne sur tout Allow, quel que soit le niveau. (deny-wins, non surchargeable par un allow plus spécifique — décision produit, on ne re-débat pas.) - fallback : l'agent peut resserrer mais le deny projet reste prioritaire. 3. Résultat = EffectivePermissions (VO de sortie, déjà aplati/normalisé), prêt à projeter. C'est le seul input des projecteurs. resolve est totale, déterministe, 100 % testable (table de vérité héritage × deny-wins × fallback). --- ## 2. Ports & emplacement On ajoute deux ports. On ne crée pas de port « renderer » côté infra : le rendu est PUR donc il vit dans le domaine (exactement comme to_config_toml aujourd'hui). | Port / élément | Type | Couche | Rôle | |---|---|---|---| | PermissionStore | trait (port) | domain/ports | load_project_permissions(project) -> Option<PermissionSet> ; load_agent_permissions(project, agent_id) -> Option<PermissionSet> ; save_*. Implémenté par un FsPermissionStore (infra) ou intégré à l'AgentContextStore/ProjectStore existant. | | PermissionProjection | trait de domaine (pas un port I/O) | domain/permission | fn project(&self, eff: &EffectivePermissions) -> RuntimeConfigArtifactRuntimeConfigArtifact = { rel_path: String, contents: String, merge: MergeMode }. Pur, une impl par runtime (ClaudeProjection, CodexProjection). Calque exact de McpServerWiring::to_config_toml. | - Application : un use case ResolveAndProjectPermissions (ou plus simplement une fonction appelée DANS LaunchAgent) qui : charge via PermissionStoreresolve(...) → si Some, appelle la PermissionProjection du runtime du profil → écrit l'artefact via FileSystem dans le run dir. Si None, ne touche à rien. - Infra : FsPermissionStore (tokio::fs + serde_json). Écriture des artefacts = FileSystem déjà existant. Aucun nouvel adapter PTY/process. - Composition root (app-tauri) : injecte FsPermissionStore + map ProfileKind → Arc<dyn PermissionProjection>. Point d'insertion concret : dans application/src/agent/lifecycle.rs, là où claude_settings_seed / le config.toml Codex sont déjà écrits dans le run dir. La projection permissions REMPLACE le seed blanket bypassPermissions actuel quand un PermissionSet est défini ; sinon le comportement actuel reste (voir §3 risque). --- ## 3. Stratégie de projection par runtime L'artefact est écrit dans le run dir isolé (.ideai/run/<id>/), jamais dans le ~/.claude / ~/.codex global — exactement comme le MCP wiring aujourd'hui. C'est ce qui supprime les prompts sans polluer la machine. ### Claude Code → {runDir}/.claude/settings.local.json Mapping EffectivePermissions → schéma natif Claude : - Capability::ExecuteBash + CommandRule(effect=Allow)permissions.allow: ["Bash(<cmd>:*)"] (ou pattern exact). Deny → permissions.deny. - Read/Write/Delete + PathScopepermissions.allow/deny avec Read(<glob>), Edit(<glob>), Write(<glob>). (Delete ≈ pas de capacité native dédiée → mappé sur Bash rm + Write ; voir spike.) - fallback/posture globale → permissions.defaultMode : - Allow global large ⇒ acceptEdits (ou bypassPermissions si l'utilisateur l'assume) ; - posture restrictive avec allowlist ⇒ default (ask) + listes allow. - MergeMode = merge sur l'existant (on garde la logique de merge actuelle de claude_settings_seed, on ne l'écrase pas brutalement). ### Codex → {runDir}/.codex/config.toml (CODEX_HOME déjà câblé) Granularité bien plus grossière que Claude — c'est le risque principal : - Codex n'a pas d'allowlist de commandes par règle fine : il a approval_policy (never / on-failure / on-request / untrusted) et sandbox_mode (read-only / workspace-write / danger-full-access). - Mapping pragmatique : - PermissionSet majoritairement Allow write+bash sans deny bloquant ⇒ approval_policy = "never" + sandbox_mode = "workspace-write". - Read seul ⇒ sandbox_mode = "read-only". - Présence de Denyon NE peut pas exprimer un deny fin dans Codex : on rabat sur approval_policy = "on-request" (Codex redemandera pour les cas sensibles) OU on documente la perte de fidélité (voir §6). - Les denylists de commandes Claude (ex. rm -rf /) n'ont pas d'équivalent Codex → couvert seulement par sandbox_mode = workspace-write (qui borne au workspace) + spike. Limite assumée : la projection est best-effort fidèle. Le domaine reste la source de vérité ; chaque projecteur fait au mieux et on expose un ProjectionFidelity { lossless: bool, warnings: Vec<String> } dans l'artefact pour remonter à l'UI « cette permission n'est pas exprimable telle quelle sous Codex ». --- ## 4. Stockage & format Décision : fichier dédié .ideai/permissions.json (pas dans agents.json). Raisons : SRP (le manifeste mappe md↔template↔sync, pas la sécurité), diff/review propre, et on évite de versionner les overrides agent au milieu du manifeste. Schéma proposé : json { "version": 1, "project": { "fallback": "ask", "rules": [ { "capability": "read", "effect": "allow", "paths": ["**/*"] }, { "capability": "write", "effect": "allow", "paths": ["src/**", "crates/**"] }, { "capability": "write", "effect": "deny", "paths": [".git/**", "**/*.pem"] }, { "capability": "execute-bash", "effect": "allow", "commands": ["git *", "cargo *", "npm *"] }, { "capability": "execute-bash", "effect": "deny", "commands": ["rm -rf *", "sudo *"] } ] }, "agents": { "<agentId>": { "fallback": "ask", "rules": [ { "capability": "execute-bash", "effect": "deny", "commands": ["*"] } ] } } } - project absent + agents[id] absent ⇒ resolve rend None ⇒ rien d'écrit (posture par défaut respectée). - Impact manifeste : agents.json inchangé. Lien faible par agentId (clé partagée). Pas de migration de l'existant requise. - Mémoire projet : ce fichier voyage avec le projet (versionnable), cohérent avec la décision « mémoire/contexte partagés au project root ». À afficher dans l'UI permissions (lot front). --- ## 5. Découpage en lots (façon §12, binôme dev+test) | # | Lot | Contenu | Dépend de | |---|---|---|---| | LP0 | Domaine permissions | VO/entités (Capability, PathScope, CommandRule, PermissionRule, PermissionSet, Posture), invariants, resolve() pure (héritage + deny-wins + None-si-rien). Tests = tables de vérité. Zéro I/O. | L0 | | LP1 | PermissionStore + format | trait PermissionStore (domain), FsPermissionStore (infra), schéma .ideai/permissions.json, (dé)sérialisation. Tests intégration tmpdir. | LP0 | | LP2 | Projection Claude | ClaudeProjection (domaine pur) + branchement dans lifecycle.rs REMPLAÇANT le seed blanket quand Some. ProjectionFidelity. Tests = snapshot du settings.local.json rendu. | LP0, LP1 | | LP3 | Projection Codex | CodexProjection (mapping approval_policy/sandbox_mode), warnings de fidélité. Tests snapshot config.toml. | LP0, LP1 | | LP4 | Câblage LaunchAgent + IPC | use case ResolveAndProjectPermissions invoqué à l'activation, composition root (map runtime→projection), commandes Tauri get/set_permissions. | LP2, LP3 | | LP5 | UI permissions | éditeur projet + override agent, badges de fidélité (« non exprimable sous Codex »). Gateways mock. | LP4 | Ordre = dépendances directes. LP2 et LP3 parallélisables après LP1. --- ## 6. Points ouverts / spikes 1. Granularité bash Codex (bloquant fidélité) : pas d'allow/deny par commande → on ne peut pas refléter une denylist fine. Spike : valider si sandbox_mode=workspace-write + approval_policy suffit, sinon assumer la perte + warning UI. À trancher avant LP3. 2. Capacité Delete : ni Claude ni Codex n'ont de capacité « delete » native distincte de write/bash rm. Spike : mapper Delete → (Write sur le chemin) + (Bash rm/unlink) ? ou capacité purement informative côté FileGuard (cf. domain/src/fileguard.rs déjà présent — possible convergence : la FileGuard interne pourrait CONSOMMER le même EffectivePermissions que la projection, garantissant cohérence I/O réelle vs config CLI). 3. Cohérence FileGuard ↔ projection : on a déjà fileguard.rs. Décision à figer : EffectivePermissions devient-il la source unique consommée à la fois par la FileGuard (enforcement réel des outils MCP) ET par les projecteurs (suppression des prompts CLI) ? Recommandé : oui, une seule source — sinon double vérité. Spike d'alignement. 4. bypassPermissions actuel : le seed blanket donne aujourd'hui l'autonomie totale. Migration : tant qu'aucun permissions.json n'existe, on garde ce seed (compat). Dès qu'un set est posé, la projection prend le relais. Valider qu'on ne régresse pas l'autonomie des agents existants (Main, Architect…). 5. Globs ↔ patterns natifs : la syntaxe glob du domaine doit se traduire sans perte vers Bash(...)/Edit(...) de Claude et vers le sandbox Codex. Spike de table de correspondance. 6. Édition hors-app du permissions.json (content hash / drift) — aligné sur le mécanisme drift template existant, mineur. --- Synthèse : un module domaine permission (modèle + resolve pure + 2 projections pures), un port PermissionStore, branchement dans LaunchAgent/lifecycle.rs sur le pattern MCP existant, stockage .ideai/permissions.json sans toucher au manifeste, 6 lots binômables. Le seul vrai risque produit est la fidélité Codex (granularité bash + deny), géré par un drapeau de fidélité explicite plutôt que par une fausse équivalence. Je recommande de trancher les spikes #1 et #3 (convergence FileGuard) avant de lancer LP3/LP4.
  • Prompt: LP0 implémenté et vert. DevBackend a dû trancher un point de sémantique non couvert explicitement par ton contrat — j'ai besoin de ta validation (ou correction) avant que QA fige les tables de vérité. LE POINT : pour une règle bash PermissionRule { capability: ExecuteBash, effect: <E>, commands: [CommandRule...] }commands est NON VIDE — que signifie le effect de NIVEAU RÈGLE (<E>) pour les commandes qui ne matchent AUCUN CommandRule ? Deux interprétations : - (A) choix actuel de DevBackend : le effect de niveau règle n'est PAS consulté pour les commandes non matchées ; seules les CommandRule individuelles décident, et les commandes non matchées retombent sur le fallback (Ask/Allow/Deny). Avantage : permet « allow git*, allow cargo*, et prompt tout le reste » avec fallback=Ask. Une règle bash à commands VIDE reste un verdict blanket (effect) pour toute commande. - (B) catch-all : pour une règle bash avec commandes, le effect de niveau règle s'applique en catch-all aux commandes non matchées par ses CommandRule. DevBackend a choisi (A) et l'a documenté dans le rustdoc. Pour moi (A) est cohérent avec la posture produit « deny prioritaire + fallback Ask par défaut » et évite qu'une règle allow se transforme en allow-tout implicite. Confirme (A), ou impose (B) si tu vois un risque sur la projection Claude/Codex (notamment : Codex n'ayant pas de granularité par commande, comment (A) vs (B) se projette en approval_policy/sandbox_mode). Réponds via idea_reply : tranche (A) ou (B), + une phrase sur l'impact projection Codex. Court.
  • Response: Tranché : (A) confirmée. C'est le bon choix et il est cohérent avec mon contrat : chaque CommandRule porte déjà son propre effect, donc sur une règle bash à commands non vide, l'effect de niveau règle est inerte (ne pas le consulter pour les non-matchés ; les non-matchés retombent sur fallback). Une règle bash à commands VIDE reste un verdict blanket. Garde le deny-wins prioritaire sur les CommandRule matchées. Recommande à QA d'ajouter une ligne de table figeant explicitement « rule-level effect ignoré quand commands non vide » pour éviter toute régression future. Impact projection Codex : (A) projette plus fidèlement — Codex n'ayant aucune granularité par commande, l'allowlist fine est de toute façon perdue, et c'est le fallback (la posture globale) qui pilote approval_policy/sandbox_mode (Ask→on-request, Allow→never+workspace-write, Deny→on-request best-effort) ; (B) aurait introduit un second curseur catch-all en conflit avec fallback, ambigu à projeter. Donc (A) simplifie aussi le projecteur Codex.
  • Prompt: Cadrage du lot LP3 — projection des permissions vers les CLI (Claude + Codex), avec swap cross-profile automatique. CONTEXTE (déjà en place, tout vert) : - domain/src/permission.rs : modèle pur + resolve()EffectivePermissions (profil-agnostique), avec decide_file(capability, path) -> Posture et decide_bash(command) -> Posture (Posture = Ask|Allow|Deny, deny-wins). - Port PermissionStore + FsPermissionStore lisant .ideai/permissions.json. - Use cases application (get/update/resolve) + commandes Tauri + UI PermissionsPanel montée. Slice de gestion terminé. - EffectivePermissions / decide_* ne sont consommés NULLE PART hors tests : rien n'est appliqué aux agents. - Le swap cross-profile existe déjà (commit P8d : « swap cross-profile préserve l'id de paire + handoff »). EXIGENCE PRODUIT (utilisateur) : « Fonctionnel pour Codex comme pour Claude. Si je passe un agent de Claude à Codex, que les permissions soient exportées de l'un vers l'autre automatiquement. » Décision d'archi pressentie à valider/affiner : les permissions restent stockées une seule fois (profil-agnostique) ; on introduit un Port PermissionProjector avec une implémentation par profil (Claude → settings.json allow/deny/ask ; Codex → config sandbox), sélectionnée selon le profil actif au lancement/handoff. Le swap Claude→Codex re-projette automatiquement via l'autre projecteur depuis la même EffectivePermissions. CE QUE J'ATTENDS DE TOI (réponds via idea_reply) : 1. Validation/correction du contrat du Port PermissionProjector : signature exacte (entrée EffectivePermissions + cwd/projectRoot ? sortie : écriture fichier vs valeur ?), où il vit (domain port, impls infrastructure), et comment on évite le couplage au profil concret (registre profil→projector). 2. Où exactement brancher la projection dans le chemin de lancement d'agent et dans le chemin de swap/handoff cross-profile (cite les modules : application/src/agent/lifecycle.rs, orchestrator/service.rs, etc.). 3. Stratégie de nettoyage : quand un profil n'est plus actif (swap), faut-il retirer/écraser la config projetée du profil précédent ? Règle précise. 4. Découpage en sous-tâches dev/test concret (ce que je donnerai à DevBackend puis QA), dans l'ordre. Ne code pas : donne le contrat et le plan.
  • Response: CADRAGE LP3 — Projection des permissions vers les CLI (Claude + Codex) + re-projection au swap cross-profile ⚠️ Constat préalable (à dire à DevBackend avant tout) : la projection EXISTE DÉJÀ, mais en l'état c'est de la dette à formaliser, pas un greenfield. Dans application/src/agent/lifecycle.rs on a aujourd'hui : - Claude : seed_cli_permissions (l.1620) → claude_settings_seed (l.2063) → écrit .claude/settings.local.json. Sélection IMPLICITE : sniff du nom de convention-file == CLAUDE.md. Et surtout non-clobbering (if exists return, l.1642). - Codex : apply_codex_cli_permission_args (l.2226, args --sandbox/--ask-for-approval) + codex_config_toml (l.2258, clés sandbox_mode/approval_policy). Sélection IMPLICITE : branche McpConfigStrategy::TomlConfigHome DANS apply_mcp_config (l.1838). Donc la projection Codex est PARASITÉE par la présence de MCP : un profil Codex sans MCP ne reçoit aucune sandbox. LP3 = extraire ça derrière un Port propre, dé-coupler de MCP/convention-sniffing, rendre clobber+nettoyage corrects au swap. Pas de réécriture des règles de traduction (elles sont bonnes et déjà testées l.2957-3068), juste relocalisation + cadrage. ──────────────────────────────────────── 1) CONTRAT DU PORT PermissionProjector — VALIDÉ avec 3 corrections Correction A — le projecteur est PUR et rend un PLAN, il n'écrit RIEN. Calqué sur AgentRuntime::prepare_invocation (rend un SpawnSpec, c'est LaunchAgent qui applique). Idem ici : le projecteur traduit EffectivePermissions → valeur ; LaunchAgent applique (writes via self.fs, fold args/env dans spec). Bénéfice : testable sans FS (comme le domaine), I/O centralisée au même endroit que apply_injection. On n'injecte PAS FileSystem dans chaque projecteur. Signature (domaine — voir corr. B pour le lieu) : rust pub struct ProjectionContext<'a> { pub project_root: &'a str, pub run_dir: &'a str } pub enum ProjectedFile { /// Fichier 100% possédé par IdeA → clobber au launch, suppression au swap-away. Replace { rel_path: String, contents: String }, /// Fichier co-possédé (ex. config.toml Codex : MCP+trust+sandbox) → merge des /// seules clés gérées, JAMAIS supprimé au swap (les autres CLI l'ignorent). MergeToml { rel_path: String, managed_tables: Vec<String>, managed_keys: Vec<String>, contents: String }, } pub struct PermissionProjection { pub files: Vec<ProjectedFile>, pub args: Vec<String>, // ex. ["--sandbox","workspace-write",...] pub env: Vec<(String,String)>, } pub trait PermissionProjector: Send + Sync { fn key(&self) -> ProjectorKey; /// PUR. `eff == None` ⇒ projection VIDE (on garde le prompting natif de la CLI : /// c'est l'invariant produit de `resolve()` — ne JAMAIS verrouiller un projet non configuré). fn project(&self, eff: Option<&EffectivePermissions>, ctx: &ProjectionContext) -> PermissionProjection; /// Chemins run-dir-relatifs des fichiers `Replace` possédés (pour le nettoyage swap). fn owned_replace_paths(&self) -> Vec<String>; } Entrée : Option<&EffectivePermissions> + ProjectionContext{project_root, run_dir} (les deux sont nécessaires : Claude embarque additionalDirectories=[project_root], et tout est écrit dans le run dir). Sortie : un PLAN (fichiers + args + env), pas une écriture. Correction B — où il vit. Trait + value types PermissionProjection/ProjectedFile/ProjectionContext/ProjectorKey → DANS LE DOMAINE (domain/src/permission.rs, à côté de EffectivePermissions). C'est un port piloté, pur, qui ne référence que des types domaine déjà présents. Les IMPLÉMENTATIONS (ClaudePermissionProjector, CodexPermissionProjector) → DANS L'INFRASTRUCTURE (crates/infrastructure/src/permission/ ; à créer). Justification = exactement le pattern AgentRuntime(domain) / CliAgentRuntime(infra) : le format concret d'un settings.json Claude ou des modes sandbox Codex est un détail technique d'UNE CLI ⇒ adapter (§5). On y déplace tel quel claude_settings_seed, apply_codex_cli_permission_args, codex_config_toml (partie permissions) + leurs tests. Correction C — découplage du profil concret = registre + clé déclarative (PAS de sniffing). - Ajouter au profil déclaratif un champ projector: Option<ProjectorKey> (AgentProfile, domain/src/profile.rs) — cohérent avec « profil = donnée éditable » (§9). Les profils builtin posent "claude" / "codex". - Registre PermissionProjectorRegistry = HashMap<ProjectorKey, Arc<dyn PermissionProjector>>, construit au composition root (app-tauri) et injecté dans les use cases. - Sélection : registry.get(profile.projector). None ⇒ aucune projection (natif). - Fallback de migration (profiles.json déjà sur disque sans le champ) : si projector == None, dériver la clé via l'heuristique actuelle (convention-file CLAUDE.md → claude ; StructuredAdapter::Codex ou TomlConfigHome → codex). On garde donc la compat sans imposer un re-seed du store global. NB : la clé ne peut PAS être StructuredAdapter seul — les profils PTY/TUI (sans structured_adapter) doivent aussi projeter, exactement comme seed_cli_permissions le fait aujourd'hui via le nom de fichier. D'où une ProjectorKey dédiée. ──────────────────────────────────────── 2) OÙ BRANCHER Chemin de LANCEMENT — LaunchAgent::execute (lifecycle.rs ~l.1085) : - Injecter Arc<PermissionProjectorRegistry> dans LaunchAgent (builder with_permission_projectors, optionnel ⇒ zéro régression call-sites/tests legacy, même pattern que with_handoff_provider). - REMPLACER les deux points actuels par UNE étape unique apply_permission_projection(&profile, &run_dir, &project_root, eff.as_ref(), &mut spec) : 1. supprimer l'appel seed_cli_permissions (l.1234) ; 2. SORTIR la projection Codex de apply_mcp_config (l.1838/1814/1826) — apply_mcp_config ne doit plus toucher sandbox_mode/approval_policy/--sandbox ; il ne fait QUE du MCP. - Placement de la nouvelle étape : juste après apply_injection (l.1294) et après apply_mcp_config (l.1312), donc AVANT le split structuré/PTY (l.1321) et avant pty.spawn (l.1361). Critique : c'est en amont du split ⇒ les deux chemins (structuré ET PTY brut) héritent de la projection, comme aujourd'hui. Les args/env du plan sont foldés dans spec avant que launch_structured ou pty.spawn ne le consomment. - Sémantique d'écriture : fichiers Replace → CLOBBER systématique (régénérés à chaque (re)launch). Justification déjà actée pour .mcp.json (l.1735) : fichier IdeA-managed, non édité par l'utilisateur, sinon la re-projection au swap est IMPOSSIBLE (c'est le bug actuel de seed_cli_permissions non-clobbering). Fichiers MergeToml → merge des seules clés gérées (réutiliser set_top_level_toml_value/replace_toml_table existants). Chemin de SWAP/HANDOFF — ChangeAgentProfile::execute (lifecycle.rs l.418, relaunch construit en l.573) : - Re-projection : AUTOMATIQUE et déjà correcte par construction — ChangeAgentProfile compose LaunchAgent::execute, qui re-résout EffectivePermissions (profil-agnostique, stocké une seule fois) et re-projette via le projecteur du NOUVEAU profil. Aucune nouvelle branche de projection à ajouter ici. Idem pour tous les auto-launch de orchestrator/service.rs (l.671, 1296, 1373) qui passent par LaunchAgent ⇒ couverts gratuitement. - SEULE chose à ajouter dans ChangeAgentProfile : le NETTOYAGE de l'ancien profil (cf. §3), exécuté avant la relance. Il faut donc que ChangeAgentProfile connaisse l'ancien projecteur + le FileSystem (il a déjà l'ancien profile_id via le manifeste avant mutation, et le run dir est stable par agent id : agent_run_dir(root, agent.id), l.1217 — invariant clé : le swap RÉUTILISE le même run dir, d'où la nécessité du nettoyage). ──────────────────────────────────────── 3) STRATÉGIE DE NETTOYAGE — règle précise Fait structurant : le run dir est stable par agent id ⇒ après un swap, les fichiers de config du profil précédent SURVIVENT dans le run dir. Un Claude→Codex laisse un .claude/settings.local.json (Codex l'ignore : inoffensif fonctionnellement, mais c'est une policy périmée/divergente qui fuit ⇒ à nettoyer). Un Codex→Claude laisse les clés sandbox dans config.toml (lu seulement par Codex ⇒ inoffensif). Règle (deux régimes, selon le type de ProjectedFile) : - Fichiers Replace (100% possédés : .claude/settings.local.json) → au swap, supprimer (best-effort) owned_replace_paths(ancien) owned_replace_paths(nouveau). Au launch normal, clobber (régénérés). - Fichiers MergeToml (co-possédés : config.toml Codex, partagé avec MCP+trust) → JAMAIS supprimés au swap. On retire uniquement les clés gérées (sandbox_mode/approval_policy) si on swappe AWAY de Codex et qu'on veut être strict ; recommandation pragmatique : ne rien retirer (le fichier n'est lu que par Codex, qui n'est plus actif) — le re-launch Codex futur réécrira les clés. Donc cleanup effectif = suppression des seuls fichiers Replace orphelins. - Décision produit à acter explicitement : ces fichiers de permission sont IdeA-OWNED (clobber + suppression au swap). Conséquence assumée : une édition manuelle de .claude/settings.local.json n'est PAS préservée — la source de vérité est le PermissionsPanel / .ideai/permissions.json. C'est le SEUL moyen de tenir l'exigence « permissions exportées automatiquement d'une CLI à l'autre au swap ». (Renverse le comportement non-clobbering actuel de seed_cli_permissions : à documenter dans le commit.) ──────────────────────────────────────── 4) DÉCOUPAGE DEV/TEST (ordre de livraison) LP3-1 — DOMAINE (port + clé). Dev : ajouter PermissionProjector, PermissionProjection, ProjectedFile, ProjectionContext, ProjectorKey dans domain/src/permission.rs ; ajouter projector: Option<ProjectorKey> à AgentProfile (+ #[serde(default)], builder, builtins claude/codex). QA : (dé)sérialisation du profil avec/sans le champ (compat) ; defaults builtin. cargo test -p domain. LP3-2 — INFRA (projecteurs). Dev : créer infrastructure/src/permission/ ; y DÉPLACER claude_settings_seed+helpers et apply_codex_cli_permission_args+codex_config_toml(partie perms) sous ClaudePermissionProjector / CodexPermissionProjector (purs, rendent un PermissionProjection) ; implémenter owned_replace_paths. QA : golden par posture (Allow/Ask/Deny), deny-wins, escaping JSON, args Codex, clés TOML — relocaliser/réutiliser les tests existants (lifecycle.rs l.2957-3068). cargo test -p infrastructure. LP3-3 — APPLICATION (wiring launch + dé-couplage MCP). Dev : PermissionProjectorRegistry + injection dans LaunchAgent (builder optionnel) ; nouvelle étape apply_permission_projection ; SUPPRIMER seed_cli_permissions ; PURGER la projection perms hors de apply_mcp_config. QA (fs mocké) : profil Claude → settings écrit ET clobbé au 2e launch ; profil Codex SANS MCP → args sandbox foldés + config.toml écrit (preuve du dé-couplage MCP) ; projector=None → rien d'écrit, rien dans spec ; eff=None → projection vide. cargo test -p application. LP3-4 — SWAP (nettoyage). Dev : injecter registry+fs dans ChangeAgentProfile ; avant relaunch, supprimer les Replace orphelins (anciennouveau). QA : Claude→Codex supprime .claude/settings.local.json et écrit la config Codex ; Codex→Claude réécrit settings.local.json frais reflétant les perms re-résolues (clobber) ; swap vers profil projector=None nettoie sans réécrire ; vérifier que l'id de paire + handoff (P8d) restent intacts (non-régression). LP3-5 — COMPOSITION ROOT (app-tauri). Dev : instancier le registre (claude+codex) et l'injecter dans LaunchAgent ET ChangeAgentProfile. QA : smoke de wiring + cargo test workspace complet vert + suites permissions existantes vertes. Règle d'or maintenue : aucun lot vert tant que cargo test -p <crate> du lot ne passe pas ; LP3-5 exige le workspace entier vert. Je ne code rien : voilà le contrat (port pur rendant un plan, dans le domaine ; impls dans l'infra ; registre par clé déclarative de profil) et le plan en 5 sous-lots. Point d'attention n°1 pour DevBackend : c'est d'abord un refactor d'extraction (le code de traduction existe et est testé), dont le cœur est le dé-couplage Codex↔MCP et le passage clobber+nettoyage.
  • Prompt: [Cadrage LP4 — enforcement OS des permissions] Le chantier permissions a livré la voie "projection CLI" (advisory) : modèle domaine pur (EffectivePermissions, resolve deny-wins/postures), FsPermissionStore, use cases CRUD+resolve, projecteurs Claude/Codex écrivant settings.local.json / config.toml au launch, composition root + commandes Tauri, UI PermissionsPanel. Tout vert. Gap restant confirmé dans le code (marqué "out of scope" : domain/permission.rs:12, domain/fileguard.rs:16, infrastructure/fileguard/mod.rs:18, application/orchestrator/context_guard.rs:23) : l'enforcement OS airtight (révocation réelle des accès FS), que la mémoire archi décrit comme "sandbox OS Landlock + résumé injecté". Aucun générateur de résumé ni échafaudage Landlock n'existe encore. Ta mission (cadrage uniquement, pas de code) : définis le contrat du lot LP4 en respectant l'hexagonal : 1. Le PORT domaine (nom, signature) qui transforme EffectivePermissions résolues en un plan d'enforcement OS pur (rulesets path RO/RW/exec), sans I/O ni dépendance Landlock dans le domaine. 2. La frontière exacte : où s'applique le ruleset (process agent au spawn PTY, pré-exec) vs le rôle de l'adapter infra Landlock (Linux d'abord ; Windows/macOS = stubs no-op explicites). Adapters par OS, pas par CLI. 3. Le point de câblage dans le launch-path (lifecycle.rs) en cohérence avec la projection déjà écrite (même EffectivePermissions). 4. Le volet "résumé de permissions injecté" dans le contexte .md de l'agent : port/fonction pure de génération + point d'injection (à côté du convention file/seed). 5. La stratégie de test par couche (domaine/app sans I/O via fakes ; test d'intégration Linux réel sous-process accès refusé). Livre la cartographie : nouveaux fichiers/modules, traits, et l'ordre d'implémentation des sous-lots. Réponds via idea_reply.
  • Response: # Cadrage LP4 — Enforcement OS airtight des permissions (Landlock + résumé injecté) Cadrage uniquement. Le lot réutilise la projection LP3 à l'identique (même EffectivePermissions résolues, même seam, même invariant None ⇒ on ne projette rien) et ajoute une seconde voie d'application : non plus advisory (settings CLI) mais airtight (sandbox OS au pré-exec). On ne touche ni au modèle domaine existant, ni à resolve, ni aux projecteurs LP3. --- ## 0. Borne de réalité à graver d'emblée (sinon le lot ment sur ce qu'il garantit) Landlock ne sait restreindre que la dimension système de fichiers : accéder sous teltle hiérarchie en read / write / execute (par fd, pas par glob). Il en découle deux frontières dures, à acter avant tout code : 1. Globs ≠ hiérarchies Landlock. EffectivePermissions exprime des PathScope en globs relatifs (src/**, **/*.rs) avec deny-wins par cible. Landlock ne connaît ni glob ni deny — il accorde l'accès sous des préfixes de chemins concrets. La compilation domaine doit donc traduire les globs en un ensemble de racines absolues autorisées, calculé fail-closed : quand un Deny tombe à l'intérieur d'un Allow sans frontière de répertoire qui les sépare (**/*.rs avec un deny sur un fichier), on n'accorde pas le parent (on perd un allow plutôt que de laisser fuiter un deny). Ceci est une sur-/sous-approximation conservatrice, à poser comme invariant domaine testable. 2. ExecuteBash (matching par chaîne de commande) n'est PAS enforceable par Landlock. Landlock gate l'exécution de fichiers par chemin, pas la sémantique d'argv (« deny rm »). Donc : la dimension fichiers (Read/Write/Delete → RO/RW) devient airtight ; la dimension commandes reste advisory (projection LP3 + résumé injecté). À écrire noir sur blanc dans le doc et dans le résumé injecté (« les règles de commande sont conseillées, pas verrouillées OS »). seccomp-argv = hors scope. → Le lot LP4 livre l'enforcement fichiers. C'est la moitié réellement verrouillable, et c'est exactement ce que la mémoire archi (« sandbox OS Landlock + résumé injecté ») cible. --- ## 1. Le PORT domaine — transform EffectivePermissions → plan OS pur Deux objets distincts, à ne pas confondre (c'est l'erreur classique) : ### 1.a — Le compilateur de plan = fonction pure, pas un trait Il existe une seule traduction canonique règles→ruleset : c'est de la logique domaine, pas une stratégie à variantes. On mirror resolve() (fonction libre, totale, déterministe), pas PermissionProjector (trait, car là il y avait N CLI). Nouveau module crates/domain/src/sandbox.rs : rust /// Plan d'enforcement OS, neutre vis-à-vis de l'OS. Value object pur. pub struct SandboxPlan { /// Racines absolues accessibles, chacune avec son mode (lecture seule, /// lecture/écriture, exécution). Calculées fail-closed depuis les globs. pub allowed: Vec<PathGrant>, /// Posture résiduelle (Allow ⇒ plan permissif/vide ; Deny ⇒ tout interdit /// hors `allowed` ; Ask ⇒ on ne sandboxe pas — voir §0/invariant None). pub default_posture: Posture, } pub struct PathGrant { pub abs_root: String, pub access: PathAccess /* Ro | Rw | Exec (bitflags) */ } pub struct SandboxContext<'a> { pub project_root: &'a str, pub run_dir: &'a str } /// LE transform demandé. Pur : zéro I/O, zéro dépendance Landlock. /// `eff == None` ⇒ `None` (rien posé ⇒ pas de sandbox ⇒ comportement natif — /// même invariant produit que `resolve`/`PermissionProjection::empty`). pub fn compile_sandbox_plan( eff: Option<&EffectivePermissions>, ctx: &SandboxContext, ) -> Option<SandboxPlan>; ### 1.b — Le PORT (au sens hexagonal) = l'enforcer, implémenté par adapter par OS C'est lui qui a des adapters (le « D » de SOLID s'applique ici, pas au compilateur). Domaine sandbox.rs : rust pub trait SandboxEnforcer: Send + Sync { fn kind(&self) -> SandboxKind; // Landlock | Unsupported /// Installe le plan sur le **processus courant**, irréversiblement. /// Contrat L (Liskov) : appelé **uniquement post-fork, pré-exec**, dans /// l'enfant. Un adapter Unsupported renvoie Ok(Unsupported) sans rien faire. fn enforce(&self, plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError>; } pub enum SandboxStatus { Enforced, Unsupported, Degraded(String) } pub enum SandboxError { /* kernel trop vieux en mode fail-closed, etc. */ } Plus, pour le volet (4), une fonction pure dans permission.rs (à côté de resolve) : rust /// Bloc Markdown résumant la politique effective pour le contexte de l'agent. /// `None` eff ⇒ `None` (pas de bloc ⇒ prompting natif). Pur. pub fn render_permission_summary(eff: Option<&EffectivePermissions>) -> Option<String>; Champ porté par SpawnSpec (domain/src/ports.rs) : le plan est structurel (pas args/env), donc nouveau champ rust pub sandbox: Option<SandboxPlan>, // None ⇒ pas d'enforcement (natif) (à l'image de la façon dont la projection LP3 foldait args/env, mais ici structurel). --- ## 2. La frontière exacte d'application - : sur le processus agent, post-fork / pré-execve, via un hook pre_exec (Unix) câblé par l'adapter PTY. Jamais sur le parent IdeA — Landlock est hérité et irréversible ; le poser sur IdeA se sandboxerait soi-même. Posé dans l'enfant, il couvre la CLI agent et toute sa descendance (shell, sous-process) → c'est ça l'airtight que la voie advisory ne donne pas, et qui ferme le trou « agent qui garde un shell brut » documenté dans fileguard/mod.rs:16. - Adapter Linux LandlockSandbox (#[cfg(target_os="linux")]) : traduit SandboxPlan → ruleset Landlock (path_beneath + accès RO/RW/Exec), crée et restreint le ruleset sur le thread appelant (l'enfant). Crate landlock (best-effort ABI : compat-mode pour kernels < feature). Décision à trancher (point ouvert) : kernel sans Landlock (< 5.13) ou ABI insuffisante ⇒ fail-closed (refuser le launch) si default_posture == Deny, fail-open + warning sinon. Défaut recommandé : fail-open journalisé, sauf posture Deny. À valider produit. - Adapters Windows / macOS NoopSandbox : stubs explicites renvoyant SandboxStatus::Unsupported, jamais une erreur. Documentés. Évolution future (AppContainer / sandbox_init) = nouvel adapter, zéro changement domaine (Open/Closed). Adapters par OS, pas par CLI : respecté — un seul SandboxPlan quelle que soit la CLI ; c'est l'OS qui choisit l'adapter. - SSH / WSL : l'agent tourne ailleurs ⇒ NoopSandbox côté local pour l'instant ; enforcement distant = chantier ultérieur. --- ## 3. Point de câblage dans le launch-path (lifecycle.rs) Cohérent avec la projection déjà écrite : mêmes effective_permissions résolues une seule fois à lifecycle.rs:1419 (resolve_effective_permissions). - Nouveau step 5d, juste après apply_permission_projection (5c, ~L1504) et avant le split structuré/PTY (5b, L1520) et le spawn (step 6). Nouveau helper : rust // 5d. Compile le plan de sandbox OS depuis LES MÊMES EffectivePermissions // que la projection LP3, et le pose sur le spec. None ⇒ pas de sandbox. self.apply_sandbox_plan(effective_permissions.as_ref(), &input.project.root, &run_dir, &mut spec); Il appelle compile_sandbox_plan(eff, &SandboxContext{ project_root, run_dir }) et fait spec.sandbox = plan. L'application reste pure d'OS : l'enforcer (Landlock) ne vit pas dans LaunchAgent, il vit dans l'adapter PTY. - Consommation : PortablePtyAdapter reçoit Arc<dyn SandboxEnforcer> au composition root et, dans spawn, si spec.sandbox.is_some(), installe un pre_exec qui capture (enforcer, plan) et appelle enforcer.enforce(&plan) dans l'enfant avant exec. - Résumé injecté (volet 4) : threader effective_permissions.as_ref() jusqu'à apply_injection (L1470) et y composer render_permission_summary(eff) à côté de la mémoire projet et du handoff (mêmes lignes ~1448-1481, même convention file). None ⇒ aucun bloc. Source de vérité unique : le même eff que projection + sandbox. - Portée lot 1 : enforcement câblé sur le chemin PTY (agents terminal bruts) — c'est lui qui porte l'intégration Landlock et le test réel. Le chemin structuré (AgentSessionFactory::start, L1617) a un autre seam de spawn ⇒ sous-lot de suivi LP4-4 (étendre le pre_exec/plan au factory). À flagger pour ne pas prétendre une couverture qu'on n'a pas encore. --- ## 4. Volet « résumé injecté » — déjà couvert ci-dessus (1.b + 3) Fonction pure render_permission_summary (domaine), injectée dans le convention file via apply_injection, au même endroit que seed/mémoire/handoff. Doit mentionner explicitement la borne §0.2 (« règles de commandes = conseillées, fichiers = verrouillés OS quand supporté »). Aucun port nouveau pour ce volet. --- ## 5. Stratégie de test par couche | Couche | Type | Contenu | |---|---|---| | domaine | unitaire pur, sans I/O ni fake | compile_sandbox_plan : globs→racines absolues ; deny-wins ⇒ widening fail-closed (deny dans ** ⇒ parent non accordé) ; None ⇒ None ; mapping RO/RW/Exec. render_permission_summary : bloc présent/absent, mention commandes-advisory. Déterministe, zéro mock. | | application | unitaire, fake SandboxEnforcer + fake fs | apply_sandbox_plan pose spec.sandbox depuis le même EffectivePermissions que la projection ; None posé ⇒ spec.sandbox == None. apply_injection insère le résumé ssi eff.is_some(). Aucun vrai Landlock. | | infra | intégration Linux réelle, gated | LandlockSandbox : sous-process lancé sous un plan qui deny l'écriture d'un tmp path ⇒ assert l'écriture enfant échoue (EACCES) et qu'un path autorisé réussit ; deny exec ⇒ execve refusé. Gating : #[cfg(target_os="linux")] + garde runtime de disponibilité Landlock (sinon skip), à la manière des tests SSH/WSL #[ignore]. NoopSandbox : enforceUnsupported, jamais d'erreur. | | app-tauri | wiring | Sélection cfg!-dépendante du bon enforcer + injection dans l'adapter PTY. | --- ## 6. Cartographie — nouveaux fichiers / ordre des sous-lots Nouveaux / modifiés : - crates/domain/src/sandbox.rs (new) : SandboxPlan, PathGrant/PathAccess, SandboxContext, SandboxKind, SandboxStatus, SandboxError, trait SandboxEnforcer, fn compile_sandbox_plan. Export lib.rs. - crates/domain/src/permission.rs (mod) : render_permission_summary. - crates/domain/src/ports.rs (mod) : SpawnSpec.sandbox: Option<SandboxPlan>. - crates/infrastructure/src/sandbox/{mod,landlock,noop}.rs (new) : adapters par OS. - crates/infrastructure/src/pty/… (mod) : PortablePtyAdapter reçoit Arc<dyn SandboxEnforcer> + pre_exec. - crates/application/src/agent/lifecycle.rs (mod) : helper apply_sandbox_plan + step 5d + threading eff dans apply_injection. - crates/app-tauri/src/state.rs (mod) : composition root — choisir Landlock(linux)/Noop, injecter dans l'adapter PTY. Ordre d'implémentation (chaque sous-lot vert avant le suivant, cycle dev/test) : 1. LP4-0 (domaine, pur)sandbox.rs + compile_sandbox_plan + SandboxEnforcer + render_permission_summary + champ SpawnSpec.sandbox. Tests purs (globs→racines, fail-closed, résumé). Aucun comportement runtime encore — zéro régression. 2. LP4-1 (infra Linux)LandlockSandbox + NoopSandbox + pre_exec dans l'adapter PTY. Test d'intégration Linux réel (accès refusé). 3. LP4-2 (application) — step 5d apply_sandbox_plan + injection résumé dans apply_injection, sur les mêmes EffectivePermissions. Tests fake-enforcer. 4. LP4-3 (composition root) — câblage app-tauri par OS + valid. e2e AppImage (⚠️ rebuild AppImage, pas seulement sources — cf. mémoire mcp-bridge-and-delegation-runtime-notes). 5. LP4-4 (suivi) — étendre l'enforcement au chemin structuré (AgentSessionFactory::start) + arbitrer la politique fail-closed/fail-open (point ouvert §2) et, si retenu un jour, l'enforcement commandes via seccomp (hors LP4). Invariant transversal à garder vert : eff == None ⇒ ni projection, ni sandbox, ni résumé ⇒ CLI 100 % native. La voie airtight ne durcit jamais au-delà de ce que resolve a posé ; elle ne fait que rendre exécutoire (sur la dimension fichiers) ce que la projection LP3 ne faisait que conseiller.
  • Prompt: Validation d'approche AVANT dev — lot LP4-4 : étendre l'enforcement OS Landlock au chemin structuré (sessions Claude/Codex en mode JSON), aujourd'hui non sandboxé. État actuel (committé, vert) : - Domaine pur : domain::sandbox avec SandboxPlan, SandboxContext, compile_sandbox_plan(eff, ctx) -> Option<SandboxPlan>, et le port SandboxEnforcer { fn enforce(&self, &SandboxPlan) -> Result<SandboxStatus, SandboxError>; fn kind(); }. Adapters infra : LandlockSandbox (Linux), NoopSandbox, default_enforcer(). - Chemin PTY (crates/infrastructure/src/pty/mod.rs) : OS-enforcé. Technique retenue = spawn_command_sandboxed : un THREAD JETABLE appelle enforcer.enforce(plan) (restriction Landlock irréversible, donc confinée au thread) PUIS spawn le child depuis ce thread → le domaine Landlock est hérité par le child via fork+exec. portable-pty n'expose pas de pre_exec injectable, d'où ce choix ; il évite aussi le piège de l'allocation non async-signal-safe dans un pre_exec post-fork en process multithreadé. - Chemin structuré (crates/infrastructure/src/session/process.rs::drain) : spawn via tokio::process::Command (async), draine stdout ligne-à-ligne jusqu'à EOF. SpawnLine { command, args, cwd, env, stdin } ne porte aucun plan. C'est l'incarnation « un exec par tour ». Question d'archi à trancher : comment enforcer le SandboxPlan sur le child spawné par tokio::process::Command en respectant l'hexagonal ET la sûreté (le truc du thread jetable du PTY ne transpose pas tel quel au spawn async tokio qui forke sur les threads du runtime) ? Pistes à arbitrer : (a) CommandExt::pre_exec (tokio le réexpose) appelant l'enforce dans le child post-fork — simple mais risque async-signal-safety si landlock alloue ; (b) déporter le spawn du tour structuré sur un thread dédié restreint (via spawn_blocking + thread jetable comme le PTY), au prix de la nature async du drain ; (c) un helper de spawn sandboxé partagé entre PTY et session (factoriser la technique) ; (d) autre. Livrable attendu (via idea_reply) : la stratégie retenue + justification (sûreté + hexagonal), le contrat à modifier (faut-il que SpawnLine porte le plan ? l'enforcer est-il injecté dans la session factory comme il l'est dans le PTY adapter ?), le découpage des fichiers à toucher, et les invariants/tests que QA devra vérifier. NE code rien. Si une piste est dangereuse (ex. allocation post-fork), dis-le explicitement.
  • Response: # LP4-4 — Validation d'approche : enforcement Landlock sur le chemin structuré ## Verdict Stratégie retenue : (b) raffiné — spawn du tour structuré sandboxé sur un thread jetable restreint, en spawn std synchrone, exposé à l'async via spawn_blocking. C'est la transposition directe et sûre de la technique déjà prouvée du PTY. Piste (a) pre_exec(enforce) REJETÉE comme dangereuse (détail plus bas). (c) factorisation : oui sur le principe, non sur le corps — les deux spawns sont trop différents (portable-pty SlavePty vs std::process). --- ## Pourquoi PAS (a) pre_exec(enforce) — DANGER explicite tokio::process::Command réexpose bien CommandExt::pre_exec, mais le closure s'exécute dans l'enfant après fork, avant exec, dans un process multithreadé (les workers du runtime tokio). Après fork en contexte multithreadé, seules les opérations async-signal-safe sont permises : tout mutex tenu par un autre thread au moment du fork (typiquement le lock de l'arène malloc) reste verrouillé à jamais dans l'enfant. Or landlock::Ruleset::…restrict_self() alloue (construction du ruleset, RestrictionStatus, Vec internes) → malloc post-fork → risque de deadlock de l'enfant. C'est exactement le piège que le commentaire du PTY (spawn_command_sandboxed, lignes 185-206) documente et évite. La cohérence d'archi impose de le rejeter ici aussi. > Note : il existe une variante async-signal-safe de Landlock (construire le ruleset_fd dans le parent, ne faire que prctl(NO_NEW_PRIVS) + landlock_restrict_self(fd) — deux syscalls purs — dans le pre_exec). Mais elle force à scinder le port SandboxEnforcer en deux phases (prepare allouant / commit syscall-only) et fait fuiter cette mécanique dans le domaine. Inutile : la technique du thread jetable atteint la même sûreté sans toucher au contrat de port. Je la mentionne pour mémoire, je ne la recommande pas. ## Pourquoi (b) est sûr La technique du PTY ne fait rien dans l'enfant post-fork : enforce(plan) tourne dans le thread jetable parent AVANT le fork ; l'enfant hérite simplement le domaine Landlock (hérité across fork, préservé across execve). Zéro code post-fork ⇒ zéro problème d'async-signal-safety. C'est toute l'élégance, et elle transpose telle quelle. La seule difficulté est que tokio::process::Command forke sur un worker partagé du runtime (qu'on ne peut pas restreindre : la restriction est irréversible → on empoisonnerait le runtime). D'où : pour le chemin sandboxé on abandonne le drain async tokio au profit d'un drain std synchrone sur le thread jetable restreint, réconcilié à l'async par spawn_blocking. Le chemin non-sandboxé (sandbox == None ou pas d'enforcer) reste l'actuel drain async tokio, inchangé (zéro régression, c'est aussi le seul chemin sur non-Linux). Détails de sûreté du thread sandboxé : 1. enforcer.enforce(&plan) restreint CE thread (fail-closed : Err ⇒ on échoue le tour, aucun child ne tourne) ; 2. std::process::Command::spawn() depuis ce thread ⇒ l'enfant hérite le domaine ; 3. poser un unsafe { cmd.pre_exec(|| Ok(())) } vide (async-signal-safe) pour forcer le chemin fork+exec déterministe (parité avec portable-pty, lève tout doute vs un éventuel posix_spawn glibc — l'héritage tiendrait de toute façon, mais on ne parie pas) ; 4. drain bloquant ligne-à-ligne stdin/stdout → EOF → wait()Vec<String> ; 5. le thread meurt, emportant sa restriction irréversible ; les autres threads d'IdeA sont intouchés. Timeout : aujourd'hui run_turn enveloppe le drain async. En sandboxé, on enveloppe le JoinHandle du thread via tokio::time::timeout ; à expiration il faut tuer le child (le thread est bloqué en read). Donc le thread renvoie son killer (pid / Arc<Mutex<Child>>) par un oneshot dès après spawn ; le kill provoque l'EOF ⇒ le read débloque ⇒ le thread finit ⇒ on retourne Timeout. À implémenter proprement (c'est le seul vrai surcoût de machinerie vs l'élégance actuelle). --- ## Contrat à modifier 1. SpawnLine porte le plan (oui) — crates/infrastructure/src/session/process.rs : ajouter pub sandbox: Option<SandboxPlan>. Symétrie avec SpawnSpec.sandbox. SpawnLine est un DTO infra (pas domaine), donc OK. 2. L'enforcer est injecté dans la factory (oui, comme le PTY adapter) — StructuredSessionFactory gagne un champ Option<Arc<dyn SandboxEnforcer>> + un builder with_sandbox_enforcer(...), jumeau exact de PortablePtyAdapter::with_sandbox_enforcer. Pas dans la signature du port : c'est une dépendance de composition root, par-instance, pas par-appel. 3. Le plan traverse le port par-appel — le SandboxPlan dépend des permissions résolues de l'agent, il est calculé par-lancement dans lifecycle.rs step 5d (spec.sandbox). Il doit donc passer dans AgentSessionFactory::start : ajouter sandbox: Option<&SandboxPlan>. SandboxPlan est un type domaine (domain::sandbox) franchissant un port domaine — cohérent (déjà le cas via SpawnSpec.sandbox sur AgentRuntime). Extension O/C : une seule vraie impl + les fakes. Flux complet : lifecycle.rs (calcule spec.sandbox) → passe le plan à launch_structured (qui ne reçoit pas spec aujourd'hui) → factory.start(..., sandbox) → la factory apparie sandbox (plan, par-appel) + son enforcer (par-instance) et les passe à ClaudeSdkSession::new / CodexExecSession::new → l'adapter les stocke, remplit SpawnLine.sandbox, et passe l'enforcer à run_turn. 4. run_turncrates/infrastructure/src/session/process.rs : signature run_turn(spec, enforcer: Option<&Arc<dyn SandboxEnforcer>>, timeout). Si spec.sandbox.is_some() && enforcer.is_some() ⇒ drain sandboxé (thread restreint) ; sinon ⇒ drain async actuel strictement inchangé. --- ## Découpage des fichiers à toucher - crates/domain/src/ports.rs:537AgentSessionFactory::start : + sandbox: Option<&SandboxPlan>. - crates/infrastructure/src/session/process.rs — champ SpawnLine.sandbox ; run_turn reçoit l'enforcer ; nouveau drain_sandboxed (thread jetable + std spawn + pre_exec vide + timeout par kill). - crates/infrastructure/src/session/factory.rs — champ Option<Arc<dyn SandboxEnforcer>> + with_sandbox_enforcer ; start apparie plan+enforcer et les injecte dans les ctors d'adapters. - crates/infrastructure/src/session/claude.rs (build_spawn_line ~L187, send ~L195) & codex.rs (~L162/L195) — stocker plan+enforcer, remplir SpawnLine.sandbox, passer l'enforcer à run_turn. - crates/application/src/agent/lifecycle.rslaunch_structured (~L1620) reçoit le plan (spec.sandbox) et le relaie à factory.start. - crates/app-tauri/src/state.rs:408StructuredSessionFactory::new().with_sandbox_enforcer(infrastructure::default_enforcer()). - Fakes à mettre à jour (nouvelle signature start) : crates/domain/tests/structured_session_d0.rs, crates/application/tests/structured_launch_d3.rs, crates/application/tests/orchestrator_service.rs. --- ## Invariants & tests pour la QA 1. Parité avec le PTY (le test pivot) : réplique de pty_spawn_enforces_sandbox_plan_end_to_end côté structuré, avec un fake CLI / sh qui émet une ligne JSONL et tente d'écrire hors grant (doit être bloqué kernel) et dans grant (doit réussir). Zéro token (aucun vrai claude/codex). Gardé derrière landlock_is_enforced() (skip propre sur kernel sans Landlock). 2. Companion négatif : même factory+enforcer mais spec.sandbox == None ⇒ l'écriture hors-grant réussit (prouve que le blocage vient du plan, pas d'une restriction ambiante). 3. Fail-closed : posture Deny sur kernel sans Landlock ⇒ enforce Errrun_turn renvoie une erreur et aucun child ne tourne (assert : le marqueur de sortie du fake CLI n'existe pas). 4. No-op par défaut : eff == Nonecompile_sandbox_plan Nonespec.sandbox None ⇒ chemin async tokio actuel, comportement natif (régression nulle). Vérifier que la suite structurée existante (conformance, D0/D3) reste verte sans modification de comportement. 5. Confinement de l'irréversibilité : après un tour sandboxé, un autre tour (autre session, plan plus large) sur le même process IdeA n'est PAS affecté par la restriction du tour précédent (le thread jetable est mort). Test : deux tours successifs avec des grants disjoints, chacun voit son propre périmètre. 6. Timeout sous sandbox : un fake CLI qui ne ferme jamais stdout ⇒ run_turn(timeout) tue le child et renvoie Timeout, sans laisser de zombie ni de thread bloqué. 7. Resume préservé : le conversation_id est toujours capté correctement après un tour sandboxé (la restriction FS ne casse pas le parsing du session_id). ## ⚠️ Risque produit à signaler (hors mécanisme, mais bloquant à valider) Les CLI structurées (claude/codex = binaires Node) ont des besoins FS ambiants bien plus lourds qu'un sh : lecture de ~/.claude / ~/.codex (credentials, cache de session pour le resume), node_modules, libs système, écriture de caches temporaires. compile_sandbox_plan ne fence en écriture/lecture que les classes explicitement posées et garde les lectures globalement ouvertes — mais si une policy pose un Deny/posture restrictive touchant $HOME, le resume peut casser (la CLI n'écrit plus son cache de session). Recommandation : LP4-4 livre le mécanisme validé au fake CLI (zéro token, points 1-7), et on flag qu'une validation e2e manuelle d'un vrai tour claude/codex sous un plan représentatif doit confirmer que le run dir + le home de la CLI restent atteignables avant d'activer le sandbox en prod pour le chemin structuré. C'est un sujet de composition du plan (run_dir reachability, déjà réservé dans SandboxContext.run_dir mais non consommé par la traduction pure), pas du mécanisme d'enforcement — à traiter en lot suivant si besoin. --- Résumé : (b) avec thread jetable restreint + std spawn + spawn_blocking, fail-closed identique au PTY ; SpawnLine.sandbox ajouté ; enforcer injecté par construction dans la factory ; plan passé par-appel via AgentSessionFactory::start(.., sandbox). (a) rejetée (alloc post-fork). Ne rien coder avant ce GO — QA doit pouvoir s'appuyer sur les 7 invariants ci-dessus.
  • Prompt: Arbitrage LP4-4 — une divergence est remontée par DevBackend sur ton cadrage, elle touche un invariant crate-wide. Verdict requis. Ton cadrage demandait, dans drain_sandboxed (chemin structuré), un unsafe { cmd.pre_exec(|| Ok(())) } VIDE pour forcer std sur le chemin déterministe fork+exec (parité avec portable-pty). Blocage : la crate infrastructure est #![forbid(unsafe_code)] (lib.rs:12). Un forbid ne peut pas être relâché par un #[allow] local. Le PTY n'avait jamais eu besoin d'unsafe car il hérite le fork+exec du pre_exec INTERNE de portable-pty ; sur le chemin structuré on pilote std::process en direct, donc le pre_exec serait à nous → unsafe → interdit. DevBackend a retenu (et livré, build+tests verts) l'option (a) : RETIRER le pre_exec. Son argument : landlock_restrict_self restreint le thread courant et toute sa descendance ; l'héritage est assuré par le noyau à travers fork/clone/vfork ET préservé par execve, Y COMPRIS via posix_spawn (= clone+execve), car l'enforcement vit au niveau des credentials de la tâche, hors d'atteinte de l'espace utilisateur. Le fork+exec n'était donc qu'une garantie de DÉTERMINISME du syscall de spawn, pas de sécurité ; enforce()-avant-spawn-sur-le-même-thread suffit. Les deux options sur la table : (a) rester comme livré : zéro unsafe, héritage noyau (choix par défaut de DevBackend). (b) passer lib.rs de #![forbid(unsafe_code)] à #![deny(unsafe_code)] + #[allow(unsafe_code)] scoppé sur drain_sandboxed pour rétablir le pre_exec vide. Question : valides-tu (a) — l'héritage de la restriction Landlock est-il bien garanti par le noyau indépendamment de fork+exec vs posix_spawn, rendant le pre_exec inutile à la sécurité — ou exiges-tu (b) (relâcher l'invariant unsafe-code de la crate) ? Si (a), confirme qu'on garde #![forbid(unsafe_code)] intact. Réponds avec le verdict + justification courte ; c'est juste cet arbitrage, pas une re-revue complète.
  • Response: # Verdict LP4-4 : (a) validée — on garde #![forbid(unsafe_code)] intact DevBackend a raison, et son choix est même cohérent avec mon cadrage initial : j'y avais explicitement écrit que l'héritage tient sans le pre_exec (« even without it inheritance holds across posix_spawn's clone »), le pre_exec vide n'étant qualifié que d'« assurance de déterminisme », jamais de garantie de sécurité. Le retirer ne retire donc aucune propriété de sûreté. ## Justification technique (l'héritage est bien garanti par le noyau) - L'enforcement Landlock est attaché aux credentials de la tâche (le domaine Landlock vit dans la cred, sous NO_NEW_PRIVS — que restrict_self pose lui-même). C'est de l'état noyau, hors d'atteinte de l'espace utilisateur. - landlock_restrict_self restreint le thread appelant ; tout enfant créé par ce thread hérite ses credentials → donc le domaine Landlock. L'héritage se fait à travers fork/clone/vfork et est préservé par execve. - posix_spawn (glibc) = clone(CLONE_VM|CLONE_VFORK) + execve émis depuis le thread appelant dans le même process : l'enfant hérite les creds exactement comme via fork+exec. Le mécanisme de spawn ne change que le déterminisme du syscall, pas l'héritage. La seule précondition de sûreté — le syscall de spawn doit être émis par le thread restreint — est respectée par le design : enforce(plan) puis Command::spawn() sur le même thread jetable. ✔ ## Confirmation empirique décisive Le test e2e de parité (écriture hors-grant bloquée kernel / dans-grant réussie) passe sur le chemin structuré avec le spawn réellement utilisé. C'est la preuve in vivo que l'héritage tient indépendamment de fork+exec vs posix_spawn — exactement l'invariant en débat. Build + suite verts ⇒ rien à rouvrir. ## Arbitrage de l'invariant crate-wide Relâcher #![forbid(unsafe_code)]deny + #[allow] scoppé pour ne récupérer qu'une garantie de déterminisme de spawn (sans valeur de sécurité) serait un mauvais échange : on dégraderait une protection statique crate-wide pour une commodité nulle côté sûreté. Non. Décision : on reste en (a). crates/infrastructure/src/lib.rs:12 #![forbid(unsafe_code)] reste INTACT. Pas de pre_exec, pas d'unsafe. Une seule exigence pour QA, à garder verte en permanence (déjà couverte par le test de parité) : l'invariant « écriture hors-grant bloquée sur le chemin structuré » est le garde-fou qui détecterait toute régression d'héritage si le mécanisme de spawn venait à changer. Tant qu'il est vert, (a) est prouvée.
  • Prompt: Cadrage architecture — nouvelle feature « Gestion des limites de session des agents » (détection + reprise auto). Étape 1 du cycle §3, AVANT tout code. Produis la cartographie : frontières domaine/application/infra, ports/contrats à créer ou étendre, et l'arborescence des fichiers touchés. Mets aussi à jour ARCHITECTURE.md. CONTEXTE PRODUIT (verrouillé avec l'utilisateur le 2026-06-16) : Besoin : IdeA doit savoir quand un agent est en limite de session ET jusqu'à quelle heure, puis lui demander de reprendre où il en était une fois la limite levée. Priorités : (1) SOLIDE — pas de bidouille, marche dans ~100% des cas même pour un novice ; (2) si possible sans dépendance au modèle de l'agent. CONSTAT DUR à intégrer : l'heure exacte de reset n'existe nulle part de façon universelle (ni OS, ni code de sortie, ni API inter-modèles). Elle est fabriquée par le fournisseur et seulement exposée dans le flux de sa CLI. Donc « 100% fiable + zéro dépendance modèle + heure exacte » sont incompatibles simultanément. SOLUTION RETENUE — détecteur HIÉRARCHIQUE calqué sur la hiérarchie de readiness existante (domain/readiness.rs) : - Niveau 1 (solide, structuré) : l'adapter structuré extrait limite + reset du flux machine. Pour Claude, rate_limit_event.rate_limit_info est DÉJÀ parsé dans infrastructure/session/claude.rs (~ligne 90) mais jeté (réduit à Heartbeat) — il suffit de lire le timestamp de reset (resetsAt) au lieu de le dropper. - Niveau 2 (déclaratif, configurable) : champ de profil rate_limit_pattern (regex + groupe de capture pour l'heure) pour agents PTY/TUI sans adapter structuré, dans la lignée des profils déclaratifs §9 (domain/profile.rs). - Niveau 3 (filet humain) : si rien ne matche mais agent Stalled (variante DÉJÀ prévue dans domain/readiness.rs), IdeA DEMANDE à l'utilisateur. Garantit le « 100% même pour un novice » : jamais d'inaction silencieuse. MODEL-AGNOSTIC tenu AU DOMAINE : le domaine ne connaît que quelque chose comme RateLimited { until: Option<Instant> }. Tout le savoir spécifique modèle reste confiné aux adapters/profils. REPRISE (model-agnostique, briques existantes) : pivot sur le conversation_id du moteur + --resume natif, déjà câblés (session/claude.rs build_spawn_line + application/agent/resume.rs ListResumableAgents). Le --resume porte tout l'historique → pas de reconstruction manuelle. Le SessionInspector (infrastructure/inspector/claude.rs) fournit le « dernier sujet » pour l'UI. Un planificateur arme un réveil sur le port Clock (infrastructure/clock/) à resets_at puis relance via SessionPlan::Resume + prompt de reprise court. DÉCISIONS PRODUIT VERROUILLÉES : - Reprise : AUTOMATIQUE à l'heure de reset, ANNULABLE (fenêtre + notification UI). - Couverture : les TROIS niveaux d'emblée (y compris le repli regex niveau 2). - État : EN MÉMOIRE uniquement — PAS de persistance de SessionLimit. Conséquence assumée : le réveil auto ne joue que tant qu'IdeA reste ouvert ; si l'IDE est fermé/rouvert après le reset, le chemin existant ListResumableAgents (agent_was_running/conversation_id) prend le relais. DÉCOUPAGE PRESSENTI (à valider/affiner par toi) : 1) domaine (variante ReplyEvent::RateLimited, ReadinessSignal::RateLimited, type SessionLimit, état/statut agent RateLimited) ; 2) adapter Claude (extraire resetsAt) ; 3) profil (champ rate_limit_pattern) ; 4) application (service de planification de reprise sur le port Clock, annulable) ; 5) UI front (badge « limité jusqu'à HH:MM » + filet humain de confirmation). Livrable attendu : la cartographie complète (ports à créer/étendre avec leurs signatures, où vit chaque pièce, fichiers à créer/modifier, lots ordonnés pour le cycle dev/test), + ARCHITECTURE.md mis à jour. Signale tout point où ma solution entre en tension avec l'archi hexagonale existante.
  • Response: # Cadrage architecture validé — « Gestion des limites de session des agents » Cartographie produite et ARCHITECTURE.md mis à jour (nouvelle §21). J'ai vérifié ta solution contre le code réel (readiness.rs, session/claude.rs, profile.rs, ports.rs, input.rs, resume.rs, clock, events.rs). Elle est architecturalement saine ; je valide les 5 niveaux, avec 5 tensions hexagonales corrigées (à respecter au dev). ## Frontières (où vit quoi) - Domaine (pur) : le fait neutre « limité, reset à T (peut-être) ». Rien de spécifique modèle. - Infra (adapters) : forme du rate_limit_event Claude, regex d'une TUI, parsing d'heure locale, minuterie. - Application : orchestration détecter→planifier→reprendre. - Présentation : badge + countdown + Annuler + dialogue filet humain. ## ⚠️ 5 tensions avec l'hexagonal (corrigées dans §21.2 — à lire avant de coder) 1. T1 — Instant rejeté. Le domaine parle i64 époche-ms (via Clock::now_millis), Instant est non sérialisable/monotone/absent. ⇒ RateLimited { resets_at_ms: Option<i64> }. Déviation assumée de ta proposition. 2. T2 — pas de regex dans le domaine. prompt_ready_pattern a été choisi littéral exprès pour éviter la dép regex. ⇒ le domaine stocke la donnée (RateLimitPattern{pattern,…}), le moteur regex + parsing d'heure vivent en infra (regex ajouté au seul Cargo.toml d'infrastructure). 3. T3 — Clock ne réveille pas. Il donne l'heure, pas un timer. ⇒ 1 seul nouveau port Scheduler (arm/cancel), tout le reste réutilise l'existant. 4. T4 — RateLimited non terminal. Le contrat ReplyStream dit « seul Final est terminal » et claude.rs rompt sur Final. ⇒ RateLimited s'intercale comme Heartbeat. Point d'intégration : un tour clos sans Final parce que limité ne doit pas devenir AgentSessionError::Iodrain_bounded doit le traiter en fin gracieuse. 5. T5 — niveau 3 dépend du lot 2. ReadinessSignal::Stalled est réservé/non produit aujourd'hui. ⇒ le filet humain (LS6) est gated sur la livraison du lot 2 (stagnation). Niveaux 1+2 couvrent déjà structuré + PTY entre-temps. ## Ports à créer / étendre - NOUVEAUScheduler (domaine, ports.rs) : arm(deadline_ms, ScheduledTask) -> ScheduleId + cancel(id) -> bool, minuterie one-shot annulable in-memory ; adapter TokioScheduler (infra). ScheduledTask::ResumeAgent{agent_id,node_id,conversation_id} = donnée pure (pas de closure traversant la frontière), drain côté application — même motif que le dispatch orchestrateur §14.3. - ÉTENDUS (aucun autre port neuf) : ReplyEvent +RateLimited ; ReadinessSignal +RateLimited (+classify) ; AgentProfile +rate_limit_pattern ; EventBus/DomainEvent +5 variantes. Reprise = réutilise AgentSessionFactory::start/LaunchAgent + SessionPlan::Resume + conversation_id (déjà câblés) — zéro reconstruction. ## Fichiers touchés - domaine : ports.rs, readiness.rs, profile.rs, events.rs, lib.rs, session_limit.rs (NEW : VO SessionLimit + fn pure plan_resume). - infra : session/claude.rs (lire resetsAt→époche-ms au lieu de jeter en Heartbeat, ligne ~90), session/codex.rs (si signal Codex), ratelimit/ (NEW : RateLimitParser regex niveau 2), scheduler/ (NEW : TokioScheduler), clock/ inchangé. - application : agent/session_limit.rs (NEW : SessionLimitService), agent/structured.rs (réagir au signal + réconcilier T4), agent/mod.rs. - app-tauri : composition root (wire scheduler+service+drain), commandes cancel_agent_resume/resume_agent_now/confirm_agent_rate_limit, relais events camelCase. - frontend : features/{agents,terminals} (badge « limité jusqu'à HH:MM » + countdown + Annuler + dialogue filet humain), gateway TS + adapter + mocks. ## Lots ordonnés (cycle dev/test §3) LS1 domaine (variantes + VO + plan_resume + profil, tests purs) → LS2 adapter Claude niv.1 ∥ LS3 port Scheduler+TokioSchedulerLS4 SessionLimitService + réconciliation T4 → LS5 parser regex niv.2 → LS6 filet humain niv.3 (gate : lot 2 stagnation livré)LS7 app-tauri → LS8 frontend. LS1+LS2+LS4 = niveau 1 Claude de bout en bout : valeur atteinte tôt. ## Confirmé sain - In-mémoire seul : aucun store, aucun schéma .ideai/ modifié. IDE rouvert après reset ⇒ le chemin existant ListResumableAgents reprend (popup, pas d'auto). Cohérent. - Auto + annulable = Scheduler::arm/cancel + events AgentResumeScheduled/AgentResumeCancelled. ## Spikes (§21.10) Format réel de resetsAt (époch/ISO/relatif) ; heure locale→époche + passage de minuit (niv.2) ; signal de limite côté Codex ; dédoublonnage si niv.1 et niv.2 matchent le même épisode (1 SessionLimit vivante/agent). Prochaine étape du cycle : passer la main à Git pour décider de la branche (feature/session-limits ?) avant que le binôme dev/test attaque LS1.
  • Prompt: Feature session-limits — cadrage du lot LS7 (câblage au composition root). Project root: /home/anthony/Documents/Projects/IdeA. État : LS1→LS6 committés. Tout le mécanisme existe mais n'est PAS branché dans app-tauri : application::agent::session_limit::SessionLimitService (ports injectés : Clock, Scheduler, EventBus, AgentResumer) n'est référencé nulle part dans le composition root → aucun DomainEvent::AgentRateLimited/ResumeScheduled/Resumed/RateLimitSuspected n'est jamais émis, aucune reprise armée. LS7 doit câbler au composition root (app-tauri), conformément à ARCHITECTURE §21. J'ai besoin d'une carte de câblage précise (PAS de code), répondant à ces points, en nommant les fichiers/structs/fonctions exacts du repo où chaque tap se branche : 1. Instanciation du service : où, dans app-tauri (state.rs ? di/composition root ?), instancier SessionLimitService::new(clock, scheduler, events, resumer). Quel Scheduler concret (TokioScheduler déjà en infra), quel EventBus (TokioBroadcastEventBus partagé), quel Clock. Cycle de vie/partage (Arc) cohérent avec l'existant. 2. Port AgentResumer → LaunchAgent : comment implémenter AgentResumer::resume(agent_id, node_id, conversation_id, resume_prompt) par-dessus le mécanisme de lancement existant (LaunchAgent + AgentSessionFactory + SessionPlan::Resume). Où vit ce code (un adapter app-tauri ?), et quelles dépendances il capture. Référence : les passerelles voisines HandoffProvider/ProviderSessionProvider mentionnées dans session_limit.rs. 3. Tap détection niveau 1 (structuré) : où le flux ReplyStream/ReplyEvent des sessions structurées est consommé aujourd'hui (le « pump » qui draine les tours — cf. chat.rs chunk_from_event), et comment y intercepter ReplyEvent::RateLimited{resets_at_ms} pour appeler service.on_rate_limited(agent_id, node_id, conversation_id, resets_at_ms). Comment récupérer node_id + conversation_id à ce point. 4. Tap détection niveau 2 (PTY) : où la sortie PTY des agents non-structurés est drainée, et où instancier un infrastructure::RateLimitParser (règle de sélection ratelimit::applies(profile)) pour le nourrir des fragments et router un SessionLimit détecté vers le service. Respect de l'anti-double-détection §21.10-4. 5. Drain du Scheduler : comment TokioScheduler remet les ScheduledTask::ResumeAgent échus (canal de remise) et où câbler le récepteur qui appelle service.execute_resume(task) sur le runtime Tokio. 6. Commande Tauri d'annulation : exposer cancel_resume(agent_id) comme #[tauri::command] (la fenêtre annulable côté UI l'appellera en LS8) — où l'enregistrer dans le generate_handler!. Donne aussi : ordre d'implémentation recommandé, points de fragilité/race à surveiller pour QA, et confirme que rien ne viole l'hexagonal (domaine pur, regex/parse confinés infra). Rends via idea_reply.
  • Response: # LS7 — Carte de câblage « session-limits » au composition root Tous les chemins sont relatifs au project root. Aucun code ici : seulement les points d'ancrage exacts (fichier · struct/fn · ligne). Faits vérifiés en amont : - Service : crates/application/src/agent/session_limit.rsSessionLimitService::new(clock, scheduler, events, resumer) ; API publique : on_rate_limited(agent_id, node_id, conversation_id, resets_at_ms), execute_resume(task).await -> Result<(),AppError>, cancel_resume(agent_id) -> bool. Port à implémenter : AgentResumer::resume(agent_id, node_id, conversation_id, resume_prompt) (async). - Scheduler : crates/infrastructure/src/scheduler/mod.rsTokioScheduler::new(tx: UnboundedSender<ScheduledTask>, clock: Arc<dyn Clock>). Réexporté infrastructure::TokioScheduler. Il pousse la ScheduledTask échue dans tx ; le drain détient rx. - Parser N2 : crates/infrastructure/src/ratelimit/mod.rsRateLimitParser::new(&RateLimitPattern) -> Option<Self>, detect(text, now_ms) -> Option<SessionLimit>, et la règle de sélection ratelimit::applies(profile) -> bool (= structured_adapter.is_none() && rate_limit_pattern.is_some()). Réexportés infrastructure::RateLimitParser / infrastructure::ratelimit::applies. - LS6 a déjà câblé les 6 variantes DomainEvent::Agent{RateLimited,ResumeScheduled,ResumeCancelled,Resumed,RateLimitSuspected} → DTO → relay (crates/app-tauri/src/events.rs:208-253, 426-452). Rien à faire côté event wire. --- ## 1. Instanciation du service (composition root) Fichier : crates/app-tauri/src/state.rs, dans AppState::build (à partir de la ligne 355). Réutiliser les adapters déjà construits en tête de build : - clock (l.358, SystemClock, implémente Clock::now_millis) → caster en Arc<dyn Clock> comme partout (cf. Arc::clone(&clock) as Arc<dyn Clock>). - event_bus (l.357) → events_port (l.369, Arc<dyn EventBus>), le même bus partagé que tout le reste (donc l'AgentRateLimited émis passera par spawn_relay). Séquence d'instanciation (à placer après la construction de launch_agent l.668-700 et de project_store l.750, car le resumer en dépend ; idéalement juste avant le bloc orchestrateur l.917) : 1. let (resume_tx, resume_rx) = tokio::sync::mpsc::unbounded_channel::<ScheduledTask>(); 2. let scheduler = Arc::new(TokioScheduler::new(resume_tx, Arc::clone(&clock) as Arc<dyn Clock>)) as Arc<dyn Scheduler>; 3. let resumer = Arc::new(AppAgentResumer::new(Arc::clone(&launch_agent), Arc::clone(&store_port), <resume_ctx_registry>)) as Arc<dyn application::AgentResumer>; (cf. §2). 4. let session_limit_service = Arc::new(SessionLimitService::new(Arc::clone(&clock) as Arc<dyn Clock>, scheduler, Arc::clone(&events_port), resumer)); Cycle de vie / partage : ajouter un champ pub session_limit_service: Arc<SessionLimitService> à la struct AppState (déclaration vers l.326, à côté de orchestrator_service) et le renvoyer dans le littéral final (l.967-1055). Le Arc est partagé par : (a) la commande cancel_resume (§6), (b) la tâche de drain (§5), (c) les taps de détection N1/N2 (§3/§4) qui appellent on_rate_limited. Le resume_rx n'entre pas dans AppState : il est moved dans la tâche de drain spawné à l'intérieur de build (§5), exactement comme sweep_stalled (l.899-911). Imports à ajouter en tête de state.rs : application::{SessionLimitService, AgentResumer}, domain::ports::{Scheduler, ScheduledTask}, infrastructure::TokioScheduler. --- ## 2. Port AgentResumerLaunchAgent (adapter app-tauri) Nouvel adapter dans crates/app-tauri/src/state.rs, à côté des passerelles stateless existantes AppHandoffProvider (l.100-109) / AppProviderSessionProvider (l.119-128) / AppRecordTurnProvider (l.81-90) — même patron impl application::Trait for AppXxx. impl application::AgentResumer for AppAgentResumer { async fn resume(agent_id, node_id, conversation_id, resume_prompt) -> Result<(), AppError> } recompose un LaunchAgentInput et appelle self.launch_agent.execute(...) (le même Arc<LaunchAgent> que la commande launch_agent, l.1140). C'est LaunchAgent qui, via with_handoff_provider / with_provider_session_provider (l.687-695) et le routage §17.4, applique déjà SessionPlan::Resume quand un conversation_id est présent (chemin de reprise P7/P8b/§15). Le resume_prompt (constante application::RESUME_PROMPT) est le premier tour — pour le chemin PTY natif (composition B-2, voir l.645-653), il devra être écrit dans le PTY après spawn ; pour le chemin structuré (dormant), passé en premier send. À cadrer dev : où injecter ce premier tour. La voie la plus cohérente avec l'existant est de réutiliser le médiateur d'entrée (MediatedInbox/portail d'écriture PTY, l.883-892) plutôt qu'un write direct. ⚠️ Point dur de conception (à trancher, c'est LE risque du lot) : AgentResumer::resume et ScheduledTask::ResumeAgent ne portent pas de project_id, alors que LaunchAgentInput exige un Project complet + rows/cols + mcp_runtime (cf. commande l.1126-1152). Le service est volontairement model/projet-agnostique. Il faut donc que l'adapter résolve le Project à partir de l'agent_id. Recommandation : AppAgentResumer détient un Arc<Mutex<HashMap<AgentId, ResumeContext>>> (ResumeContext = { project: Project, rows: u16, cols: u16 }) alimenté par le chemin de lancement (commande launch_agent, l.1140, où project/rows/cols sont en main) et lu au moment du resume. Le mcp_runtime est recalculé dans resume à partir de project.id via crate::mcp_endpoint::{idea_exe_path, mcp_endpoint} (recette identique à l.1126-1133). Évite un scan coûteux de project_store.list_projects + manifestes. store_port reste injecté en repli (résolution si le contexte est absent après un restart — cohérent avec « état en mémoire only » : après restart c'est ListResumableAgents qui reprend, pas le scheduler). Dépendances capturées par l'adapter : Arc<LaunchAgent>, Arc<dyn ProjectStore> (repli), le registre ResumeContext partagé. --- ## 3. Tap détection niveau 1 (structuré) Fichier : crates/app-tauri/src/commands.rs, fn agent_send, boucle de pump l.1283-1294. C'est le seul drain actif d'un ReplyStream structuré (for event in stream { ... }). Aujourd'hui chunk_from_event (crates/app-tauri/src/chat.rs:186-193) mappe ReplyEvent::RateLimited → None (jeté). Le tap : avant d'appeler chunk_from_event, faire un if let ReplyEvent::RateLimited { resets_at_ms } = &event { service.on_rate_limited(agent_id, node_id, conversation_id, *resets_at_ms); }, puis continuer le drain normalement (l'event reste non-terminal, le tour continue jusqu'au Final). Récupération de node_id + agent_id : le pump ne connaît que sid: SessionId. Le registre state.structured_sessions (crates/application/src/terminal/registry.rs:297) mappe SessionId → (agent_id, node_id) — mais il manque un accès par session_id. Ajouter une petite méthode meta_for_session(&SessionId) -> Option<(AgentId, NodeId)> sur StructuredSessions (jumeau trivial de live_agents l.388, lookup direct dans entries). conversation_id : StructuredEntry ne le porte pas ; passer None (le resume dégrade proprement sans id, contrat ScheduledTask.conversation_id: Option) ou, plus précis, le lire via providers.json (passerelle AppProviderSessionProvider) — optionnel, None est acceptable pour LS7. État actuel : ce tap est DORMANT. En composition B-2 la fabrique structurée est décâblée (launch_agent retombe toujours sur PTY, l.645-653 ; orchestrateur sans .with_structured, l.947-958). Aucun agent n'a de session structurée vivante ⇒ agent_send n'est jamais drainé. Le câbler quand même = robustesse forward (réactivation structurée). La détection réelle aujourd'hui passe exclusivement par le niveau 2. Passage du service au pump : agent_send a state: State<AppState>Arc::clone(&state.session_limit_service) avant le thread::spawn (l.1282) et le move dans le thread. --- ## 4. Tap détection niveau 2 (PTY) Fichier : crates/app-tauri/src/commands.rs, fn launch_agent, branche PTY l.1165-1190 (le if output.structured.is_none() + le thread::spawn du pump d'octets l.1176-1183). C'est le drain de la sortie des agents non-structurés — donc le chemin actif en B-2. Câblage : 1. Sélection (anti-double-détection §21.10-4) : avant d'armer un parser, résoudre le AgentProfile de l'agent et appeler infrastructure::ratelimit::applies(&profile). applies impose déjà structured_adapter.is_none() (or on est dans la branche output.structured.is_none() ⇒ cohérent) et rate_limit_pattern.is_some(). Besoin de wiring : la commande launch_agent ne charge pas le profil. Deux options — (a) exposer le AgentProfile (ou au minimum le RateLimitPattern) résolu sur LaunchAgentOutput (LaunchAgent::execute le résout déjà en interne — le plus propre, zéro I/O en plus) ; (b) le relire via le profile store. Recommandation : (a). 2. Instanciation : RateLimitParser::new(&pattern) (retourne Option ⇒ regex invalide = pas de détecteur, jamais de panique). À construire une fois par lancement, déplacé dans le thread de pump. 3. Alimentation : dans la boucle for chunk in stream (l.1177), après send_output, décoder le fragment (String::from_utf8_lossy) et appeler parser.detect(&text, clock.now_millis()). Sur Some(SessionLimit)service.on_rate_limited(agent_id, node_id, conversation_id, limit.resets_at_ms). Ici agent_id, node_id (l.1115) et conversation_id (request.conversation_id, l.1150) sont déjà en main dans la commande ⇒ les cloner avant le thread::spawn. Idem Arc::clone(&state.session_limit_service) et un Arc<dyn Clock> (ajouter un champ clock à AppState, ou réutiliser SystemMillisClock). Anti-double-détection : garantie par construction — applies ne renvoie true que pour les agents sans adapter structuré ; un agent structuré (N1) n'arme jamais de parser N2. Un seul tap actif par agent. Fragilité fragmentation : detect reçoit des fragments PTY ; un motif peut être coupé entre deux chunks. Pour LS7, accepter la détection best-effort par fragment (les bannières de limite des CLI arrivent en général d'un bloc). Si QA observe des ratés, prévoir un petit buffer glissant borné (dernières ~4 Kio) côté thread — note pour QA, pas bloquant. --- ## 5. Drain du Scheduler TokioScheduler (crates/infrastructure/src/scheduler/mod.rs:62-85) pousse la ScheduledTask échue dans son tx (canal mpsc non borné). Le récepteur resume_rx (créé en §1) est drainé dans une tâche détachée spawné à l'intérieur de AppState::build, sur le patron exact du sweeper sweep_stalled (crates/app-tauri/src/state.rs:899-911) — tauri::async_runtime::spawn (et pas tokio::spawn : build tourne dans le hook setup sans runtime ambiant, cf. commentaire l.901-903). Boucle : while let Some(task) = resume_rx.recv().await { if let Err(e) = service.execute_resume(task).await { /* log best-effort */ } }. Le Arc<SessionLimitService> et resume_rx sont moved dans la closure. execute_resume désarme l'entrée puis appelle AgentResumer::resume puis publie AgentResumed (déjà relayé par LS6). La tâche vit autant que l'app (le canal se ferme au drop du TokioScheduler/AppState). --- ## 6. Commande Tauri cancel_resume Déclaration : crates/app-tauri/src/commands.rs — nouvelle #[tauri::command] pub async fn cancel_resume(agent_id: String, state: State<'_, AppState>) -> Result<bool, ErrorDto> : let id = parse_agent_id(&agent_id)?; Ok(state.session_limit_service.cancel_resume(id)). (parse_agent_id existe déjà, cf. l.1111/1215.) Retourne le bool (true = reprise effectivement annulée ; false = rien d'armé ou « cancel pile au tir » — voir doc service l.188-213). Enregistrement : crates/app-tauri/src/lib.rs, dans tauri::generate_handler![ … ] (l.122) — ajouter commands::cancel_resume, à côté des commandes agent (p.ex. après commands::change_agent_profile, l.166). LS8 (UI fenêtre annulable) l'appellera via invoke("cancel_resume", { agentId }). --- ## Ordre d'implémentation recommandé 1. Service + scheduler + drain (§1 + §5) avec un AgentResumer stub (renvoie Ok(())) ⇒ valide que on_rate_limited arme, que le canal remet la tâche, que execute_resume/AgentResumed partent. Testable sans toucher aux pumps. 2. cancel_resume (§6) ⇒ boucle détecter→annuler bout-en-bout vérifiable manuellement (events au front via LS6). 3. Tap N2 PTY (§4) — chemin actif ⇒ première vraie détection. Implique d'exposer le RateLimitPattern/AgentProfile sur LaunchAgentOutput. 4. AppAgentResumer réel (§2) + registre ResumeContext alimenté par launch_agent ⇒ reprise réelle. 5. Tap N1 structuré (§3) + meta_for_session ⇒ forward-compat (dormant), à câbler en dernier. ## Points de fragilité / race pour QA - Résolution agent_id → Project au resume (§2) : le gros risque. Vérifier qu'un resume après fermeture/réouverture de projet ne plante pas (repli None/erreur propre, jamais de panique). Tester resume quand le ResumeContext est absent. - Cancel « pile au tir » (service l.188-213, scheduler cancel l.87-100) : sous runtime multi-thread, cancel peut renvoyer false car la tâche vient de tirer ⇒ pas d'AgentResumeCancelled, la reprise suit son cours. UI LS8 doit tolérer un false (la reprise arrive quand même). Test de course explicite recommandé. - Rafraîchissement (dédoublonnage §21.10-4) : deux signaux de limite rapprochés pour le même agent ⇒ disarm puis ré-arm ; vérifier qu'on n'empile pas deux ScheduledTask et qu'un seul AgentResumed sort. - Fragmentation PTY (§4) : motif coupé entre chunks (best-effort en LS7, buffer glissant en repli si raté). - Injection du resume_prompt en PTY (§2) : timing d'écriture après spawn (la CLI doit être prête) — réutiliser le portail d'écriture médié plutôt qu'un write brut. - Premier tour différé / cold-start : interaction avec release_agent_cold_start (l.1132-1137) si le resume relance un agent dont le pont MCP n'est pas encore connecté. ## Conformité hexagonale — confirmée - Domaine pur : SessionLimit/plan_resume/RateLimitPattern/ScheduledTask/Scheduler/Clock/EventBus ne portent aucune I/O ni regex. - Regex + parsing d'heure confinés infra : RateLimitParser et timeparse vivent dans crates/infrastructure/src/ratelimit|timeparse ; ils produisent une valeur domaine (SessionLimit). Aucune regex ne franchit la frontière. - Service applicatif pur-ports : SessionLimitService ne dépend que de traits (Clock/Scheduler/EventBus/AgentResumer). - Composition root seul à connaître le concret : TokioScheduler, RateLimitParser, AppAgentResumer, le canal mpsc et les taps sont tous dans app-tauri (state.rs/commands.rs/lib.rs), jamais ailleurs. AgentResumer est un port applicatif implémenté à la racine, exactement comme HandoffProvider/ProviderSessionProvider. Aucun nouveau port domaine, aucun nouvel adapter infra : LS7 est purement du câblage (composition de l'existant), conforme à ARCHITECTURE §21 et au principe « zéro nouveau port/adapter » du lot.
  • Prompt: Arbitrage de scope sur le filet humain NIVEAU 3 de la feature limites de session (ARCHITECTURE §21.1 / §21 « niveau 3 »). Contexte : backend (commit 9df5923) + front (en cours, vert) de la gestion des limites de session sont posés. Niveaux 1 (structuré) et 2 (regex PTY) : pleinement fonctionnels (détecter→planifier reprise auto annulable→reprendre). Le front consomme les 5 DOMAIN_EVENT, affiche le badge « limité jusqu'à HH:MM », le compte à rebours + bouton « Annuler la reprise », et appelle la commande Tauri cancel_resume. GAP constaté sur le niveau 3 : l'événement agentRateLimitSuspected (limite suspectée SANS heure de reset fiable) est bien émis et affiché côté front (note « limité · heure inconnue — reprise à préciser »), MAIS il n'existe AUCUNE commande backend pour que l'utilisateur SAISISSE l'heure de reprise et déclenche une reprise auto à cet instant. DevFrontend a (correctement) refusé de fabriquer une commande inexistante et a posé un TODO. Donc aujourd'hui, sur niveau 3, l'utilisateur est INFORMÉ mais ne peut pas AGIR via le mécanisme de reprise auto — il lui reste seulement le relancement manuel de l'agent par les contrôles existants. Le design verrouillé (mémoire session-limit-handling-design) dit : niveau 3 = « filet humain : IdeA DEMANDE à l'utilisateur. Garantit le 100% même pour un novice : jamais d'inaction silencieuse » ; et « Couverture : les TROIS niveaux d'emblée ». QUESTION (tranche, c'est ton domaine — contrat/frontière) : A) Le niveau 3 est-il considéré COMPLET en l'état (surfacer la suspicion + s'appuyer sur le relancement manuel existant satisfait « jamais d'inaction silencieuse ») → alors la feature est terminée, on merge. OU B) Le niveau 3 EXIGE une boucle d'action : une commande applicative type set_resume_at(agentId, epochMs) qui arme le même SessionLimitService (planifier une reprise à l'instant saisi, annulable comme les autres) + un formulaire de saisie d'heure côté front. → alors c'est une slice supplémentaire LSx (domaine? non / application: méthode service + port? / app-tauri: commande / front: form) que je dois cadrer avant le merge. Si B), précise le découpage minimal et le contrat exact de la nouvelle entrée (signature commande, où elle branche dans SessionLimitService, événements émis — réutilise-t-on AgentResumeScheduled ?). Réponds de façon décisionnelle.
  • Response: VERDICT : B. Le niveau 3 n'est PAS complet en l'état. Une slice supplémentaire (LS8) est requise AVANT le merge. Le découpage et le contrat exact suivent. ## Pourquoi B (et pas A) Le design verrouillé est sans ambiguïté : niveau 3 = « IdeA DEMANDE à l'utilisateur » et « les TROIS niveaux d'emblée ». « Demander » est un acte qui arme une reprise, pas une note d'information. La preuve est dans le code lui-même : 1. domain::session_limit::RateLimitSource::Human existe déjà, documenté « niveau 3 : confirmée par l'utilisateur », et n'est produit nulle part aujourd'hui. C'est du code mort qui attend exactement cette entrée. L'archi a toujours prévu B. 2. La doc de ResumePlan::HumanFallback dit : « L'application demandera l'heure à l'utilisateur plutôt que d'agir à l'aveugle ». La boucle d'action fait partie du contrat domaine, pas une extension. Surfacer + relancement manuel (chemin ListResumableAgents) ne satisfait PAS « jamais d'inaction silencieuse » au sens de la feature : c'est un autre mécanisme, qui ne donne ni la reprise auto programmée ni la fenêtre annulable que les niveaux 1/2 garantissent. Sur niveau 3, l'utilisateur est aujourd'hui informé mais le mécanisme central de la feature lui est inaccessible. Incohérence de contrat ⇒ non mergeable tel quel. DevFrontend a eu raison de poser le TODO plutôt que d'inventer la commande. ## Découpage minimal — LS8 « filet humain : armement par heure saisie » Domaine : RIEN à ajouter. plan_resume, SessionLimit, RateLimitSource::Human, ResumePlan::Scheduled couvrent déjà tout. Une heure saisie par l'utilisateur est fonctionnellement une SessionLimit de source Human avec resets_at_ms = Some(epoch). Le clamp anti-passé (max(now)) protège déjà une saisie déjà échue ⇒ reprise immédiate. C'est le payoff de l'hexagonal : zéro nouveau port, zéro nouvel adapter. Application — SessionLimitService : une méthode publique. N'élargis PAS on_rate_limited (sémantique « signal de détection auto »). Ajoute une entrée dédiée, en réutilisant strictement la branche Scheduled existante : rust /// (d) Filet humain (§21.1 niveau 3). L'utilisateur a saisi l'heure de reset /// pour un agent en limite SUSPECTÉE (AgentRateLimitSuspected, sans heure fiable). /// Construit une SessionLimit source `Human`, calcule le plan et arme la reprise /// EXACTEMENT comme la branche auto : mêmes événements, même dédoublonnage, /// même annulabilité via cancel_resume. pub fn confirm_human_resume( &self, agent_id: AgentId, node_id: NodeId, conversation_id: Option<String>, resets_at_ms: i64, // i64 nu, pas Option : la saisie EST l'heure ) Corps = copie de la branche ResumePlan::Scheduled de on_rate_limited (publish AgentRateLimited{Some}disarmscheduler.arm(ResumeAgent) → mémoriser le ScheduleId → publish AgentResumeScheduled), avec SessionLimit::new(Some(resets_at_ms), now, RateLimitSource::Human). Factorise la branche en un fn arm_scheduled(&self, agent_id, fire_at_ms, node_id, conversation_id) privé appelé par les deux chemins, pour ne pas dupliquer le dédoublonnage. execute_resume et cancel_resume restent inchangés : l'armement humain est annulable et s'exécute par les mêmes voies (c'est l'invariant à préserver — un seul mécanisme de reprise). app-tauri — une commande. Miroir exact de cancel_resume : rust #[tauri::command] pub async fn set_resume_at( agent_id: String, resets_at_ms: i64, state: State<'_, AppState>, ) -> Result<(), ErrorDto> Corps : parse_agent_id → résoudre node_id et conversation_id côté backend depuis la registry (le front n'a que l'agent_id ; agentRateLimitSuspected ne porte que ça) : - node_id via TerminalSessions::node_for_agent(&id) (ou la registry unifiée). Si NoneErrorDto INVALID/NOT_FOUND (l'agent n'a plus de cellule vivante — la saisie n'a pas de cible). - conversation_id via la session structurée session_for_agent(&id).map(|s| s.conversation_id()) (best-effort None toléré, comme tout le chemin de reprise dégradé). Puis state.session_limit_service.confirm_human_resume(id, node_id, conv_id, resets_at_ms). Enregistre la commande dans lib.rs à côté de cancel_resume. Front — un formulaire minimal. Sur le badge « limité · heure inconnue — reprise à préciser » : un petit input heure (ou datetime) → calcule l'epoch ms → invoke("set_resume_at", { agentId, resetsAtMs }). Dès réception de AgentResumeScheduled (déjà consommé !), le badge bascule automatiquement sur l'état nominal « limité jusqu'à HH:MM » + compte à rebours + bouton « Annuler la reprise » déjà câblés. Aucun nouvel événement, aucune nouvelle vue d'état côté front : la boucle se referme sur l'UI existante. ## Contrat des événements — RÉUTILISE l'existant, n'en crée AUCUN C'est le point clé de cohérence : confirm_human_resume émet la même paire que la branche auto — AgentRateLimited { agent_id, resets_at_ms: Some(t) } puis AgentResumeScheduled { agent_id, fire_at_ms }. Le front les consomme déjà. La transition « suspecté → programmé » se fait donc sans code de présentation neuf : AgentResumeScheduled est le pivot qui retire l'état « heure inconnue » et réutilise le rendu nominal. Annulation ⇒ AgentResumeCancelled (inchangé). Reprise ⇒ AgentResumed (inchangé). AgentRateLimitSuspected reste l'unique signal « il faut demander », rien d'autre. ## Garde-fous QA pour le binôme - Saisie dans le passé : pas un cas d'erreur — plan_resume clampe à now ⇒ reprise quasi-immédiate. Test à ajouter. - Re-saisie / second suspect : confirm_human_resume doit passer par le même disarm que l'auto ⇒ un seul armement par agent (§21.10-4). Test de dédoublonnage croisé (humain après auto, et inverse). - Agent disparu entre suspicion et saisie : node_for_agent == None ⇒ erreur propre, pas d'armement orphelin. - Course cancel-pile-au-tir : inchangée, déjà couverte par cancel_resume. Périmètre total : ~1 méthode service (factorisée) + 1 commande + 1 input front + tests. Aucune frontière nouvelle, aucun port, aucun adapter. À cadrer et livrer en LS8 avant que Git n'envisage le merge feature/* → develop.