Annexe — Harnais pi Chapitre A2 / 43

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

Une extension pi est un fichier TypeScript qui écoute le cycle de vie d'une session pour observer, modifier ou bloquer. Comparée aux mods, hooks et status line de Claude Code. La pièce du jour : le pied de page de l'usine.

Dans une session pi, le pied de page affiche un modèle et un coût. Il ne dit pas si ce modèle est le builder ou le reviewer de votre roster, ni où en est votre budget. pi ne connaît pas votre usine. Une extension, un fichier TypeScript qui écoute les événements d’une session, le lui apprend. La pièce du jour, .pi/extensions/factory-footer.ts, en est une. pi -p tourne comme avant, et la gate le vérifie.

L’ExtensionAPI et les événements du harnais

Le gabarit

Voici une extension complète. Elle affiche le numéro du tour en cours et refuse un rm -rf /.

// .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." };
    }
  });
}

La fonction par défaut reçoit pi, un objet ExtensionAPI. pi.on("événement", handler) s’abonne à un événement. pi.registerTool et pi.registerCommand ajoutent outils et commandes.

pi découvre seul .pi/extensions/*.ts (ou */index.ts) et le charge sans compilation. Les types viennent de @earendil-works/pi-coding-agent. pi -e ./fichier.ts charge une extension pour une session d’essai, et /reload la recharge après une modification. Elle tourne dans le process de pi, pas dans le modèle, et ne coûte aucun jeton.

Le cycle de vie

pi déroule toujours le même enchaînement. Vous vous y abonnez, vous ne le modifiez pas.

  1. session_start à l’ouverture de la session.
  2. Pour chaque prompt, before_agent_start puis agent_start.
  3. Des tours. Un tour est une réponse du modèle et ses appels d’outils : turn_start, context, l’appel au modèle, puis pour chaque outil tool_execution_start, tool_call, tool_result, tool_execution_end, et enfin turn_end.
  4. agent_end, puis agent_settled quand plus aucun retry, compaction ni message en attente ne suivra. Pour un statut fiable, écoutez agent_settled.

Une phase build du runner fait par exemple six tours et neuf appels d’outils. Une extension qui compte les tool_call en voit neuf. Une extension qui bloque peut en refuser un, et le modèle lit le motif au tour suivant.

Observer, modifier, bloquer

Un handler fait l’une de trois choses.

  • Observer. agent_start, turn_end, model_select, thinking_level_select. pi ignore la valeur de retour.
  • Modifier. before_agent_start réécrit le system prompt. context filtre les messages. tool_result remplace un résultat, message_end remplace un message.
  • Bloquer. Seul tool_call le peut. Il retourne { block: true, reason } et l’outil ne s’exécute pas. Le chapitre A3 reposera sur ce retour.

Les événements dont l’usine se sert :

É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)

Le contexte et le mode

Chaque handler reçoit aussi ctx, l’état de la session : ctx.model, ctx.thinkingLevel, ctx.getContextUsage(), ctx.sessionManager.getBranch() (les messages et leur usage), ctx.cwd, ctx.isProjectTrusted() et ctx.ui.

ctx.mode vaut tui, rpc, json ou print, et ctx.hasUI n’est vrai qu’en tui et rpc. En -p et --mode json, les deux modes de votre adaptateur _run_pi, l’affichage ne fait rien et aucun dialogue ne s’ouvre. Testez ctx.hasUI avant de dessiner pour ne pas travailler pour rien à chaque phase.

Le piège. Une extension projet ne se charge que si le projet est approuvé (chapitre A1). Sur une machine neuve, sans /trust ni defaultProjectTrust global à always, votre pied de page n’apparaît pas, sans message. Et project_trust n’est visible que des extensions globales ou passées par -e.

Côté Claude Code : l’équivalent le plus proche est le mod, un plugin dont les handlers JavaScript ou TypeScript tournent dans le process de Claude Code, sans compilation. Plus simple, un hook est une commande shell déclarée dans les settings, qui reçoit l’événement en JSON sur stdin, et PreToolUse bloque l’outil en sortant avec le code 2. En claude -p, les handlers d’un mod s’exécutent mais rien de ce qu’ils dessinent n’apparaît, comme chez pi.


Le pied de page de série affiche déjà jetons, coût, contexte, modèle et niveau de réflexion. Il lui manque l’usine : le rôle du roster et le budget. ctx.ui offre trois surfaces pour les afficher, invisibles du modèle.

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

Pour un mot d’état, setStatus suffit. setFooter ne se justifie que pour une ligne qui mélange harnais et usine.

Remplacer le pied de page

setFooter reçoit une fabrique (tui, theme, footerData) => composant. Le composant expose render(width), qui retourne les lignes à afficher, invalidate() et un dispose optionnel.

  • footerData seul donne la branche git (getGitBranch()) et les statuts des autres extensions (getExtensionStatuses()). onBranchChange(cb) signale un changement de branche.
  • render tourne à chaque rafraîchissement, donc à chaque frappe. Gardez-le bon marché. Pour refléter un turn_end ou un model_select, appelez tui.requestRender().
  • Les couleurs passent par le thème, par exemple theme.fg("accent", texte). visibleWidth et truncateToWidth (@earendil-works/pi-tui) mesurent le texte sans les codes ANSI.
  • Tout se défait en passant undefined à setFooter, setWidget ou setStatus.

Exemple

Vous résumez trois traces sur le workhorse : quatre tours, quelques centimes. Le pied de page affiche builder ≈ openrouter/z-ai/glm-5.3 · medium, la jauge, 0.04 $ / 2.00 $ et la branche. Vous passez sur un modèle frontier par /model. Le rôle devient planner, et deux tours plus tard le budget passe en orange. Vous voyez le coût monter au moment où il monte.

Pour essayer une extension avant de la poser (mêmes commandes 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

Côté Claude Code : l’équivalent est la status line, une commande shell déclarée sous statusLine dans les settings. Elle reçoit en JSON le modèle, le pourcentage de contexte et le coût estimé de la session, puis affiche ce qu’elle imprime. Elle ajoute une ligne au-dessus du pied de page sans le remplacer, et ne consomme aucun jeton.

Le piège des dialogues en headless

Un tool_call qui attend ctx.ui.confirm() en mode print reçoit une réponse par défaut, et peut laisser passer ce qu’il devait interdire. Testez ctx.hasUI avant un dialogue. Quand personne n’est là pour répondre, bloquez sans poser de question.

pi et Claude Code côte à côte

piClaude Code
Étendre le harnaisextension TypeScript, dans le processmod (JS ou TS, dans le process) ou hook (commande shell)
Bloquer un outiltool_call retourne block: truehook PreToolUse qui sort avec le code 2
Essai pour une sessionpi -e ./fichier.tsclaude --plugin-dir ./dossier
Recharger après une modification/reloadautomatique pour un dossier --plugin-dir
Pied de pagesetFooter remplace celui de sériestatusLine ajoute une ligne au-dessus
UI en headlessles méthodes d’affichage ne font rienles handlers tournent, rien ne s’affiche

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

La pièce se pose dans le poste de pilotage, à côté du .pi/settings.json du chapitre A1. C’est la première brique de l’observabilité côté harnais, que le chapitre A4 raccordera à factory.db. La couture ne bouge pas : le runner possède les phases, pi les tours d’une phase, et l’extension écoute les tours. Coût : zéro jeton. Gain : voir une session dériver vers un moteur frontier pendant qu’elle dérive.


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, en TUI seulement : rôle du roster, jauge de contexte, coût face au budget, branche git. Le rôle vient de adws/adw_config/factory.config.yaml (chapitre 10), lu ligne à ligne. /factory-footer rend le pied de page de série, /budget 3 change le plafond. En -p et --mode json, le handler rend la main tout de suite.

// .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é. Les commandes sont identiques dans bash et PowerShell. La première est interactive : lisez le pied de page, puis quittez avec Ctrl+D.

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

Résultat attendu. La première affiche builder ≈ openrouter/z-ai/glm-5.3 · medium, la jauge à 0 %, 0.00 $ / 2.00 $ et votre branche. Les deux autres tournent comme avant : aucune ligne JSON de plus, et la sortie pong. Coût : moins d’un centime.


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.