Annexe — Harnais pi Chapitre A5 / 43

Orchestration multi-agents & distribution en package

Des sous-agents pi bornés par le code, avec une chaîne plan → build décrite en JSON, puis le nœud pi de plume-factory empaqueté en package pi, installable d'une commande. Comparé aux subagents et aux plugins de Claude Code.

Au chapitre 27, stamp.py a copié l’usine dans un dépôt vierge. Ouvrez pi dans ce dépôt : rien de l’annexe n’est là, car le stamp ne copie pas .pi/.

Ce chapitre traite deux sujets. D’abord, comment un agent pi en lance d’autres, et pourquoi l’usine ne garde que la forme où le code fixe l’ordre. Ensuite, comment emballer .pi/ dans un package pi, installable d’une commande. Le runner Python reste maître du graphe.

Subagents, équipes, chaînes et pair-à-pair

Un sous-agent est un processus

Voici ce que l’extension du jour lance pour un sous-agent planner.

# Un sous-agent = un processus : contexte vide, outils bornés, facture séparée.
pi -p --mode json --thinking off \
  --model openrouter/z-ai/glm-5.3 \
  --tools read,grep,find,ls \
  --append-system-prompt "Vous planifiez, vous n'ecrivez jamais de fichier. Rendez un plan numerote." \
  "Planifiez : ajouter un compteur de mots dans la barre d'etat de Plume"

C’est un second pi -p, comme l’adaptateur _run_pi du chapitre 15. Il a son propre système prompt, sa propre allowlist d’outils (--tools), son propre modèle et sa propre fenêtre de contexte. Ce planner reçoit read,grep,find,ls et ne peut pas écrire.

Le sous-agent démarre vide. Il ne voit pas votre conversation. Vous gagnez un contexte propre et une facture par tâche. En échange, tout ce qu’il doit savoir passe par son prompt. Croire qu’il partage le contexte de l’agent parent est l’erreur la plus courante.

Côté Claude Code : claude -p offre des réglages équivalents : --tools pour restreindre les outils intégrés, --append-system-prompt, --model et --output-format json.

Qui fixe l’ordre

Avec plusieurs sous-agents, il faut décider de l’ordre. Trois formes existent.

  • Chaîne. Un fichier liste les étapes. La sortie de l’étape N devient $INPUT de l’étape N+1, et $ORIGINAL rappelle la demande de départ.
  • Équipe. Le modèle principal choisit le spécialiste à appeler, via un outil dispatch_agent.
  • Pair-à-pair. Deux agents à égalité s’envoient des messages, sans orchestrateur.

Trois façons de faire travailler plusieurs agents

FormeQui décide de l’ordreReproductiblePlace dans l’usine
Chaînele fichier de chaîne (code)oui, à la variabilité du modèle prèsversion de poche du runner, pour explorer
Équipele modèle principalnonà éviter
Pair-à-pairpersonnenonoutil de recherche
Runner Python (ch. 13)runner.py, avec gatesoui, verdict comprisla référence

Seule la chaîne est déterministe. L’usine la garde.

Côté Claude Code : les subagents natifs suivent la forme équipe. Ce sont des fichiers Markdown à frontmatter YAML dans .claude/agents/, avec des champs tools et model. Claude décide seul de les appeler, d’après leur description. Les agent teams, expérimentales et désactivées par défaut, se rapprochent du pair-à-pair : les coéquipiers s’écrivent directement.

La chaîne de l’usine

Le fichier .pi/agents/factory-chain.json décrit des agents (système prompt, outils) et des chaînes (des étapes, chacune avec son agent et son gabarit de prompt).

{
  "agents": {
    "planner": {
      "tools": "read,grep,find,ls",
      "system": "Vous planifiez, vous n'ecrivez jamais de fichier. Rendez un plan numerote."
    }
  },
  "chains": {
    "plan-only": {
      "description": "Un plan, rien d'autre",
      "steps": [
        { "agent": "planner", "prompt": "Planifiez : $INPUT" }
      ]
    }
  }
}

L’outil run_chain n’apparaît dans aucune allowlist, donc un sous-agent ne relance pas de chaîne. Le damage control du chapitre A3 se charge dans chaque sous-processus.

Entre deux étapes, seule une enveloppe passe : le texte final (le dernier message_end d’assistant du flux JSON), le coût, les jetons et la durée.

Exemple : vous demandez la chaîne plan-build pour un compteur de mots dans Plume. Le planner rend un plan numéroté en une trentaine de secondes, pour moins d’un centime. Le builder reçoit ce plan en $INPUT, modifie deux fichiers et lance bun test, en deux minutes, pour quelques centimes. Votre session reçoit le texte final et deux factures, mais pas de verdict. Le verdict reste à uv run adws/adw_sdlc.py.


Empaqueter sa configuration pi en package

Un manifeste dans package.json

Un package pi est un dossier avec un package.json. Voici le plus court qui fonctionne.

{
  "name": "mon-noeud-pi",
  "version": "0.1.0",
  "private": true,
  "keywords": ["pi-package"],
  "pi": {
    "extensions": [".pi/extensions"],
    "skills": [".claude/skills"]
  },
  "peerDependencies": {
    "@earendil-works/pi-coding-agent": "*"
  }
}

Le champ pi est le manifeste. Il dit où chercher extensions et skills, en chemins relatifs au package. Sans lui, pi cherche dans extensions/, skills/, prompts/ et themes/, qui ne sont pas les dossiers de l’usine.

Les paquets cœur que pi embarque, comme @earendil-works/pi-coding-agent ou typebox, vont en peerDependencies avec "*". pi les fournit au chargement. Ce dont la gate Bun a besoin pour tester à sec va en devDependencies.

Installer un package

# Enregistrer le package dans le projet courant (écrit .pi/settings.json, à commiter)
pi install -l /chemin/absolu/vers/plume-factory

# L'essayer sans rien écrire : chargé pour ce run seulement
pi -e ../plume-factory -p --approve "ping"

# Ne charger qu'une partie : la forme objet dans .pi/settings.json
# { "packages": [ { "source": "/chemin/vers/plume-factory", "extensions": [".pi/extensions/factory-obs.ts"], "skills": [] } ] }

pi install accepte trois sources : npm:@scope/pkg@1.2.3, git:github.com/user/repo@v1 ou un chemin local. npm et git sont copiés, et npm install est lancé. Un chemin local est référencé sans copie. Réservez-le au poste de travail.

Sans drapeau, pi install écrit dans ~/.pi/agent/settings.json. Avec -l, il écrit packages dans le .pi/settings.json du dépôt, que vous commitez. pi installe alors les packages manquants au démarrage, une fois le projet approuvé. pi -e charge un package pour un seul run.

Côté Claude Code : l’équivalent est le plugin (manifeste optionnel .claude-plugin/plugin.json), qui transporte skills, subagents, hooks et serveurs MCP. Il s’installe depuis une marketplace, un catalogue qui peut être un dépôt git ou un dossier local. --scope project l’active dans .claude/settings.json, mais chaque collaborateur doit encore l’installer une fois. --plugin-dir le charge pour une session, comme pi -e.

Ce que le package ne transporte pas

Un package transporte des capacités : extensions, skills, prompts, thèmes. Il ne transporte pas de réglages. defaultModel, compaction ou retry (chapitre A1) restent ceux du dépôt qui l’installe. Pour les mêmes réglages partout, le stamp copie un .pi/settings.json de plus.

Essayez dans un dépôt vierge, ../pi-essai. pi install -l /chemin/vers/plume-factory prend une seconde et zéro jeton. Lancez ensuite pi -p --approve --mode json "ping". Sans --approve, un projet non approuvé est ignoré sans message en headless (chapitre A1). Une trace apparaît sous adws/adw_data/traces/harness/ : les extensions du package tournent dans un dépôt sans adws/ ni runner. Le damage control aussi, sans règles, donc sans rien bloquer. Le champ model du flux montre pourtant le modèle du global : le modèle par défaut n’a pas voyagé.

CanalCe qui voyageCe qui resteCommande
Stamp (ch. 27)adws/, rosters, justfile, .env.sample, skillspayload, .env, adw_data/, .pi/uv run .claude/skills/factory/scripts/stamp.py --into …
Package pi (A5)extensions, skills, prompts, thèmessettings.json, règles de damage control, secretspi install -l <source>
Extension à l’essaiun fichier ou un package, pour un runtout, rien n’est écrit dans les réglagespi -e <source>
Dépôt clonétout, y compris ce qui ne devrait pasriengit clone

Pour reproduire plume-factory dans un autre dépôt, le stamp copie adws/ et le package apporte .pi/. Deux commandes, une minute.

Côté Claude Code : même séparation. Dans les réglages d’un plugin, seules les clés agent et subagentStatusLine s’appliquent.

pi et Claude Code côte à côte

piClaude Code
Sous-agentun pi -p lancé par une extensionun subagent Markdown dans .claude/agents/
Qui choisit l’agent suivantle fichier de chaîneClaude, d’après la description
Agents qui se parlentpair-à-pairagent teams, expérimentales
Emballer sa configurationpackage pi, champ pi du package.jsonplugin, .claude-plugin/plugin.json
Installer pour le dépôtpi install -l, installé au démarrage--scope project, chacun installe une fois
Charger pour un essaipi -e--plugin-dir
Réglages dans le paquetaucunseulement agent et subagentStatusLine

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

La pièce du jour ferme la zone distribution côté harnais. package.json rejoint le skill factory (chapitre 26) et stamp.py (chapitre 27). Le poste de pilotage gagne une quatrième extension, factory-chain.ts.

La couture ne bouge pas. Le runner Python possède le graphe des phases. La chaîne sert à explorer, sans gate : elle propose, elle ne dispose pas. Une chaîne plan-build coûte quelques centimes et deux à trois minutes, installer le package une seconde. L’annexe a rendu le nœud pi plus sûr (A3), plus observable (A4) et, aujourd’hui, transportable.


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. Aujourd’hui, trois fichiers : le fichier de chaînes, l’extension qui les exécute, et le package.json qui fait voyager .pi/.

Pièce — .pi/agents/factory-chain.json

Deux agents et une chaîne. Le planner ne peut pas écrire. Le builder peut, et doit lancer bun test avant de rendre la main. Sans gate, c’est une consigne et non une garantie. Le runner du chapitre 13 corrige ce point.

{
  "agents": {
    "planner": {
      "description": "Planifie sans ecrire : un plan numerote, les fichiers a toucher, les risques.",
      "tools": "read,grep,find,ls",
      "system": "Vous etes le planner de l'usine plume-factory. Vous lisez le depot, vous n'ecrivez jamais de fichier. Rendez un plan numerote, court, qui nomme chaque fichier a modifier et le test qui prouvera le resultat. Repondez en francais."
    },
    "builder": {
      "description": "Implemente un plan donne, lance bun test, rend un compte rendu.",
      "tools": "read,write,edit,bash,grep,find,ls",
      "system": "Vous etes le builder de l'usine plume-factory. Vous implementez exactement le plan recu, sans l'etendre. Le payload vit dans apps/plume ; ses tests se lancent avec bun test depuis apps/plume. Avant de rendre la main, lancez les tests et rapportez leur resultat tel quel. Repondez en francais : fichiers touches, resultat des tests, ce qui reste ouvert."
    }
  },
  "chains": {
    "plan-build": {
      "description": "Plan puis implementation, en deux sous-agents isoles",
      "steps": [
        { "agent": "planner", "prompt": "Planifiez l'implementation de : $INPUT" },
        { "agent": "builder", "prompt": "Implementez ce plan :\n\n$INPUT\n\nDemande d'origine : $ORIGINAL" }
      ]
    },
    "plan-only": {
      "description": "Un plan, rien d'autre — pour reflechir sans toucher au depot",
      "steps": [
        { "agent": "planner", "prompt": "Planifiez l'implementation de : $INPUT" }
      ]
    }
  }
}

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

L’extension enregistre l’outil run_chain et la commande /chains. Elle lit le fichier de chaînes du projet, ou à défaut celui du package, ce qui la rend autonome dans un dépôt vierge. Chaque étape tourne sur le modèle de la session courante. Une étape en échec ou muette arrête la chaîne. La gate Bun du fichier vérifie gabarits et arguments sans lancer de processus. Sous Windows, si exec ne trouve pas pi, exportez FACTORY_PI_BIN=pi.cmd.

// .pi/extensions/factory-chain.ts — des sous-agents bornés, une séquence tenue par un fichier.
// La loi ne change pas : le fichier de chaînes séquence, le modèle exécute une étape bornée,
// et seule une enveloppe (texte, coût, jetons, durée) traverse d'une étape à l'autre.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

const PROJECT_CHAINS = join(".pi", "agents", "factory-chain.json");
const PACKAGED_CHAINS = join(import.meta.dirname, "..", "agents", "factory-chain.json");
const STEP_TIMEOUT_MS = 10 * 60 * 1000; // dix minutes par étape : au-delà, on rend la main
const PI_BIN = process.env.FACTORY_PI_BIN ?? "pi";

// ---------- le contrat : ce que le fichier de chaînes doit contenir ----------

export interface AgentDef { description?: string; tools: string; system: string }
export interface ChainStep { agent: string; prompt: string }
export interface ChainDef { description?: string; steps: ChainStep[] }
export interface ChainConfig { agents: Record<string, AgentDef>; chains: Record<string, ChainDef> }

// L'enveloppe d'une étape : ce qui traverse, et rien d'autre.
export interface StepEnvelope {
  step: number;
  agent: string;
  model: string;
  output: string;
  tokens: number;
  cost_usd: number;
  elapsed_ms: number;
  exit_code: number;
}

export function loadConfig(cwd: string): { config: ChainConfig; source: string } {
  const projectFile = join(cwd, PROJECT_CHAINS);
  const file = existsSync(projectFile) ? projectFile : PACKAGED_CHAINS;
  const config = JSON.parse(readFileSync(file, "utf8")) as ChainConfig;
  for (const [name, chain] of Object.entries(config.chains)) {
    for (const step of chain.steps) {
      if (!config.agents[step.agent]) throw new Error(`chaîne ${name} : agent inconnu « ${step.agent} »`);
      if (/\brun_chain\b/.test(config.agents[step.agent].tools)) throw new Error(`agent ${step.agent} : run_chain interdit dans un sous-agent`);
    }
  }
  return { config, source: file };
}

// $INPUT = la sortie de l'étape précédente (ou la demande, à la première) ; $ORIGINAL = la demande.
export function resolvePrompt(template: string, input: string, original: string): string {
  return template.replace(/\$INPUT/g, input).replace(/\$ORIGINAL/g, original);
}

// Les arguments d'un sous-agent : headless, JSON, sans réflexion, outils bornés, prompt en dernier.
export function argsFor(agent: AgentDef, model: string, prompt: string): string[] {
  return [
    "-p", "--mode", "json", "--thinking", "off",
    "--model", model,
    "--tools", agent.tools,
    "--append-system-prompt", agent.system,
    "--", prompt,
  ];
}

// Lire le flux JSON d'un `pi -p --mode json` : le dernier message d'assistant est la réponse,
// et chaque message d'assistant porte sa facture (même source que la trace du chapitre A4).
export function parseStream(stdout: string): { output: string; tokens: number; cost_usd: number } {
  let output = "";
  let tokens = 0;
  let cost = 0;
  for (const line of stdout.split("\n")) {
    if (!line.trim()) continue;
    let event: { type?: string; message?: { role?: string; content?: unknown; usage?: { totalTokens?: number; cost?: { total?: number } } } };
    try { event = JSON.parse(line); } catch { continue; }
    if (event.type !== "message_end" || event.message?.role !== "assistant") continue;
    const parts = Array.isArray(event.message.content) ? event.message.content : [];
    const text = parts
      .filter((p) => (p as { type?: string })?.type === "text")
      .map((p) => (p as { text?: string }).text ?? "")
      .join("");
    if (text.trim()) output = text; // la dernière réponse textuelle gagne
    tokens += event.message.usage?.totalTokens ?? 0;
    cost += event.message.usage?.cost?.total ?? 0;
  }
  return { output, tokens, cost_usd: cost };
}

function summarize(envelopes: StepEnvelope[]): string {
  const cost = envelopes.reduce((s, e) => s + e.cost_usd, 0);
  const tokens = envelopes.reduce((s, e) => s + e.tokens, 0);
  const seconds = Math.round(envelopes.reduce((s, e) => s + e.elapsed_ms, 0) / 1000);
  return `${envelopes.length} étape(s) · ${tokens} jetons · ${cost.toFixed(4)} $ · ${seconds} s`;
}

// ---------- l'extension : un outil, une commande, aucune décision confiée au modèle ----------

export default function (pi: ExtensionAPI) {
  pi.registerTool({
    name: "run_chain",
    label: "Run Chain",
    description: "Exécute une chaîne d'agents définie dans .pi/agents/factory-chain.json : chaque étape est un sous-agent pi isolé, la sortie d'une étape nourrit la suivante. Sans gate : pour explorer, pas pour livrer.",
    parameters: Type.Object({
      chain: Type.String({ description: "Nom de la chaîne (ex. plan-build, plan-only)" }),
      task: Type.String({ description: "La demande à traiter" }),
    }),

    async execute(_toolCallId, params, signal, onUpdate, ctx) {
      const { chain: chainName, task } = params as { chain: string; task: string };
      const { config } = loadConfig(ctx.cwd);
      const chain = config.chains[chainName];
      // Une erreur se signale en levant : c'est ainsi que pi marque le résultat isError pour le modèle.
      if (!chain) throw new Error(`Chaîne inconnue « ${chainName} ». Disponibles : ${Object.keys(config.chains).join(", ")}`);
      const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
      if (!model) throw new Error("Aucun modèle actif : impossible de lancer un sous-agent.");

      const envelopes: StepEnvelope[] = [];
      let input = task;
      for (const [index, step] of chain.steps.entries()) {
        const agent = config.agents[step.agent];
        onUpdate?.({ content: [{ type: "text", text: `étape ${index + 1}/${chain.steps.length} · ${step.agent} · en cours` }], details: { envelopes } });
        const started = Date.now();
        const result = await pi.exec(PI_BIN, argsFor(agent, model, resolvePrompt(step.prompt, input, task)),
          { cwd: ctx.cwd, signal, timeout: STEP_TIMEOUT_MS });
        const parsed = parseStream(result.stdout);
        const envelope: StepEnvelope = {
          step: index + 1, agent: step.agent, model, output: parsed.output,
          tokens: parsed.tokens, cost_usd: parsed.cost_usd,
          elapsed_ms: Date.now() - started, exit_code: result.code,
        };
        envelopes.push(envelope);
        // Échec fermé : une étape rouge ou muette arrête la chaîne, l'enveloppe dit laquelle.
        if (envelope.exit_code !== 0 || !envelope.output.trim()) {
          const why = result.stderr.trim().split("\n").pop() || "sortie vide";
          throw new Error(`[chaîne ${chainName}] arrêt à l'étape ${envelope.step} (${step.agent}) : ${why} · ${summarize(envelopes)}`);
        }
        input = envelope.output;
      }

      return {
        content: [{ type: "text", text: `[chaîne ${chainName}] terminée · ${summarize(envelopes)}\n\n${input}` }],
        details: { chain: chainName, envelopes },
      };
    },
  });

  pi.registerCommand("chains", {
    description: "Lister les chaînes d'agents disponibles",
    handler: async (_args, ctx) => {
      if (!ctx.hasUI) return;
      const { config, source } = loadConfig(ctx.cwd);
      const lines = Object.entries(config.chains)
        .map(([name, c]) => `${name} : ${c.steps.map((s) => s.agent).join(" → ")}${c.description ? ` — ${c.description}` : ""}`);
      ctx.ui.notify(`${lines.join("\n")}\n(${source})`, "info");
    },
  });
}

// ---------- la gate du fichier : `bun .pi/extensions/factory-chain.ts`, zéro jeton, zéro processus ----------

if (import.meta.main) {
  const { config, source } = loadConfig(process.cwd());
  const chain = config.chains["plan-build"];
  const prompt = resolvePrompt(chain.steps[1].prompt, "1. Lire apps/plume/src/editor.ts", "un compteur de mots");
  const args = argsFor(config.agents["builder"], "openrouter/z-ai/glm-5.3", prompt);
  const stream = [
    JSON.stringify({ type: "session", id: "x" }),
    JSON.stringify({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "Plan : 1. editor.ts" }], usage: { totalTokens: 900, cost: { total: 0.001 } } } }),
    JSON.stringify({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "Fait. bun test : 6 pass." }], usage: { totalTokens: 2100, cost: { total: 0.003 } } } }),
  ].join("\n");
  const parsed = parseStream(stream);
  const agents = Object.keys(config.agents).length;
  const chains = Object.keys(config.chains).length;
  const ok =
    chain.steps.length === 2 &&
    prompt.includes("1. Lire apps/plume/src/editor.ts") && prompt.includes("Demande d'origine : un compteur de mots") &&
    args[0] === "-p" && args.includes("--tools") && args[args.length - 1] === prompt &&
    !config.agents["planner"].tools.includes("write") && config.agents["builder"].tools.includes("bash") &&
    parsed.output === "Fait. bun test : 6 pass." && parsed.tokens === 3000 && Math.abs(parsed.cost_usd - 0.004) < 1e-9;
  console.log(`factory-chain ${ok ? "OK" : "KO"} — ${agents} agents, ${chains} chaînes, gabarits résolus, outils bornés, flux JSON lu (${source})`);
  process.exit(ok ? 0 : 1);
}

Pièce — package.json

À la racine de plume-factory (celui de Plume vit dans apps/plume/). Le manifeste pointe vers les extensions de l’annexe et les skills des chapitres 25-26. private empêche une publication npm accidentelle.

{
  "name": "plume-factory-pi",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "description": "Le noeud pi de l'usine plume-factory : pied de page, damage control, trace harnais, chaines d'agents et skills de l'usine.",
  "keywords": ["pi-package"],
  "pi": {
    "extensions": [".pi/extensions"],
    "skills": [".claude/skills"]
  },
  "peerDependencies": {
    "@earendil-works/pi-coding-agent": "*",
    "typebox": "*"
  },
  "peerDependenciesMeta": {
    "@earendil-works/pi-coding-agent": { "optional": true },
    "typebox": { "optional": true }
  },
  "devDependencies": {
    "typebox": "1.3.7"
  }
}

Les peerDependenciesMeta marquent les paquets cœur comme optionnels. bun install ne télécharge donc que typebox, pas pi entier.

La gate du TP

Depuis la racine de plume-factory, projet approuvé (chapitre A1). Une commande par ligne, identiques dans bash et PowerShell. Remplacez le chemin absolu par celui de votre dépôt.

bun install
bun .pi/extensions/factory-chain.ts
pi -p --approve --mode json --tools run_chain "Appelle run_chain avec chain=plan-only et task=ajouter un compteur de mots dans la barre d'etat de Plume. Rends le resultat tel quel."
git init ../pi-essai
cd ../pi-essai
pi install -l /chemin/absolu/vers/plume-factory
pi -p --approve --mode json "ping"
ls adws/adw_data/traces/harness
cd ../plume-factory

Résultat attendu :

  1. factory-chain OK — 2 agents, 2 chaînes, gabarits résolus, outils bornés, flux JSON lu.
  2. Un tool_execution_end pour run_chain dont le texte commence par [chaîne plan-only] terminée · 1 étape(s), suivi d’un plan. Moins d’un centime, ~30 s.
  3. Dans pi-essai, une entrée packages dans .pi/settings.json, et un fichier .jsonl listé par ls. Zéro jeton pour l’installation.

Pour la chaîne complète, ouvrez pi, tapez /chains, puis demandez plan-build sur une petite évolution de Plume (quelques centimes, deux à trois minutes). Effacez ../pi-essai à la fin.


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.