SLOPSHOPPER

opencode delegate

Delegue des taches a opencode 2 comme sous-agent, avec un pane de suivi

paneguardcommandtoastprompttool
v0.9.0MITupdated 2026-10-19PaulThiberville/opencode-delegate
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · opencode-delegate
│ ┃ opencode ✕ › fix the failing auth test and add an audit log call │ ┃ Delegation indisponible │ ┃ ● opencode-delegate: delegation vers opencode desactivee : `opencode │ ┃ `opencode --version` ne donne pas de numero ⏺ Read(src/auth.ts) │ ┃ de version lisible ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Installer opencode 2.x ⎿ Added 2 lines, removed 1 line │ ┃ (https://opencode.ai), puis relancer la ⏺ Bash(bun test) │ ┃ session. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /opencode │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · opencode
Delegation indisponible `opencode --version` ne donne pas de numero de version lisible Installer opencode 2.x (https://opencode.ai), puis relancer la session.
README

opencode-delegate

Mod Claude Code qui utilise opencode 2 comme sous-agent.

Ce que ça fait

  • Sonde opencode --version au démarrage de la session et n'enregistre l'outil que si opencode est en 2.x. Sans ça, rien n'est proposé à Claude.
  • Enregistre un outil mcp__opencode-delegate__delegate que Claude peut appeler comme un Task, avec prompt, writeFiles, model, agent, dir, resume, budgetMs, background.
  • Lance opencode run --format json -m <model> via $.process.run (plus --session <id> sur un resume), en --agent plan (lecture seule) par défaut, et seulement avec --auto quand l'utilisateur vient d'autoriser l'écriture. En v2, opencode run parle au service de fond : pas de cold start à chaque tâche.
  • Pendant le run, un timer interroge opencode api session.get toutes les 2 s pour remonter coût, tokens et titre dans le pane.
  • /opencode ouvre le pane : choix du modèle, liste des tâches, totaux.
  • Annonce la capacité aux agents dans le contexte du premier message, pour que Claude y pense sans qu'on ait à le lui rappeler.
  • Rend à Claude la réponse finale d'opencode + l'id de session, à repasser dans resume pour un suivi sur la même conversation opencode.
  • Nomme dans chaque résultat où et sur quoi la tâche a tourné : répertoire effectif, commit de départ, état du checkout, et un avertissement quand le checkout est en retard sur son amont.
  • Quand la tâche s'arrête sans conclure, dit pourquoi, rend quand même ce qu'opencode avait déjà produit, et rend l'id de session pour reprendre par resume : voir Quand la tâche s'arrête mal.
  • Avec background: true, rend la main tout de suite et ramène le compte rendu plus tard, dans un tour à lui, pour que l'orchestrateur reste disponible pendant qu'opencode travaille : voir La délégation en arrière-plan.
  • Enregistre un second outil mcp__opencode-delegate__delegations qui regarde ces délégations sans rien relancer : action: "list" pour celles de la session, action: "log" pour le journal de l'une d'elles : voir Voir ses propres délégations.
  • Accepte un schema sur une délégation pour recevoir des données plutôt qu'un paragraphe — en disant exactement ce que ça garantit, et ce que ça ne garantit pas : voir La sortie en JSON.

Le pane

Modele  opencode/glm-5.3-flash-free

Autre   provider/model (458 au catalogue)

En cours 1 ⌄
╭──────────────────────────────────────────╮
│ Parité clés i18n fr/en apps/mobile  6m02 │
│ glm-5.3-flash-free  12.4k tokens         │
│ 15 appels · 2 reprises · 1 coupure ⌄     │
│   read taxonomy.ts                  420ms│
│   grep (error)                         3s│
╰──────────────────────────────────────────╯

Terminees 3 ›

4 taches · $0.0043

Une carte par session opencode, pas par appel : une reprise avec resume repasse la même carte en « En cours » et lui ajoute un segment, au lieu d'en créer une seconde au même titre. Trois lignes, sur le modèle du panneau « tâches en arrière-plan » de Claude Code : le titre de la session et sa durée cumulée, rien d'autre : le budget restant ne faisait qu'inquiéter pour rien, puisqu'une coupure se reprend par resume ; le modèle et les tokens consommés sur toute la session ; puis le nombre d'appels d'outils, dépliable pour voir chacun avec sa durée. Le début du prompt n'est jamais affiché — c'est du transcript, illisible en une ligne ; tant qu'opencode n'a pas nommé sa session, le modèle prend la place du titre, et la seconde ligne ne le répète alors pas. Ce titre vient d'opencode, pas du mod, qui ne fait que le lire : sa génération échoue par intermittence, surtout sur les modèles gratuits, donc une carte sans titre n'a rien d'anormal. La seconde ligne ne mentionne ecriture que lorsque l'écriture a été autorisée, la lecture seule étant le défaut, fond pour une délégation lancée en arrière-plan — dire où son résultat va arriver, puisqu'il ne revient pas à l'appel —, et la cause d'arrêt seulement quand il y en a une. La troisième compte les reprises et les coupures, pour qu'une session recousue garde la trace de ce qui l'a interrompue. Ni bordure colorée ni glyphe : la section dit déjà si la tâche tourne, et le seul état qui doit sauter aux yeux est l'échec — il passe donc par le titre, en rouge.

Le journal des appels se remplit pendant que la tâche tourne : il est relu dans la session opencode toutes les deux rondes de suivi, parce que le flux de opencode run, lui, n'arrive qu'à la sortie du processus. Un outil qui n'a pas abouti porte son statut dans son libellé (grep (error)).

Une tâche mal arrêtée porte en seconde ligne la cause de son arrêt (budget, abandon, fournisseur, erreur, demarrage, rechargement).

Le sélecteur Modele liste les modèles récents d'abord, puis ceux des mêmes fournisseurs, puis le reste du catalogue d'opencode models, chargé en tâche de fond au démarrage de la session. Le champ Autre accepte n'importe quel provider/model et le refuse s'il n'est pas dans ce catalogue, ce qui évite les fautes de frappe.

La liste est coupée à 64 entrées : un Select n'accepte que 1 à 64 options, et au-delà le moteur refuse l'arbre entier et dessine un pane vide à la place. C'est pour ça que le champ de saisie libre existe : le catalogue d'opencode compte environ 500 modèles. tests/surface.test.ts monte le pane avec un catalogue de 501 entrées sur les trois surfaces pour que la régression ne revienne pas.

Le modèle choisi devient celui des délégations suivantes. Il est gardé dans $.store, partagé par toutes les sessions de la machine, donc il survit au redémarrage. L'argument model de l'outil reste prioritaire sur ce choix, et la variable d'environnement OPENCODE_DELEGATE_MODEL, si elle est posée, l'emporte au démarrage de la session.

Préflight

session.start lance opencode --version ($.process.run, 5 s de budget, try/catch parce que l'appel rejette si le binaire ne démarre pas) et lit le numéro de version. Si opencode manque, répond en erreur, ou est en 1.x :

  • l'outil n'est pas enregistré ;
  • le bloc prompt.context qui annonce la capacité n'est pas ajouté, sinon on promettrait à Claude un outil qui n'existe pas ;
  • une ligne $.ui.log dit une fois ce qui manque et quoi faire ;
  • le catalogue opencode models n'est pas chargé ;
  • /opencode reste enregistrée, et son pane explique la situation au lieu d'afficher un sélecteur de modèle et une liste vide trompeuse.

Le préflight bloque le premier prompt, donc son budget est court. Il tourne une fois par session : une installation d'opencode faite en cours de session n'est vue qu'à la session suivante.

Quand la tâche s'arrête mal

$.process.run ne streame pas : il lit toute la sortie à la fin. Et quand il tue le processus — budget épuisé, évènement abandonné — il rejette sans rien rendre du tout. Sept minutes de lecture pouvaient donc se solder par une ligne d'échec, et, pire, par une ligne d'échec qui ne disait pas laquelle.

La cause est nommée

Six causes, qui ne se traitent pas de la même façon. Le résultat rendu à Claude s'ouvre sur la phrase qui va avec, et la carte du pane porte le mot :

| Carte | Ce qui s'est passé | Ce que le résultat conseille | | :- | :- | :- | | budget | le processus tournait encore quand budgetMs a été atteint | relancer avec un budgetMs plus large, ou découper | | abandon | next.signal s'est abattu : tour interrompu, ou moteur qui a coupé | ne pas relancer de soi-même | | fournisseur | une erreur provider.* dans le flux (clé, quota, modèle) | régler le fournisseur d'abord : relancer à l'identique redonnera la même erreur | | erreur | opencode s'est arrêté en erreur sur la tâche | lire le détail | | demarrage | la commande n'a pas pu démarrer, ou est morte bien avant son budget | vérifier l'installation, rien n'a tourné | | rechargement | le module a été remplacé pendant une délégation de fond : son minuteur est mort avec lui | reprendre par resume, le travail a sans doute continué |

Les trois premières sont celles qui coûtaient cher à confondre. Elles se distinguent par deux signaux différents, et non par le message d'erreur :

  • l'abandon, par next.signal, l'AbortSignal du hook, qui s'abat quand l'évènement est abandonné ;
  • le budget, par un $.clock.after(budgetMs) armé avant le lancement : s'il s'est déclenché avant que $.process.run rejette, c'est que le processus a tenu jusqu'au bout. Comparer des Date.now() n'aurait pas suffi — une commande qui refuse de démarrer rejette aussi, et c'est un tout autre diagnostic.

La sortie partielle est rendue quand même

Le flux est perdu, mais la session d'opencode a tout gardé : tuer le CLI opencode run ne tue pas la session côté service de fond. Le mod retrouve donc la session (opencode api session.list, comme pour le suivi live) et la relit par opencode api session.message.list --param sessionID=<id> pour en sortir :

  • le dernier texte produit par l'assistant, rendu tel quel à Claude ;
  • le journal des outils appelés, dans l'ordre, avec leur statut quand il n'est pas completed — le minimum utile quand il n'y a pas de texte ;
  • la dernière erreur enregistrée par la session, s'il y en a une.

La forme de cette réponse n'est pas celle de session.get : c'est { data: [messages] }, du plus récent au plus ancien ; un message idle porte outcome et, en cas d'échec, error ; un message assistant porte content, où une part de texte est { type: 'text', text } et une part d'outil { type: 'tool', name, state: { status } } — name, et non tool comme dans le flux NDJSON. Les messages arrivent à l'envers du temps mais les parts d'un message sont dans l'ordre, d'où le regroupement par message avant inversion : sans ça le journal d'outils se lit à l'envers.

Quand la session ne garde aucun texte, le résultat le dit, au lieu de laisser croire qu'opencode n'a rien trouvé.

Et comment reprendre

Un échec rend l'id de session opencode et dit explicitement que resume le reprend. Ça a l'air secondaire, c'est ce qui change le plus : le plafond de dix minutes porte sur un appel de $.process.run, pas sur une tâche. La session opencode garde tout son historique, donc une tâche longue se mène en plusieurs reprises — à condition que Claude ait l'id. Sans lui, la chaîne casse à la première coupure et le mod a l'air incapable de tenir une tâche longue, alors qu'il attendait juste qu'on lui repasse l'id.

Quand la session n'a pas été retrouvée, le résultat le dit aussi, plutôt que de laisser croire à une reprise possible : il n'y a alors rien d'autre à faire que relancer depuis le début. Les deux cas sont verrouillés par un test.

Cas particulier de l'abandon : un tour abandonné a déjà repris sans nous, et le résultat de l'outil ne va plus nulle part. Le texte retrouvé est donc aussi recopié dans le transcript par $.ui.log — la seule voie qui reste pour rendre à l'utilisateur ce qu'il a payé.

Où et sur quoi la tâche a tourné

Chaque résultat, succès compris, se termine par une ligne du genre :

ou: /Users/paul/projet @ 4bf7906ab12c (main) checkout sale

Le répertoire effectif (obtenu par un pwd dans le cwd visé, ce qui résout un dir relatif), le commit de départ, la branche, et l'état sale du checkout. Quand le checkout est en retard sur son amont, une seconde ligne le dit et invite à pondérer la conclusion.

C'est le correctif d'un cas réel : un agent a conclu de travers parce qu'opencode lisait un checkout en retard, sans que personne puisse s'en apercevoir. Attention, git rev-list --count HEAD..@{upstream} mesure le retard sur l'amont tel que le dernier fetch l'a laissé, pas sur le dépôt distant maintenant : le mod ne fetche pas tout seul.

La délégation en arrière-plan

Par défaut, l'appel attend la fin de la tâche : c'est le bon mode quand Claude a besoin de la réponse d'opencode pour raisonner dans le même tour. Mais pendant ce temps l'orchestrateur est bloqué, et l'utilisateur avec lui.

background: true renverse ça :

delegate({ prompt: "...", background: true })
  → rend la main immédiatement, avec un accusé qui dit de ne pas attendre
  → le travail part dans un $.clock.after(0, …), hors de tout événement
  → la carte du pane vit exactement comme pour une délégation bloquante
  → à la fin, $.prompt.submit() ramène le compte rendu dans un tour à lui

Le compte rendu différé est le même que celui du mode bloquant : réponse d'opencode, id de session à repasser dans resume, répertoire, commit de départ, durée, et la cause quand la tâche s'arrête sans conclure. Il arrive précédé d'une phrase qui dit à Claude que c'est un résultat différé, qu'il n'y a rien à relancer et que l'outil ne doit pas être rappelé pour cette tâche.

Ce n'est pas un changement de défaut, et l'argument s'appelle background plutôt qu'un nom déjà pris : les arguments de l'outil arrivent à plat sur e dans un hook tool.call, comme dir, resume et budgetMs.

Trois réserves, et ce que le mod en fait.

1. Un rechargement du mod tue les minuteurs

Une mise à jour du plugin, un /reload-plugins, une perte du worker de hooks : le module est remplacé, et $.clock « annule ses attentes en cours avec l'ancien environnement ». Une délégation de fond en vol perdrait donc son minuteur : personne ne notifierait, et sa carte resterait « en cours » à mentir indéfiniment.

Le mod ne laisse pas cet état mentir. Au lancement d'un job de fond, il écrit un marqueur durable dans $.store — une clé par session Claude, bg:<id de session>, parce que le store est partagé entre toutes les sessions. Le marqueur suit la tâche : id de session opencode dès qu'elle est retrouvée, état du checkout, battement toutes les 30 s pour savoir jusqu'à quand la tâche tournait. Il est effacé dès que la tâche conclut.

session.start repasse à chaque chargement du module. Le mod y relit les marqueurs de sa session : ceux qui restent sont des jobs qui tournaient quand le module est tombé. Pour chacun, il reconstruit une carte dans le pane avec la cause rechargement — jamais « en cours » —, relit la session opencode pour rendre ce qu'elle avait gardé, et notifie comme une fin normale, avec l'id à repasser dans resume. Passé le budget du job plus deux minutes, il n'ouvre plus un tour pour en parler (la tâche a pu finir toute seule depuis longtemps) : la carte et une ligne de transcript suffisent. Au-delà de deux heures, le marqueur est périmé et jeté sans un mot. Les marqueurs des autres sessions sont laissés tranquilles, sauf abandon de plus de deux heures, qui fait le ménage.

Ce balayage tourne dans le hook de démarrage, avant que l'outil ne soit enregistré, et pas dans un minuteur : sinon une délégation lancée pendant le balayage écrirait son marqueur dans la même clé et serait prise pour un orphelin. C'est arrivé, et un test couvre le cas. Le compte rendu, lui, part hors de l'événement — notifier attend que la session soit inactive.

Et le processus opencode lancé par $.process.run ? Il n'est pas tué par le rechargement. Le .d.ts publié ne lie la vie de l'enfant au module que pour $.process.spawn (« le déchargement du module tue l'enfant ») ; pour run, un appel one-shot exécuté par l'hôte, il ne dit rien de tel. L'enfant continue donc jusqu'à son terme ou son budget, côté hôte, mais sa sortie ne va plus nulle part : plus personne ne lit son NDJSON. Le travail n'est pas perdu pour autant — la session opencode en garde l'historique —, d'où le conseil de reprendre par resume plutôt que de relancer la tâche depuis le début.

2. L'écriture est lue à l'appel, jamais au lancement du job

L'interrupteur du pane est lu dans le hook tool.call, et le booléen qui en sort est figé dans la spécification du job. Un job différé ne relit jamais l'interrupteur : sinon une délégation partie en lecture seule se mettrait à écrire parce que l'utilisateur a armé l'écriture entre l'appel et le lancement effectif, c'est-à-dire sans avoir rien autorisé pour cette tâche-là.

Le refus, quand writeFiles: true arrive sur un interrupteur désarmé, reste donc immédiat et synchrone, background ou pas : rien ne part, et le résultat dit quoi faire. Deux tests tiennent les deux sens : armer après coup ne débloque rien, et une tâche de fond partie en lecture seule reste en --agent plan sans --auto même si l'interrupteur s'arme pendant qu'elle attend son tour.

Pour la même raison, le répertoire de la session est lu dans le hook et non dans le job : le .d.ts prévient qu'« un minuteur qui survit à son hook lit le répertoire de la session » au moment où il tourne, pas celui de l'appel.

3. Plusieurs notifications : un seul tour, groupé

$.prompt.submit démarre un tour à lui une fois la session inactive, et résout au démarrage de ce tour, pas à sa fin. Deux délégations de fond qui finissent presque ensemble enchaîneraient donc deux tours, le second coupant ce que l'utilisateur vient de commencer après avoir lu le premier.

Le mod groupe : un compte rendu prêt entre dans une file, et une fenêtre de 1,5 s laisse le temps à un voisin de la rejoindre, puis toute la file part dans un seul $.prompt.submit, chaque rapport sous un en-tête === délégation n/N ===. Le drain boucle, donc un job qui finit pendant l'attente de l'inactivité fera un second tour, groupé de la même façon.

Le choix est pour le groupage et pas pour deux tours parce que les deux comptes rendus servent le même raisonnement : Claude a besoin de les voir ensemble, et l'utilisateur d'être interrompu une fois plutôt que deux. Si la notification ne peut pas entrer (prompt refusé, session qui se ferme), le compte rendu est écrit dans le transcript par $.ui.log — la seule voie qui reste pour rendre ce qui a été payé.

Voir ses propres délégations

Depuis que l'arrière-plan existe, Claude pouvait lancer des délégations de fond puis n'avait plus aucun moyen de savoir ce qui tournait : l'utilisateur voyait le pane, lui était aveugle. Son équivalent natif est $.agent.list().

mcp__opencode-delegate__delegations comble ça. Il ne lance rien, ne paye rien, et action: "list" ne fait aucun appel de CLI — tout est déjà en mémoire dans le mod.

action: "list"

Rend les délégations de la session, la plus récente d'abord : statut, modèle, id de session opencode, durée, coût, tokens, nombre d'appels d'outils, et pour une tâche de fond si son compte rendu a déjà été remis ou non.

2 delegations opencode dans cette session, la plus recente en premier.
1 tourne encore.

Une delegation « en cours » est deja en vol : ne pas relancer la meme tache,
elle serait payee deux fois. Si sa reponse est necessaire pour la suite du
raisonnement, attendre son compte rendu plutot que de la relancer.

#1  en cours  ·  glm-5.3-flash-free  ·  session opencode: ses_eac6810…
    6m02 · $0.0031 · 12.4k tokens · 15 appels d'outils · lecture seule
    fond : le compte rendu arrivera tout seul dans un tour suivant, rien a
    attendre ni a relancer
    tache : parite des cles i18n fr/en dans apps/mobile

Deux choses que ça débloque, et que tests/visibility.test.ts vérifie :

  • ne pas relancer une tâche déjà en vol. Une délégation en cours est reconnaissable avant d'être relancée, et le texte le dit explicitement : la relancer la ferait payer deux fois.
  • pouvoir décider d'attendre. Entre l'instant où une tâche de fond finit et celui où son compte rendu entre dans un tour, il y a la fenêtre de regroupement : la liste dit pas encore remis, il arrive dans un tour suivant, puis deja remis dans un tour precedent. Claude peut donc attendre au lieu de repayer. Le cas dégradé est dit aussi : un compte rendu qui n'a pu aller que dans le transcript (claude -p, Agent SDK, prompt refusé) est annoncé comme tel, parce qu'il n'entrera jamais dans un tour.

action: "log"

Avant, Claude ne récupérait que le texte final. Pour savoir quels fichiers avaient été lus ou quelle piste avait été abandonnée, il devait relancer la tâche et la repayer. session.message.list donne déjà tout, et recoverPartial savait déjà le lire : log réutilise cette lecture, étendue pour rendre le journal complet en plus de la tranche bornée que les appelants d'avant utilisent. Une seule façon de lire session.message.list dans le mod, donc une seule à pouvoir se tromper.

Journal de la session opencode ses_eac6810…, relu sans rien relancer ni repayer.
95 appels d'outils au total, affiches 1-40 (du plus ancien au plus recent).

  1. read taxonomy.ts  420ms
  2. grep i18n (error)  3s
  …

Ce qu'opencode a dit (2 prises de parole, de la plus ancienne a la derniere) :

--- 1 ---
piste abandonnee : la taxonomie ne porte pas les cles
…

Journal tronque : il reste 55 appels d'outils. Pour la suite, rappeler cet outil
avec `action: "log"`, `sessionId: "ses_eac6810…"`, `offset: 40`.

Le volume est le vrai risque : une session longue peut rendre énormément, et le résultat d'un outil entre dans le contexte de Claude. Donc : fenêtre de 40 appels d'outils, 6 000 caractères au total, 1 500 par prise de parole. La sortie dit quand elle est tronquée et donne l'offset pour la suite ; toolsOnly: true laisse les textes de côté quand seuls les fichiers lus comptent. Les textes passent après les appels parce que c'est la partie qui peut exploser, et donc celle qu'on sacrifie quand la place manque.

Un id qui n'existe pas, ou un service de fond muet, ne donne pas un faux journal vide : le résultat dit que la relecture a échoué, et que rien n'a été relancé.

Un seul outil, qui porte une action

La description d'un outil enregistré est relue à chaque tour, dans toutes les sessions de tous ceux qui installent le mod. Celle de delegate est déjà longue. Deux outils de plus auraient voulu dire deux descriptions à payer en permanence, pour deux capacités qui répondent de toute façon à la même question : « qu'est-ce que j'ai délégué, et qu'en est-il ? ». D'où un seul outil portant une action, et des descriptions serrées.

La sortie en JSON

L'idée : qu'un orchestrateur reçoive {"fichiers": [...], "risques": [...]} plutôt qu'un paragraphe à relire. Avant d'implémenter quoi que ce soit, la question était de savoir par où c'était possible. La mesure a tranché, et elle a écarté les deux voies par l'API.

Ce que la mesure a montré

Sur opencode v2.0.32, mesuré avec de vraies commandes :

  • Le corps de session.prompt est {id, text, files, agents, skills, metadata, delivery, resume}, en additionalProperties: false. Il n'y a aucun champ de schéma. Le format documenté côté SDK n'existe pas sur cette surface HTTP.
  • Pire : un format envoyé quand même repart en 200, et le message stocké ne garde que {"text": "…"}. Le serveur jette le champ en silence. Un mod bâti là-dessus aurait cru demander un schéma sans jamais être prévenu du contraire. Même chose pour response_format et outputSchema.
  • session.get rend id, projectID, cost, tokens, outcome, time, title, location : pas de structured_output. La chaîne « structured » n'apparaît nulle part dans les 818 schémas de l'OpenAPI du serveur.
  • opencode run n'a pas de drapeau de schéma — --format ne vaut que default ou json, c'est le rendu.
  • Et pour mémoire, reconfirmé au passage : une session créée par session.create depuis /tmp se voit attribuer location.directory: /Users/paul, le répertoire du service de fond.

Donc les deux premières voies ne meurent même pas sur la question du répertoire de travail : elles meurent avant, faute de champ pour porter le schéma. La troisième voie — demander le JSON dans le prompt et le lire nous-

Source 1 files
hooks/register.js 2157 lines
1// opencode-delegate: donne a Claude Code un outil qui delegue une tache a
2// opencode 2, et un pane qui suit les taches et choisit le modele.
3//
4// Le hook `tool.call` lance `opencode run --format json`, qui parle au service
5// de fond d'opencode 2 (pas de cold start). Pendant le run, un timer interroge
6// `opencode api session.get` pour alimenter le pane : statut, cout, tokens.
7
8const PANE = 'opencode-delegate'
9const TOOL = 'mcp__opencode-delegate__delegate'
10// Un seul outil de plus, qui porte une action : sa description est relue a
11// chaque tour dans toutes les sessions de tous ceux qui installent le mod, et
12// deux outils voudraient dire deux descriptions a payer.
13const LOOK = 'mcp__opencode-delegate__delegations'
14
15// `$.process.run` plafonne a dix minutes, et c'est aussi notre plafond de tache
16const MAX_TIMEOUT_MS = 600_000
17const DEFAULT_TIMEOUT_MS = 300_000
18const POLL_MS = 2_000
19
20// Modele de repli, quand le store et l'environnement ne disent rien
21const FALLBACK_MODEL = 'ollama-cloud/gpt-oss:20b'
22const MAX_RECENT_MODELS = 6
23// Un Select n'accepte que 1 a 64 options : au-dela, l'arbre est refuse et le
24// moteur dessine son propre pane vide. Le champ de saisie couvre le reste.
25const MAX_OPTIONS = 64
26
27// Version minimale d'opencode : `opencode run --format json` et `opencode api`
28// n'existent qu'en 2.x
29const MIN_MAJOR = 2
30// Le preflight bloque le premier prompt, donc il tient en quelques secondes
31const PROBE_MS = 5_000
32
33
34// L'agent opencode en lecture seule, le mode par defaut de toute delegation
35const PLAN_AGENT = 'plan'
36
37// Relire la session apres une coupe, et sonder le checkout, prend quelques
38// appels de CLI : des budgets courts, pour ne pas ajouter une attente a une
39// tache qui vient deja de mourir d'attente
40const RECOVER_MS = 15_000
41const GIT_MS = 10_000
42// Le journal d'outils rendu apres une coupe : assez pour voir ce qu'opencode a
43// lu, pas assez pour noyer le resultat
44const MAX_RECOVERED_TOOLS = 20
45// Le journal relu par l'outil de relecture entre dans le contexte de Claude :
46// une session longue en rendrait des milliers de lignes. On borne donc la
47// fenetre, et le resultat dit comment demander la suite.
48const MAX_LOG_TOOLS = 40
49const MAX_LOG_CHARS = 6_000
50const MAX_LOG_TEXT = 1_500
51// Ce qu'on recopie de la reponse brute quand le JSON demande n'est pas venu :
52// assez pour que le travail paye ne soit pas perdu
53const MAX_RAW_ANSWER = 2_000
54// Ce qu'on recopie du texte retrouve dans le transcript, quand c'est la seule
55// voie de sortie (tour abandonne : le resultat de l'outil ne va plus nulle part)
56const LOG_EXCERPT = 1_200
57
58// Les marqueurs des delegations de fond en vol, dans le store, une cle par
59// session Claude : le store est partage entre toutes les sessions, et une cle
60// unique les ferait s'ecraser l'une l'autre
61const JOB_PREFIX = 'bg:'
62// Deux jobs qui finissent presque ensemble ne notifient qu'une fois : chaque
63// `$.prompt.submit` attend que la session soit inactive, donc deux appels font
64// deux tours de suite. On laisse donc une courte fenetre de regroupement avant
65// de notifier, puis on vide toute la file dans un seul compte rendu.
66const GROUP_MS = 1_500
67// Le marqueur du job se reecrit quand quelque chose change (session retrouvee,
68// checkout sonde) et sinon a ce rythme : de quoi savoir, au rechargement, jusqu'a
69// quand la tache tournait, sans reecrire le store a chaque tour de suivi
70const HEARTBEAT_MS = 30_000
71// Un marqueur plus vieux que ca ne dit plus rien d'utile sur une tache : le
72// processus est mort depuis longtemps et personne ne peut plus dire comment
73const STALE_MS = 2 * 60 * 60 * 1_000
74// Passe son budget, une tache de fond perdue par un rechargement a pu finir
75// toute seule : au-dela de cette marge on n'ouvre plus un tour pour elle, on
76// l'ecrit seulement dans le transcript et dans le pane
77const RELOAD_GRACE_MS = 120_000
78
79// Pourquoi une tache s'est arretee. Les causes ne se traitent pas pareil : un
80// budget epuise invite a relancer plus large, un abandon pas du tout, une
81// erreur de fournisseur demande de regler le fournisseur d'abord. Le mot sert
82// aussi de libelle dans la carte du pane.
83const STOPS = {
84  budget: {
85    word: 'budget',
86    line: (task) =>
87      'opencode a ete coupe par le mod : le budget de ' +
88      clock(Math.round(task.timeoutMs / 1000)) +
89      ' est epuise. Ce n\'est ni une erreur d\'opencode ni un abandon : la tache tournait encore. ' +
90      'Relancer avec un `budgetMs` plus large (600000 au plafond), ou decouper la tache en morceaux.',
91  },
92  abandoned: {
93    word: 'abandon',
94    line: () =>
95      "Le tour a ete abandonne pendant la delegation (l'utilisateur a interrompu, ou le moteur a coupe) : " +
96      "ce n'est pas un depassement de budget. Ne pas relancer de soi-meme ; attendre que l'utilisateur redemande.",
97  },
98  provider: {
99    word: 'fournisseur',
100    line: () =>
101      "opencode a echoue cote fournisseur de modele, pas sur la tache. Relancer a l'identique redonnera la " +
102      'meme erreur : regarder la cle, le quota ou le modele avant de reessayer.',
103  },
104  run: {
105    word: 'erreur',
106    line: () => "opencode s'est arrete en erreur pendant la tache.",
107  },
108  stopped: {
109    word: 'arrete',
110    line: () =>
111      "L'utilisateur a arrete la delegation depuis le pane `/opencode`. Ce n'est ni un echec d'opencode " +
112      "ni un budget epuise : la tache a ete coupee a la main. Ne pas la relancer de soi-meme ; attendre " +
113      "qu'il redemande, et reprendre avec `resume` s'il le demande.",
114  },
115  start: {
116    word: 'demarrage',
117    line: () =>
118      "La commande `opencode` n'a pas pu demarrer, ou est morte bien avant son budget : rien n'a tourne. " +
119      'Verifier l\'installation d\'opencode plutot que relancer.',
120  },
121  // Une delegation de fond, et elle seule, peut mourir de ca : son minuteur
122  // vit dans le module, qu'un rechargement remplace
123  reload: {
124    word: 'rechargement',
125    line: () =>
126      'Le mod a ete recharge pendant que cette delegation de fond tournait (mise a jour du plugin, ' +
127      '`/reload-plugins`, ou perte du worker de hooks) : le minuteur qui la suivait est mort avec ' +
128      "l'ancien module, donc personne n'a recu la fin de la tache. Ce n'est ni un echec d'opencode ni " +
129      'un abandon : le travail a sans doute continue, et la session opencode en garde la trace. ' +
130      'Reprendre par `resume` plutot que relancer la tache depuis le debut.',
131  },
132}
133
134// Les taches de la session, la plus recente en premier
135let tasks = []
136let seq = 0
137
138// Le modele que prennent les nouvelles delegations, choisi dans le pane
139let defaultModel = FALLBACK_MODEL
140// Les derniers modeles utilises, partages entre sessions par $.store
141let recentModels = []
142// Le catalogue d'`opencode models`, charge en tache de fond pour valider une saisie
143let catalog = null
144// Ce que le champ de saisie a repondu en dernier
145let notice = null
146// Quelles sections du pane sont repliees
147let folded = { running: false, done: false }
148// Quelles cartes montrent le detail de leurs appels d'outils
149let openCalls = {}
150// L'ecriture est-elle armee ? Volontairement au niveau module, donc remise a
151// faux a chaque session : garder l'interrupteur arme d'une session a l'autre
152// ferait d'un geste explicite une permission permanente, ce qui n'est plus un
153// interrupteur. Il se bascule dans le pane, a cote du selecteur de modele.
154let writeArmed = false
155// Ce que le preflight a trouve ; `reason` est deja une phrase pour l'utilisateur
156let opencode = { isReady: false, version: null, reason: "opencode n'a pas encore ete sonde" }
157
158// Les delegations de fond en vol, par cle, telles qu'on les ecrit dans le store.
159// La verite est ici, en memoire : le store n'en est que la copie durable, ce qui
160// evite qu'un lire-modifier-ecrire en perde une.
161let liveJobs = new Map()
162// La carte que chaque job de fond alimente, par cle de job : c'est par la que
163// le compte rendu, remis bien plus tard, retrouve la tache a marquer comme
164// remise. Le marqueur du store ne peut pas la porter : il est serialise.
165let jobTasks = new Map()
166let jobSeq = 0
167// L'id de la session Claude, qui range nos marqueurs a part de ceux des autres
168let sessionKey = null
169// Les comptes rendus de fond prets a notifier, et le drain en cours
170let pendingReports = []
171let isDraining = false
172
173// Sonde `opencode --version` avant d'enregistrer quoi que ce soit. `$.process.run`
174// rejette si le binaire ne demarre pas, d'ou le try/catch ; un code de sortie non
175// nul, lui, revient dans le resultat.
176async function probeOpencode($) {
177  let out
178  try {
179    out = await $.process.run(['opencode', '--version'], { timeoutMs: PROBE_MS })
180  } catch (err) {
181    return {
182      isReady: false,
183      version: null,
184      reason: "le binaire `opencode` est introuvable ou ne demarre pas (" + err.message + ')',
185    }
186  }
187  if (out.exitCode !== 0) {
188    return {
189      isReady: false,
190      version: null,
191      reason: '`opencode --version` a repondu en erreur (code ' + out.exitCode + ')',
192    }
193  }
194  // « opencode v2.0.32 », et on accepte toute mise en forme autour du numero
195  const found = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(out.stdout + '\n' + out.stderr)
196  if (!found) {
197    return {
198      isReady: false,
199      version: null,
200      reason: '`opencode --version` ne donne pas de numero de version lisible',
201    }
202  }
203  const version = found[0]
204  if (Number(found[1]) < MIN_MAJOR) {
205    return {
206      isReady: false,
207      version,
208      reason: 'opencode ' + version + ' est trop ancien : la delegation demande une 2.x',
209    }
210  }
211  return { isReady: true, version, reason: null }
212}
213
214// Ce que l'utilisateur doit faire, quand l'outil n'a pas ete enregistre
215const FIX = 'Installer opencode 2.x (https://opencode.ai), puis relancer la session.'
216
217// Deux contraintes se contredisent. Le palier gratuit d'OpenCode Console
218// refuse une session dont l'id n'a pas ete emis par opencode : il lit un id
219// invente comme un appel « hors d'OpenCode » et repond 403 FreeTierError. Mais
220// une session creee par l'API se voit imposer le repertoire du service de fond,
221// celui d'ou il a demarre : la tache lirait et ecrirait la, pas dans le projet,
222// et `dir` serait sans effet (ni le corps, ni un parametre, ni un en-tete ne
223// le changent). On laisse donc `opencode run` creer sa session, ou le cwd est
224// honore, et on la retrouve ensuite pour alimenter le suivi live.
225async function findSession($, since, dir) {
226  try {
227    const listed = await $.process.run(['opencode', 'api', 'session.list'], { timeoutMs: 10_000 })
228    if (listed.exitCode !== 0) return null
229    const body = JSON.parse(listed.stdout)
230    const items = body.data || body
231    if (!Array.isArray(items)) return null
232    // la liste vient de la plus recente a la plus ancienne
233    const match = items.find((item) => {
234      const created = item && item.time && item.time.created
235      if (typeof created !== 'number' || created < since) return false
236      if (!dir) return true
237      return ((item.location || {}).directory || null) === dir
238    })
239    return match && typeof match.id === 'string' ? match.id : null
240  } catch {
241    return null
242  }
243}
244
245function parseEvents(stdout) {
246  const texts = new Map() // id de part -> texte, le dernier gagne
247  const toolCalls = new Map()
248  const errors = []
249  let sessionId
250
251  for (const line of stdout.split('\n')) {
252    const trimmed = line.trim()
253    if (!trimmed || trimmed[0] !== '{') continue
254    let event
255    try {
256      event = JSON.parse(trimmed)
257    } catch {
258      continue
259    }
260    if (event.sessionID) sessionId = event.sessionID
261    const part = event.part || {}
262    if (event.type === 'text' && typeof part.text === 'string') {
263      texts.set(part.id || 'text-' + texts.size, part.text)
264    } else if (event.type === 'tool_use' && part.tool) {
265      const state = part.state || {}
266      const time = state.time || {}
267      // un meme appel est emis plusieurs fois au fil de son avancement :
268      // la derniere version gagne
269      toolCalls.set(part.id || 'tool-' + toolCalls.size, {
270        tool: part.tool,
271        status: state.status || 'unknown',
272        title: state.title || part.tool,
273        ms: time.start && time.end ? time.end - time.start : null,
274      })
275    } else if (event.type === 'error') {
276      const error = event.error || {}
277      errors.push({
278        type: typeof error.type === 'string' ? error.type : '',
279        message: error.message || 'erreur opencode sans message',
280      })
281    }
282  }
283
284  return {
285    text: [...texts.values()].join('\n').trim(),
286    toolCalls: [...toolCalls.values()],
287    errors,
288    sessionId,
289  }
290}
291
292// `$.process.run` ne rend rien du tout quand il tue le processus : tout ce
293// qu'opencode a produit est perdu cote flux NDJSON. La session, elle, l'a garde.
294// On la relit donc pour rendre quand meme le dernier texte et le journal des
295// outils appeles — sept minutes de lecture valent mieux qu'une ligne d'echec.
296//
297// La forme n'est pas celle de `session.get` : `{ data: [messages] }`, du plus
298// recent au plus ancien ; un message `idle` porte `outcome` et, en cas d'echec,
299// `error` ; un message `assistant` porte `content`, ou une part de texte est
300// `{ type: 'text', text }` et une part d'outil `{ type: 'tool', name, state }`
301// (`name`, pas `tool` comme dans le flux).
302async function recoverPartial($, sessionId) {
303  if (!sessionId) return null
304  let out
305  try {
306    out = await $.process.run(
307      ['opencode', 'api', 'session.message.list', '--param', 'sessionID=' + sessionId],
308      { timeoutMs: RECOVER_MS },
309    )
310  } catch {
311    return null
312  }
313  if (out.exitCode !== 0) return null
314  let items
315  try {
316    const body = JSON.parse(out.stdout)
317    items = body.data || body
318  } catch {
319    return null
320  }
321  if (!Array.isArray(items)) return null
322
323  let text = null
324  let error = null
325  let outcome = null
326  // Les messages viennent a l'envers du temps, mais les parts d'un message sont
327  // dans l'ordre : on garde un groupe par message, puis on remet les groupes
328  // dans l'ordre, pour que le journal se lise comme la tache s'est deroulee
329  const groups = []
330  for (const message of items) {
331    if (!message) continue
332    const mine = { tools: [], texts: [] }
333    // le plus recent d'abord : le premier `outcome` et la premiere erreur
334    // rencontres sont les derniers qu'opencode ait enregistres
335    if (!outcome && typeof message.outcome === 'string') outcome = message.outcome
336    if (!error && message.error && message.error.message) error = message.error.message
337    for (const part of message.content || []) {
338      if (!part) continue
339      if (part.type === 'text' && typeof part.text === 'string') {
340        const said = part.text.trim()
341        // `text` reste la derniere conclusion, comme avant ; le journal, lui,
342        // garde tout ce qui a ete dit, pour que la relecture montre la piste
343        // abandonnee et pas seulement le mot de la fin
344        if (said && !text) text = said
345        if (said) mine.texts.push(said)
346      } else if (part.type === 'tool') {
347        const name = part.name || part.tool
348        const state = part.state || {}
349        const time = state.time || {}
350        // meme forme que les appels lus dans le flux, pour que la carte n'ait
351        // pas deux sortes d'appels a dessiner
352        if (name) {
353          mine.tools.push({
354            tool: name,
355            status: state.status || 'unknown',
356            // un outil qui n'a pas abouti le dit dans son libelle : la carte
357            // comme le compte rendu rendu a Claude le montrent alors
358            title:
359              (state.title || name) +
360              (state.status && state.status !== 'completed' ? ' (' + state.status + ')' : ''),
361            ms: time.start && time.end ? time.end - time.start : null,
362          })
363        }
364      }
365    }
366    if (mine.tools.length || mine.texts.length) groups.push(mine)
367  }
368
369  const tools = []
370  const texts = []
371  for (let i = groups.length - 1; i >= 0; i--) {
372    tools.push(...groups[i].tools)
373    texts.push(...groups[i].texts)
374  }
375  // `tools` reste borne comme avant, pour les appelants d'avant ; `allTools` et
376  // `texts` sont la lecture complete, dont l'outil de relecture decoupe sa
377  // fenetre. Une seule lecture de `session.message.list` sert les deux.
378  return { text, error, outcome, tools: tools.slice(-MAX_RECOVERED_TOOLS), allTools: tools, texts }
379}
380
381// Ou et sur quoi la tache va tourner. Un agent a conclu de travers parce
382// qu'opencode lisait un checkout en retard sans que personne puisse s'en
383// apercevoir : le resultat nomme donc toujours le repertoire effectif, le
384// commit de depart, l'etat sale, et le retard sur l'amont quand il y en a.
385async function probeCheckout($, cwd) {
386  const at = { timeoutMs: GIT_MS }
387  if (cwd) at.cwd = cwd
388  const look = async (argv) => {
389    try {
390      const out = await $.process.run(argv, at)
391      return out.exitCode === 0 ? out.stdout : null
392    } catch {
393      return null
394    }
395  }
396
397  // `pwd` plutot qu'un calcul de chemin : un `dir` relatif devient absolu, et
398  // le module de hooks n'a pas de quoi joindre deux chemins lui-meme
399  const here = await look(['pwd'])
400  const found = { dir: here ? here.trim() || null : null, commit: null, branch: null, isDirty: false, behind: 0 }
401
402  // `HEAD --abbrev-ref HEAD` rend le sha puis le nom de branche, en un appel
403  const head = await look(['git', 'rev-parse', 'HEAD', '--abbrev-ref', 'HEAD'])
404  if (!head) return found
405  const lines = head.trim().split('\n')
406  found.commit = (lines[0] || '').slice(0, 12) || null
407  // un HEAD detache rend « HEAD » comme nom de branche : rien a nommer
408  if (lines[1] && lines[1] !== 'HEAD') found.branch = lines[1]
409
410  const status = await look(['git', 'status', '--porcelain'])
411  found.isDirty = Boolean(status && status.trim())
412
413  // Le cas qui a produit la fausse conclusion : un checkout en retard sur son
414  // amont. Sans amont configure, la commande sort en erreur et on n'en dit rien.
415  const behind = await look(['git', 'rev-list', '--count', 'HEAD..@{upstream}'])
416  if (behind) found.behind = Number(behind.trim()) || 0
417  return found
418}
419
420// Un build sans `next.signal` ne doit pas faire echouer le traitement d'un echec
421function isAborted(next) {
422  try {
423    return Boolean(next.signal && next.signal.aborted)
424  } catch {
425    return false
426  }
427}
428
429// 8s, 1m12, 11m05
430function clock(total) {
431  if (total < 60) return total + 's'
432  const minutes = Math.floor(total / 60)
433  const seconds = String(total % 60).padStart(2, '0')
434  return minutes + 'm' + seconds
435}
436
437// La carte represente une session opencode, pas un appel : une reprise ajoute
438// un segment a la meme carte, et la duree affichee est leur somme
439function elapsed(task) {
440  const segments = task.segments || [{ startedAt: task.startedAt, endedAt: task.endedAt }]
441  const total = segments.reduce((sum, seg) => sum + ((seg.endedAt || Date.now()) - seg.startedAt), 0)
442  return Math.round(total / 1000)
443}
444
445// Ce qu'opencode a consomme sur toute la session, tel que le serveur le compte
446function tokens(task) {
447  const t = task.tokens
448  if (!t) return ''
449  const used = (t.input || 0) + (t.output || 0) + (t.reasoning || 0)
450  if (!used) return ''
451  const label = used >= 1000 ? (used / 1000).toFixed(1) + 'k' : String(used)
452  return label + ' tokens'
453}
454
455// Fin du segment en cours : c'est lui qui alimente la duree cumulee
456function closeSegment(task) {
457  task.liveCalls = null
458  const segments = task.segments || []
459  const last = segments[segments.length - 1]
460  if (last && !last.endedAt) last.endedAt = Date.now()
461}
462
463function duration(task) {
464  return clock(elapsed(task))
465}
466
467function money(cost) {
468  if (typeof cost !== 'number' || cost === 0) return ''
469  return '$' + cost.toFixed(4)
470}
471
472// ollama-cloud/gpt-oss:20b -> gpt-oss:20b, pour tenir dans une colonne
473function shortModel(model) {
474  const slash = model.indexOf('/')
475  return slash === -1 ? model : model.slice(slash + 1)
476}
477
478function fit(text, width) {
479  if (width <= 1) return ''
480  return text.length > width ? text.slice(0, width - 1) + '…' : text
481}
482
483// Les recents d'abord, puis les modeles des memes fournisseurs, puis le reste,
484// le tout coupe a ce qu'un Select accepte
485function modelChoices() {
486  const seen = new Set()
487  const out = []
488  const push = (model) => {
489    if (!model || seen.has(model) || out.length >= MAX_OPTIONS) return
490    seen.add(model)
491    out.push(model)
492  }
493  recentModels.forEach(push)
494  const providers = new Set(recentModels.map((m) => m.split('/')[0]))
495  const rest = catalog || []
496  rest.filter((m) => providers.has(m.split('/')[0])).forEach(push)
497  rest.forEach(push)
498  return out
499}
500
501function rememberModel(model) {
502  recentModels = [model, ...recentModels.filter((m) => m !== model)].slice(0, MAX_RECENT_MODELS)
503}
504
505async function saveModels($) {
506  try {
507    await $.store.set('defaultModel', defaultModel)
508    await $.store.set('recentModels', recentModels)
509  } catch {
510    // le store peut refuser : le choix reste valable pour la session
511  }
512}
513
514// --- le compte rendu rendu a Claude ---------------------------------------
515
516// Ou et sur quoi la tache a tourne : la ligne que personne n'avait, et sans
517// laquelle un checkout en retard produit une conclusion fausse sans trace
518function whereLine(checkout) {
519  const found = checkout || {}
520  return (
521    'ou: ' +
522    [
523      found.dir || '(repertoire inconnu)',
524      found.commit ? '@ ' + found.commit : '(hors depot git)',
525      found.branch ? '(' + found.branch + ')' : null,
526      found.commit ? (found.isDirty ? 'checkout sale' : 'checkout propre') : null,
527    ]
528      .filter(Boolean)
529      .join(' ')
530  )
531}
532
533function behindLine(checkout) {
534  const behind = (checkout || {}).behind
535  return behind
536    ? '/!\\ ce checkout est ' +
537        behind +
538        " commit(s) derriere son amont : la tache a lu un etat en retard. Peser la conclusion en consequence, et verifier sur un checkout a jour avant d'en tirer quoi que ce soit."
539    : null
540}
541
542function footer(task) {
543  return [
544    task.sessionId ? 'session opencode: ' + task.sessionId : null,
545    'modele: ' + task.model,
546    'mode: ' + (task.mayWrite ? 'ecriture autorisee par l\'utilisateur' : 'lecture seule (agent plan)'),
547    'duree: ' + duration(task),
548    task.cost ? 'cout: ' + money(task.cost) : null,
549    task.toolCount ? 'outils: ' + task.toolCount + ' appels' : null,
550  ]
551    .filter(Boolean)
552    .join(' · ')
553}
554
555// Le plafond de dix minutes porte sur un appel de `$.process.run`, pas sur
556// une tache : la session opencode, elle, garde son historique. Un echec qui
557// ne rend pas son id casse la chaine — Claude n'a plus de quoi reprendre, et
558// une tache longue a l'air hors de portee du mod alors qu'elle se mene en
559// plusieurs reprises. On le dit donc explicitement, et on dit aussi quand il
560// n'y a pas d'id, pour ne pas laisser croire a une reprise possible.
561function resumeLine(task) {
562  return task.sessionId
563    ? 'Reprise possible : rappeler cet outil avec `resume: "' +
564        task.sessionId +
565        '"` continue dans la meme session opencode, qui garde tout son historique. Le budget plafonne un appel, pas une tache : une tache longue se mene en plusieurs reprises.'
566    : "Pas de reprise possible : la session opencode n'a pas ete retrouvee, donc pas d'id a passer dans `resume`. Il faudrait relancer la tache depuis le debut."
567}
568
569// Le pied commun a tous les comptes rendus
570function tailLines(task) {
571  const lines = ['', '---', footer(task), whereLine(task.checkout)]
572  const behind = behindLine(task.checkout)
573  if (behind) lines.push(behind)
574  return lines
575}
576
577
578// --- les delegations de fond ----------------------------------------------
579
580// Le marqueur durable d'un job de fond. Il existe pour une seule raison : un
581// rechargement du module tue les minuteurs, donc le job en vol n'a plus personne
582// pour le conclure. Sans marqueur, sa carte resterait « en cours » a mentir
583// indefiniment et son resultat ne reviendrait jamais.
584async function saveJobs($) {
585  if (!sessionKey) return
586  const key = JOB_PREFIX + sessionKey
587  try {
588    if (liveJobs.size === 0) await $.store.delete(key)
589    else await $.store.set(key, { updatedAt: Date.now(), jobs: [...liveJobs.values()] })
590  } catch {
591    // le store peut refuser : on perd alors la trace du job au rechargement,
592    // ce qui est exactement l'etat d'avant ce marqueur
593  }
594}
595
596// Reecrit le marqueur quand quelque chose a change, et sinon au rythme du
597// battement : le reecrire a chaque tour de suivi ne servirait a rien
598async function touchJob($, job, patch) {
599  if (!job) return
600  let changed = false
601  for (const [field, value] of Object.entries(patch || {})) {
602    if (job[field] !== value && value !== undefined) {
603      job[field] = value
604      changed = true
605    }
606  }
607  const now = Date.now()
608  if (!changed && now - (job.savedAt || 0) < HEARTBEAT_MS) return
609  job.lastSeenAt = now
610  job.savedAt = now
611  await saveJobs($)
612}
613
614// Un compte rendu de fond ne revient pas par le resultat de l'outil, qui a
615// rendu la main depuis longtemps : il revient par un tour a lui.
616//
617// Chaque `$.prompt.submit` attend que la session soit inactive et resout quand
618// son tour demarre : deux appels font donc deux tours de suite, le second
619// coupant ce que l'utilisateur a commence apres avoir lu le premier. On attend
620// donc GROUP_MS, le temps qu'un job qui finit presque en meme temps rejoigne la
621// file, puis on vide toute la file dans un seul compte rendu. Les jobs qui
622// finissent pendant l'attente de l'inactivite font un second tour, groupe de
623// la meme facon.
624function notify($, report, task) {
625  // La carte porte l'etat de remise : tant que le compte rendu n'est pas entre,
626  // `delegations` doit dire qu'il arrive, pour que Claude attende au lieu de
627  // relancer la tache.
628  pendingReports.push({ text: report, task })
629  if (isDraining) return
630  isDraining = true
631  $.clock.after(GROUP_MS, async () => {
632    let batch = []
633    try {
634      while (pendingReports.length) {
635        batch = pendingReports
636        pendingReports = []
637        await $.prompt.submit({ text: groupReports(batch) })
638        // remis seulement maintenant : avant le `submit`, le compte rendu
639        // n'etait encore nulle part
640        for (const held of batch) if (held.task) held.task.isReported = true
641        batch = []
642      }
643    } catch (err) {
644      // la notification n'a pas pu entrer (prompt refuse, session qui se
645      // ferme) : le transcript reste la seule voie pour rendre ce qui a ete paye
646      $.ui.log(
647        "opencode: le compte rendu de la delegation de fond n'a pas pu etre remis (" +
648          ((err && err.message) || 'cause inconnue') +
649          ') :\n' +
650          batch.map((held) => held.text).join('\n'),
651      )
652    } finally {
653      isDraining = false
654    }
655  })
656}
657
658function groupReports(held) {
659  const reports = held.map((one) => one.text)
660  if (reports.length === 1) {
661    return [
662      'Compte rendu de la delegation opencode lancee en arriere-plan. C\'est le resultat differe d\'un',
663      'appel a `' + TOOL + '` qui avait rendu la main tout de suite : il n\'y a rien a relancer, rien a',
664      'attendre de plus, et cet outil ne doit pas etre rappele pour cette tache. En reprendre le fil la',
665      'ou la conversation en etait.',
666      '',
667      reports[0],
668    ].join('\n')
669  }
670  const head = [
671    reports.length + ' delegations opencode lancees en arriere-plan viennent de finir, et voici leurs',
672    'comptes rendus. Ce sont les resultats differes d\'appels a `' + TOOL + '` qui avaient rendu la main',
673    'tout de suite : il n\'y a rien a relancer ni a attendre de plus. Ils sont groupes en un seul tour',
674    'parce que chaque notification ouvre un tour a elle et couperait celui d\'a cote.',
675  ].join('\n')
676  const bodies = reports.map(
677    (report, index) => '=== delegation ' + (index + 1) + '/' + reports.length + ' ===\n' + report,
678  )
679  return [head, '', ...bodies].join('\n')
680}
681
682// Ce que l'outil rend quand il vient de lancer une tache de fond : assez clair
683// pour que Claude n'attende pas et ne rappelle pas l'outil une seconde fois
684function launched(spec) {
685  return [
686    'Delegation lancee en arriere-plan : cet appel a rendu la main tout de suite, et opencode travaille',
687    'maintenant de son cote. NE PAS attendre, NE PAS rappeler cet outil pour cette tache, ne pas la',
688    'relancer : le compte rendu complet (reponse d\'opencode, id de session a repasser dans `resume`,',
689    'repertoire, commit de depart, duree, et la cause quand la tache s\'arrete sans conclure) arrivera',
690    'tout seul dans un tour suivant, sans que rien soit demande. Repondre a l\'utilisateur et continuer',
691    'autre chose en attendant.',
692    '',
693    'budget: ' + clock(Math.round(spec.timeoutMs / 1000)) + ' au plus',
694    'modele: ' + spec.model,
695    'mode: ' + (spec.mayWrite ? 'ecriture autorisee par l\'utilisateur' : 'lecture seule (agent plan)'),
696    spec.resume ? 'reprise de la session opencode ' + spec.resume : 'nouvelle session opencode',
697    '',
698    'Suivi en direct dans le pane `/opencode` : la carte de la tache y vit comme celle de n\'importe',
699    'quelle delegation. Un rechargement du mod pendant la tache est le seul cas ou le compte rendu',
700    'arrive degrade : il dit alors que la tache a ete perdue par le rechargement, et comment reprendre.',
701  ].join('\n')
702}
703
704// Reprend les jobs de fond que le module d'avant a laisses dans le store : ils
705// tournaient quand il est mort, et plus personne ne les conclura. Fait son
706// travail DANS le hook de demarrage, avant que l'outil ne soit enregistre : un
707// job lance entre-temps ecrirait son marqueur sous la meme cle, et le balayage
708// le prendrait pour un orphelin alors qu'il tourne encore.
709async function claimOrphans($) {
710  let keys
711  try {
712    keys = await $.store.keys()
713  } catch {
714    return []
715  }
716  if (!Array.isArray(keys)) return []
717  const mine = JOB_PREFIX + sessionKey
718  const orphans = []
719  for (const key of keys) {
720    if (typeof key !== 'string' || !key.startsWith(JOB_PREFIX)) continue
721    let held
722    try {
723      held = await $.store.get(key)
724    } catch {
725      continue
726    }
727    const jobs = held && Array.isArray(held.jobs) ? held.jobs : []
728    if (key === mine) {
729      // notre propre session : ces jobs etaient en vol quand le module est
730      // tombe. On les reprend a notre compte, puis on nettoie la cle. Un job
731      // que ce module-ci a lance n'en est pas un, et sa cle le dit.
732      for (const job of jobs) {
733        if (!job || liveJobs.has(job.key)) continue
734        if (Date.now() - (job.startedAt || 0) < STALE_MS) orphans.push(job)
735      }
736      try {
737        await $.store.delete(key)
738      } catch {
739        // tant pis : le prochain demarrage les relira et les trouvera perimes
740      }
741    } else if (!held || Date.now() - (held.updatedAt || 0) > STALE_MS) {
742      // une autre session, abandonnee depuis longtemps : menage
743      try {
744        await $.store.delete(key)
745      } catch {}
746    }
747  }
748  return orphans
749}
750
751// Puis on en rend compte, hors evenement : notifier attend que la session soit
752// inactive, et relire une session opencode prend quelques appels de CLI
753async function reportOrphans($, orphans) {
754  if (orphans.length === 0) return
755
756  await $.ui.open({ id: PANE, title: 'opencode' })
757  for (const job of orphans) {
758    const out = await reportOrphan($, job)
759    // Passe son budget et la marge, la tache a pu finir toute seule depuis
760    // longtemps : ouvrir un tour pour en parler reveillerait la session pour
761    // une archeologie. La carte et le transcript le disent, ca suffit.
762    const deadline = (job.lastSeenAt || job.startedAt || 0) + (job.timeoutMs || DEFAULT_TIMEOUT_MS)
763    if (Date.now() < deadline + RELOAD_GRACE_MS) notify($, out.report, out.task)
764    else {
765      $.ui.log('opencode (delegation de fond perdue par un rechargement) :\n' + out.report)
766      // ce compte rendu n'entrera jamais dans un tour : `delegations` doit le
767      // dire plutot que de laisser croire qu'il arrive
768      out.task.isReported = true
769      out.task.isLoggedOnly = true
770    }
771  }
772}
773
774// La carte d'un job perdu, reconstruite depuis son marqueur : elle existe pour
775// que le pane ne montre pas une tache « en cours » qui n'a plus personne
776async function reportOrphan($, job) {
777  const startedAt = job.startedAt || Date.now()
778  const endedAt = job.lastSeenAt || startedAt
779  const task = {
780    id: ++seq,
781    prompt: job.prompt || '',
782    model: job.model || defaultModel,
783    agent: job.agent || null,
784    mayWrite: Boolean(job.mayWrite),
785    isBackground: true,
786    isReported: false,
787    sessionId: job.sessionId || null,
788    title: job.title || null,
789    status: 'failed',
790    stop: 'reload',
791    startedAt,
792    endedAt,
793    segments: [{ startedAt, endedAt }],
794    runs: 1,
795    cuts: 1,
796    cost: job.cost || 0,
797    tokens: null,
798    toolCalls: [],
799    checkout: job.checkout || null,
800    timeoutMs: job.timeoutMs || DEFAULT_TIMEOUT_MS,
801  }
802  tasks = [task, ...tasks]
803
804  const partial = await recoverPartial($, task.sessionId)
805  if (partial && partial.tools.length) {
806    task.toolCalls = partial.tools
807    task.toolCount = partial.tools.length
808  }
809  $.ui.invalidate('ui.render')
810  $.ui.toast('opencode: delegation de fond perdue par un rechargement')
811
812  const body = [STOPS.reload.line(task)]
813  body.push('', 'La tache etait : ' + fit(task.prompt, 400))
814  if (partial && partial.error) body.push("Derniere erreur enregistree par la session : " + partial.error)
815  if (partial && partial.text) {
816    body.push('', "Ce qu'opencode avait produit, relu dans sa session :", '', partial.text)
817  } else {
818    body.push(
819      '',
820      "La session d'opencode ne garde aucun texte : soit elle n'a pas ete retrouvee, soit opencode n'avait rien conclu.",
821    )
822  }
823  if (partial && partial.tools.length) {
824    body.push('', 'Outils appeles : ' + partial.tools.map((call) => call.title).join(', '))
825  }
826  body.push('', resumeLine(task))
827  body.push(...tailLines(task))
828  return { report: body.join('\n'), task }
829}
830
831
832// --- la sortie en JSON ----------------------------------------------------
833
834// Ce que la mesure a tranche. opencode 2.0.32 n'a AUCUN champ de schema :
835// le corps de `session.prompt` est `{id, text, files, agents, skills, metadata,
836// delivery, resume}` en `additionalProperties: false`, et un `format` envoye
837// quand meme repart en 200 sans etre garde — le serveur le jette en silence,
838// ce qui est pire qu'un refus. `session.get` n'a pas de `structured_output`, et
839// `opencode run` n'a pas de drapeau de schema (`--format` ne vaut que `default`
840// ou `json`, c'est le rendu). Les deux voies par l'API meurent donc avant meme
841// la question du repertoire de travail.
842//
843// Reste a demander le JSON dans le prompt et a le lire nous-memes. Ce qu'on
844// garantit alors n'est pas le schema : c'est la verification. Le mod demande la
845// forme, relit la reponse, et dit laquelle des deux il tient en main — du JSON
846// conforme, ou du texte. Il n'invente jamais un champ manquant.
847function jsonInstruction(schema) {
848  return [
849    '',
850    '',
851    '---',
852    'CONTRAINTE DE SORTIE, imposee par l\'appelant et non negociable :',
853    "ta reponse finale doit etre un objet JSON valide et RIEN d'autre — pas une phrase avant, pas une",
854    'phrase apres, pas de bloc de code markdown, pas de commentaire. Elle doit respecter ce schema JSON :',
855    '',
856    JSON.stringify(schema, null, 2),
857    '',
858    "Si une information te manque, mets une valeur vide du bon type plutot que de changer la forme.",
859  ].join('\n')
860}
861
862// Le JSON peut arriver nu, dans un bloc de code, ou noye dans une phrase : on
863// tente les trois, du plus propre au plus tolerant, sans jamais reparer le
864// contenu lui-meme
865function extractJson(text) {
866  const said = String(text || '').trim()
867  if (!said) return null
868  const tries = [said]
869  // ```json { ... } ```
870  const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(said)
871  if (fenced) tries.push(fenced[1].trim())
872  // le premier objet equilibre du texte
873  const start = said.indexOf('{')
874  if (start !== -1) {
875    let depth = 0
876    let isInString = false
877    let isEscaped = false
878    for (let i = start; i < said.length; i++) {
879      const ch = said[i]
880      if (isEscaped) {
881        isEscaped = false
882        continue
883      }
884      if (ch === '\\' && isInString) {
885        isEscaped = true
886        continue
887      }
888      if (ch === '"') isInString = !isInString
889      else if (!isInString && ch === '{') depth++
890      else if (!isInString && ch === '}') {
891        depth--
892        if (depth === 0) {
893          tries.push(said.slice(start, i + 1))
894          break
895        }
896      }
897    }
898  }
899  for (const candidate of tries) {
900    try {
901      const parsed = JSON.parse(candidate)
902      if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
903    } catch {
904      // candidat suivant
905    }
906  }
907  return null
908}
909
910function jsonType(value) {
911  if (Array.isArray(value)) return 'array'
912  if (value === null) return 'null'
913  return typeof value
914}
915
916// Une verification volontairement peu ambitieuse : les cles requises, et le type
917// des proprietes declarees au premier niveau. On ne pretend pas valider un
918// schema JSON complet — mieux vaut dire ce qu'on verifie que de laisser croire
919// a une validation qui n'a pas lieu.
920function checkShape(value, schema) {
921  const wrong = []
922  const required = Array.isArray(schema.required) ? schema.required : []
923  for (const key of required) {
924    if (!(key in value)) wrong.push('la cle requise `' + key + '` manque')
925  }
926  const properties = schema.properties && typeof schema.properties === 'object' ? schema.properties : {}
927  for (const [key, declared] of Object.entries(properties)) {
928    if (!(key in value) || !declared || typeof declared !== 'object') continue
929    const wanted = declared.type
930    if (typeof wanted !== 'string') continue
931    const got = jsonType(value[key])
932    // JSON Schema distingue integer et number ; au premier niveau ca suffit
933    const isOk = wanted === 'integer' ? Number.isInteger(value[key]) : got === wanted
934    if (!isOk) wrong.push('`' + key + '` est un ' + got + ' au lieu d\'un ' + wanted)
935  }
936  return wrong
937}
938
939// Ce qui est rendu a la place de la reponse libre, quand un schema a ete demande
940function jsonOutcome(answer, schema) {
941  const said = String(answer || '').trim()
942  if (!said) {
943    return {
944      isJson: false,
945      lines: [
946        "LA SORTIE JSON DEMANDEE N'EST PAS VENUE, et opencode n'a rien conclu du tout : ni JSON, ni texte.",
947        "Il n'y a donc rien a relire ni a rattraper ici. Regarder le journal de la session",
948        '(`' + LOOK + '` avec `action: "log"`) pour voir ou la tache',
949        "s'est arretee avant de la relancer.",
950      ],
951    }
952  }
953  const parsed = extractJson(answer)
954  if (!parsed) {
955    return {
956      isJson: false,
957      lines: [
958        'LA SORTIE JSON DEMANDEE N\'EST PAS VENUE. opencode a repondu du texte, pas un objet JSON.',
959        "Le schema n'est pas impose par le fournisseur : opencode 2 n'a aucun champ de schema, donc le",
960        'mod ne peut que le demander dans le prompt et verifier la reponse. Ici le modele ne l\'a pas suivi.',
961        "Le mod n'invente aucun champ : voici la reponse telle quelle, a lire ou a redemander (un modele",
962        'plus capable, ou un schema plus simple, y arrivent mieux).',
963        '',
964        'Reponse brute :',
965        '',
966        fit(String(answer || '').trim(), MAX_RAW_ANSWER),
967      ],
968    }
969  }
970  const wrong = checkShape(parsed, schema)
971  if (wrong.length) {
972    return {
973      isJson: false,
974      lines: [
975        'JSON VALIDE MAIS HORS SCHEMA : opencode a bien rendu un objet JSON, mais il ne respecte pas la',
976        'forme demandee. Le schema n\'est pas impose par le fournisseur (opencode 2 n\'a aucun champ de',
977        'schema) : le mod le demande et le verifie, c\'est tout. Ce qui ne colle pas :',
978        ...wrong.map((one) => '  - ' + one),
979        '',
980        "L'objet est rendu tel quel, sans aucun champ ajoute ni corrige :",
981        '',
982        JSON.stringify(parsed, null, 2),
983      ],
984    }
985  }
986  return {
987    isJson: true,
988    lines: [
989      'Sortie JSON conforme au schema demande (cles requises presentes, types declares respectes au',
990      'premier niveau ; le mod ne verifie pas plus loin).',
991      '',
992      JSON.stringify(parsed, null, 2),
993    ],
994  }
995}
996
997// --- regarder ses propres delegations -------------------------------------
998
999// 420ms, 3s, 1m12 : la duree d'un appel d'outil
1000function span(call) {
1001  if (call.ms == null) return ''
1002  return call.ms < 1000 ? call.ms + 'ms' : clock(Math.round(call.ms / 1000))
1003}
1004
1005function statusWord(task) {
1006  if (task.status === 'running') return 'en cours'
1007  if (task.status === 'done') return 'terminee'
1008  return 'arretee (' + ((STOPS[task.stop] || {}).word || 'cause inconnue') + ')'
1009}
1010
1011// Ou en est le compte rendu d'une delegation de fond. C'est la ligne qui evite
1012// le double paiement : elle dit si Claude doit attendre, ou si c'est deja arrive.
1013function deliveryLine(task) {
1014  if (!task.isBackground) return null
1015  if (task.status === 'running') {
1016    return 'fond : le compte rendu arrivera tout seul dans un tour suivant, rien a attendre ni a relancer'
1017  }
1018  if (task.isLoggedOnly) {
1019    return 'fond : compte rendu ecrit dans le transcript seulement, il n\'entrera pas dans un tour — le relire par `action: "log"`'
1020  }
1021  return task.isReported
1022    ? 'fond : compte rendu deja remis dans un tour precedent'
1023    : 'fond : compte rendu pas encore remis, il arrive dans un tour suivant'
1024}
1025
1026// La liste des delegations de la session, l'equivalent de `$.agent.list()` :
1027// tout est deja en memoire, aucun appel de CLI.
1028function delegationList() {
1029  if (tasks.length === 0) {
1030    return [
1031      "Aucune delegation opencode dans cette session : rien n'a ete lance, donc rien n'est en vol,",
1032      "rien n'est a attendre, et aucun compte rendu de fond ne va arriver.",
1033    ].join('\n')
1034  }
1035
1036  const running = tasks.filter((task) => task.status === 'running')
1037  const waiting = tasks.filter((task) => task.isBackground && task.status !== 'running' && !task.isReported)
1038
1039  const head = [
1040    tasks.length +
1041      (tasks.length > 1 ? ' delegations opencode' : ' delegation opencode') +
1042      ' dans cette session, la plus recente en premier.',
1043    running.length
1044      ? running.length + (running.length > 1 ? ' tournent encore.' : ' tourne encore.')
1045      : 'Aucune ne tourne encore.',
1046    '',
1047    "Une delegation « en cours » est deja en vol : ne pas relancer la meme tache, elle serait payee",
1048    'deux fois. Si sa reponse est necessaire pour la suite du raisonnement, attendre son compte rendu',
1049    'plutot que de la relancer.',
1050  ]
1051  if (waiting.length) {
1052    head.push(
1053      '',
1054      waiting.length +
1055        (waiting.length > 1 ? ' delegations de fond ont fini' : ' delegation de fond a fini') +
1056        " mais n'" +
1057        (waiting.length > 1 ? 'ont' : 'a') +
1058        ' pas encore remis son compte rendu : il arrive tout seul dans un tour suivant.',
1059    )
1060  }
1061
1062  const bodies = tasks.map((task, index) => {
1063    const calls = (task.status === 'running' ? task.liveCalls : null) || task.toolCalls || []
1064    const facts = [
1065      duration(task),
1066      money(task.cost),
1067      tokens(task),
1068      calls.length ? calls.length + (calls.length > 1 ? " appels d'outils" : " appel d'outil") : null,
1069      task.runs > 1 ? task.runs + ' reprises' : null,
1070      task.mayWrite ? 'ecriture autorisee' : 'lecture seule',
1071    ]
1072      .filter(Boolean)
1073      .join(' \u00b7 ')
1074    return [
1075      '#' +
1076        (index + 1) +
1077        '  ' +
1078        statusWord(task) +
1079        '  \u00b7  ' +
1080        shortModel(task.model) +
1081        '  \u00b7  ' +
1082        (task.sessionId ? 'session opencode: ' + task.sessionId : 'session opencode non retrouvee'),
1083      '    ' + facts,
1084      deliveryLine(task) ? '    ' + deliveryLine(task) : null,
1085      task.error ? '    erreur : ' + fit(task.error, 160) : null,
1086      '    tache : ' + fit((task.prompt || '').replace(/\s+/g, ' '), 200),
1087    ]
1088      .filter(Boolean)
1089      .join('\n')
1090  })
1091
1092  const foot = [
1093    '',
1094    "Pour relire ce qu'une delegation a lu, appele et conclu sans la repayer : rappeler cet outil avec",
1095    '`action: "log"` et son id de session opencode. Pour continuer une conversation opencode : `' +
1096      TOOL +
1097      '` avec `resume`.',
1098  ]
1099
1100  return [...head, '', ...bodies, ...foot].join('\n')
1101}
1102
1103// Le journal d'une delegation, relu dans sa session opencode. La lecture est
1104// celle de `recoverPartial` : une seule facon de lire `session.message.list`
1105// dans le mod, et donc une seule a pouvoir se tromper.
1106async function delegationLog($, sessionId, opts) {
1107  const wanted = String(sessionId || '').trim()
1108  if (!wanted) {
1109    return (
1110      '`action: "log"` demande un `sessionId` : l\'id de session opencode d\'une delegation. ' +
1111      '`action: "list"` donne celui de chacune.'
1112    )
1113  }
1114
1115  const partial = await recoverPartial($, wanted)
1116  if (!partial) {
1117    return [
1118      "Le journal de la session opencode " + wanted + " n'a pas pu etre relu : soit cet id n'existe pas,",
1119      "soit le service de fond d'opencode ne repond pas. Verifier l'id par `action: \"list\"`.",
1120      "Rien n'a ete relance : cet outil ne fait que lire.",
1121    ].join('\n')
1122  }
1123
1124  const all = partial.allTools || []
1125  const offset = Math.max(0, Math.floor(Number(opts.offset) || 0))
1126  const window = all.slice(offset, offset + MAX_LOG_TOOLS)
1127  const isToolsOnly = Boolean(opts.toolsOnly)
1128
1129  const head = [
1130    'Journal de la session opencode ' + wanted + ', relu sans rien relancer ni repayer.',
1131    all.length
1132      ? all.length +
1133        (all.length > 1 ? " appels d'outils au total" : " appel d'outil au total") +
1134        (window.length
1135          ? ', affiches ' + (offset + 1) + '-' + (offset + window.length) + ' (du plus ancien au plus recent).'
1136          : ' — rien a cet `offset`.')
1137      : "Aucun appel d'outil dans cette session.",
1138  ]
1139  if (partial.outcome) head.push('Issue enregistree par opencode : ' + partial.outcome + '.')
1140  if (partial.error) head.push('Derniere erreur enregistree : ' + fit(partial.error, 300))
1141
1142  const rows = window.map((call, index) => {
1143    const at = String(offset + index + 1).padStart(3, ' ')
1144    const took = span(call)
1145    return at + '. ' + (call.title || call.tool) + (took ? '  ' + took : '')
1146  })
1147
1148  const lines = [...head, ...(rows.length ? ['', ...rows] : [])]
1149
1150  // Les textes passent apres les appels : c'est la partie qui peut etre enorme,
1151  // et c'est donc elle qu'on sacrifie quand la place manque
1152  const notes = []
1153  if (!isToolsOnly && partial.texts && partial.texts.length) {
1154    notes.push(
1155      '',
1156      "Ce qu'opencode a dit (" +
1157        partial.texts.length +
1158        (partial.texts.length > 1 ? ' prises de parole' : ' prise de parole') +
1159        ', de la plus ancienne a la derniere) :',
1160    )
1161    let budget = MAX_LOG_CHARS - lines.join('\n').length
1162    let shown = 0
1163    for (const said of partial.texts) {
1164      const piece = fit(said.replace(/\s+/g, ' '), MAX_LOG_TEXT)
1165      if (piece.length + 8 > budget) break
1166      budget -= piece.length + 8
1167      shown += 1
1168      notes.push('', '--- ' + shown + ' ---', piece)
1169    }
1170    if (shown < partial.texts.length) {
1171      notes.push(
1172        '',
1173        '(' +
1174          (partial.texts.length - shown) +
1175          " prises de parole de plus, laissees de cote pour tenir dans la place : elles sont dans la session.)",
1176      )
1177    }
1178  }
1179
1180  const foot = []
1181  if (offset + window.length < all.length) {
1182    foot.push(
1183      '',
1184      'Journal tronque : il reste ' +
1185        (all.length - offset - window.length) +
1186        " appels d'outils. Pour la suite, rappeler cet outil avec `action: \"log\"`, `sessionId: \"" +
1187        wanted +
1188        '"`, `offset: ' +
1189        (offset + window.length) +
1190        '`.',
1191    )
1192  }
1193  if (!isToolsOnly && partial.texts && partial.texts.length) {
1194    foot.push("Pour ne lire que les appels d'outils, sans les textes : `toolsOnly: true`.")
1195  }
1196
1197  return [...lines, ...notes, ...foot].join('\n')
1198}
1199
1200// --- la delegation elle-meme ----------------------------------------------