Mise à jour: améliorations et ajouts de documentation
This commit is contained in:
parent
6ecf78a04c
commit
9c403acff4
10 changed files with 436 additions and 13 deletions
256
GUIDE_PERMISSIONS.md
Normal file
256
GUIDE_PERMISSIONS.md
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
# Guide des permissions de MAIA
|
||||
|
||||
MAIA demande une confirmation avant d'utiliser un outil sensible (Bash, Write, Edit, WebFetch, Agent…). Les **permissions** permettent d'autoriser, de demander ou d'interdire à l'avance des usages précis, pour ne plus répondre « o/n » à chaque `ls` — et pour bloquer définitivement ce qui ne doit jamais s'exécuter.
|
||||
|
||||
Les exemples de ce guide ont été vérifiés avec le moteur de permissions de MAIA (`packages/agent/src/permissions.ts`).
|
||||
|
||||
## 1. Où les définir
|
||||
|
||||
| Fichier | Portée |
|
||||
| --- | --- |
|
||||
| `~/.maia/settings.json` | toutes vos sessions (dossier de l'utilisateur) |
|
||||
| `./.maia/settings.json` | le projet (dossier de lancement de MAIA) |
|
||||
|
||||
Les deux fichiers s'**additionnent** (les listes sont fusionnées). Le fichier du dossier parent n'est pas lu. `~/.maia/settings.json` contient déjà la configuration LiteLLM : ajoutez simplement la propriété `permissions`.
|
||||
|
||||
```json
|
||||
{
|
||||
"LiteLLM_URL": "http://localhost:4000",
|
||||
"OpenAI_EndPoint": "/v1/chat/completions",
|
||||
"LiteLLM_Models": "/v1/models",
|
||||
|
||||
"permissions": {
|
||||
"allow": ["Bash(ls *)", "Read"],
|
||||
"ask": ["Bash(git push *)"],
|
||||
"deny": ["Bash(rm *)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Les trois listes sont facultatives. La commande `/permissions` affiche les règles chargées, par fichier (badges `ALLOW`, `ASK`, `DENY`). Une règle invalide est ignorée avec un avertissement au démarrage. Les règles sont lues au démarrage, à `/clear` et à `/new` : relancez ou tapez `/clear` après une modification.
|
||||
|
||||
## 2. Les trois familles et leur priorité
|
||||
|
||||
| Liste | Effet |
|
||||
| --- | --- |
|
||||
| `deny` | l'appel est **refusé** sans rien demander ; le modèle est prévenu de ne pas réessayer tel quel |
|
||||
| `ask` | l'appel demande **toujours** une confirmation |
|
||||
| `allow` | l'appel s'exécute **sans confirmation** |
|
||||
|
||||
Ordre d'évaluation : **`deny` > `ask` > `allow`**. La première famille qui correspond décide, quelle que soit la précision des règles : un `allow` très précis ne peut jamais lever un `deny` ou un `ask` plus large. Si **aucune** règle ne s'applique, MAIA demande une confirmation (c'est le comportement par défaut, pour tous les outils).
|
||||
|
||||
## 3. Syntaxe d'une règle
|
||||
|
||||
```
|
||||
Outil
|
||||
Outil(motif)
|
||||
```
|
||||
|
||||
- `Outil` seul (ex. `"Read"`) : **tous** les usages de l'outil.
|
||||
- `Outil(motif)` : seulement les usages qui correspondent au motif. Le nom de l'outil respecte la casse (`Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep`, `WebFetch`, `Skill`, `Agent`, outils personnalisés…).
|
||||
- Chaque règle est une chaîne JSON : les guillemets doubles du motif doivent être échappés (`\"`).
|
||||
|
||||
Le contenu du motif dépend de l'outil :
|
||||
|
||||
| Outil | Le motif est… | Exemples |
|
||||
| --- | --- | --- |
|
||||
| `Bash` | une commande (voir § 4) | `Bash(ls *)`, `Bash(npm run test:*)` |
|
||||
| `Read`, `Write`, `Edit`, `Glob`, `Grep` | un motif de **chemin** relatif au dossier de lancement (glob `*`, `**`) ; un dossier couvre son contenu | `Read(src/**)`, `Edit(docs)`, `Write(*.md)`, `Read(.env)` |
|
||||
| `WebFetch` | `domain:nom.de.domaine` (le `*` est accepté) | `WebFetch(domain:docs.python.org)`, `WebFetch(domain:*.github.com)` |
|
||||
| `Skill` | le nom du skill | `Skill(api-docs)` |
|
||||
| `Agent` | le nom du sous-agent | `Agent(code_reviewer)` |
|
||||
| outils personnalisés | un motif (`*`) comparé aux valeurs des paramètres | `addition(*)` |
|
||||
|
||||
Exemples hors Bash :
|
||||
|
||||
```json
|
||||
"permissions": {
|
||||
"allow": ["Read", "Glob", "Grep", "Edit(src/**)", "WebFetch(domain:docs.python.org)", "Agent(code_reviewer)"],
|
||||
"deny": ["Read(.env)", "Write(.git/**)"]
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Les règles `Bash`
|
||||
|
||||
### 4.1 Comment une commande est analysée
|
||||
|
||||
MAIA découpe la commande aux séparateurs `&&`, `||`, `;`, `|` et aux retours à la ligne, **sans tenir compte des guillemets**. Chaque morceau est une « sous-commande ».
|
||||
|
||||
- Pour **`allow`** : **tous** les morceaux doivent être couverts par une règle. Un seul morceau non couvert, et MAIA redemande confirmation.
|
||||
- Pour **`deny` et `ask`** : **un seul** morceau qui correspond suffit. Le découpage est même plus fin : il coupe aussi aux `$(…)`, aux backticks, aux parenthèses et aux redirections, pour retrouver une commande cachée dans `echo $(rm -rf x)`.
|
||||
- Une commande qui contient une substitution (`$(…)`, backticks, `<(…)`) ou une redirection (`>`, `>>`) n'est **jamais** autorisée automatiquement par un `allow` : MAIA demande toujours.
|
||||
|
||||
### 4.2 Écrire un motif
|
||||
|
||||
| Motif | Correspond à | Ne correspond pas à |
|
||||
| --- | --- | --- |
|
||||
| `ls` | `ls` exactement | `ls -la` |
|
||||
| `ls *` | `ls`, `ls -la`, `ls src` | `lsof` |
|
||||
| `npm run test:*` | tout ce qui **commence** par `npm run test:` (ancienne syntaxe « préfixe ») | `npm run build` |
|
||||
| `git * --help` | `git commit --help`, `git log --help` | `git commit` |
|
||||
| `git status` | `git status` exactement | `git status -s` |
|
||||
|
||||
Règle pratique : `commande *` autorise la commande avec ou sans arguments ; la commande seule la limite à elle-même. Les espaces comptent. Un `*` au milieu remplace n'importe quelle suite de caractères. `"Bash"` seul autorise **toute** commande : à éviter sauf dans un environnement jetable.
|
||||
|
||||
## 5. Exemple simple : autoriser `ls`
|
||||
|
||||
Pour ne plus confirmer `ls`, avec ou sans options :
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(ls *)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Résultats :
|
||||
|
||||
| Commande demandée par le modèle | Décision |
|
||||
| --- | --- |
|
||||
| `ls` | autorisée |
|
||||
| `ls -la src` | autorisée |
|
||||
| `lsof` | confirmation (autre commande) |
|
||||
| `ls && rm -rf x` | confirmation (le morceau `rm -rf x` n'est pas couvert) |
|
||||
| `ls > sortie.txt` | confirmation (redirection) |
|
||||
| `ls $(pwd)` | confirmation (substitution) |
|
||||
|
||||
Pour autoriser plusieurs commandes de lecture :
|
||||
|
||||
```json
|
||||
"allow": ["Bash(ls *)", "Bash(cat *)", "Bash(grep *)", "Bash(git status)", "Bash(git diff *)", "Bash(git log *)"]
|
||||
```
|
||||
|
||||
Un tube entre commandes toutes autorisées passe : `cat a.txt | grep foo` est autorisé avec `Bash(cat *)` et `Bash(grep *)`.
|
||||
|
||||
## 6. Exemple complexe : `podman run … sh -c "… | grep …"`
|
||||
|
||||
Commande à autoriser :
|
||||
|
||||
```
|
||||
podman run --rm alpine sh -c "echo 'hello world' | grep 'll'"
|
||||
```
|
||||
|
||||
**Piège** : le `|` à l'intérieur des guillemets coupe quand même la commande en deux morceaux :
|
||||
|
||||
1. `podman run --rm alpine sh -c "echo 'hello world'`
|
||||
2. `grep 'll'"`
|
||||
|
||||
Une règle qui reproduit la commande entière n'est donc **jamais** reconnue, et une règle `podman … sh -c *` seule ne couvre que le premier morceau : MAIA redemande confirmation. (Les deux cas ont été vérifiés.) Chaque morceau doit avoir sa règle :
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(podman run --rm alpine sh -c *)",
|
||||
"Bash(grep *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Avec ces deux règles, la commande ci-dessus est autorisée sans confirmation. Remarques :
|
||||
|
||||
- `Bash(podman run --rm alpine sh -c *)` limite l'image (`alpine`) et l'option `--rm` : `podman run --privileged …` ou une autre image redemandent confirmation.
|
||||
- `Bash(grep *)` autorise `grep` partout, pas seulement dans ce conteneur. C'est le prix du découpage : si c'est trop large, ne l'autorisez pas et acceptez la confirmation pour cette commande.
|
||||
- Dans un fichier JSON, écrivez la règle telle quelle ; il n'y a pas de guillemets à échapper tant que le motif n'en contient pas. Si le motif en contient : `"Bash(echo \"hello\")"`.
|
||||
|
||||
Les morceaux non couverts restent bloqués : avec les mêmes règles, `podman run --rm alpine sh -c "echo x | rm -rf /tmp/y"` demande confirmation (le morceau `rm -rf /tmp/y"` n'est pas autorisé).
|
||||
|
||||
## 7. Interdire une commande, même cachée dans un tube
|
||||
|
||||
Pour interdire `rm` quoi qu'il arrive :
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"deny": ["Bash(rm *)", "Bash(* rm *)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Les deux règles sont complémentaires :
|
||||
|
||||
- `Bash(rm *)` : un morceau qui **commence** par `rm`.
|
||||
- `Bash(* rm *)` : `rm` apparaît comme un mot **après** autre chose (`xargs rm`, `sudo rm`, `time rm`…).
|
||||
|
||||
Vérifications (avec ces deux règles) :
|
||||
|
||||
| Commande | Décision |
|
||||
| --- | --- |
|
||||
| `rm -rf build` | refusée |
|
||||
| `ls && rm -rf build` | refusée |
|
||||
| `echo ok > f; rm f` | refusée |
|
||||
| `cat f \| (rm -f g)` | refusée |
|
||||
| `echo $(rm -rf x)` | refusée |
|
||||
| `ls \| xargs rm` | refusée (grâce à `* rm *`) |
|
||||
| `podman run --rm alpine sh -c "echo x \| rm -rf /tmp/y"` | refusée : le découpage de `deny` entre aussi dans les guillemets |
|
||||
|
||||
Avec `Bash(rm *)` **seul**, `ls | xargs rm` n'est **pas** refusé (le morceau commence par `xargs`) : ajoutez `Bash(* rm *)`.
|
||||
|
||||
Un `deny` l'emporte toujours sur un `allow` : même avec `Bash(podman run --rm alpine sh -c *)` et `Bash(grep *)` autorisés, la commande contenant `| rm -rf …` est refusée si `Bash(rm *)` est dans `deny`.
|
||||
|
||||
Pour **demander confirmation** plutôt qu'interdire (ex. `git push`), utilisez `ask` :
|
||||
|
||||
```json
|
||||
"ask": ["Bash(git push *)", "Bash(git reset --hard *)"]
|
||||
```
|
||||
|
||||
Même avec `"Bash(git *)"` en `allow`, ces commandes redemandent confirmation.
|
||||
|
||||
## 8. Limites : `deny` n'est pas un bac à sable
|
||||
|
||||
Les règles analysent le **texte** de la commande. Elles ne peuvent pas tout voir :
|
||||
|
||||
- `find . -delete`, `git clean -fd`, `truncate`, `mv x /dev/null`, `python -c "import os; os.remove('f')"` ou `perl -e …` suppriment des fichiers **sans** écrire `rm`. Ajoutez les règles correspondantes si elles vous importent (`Bash(find * -delete*)`, `Bash(git clean *)`), ou n'autorisez que les commandes dont vous avez besoin (liste `allow` courte) et laissez le reste en confirmation.
|
||||
- Une commande lancée depuis un script (`bash script.sh`) n'est pas analysée : le contenu du script est invisible.
|
||||
- Les `deny` sur les outils de fichiers (`Read(.env)`, `Write(.git/**)`) ne concernent que ces outils : `Bash(cat .env)` est une autre règle, à ajouter si besoin.
|
||||
|
||||
Approche conseillée : peu de `allow` (lecture seule, tests, build), quelques `ask` (publication, écriture hors projet), des `deny` pour ce qui ne doit jamais arriver (`rm`, `sudo`, `curl … | sh`), et **laisser le défaut** (confirmation) pour tout le reste.
|
||||
|
||||
## 9. Sous-agents
|
||||
|
||||
Un sous-agent hérite des permissions de l'agent principal. Sa définition (`AGENT.md`) peut en **ajouter** :
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
allow: [Read, Grep]
|
||||
deny: [Bash]
|
||||
```
|
||||
|
||||
Les règles s'additionnent : un `deny` ou un `ask` hérité n'est jamais levé par un sous-agent. Quand un sous-agent a besoin d'une confirmation, la question s'affiche dans l'interface principale, préfixée `[sous-agent nom]`.
|
||||
|
||||
## 10. Aide-mémoire
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read", "Glob", "Grep",
|
||||
"Edit(src/**)",
|
||||
"Bash(ls *)", "Bash(git status)", "Bash(git diff *)",
|
||||
"Bash(npm run test:*)",
|
||||
"WebFetch(domain:docs.python.org)"
|
||||
],
|
||||
"ask": [
|
||||
"Bash(git push *)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(rm *)", "Bash(* rm *)",
|
||||
"Bash(sudo *)", "Bash(* sudo *)",
|
||||
"Read(.env)", "Write(.git/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Je veux… | Règle |
|
||||
| --- | --- |
|
||||
| autoriser `ls` et ses options | `allow: ["Bash(ls *)"]` |
|
||||
| autoriser une commande exacte, sans options | `allow: ["Bash(git status)"]` |
|
||||
| autoriser un préfixe de scripts npm | `allow: ["Bash(npm run test:*)"]` |
|
||||
| autoriser une commande avec tube | une règle **par morceau** (§ 6) |
|
||||
| toujours confirmer une commande | `ask: ["Bash(git push *)"]` |
|
||||
| interdire une commande partout, tubes compris | `deny: ["Bash(rm *)", "Bash(* rm *)"]` |
|
||||
| protéger un fichier | `deny: ["Read(.env)", "Write(.env)", "Edit(.env)"]` |
|
||||
| limiter les sites web | `allow: ["WebFetch(domain:docs.python.org)"]` |
|
||||
12
README.md
12
README.md
|
|
@ -63,7 +63,7 @@ Spécifications techniques détaillées (contrats, formats, constantes) : [`SPEC
|
|||
| `/compact` | résume la conversation et repart de ce résumé (voir « Compactage ») |
|
||||
| `/rename <nom>` · `/load [nom]` · `/new` | sessions (voir plus bas) |
|
||||
| `/exit` (ou Ctrl+D) | quitte (la session est sauvegardée) |
|
||||
| `!commande` · `@fichier` · `§skill` | commande shell, fichier injecté, skill (voir plus bas) |
|
||||
| `!commande` · `@fichier` · `§skill` · `&agent` | commande shell, fichier injecté, skill (voir plus bas) |
|
||||
|
||||
Option de lancement : `--model <spec>` (ex. `litellm:gpt-4o`).
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ Le CLI garde une façade impérative (`InkUI` : `info`, `select`, `readInput`, `
|
|||
ce qui rend la logique testable (`store.test.ts`). Les composants (saisie, liste, confirmation, spinner) sont écrits à la main ; seuls les badges viennent de [`@inkjs/ui`](https://github.com/vadimdemedes/ink-ui).
|
||||
Un terminal interactif (TTY) est requis.
|
||||
|
||||
Présentation : la bannière d'accueil reprend le style « MAIA » : image à gauche, affichée telle quelle en couleurs réelles (demi-blocs ▀▄█ : 32 × 32 pixels, soit 32 colonnes × 16 lignes ; sans pixelisation). Elle vient de `packages/ui/assets/mask.png` ; `python3 packages/ui/scripts/make-mask.py [taille]` (Pillow) régénère `src/mask-data.ts` à une autre taille, titre « Maia » en gros caractères pixel avec ombre et dégradé arc-en-ciel à droite, puis deux textes (vert : « Assistant IA en ligne de commande… », rose : « Un seul agent, plusieurs modèles »). Terminal d'au moins 58 colonnes pour la version complète ; entre 25 et 57 : sans le masque ; en dessous : cadre à double bordure compact, titre centré ;
|
||||
Présentation : la bannière d'accueil reprend le style « MAIA » : image à gauche, affichée telle quelle en couleurs réelles (demi-blocs ▀▄█ : 32 × 32 pixels, soit 32 colonnes × 16 lignes ; sans pixelisation). Elle vient de `packages/ui/assets/mask.png` ; `python3 packages/ui/scripts/make-mask.py [taille]` (Pillow) régénère `src/mask-data.ts` à une autre taille, titre « Maia » en gros caractères pixel avec ombre et dégradé arc-en-ciel à droite, puis trois textes (vert : « Assistant IA en ligne de commande… », rose : « Un seul agent, plusieurs modèles », puis la signature « Arnaud VINCENT » en jaune et « ClaudeCode » en orange). Terminal d'au moins 58 colonnes pour la version complète ; entre 25 et 57 : sans le masque ; en dessous : cadre à double bordure compact, titre centré ;
|
||||
une ligne vide sépare chaque message.
|
||||
|
||||
Messages du journal, avec le composant `Badge` de `@inkjs/ui` :
|
||||
|
|
@ -187,6 +187,7 @@ Au démarrage, le fichier **`~/.maia/settings.json`** (dossier de l'utilisateur,
|
|||
- `OpenAI_EndPoint` : suffixe ajouté à `LiteLLM_URL` pour envoyer une requête au format OpenAI.
|
||||
- `LiteLLM_Models` : suffixe ajouté à `LiteLLM_URL` pour récupérer la liste des modèles disponibles.
|
||||
- Optionnel : `LiteLLM_API_Key` (ou variable d'environnement `LITELLM_API_KEY`), envoyée en `Authorization: Bearer …`. Ce fichier peut donc contenir un secret : ne pas le versionner.
|
||||
- Optionnel : `MaxIterations` (entier, défaut 50) : étapes (appel du LLM puis des outils) par série avant que MAIA demande s'il faut continuer.
|
||||
- Optionnel : `MaxOutputTokens` (entier, ex. `16000`) : `max_tokens` envoyé à chaque requête. Sans lui, le serveur applique sa limite par défaut, parfois courte : un long fichier écrit par l'outil `Write` peut être coupé (le paramètre `content` arrive alors absent). MAIA affiche un avertissement quand la réponse est coupée (finish_reason `length`, ou sortie qui tombe pile sur une limite usuelle 4096/8192… avec un appel d'outil : certains serveurs « réparent » alors l'appel et annoncent `tool_calls`).
|
||||
Cas constaté avec `anthropic--claude-4.5-sonnet` : `completion_tokens: 4096` et `Write` reçu avec `path` seul → fixer par ex. `"MaxOutputTokens": 32000`.
|
||||
- Diagnostic : `MAIA_DEBUG_LLM=1 maia` enregistre le flux brut renvoyé par LiteLLM dans `maia-llm-debug.log` (dossier temporaire ; `MAIA_DEBUG_LLM=/chemin/fichier.log` pour choisir le fichier).
|
||||
|
|
@ -224,6 +225,8 @@ Conséquence : il n'y a plus de variables d'environnement venant de la configura
|
|||
|
||||
## Permissions
|
||||
|
||||
> Guide complet avec exemples (commande simple, commande avec tube, interdiction d'un `rm` caché) : [`GUIDE_PERMISSIONS.md`](GUIDE_PERMISSIONS.md).
|
||||
|
||||
Les permissions s'appliquent aux **outils** (intégrés ou non). Trois types de règles :
|
||||
|
||||
| Type | Effet |
|
||||
|
|
@ -374,6 +377,7 @@ Pour chaque problème : catégorie (CRITICAL / HIGH / MEDIUM / LOW), description
|
|||
- **Effort** : borne le nombre d'étapes du sous-agent (low 10, medium 25, high 50) et module son prompt.
|
||||
- **Modèle** : identifiant exact ou fragment (`sonnet` → premier modèle LiteLLM dont le nom contient « sonnet ») ; introuvable → modèle de l'agent principal, avec avertissement.
|
||||
- **Autorisations** : si le sous-agent doit demander une confirmation, la question s'affiche dans l'interface principale, préfixée `[sous-agent nom]` (une question à la fois). Ctrl+C interrompt aussi les sous-agents en cours.
|
||||
- **Lancement par l'utilisateur** : une saisie qui **commence** par `&` lance un sous-agent directement : `&code_reviewer relis src/a.ts` (le nom est reconnu sans tenir compte de la casse ; sans tâche, l'agent exécute sa mission telle que décrite). `&` seul liste les agents. Aucune confirmation de l'outil `Agent` n'est demandée (c'est vous qui lancez), mais les autorisations des outils du sous-agent suivent les règles habituelles. Le résumé s'affiche et est joint à votre prochaine demande, comme le résultat d'une commande `!`. Ctrl+C interrompt le sous-agent.
|
||||
- **Fin de mission** : un message INFO est affiché dans l'interface principale (durée, nombre d'appels d'outils) ; un échec est signalé par un avertissement et renvoyé à l'agent principal comme erreur d'outil. Délai maximal : 30 minutes.
|
||||
- **Trace** (`trace: true`) : la conversation du sous-agent est enregistrée dans `./.maia/.sessions/` sous le nom `{session de l'agent principal}-{agent}`, ex. `session_2026-10-20_00_01_53-agent_1.json` (`_2`, `_3`… si le nom est pris). Sans `trace`, rien n'est enregistré.
|
||||
|
||||
|
|
@ -430,3 +434,7 @@ Journal des évolutions, dans l'ordre :
|
|||
36. **Barre de contexte toujours affichée** : en bas de l'écran en permanence (saisie, itérations, outils, confirmations, listes) ; si la taille maximale du contexte est inconnue, seul le nombre de tokens utilisés est montré.
|
||||
37. **Zone de saisie toujours visible** : grisée pendant le travail de l'agent (« Traitement en cours — Ctrl+C : interrompre »), et c'est elle qui porte les demandes d'autorisation (`ASK … (o/n)`), dans le même cadre en pointillés.
|
||||
38. **Ligne d'état réservée** au-dessus de la zone de saisie (« En réflexion… », outils en cours) : toujours présente, vide hors traitement, pour que la zone de saisie ne remonte ni ne descende.
|
||||
39. **Limite d'itérations** : 50 par défaut, réglable avec `MaxIterations` dans `~/.maia/settings.json` ; à la limite, MAIA demande s'il faut continuer (o/n) pour une nouvelle série ; une demande tapée après un arrêt est fusionnée dans le dernier message utilisateur (alternance respectée).
|
||||
40. **Guide des permissions** : `GUIDE_PERMISSIONS.md` (syntaxe, exemples simples et complexes, interdiction d'une commande cachée dans un tube, limites).
|
||||
41. **Signature dans la bannière** : « Arnaud VINCENT (avec ClaudeCode) » sous « Un seul agent, plusieurs modèles » (ligne séparée par un espace ; nom en #EEF527, ClaudeCode en #F57D27).
|
||||
42. **Lancement direct d'un sous-agent** : une saisie qui commence par `&` (`&Agent_1 tâche…`) exécute le sous-agent sans passer par le LLM ; son résumé est affiché et ajouté au contexte.
|
||||
|
|
|
|||
|
|
@ -172,11 +172,11 @@ Toute autre commande : « Commande inconnue ». (Les slash-commandes personnalis
|
|||
### 6.2 `runTurn(session, userText, signal?)`
|
||||
|
||||
1. Le `pendingContext` est vidé et **joint** au texte utilisateur (`contexte\n\ntexte`) en **un seul message** `user` (certains providers exigent l'alternance user/assistant).
|
||||
2. Boucle d'au plus **25 itérations** (`MAX_ITERATIONS`) :
|
||||
2. Boucle d'au plus **50 itérations** par série (`MAX_ITERATIONS`, réglable par `MaxIterations`) :
|
||||
- appel `provider.chat({ system, messages, tools })` (**bloquant** : le raisonnement du LLM n'est jamais mis en tâche de fond, pour préserver l'intégrité du contexte) ;
|
||||
- le texte est diffusé via `ui.onText` ; le message `assistant` (texte + `tool_use`) est ajouté ; `ui.onContextUsage(sessionContextTokens(session))` ;
|
||||
- sans appel d'outil → fin du tour ; sinon `runToolCalls`, ajout d'un message `user` portant les `tool_result`, itération suivante.
|
||||
3. Au-delà de 25 itérations : message d'erreur « Arrêt : plus de 25 itérations ».
|
||||
3. À la limite : si l'appelant fournit `onLimit` (le CLI pose la question « Continuer ? (o/n) »), `true` ajoute une série d'itérations ; sinon (ou refus) message d'erreur « Arrêt : plus de N itérations » et retour `"max_iterations"`. Une demande suivante est fusionnée dans le dernier message « utilisateur » (résultats d'outils) pour respecter l'alternance ; si Ctrl+C l'annule, le CLI restaure ce message.
|
||||
|
||||
Les événements `usage` du provider sont ignorés par le cœur.
|
||||
|
||||
|
|
@ -242,6 +242,7 @@ Erreurs : serveur injoignable (`LiteLLM injoignable (url) : …`), HTTP non 2xx
|
|||
| `OpenAI_EndPoint` (obligatoire) | suffixe de la requête de chat, ex. `/v1/chat/completions` |
|
||||
| `LiteLLM_Models` (obligatoire) | suffixe de la liste des modèles, ex. `/v1/models` |
|
||||
| `LiteLLM_API_Key` (facultatif) | clé API ; sinon variable `LITELLM_API_KEY` |
|
||||
| `MaxIterations` (facultatif) | entier positif : itérations par série avant arrêt / question « Continuer ? » (défaut 50) |
|
||||
| `MaxOutputTokens` (facultatif) | entier positif : `max_tokens` envoyé à chaque requête (sauf si la requête fixe sa propre valeur, ex. le résumé de compactage à 4000) |
|
||||
|
||||
Ce fichier est dans le dossier de l'**utilisateur** (pas celui de lancement) ; il peut contenir un secret. La même propriété `permissions` y est lue (§ 9).
|
||||
|
|
@ -600,6 +601,10 @@ Le modèle est résolu par le parent (`resolveSubAgentModel` : identifiant exact
|
|||
|
||||
Confirmations : l'interface de l'enfant n'affiche rien ; `confirm` est relayé au parent, qui pose la question (`[sous-agent nom] Autoriser … ?`) ; `InkUI.confirm` sérialise désormais toutes les questions (outils parallèles, plusieurs sous-agents).
|
||||
|
||||
### 20.3b Lancement direct par l'utilisateur (`&`)
|
||||
|
||||
`parseAgentInvocation(input)` (core) : uniquement si la saisie **commence** par `&` ; `&nom tâche` ou `& nom tâche`, `&` seul = liste. Le CLI trouve l'agent (nom insensible à la casse), appelle directement le lanceur (pas d'outil `Agent`, donc pas de confirmation d'outil ; spinner « Sous-agent … au travail », Ctrl+C interrompt), affiche le résumé et l'ajoute à `pendingContext` (joint à la demande suivante, comme `!`). Sans tâche : `DEFAULT_AGENT_TASK`. Agent inconnu : erreur avec la liste.
|
||||
|
||||
### 20.4 Fin, erreurs, interruption
|
||||
|
||||
- Fin normale : INFO « Sous-agent « x » terminé en N s (M appels d'outil). » (+ nom du fichier de trace) ; le résumé est le `tool_result`.
|
||||
|
|
@ -614,7 +619,7 @@ Si `trace: true` (même en cas d'échec, s'il y a des messages) : `saveSubAgentT
|
|||
|
||||
| Constante | Valeur | Lieu |
|
||||
| --- | --- | --- |
|
||||
| Itérations maximales d'un tour | 25 | `agent.ts` |
|
||||
| Itérations par série d'un tour (réglable : `MaxIterations`) | 50 | `agent.ts` |
|
||||
| Outils simultanés | 8 | `tool-runner.ts` |
|
||||
| Timeout `!` / Bash / outil personnalisé | 120 s | `shell.ts`, `bash.ts`, `custom-tools.ts` |
|
||||
| Sortie Bash / outil personnalisé | 30 000 caractères | `bash.ts`, `custom-tools.ts` |
|
||||
|
|
|
|||
|
|
@ -9,9 +9,12 @@ import {
|
|||
ensureMaiaHome,
|
||||
findSkill,
|
||||
EMERGENCY_TOOL_RESULT_CHARS,
|
||||
MAX_ITERATIONS,
|
||||
isCompactable,
|
||||
isContextTooLongError,
|
||||
normalizeSessionName,
|
||||
DEFAULT_AGENT_TASK,
|
||||
parseAgentInvocation,
|
||||
parseShellInvocation,
|
||||
resolveFileReferences,
|
||||
parseSkillInvocation,
|
||||
|
|
@ -80,6 +83,7 @@ Commandes en session : /help /model /permissions /agents /clear /compact /
|
|||
Sessions : /rename <nom> /load [nom] /new (sauvegarde automatique à la sortie, dans ./.maia/.sessions)
|
||||
Skills de l'agent : §nom_du_skill [arguments] (§ seul : liste des skills)
|
||||
Sous-agents : définis dans ./.maia/agents/<nom>/AGENT.md ou ~/.maia/agents ; l'agent les lance (outil Agent) ; /agents : liste
|
||||
&nom [tâche] : vous lancez vous-même un sous-agent (& seul : liste) ; son résumé est ajouté au contexte
|
||||
Commande shell : !commande ou !'commande avec paramètres' (exécutée directement ; résultat ajouté au contexte)
|
||||
Fichier référencé : @chemin/fichier ou @'chemin avec espaces' ou @ chemin/fichier (contenu injecté dans le contexte)`;
|
||||
|
||||
|
|
@ -401,6 +405,45 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Lancement direct d'un sous-agent : une saisie qui commence par « & » (&nom [tâche])
|
||||
const agentCall = parseAgentInvocation(input);
|
||||
if (agentCall) {
|
||||
if (!agentCall.name) {
|
||||
ui.info(
|
||||
agents.length > 0
|
||||
? ["Sous-agents disponibles :", ...agents.map((a) => ` &${a.name} — ${a.description}`)].join("\n")
|
||||
: "Aucun sous-agent : créez ./.maia/agents/<nom>/AGENT.md (ou ~/.maia/agents/<nom>/AGENT.md).",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const def = agents.find((a) => a.name.toLowerCase() === agentCall.name.toLowerCase());
|
||||
if (!def) {
|
||||
const names = agents.map((a) => `&${a.name}`).join(", ") || "aucun";
|
||||
ui.error(`Sous-agent inconnu : &${agentCall.name} (disponibles : ${names})`);
|
||||
continue;
|
||||
}
|
||||
const task = agentCall.task || DEFAULT_AGENT_TASK;
|
||||
const controller = new AbortController();
|
||||
ui.setInterruptHandler(() => controller.abort());
|
||||
ui.setBusy(`Sous-agent « ${def.name} » au travail…`);
|
||||
try {
|
||||
const summary = await launchSubAgent({ agent: def, task, signal: controller.signal });
|
||||
// Le résumé s'affiche et rejoint la conversation principale (joint à la prochaine demande, comme le résultat d'un « ! »)
|
||||
ui.onText(summary);
|
||||
ui.onTextEnd();
|
||||
session.pendingContext.push(`Résumé du sous-agent « ${def.name} » (lancé par l'utilisateur) pour la tâche : ${task}\n\n${summary}`);
|
||||
ui.info("Résumé ajouté au contexte : il sera joint à votre prochaine demande.");
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) ui.warn(`Sous-agent « ${def.name} » interrompu (Ctrl+C).`);
|
||||
else ui.error((err as Error).message);
|
||||
} finally {
|
||||
ui.setBusy(null);
|
||||
ui.setInterruptHandler(null);
|
||||
}
|
||||
showContext(session);
|
||||
continue;
|
||||
}
|
||||
|
||||
let prompt = input;
|
||||
|
||||
const skillCall = parseSkillInvocation(input);
|
||||
|
|
@ -591,6 +634,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
|
||||
// Ctrl+C pendant le tour : annule la requête en cours et restaure la conversation
|
||||
let mark = session.messages.length;
|
||||
let lastBackup = session.messages.at(-1); // runTurn peut compléter ce message (reprise après la limite d'itérations)
|
||||
const contextBackup = [...session.pendingContext];
|
||||
|
||||
// Références @fichier (communes à tous les agents) : le contenu des fichiers cités dans ce que
|
||||
|
|
@ -612,11 +656,15 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
let retried = false;
|
||||
for (;;) {
|
||||
try {
|
||||
await runTurn(session, attempt, controller.signal);
|
||||
await runTurn(session, attempt, controller.signal, {
|
||||
maxIterations: settings.MaxIterations,
|
||||
onLimit: (n) => ui.confirm(`${n} étapes effectuées sans réponse finale. Continuer pour ${settings.MaxIterations ?? MAX_ITERATIONS} étapes de plus ?`),
|
||||
});
|
||||
break;
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat)
|
||||
if (mark > 0 && lastBackup) session.messages[mark - 1] = lastBackup;
|
||||
session.pendingContext = contextBackup; // le contexte des commandes « ! » n'est pas perdu
|
||||
showContext(session);
|
||||
ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande.");
|
||||
|
|
@ -629,7 +677,8 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
const compacted = await compactNow({ emergency: true });
|
||||
ui.setInterruptHandler(() => controller.abort()); // compactNow a retiré le gestionnaire Ctrl+C
|
||||
if (compacted) {
|
||||
mark = 0; // nouvelle conversation (résumé comme contexte de départ)
|
||||
mark = 0;
|
||||
lastBackup = undefined; // nouvelle conversation (résumé comme contexte de départ)
|
||||
attempt =
|
||||
"Le contexte a été compacté parce qu'il dépassait la fenêtre du modèle. Le résumé de départ indique ce qui est déjà fait. " +
|
||||
`Reprends là où tu t'es arrêté et termine la demande suivante :\n\n${prompt}`;
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@ export function createSession(opts: {
|
|||
};
|
||||
}
|
||||
|
||||
const MAX_ITERATIONS = 25;
|
||||
/** Nombre d'itérations par défaut avant de s'arrêter (ou de demander s'il faut continuer). */
|
||||
export const MAX_ITERATIONS = 50;
|
||||
|
||||
/** Fin d'un tour : réponse finale du modèle, ou arrêt faute d'itérations (le dernier message est alors un résultat d'outil). */
|
||||
export type TurnEnd = "done" | "max_iterations";
|
||||
|
|
@ -67,17 +68,38 @@ export async function runTurn(
|
|||
session: AgentSession,
|
||||
userText: string,
|
||||
signal?: AbortSignal,
|
||||
opts: { maxIterations?: number } = {},
|
||||
opts: {
|
||||
maxIterations?: number;
|
||||
/**
|
||||
* Appelée quand la limite d'itérations est atteinte sans réponse finale : true → continuer pour `maxIterations`
|
||||
* itérations de plus, false → s'arrêter. Absente : arrêt avec un message d'erreur.
|
||||
*/
|
||||
onLimit?: (iterations: number) => Promise<boolean>;
|
||||
} = {},
|
||||
): Promise<TurnEnd> {
|
||||
const maxIterations = opts.maxIterations ?? MAX_ITERATIONS;
|
||||
const { ui } = session;
|
||||
// Le contexte en attente est joint à la demande (un seul message : certains providers exigent l'alternance user/assistant)
|
||||
const context = session.pendingContext.splice(0);
|
||||
const text = context.length > 0 ? `${context.join("\n\n")}\n\n${userText}` : userText;
|
||||
// Après un arrêt à la limite d'itérations, le dernier message est déjà un message « utilisateur » (résultats d'outils) :
|
||||
// la nouvelle demande s'y ajoute (l'alternance user/assistant est exigée par certains serveurs)
|
||||
const last = session.messages.at(-1);
|
||||
if (last?.role === "user") {
|
||||
session.messages[session.messages.length - 1] = { ...last, content: [...last.content, { type: "text", text }] };
|
||||
} else {
|
||||
session.messages.push({ role: "user", content: [{ type: "text", text }] });
|
||||
}
|
||||
ui.onContextUsage?.(sessionContextTokens(session)); // la demande (et le contexte joint) occupe déjà des tokens
|
||||
|
||||
for (let i = 0; i < maxIterations; i++) {
|
||||
let iterations = 0;
|
||||
let limit = maxIterations;
|
||||
for (;;) {
|
||||
if (iterations >= limit) {
|
||||
if (opts.onLimit && (await opts.onLimit(iterations))) limit += maxIterations;
|
||||
else break;
|
||||
}
|
||||
iterations++;
|
||||
const assistant: ContentBlock[] = [];
|
||||
let text = "";
|
||||
let truncated = false; // réponse coupée par la limite de tokens de sortie du modèle
|
||||
|
|
@ -141,7 +163,7 @@ export async function runTurn(
|
|||
session.messages.push({ role: "user", content: results });
|
||||
ui.onContextUsage?.(sessionContextTokens(session)); // les résultats d'outils pèsent avant l'itération suivante
|
||||
}
|
||||
session.ui.onToolResult("agent", `Arrêt : plus de ${maxIterations} itérations`, true);
|
||||
session.ui.onToolResult("agent", `Arrêt : plus de ${iterations} itérations`, true);
|
||||
return "max_iterations";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { join } from "node:path";
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createSession, runTurn } from "./agent";
|
||||
import { SessionStore, ensureMaiaHome, saveSubAgentTrace, subAgentSessionName } from "./sessions";
|
||||
import { SUB_AGENT_SUMMARY_PROMPT, finalAssistantText, requestFinalSummary } from "./subagent";
|
||||
import { SUB_AGENT_SUMMARY_PROMPT, finalAssistantText, parseAgentInvocation, requestFinalSummary } from "./subagent";
|
||||
import type { AgentUI, ChatRequest, LLMProvider, Message, StreamEvent } from "./types";
|
||||
|
||||
const ui: AgentUI = {
|
||||
|
|
@ -109,3 +109,44 @@ describe("trace d'un sous-agent", () => {
|
|||
expect(again).toBe(`${name}_2`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("limite d'itérations : continuer et reprendre", () => {
|
||||
const mk = (replies: StreamEvent[][]) =>
|
||||
createSession({ provider: scripted(replies), init: { systemPrompt: "S", tools: [] }, ui, cwd: "." });
|
||||
|
||||
it("onLimit = true : le tour continue pour une nouvelle série d'itérations jusqu'à la réponse finale", async () => {
|
||||
const s = mk([callTool, callTool, callTool, say("fin")]);
|
||||
const asked: number[] = [];
|
||||
const end = await runTurn(s, "q", undefined, { maxIterations: 2, onLimit: async (n) => (asked.push(n), true) });
|
||||
expect(end).toBe("done");
|
||||
expect(asked).toEqual([2]);
|
||||
expect(finalAssistantText(s.messages)).toBe("fin");
|
||||
});
|
||||
it("onLimit = false : arrêt sur « max_iterations »", async () => {
|
||||
const s = mk([callTool]);
|
||||
expect(await runTurn(s, "q", undefined, { maxIterations: 2, onLimit: async () => false })).toBe("max_iterations");
|
||||
});
|
||||
it("nouvelle demande après un arrêt : fusionnée dans le dernier message utilisateur (alternance respectée)", async () => {
|
||||
const s = mk([callTool, say("ok")]);
|
||||
await runTurn(s, "q", undefined, { maxIterations: 1 });
|
||||
expect(s.messages.at(-1)!.role).toBe("user");
|
||||
await runTurn(s, "continue");
|
||||
s.messages.forEach((m, i) => expect(m.role).toBe(i % 2 === 0 ? "user" : "assistant"));
|
||||
const merged = s.messages[2]!;
|
||||
expect(merged.content.map((b) => b.type)).toEqual(["tool_result", "text"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lancement direct par « & »", () => {
|
||||
it("« &nom tâche » et « & nom tâche »", () => {
|
||||
expect(parseAgentInvocation("&Agent_1")).toEqual({ name: "Agent_1", task: "" });
|
||||
expect(parseAgentInvocation("&Agent_1 relis src/a.ts\net tests")).toEqual({ name: "Agent_1", task: "relis src/a.ts\net tests" });
|
||||
expect(parseAgentInvocation("& agent_1 fais X ")).toEqual({ name: "agent_1", task: "fais X" });
|
||||
});
|
||||
it("« & » seul : nom vide (liste) ; une saisie qui ne COMMENCE pas par & n'est pas un lancement", () => {
|
||||
expect(parseAgentInvocation("&")).toEqual({ name: "", task: "" });
|
||||
expect(parseAgentInvocation("lance &Agent_1")).toBeNull();
|
||||
expect(parseAgentInvocation(" &Agent_1")).toBeNull();
|
||||
expect(parseAgentInvocation("bonjour")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,3 +54,16 @@ export async function requestFinalSummary(session: AgentSession, signal?: AbortS
|
|||
messages.push({ role: "assistant", content: [{ type: "text", text: summary }] });
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Tâche donnée au sous-agent quand l'utilisateur le lance sans préciser de tâche. */
|
||||
export const DEFAULT_AGENT_TASK = "Exécute ta mission telle qu'elle est décrite dans tes instructions, puis rédige ton résumé final.";
|
||||
|
||||
/**
|
||||
* Lancement direct d'un sous-agent par l'utilisateur : une saisie qui COMMENCE par « & ».
|
||||
* « &nom tâche… » ou « & nom tâche… » ; « & » seul : name vide (liste des agents). Autre saisie : null.
|
||||
*/
|
||||
export function parseAgentInvocation(input: string): { name: string; task: string } | null {
|
||||
if (!input.startsWith("&")) return null;
|
||||
const m = /^&\s*(\S*)\s*([\s\S]*)$/.exec(input);
|
||||
return { name: m?.[1] ?? "", task: (m?.[2] ?? "").trim() };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,18 @@ describe("MaxOutputTokens", () => {
|
|||
await expect(loadSettings(await mk(',"MaxOutputTokens":"beaucoup"'))).rejects.toThrow("MaxOutputTokens");
|
||||
await expect(loadSettings(await mk(',"MaxOutputTokens":0'))).rejects.toThrow("MaxOutputTokens");
|
||||
});
|
||||
it("MaxIterations : entier positif, absent par défaut", async () => {
|
||||
const base = '"LiteLLM_URL":"http://x","OpenAI_EndPoint":"/c","LiteLLM_Models":"/m"';
|
||||
const mk = async (extra: string): Promise<string> => {
|
||||
const h = await mkdtemp(join(tmpdir(), "maia-"));
|
||||
await mkdir(join(h, ".maia"));
|
||||
await writeFile(join(h, ".maia", "settings.json"), `{${base}${extra}}`);
|
||||
return h;
|
||||
};
|
||||
expect((await loadSettings(await mk(""))).MaxIterations).toBe(undefined);
|
||||
expect((await loadSettings(await mk(',"MaxIterations":80'))).MaxIterations).toBe(80);
|
||||
await expect(loadSettings(await mk(',"MaxIterations":-1'))).rejects.toThrow("MaxIterations");
|
||||
});
|
||||
it("max_tokens est ajouté à la requête quand il est fourni", () => {
|
||||
const body = buildChatBody("m", { system: "s", messages: [], tools: [], maxTokens: 16000 });
|
||||
expect(body).toMatchObject({ max_tokens: 16000 });
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export interface MaiaSettings {
|
|||
* applique sa limite par défaut, parfois courte : un long fichier écrit par l'outil Write peut alors être coupé.
|
||||
*/
|
||||
MaxOutputTokens?: number;
|
||||
/** Optionnel : nombre d'itérations (appel du LLM puis des outils) par demande avant de s'arrêter ou de demander s'il faut continuer. Défaut : 50. */
|
||||
MaxIterations?: number;
|
||||
}
|
||||
|
||||
export const SETTINGS_EXAMPLE = `{
|
||||
|
|
@ -56,12 +58,17 @@ export async function loadSettings(home: string = homedir()): Promise<MaiaSettin
|
|||
if (json.MaxOutputTokens !== undefined && !(Number.isInteger(max) && max > 0)) {
|
||||
throw new Error(`${path} : MaxOutputTokens doit être un entier positif (reçu : ${JSON.stringify(json.MaxOutputTokens)})`);
|
||||
}
|
||||
const iterations = Number(json.MaxIterations);
|
||||
if (json.MaxIterations !== undefined && !(Number.isInteger(iterations) && iterations > 0)) {
|
||||
throw new Error(`${path} : MaxIterations doit être un entier positif (reçu : ${JSON.stringify(json.MaxIterations)})`);
|
||||
}
|
||||
return {
|
||||
LiteLLM_URL: (json.LiteLLM_URL as string).trim(),
|
||||
OpenAI_EndPoint: (json.OpenAI_EndPoint as string).trim(),
|
||||
LiteLLM_Models: (json.LiteLLM_Models as string).trim(),
|
||||
...(apiKey ? { LiteLLM_API_Key: apiKey } : {}),
|
||||
...(json.MaxOutputTokens !== undefined ? { MaxOutputTokens: max } : {}),
|
||||
...(json.MaxIterations !== undefined ? { MaxIterations: iterations } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import {
|
|||
|
||||
const GREEN = "#7BD74F";
|
||||
const PINK = "#E58AFF";
|
||||
const YELLOW = "#EEF527";
|
||||
const ORANGE = "#F57D27";
|
||||
const GREEN_LINES = ["Assistant IA en ligne de commande,", "propulsé par Ink et vos modèles de langage."];
|
||||
const PINK_LINES = ["Un seul agent, plusieurs modèles"];
|
||||
|
||||
|
|
@ -85,6 +87,14 @@ export function Banner({ title }: { title: string }) {
|
|||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text wrap="truncate">
|
||||
<Text color={YELLOW}>Arnaud VINCENT</Text>
|
||||
<Text> (avec </Text>
|
||||
<Text color={ORANGE}>ClaudeCode</Text>
|
||||
<Text>)</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue