aicodebox: Une Seule Image de Base, N’importe Quel Agent de Code IA, Quatre Modes Que Tu N’as Pas Eu à Écrire

J’ai construit claudebox parce que laisser Claude Code courir en liberté sur mon host me donnait des douleurs à la poitrine. Ça a marché. Ça s’est mis à faire pousser une API HTTP, un endpoint compatible OpenAI, un serveur MCP, un bot Telegram et un scheduler cron. Des mois de boulot, tout utile.
Puis pi-coding-agent est arrivé et j’ai voulu exactement la même chose pour lui. Puis OpenAI a sorti le CLI Codex et je l’ai voulu une troisième fois.
Et c’est là que je me suis assis pour regarder vraiment ce que j’allais faire: copier-coller un serveur FastAPI, un bot Telegram avec des workspaces par chat, un scheduler cron avec ses dossiers d’historique et un serveur MCP dans un deuxième repo. Puis un troisième. Trois copies des mêmes neuf cents lignes, qui divergent à la seconde où je corrige un bug dans l’une et oublie les autres. Trois endroits à patcher quand Telegram change une règle de rendu. Trois suites de tests qui testent une logique identique sur des agents différents.
Et puis merde. Rien dans cette plomberie n’est spécifique à Claude. Rien n’est spécifique à pi. Tout se résume à « prends un prompt, lance un CLI, attrape ce qui revient, file-le à celui qui a demandé ». L’agent est un détail. Les surfaces sont le produit.
Alors je les ai arrachées pour en faire une image de base. C’est aicodebox.

Le Problème de Tous les Projets « Mets un Agent sur le Réseau »

Chacun de ces outils fait la même erreur, le mien compris, la première fois: il soude le transport au cerveau.
Tu veux ton agent joignable en HTTP, alors tu écris un serveur HTTP qui sait invoquer ce binaire précis, parser ce format de sortie précis et gérer ce jeu de flags précis. Six mois plus tard il y a un meilleur agent, et tu possèdes un tas d’infrastructure qui ne parle qu’à l’ancien.
Les symptômes sont toujours les mêmes:

  • Des upgrades au fork et à la hache. Tu veux les mêmes surfaces pour un autre agent? Tu forkes le repo, tu cherches chaque endroit où le nom de l’ancien binaire est en dur, et tu espères les avoir tous eus.
  • Des corrections de bugs qui divergent. Le rendu markdown de Telegram a un bug. Tu le corriges dans un repo. Les deux autres gardent le bug pendant un mois, jusqu’à ce que tu t’en souviennes.
  • Des surfaces incohérentes. Une boîte a les runs async, l’autre non. L’une a un endpoint OpenAI, l’autre en a la moitié. Rien ne se compose parce que rien ne s’accorde sur une forme de fil.
  • Un lock-in sur l’agent par accident. Pas parce que quelqu’un a décidé de s’enfermer, mais parce que la plomberie a poussé autour des lubies d’un seul binaire et s’est pétrifiée là.

Le correctif n’est pas un meilleur wrapper. C’est admettre que le wrapper ne devrait pas savoir ce qu’il enveloppe.

Comment Ce Truc Marche Vraiment

aicodebox est une image Docker de base. Tu ne la forkes pas. Tu lui fais FROM.

FROM psyb0t/aicodebox
RUN npm install -g @earendil-works/[email protected]
COPY mypkg /opt/mypkg
RUN pip3 install --break-system-packages /opt/mypkg
ENV AICODEBOX_ADAPTER=mypkg.adapter:MyAdapter 
    AICODEBOX_AGENT_BINARY=pi

Voilà toute l’intégration. La ligne npm install est accessoire, c’est juste comme ça que pi-coding-agent se livre. Ça pourrait tout aussi bien être un apt-get install, un binaire copié depuis un stage de build Go, ou n’importe quoi d’autre qui pose un exécutable sur le PATH; voir comment l’agent est installé plus bas. La base possède toutes les surfaces réseau. Ton adaptateur traduit « lance ce prompt » en ce qu’attend le CLI de ton agent. Un nouvel agent atterrit en un après-midi, et il atterrit avec une API HTTP, un endpoint compatible OpenAI, un serveur MCP, un bot Telegram et un scheduler cron déjà accrochés, parce que ce n’est plus à toi de les écrire.
Ce qu’il y a vraiment dans l’image:

  • L’OS: Ubuntu 24.04, un utilisateur aicode en UID 1000 avec sudo sans mot de passe et appartenance au groupe docker.
  • Les runtimes: Node.js 24 LTS (la plupart des agents se livrent en paquets npm), Python 3.14, et Docker CE avec buildx et compose, pour quand ton agent décide qu’il lui faut engendrer ses propres containers.
  • Le paquet: aicodebox, le contrat d’adaptateur plus quatre répartiteurs de modes. Du Python pur, zéro effet de bord tant que tu ne démarres pas réellement un mode.
  • L’état: les overrides par chat et l’historique cron vivent sous $HOME/.aicodebox/. Fais-lui un bind-mount si tu veux qu’il survive au container. Le paquet lui-même ne stocke rien.

Le Contrat d’Adaptateur Tient en Deux Méthodes

C’est la partie dont je suis vraiment fier, parce qu’elle est petite. Tout passe par une seule interface, et sa surface obligatoire fait deux méthodes.

from aicodebox.adapters.base import AgentAdapter, RunRequest, RunResult, StreamEvent
class MyAdapter(AgentAdapter):
    name = "my-agent"
    available_models = ["fast", "smart"]
    available_thinking_levels = ["off", "low", "high"]
    def build_argv(self, req: RunRequest) -> list[str]:
        argv = ["my-agent", "-p", req.prompt]
        if req.model: argv += ["--model", req.model]
        if req.workspace: argv += ["--cwd", req.workspace]
        return argv
    def parse_output(self, stdout: str, req: RunRequest) -> RunResult:
        return RunResult(text=stdout.strip(), raw_stdout="", raw_stderr="", exit_code=0)

build_argv dit comment invoquer le truc. parse_output dit comment lire ce qui est revenu. Voilà le contrat. Tout le reste, validate, build_env, translate_auth, post_validate_json, parse_events, parse_stream_event, est optionnel, avec des défauts qui font la chose ennuyeuse et correcte.
parse_stream_event mérite un mot: le défaut c’est « un delta par ligne de stdout », ce qui va très bien pour un binaire qui se contente de cracher de la prose. Ne le surcharge que si ton agent émet un flux d’événements JSON structuré et que tu veux une granularité par token ou par outil dans le streaming OpenAI. La plupart des adaptateurs ne s’en donneront pas la peine.
L’adaptateur est résolu au premier appel et mis en cache pour la durée de vie du processus. Chaque mode tire le même, donc ce que build_argv sait piloter est exactement ce qui est exposé en HTTP, MCP, Telegram et cron. Zéro travail d’intégration par mode. Tu l’écris une fois, tu obtiens cinq portes d’entrée.

Quatre Modes, Tu Poses un Flag

Les modes sont des variables d’environnement. Tu poses le flag, l’entrypoint démarre ce mode. Pas de flag, pas de mode, le container se contente de lancer ton agent en interactif comme un shell normal.
Les modes de premier plan (API, Telegram, Cron) sont mutuellement exclusifs, avec une exception délibérée: Telegram et Cron partagent un processus, parce que le cron tourne en thread à l’intérieur du bot Telegram. L’API gagne si tu la poses à côté d’autre chose. MCP est indépendant et cohabite avec n’importe lequel d’entre eux.

Le mode API

AICODEBOX_API_MODE=1 démarre FastAPI sur :8080.

POST   /run                  # sync run
POST   /run  {"async": true} # returns {runId, status} immediately
GET    /run/result?runId=X   # poll an async run
DELETE /run/{id}             # kill an in-flight run
GET|PUT|DELETE /files/{path} # workspace file CRUD
POST   /v1/chat/completions  # OpenAI-compatible
GET    /v1/models            # model list from the adapter
POST   /mcp                  # MCP, when AICODEBOX_MCP_MODE=1

Un seul flag dicte la forme de la réponse. POST /run sans jsonSchema te donne la version maigre: {runId, workspace, exitCode, text}. Juste la prose. Passe un jsonSchema et tu obtiens toute la surface de diagnostic, text, json, events, sessionId, usage, attempts. Sous le capot l’agent est invoqué en mode json-verbose, sa sortie décodée et validée contre ton schéma.
Ça a été tout le design pendant un moment: schéma veut dire surface complète, pas de schéma veut dire maigre, deux formes de fil et un flag. Ça a tenu jusqu’à ce que ça ne tienne plus. Vouloir le flux brut d’événements sans vouloir aussi un schéma est une vraie envie, et l’ancienne forme t’obligeait à inventer un schéma pour l’obtenir. Donc depuis la v0.14.6 il y a un deuxième bouton, eventMode, et c’est la correction honnête plutôt qu’un flag qui tordrait discrètement le premier: auto garde l’ancien comportement, none jette les événements entièrement, et full te rend les enregistrements du provider intacts dans une enveloppe stable {sequence, attempt, backend, eventType, event}. La rétention des événements est maintenant indépendante du fait que tu aies demandé une sortie structurée. Si une reprise sur schéma brûle trois tentatives, tu obtiens les enregistrements des trois, ce qui est le seul moment où tu les veux vraiment.
Les reprises sont honnêtes sur ce qu’elles coûtent. Sur un échec de parsing ou de validation, le wrapper reprompte jusqu’à trois fois (JSON_RETRY_MAX = 3) avec la mauvaise sortie précédente et l’erreur précise. Si les trois échouent, parseError et jsonRetries remplacent json, mais text, events, sessionId, usage et attempts reviennent quand même, parce qu’un run structuré qui a échoué est exactement le moment où tu as besoin des diagnostics.
Et usage est la somme sur toutes les tentatives, pas la dernière. Ton provider te facture par tentative; ne rapporter que la dernière serait un mensonge. attempts porte le détail par tentative pour que tu puisses afficher « la reprise 2 sur 3 a coûté ça » ou la refacturer.
L’astuce de reprise que je préfère. Les requêtes à schéma qui ne nomment pas de workspace en obtiennent un éphémère par requête sous /tmp/aicodebox/<uuid>/, nettoyé dans un finally. Comme la session persiste, les reprises envoient un prompt correctif minimal, l’erreur, une directive, le schéma, au lieu de rejouer tout ton input d’origine. Sur un gros prompt, ça fait à peu près cent fois moins de coût d’input à la reprise. Si tu passes quand même ton propre workspace, ça retombe sur des reprises en session neuve qui reformulent la tâche: sûr dans n’importe quel workspace, juste plus cher.

L’endpoint compatible OpenAI

POST /v1/chat/completions, avec et sans streaming. Pointe LiteLLM, Open WebUI ou n’importe quel SDK OpenAI dessus et ton agent de code apparaît comme un modèle.
La sortie structurée passe par le champ standard, response_format avec {"type":"json_object"} pour le permissif ou {"type":"json_schema", ...} pour de vraies sorties structurées. Il y a un header x-aicodebox-json-schema en repli pour les clients qui ne savent pas poser le champ du body; le body gagne si les deux sont présents. Reprises épuisées, tu prends un 422. Crash du processus de l’agent, tu prends un 500 avec le code de sortie et stderr dans detail.
Appel d’outils exécuté côté client. Envoie un tableau tools standard et la boîte se comporte comme un simple modèle à function calling: elle répond avec tool_calls et finish_reason: "tool_calls", ton client exécute l’outil et renvoie le résultat role: "tool", la boucle continue. Sans état, tu renvoies tout l’historique à chaque tour, exactement comme chez OpenAI. tool_choice gère auto / none / required / une fonction nommée.
Et tools se compose avec response_format. Un tour d’appel d’outil renvoie des appels d’outils et n’est délibérément pas vérifié contre le schéma; le tour de réponse finale du modèle est validé contre ton schéma avec reprises et revient en JSON canonique. Donc un flux agentique multi-outils peut quand même se terminer par une réponse structurée, ce qui est précisément ce que tu veux quand tu branches ça dans un pipeline.
Une réserve honnête: le streaming avec tools ou response_format est du SSE tamponné. La réponse complète est calculée, puis rejouée en flux d’événements d’un seul coup, chunk de rôle à l’ouverture, un delta, chunk de fin, [DONE]. C’est un flux valide, il n’est juste pas incrémental au token. Le chat simple streame toujours correctement.

Le mode Telegram

AICODEBOX_TELEGRAM_MODE=1 plus un token de bot. Du texte entre, un run d’agent se produit, la réponse revient découpée et rendue du Markdown vers la variante HTML de Telegram. Les envois, documents, photos, vidéos, voix, atterrissent dans le workspace de ce chat. L’agent repousse des fichiers en émettant [SEND_FILE: relative/path] dans sa sortie.
Des overrides par chat pour /model, /effort, /system_prompt et /append_system_prompt, persistés sur disque. /cancel tue le run en cours, /reload relit le yaml, /config vide la config fusionnée, /fetch tire un fichier du workspace, /status liste les chats occupés.
Le détail qui rend ça utilisable au quotidien: répondre à un message déclenché par le cron injecte l’instruction et le résultat de ce job dans le contexte, du coup ta question de suivi a vraiment du sens pour l’agent, au lieu d’arriver sans qu’il ait la moindre idée de ce dont tu parles.

Le mode Cron

Des planifications croniter à six champs, un workspace par job, une notification Telegram optionnelle.

jobs:
  - name: morning-report
    schedule: "0 0 9 * * *"
    instruction: |
      Summarize yesterday's git activity in {workspace}.
    workspace: shared
    telegram_chat_id: -100123
    model: claude-sonnet

Chaque run reçoit son propre dossier d’historique, meta.json, stdout.log, stderr.log, result.txt, et telegram.json s’il a notifié. Puis le prompt du run suivant reçoit un indice pointant vers ce dossier.
Ce dernier bout est petit et il change ce que ces jobs peuvent être. L’agent peut lire sa propre sortie passée sans que tu construises quoi que ce soit de cette plomberie. « Qu’est-ce qui a changé depuis hier », « est-ce que ça a régressé », « compare à la semaine dernière », tout ça marche parce que l’historique est sur disque et qu’on a dit à l’agent où regarder.

Le mode MCP

AICODEBOX_MCP_MODE=1. En mode API il se monte sur /mcp sur le même port, pas de processus en plus. Sous Telegram, cron ou simple passthrough il tourne en sidecar uvicorn sur AICODEBOX_MCP_MODE_PORT (par défaut 8081).
Cinq outils: run_prompt, list_files, read_file, write_file, delete_file. Pointe Claude Desktop, Cursor ou un autre agent dessus et ton agent de code devient un outil que d’autres agents peuvent appeler.
L’auth c’est AICODEBOX_MCP_MODE_TOKEN, son propre bearer, sans repli sur le token de l’API. C’est délibéré. MCP est une surface séparée avec une exposition séparée, et accepter discrètement le bearer de l’API là-dessus reviendrait à donner à chaque client de l’API un outil d’exécution d’agent pour lequel il n’a jamais été habilité. Il y a un paramètre de query ?apiToken= pour les clients incapables de poser des headers.

La Configuration, Avec une Vraie Convention

Tout est en variables d’environnement, et le nommage suit une seule règle: <MODE>_MODE est le flag on/off, <MODE>_MODE_<KNOB> est sa config, et tout ce qui n’est pas rattaché à un mode est nu.

AICODEBOX_ADAPTER            # required — pkg.module:Class
AICODEBOX_AGENT_BINARY       # required — the CLI binary name
AICODEBOX_WORKSPACE          # default /workspace
AICODEBOX_AVAILABLE_MODELS   # required for API mode
AICODEBOX_API_MODE           # 0/1  + _PORT, _TOKEN
AICODEBOX_TELEGRAM_MODE      # 0/1  + _TOKEN, _CONFIG, _OVERRIDES
AICODEBOX_CRON_MODE          # 0/1  + _FILE
AICODEBOX_MCP_MODE           # 0/1  + _PORT, _TOKEN

La liste des modèles se résout en deux temps: AICODEBOX_AVAILABLE_MODELS gagne si elle est posée, sinon ça retombe sur ce que ton adaptateur déclare dans sa variable de classe available_models. Donc la variable d’environnement n’est pas strictement obligatoire, c’est l’override. Déclare la liste dans l’adaptateur et tu n’auras jamais besoin de la poser.
Ce qui est obligatoire, c’est que l’une des deux produise quelque chose. Si les deux reviennent vides, le mode API refuse de démarrer, logue la raison et sort en non-zéro plutôt que de se lancer cassé. C’est la bonne décision: /v1/models a besoin d’une vraie liste et il n’y a pas de repli sûr, parce que le nom de l’adaptateur n’est pas un nom de modèle. Mieux vaut planter bruyamment au boot que servir une liste de modèles pourrie qui casse le client de quelqu’un trois couches plus loin.

Deux images, et les enfants ont arrêté de reconstruire la même chaîne d’outils

La base se livre désormais en deux variantes. psyb0t/aicodebox:latest est la minimale décrite plus haut, et psyb0t/aicodebox:latest-full (taguée en version v0.15.0-full) porte la chaîne d’outils de développement partagée: Go, Node, Python, éditeurs, diagnostics, clients de bases de données, outillage ops.
Cette scission existe à cause d’un problème de duplication qui était discrètement réapparu. Chaque image enfant qui voulait un vrai environnement de développement installait le même Go, le même Node, le même Python, le même psql et redis-cli, depuis son propre Dockerfile et ses propres lockfiles. Trois copies de la chaîne d’outils, qui divergeaient exactement comme divergeaient jadis les trois copies du serveur HTTP. La même maladie, une couche plus bas.
Donc ça a déménagé dans la base. Les enfants en héritent en partant de la variante full au lieu de la construire eux-mêmes, et le nettoyage n’a rien eu de subtil: codexbox a supprimé environ 15 800 lignes de ses propres lockfiles de chaîne d’outils, le Dockerfile.full de claudebox a perdu 173 lignes. make build-full, make build-all et make test-full-image couvrent la variante, et la CI publie d’abord l’image minimale, puis construit la full par-dessus.

La Famille

Trois images tournent sur cette base en ce moment, toutes épinglées pour l’instant sur psyb0t/aicodebox:v0.15.1:

  • claudebox: Claude Code. Sa v2.0.0 a été un rebase complet sur cette base; il contribue maintenant ClaudecodeAdapter et hérite de toutes les surfaces.
  • pibox: pi-coding-agent, pointé sur le LLM que tu veux. C’est l’enfant de référence: il utilise la base telle quelle et ajoute PiAdapter.
  • codexbox: le CLI Codex d’OpenAI, via CodexAdapter.

Comment l’agent est réellement installé

Les trois se trouvent installer leur agent avec npm install -g, mais ne lis pas ça comme la recette, c’est juste que claude-code, pi et codex se livrent tous en globaux npm. La base n’a aucun avis sur la façon dont ton binaire est arrivé là.
Le vrai contrat tient en deux choses: le binaire nommé par AICODEBOX_AGENT_BINARY doit exister sur le PATH, et ton paquet d’adaptateur doit être importable. C’est tout. Ce qui t’y amène te regarde:

  • npm: RUN npm install -g @vendor/[email protected]. Node 24 LTS est déjà dans la base, ce qui est précisément pourquoi les trois enfants existants ont pris cette route.
  • apt: c’est de l’Ubuntu 24.04 avec sudo et un apt qui marche. RUN apt-get update && apt-get install -y your-agent convient très bien si quelqu’un livre effectivement un deb.
  • pip / uv: Python 3.14 est là, et uv aussi, épinglé au digest. C’est déjà comme ça que chaque enfant installe son adaptateur: uv pip install --system --break-system-packages --no-deps /opt/yourpkg.
  • Un binaire compilé: Go, Rust, ce que tu veux. Note que la base ne porte pas de chaîne d’outils Go ou Rust, donc ne t’attends pas à ce que go install marche d’emblée. Soit tu amènes la chaîne d’outils dans ta propre couche, soit tu fais le truc sain et tu fais COPY --from= depuis un stage de build pour que le compilateur n’atterrisse jamais dans l’image de runtime:
FROM golang:1.26 AS build
RUN go install github.com/someone/[email protected]
FROM psyb0t/aicodebox
COPY --from=build /go/bin/some-agent /usr/local/bin/some-agent
COPY myadapter /opt/myadapter
RUN uv pip install --system --break-system-packages --no-deps /opt/myadapter
ENV AICODEBOX_ADAPTER=myadapter.adapter:MyAdapter 
    AICODEBOX_AGENT_BINARY=some-agent

Les mêmes cinq surfaces de l’autre côté. La base n’apprend jamais dans quel langage ton agent a été écrit, et c’est tout l’intérêt, elle ne fait que lancer un nom trouvé sur le PATH et tendre les octets à ton parse_output.
pibox est le minimum honnête. npm install de l’agent, uv pip install du paquet d’adaptateur, exactement trois variables d’environnement à poser, AICODEBOX_ADAPTER, AICODEBOX_AGENT_BINARY et une étiquette PIBOX_IMAGE_VARIANT, et un petit entrypoint brandé qui alias PIBOX_* vers AICODEBOX_*. Voilà toute l’image enfant. Ce n’est pas pour rien qu’elle est l’implémentation de référence.
Les deux autres sont plus grosses, et c’est là que ça devient intéressant. claudebox pose cinq variables d’environnement, codexbox quatre, et toutes deux livrent un script de lancement en plus de l’adaptateur. Pas parce que la base fuit, mais parce que chaque agent traîne son propre problème d’état dont une base agnostique à l’agent n’a pas le droit d’avoir connaissance:

  • claudebox pose CLAUDE_CONFIG_DIR pour que Claude Code écrive .claude.json et ses identifiants sur le bind mount au lieu d’un $HOME non monté, sinon tu refais le choix du thème et le login à chaque recréation de container. Seul claudebox sait que la charge utile est Claude Code, donc seul claudebox peut poser ça.
  • codexbox pose CODEX_HOME pour la même raison (une clé d’API ou un token OAuth d’abonnement ChatGPT qui doit survivre à une recréation) et doit faire un mkdir et un chown dessus au build, parce que codex plante au démarrage si CODEX_HOME pointe vers un dossier qui n’existe pas déjà.
  • Les deux pointent AICODEBOX_AGENT_BINARY vers un script de lancement plutôt que vers le binaire de l’agent directement. Celui de claudebox restaure les défauts interactifs que la base agnostique à l’agent a délibérément laissés tomber: --continue avec un repli, --permission-mode bypassPermissions, et l’injection always-skills via --append-system-prompt. Les modes serveur contournent entièrement le lanceur et construisent argv par l’adaptateur.

Voilà la vraie forme de cette abstraction, et je préfère la décrire avec exactitude plutôt que faire semblant que chaque enfant tient en quatre lignes. La base possède tout ce qui n’est pas spécifique à l’agent. Ce qui reste dans chaque enfant est exactement ce qui l’est: l’adaptateur qui sait que ce binaire veut -p pendant que celui-là veut --prompt, que l’un émet du stream-json pendant qu’un autre imprime du texte brut, plus la lubie de persistance de config que cet agent-là t’inflige.
Tout le reste, l’API, le shim OpenAI avec ses reprises sur schéma et son appel d’outils, le bot Telegram, le scheduler cron avec son historique, le serveur MCP, est hérité. Tu corriges un bug dans la base, tu tagues, tu montes l’épingle dans trois enfants, terminé. Ce qui est tout l’intérêt, et la raison pour laquelle claudebox v2 existe.

En Lancer Une

docker run --rm -p 8080:8080 
  -e AICODEBOX_API_MODE=1 
  -e AICODEBOX_API_MODE_TOKEN=$(openssl rand -hex 16) 
  -e AICODEBOX_AVAILABLE_MODELS=fast,smart 
  -v "$PWD/workspace:/workspace" 
  your/child-image:latest

Ensuite tu lui parles comme si c’était OpenAI, ou tu tapes /run directement, ou tu pointes un client MCP dessus, ou tu ne touches jamais à HTTP et tu laisses simplement le cron le déclencher à neuf heures du matin.

La boucle de redémarrage du container qui ne ressemblait à rien du tout

Le meilleur bug que ce truc ait produit, parce que chaque signal qu’il te donnait était un mensonge.

Symptôme: en mode api le container redémarre périodiquement. Pas de crash, pas d’OOM, pas de stack trace. Le code de sortie est 0. La boucle est corrélée à l’activité des requêtes plutôt qu’à la mémoire ou à l’uptime, ce qui t’envoie chercher complètement à côté.

Ce qui se passait vraiment: le sous-processus de l’agent partageait le groupe de processus de ce qui avait lancé le serveur. En mode api le serveur est PID 1. Donc quand le CLI de l’agent, ou n’importe quel outil engendré par l’agent lui-même, délivrait un SIGTERM ou un SIGINT à ce groupe partagé, uvicorn l’attrapait aussi et s’arrêtait. Proprement. Délibérément. Sortie 0, travail terminé.

Et tu ne le voyais jamais, parce que uvicorn.run(..., log_config=None) supprime ses propres lignes d’arrêt. Un arrêt net, silencieux et parfaitement correct, que la politique de redémarrage du container défaisait ensuite, encore et encore.

Le correctif tient en un flag dans aicodebox/shared/runner.py, start_new_session=True sur l’engendrement de l’agent, à la fois sur le chemin synchrone run() et sur le chemin streaming run_stream(). Session propre, groupe de processus propre, les signaux cessent de remonter.

Des tests de régression espionnent les deux primitives d’engendrement et vérifient que le flag est bien passé, si bien que le retirer de l’un des deux endroits casse le build au lieu de réintroduire en silence une boucle que personne ne peut lire dans les logs.

Épingle la v0.14.5, pas la v0.14.4. Même correctif, mais pyproject.toml était resté à 0.14.0 de la v0.14.1 à la v0.14.3 alors que le Makefile dérive le tag de l’image depuis lui, et la v0.14.5 est la republication où le fichier et le tag finissent par s’accorder.

En Résumé

L’agent que tu utilises aujourd’hui n’est pas celui que tu utiliseras dans un an. Ce n’est pas du pessimisme, c’est juste le rythme des sorties, cet espace se réécrit tous les quelques mois, et tout ce que tu construis étroitement couplé à un seul CLI est un échafaudage avec une date de péremption.
Donc ne t’y couple pas. Mets les surfaces dans une image de base, mets l’agent derrière un adaptateur à deux méthodes, et quand le suivant arrive tu écris quarante lignes au lieu d’en forker neuf cents. Je l’ai fait trois fois maintenant. La troisième a pris un après-midi.
github.com/psyb0t/docker-aicodebox
Sous licence WTFPL, parce qu’une image de base qui existe précisément pour que tu n’aies pas à la forker serait une chose stupide sur laquelle coller une licence restrictive.