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.tssert aux essais rapides,/reloadrecharge 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 promptbefore_agent_start→agent_start→ une suite de tours (turn_start,context, appel modèle, éventuelstool_execution_start→tool_call→tool_result→tool_execution_end,turn_end) →agent_end→agent_settledquand 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_startréécrit le system prompt,contextfiltre les messages,tool_resultremplace un résultat,message_endremplace un message). Et ceux qui bloquent :tool_callretourne{ 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. ctxporte l’état, pas l’événement. Chaque handler reçoit unExtensionContext:ctx.model,ctx.thinkingLevel,ctx.getContextUsage()(jetons, fenêtre, pourcentage),ctx.sessionManager.getBranch()(les entrées de la branche courante, avec l’usagede chaque message assistant),ctx.cwd,ctx.isProjectTrusted(), etctx.ui.- Le mode décide de ce que
ctx.uipeut faire.ctx.modevauttui,rpc,jsonouprint, etctx.hasUIest vrai en TUI et RPC seulement. En-pet--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énement | Quand | Peut… | Utilisé par |
|---|---|---|---|
session_start | ouverture, /new, /resume, /reload | observer, installer l’UI | A2 (pied de page), A4 (ouvrir la base) |
before_agent_start | après le prompt, avant la boucle | injecter un message, réécrire le system prompt | — (le runner impose déjà ses prompts) |
turn_start / turn_end | à chaque réponse du modèle | observer | A2 (rafraîchir), A4 (tracer) |
tool_call | avant l’exécution d’un outil | bloquer, patcher event.input | A3 (damage control) |
tool_result | après l’exécution | remplacer le résultat | — |
model_select / thinking_level_select | /model, Ctrl+P, restauration | observer | A2 (pied de page) |
agent_settled | plus rien ne suivra automatiquement | observer | A2 (statut), A4 (clore le run) |
session_shutdown | sortie, changement de session | libérer les ressources | A4 (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, etproject_trustlui-même n’est visible que des extensions globales ou passées par-e, jamais des extensions du projet. Sur une machine neuve, sans/trustni globalalways, votre pied de page n’apparaîtra pas, et pi ne s’en plaindra pas.
Footer et widgets : l’UI au service de l’usine
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
setStatussuffit : un statut nommé, écrasé à chaque appel, effacé avecundefined. setFooterreçoit une fabrique(tui, theme, footerData) => composant: le composant exposerender(width)qui retourne des lignes,invalidate(), et undisposeoptionnel.footerData.getGitBranch()etfooterData.getExtensionStatuses()sont les deux données que seule cette fabrique peut atteindre, etfooterData.onBranchChange(cb)rend le pied de page réactif au changement de branche.- Le rendu n’a lieu que si quelqu’un le demande.
renderest appelé à chaque rafraîchissement du terminal. Pour refléter unturn_endou unmodel_select, l’extension appelletui.requestRender(). Gardezrenderbon 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 utilitairesvisibleWidthettruncateToWidthde@earendil-works/pi-tuialignent 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-footerqui 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
| Surface | Vit où | Se rafraîchit | Bon pour |
|---|---|---|---|
setStatus("nom", texte) | dans le pied de page de série | à chaque appel | un mot d’état : « run en cours », « 3 outils bloqués » |
setWidget("nom", lignes) | au-dessus (ou sous) l’éditeur | à chaque appel | une liste : plan en cours, fichiers protégés touchés |
setFooter(fabrique) | remplace le pied de page | tui.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 : untool_callqui attend unctx.ui.confirm()en modectx.hasUIavant un dialogue, et préférezblocksans 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.