merge(web): runtime node introuvable custom CLI téléphone — #224 (QA verte)

This commit is contained in:
2026-08-20 12:49:13 +02:00
21 changed files with 943 additions and 61 deletions

View File

@ -0,0 +1,45 @@
---
issueRef: "#219"
version: 8
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1786982821404
---
## Build AppImage 2026-08-15
- Demande: rebuild de l'AppImage Linux après le fix Git Graph des tags annotés (#218).
- Workflow utilisé: skill `build-appimage`.
- Tag de build posé localement avant build par Git: `v0.14.0`.
- Commit buildé: `d9172a1` sur `develop`.
- Message du tag: `AppImage build 2026-08-15 on develop (d9172a17) — affichage tags annotés Git Graph #218 + bookkeeping`.
- Aucun push demandé/effectué.
## Exécution
- Frontend: `npm --prefix frontend run build` => OK
- Bundle Tauri AppImage: `CARGO_HOME=/tmp/idea-cargo-home APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage` depuis `crates/app-tauri/` => OK
## Artefact produit
- `/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`
## Rappel opérationnel
- Remplacement de l'AppImage installée et relance d'IdeA non effectués volontairement: opération manuelle pour éviter de tuer l'orchestrateur/session en cours.
## Build AppImage 2026-08-17
- Demande: rebuild de l'AppImage Linux après le ticket mobile custom CLI #224.
- Workflow utilisé: skill `build-appimage`.
- Commit buildé: `b25c17c0` sur `develop`.
- Premier essai du bundle Tauri avec `CARGO_HOME=/tmp/idea-cargo-home` => ECHEC environnemental, `/tmp` saturé (`No space left on device`, `database or disk is full`).
- Contournement appliqué: relance réussie avec `CARGO_HOME=/home/anthony/Documents/Projects/IdeA/.appimage-build/cargo-home` tout en conservant `APPIMAGE_EXTRACT_AND_RUN=1` et `NO_STRIP=1`.
- Tag de build posé localement par Git apres build: `v0.15.0`.
- Message du tag: `AppImage build 2026-08-17 on develop (b25c17c0) — passage en custom CLI sur version téléphone #224 + bookkeeping`.
- Aucun push demandé/effectué.
## Exécution 2026-08-17
- Frontend: `npm --prefix frontend run build` => OK
- Bundle Tauri AppImage essai 1: `CARGO_HOME=/tmp/idea-cargo-home APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage` depuis `crates/app-tauri/` => ECHEC faute d'espace dans `/tmp`
- Vérification espace: `df -h /tmp /home/anthony/Documents/Projects/IdeA` => `/tmp` à 100%, volume projet avec marge
- Bundle Tauri essai 2: `CARGO_HOME=/home/anthony/Documents/Projects/IdeA/.appimage-build/cargo-home APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage` depuis `crates/app-tauri/` => OK
## Artefact produit 2026-08-17
- `/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`
## Rappel opérationnel 2026-08-17
- Remplacement de l'AppImage installée et relance d'IdeA non effectués volontairement: opération manuelle pour éviter de tuer l'orchestrateur/session en cours.

View File

@ -0,0 +1,17 @@
---
id: "f8828b8b-1f67-4b99-87e8-257c5e658237"
number: 219
title: "Rebuild AppImage Linux après fix Git Graph tags annotés"
status: "closed"
priority: "high"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1786817374873
updatedAt: 1786982821404
version: 8
---
Reconstruire l'AppImage Linux d'IdeA depuis l'état courant du dépôt après le correctif Git Graph des tags annotés, avec pose du tag de version local sur le commit buildé selon la règle projet. Aucun remplacement automatique de l'AppImage installée ni relance d'IdeA.

View File

@ -0,0 +1,19 @@
---
issueRef: "#220"
version: 4
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1786819020869
---
2026-08-15 — Cadrage initial.
Demande utilisateur : étudier l’intégration d’une fonction vocale Codex dans IdeA via un agent global partagé entre projets, avec contexte propre, tout en gardant une architecture hexagonale/SOLID extensible à d’autres providers.
Synthèse Architecture : faisable proprement si la voix est modélisée comme une surface globale IDE distincte des agents de projet. Découpage recommandé autour de ports dédiés (`SpeechToTextPort`, `TextToSpeechPort`, `VoiceSessionRuntime`/`RealtimeConversationPort`, `GlobalAssistantContextStore`, `ProjectContextResolver`, `AssistantRuntimeRouter`). Séparer impérativement provider vocal et provider cognitif. Contexte/mémoire globaux à isoler strictement des contextes/mémoires projet. Scope actif explicite (`Global` vs `Project(projectId)`).
Synthèse UX : forte valeur comme porte d’entrée transverse à IdeA pour réduire la friction. Risque majeur de confusion entre assistant global et agents de projet. Recommandation MVP : assistant vocal global hors projet par défaut, badge de contexte permanent, transcript visible, bascule explicite vers un projet, confirmation avant actions projet sensibles, pas de sélection implicite du scope.
MVP recommandé : push-to-talk, Codex seul côté raisonnement, réponse texte d’abord, TTS facultatif/basique, aucune mémoire cross-project implicite.
Version cible : session temps réel plus riche, multi-provider vocal, routage vers plusieurs profils IA derrière un port dédié, handoff propre global <-> projet et permissions/outils médiés.
Décision produit 2026-08-15 : abandon du chantier à ce stade. Motif : la feature nécessiterait un usage API OpenAI supplémentaire (realtime/audio et/ou tokens), non couvert par l’abonnement ChatGPT Pro de l’utilisateur, qui ne souhaite pas de coût additionnel.

View File

@ -0,0 +1,17 @@
---
id: "df5eaadf-21a9-4a1b-ac01-1aeee98cd02f"
number: 220
title: "Étudier un agent vocal global Codex cross-project pour IdeA"
status: "closed"
priority: "medium"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1786818465461
updatedAt: 1786819020869
version: 4
---
Cadrage de faisabilité pour intégrer une fonction vocale reposant sur Codex dans IdeA via un agent global non lié à un projet, avec contexte propre et architecture extensible à d'autres providers/profils plus tard.

View File

@ -0,0 +1,21 @@
---
issueRef: "#221"
version: 6
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1786825279605
---
2026-08-15 — Ouverture du bug.
Signalement utilisateur : l'heure affichée sur les messages ne correspond pas a l'heure reelle; toutes les heures semblent a 1:00.
Cadrage UX : l'heure doit etre fiable, coherente et rendue dans la timezone locale utilisateur. Verifier aussi date hors jour courant, passage minuit, DST, fallback timestamp invalide, coherence desktop/mobile.
Cadrage Architecture : suspect principal = timestamp minuscule ou en secondes interprete comme epoch ms (symptome compatible avec new Date(1) => 01:00 Europe/Paris). Point UI cite : frontend/src/features/agents/CustomAgentChatView.tsx autour du formatage `new Date(receivedAtMs)`. Strategie recommandee : corriger l'unite a la source si besoin, ajouter une garde frontend contre timestamps absurdes et centraliser la normalisation.
Implementation DevFrontend : cause racine trouvee dans le replay de scrollback au reattach. Le code faisait `reattached.scrollback.reduce(foldChunk, ...)`; `Array.reduce` passait l'index en 3e argument, interprete par `foldChunk` comme `receivedAtMs`. Les messages rejoues recevaient donc `0`, `1`, `2` ms, ce qui rendait `new Date(1)` a 01:00 en Europe/Paris. Correctif applique dans `frontend/src/features/agents/CustomAgentChatView.tsx` : wrapper explicite autour de `foldChunk` dans le `reduce`, plus normalisation/garde centralisee avant rendu des heures. Test de regression ajoute dans `frontend/src/features/agents/CustomAgentChatView.test.tsx`.
Resultats annonces par DevFrontend : `npm test -- CustomAgentChatView.test.tsx` OK (51 tests), `npm run typecheck` OK.
Validation QA 2026-08-15 : VERT sur `fix/221-message-time-frozen`. Verifications reelles : `git rev-parse --abbrev-ref HEAD` => `fix/221-message-time-frozen`; `npm run test -- src/features/agents/CustomAgentChatView.test.tsx` => 51/51 OK; `npm run typecheck` => OK. Limite residuelle QA : pas de scenario E2E/UI manuel complet execute.
Note produit/contrat : le contrat backend de scrollback ne porte pas d'horodatage metier sur les `ReplyChunk`; apres reattach l'UI evite des heures absurdes mais ne peut pas reconstituer l'heure historique exacte du chunk sans evolution de contrat.

View File

@ -0,0 +1,17 @@
---
id: "a2eb5752-4604-44ad-8670-9c25921c84cb"
number: 221
title: "Corriger l'heure affichée des messages figée à 1:00"
status: "closed"
priority: "high"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1786824825568
updatedAt: 1786825279605
version: 6
---
Bug UI: les heures affichées sur les messages ne correspondent pas à l'heure réelle; elles semblent toutes rendues à 1:00. Investiguer source des timestamps, timezone/formatting et corriger l'affichage.

View File

@ -0,0 +1,99 @@
---
issueRef: "#222"
version: 4
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1786974344024
---
## Triage 2026-08-17
- Symptôme rapporté: le script de redémarrage d'IdeA lancé en SSH affiche un succès, mais IdeA ne redémarre pas visiblement.
- Reproduction locale statique:
- commande: `bash scripts/restart-idea-over-ssh.sh --dry-run /home/anthony/Documents/Projects/IdeA/IdeA_0.3.0_amd64.AppImage`
- sortie clé: `Detected running IdeA pid: 1` et `Detected graphical session pid: 1`
- Diagnostic initial Main:
- la détection `find_running_idea_pid()` repose sur `pgrep -f 'IdeA|AppImage'`, ce qui peut matcher le shell/script appelant car sa ligne de commande contient le chemin de l'AppImage;
- le dry-run montre effectivement une fausse détection sur `pid 1`, incompatible avec un vrai process IdeA utilisateur;
- conséquence probable: le script conclut sur un faux positif de détection au lieu de vérifier l'instance réelle.
- Flux demandé:
- Architect: cadrer le correctif minimal et les garde-fous de détection;
- Git: décider la branche locale;
- DevBackend: corriger le script shell;
- QA: prouver la correction par commandes réelles.
## Architect
- Cause racine retenue: détection trop floue et premier match accepté, via heuristiques `pgrep -f 'IdeA|AppImage'` capables de matcher le script lui-même, un parent, ou un process générique.
- Cadrage de fix: valider strictement le PID IdeA contre le chemin AppImage cible, séparer la détection de session graphique, interdire `pid 1`, `$$` et les ancêtres, et échouer explicitement en cas d'ambiguïté.
## Git
- Topologie recommandée: branche `fix/ticket-222-restart-idea-over-ssh` depuis `develop`.
- Réserve Git: le worktree courant est sale et la branche active `qa/functional-coverage-suite` n'est pas une bonne base pour ce correctif.
## DevBackend
- Correctif livré dans `scripts/restart-idea-over-ssh.sh` uniquement.
- Changements clés:
- exclusion de `pid 1`, `$$` et des ancêtres du script;
- validation stricte d'un process IdeA par `/proc/<pid>/exe` et `cmdline` liés à l'AppImage cible;
- détection de session graphique limitée à des process de session connus avec variables d'environnement graphiques et DBus cohérentes;
- échec explicite en cas de pluralité de candidats ou d'absence de session graphique exploitable.
- Vérifications DevBackend:
- `bash -n /home/anthony/Documents/Projects/IdeA/scripts/restart-idea-over-ssh.sh`
- `bash /home/anthony/Documents/Projects/IdeA/scripts/restart-idea-over-ssh.sh --dry-run /home/anthony/Documents/Projects/IdeA/IdeA_0.3.0_amd64.AppImage`
- Résultat utile post-fix:
- `Detected running IdeA pid: 16`
- `error: no unambiguous graphical session found for user anthony`
## QA
- Verdict: `PARTIEL`.
- Prouvé:
- le script est syntaxiquement valide;
- le `--dry-run` ne retourne plus `pid 1`;
- le script n'annonce plus un faux succès quand la session graphique n'est pas détectable, il échoue proprement.
- Non prouvé dans cet environnement:
- un redémarrage réel de bout en bout avec changement de PID sur une vraie session graphique utilisateur.
- Décision Main: ticket passé en statut `QA`, pas clôturé, en attente d'une validation live sur machine/session graphique réelle.
## Reprise 2026-08-17
- Demande utilisateur complémentaire: le but du script est précisément de pouvoir redémarrer IdeA à distance via SSH quand l'utilisateur n'est pas chez lui.
- Nouveau périmètre borné:
- conserver l'auto-détection quand elle marche;
- ajouter un mode explicite pour fournir/cibler la session graphique depuis SSH (variables d'environnement et/ou PID de session);
- documenter la ligne de commande attendue.
- Cycle relancé: Architect -> Git -> DevBackend -> QA.
## Architect lot 2
- Contrat retenu pour SSH:
- `--session-pid <pid>`
- `--display <value>`
- `--wayland-display <value>`
- `--xdg-runtime-dir <path>`
- `--dbus-session-bus <addr>`
- `--xauthority <path>`
- Priorité de résolution retenue: flags explicites, puis environnement shell, puis auto-détection.
## Git lot 2
- Topologie recommandée: continuer `fix/ticket-222-restart-idea-over-ssh` si la branche existe encore, sinon repartir de `develop`.
- Réserve inchangée: le worktree courant est sale.
## DevBackend lot 2
- `scripts/restart-idea-over-ssh.sh` supporte maintenant le ciblage explicite de session graphique pour usage SSH.
- Garde-fous clés:
- validation de la cohérence `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS`;
- validation des chemins absolus;
- validation croisée avec `--session-pid`;
- auto-détection reléguée au dernier recours;
- pas de succès si aucun nouveau PID IdeA n'est vu après relance.
- Vérifications utiles:
- sans env/flags: échec explicite;
- avec env shell Wayland/DBus: `Session source: shell environment`;
- avec flags explicites:
- commande: `bash /home/anthony/Documents/Projects/IdeA/scripts/restart-idea-over-ssh.sh --dry-run --xdg-runtime-dir /run/user/1000 --dbus-session-bus unix:path=/run/user/1000/bus --wayland-display wayland-0 /home/anthony/Documents/Projects/IdeA/IdeA_0.3.0_amd64.AppImage`
- résultat: `Session source: explicit flags`
## QA lot 2
- Verdict: `PARTIEL`.
- Prouvé:
- le mode `flags explicites` existe bien et fonctionne en `--dry-run`;
- le mode `environnement shell` fonctionne en `--dry-run`;
- sans cible exploitable, le script échoue proprement au lieu d'annoncer un faux succès.
- Non prouvé:
- relance réelle de bout en bout depuis SSH sur une session graphique distante visible côté utilisateur.
- Décision Main: ticket repassé en `QA`; prêt pour validation live utilisateur avec la commande SSH finale.

View File

@ -0,0 +1,17 @@
---
id: "2a6a8109-3e3b-4032-b3cc-478e61433df9"
number: 222
title: "Script de redémarrage SSH: succès affiché sans redémarrage réel d'IdeA"
status: "QA"
priority: "high"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1786965035875
updatedAt: 1786974344024
version: 4
---
Le script `scripts/restart-idea-over-ssh.sh` annonce un redémarrage réussi ou complet, mais l'instance IdeA visible ne redémarre pas réellement. Lot 2 demandé par l'utilisateur: permettre un vrai redémarrage à distance en SSH même quand l'auto-détection de la session graphique est ambiguë, via un ciblage explicite de l'environnement/session.

View File

@ -0,0 +1,66 @@
---
issueRef: "#223"
version: 3
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1787578200000
---
## Triage 2026-08-17
- Symptôme utilisateur:
- le startup MCP échoue;
- l'utilisateur précise qu'il utilise la surface Web fournie par l'AppImage IdeA, pas le binaire Web standalone.
- Vérifications statiques:
- `crates/app-tauri/src/lib.rs`: le binaire AppImage route `--serve` vers `server::run_from_args(...)`;
- `crates/app-tauri/src/server.rs`: ce wrapper appelle `web_server::run_from_args(...)`;
- `crates/app-tauri/src/commands.rs`: `open_project_for_adapter(...)` appelle `state.ensure_orchestrator_watch(&output.project)`;
- `crates/web-server/src/lib.rs`: `invoke_open_project(...)` se contente de `resolve_project_readonly(...)` et sérialise le DTO.
- Hypothèse principale:
- en surface Web embarquée, l'ouverture de projet ne passe pas par la logique partagée qui réconcilie l'état et démarre `ensure_orchestrator_watch`;
- conséquence probable: watcher/orchestrateur/MCP non armés pour le projet ouvert via HTTP, alors que le desktop le fait.
- Références de code utiles:
- `crates/app-tauri/src/commands.rs:462`
- `crates/web-server/src/lib.rs:3094`
- `crates/backend/src/lib.rs:3301`
- Travail attendu:
- Architect: valider la cause racine et le contrat de convergence desktop/Web;
- Git: décider la branche locale;
- DevBackend: faire passer l'ouverture de projet Web par le même flux que le desktop, sans régression sur les lectures read-only non concernées;
- QA: prouver que le startup MCP remarche réellement en surface Web embarquée, avec commandes/sorties réelles.
## Implémentation 2026-08-17
- Correctif appliqué dans `crates/web-server/src/lib.rs`.
- Changement:
- `invoke_open_project(...)` ne fait plus une simple lecture read-only;
- nouveau helper `open_project_for_web(...)` qui exécute `open_project`, réconcilie layouts/live-state/background tasks, arme `ensure_orchestrator_watch`, puis lance `reconcile_claude_run_dirs`, comme le flux desktop.
- Test ajouté:
- `authorized_open_project_arms_orchestrator_watch`
- preuve: après `open_project` via `/api/invoke`, `state.app.open_project_ids()` contient bien le projet ouvert.
## QA 2026-08-17
- Commandes:
- `cargo test -p web-server authorized_open_project -- --nocapture`
- `cargo test -p web-server web_invoke_routes_agent_commands -- --nocapture`
- Résultat:
- `authorized_open_project_returns_readonly_project_dto ... ok`
- `authorized_open_project_arms_orchestrator_watch ... ok`
- `web_invoke_routes_agent_commands ... ok`
- Verdict:
- `QA` sur preuve automatisée ciblée.
- Validation live restante utile: vérifier en AppImage surface Web qu'un projet ouvert puis un agent MCP-capable lancé déclenche bien un startup MCP réussi de bout en bout.
## Reprise 2026-08-20
- Signal utilisateur:
- "le mcp fail encore sur la version web".
- Vérification de l'état courant du code:
- `crates/web-server/src/lib.rs` contient a nouveau le helper `open_project_for_web(...)`;
- le flux HTTP `invoke_open_project(...)` n'utilise plus `resolve_project_readonly(...)` mais execute `open_project`, puis `reconcile_layouts`, `reconcile_live_state`, `reconcile_background_tasks`, `ensure_orchestrator_watch(&output.project)` et `reconcile_claude_run_dirs(&output.project)`;
- `crates/web-server/src/bin/idea-serve.rs` route maintenant aussi le sous-mode `mcp-server` vers `backend::mcp_bridge::run_mcp_bridge(...)`;
- `crates/backend/src/lib.rs` exporte le module partage `mcp_bridge` pour reutiliser le meme pont stdio<->loopback entre desktop et binaire web.
- Validation automatisee du 2026-08-20:
- `cargo check -p web-server` -> OK
- `cargo test -p web-server authorized_open_project_arms_orchestrator_watch -- --nocapture` -> OK
- `cargo test -p web-server web_root_candidates_include_appimage_resource_dir -- --nocapture` -> OK
- `cargo test -p web-server web_invoke_routes_agent_commands -- --nocapture` -> OK
- Conclusion de pilotage:
- le correctif backend/web pour re-armer l'orchestrateur/MCP sur `open_project` web est present dans l'arbre courant et vert en tests cibles;
- le ticket passe en `QA`;
- fermeture a garder conditionnee a une preuve live AppImage/web montrant qu'un agent MCP-capable lance depuis la surface web demarre bien son MCP de bout en bout.

View File

@ -0,0 +1,17 @@
---
id: "6d08f613-f1b7-4e16-91c2-e1b94833d6e1"
number: 223
title: "MCP startup en surface Web embarquée: open_project n'arme pas l'orchestrateur comme le desktop"
status: "QA"
priority: "high"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1786992000000
updatedAt: 1787578200000
version: 4
---
En surface Web servie par l'AppImage IdeA, le startup MCP échoue alors qu'il fonctionnait auparavant. Le diagnostic initial pointe un écart de câblage entre le chemin desktop/Tauri et le chemin HTTP Web: l'ouverture de projet desktop appelle `open_project_for_adapter(...)`, qui exécute `state.ensure_orchestrator_watch(&output.project)`, tandis que le routeur Web `invoke_open_project(...)` ne fait actuellement qu'un `resolve_project_readonly(...)` sans armer l'orchestrateur/MCP du projet.

View File

@ -1,17 +1,34 @@
---
issueRef: "#224"
version: 5
version: 14
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1786981905837
updatedAt: 1787222847970
---
2026-08-17 Main
- Demande: permettre le passage en custom CLI sur la version web telephone avec une UI correctement adaptee aux telephones.
- UX: la bascule doit vivre dans la session agent plein ecran mobile, pas dans la liste Travail. Controle segmente `TUI native` / `CLI custom`, affichage exclusif d'un mode a la fois, composer fixe en bas, adaptation tactile/mobile, confirmations en bottom sheet.
- Architect: aucun nouveau port backend; reutiliser AgentGateway et CustomAgentChatView. Introduire un host mobile de session agent, partager la logique d'eligibilite custom CLI avec le desktop, et ajouter un `draftScope` explicite pour la persistance mobile sans `layoutId`.
- DevFrontend: implementation annoncee terminee sur `feature/224-mobile-web-custom-cli`.
- QA: verdict ROUGE. Repro reelle sur fallback mobile quand un profil perd le support custom CLI apres le mount.
- Commande QA en echec: `cd /home/anthony/Documents/Projects/IdeA/frontend && npm test -- --run src/features/web/WebAgentCell.test.tsx`.
- Symptom QA: test `falls back to native TUI when the mobile agent loses custom CLI support` KO, `terminal-view` introuvable, l'UI reste sur `custom-agent-chat-view` avec le bouton `CLI custom` toujours actif.
- Diagnostic QA: `WebAgentCell.tsx` ne re-resout agent/profil qu'au mount; le fallback depend d'un `resolvedProfile` perime.
- Signal secondaire QA: warning cleanup au detach depuis `CustomAgentChatView.tsx:924` avec `Cannot read properties of undefined (reading 'has')`.
- Prochaine etape: correctif DevFrontend puis nouveau passage QA.
- DevFrontend: implementation livree puis correctif applique apres retour QA rouge sur le fallback quand le support custom disparait apres mount.
- QA finale: VERTE le 2026-08-17.
- Commandes QA vertes: `cd /home/anthony/Documents/Projects/IdeA/frontend && npm test -- --run src/features/web/WebAgentCell.test.tsx` (8 passed), `npm test -- --run src/features/layout/LayoutGrid.chat.test.tsx` (14 passed), `npm run typecheck` (OK), `npm run build:web` (OK).
- Git: commit feature `c547b71`, merge local `911dcf8` dans `develop`, puis bookkeeping `b25c17c`. Aucun push.
- Statut: ticket cloture apres merge local dans `develop` avec QA verte.
2026-08-20 Main
- Regression utilisateur: sur la version web telephone, le lancement de la custom CLI echoue maintenant avec un symptome runtime indiquant que `node` est introuvable.
- Decision de pilotage: reouverture du ticket #224 car le symptome touche le meme parcours produit `version web telephone -> custom CLI`.
- Ordre impose avant tout fix: QA doit d'abord ajouter des tests de non-regression sur le bon demarrage des serveurs MCP dans ce parcours web/mobile, vu l'historique de regressions repetes dans cette zone.
- Regle anti-boucle pour la reprise: ne pas partir directement sur un correctif applicatif tant que QA n'a pas produit un signal rouge/vert concret sur ce scenario.
- Retour QA: non-regression ajoutee dans `frontend/src/features/web/WebAgentCell.test.tsx` pour figer la couche existante la plus pertinente du parcours mobile web -> CLI custom.
- Bornes couvrees par QA: reprise visible d'une session custom mobile avec replay d'activite MCP au re-attach; echec de lancement mobile quand le runtime remonte un `spawn node ENOENT`, avec erreur visible cote UI.
- Commande QA executee: `cd /home/anthony/Documents/Projects/IdeA/frontend && npm test -- --run src/features/web/WebAgentCell.test.tsx`.
- Resultat QA: VERT, `Test Files 1 passed`, `Tests 10 passed`.
- Gap residuel explicitement note par QA: cette suite ne prouve pas encore le demarrage reel de `node` ni le boot effectif des serveurs MCP dans le runtime backend/web reel; elle fige le contrat frontend/integration autour du symptome.
- Retour UX: en cas d'echec de boot runtime/MCP, garder la vue `CLI custom` mais afficher une alerte inline bloquante avec message utilisateur clair, CTA `Reessayer` et `Revenir a TUI native`; le detail technique brut `spawn node ENOENT` ne doit pas etre le message principal final.
- Retour Architect: cause la plus probable cote backend/runtime sur le profil custom CLI Node-backed ou la resolution de commande dans le chemin web; points de verite a traiter d'abord dans `crates/web-server/src/lib.rs`, `StructuredSessionFactory`, et plus generalement dans le routage structured pour ne jamais degrader en PTY.
- Decision d'implementation: DevBackend d'abord; frontend ne revient que si le backend expose une erreur typee ou si l'etat visible doit etre nettoye apres le fix runtime.
- Decision Git: branche de travail locale `fix/224-web-custom-cli-node-runtime` creee depuis `develop@b25c17c`; commit scope puis merge local `--no-ff` vers `develop` une fois QA verte; aucun push.
- Retour DevBackend: fix runtime implemente sans commit. `crates/application/src/agent/lifecycle.rs` fait maintenant reutiliser `mcp_server_wiring(...)` par `opencode_config_json` pour ecrire une commande MCP OpenCode basee sur le binaire IdeA (`idea-serve ... mcp-server`) quand un `McpRuntime` web est injecte, au lieu de dependre d'un `node` externe.
- Preuve de test ajoutee par DevBackend: integration dans `crates/application/tests/agent_lifecycle.rs` garantissant qu'un lancement custom chat OpenCode avec runtime reste structured, n'ouvre aucun PTY, ecrit `opencode.json`, transmet `OPENCODE_CONFIG`, et que `mcp.idea.command[0] != "node"`.
- Tests verts annonces par DevBackend: `cargo fmt`; `cargo test -p application opencode_config_json_with_runtime_uses_idea_exe_not_node -- --nocapture`; `cargo test -p application --test agent_lifecycle launch_opencode_chat_with_runtime_uses_idea_bridge_without_pty_or_node -- --nocapture`; `cargo test -p backend mcp_bridge -- --nocapture`; `cargo test -p web-server web_root_candidates_include_appimage_resource_dir -- --nocapture`; `cargo test -p web-server authorized_open_project_arms_orchestrator_watch -- --nocapture`; `npm test -- WebAgentCell.test.tsx`.
- Note de reprise: un filtre large `cargo test -p application opencode -- --nocapture` remonte un echec existant non cible `p8c_opencode_same_pair_and_provider_are_isolated_by_project_root` (`left: 0 right: 2`), mais les non-regressions cibles du fix sont vertes separement.

View File

@ -2,16 +2,20 @@
id: "2be065b5-1859-4698-8c9e-7f1fc2c1f7a3"
number: 224
title: "Version web téléphone: permettre le passage en custom CLI avec UI mobile adaptée"
status: "inProgress"
status: "qa"
priority: "high"
sprint: null
links: []
agentRefs: []
agentRefs: [{"agentId":"fe887179-933f-47d4-960f-c3b06827f86c","role":"assigned"},{"agentId":"ab328d90-c307-4771-a3b6-6c56089c8506","role":"assigned"}]
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1786980793023
updatedAt: 1786981905837
version: 5
updatedAt: 1787222847970
version: 14
---
Ajouter sur la version web téléphone d'IdeA la possibilité de passer en custom CLI, avec une interface et un parcours adaptés aux écrans téléphone.
Regression du parcours version web téléphone -> custom CLI. Le jeudi 20 août 2026, l'utilisateur signale qu'en lançant la custom CLI depuis la version web téléphone, le runtime indique que `node` est introuvable. Avant tout correctif de production, la priorité est d'ajouter des tests de non-régression prouvant le bon démarrage des serveurs MCP dans ce parcours web/mobile, car cette zone a déjà régressé plusieurs fois.
Ordre de traitement imposé:
1. QA ajoute/étend la couverture de tests pour le démarrage des serveurs MCP via la version web téléphone et établit un signal rouge/vert fiable.
2. Ensuite seulement, l'implémentation du correctif sera cadrée et déléguée au(x) dev(s) concerné(s).

View File

@ -1,3 +1,3 @@
{
"nextNumber": 219
"nextNumber": 222
}

View File

@ -2968,7 +2968,7 @@
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1786817616398
"updatedAt": 1786982821404
},
{
"issueRef": "#220",
@ -3002,15 +3002,18 @@
"issueRef": "#224",
"path": "224",
"title": "Version web téléphone: permettre le passage en custom CLI avec UI mobile adaptée",
"status": "inProgress",
"status": "qa",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"assignedAgentIds": [
"fe887179-933f-47d4-960f-c3b06827f86c",
"ab328d90-c307-4771-a3b6-6c56089c8506"
],
"createdBy": {
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1786981905837
"updatedAt": 1787222847970
}
]
}

View File

@ -3441,23 +3441,9 @@ fn opencode_config_json(
}),
);
let (command, args) = match runtime {
Some(rt) => (
rt.exe.clone(),
vec![
"mcp-server".to_owned(),
"--endpoint".to_owned(),
rt.endpoint.clone(),
"--project".to_owned(),
rt.project_id.clone(),
"--requester".to_owned(),
rt.requester.clone(),
],
),
None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
};
let command_array = std::iter::once(command)
.chain(args)
let wiring = mcp_server_wiring(domain::profile::McpTransport::Stdio, runtime);
let command_array = std::iter::once(wiring.command)
.chain(wiring.args)
.map(serde_json::Value::String)
.collect::<Vec<_>>();
root.insert(
@ -5334,6 +5320,37 @@ command = "idea-mcp"
assert!(body.get("permission").is_none());
}
#[test]
fn opencode_config_json_with_runtime_uses_idea_exe_not_node() {
let config =
domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None)
.unwrap();
let runtime = McpRuntime {
exe: "/opt/idea/idea-serve".to_owned(),
endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(),
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
requester: "11112222-3333-4444-5555-666677778888".to_owned(),
};
let body = opencode_config_json(&config, "/project", Some(&runtime), None);
let command = body["mcp"]["idea"]["command"]
.as_array()
.expect("OpenCode local command is an argv array")
.iter()
.filter_map(serde_json::Value::as_str)
.collect::<Vec<_>>();
assert_eq!(command[0], "/opt/idea/idea-serve");
assert_ne!(command[0], "node");
assert_eq!(command[1], "mcp-server");
assert!(command.contains(&"--endpoint"));
assert!(command.contains(&"/run/user/1000/idea-mcp/proj.sock"));
assert!(command.contains(&"--project"));
assert!(command.contains(&"0123456789abcdef0123456789abcdef"));
assert!(command.contains(&"--requester"));
assert!(command.contains(&"11112222-3333-4444-5555-666677778888"));
}
#[test]
fn opencode_config_json_projects_allow_and_deny() {
let config =

View File

@ -2594,6 +2594,80 @@ async fn launch_opencode_writes_isolated_config_and_env() {
);
}
#[tokio::test]
async fn launch_opencode_chat_with_runtime_uses_idea_bridge_without_pty_or_node() {
let profile = opencode_profile(pid(9));
let (launch, agent, fs, pty, _bus, _sessions, tr, _session) = launch_fixture_with_profile(
profile,
Some(ContextInjectionPlan::File {
target: "AGENTS.md".to_owned(),
}),
);
let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
let structured = Arc::new(StructuredSessions::new());
let launch = launch
.with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback)
.with_structured(Arc::new(factory.clone()), Arc::clone(&structured));
let runtime = application::McpRuntime {
exe: "/opt/idea/idea-serve".to_owned(),
endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(),
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
requester: agent.id.to_string(),
};
let mut input = launch_input_with_runtime(agent.id, runtime);
input.require_structured = true;
input.node_id = Some(nid(224));
let out = launch.execute(input).await.unwrap();
assert_eq!(factory.starts(), vec![pid(9)]);
assert!(
pty.spawns().is_empty(),
"web custom chat must stay structured"
);
assert!(
out.structured.is_some(),
"backend returns a chat descriptor"
);
assert_eq!(
structured.node_for_agent_in_project(project().id, &agent.id),
Some(nid(224))
);
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
let opencode = writes_ending_with(&fs, "/opencode.json");
assert_eq!(opencode.len(), 1, "one OpenCode config is written");
let body: serde_json::Value = serde_json::from_slice(&opencode[0].1).unwrap();
let command = body["mcp"]["idea"]["command"]
.as_array()
.expect("OpenCode local command is an argv array")
.iter()
.filter_map(serde_json::Value::as_str)
.collect::<Vec<_>>();
assert_eq!(command[0], "/opt/idea/idea-serve");
assert_ne!(command[0], "node");
assert_eq!(command[1], "mcp-server");
assert!(command.contains(&"--endpoint"));
assert!(command.contains(&"/run/user/1000/idea-mcp/proj.sock"));
assert!(command.contains(&"--project"));
assert!(command.contains(&"0123456789abcdef0123456789abcdef"));
assert!(command.contains(&"--requester"));
let requester = agent.id.to_string();
assert!(command.contains(&requester.as_str()));
let env: std::collections::HashMap<_, _> = factory
.envs()
.into_iter()
.next()
.expect("structured start receives env")
.into_iter()
.collect();
assert_eq!(
env.get("OPENCODE_CONFIG").map(String::as_str),
Some(format!("{run_dir}/opencode.json").as_str())
);
}
#[tokio::test]
async fn launch_mcp_flag_appends_flag_and_path_to_args() {
// Test 5 — Flag { flag: "--mcp-config" } ⇒ spec.args carries the flag followed

View File

@ -113,6 +113,7 @@ use infrastructure::{
pub mod dto;
pub mod events;
pub mod mcp_bridge;
pub mod mcp_endpoint;
pub mod openai_tools;
pub mod stream;

View File

@ -0,0 +1,260 @@
//! The `idea mcp-server` bridge shared by desktop and standalone web binaries.
//!
//! An MCP CLI spawns `<exe> mcp-server --endpoint <…> --project <…> --requester <…>`.
//! This process stays headless and relays JSON-RPC lines between stdio and the
//! per-project local loopback endpoint where the real MCP server listens.
use std::process::ExitCode;
use std::time::Duration;
use interprocess::local_socket::tokio::Stream as LocalSocketStream;
use interprocess::local_socket::traits::tokio::Stream as _;
use interprocess::local_socket::{GenericFilePath, ToFsName as _};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const DRAIN_GRACE: Duration = Duration::from_secs(1);
/// Parsed `mcp-server` invocation arguments.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BridgeArgs {
/// The loopback address to connect to.
pub endpoint: String,
/// The project id forwarded in the handshake.
pub project: String,
/// The requester id forwarded in the handshake.
pub requester: String,
}
impl BridgeArgs {
/// Parses `mcp-server`'s own arguments.
pub fn parse<I, S>(args: I) -> Result<Self, String>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut endpoint: Option<String> = None;
let mut project = String::new();
let mut requester = String::new();
let mut it = args.into_iter().map(Into::into);
while let Some(flag) = it.next() {
match flag.as_str() {
"--endpoint" => {
endpoint = Some(
it.next()
.ok_or_else(|| "--endpoint requires a value".to_string())?,
);
}
"--project" => {
project = it
.next()
.ok_or_else(|| "--project requires a value".to_string())?;
}
"--requester" => {
requester = it
.next()
.ok_or_else(|| "--requester requires a value".to_string())?;
}
other => return Err(format!("unknown argument: {other}")),
}
}
let endpoint = endpoint.ok_or_else(|| "--endpoint is required".to_string())?;
Ok(Self {
endpoint,
project,
requester,
})
}
fn handshake_line(&self) -> Vec<u8> {
let line = serde_json::json!({
"project": self.project,
"requester": self.requester,
})
.to_string();
let mut bytes = line.into_bytes();
bytes.push(b'\n');
bytes
}
}
/// Synchronous entry point for the headless stdio bridge.
#[must_use]
pub fn run_mcp_bridge<I, S>(args: I) -> ExitCode
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let args = match BridgeArgs::parse(args) {
Ok(a) => a,
Err(e) => {
eprintln!("idea mcp-server: {e}");
return ExitCode::FAILURE;
}
};
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
{
Ok(rt) => rt,
Err(e) => {
eprintln!("idea mcp-server: failed to start runtime: {e}");
return ExitCode::FAILURE;
}
};
runtime.block_on(async move {
match bridge_over_loopback(&args).await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("idea mcp-server: {e}");
ExitCode::FAILURE
}
}
})
}
async fn bridge_over_loopback(args: &BridgeArgs) -> Result<(), String> {
let conn = connect_loopback(&args.endpoint).await?;
let (lp_read, lp_write) = tokio::io::split(conn);
relay(
args,
tokio::io::stdin(),
tokio::io::stdout(),
lp_read,
lp_write,
)
.await
}
async fn connect_loopback(endpoint: &str) -> Result<LocalSocketStream, String> {
let name = endpoint
.to_fs_name::<GenericFilePath>()
.map_err(|e| format!("invalid endpoint {endpoint:?}: {e}"))?;
match tokio::time::timeout(CONNECT_TIMEOUT, LocalSocketStream::connect(name)).await {
Ok(Ok(stream)) => Ok(stream),
Ok(Err(e)) => Err(format!("cannot connect to endpoint {endpoint:?}: {e}")),
Err(_) => Err(format!(
"timed out connecting to endpoint {endpoint:?} after {}s",
CONNECT_TIMEOUT.as_secs()
)),
}
}
async fn relay<CIn, COut, LIn, LOut>(
args: &BridgeArgs,
cli_in: CIn,
cli_out: COut,
lp_read: LIn,
mut lp_write: LOut,
) -> Result<(), String>
where
CIn: AsyncRead + Unpin,
COut: AsyncWrite + Unpin,
LIn: AsyncRead + Unpin,
LOut: AsyncWrite + Unpin,
{
lp_write
.write_all(&args.handshake_line())
.await
.map_err(|e| format!("handshake write failed: {e}"))?;
lp_write
.flush()
.await
.map_err(|e| format!("handshake flush failed: {e}"))?;
let cli_to_lp = pump_lines(BufReader::new(cli_in), lp_write, "stdin", "loopback");
let lp_to_cli = pump_lines(BufReader::new(lp_read), cli_out, "loopback", "stdout");
tokio::pin!(cli_to_lp);
tokio::pin!(lp_to_cli);
tokio::select! {
r = &mut cli_to_lp => {
let mut lp_write = r?;
let _ = lp_write.shutdown().await;
let _ = tokio::time::timeout(DRAIN_GRACE, &mut lp_to_cli).await;
Ok(())
}
r = &mut lp_to_cli => r.map(|_| ()),
}
}
async fn pump_lines<R, W>(
mut reader: BufReader<R>,
mut writer: W,
src: &str,
dst: &str,
) -> Result<W, String>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let mut line = String::new();
loop {
line.clear();
let n = reader
.read_line(&mut line)
.await
.map_err(|e| format!("{src} read failed: {e}"))?;
if n == 0 {
writer
.flush()
.await
.map_err(|e| format!("{dst} flush failed: {e}"))?;
return Ok(writer);
}
writer
.write_all(line.as_bytes())
.await
.map_err(|e| format!("{dst} write failed: {e}"))?;
writer
.flush()
.await
.map_err(|e| format!("{dst} flush failed: {e}"))?;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_all_three_flags() {
let args = BridgeArgs::parse([
"--endpoint",
"/tmp/x.sock",
"--project",
"p1",
"--requester",
"agent-7",
])
.unwrap();
assert_eq!(args.endpoint, "/tmp/x.sock");
assert_eq!(args.project, "p1");
assert_eq!(args.requester, "agent-7");
}
#[test]
fn handshake_line_carries_identity_as_json() {
let args = BridgeArgs {
endpoint: "/tmp/x.sock".into(),
project: "proj".into(),
requester: "rq".into(),
};
let line = args.handshake_line();
assert_eq!(*line.last().unwrap(), b'\n');
let value: serde_json::Value = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
assert_eq!(value["project"], "proj");
assert_eq!(value["requester"], "rq");
}
#[test]
fn missing_endpoint_returns_failure() {
assert_eq!(run_mcp_bridge(["--project", "p"]), ExitCode::FAILURE);
}
}

View File

@ -1,5 +1,12 @@
use std::process::ExitCode;
const MCP_SERVER_SUBCOMMAND: &str = "mcp-server";
fn main() -> ExitCode {
let mut args = std::env::args_os().skip(1);
if args.next().is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND) {
let rest: Vec<String> = args.map(|a| a.to_string_lossy().into_owned()).collect();
return backend::mcp_bridge::run_mcp_bridge(rest);
}
web_server::run_from_args(std::env::args().skip(1).collect())
}

View File

@ -53,10 +53,11 @@ use application::{
PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput,
ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput,
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
RenameDeviceInput, RenameLayoutInput, RenameSprintInput, ReorderSprintsInput,
ResizeTerminalInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput,
ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput,
StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput,
RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, SetActiveLayoutInput, StopLiveAgentInput,
SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContextInput,
UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
@ -506,33 +507,55 @@ fn config_with_effective_listen(mut config: ServerConfig, local_addr: SocketAddr
}
fn resolve_web_root(explicit: Option<PathBuf>) -> Result<PathBuf, String> {
if let Some(path) = explicit {
return validate_web_root(path, "--web-root");
}
if let Some(path) = env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) {
return validate_web_root(path, "IDEA_WEB_ROOT");
}
let mut candidates = Vec::new();
if let Ok(exe) = env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(dir.join("web"));
candidates.push(dir.join("frontend").join("dist"));
}
}
if let Ok(cwd) = env::current_dir() {
candidates.push(cwd.join("frontend").join("dist"));
}
for candidate in candidates {
for (candidate, source) in web_root_candidates(explicit) {
if candidate.join("index.html").is_file() {
return Ok(candidate);
}
if matches!(source, "--web-root" | "IDEA_WEB_ROOT") {
return validate_web_root(candidate, source);
}
}
Err("web assets not found: build frontend/dist or pass --web-root PATH".to_owned())
}
fn web_root_candidates(explicit: Option<PathBuf>) -> Vec<(PathBuf, &'static str)> {
let mut candidates = Vec::new();
if let Some(path) = explicit {
candidates.push((path, "--web-root"));
}
if let Some(path) = env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) {
candidates.push((path, "IDEA_WEB_ROOT"));
}
if let Some(appdir) = env::var_os("APPDIR").map(PathBuf::from) {
candidates.extend(
web_root_candidates_from_appdir(&appdir)
.into_iter()
.map(|candidate| (candidate, "APPDIR")),
);
}
if let Ok(exe) = env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push((dir.join("web"), "current_exe"));
candidates.push((dir.join("frontend").join("dist"), "current_exe"));
if let Some(prefix) = dir.parent() {
candidates.push((prefix.join("lib").join("IdeA").join("web"), "current_exe"));
}
}
}
if let Ok(cwd) = env::current_dir() {
candidates.push((cwd.join("frontend").join("dist"), "cwd"));
}
candidates
}
fn web_root_candidates_from_appdir(appdir: &Path) -> Vec<PathBuf> {
vec![
appdir.join("usr").join("lib").join("IdeA").join("web"),
appdir.join("web"),
]
}
fn validate_web_root(path: PathBuf, source: &str) -> Result<PathBuf, String> {
if path.join("index.html").is_file() {
Ok(path)
@ -3099,8 +3122,32 @@ async fn invoke_open_project(args: &Value, state: &BackendCore) -> Result<Value,
code: "INVALID".to_owned(),
message: "open_project requires args.projectId".to_owned(),
})?;
let project = resolve_project_readonly(project_id, state).await?;
serde_json::to_value(ProjectDto::from(project)).map_err(serialization_error)
let project = open_project_for_web(project_id, state).await?;
serde_json::to_value(project).map_err(serialization_error)
}
async fn open_project_for_web(
project_id: &str,
state: &BackendCore,
) -> Result<ProjectDto, ErrorDto> {
let id = parse_project_id(project_id)?;
let output = state
.open_project
.execute(OpenProjectInput { project_id: id })
.await
.map_err(ErrorDto::from)?;
let _ = state
.reconcile_layouts
.execute(ReconcileLayoutsInput { project_id: id })
.await;
let _ = state
.reconcile_live_state
.execute(ReconcileLiveStateInput { project_id: id })
.await;
let _ = state.reconcile_background_tasks.execute(id).await;
state.ensure_orchestrator_watch(&output.project);
state.reconcile_claude_run_dirs(&output.project).await;
Ok(ProjectDto::from(output))
}
async fn invoke_load_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
@ -8359,6 +8406,15 @@ mod tests {
assert!(validate_web_root(create_web_root(), "--web-root").is_ok());
}
#[test]
fn web_root_candidates_include_appimage_resource_dir() {
let appdir = PathBuf::from("/tmp/.mount_IdeA_test");
let candidates = web_root_candidates_from_appdir(&appdir);
assert!(candidates.contains(&appdir.join("usr").join("lib").join("IdeA").join("web")));
assert!(candidates.contains(&appdir.join("web")));
}
#[tokio::test]
async fn non_allowed_origin_is_refused() {
let state = state();
@ -8472,6 +8528,49 @@ mod tests {
assert!(body["root"].is_string());
}
#[tokio::test]
async fn authorized_open_project_arms_orchestrator_watch() {
let config = test_config();
let core = Arc::new(BackendCore::build(config.app_data_dir.clone()));
let project_id = create_project_on_core(&core, "MCP Armed Open").await;
let state = Arc::new(ServerState::with_core(config, core));
state.set_pairing_code_for_test("PAIR1234".to_owned());
let cookie = pair_and_cookie(Arc::clone(&state)).await;
assert!(
state.app.open_project_ids().is_empty(),
"precondition: the project must not already be armed before HTTP open_project"
);
let response = request(
Arc::clone(&state),
Method::POST,
"/api/invoke",
json!({ "command": "open_project", "args": { "projectId": project_id } }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["id"], project_id);
let expected = parse_project_id(&project_id)
.expect("test project id parses")
.as_uuid()
.simple()
.to_string();
let opened = state
.app
.open_project_ids()
.into_iter()
.map(|id| id.as_uuid().simple().to_string())
.collect::<Vec<_>>();
assert!(
opened.iter().any(|id| id == &expected),
"expected open_project HTTP flow to arm the orchestrator watch"
);
}
#[tokio::test]
async fn authorized_layout_invokes_match_tauri_contracts() {
let state = state();

View File

@ -120,6 +120,51 @@ describe("WebAgentCell", () => {
});
});
it("replays startup MCP activity when the mobile custom CLI launches", async () => {
const { agent, profile, agentId } = await seededAgentGateway(structuredProfile);
const launchChatSpy = vi.spyOn(agent, "launchAgentChat");
const startupScrollback = [
{
kind: "progress" as const,
progress: {
source: "ideaLocal" as const,
kind: "mcp" as const,
stage: "started" as const,
label: "idea_context_read",
toolName: "idea_context_read",
text: "Bootstrap MCP tools",
},
},
{ kind: "final" as const, content: "Custom CLI ready" },
];
const reattachChatSpy = vi
.spyOn(agent, "reattachAgentChat")
.mockImplementation(async (sessionId: string) => ({
sessionId,
scrollback: startupScrollback,
}));
renderCell(agent, profile, agentId, { fullHeight: true });
fireEvent.click(await screen.findByRole("button", { name: "CLI custom" }));
await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1));
await waitFor(() => expect(reattachChatSpy).toHaveBeenCalledTimes(1));
expect(launchChatSpy.mock.calls[0][2]).toMatchObject({
cwd: "/srv/demo",
nodeId: `web-mobile-${agentId}`,
});
fireEvent.click(screen.getByRole("button", { name: "TUI native" }));
await waitFor(() => expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull());
fireEvent.click(screen.getByRole("button", { name: "CLI custom" }));
await waitFor(() => expect(reattachChatSpy).toHaveBeenCalledTimes(2));
expect(await screen.findByText("MCP")).toBeTruthy();
expect(screen.getByText("idea_context_read")).toBeTruthy();
expect(screen.getByText("Bootstrap MCP tools")).toBeTruthy();
expect(screen.getByText("Custom CLI ready")).toBeTruthy();
});
it("reattaches the mobile custom CLI session when returning to custom mode", async () => {
const { agent, profile, agentId } = await seededAgentGateway(structuredProfile);
const launchChatSpy = vi.spyOn(agent, "launchAgentChat");
@ -138,6 +183,26 @@ describe("WebAgentCell", () => {
expect(launchChatSpy).toHaveBeenCalledTimes(1);
});
it("surfaces a mobile custom CLI launch failure when the runtime cannot find node", async () => {
const { agent, profile, agentId } = await seededAgentGateway(structuredProfile);
const launchChatSpy = vi.spyOn(agent, "launchAgentChat").mockRejectedValueOnce({
code: "PROCESS",
message: "spawn node ENOENT: node is not installed on this runtime",
});
const reattachChatSpy = vi.spyOn(agent, "reattachAgentChat");
renderCell(agent, profile, agentId, { fullHeight: true });
fireEvent.click(await screen.findByRole("button", { name: "CLI custom" }));
await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1));
expect(launchChatSpy.mock.calls[0][2]).toMatchObject({
cwd: "/srv/demo",
nodeId: `web-mobile-${agentId}`,
});
expect(reattachChatSpy).not.toHaveBeenCalled();
expect((await screen.findByRole("alert")).textContent).toContain("spawn node ENOENT");
});
it("falls back to native TUI when the mobile agent loses custom CLI support", async () => {
const { agent, profile, agentId } = await seededAgentGateway(structuredProfile);
const created = (await agent.listAgents(PROJECT_ID)).find((candidate) => candidate.id === agentId);