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.
session_startà l’ouverture de la session.- Pour chaque prompt,
before_agent_startpuisagent_start. - 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 outiltool_execution_start,tool_call,tool_result,tool_execution_end, et enfinturn_end. agent_end, puisagent_settledquand plus aucun retry, compaction ni message en attente ne suivra. Pour un statut fiable, écoutezagent_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_startréécrit le system prompt.contextfiltre les messages.tool_resultremplace un résultat,message_endremplace un message. - Bloquer. Seul
tool_callle 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é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) |
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.
Footer et widgets : l’UI au service de l’usine
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.
| 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 |
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.
footerDataseul donne la branche git (getGitBranch()) et les statuts des autres extensions (getExtensionStatuses()).onBranchChange(cb)signale un changement de branche.rendertourne à chaque rafraîchissement, donc à chaque frappe. Gardez-le bon marché. Pour refléter unturn_endou unmodel_select, appeleztui.requestRender().- Les couleurs passent par le thème, par exemple
theme.fg("accent", texte).visibleWidthettruncateToWidth(@earendil-works/pi-tui) mesurent le texte sans les codes ANSI. - Tout se défait en passant
undefinedàsetFooter,setWidgetousetStatus.
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
| pi | Claude Code | |
|---|---|---|
| Étendre le harnais | extension TypeScript, dans le process | mod (JS ou TS, dans le process) ou hook (commande shell) |
| Bloquer un outil | tool_call retourne block: true | hook PreToolUse qui sort avec le code 2 |
| Essai pour une session | pi -e ./fichier.ts | claude --plugin-dir ./dossier |
| Recharger après une modification | /reload | automatique pour un dossier --plugin-dir |
| Pied de page | setFooter remplace celui de série | statusLine ajoute une ligne au-dessus |
| UI en headless | les méthodes d’affichage ne font rien | les 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.