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

Mod Claude Code qui utilise opencode 2 comme sous-agent.
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.mcp__opencode-delegate__delegate que Claude peut appeler comme un Task, avec prompt, writeFiles, model, agent, dir, resume, budgetMs, background.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.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.resume pour un suivi sur la même conversation opencode.resume : voir Quand la tâche s'arrête mal.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.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.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.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.
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 :
prompt.context qui annonce la capacité n'est pas ajouté, sinon on promettrait à Claude un outil qui n'existe pas ;$.ui.log dit une fois ce qui manque et quoi faire ;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.
$.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.
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 :
next.signal, l'AbortSignal du hook, qui s'abat quand l'évènement est abandonné ;$.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.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 :
completed — le minimum utile quand il n'y a pas de texte ;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é.
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é.
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.
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.
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.
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.
$.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é.
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 :
en cours est reconnaissable avant d'être relancée, et le texte le dit explicitement : la relancer la ferait payer deux fois.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é.
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.
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.
Sur opencode v2.0.32, mesuré avec de vraies commandes :
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.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.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-
hooks/register.js 2157 lines1// 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 ----------------------------------------------