Annexe — Harnais pi Chapitre A2 / 42

Votre première extension pi & le cycle de vie des événements

Ouvrez le capot du harnais : une extension pi est un module TypeScript qui écoute le cycle de vie d'une session et peut bloquer, enrichir ou afficher. La pièce du jour pose le pied de page de l'usine dans plume-factory — modèle, jauge de contexte, coût du run et rôle du roster — sans toucher au mode headless du runner.

Au chapitre A1, vous avez posé .pi/settings.json et pi démarre enfin sur le workhorse de votre roster. Mais ouvrez une session interactive dans plume-factory pour relire une trace ou préparer un best-of-N : le pied de page vous montre un modèle, un pourcentage et un coût, et rien ne vous dit si ce modèle est celui du builder ou du reviewer de factory.config.yaml, ni où en est votre budget de session. Le harnais ne connaît pas votre usine. À la fin de ce chapitre, vous saurez ce qu’est une extension pi, à quels événements du cycle de vie elle peut s’abonner, lesquels peuvent bloquer et lesquels ne font qu’observer. Vous saurez lire l’enchaînement agent_start → turn → tool_call → agent_settled comme vous lisez les swim lanes du chapitre 19. La pièce du jour est la première extension de plume-factory : .pi/extensions/factory-footer.ts, un pied de page qui parle la langue de l’usine. Elle vit entièrement côté pi de la couture : le runner Python ne la voit pas, et pi -p continue de tourner exactement comme avant, ce que la gate vérifie.

L’ExtensionAPI et les événements du harnais

L’idée en une phrase

Une extension pi est un module TypeScript qui exporte une fonction recevant un objet ExtensionAPI. Par pi.on("événement", handler) elle s’abonne au cycle de vie d’une session, et par pi.registerTool / pi.registerCommand elle ajoute des outils et des commandes. Cette pièce vit dans le harnais, côté code déterministe : elle s’exécute dans le process de pi, avant, entre et après les appels au modèle, jamais dans le modèle.

Points clés

  • Un fichier, une fonction par défaut. .pi/extensions/*.ts (ou */index.ts) est auto-découvert dès que le projet est approuvé (chapitre A1), et pi charge le TypeScript sans compilation. Les types s’importent depuis @earendil-works/pi-coding-agent, le nom de paquet vérifié au moment d’écrire. pi -e ./fichier.ts sert aux essais rapides, /reload recharge une extension auto-découverte sans quitter la session.
  • Le cycle de vie est un graphe fixe, et c’est pi qui le possède : session_start, puis pour chaque prompt before_agent_startagent_start → une suite de tours (turn_start, context, appel modèle, éventuels tool_execution_starttool_calltool_resulttool_execution_end, turn_end) → agent_endagent_settled quand plus aucun retry, compaction ni message en attente ne suivra. Un tour, c’est une réponse du modèle et ses appels d’outils.
  • Trois familles de handlers. Ceux qui observent (agent_start, turn_end, model_select, thinking_level_select : la valeur de retour est ignorée). Ceux qui modifient (before_agent_start réécrit le system prompt, context filtre les messages, tool_result remplace un résultat, message_end remplace un message). Et ceux qui bloquent : tool_call retourne { block: true, reason } et l’outil ne s’exécute pas, et la pièce du chapitre A3 tient tout entière sur ce retour.
  • ctx porte l’état, pas l’événement. Chaque handler reçoit un ExtensionContext : ctx.model, ctx.thinkingLevel, ctx.getContextUsage() (jetons, fenêtre, pourcentage), ctx.sessionManager.getBranch() (les entrées de la branche courante, avec l’usage de chaque message assistant), ctx.cwd, ctx.isProjectTrusted(), et ctx.ui.
  • Le mode décide de ce que ctx.ui peut faire. ctx.mode vaut tui, rpc, json ou print, et ctx.hasUI est vrai en TUI et RPC seulement. En -p et --mode json, les deux modes de votre adaptateur _run_pi, les méthodes d’affichage sont des no-op et les dialogues ne peuvent pas s’ouvrir. Une extension qui oublie ce test ne casse pas le headless, mais elle dépense du travail pour rien à chaque phase du runner.

Exemple concret

Vous lancez une phase build par l’adaptateur du chapitre 15 : pi -p --mode json --model openrouter/z-ai/glm-5.3 …. Le projet est approuvé, vos extensions se chargent. Le graphe se déroule : session_start, before_agent_start, agent_start, puis six tours, chacun un appel modèle de quelques milliers de jetons en entrée et quelques centaines en sortie, et dans quatre d’entre eux un bash ou un edit passe par tool_call. Au sixième turn_end, plus d’appel d’outil : agent_end, puis agent_settled. Une extension qui compte les tool_call a vu passer neuf appels. Une extension qui les bloque aurait pu en refuser un, avec un motif que le modèle lit au tour suivant. Coût de l’extension elle-même : zéro jeton, elle tourne côté TypeScript, en quelques millisecondes par événement. La phase, elle, coûte ce que le roster décide : quelques centimes sur le workhorse.

Les événements que l’usine utilise

ÉvénementQuandPeut…Utilisé par
session_startouverture, /new, /resume, /reloadobserver, installer l’UIA2 (pied de page), A4 (ouvrir la base)
before_agent_startaprès le prompt, avant la boucleinjecter un message, réécrire le system prompt— (le runner impose déjà ses prompts)
turn_start / turn_endà chaque réponse du modèleobserverA2 (rafraîchir), A4 (tracer)
tool_callavant l’exécution d’un outilbloquer, patcher event.inputA3 (damage control)
tool_resultaprès l’exécutionremplacer le résultat
model_select / thinking_level_select/model, Ctrl+P, restaurationobserverA2 (pied de page)
agent_settledplus rien ne suivra automatiquementobserverA2 (statut), A4 (clore le run)
session_shutdownsortie, changement de sessionlibérer les ressourcesA4 (fermer la base)

Config — le squelette de toute extension de l’usine

Chaque pièce des chapitres A2 à A4 suit ce gabarit : un import de types, une fonction par défaut, des abonnements, et un test de mode avant de toucher à l’interface. Seule la version pi existe : Claude Code a ses hooks (des commandes shell déclarées dans .claude/settings.json, déclenchées par des événements voisins comme PreToolUse), un mécanisme que l’annexe ne couvre pas et que l’adaptateur _run_claude n’exige pas.

// .pi/extensions/exemple.ts — le gabarit : observer sans jamais gêner le headless.
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  // Observer : la valeur de retour est ignorée.
  pi.on("turn_end", async (event, ctx) => {
    // ctx.hasUI est faux en -p et --mode json : on ne dessine rien pour le runner.
    if (!ctx.hasUI) return;
    ctx.ui.setStatus("exemple", `tour ${event.turnIndex + 1}`);
  });

  // Bloquer : seul tool_call accepte { block: true }.
  pi.on("tool_call", async (event) => {
    if (event.toolName === "bash" && String(event.input.command).includes("rm -rf /")) {
      return { block: true, reason: "Commande refusée par l'usine." };
    }
  });
}

Piège courant : « une extension projet se charge dès que le fichier est dans .pi/extensions/ » est inexact. Elle ne se charge qu’après la décision de confiance du chapitre A1, et project_trust lui-même n’est visible que des extensions globales ou passées par -e, jamais des extensions du projet. Sur une machine neuve, sans /trust ni global always, votre pied de page n’apparaîtra pas, et pi ne s’en plaindra pas.


L’idée en une phrase

ctx.ui offre trois surfaces persistantes : setStatus (un texte court dans le pied de page), setWidget (des lignes au-dessus ou en dessous de l’éditeur) et setFooter (le remplacement complet du pied de page, avec accès à la branche git et aux statuts des autres extensions). L’usine s’en sert pour afficher ce que le harnais ne sait pas : le rôle du roster, le budget de session, la jauge de contexte. Côté code déterministe, dans le process de pi, et le modèle n’en voit rien.

Points clés

  • Le pied de page de série montre déjà les jetons, le coût, le pourcentage de contexte, le modèle et le niveau de réflexion. Le remplacer n’a de sens que pour y ajouter ce qui vient de l’usine. Sinon setStatus suffit : un statut nommé, écrasé à chaque appel, effacé avec undefined.
  • setFooter reçoit une fabrique (tui, theme, footerData) => composant : le composant expose render(width) qui retourne des lignes, invalidate(), et un dispose optionnel. footerData.getGitBranch() et footerData.getExtensionStatuses() sont les deux données que seule cette fabrique peut atteindre, et footerData.onBranchChange(cb) rend le pied de page réactif au changement de branche.
  • Le rendu n’a lieu que si quelqu’un le demande. render est appelé à chaque rafraîchissement du terminal. Pour refléter un turn_end ou un model_select, l’extension appelle tui.requestRender(). Gardez render bon marché : il tourne à chaque frappe.
  • Les couleurs passent par le thème, jamais en dur : theme.fg("dim" | "muted" | "accent" | "success" | "warning" | "error", texte). Les utilitaires visibleWidth et truncateToWidth de @earendil-works/pi-tui alignent gauche et droite sans compter les codes ANSI.
  • Tout est réversible : setFooter(undefined), setWidget("nom", undefined), setStatus("nom", undefined) restaurent l’état de série, pratique derrière une commande /factory-footer qui bascule.

Exemple concret

Session interactive dans plume-factory, workhorse GLM 5.3 à 1,40 $ le million de jetons en entrée et 4,40 $ en sortie au moment d’écrire, fenêtre de 1,3 million de jetons. Vous relisez trois traces et demandez un résumé : quatre tours, une trentaine de milliers de jetons cumulés, quelques centimes. Le pied de page de l’usine affiche builder ≈ z-ai/glm-5.3 · medium, une jauge [██········] 2 %, 0,04 $ / 2,00 $ et la branche. Vous passez sur un modèle frontier par /model pour une question difficile : le rôle devient planner, et deux tours plus tard la ligne de budget passe en orange à mi-chemin des 2 $. Rien n’a été bloqué, ce chapitre observe, mais vous avez vu le coût monter au moment où il montait, pas dans une trace lue le lendemain.

Trois surfaces, trois usages

SurfaceVit oùSe rafraîchitBon pour
setStatus("nom", texte)dans le pied de page de sérieà chaque appelun mot d’état : « run en cours », « 3 outils bloqués »
setWidget("nom", lignes)au-dessus (ou sous) l’éditeurà chaque appelune liste : plan en cours, fichiers protégés touchés
setFooter(fabrique)remplace le pied de pagetui.requestRender()une ligne dense qui combine harnais + usine

Commande — essayer une extension avant de la poser

Seule la version pi existe ici, l’annexe équipant le côté pi de la couture. Les deux commandes sont identiques dans bash et PowerShell.

# Essai sans toucher au projet : l'extension se charge pour cette session seulement
pi -e ./.pi/extensions/factory-footer.ts

# Une fois posée et le projet approuvé : auto-découverte, puis /reload après chaque modification
pi

Piège courant : « mon extension affiche un statut, donc elle a besoin d’un terminal » est inexact. Les méthodes d’affichage sont des no-op hors TUI et RPC, et une extension mal gardée ne fait pas planter pi -p. Ce qu’elle peut casser est bien pire et plus discret : un tool_call qui attend un ctx.ui.confirm() en mode print obtient une réponse par défaut et laisse passer ce qu’il croyait interdire. Testez toujours ctx.hasUI avant un dialogue, et préférez block sans question quand personne n’est là pour répondre.


Fil rouge — la pièce posée aujourd’hui

Sur le plan de l’usine, la pièce se pose dans le poste de pilotage, à côté du .pi/settings.json du chapitre A1 et sous le port harnais du chapitre 7. C’est la première brique de la zone observabilité côté harnais, que le chapitre A4 raccordera à factory.db. La couture ne bouge pas : le runner Python possède toujours le graphe des phases, impose modèle et outils par ses drapeaux, et lit ses enveloppes JSON. pi possède le graphe des tours à l’intérieur d’une phase, et c’est ce graphe-là que l’extension écoute. Aucune enveloppe nouvelle ne traverse : le pied de page lit factory.config.yaml, la pièce du chapitre 10, pour nommer le rôle du roster, sans rien y écrire. Coût : zéro jeton, quelques millisecondes par événement. ce qu’elle économise, c’est la session interactive qui dérive sur un moteur frontier sans que vous le voyiez, et la lecture de trace a posteriori pour savoir ce qu’a coûté un après-midi.


Travaux pratiques — la pièce du jour

Une pièce complète à poser dans le repo compagnon plume-factory, qui devient, chapitre après chapitre, votre usine logicielle agentique.

Pièce — .pi/extensions/factory-footer.ts

Le pied de page de l’usine. Il remplace celui de série en mode TUI seulement, affiche le rôle du roster qui correspond au modèle courant (lu dans adws/adw_config/factory.config.yaml, chapitre 10, par un balayage volontairement simple des lignes name: et model:), une jauge de contexte, le coût cumulé de la branche face à un budget de session, et la branche git. Deux commandes l’accompagnent : /factory-footer bascule entre le pied de page de l’usine et celui de série, /budget 3 change le plafond. Côté pi, côté code déterministe. En -p et --mode json, le handler rend la main immédiatement et le runner ne voit aucune différence.

// .pi/extensions/factory-footer.ts — le pied de page qui parle la langue de l'usine.
// Observe seulement : aucun événement n'est bloqué ici (ce sera A3), rien n'est écrit (A4).
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { AssistantMessage } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";

// Le roster du chapitre 10 : chemin relatif à la racine du projet (ctx.cwd).
const ROSTER_PATH = join("adws", "adw_config", "factory.config.yaml");

// Plafond de session par défaut, en dollars ; /budget le change à chaud.
let budgetUsd = 2.0;

// Modèle → rôle du roster. Balayage ligne à ligne, sans parseur YAML : le fichier
// est le nôtre, sa forme est connue (agents: - name: … / model: …), et une
// extension sans package.json ne peut pas importer de dépendance npm.
function loadRoster(cwd: string): Map<string, string> {
  const roles = new Map<string, string>();
  const path = join(cwd, ROSTER_PATH);
  if (!existsSync(path)) return roles;

  let inAgents = false;
  let current: string | undefined;
  let defaultModel: string | undefined;
  for (const raw of readFileSync(path, "utf8").split("\n")) {
    const line = raw.replace(/#.*$/, "").trimEnd();
    if (/^agents:/.test(line)) { inAgents = true; continue; }
    if (/^[A-Za-z]/.test(line)) inAgents = false; // nouvelle section de premier niveau
    const name = line.match(/^\s*-\s*name:\s*(\S+)/);
    const model = line.match(/^\s*model:\s*(\S+)/);
    if (!inAgents && model) defaultModel = model[1];
    if (inAgents && name) current = name[1];
    if (inAgents && model && current) roles.set(model[1], current);
  }
  // Les agents sans clé model héritent du défaut : on garde le premier nommé.
  if (defaultModel && !roles.has(defaultModel)) roles.set(defaultModel, "défaut");
  return roles;
}

// Coût et jetons cumulés de la branche courante — le même calcul que le pied de page de série.
function branchTotals(ctx: ExtensionContext): { input: number; output: number; cost: number } {
  let input = 0, output = 0, cost = 0;
  for (const entry of ctx.sessionManager.getBranch()) {
    if (entry.type === "message" && entry.message.role === "assistant") {
      const m = entry.message as AssistantMessage;
      input += m.usage.input;
      output += m.usage.output;
      cost += m.usage.cost.total;
    }
  }
  return { input, output, cost };
}

const fmtTokens = (n: number) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`);

function gauge(percent: number | null): string {
  if (percent === null) return "[??????????]";
  const filled = Math.min(10, Math.round(percent / 10));
  return `[${"█".repeat(filled)}${"·".repeat(10 - filled)}]`;
}

export default function (pi: ExtensionAPI) {
  let enabled = true;
  let roster = new Map<string, string>();
  let requestRender: (() => void) | undefined;

  function install(ctx: ExtensionContext) {
    ctx.ui.setFooter((tui, theme, footerData) => {
      requestRender = () => tui.requestRender();
      const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
      return {
        dispose: unsubscribe,
        invalidate() {},
        render(width: number): string[] {
          const modelId = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "aucun modèle";
          // Le roster nomme les modèles sans préfixe de passerelle : on compare sur l'id.
          const role = ctx.model ? (roster.get(ctx.model.id) ?? "hors roster") : "—";
          const thinking = ctx.thinkingLevel ?? "off";

          const usage = ctx.getContextUsage();
          const percent = usage?.percent ?? null;
          const window = usage ? fmtTokens(usage.contextWindow) : "?";
          const contextColor = percent !== null && percent > 80 ? "error" : percent !== null && percent > 50 ? "warning" : "dim";

          const totals = branchTotals(ctx);
          const budgetColor = totals.cost >= budgetUsd ? "error" : totals.cost >= budgetUsd / 2 ? "warning" : "success";

          const statuses = [...footerData.getExtensionStatuses().values()].join(" · ");
          const branch = footerData.getGitBranch();

          const left =
            theme.fg("accent", role) + theme.fg("dim", ` ≈ ${modelId} · ${thinking}`) +
            "  " + theme.fg(contextColor, `${gauge(percent)} ${percent === null ? "?" : percent.toFixed(0)}%/${window}`) +
            "  " + theme.fg(budgetColor, `${totals.cost.toFixed(2)} $ / ${budgetUsd.toFixed(2)} $`) +
            theme.fg("dim", ` ↑${fmtTokens(totals.input)} ↓${fmtTokens(totals.output)}`);
          const right = theme.fg("dim", [statuses, branch ? `(${branch})` : ""].filter(Boolean).join(" "));

          const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
          return [truncateToWidth(left + pad + right, width)];
        },
      };
    });
  }

  // Pas d'UI en -p / --mode json : le runner ne voit jamais cette extension travailler.
  pi.on("session_start", async (_event, ctx) => {
    if (ctx.mode !== "tui") return;
    roster = loadRoster(ctx.cwd);
    if (enabled) install(ctx);
  });

  // Les événements observés : on redemande un rendu, rien de plus.
  pi.on("turn_end", async () => requestRender?.());
  pi.on("model_select", async () => requestRender?.());
  pi.on("thinking_level_select", async () => requestRender?.());
  pi.on("agent_start", async (_event, ctx) => {
    if (ctx.hasUI) ctx.ui.setStatus("factory", "run en cours");
  });
  pi.on("agent_settled", async (_event, ctx) => {
    if (ctx.hasUI) ctx.ui.setStatus("factory", undefined);
    requestRender?.();
  });

  pi.registerCommand("factory-footer", {
    description: "Basculer entre le pied de page de l'usine et celui de série",
    handler: async (_args, ctx) => {
      if (ctx.mode !== "tui") return;
      enabled = !enabled;
      if (enabled) { roster = loadRoster(ctx.cwd); install(ctx); }
      else ctx.ui.setFooter(undefined);
      ctx.ui.notify(enabled ? "Pied de page de l'usine" : "Pied de page de série", "info");
    },
  });

  pi.registerCommand("budget", {
    description: "Fixer le plafond de session en dollars, ex. /budget 3",
    handler: async (args, ctx) => {
      const value = Number.parseFloat(args ?? "");
      if (!Number.isFinite(value) || value <= 0) {
        if (ctx.hasUI) ctx.ui.notify(`Budget actuel : ${budgetUsd.toFixed(2)} $`, "info");
        return;
      }
      budgetUsd = value;
      if (ctx.hasUI) ctx.ui.notify(`Budget de session : ${budgetUsd.toFixed(2)} $`, "info");
      requestRender?.();
    },
  });
}

La gate du TP

Depuis la racine de plume-factory, projet approuvé (chapitre A1). Trois commandes, une par ligne, identiques dans bash et PowerShell. La première est interactive, quittez-la avec Ctrl+D après avoir lu le pied de page.

pi
pi -p --mode json "ping"
pi -p "Réponds uniquement : pong"

Résultat attendu : la première commande affiche le pied de page de l’usine dès l’ouverture, builder ≈ openrouter/z-ai/glm-5.3 · medium, la jauge à 0 %, 0.00 $ / 2.00 $ et votre branche. /budget 3 change le plafond, /factory-footer rend le pied de page de série. La deuxième et la troisième tournent comme avant la pièce : le flux JSON ne contient aucune ligne de plus, la sortie est pong. Coût : la première commande ne dépense rien tant que vous ne tapez pas de prompt, les deux tours headless coûtent ensemble moins d’un centime, en quelques secondes. Variante éco : elle est déjà dans la pièce, l’extension coûte zéro jeton, le modèle est celui du chapitre A1.


Quiz — teste tes connaissances
Annexe — Harnais pi 7 questions Objectif : 5/7 minimum
0/7
bonnes reponses
Objectif non atteint (minimum 5/7 requis).
Remonte relire la fiche memo en pretant attention aux points manques, puis cliquer sur « Recommencer » pour retenter.