Extensions
pi peut créer des extensions. Demandez-lui d'en créer un pour votre cas d'utilisation.
Extensions sont des modules TypeScript qui étendent le comportement de pi. Ils peuvent s'abonner aux événements du cycle de vie, enregistrer des outils personnalisés appelables par le LLM, ajouter des commandes, etc.
Placement pour /reload: Placez les extensions dans
~/.pi/agent/extensions/(global) ou.pi/extensions/(projet-local) pour la découverte automatique. Utilisezpi -e./path.tsuniquement pour les tests rapides. Extensions dans les emplacements découverts automatiquement peut être rechargé à chaud avec/reload.
Capacités clés:
- Outils personnalisés - Enregistrez les outils que le LLM peut appeler via
pi.registerTool() - Interception d'événements – Bloquer ou modifier les appels d'outils, injecter du contexte, personnaliser le compactage
- Interaction utilisateur - Inviter les utilisateurs via
ctx.ui(sélectionner, confirmer, saisir, notifier) - Composants d'interface utilisateur personnalisés - Composants complets TUI avec saisie au clavier via
ctx.ui.custom()pour des interactions complexes - Commandes personnalisées - Enregistrez des commandes comme
/mycommandviapi.registerCommand() - Persistance de session – État de stockage qui survit aux redémarrages via
pi.appendEntry() - Rendu personnalisé - Contrôlez la façon dont les appels/résultats et les messages de l'outil apparaissent dans TUI
Exemples de cas d'utilisation:
- Portes d'autorisation (confirmer avant
rm -rf,sudo, etc.) - Git checkpointing (cache à chaque tour, restauration sur branche)
- Protection du chemin (le bloc écrit dans
.env,node_modules/) - Compactage personnalisé (résumez la conversation à votre manière)
- Résumés de conversation (voir exemple
summarize.ts) - Outils interactifs (questions, assistants, boîtes de dialogue personnalisées)
- Outils avec état (listes de tâches, pools de connexions)
- Intégrations externes (observateurs de fichiers, webhooks, déclencheurs CI)
- Jeux en attendant (voir exemple
snake.ts)
Voir examples/extensions/ pour les implémentations fonctionnelles.
Table des matières
- Quick Start
- Extension Locations
- Available Imports
- Writing an Extension
- Events
- ExtensionContext
- ExtensionCommandContext
- ExtensionAPI Methods
- State Management
- Custom Tools
- Custom UI
- Error Handling
- Mode Behavior
- Examples Reference
Démarrage rapide
Créez ~/.pi/agent/extensions/my-extension.ts:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
export default function (pi: ExtensionAPI) {
// React to events
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify("Extension loaded!", "info");
});
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
if (!ok) return { block: true, reason: "Blocked by user" };
}
});
// Register a custom tool
pi.registerTool({
name: "greet",
label: "Greet",
description: "Greet someone by name",
parameters: Type.Object({
name: Type.String({ description: "Name to greet" }),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
return {
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: {},
};
},
});
// Register a command
pi.registerCommand("hello", {
description: "Say hello",
handler: async (args, ctx) => {
ctx.ui.notify(`Hello ${args || "world"}!`, "info");
},
});
}Testez avec le drapeau --extension (ou -e):
pi -e ./my-extension.tsEmplacements des extensions
Sécurité: Extensions s'exécute avec toutes les autorisations de votre système et peut exécuter du code arbitraire. Installez uniquement à partir de sources fiables.
Extensions sont découverts automatiquement à partir d'emplacements fiables. Les entrées .pi/extensions locales du projet se chargent uniquement une fois que le projet est approuvé.
| Emplacement | Portée |
|---|---|
~/.pi/agent/extensions/*.ts |
Global (tous les projets) |
~/.pi/agent/extensions/*/index.ts |
Global (sous-répertoire) |
.pi/extensions/*.ts |
Projet-local |
.pi/extensions/*/index.ts |
Projet-local (sous-répertoire) |
Chemins supplémentaires via settings.json:
{
"packages": [
"npm:@foo/bar@1.0.0",
"git:github.com/user/repo@v1"
],
"extensions": [
"/path/to/local/extension.ts",
"/path/to/local/extension/dir"
]
}Pour partager des extensions via npm ou git en tant que packages pi, voir packages.md.
Importations disponibles
| Emballer | But |
|---|---|
@earendil-works/pi-coding-agent |
Types d'extensions (ExtensionAPI, ExtensionContext, événements) |
typebox |
Définitions de schéma pour les paramètres d'outil |
@earendil-works/pi-ai |
Utilitaires d'IA (StringEnum pour les énumérations compatibles Google) |
@earendil-works/pi-tui |
TUI composants pour un rendu personnalisé |
npm les dépendances fonctionnent aussi. Ajoutez un package.json à côté de votre extension (ou dans un répertoire parent), exécutez npm install et les importations depuis node_modules/ sont résolues automatiquement.
Pour les packages pi distribués installés avec pi install (npm ou git), les dépôts d'exécution doivent être en dependencies. L'installation du package utilise les installations de production (npm install --omit=dev) par défaut, donc devDependencies ne sont pas disponibles au moment de l'exécution; lorsque npmCommand est configuré, les packages git utilisent plain install pour la compatibilité avec les wrappers.
Node.js intégrés (node:fs, node:path, etc.) sont également disponibles.
Écrire une extension
Une extension exporte une fonction d'usine par défaut qui reçoit ExtensionAPI. La fabrique peut être synchrone ou asynchrone:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// Subscribe to events
pi.on("event_name", async (event, ctx) => {
// ctx.ui for user interaction
const ok = await ctx.ui.confirm("Title", "Are you sure?");
ctx.ui.notify("Done!", "info");
ctx.ui.setStatus("my-ext", "Processing..."); // Footer status
ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor (default)
});
// Register tools, commands, shortcuts, flags
pi.registerTool({ ... });
pi.registerCommand("name", { ... });
pi.registerShortcut("ctrl+x", { ... });
pi.registerFlag("my-flag", { ... });
}Extensions sont chargés via jiti, donc TypeScript fonctionne sans compilation.
Si l'usine renvoie un Promise, pi l'attend avant de continuer le démarrage. Cela signifie que l'initialisation asynchrone se termine avant session_start, avant resources_discover et avant que les enregistrements de fournisseurs mis en file d'attente via pi.registerProvider() ne soient vidés.
Fonctions d'usine asynchrone
Utilisez une usine asynchrone pour un travail de démarrage ponctuel, comme la récupération de la configuration à distance ou la découverte dynamique des modèles disponibles.
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default async function (pi: ExtensionAPI) {
const response = await fetch("http://localhost:1234/v1/models");
const payload = (await response.json()) as {
data: Array<{
id: string;
name?: string;
context_window?: number;
max_tokens?: number;
}>;
};
pi.registerProvider("local-openai", {
baseUrl: "http://localhost:1234/v1",
apiKey: "$LOCAL_OPENAI_API_KEY",
api: "openai-completions",
models: payload.data.map((model) => ({
id: model.id,
name: model.name ?? model.id,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: model.context_window ?? 128000,
maxTokens: model.max_tokens ?? 4096,
})),
});
}Ce modèle rend les modèles récupérés disponibles lors du démarrage normal et jusqu'à pi --list-models.
Ressources de longue durée et arrêt
Les fabriques d'extensions peuvent s'exécuter dans des appels qui ne démarrent jamais de session. Ne démarrez pas les ressources en arrière-plan telles que les processus, les sockets, les observateurs de fichiers ou les minuteries depuis l'usine.
Différez le démarrage de la ressource en arrière-plan jusqu'à session_start ou jusqu'à la commande/outil/événement qui a besoin de la ressource. Enregistrez un gestionnaire session_shutdown idempotent pour fermer toutes les ressources de session que vous démarrez.
Styles d'extensions
Fichier unique - le plus simple, pour les petites extensions:
~/.pi/agent/extensions/
└── my-extension.tsRépertoire avec index.ts - pour les extensions multi-fichiers:
~/.pi/agent/extensions/
└── my-extension/
├── index.ts # Entry point (exports default function)
├── tools.ts # Helper module
└── utils.ts # Helper modulePackage avec dépendances - pour les extensions qui nécessitent npm packages:
~/.pi/agent/extensions/
└── my-extension/
├── package.json # Declares dependencies and entry points
├── package-lock.json
├── node_modules/ # After npm install
└── src/
└── index.ts// package.json
{
"name": "my-extension",
"dependencies": {
"zod": "^3.0.0",
"chalk": "^5.0.0"
},
"pi": {
"extensions": ["./src/index.ts"]
}
}Exécutez npm install dans le répertoire d'extension, puis les importations depuis node_modules/ fonctionnent automatiquement.
Événements
Aperçu du cycle de vie
pi starts
│
├─► project_trust (user/global and CLI extensions only, before project resources load)
├─► session_start { reason: "startup" }
└─► resources_discover { reason: "startup" }
│
▼
user sends prompt ─────────────────────────────────────────┐
│ │
├─► (extension commands checked first, bypass if found) │
├─► input (can intercept, transform, or handle) │
├─► (skill/template expansion if not handled) │
├─► before_agent_start (can inject message, modify system prompt)
├─► agent_start │
├─► message_start / message_update / message_end │
│ │
│ ┌─── turn (repeats while LLM calls tools) ───┐ │
│ │ │ │
│ ├─► turn_start │ │
│ ├─► context (can modify messages) │ │
│ ├─► before_provider_headers (can mutate headers) |
│ ├─► before_provider_request (can inspect or replace payload)
│ ├─► after_provider_response (status + headers, before stream consume)
│ │ │ │
│ │ LLM responds, may call tools: │ │
│ │ ├─► tool_execution_start │ │
│ │ ├─► tool_call (can block) │ │
│ │ ├─► tool_execution_update │ │
│ │ ├─► tool_result (can modify) │ │
│ │ └─► tool_execution_end │ │
│ │ │ │
│ └─► turn_end │ │
│ │
├─► agent_end │
└─► agent_settled (no retry/compaction/follow-up left) │
│
user sends another prompt ◄────────────────────────────────┘
/new (new session) or /resume (switch session)
├─► session_before_switch (can cancel)
├─► session_shutdown
├─► session_start { reason: "new" | "resume", previousSessionFile? }
└─► resources_discover { reason: "startup" }
/fork or /clone
├─► session_before_fork (can cancel)
├─► session_shutdown
├─► session_start { reason: "fork", previousSessionFile }
└─► resources_discover { reason: "startup" }
/name or pi.setSessionName()
└─► session_info_changed
/compact or auto-compaction
├─► session_before_compact (can cancel or customize)
└─► session_compact
/tree navigation
├─► session_before_tree (can cancel or customize)
└─► session_tree
/model or Ctrl+P (model selection/cycling)
├─► thinking_level_select (if model change changes/clamps thinking level)
└─► model_select
thinking level changes (settings, keybinding, pi.setThinkingLevel())
└─► thinking_level_select
exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM)
└─► session_shutdownÉvénements de démarrage
projet_trust
Lancé avant que pi ne décide s'il doit faire confiance à un projet avec des configurations dynamiques (.pi ou .agents/skills). Il s'exécute au démarrage et lors du remplacement de session (par exemple /resume) entre dans un cwd dont la confiance n'a pas été résolue dans le processus en cours. Seules les extensions utilisateur/globales et les extensions CLI -e participent; les extensions locales du projet ne sont chargées qu'une fois la confiance résolue.
pi.on("project_trust", async (event, ctx) => {
// event.cwd - current working directory
// ctx has a limited trust context: cwd, mode, hasUI, and select/confirm/input/notify UI helpers
if (await ctx.ui.confirm("Trust project?", event.cwd)) {
return { trusted: "yes", remember: true };
}
return { trusted: "undecided" };
});Un gestionnaire project_trust doit renvoyer { trusted: "yes" | "no" | "undecided" }. Un utilisateur/global ou une extension CLI qui renvoie "yes" ou "no" est propriétaire de la décision; la première décision oui/non l'emporte et supprime l'invite de confiance intégrée. Utilisez remember: true pour conserver une décision oui/non; sinon, cela s'applique uniquement au processus en cours. Renvoyez "undecided" pour laisser les gestionnaires ultérieurs ou le flux de confiance intégré décider. Vérifiez ctx.hasUI avant de demander. Si aucun gestionnaire ne renvoie oui/non, la résolution de confiance normale continue: les décisions trust.json enregistrées s'appliquent en premier, puis defaultProjectTrust contrôle si pi demande, approuve ou refuse par défaut.
Événements de ressources
ressources_découvrir
Déclenché après session_start afin que les extensions puissent apporter des chemins de compétences, d'invites et de thèmes supplémentaires.
Le chemin de démarrage utilise reason: "startup". Le rechargement utilise reason: "reload".
pi.on("resources_discover", async (event, _ctx) => {
// event.cwd - current working directory
// event.reason - "startup" | "reload"
return {
skillPaths: ["/path/to/skills"],
promptPaths: ["/path/to/prompts"],
themePaths: ["/path/to/themes"],
};
});Événements de session
Voir Session Format pour les composants internes du stockage de session et le SessionManager API.
session_start
Déclenché lorsqu'une session est démarrée, chargée ou rechargée.
pi.on("session_start", async (event, ctx) => {
// event.reason - "startup" | "reload" | "new" | "resume" | "fork"
// event.previousSessionFile - present for "new", "resume", and "fork"
ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
});session_info_changed
Déclenché lorsque le nom d'affichage de la session actuelle est défini via /name, RPC ou pi.setSessionName().
pi.on("session_info_changed", async (event, ctx) => {
// event.name - current normalized name, or undefined if cleared
ctx.ui.notify(`Session renamed: ${event.name ?? "(none)"}`, "info");
});session_avant_switch
Déclenché avant de démarrer une nouvelle session (/new) ou de changer de session (/resume).
pi.on("session_before_switch", async (event, ctx) => {
// event.reason - "new" or "resume"
// event.targetSessionFile - session we're switching to (only for "resume")
if (event.reason === "new") {
const ok = await ctx.ui.confirm("Clear?", "Delete all messages?");
if (!ok) return { cancel: true };
}
});Après un changement réussi ou une action de nouvelle session, pi émet session_shutdown pour l'ancienne instance d'extension, recharge et relie les extensions pour la nouvelle session, puis émet session_start avec reason: "new" | "resume" et previousSessionFile.
Effectuez un travail de nettoyage en session_shutdown, puis rétablissez tout état en mémoire en session_start.
session_avant_fork
Lancé lors d'un fork via /fork ou d'un clonage via /clone.
pi.on("session_before_fork", async (event, ctx) => {
// event.entryId - ID of the selected entry
// event.position - "before" for /fork, "at" for /clone
return { cancel: true }; // Cancel fork/clone
// OR
return { skipConversationRestore: true }; // Reserved for future conversation restore control
});Après un fork ou un clone réussi, pi émet session_shutdown pour l'ancienne instance d'extension, recharge et relie les extensions pour la nouvelle session, puis émet session_start avec reason: "fork" et previousSessionFile.
Effectuez un travail de nettoyage en session_shutdown, puis rétablissez tout état en mémoire en session_start.
session_avant_compact / session_compact
Tiré par compactage. Voir compaction.md pour plus de détails.
pi.on("session_before_compact", async (event, ctx) => {
const { preparation, branchEntries, customInstructions, reason, willRetry, signal } = event;
// reason - "manual" (/compact), "threshold", or "overflow"
// willRetry - whether the aborted turn is retried after compaction (overflow recovery)
// Cancel:
return { cancel: true };
// Custom summary:
return {
compaction: {
summary: "...",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
// usage: summaryResponse.usage, // Optional; included in session totals
}
};
});
pi.on("session_compact", async (event, ctx) => {
// event.compactionEntry - the saved compaction
// event.fromExtension - whether extension provided it
// event.reason - "manual" (/compact), "threshold", or "overflow"
// event.willRetry - whether the aborted turn is retried after compaction (overflow recovery)
});session_avant_arbre / session_arbre
Tiré sur la navigation /tree. Voir Sessions pour les concepts de navigation dans l'arborescence.
pi.on("session_before_tree", async (event, ctx) => {
const { preparation, signal } = event;
return { cancel: true };
// OR provide custom summary:
return {
summary: {
summary: "...",
// usage: summaryResponse.usage, // Optional; included in session totals
details: {},
},
};
});
pi.on("session_tree", async (event, ctx) => {
// event.newLeafId, oldLeafId, summaryEntry, fromExtension
});session_shutdown
Lancé avant qu'un runtime de session démarré ne soit détruit. Utilisez-le pour nettoyer les ressources ouvertes à partir de session_start ou d'autres hooks à l'échelle de la session.
pi.on("session_shutdown", async (event, ctx) => {
// event.reason - "quit" | "reload" | "new" | "resume" | "fork"
// event.targetSessionFile - destination session for session replacement flows
// Cleanup, save state, etc.
});Événements d'agent
avant_agent_start
Lancé après que l'utilisateur soumet l'invite, avant la boucle de l'agent. Peut injecter un message et/ou modifier l'invite du système.
pi.on("before_agent_start", async (event, ctx) => {
// event.prompt - user's prompt text
// event.images - attached images (if any)
// event.systemPrompt - current chained system prompt for this handler
// (includes changes from earlier before_agent_start handlers)
// event.systemPromptOptions - structured options used to build the system prompt
// .customPrompt - any custom system prompt (from --system-prompt, SYSTEM.md, or custom templates)
// .selectedTools - tools currently active in the prompt
// .toolSnippets - one-line descriptions for each tool
// .promptGuidelines - custom guideline bullets
// .appendSystemPrompt - text from --append-system-prompt flags
// .cwd - working directory
// .contextFiles - AGENTS.md files and other loaded context files
// .skills - loaded skills
return {
// Inject a persistent message (stored in session, sent to LLM)
message: {
customType: "my-extension",
content: "Additional context for the LLM",
display: true,
},
// Replace the system prompt for this turn (chained across extensions)
systemPrompt: event.systemPrompt + "\n\nExtra instructions for this turn...",
};
});Le champ systemPromptOptions donne aux extensions l'accès aux mêmes données structurées que Pi utilise pour créer l'invite système. Cela vous permet d'inspecter ce que Pi a chargé (invites personnalisées, directives, extraits d'outils, context files, compétences) — sans redécouvrir les ressources ni réanalyser les indicateurs. Utilisez-le lorsque votre extension doit apporter des modifications approfondies et éclairées à l'invite système tout en respectant la configuration fournie par l'utilisateur.
À l'intérieur de before_agent_start, event.systemPrompt et ctx.getSystemPrompt() reflètent tous deux l'invite système chaînée du gestionnaire actuel. Les gestionnaires before_agent_start ultérieurs peuvent toujours le modifier à nouveau.
agent_start / agent_end / agent_settled
agent_start se déclenche lorsqu'une exécution d'agent de bas niveau commence. agent_end se déclenche à la fin de cette exécution, mais Pi peut toujours réessayer automatiquement, compacter et réessayer automatiquement, ou continuer avec les messages de suivi en file d'attente. Utilisez agent_settled pour les intégrations de statut qui doivent savoir que Pi ne continuera pas à s'exécuter automatiquement.
pi.on("agent_start", async (_event, ctx) => {});
pi.on("agent_end", async (event, ctx) => {
// event.messages - messages from this low-level run
});
pi.on("agent_settled", async (_event, ctx) => {
// ctx.isIdle() is true here unless another extension started a new run.
});tour_début / tour_end
Déclenché à chaque tour (une réponse LLM + appels d'outils).
pi.on("turn_start", async (event, ctx) => {
// event.turnIndex, event.timestamp
});
pi.on("turn_end", async (event, ctx) => {
// event.turnIndex, event.message, event.toolResults
});message_start / message_update / message_end
Déclenché pour les mises à jour du cycle de vie des messages.
message_startetmessage_endse déclenchent pour les messages utilisateur, assistant et toolResult.message_updatese déclenche pour les mises à jour en continu de l'assistant.- Les gestionnaires
message_endpeuvent renvoyer{ message }pour remplacer le message finalisé. Le remplaçant doit garder le mêmerole.
pi.on("message_start", async (event, ctx) => {
// event.message
});
pi.on("message_update", async (event, ctx) => {
// event.message
// event.assistantMessageEvent (token-by-token stream event)
});
pi.on("message_end", async (event, ctx) => {
if (event.message.role !== "assistant") return;
return {
message: {
...event.message,
usage: {
...event.message.usage,
cost: {
...event.message.usage.cost,
total: 0.123,
},
},
},
};
});tool_execution_start/tool_execution_update/tool_execution_end
Déclenché pour les mises à jour du cycle de vie d’exécution des outils.
En mode outil parallèle:
tool_execution_startest émis dans l'ordre des sources assistantes pendant la phase de contrôle en amonttool_execution_updateles événements peuvent s'entrelacer entre les outilstool_execution_endest émis dans l'ordre d'achèvement des outils après la finalisation de chaque outil- Les événements de message finaux
toolResultsont toujours émis plus tard dans l'ordre des sources de l'assistant
pi.on("tool_execution_start", async (event, ctx) => {
// event.toolCallId, event.toolName, event.args
});
pi.on("tool_execution_update", async (event, ctx) => {
// event.toolCallId, event.toolName, event.args, event.partialResult
});
pi.on("tool_execution_end", async (event, ctx) => {
// event.toolCallId, event.toolName, event.result, event.isError
});contexte
Lancé avant chaque appel LLM. Modifier les messages de manière non destructive. Voir Session Format pour les types de messages.
pi.on("context", async (event, ctx) => {
// event.messages - deep copy, safe to modify
const filtered = event.messages.filter(m => !shouldPrune(m));
return { messages: filtered };
});avant_provider_headers
Déclenché après l'assemblage des en-têtes HTTP sortants. Utilisez-le pour ajouter, remplacer ou supprimer des en-têtes de requête.
Les gestionnaires mutent event.headers sur place. Définissez une clé sur une chaîne pour l'ajouter ou la remplacer, ou sur null pour la supprimer.
pi.on("before_provider_headers", (event, ctx) => {
// Add or override — e.g. a session id for gateway tracing/attribution
event.headers["x-session-id"] = ctx.sessionManager.getSessionId();
// Drop a tracking header pi adds for this call
event.headers["X-OpenRouter-Title"] = null;
});S'exécute une fois par demande du fournisseur; les nouvelles tentatives réutilisent les mêmes en-têtes plutôt que de relancer le hook.
avant_provider_request
Lancé après la création de la charge utile spécifique au fournisseur, juste avant l'envoi de la demande. Les gestionnaires s’exécutent dans l’ordre de chargement des extensions. Le retour de undefined maintient la charge utile inchangée. Le renvoi de toute autre valeur remplace la charge utile pour les gestionnaires ultérieurs et pour la demande réelle.
Ce hook peut réécrire les instructions système au niveau du fournisseur ou les supprimer complètement. Ces modifications au niveau de la charge utile ne sont pas reflétées par ctx.getSystemPrompt(), qui signale la chaîne d'invite système de Pi plutôt que la charge utile sérialisée finale du fournisseur.
pi.on("before_provider_request", (event, ctx) => {
console.log(JSON.stringify(event.payload, null, 2));
// Optional: replace payload
// return { ...event.payload, temperature: 0 };
});Ceci est principalement utile pour déboguer la sérialisation du fournisseur et le comportement du cache.
after_provider_response
Déclenché après la réception d'une réponse HTTP et avant que le corps de son flux ne soit consommé. Les gestionnaires s’exécutent dans l’ordre de chargement des extensions.
pi.on("after_provider_response", (event, ctx) => {
// event.status - HTTP status code
// event.headers - normalized response headers
if (event.status === 429) {
console.log("rate limited", event.headers["retry-after"]);
}
});La disponibilité de l'en-tête dépend du fournisseur et du transport. Providers que les réponses HTTP abstraites ne peuvent pas exposer les en-têtes.
Événements modèles
model_select
Déclenché lorsque le modèle change via la commande /model, le cycle de modèle (Ctrl+P) ou la restauration de session.
pi.on("model_select", async (event, ctx) => {
// event.model - newly selected model
// event.previousModel - previous model (undefined if first selection)
// event.source - "set" | "cycle" | "restore"
const prev = event.previousModel
? `${event.previousModel.provider}/${event.previousModel.id}`
: "none";
const next = `${event.model.provider}/${event.model.id}`;
ctx.ui.notify(`Model changed (${event.source}): ${prev} -> ${next}`, "info");
});Utilisez-le pour mettre à jour les éléments de l'interface utilisateur (barres d'état, pieds de page) ou effectuer une initialisation spécifique au modèle lorsque le modèle actif change.
réflexion_level_select
Lancé lorsque le niveau de réflexion change. Il s'agit uniquement d'une notification; les valeurs de retour du gestionnaire sont ignorées.
pi.on("thinking_level_select", async (event, ctx) => {
// event.level - newly selected thinking level
// event.previousLevel - previous thinking level
ctx.ui.setStatus("thinking", `thinking: ${event.level}`);
});Utilisez-le pour mettre à jour l'interface utilisateur de l'extension lorsque pi.setThinkingLevel(), le modèle change ou les contrôles de niveau de réflexion intégrés modifient le niveau de réflexion actif.
Événements d'outils
appel_outil
Lancé après tool_execution_start, avant que l'outil ne s'exécute. Peut bloquer. Utilisez isToolCallEventType pour affiner et obtenir des entrées saisies.
Avant l'exécution de tool_call, pi attend que les événements d'agent précédemment émis finissent de s'écouler via AgentSession. Cela signifie que ctx.sessionManager est à jour via le message d'appel d'outil de l'assistant actuel.
Dans le mode d'exécution d'outil parallèle par défaut, les appels d'outils frères à partir du même message d'assistant sont contrôlés en amont de manière séquentielle, puis exécutés simultanément. tool_call n'est pas garanti de voir les résultats de l'outil frère de ce même message d'assistant dans ctx.sessionManager.
event.input est mutable. Mutez-le sur place pour corriger les arguments de l'outil avant l'exécution.
Garanties de comportement:
- Les mutations vers
event.inputaffectent l'exécution réelle de l'outil - Les gestionnaires
tool_callultérieurs voient les mutations effectuées par les gestionnaires précédents - Aucune revalidation n'est effectuée après votre mutation
- Renvoie les valeurs de
tool_callcontrôle le blocage via{ block: true, reason?: string, terminate?: boolean } terminatene s'applique qu'à un appel bloqué; l'agent s'arrête plus tôt que lorsque chaque résultat finalisé du lot se termine
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
pi.on("tool_call", async (event, ctx) => {
// event.toolName - "bash", "read", "write", "edit", etc.
// event.toolCallId
// event.input - tool parameters (mutable)
// Built-in tools: no type params needed
if (isToolCallEventType("bash", event)) {
// event.input is { command: string; timeout?: number }
event.input.command = `source ~/.profile\n${event.input.command}`;
if (event.input.command.includes("rm -rf")) {
return { block: true, reason: "Dangerous command", terminate: true };
}
}
if (isToolCallEventType("read", event)) {
// event.input is { path: string; offset?: number; limit?: number }
console.log(`Reading: ${event.input.path}`);
}
});Saisie d'une entrée d'outil personnalisée
Les outils personnalisés doivent exporter leur type d'entrée:
// my-extension.ts
export type MyToolInput = Static<typeof myToolSchema>;Utilisez isToolCallEventType avec des paramètres de type explicites:
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
import type { MyToolInput } from "my-extension";
pi.on("tool_call", (event) => {
if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) {
event.input.action; // typed
}
});résultat_outil
Déclenché après la fin de l'exécution de l'outil et avant l'émission de tool_execution_end ainsi que des événements de message de résultat final de l'outil. Peut modifier le résultat.
En mode outil parallèle, tool_result et tool_execution_end peuvent s'entrelacer dans l'ordre d'achèvement de l'outil, tandis que les événements de message finaux toolResult sont toujours émis plus tard dans l'ordre des sources de l'assistant.
tool_result chaîne de gestionnaires comme un middleware:
- Les gestionnaires s'exécutent dans l'ordre de chargement des extensions
- Chaque gestionnaire voit le dernier résultat après les modifications précédentes du gestionnaire
- Les gestionnaires peuvent renvoyer des correctifs partiels (
content,details,isErrorouusage); les champs omis conservent leurs valeurs actuelles
Utilisez ctx.signal pour le travail asynchrone imbriqué à l'intérieur du gestionnaire. Cela permet à Esc d'annuler les appels de modèle, fetch() et d'autres opérations prenant en compte l'abandon lancées par l'extension.
import { isBashToolResult } from "@earendil-works/pi-coding-agent";
pi.on("tool_result", async (event, ctx) => {
// event.toolName, event.toolCallId, event.input
// event.content, event.details, event.isError, event.usage
if (isBashToolResult(event)) {
// event.details is typed as BashToolDetails
}
const response = await fetch("https://example.com/summarize", {
method: "POST",
body: JSON.stringify({ content: event.content }),
signal: ctx.signal,
});
// Modify result:
return { content: [...], details: {...}, isError: false, usage: nestedModelUsage };
});Événements de bash utilisateur
utilisateur_bash
Lancé lorsque l'utilisateur exécute les commandes ! ou !!. Peut intercepter.
import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
pi.on("user_bash", (event, ctx) => {
// event.command - the bash command
// event.excludeFromContext - true if !! prefix
// event.cwd - working directory
// Option 1: Provide custom operations (e.g., SSH)
return { operations: remoteBashOps };
// Option 2: Wrap pi's built-in local bash backend
const local = createLocalBashOperations();
return {
operations: {
exec(command, cwd, options) {
return local.exec(`source ~/.profile\n${command}`, cwd, options);
}
}
};
// Option 3: Full replacement - return result directly
return { result: { output: "...", exitCode: 0, cancelled: false, truncated: false } };
});Événements d'entrée
saisir
Déclenché lorsque l'entrée de l'utilisateur est reçue, après la vérification des commandes d'extension mais avant l'expansion des compétences et du modèle. L'événement voit le texte brut d'entrée, donc /skill:foo et /template ne sont pas encore développés.
Ordre de traitement:
- Commandes d'extension (
/cmd) vérifiées en premier - si elles sont trouvées, le gestionnaire s'exécute et l'événement d'entrée est ignoré inputévénements déclenchés - peut intercepter, transformer ou gérer- Si non géré: commandes de compétence (
/skill:name) étendues au contenu de la compétence - S'il n'est pas géré: prompt templates (
/template) étendu au contenu du modèle - Le traitement de l'agent commence (
before_agent_start, etc.)
pi.on("input", async (event, ctx) => {
// event.text - raw input (before skill/template expansion)
// event.images - attached images, if any
// event.source - "interactive" (typed), "rpc" (API), or "extension" (via sendUserMessage)
// event.streamingBehavior - "steer" | "followUp" | undefined
// undefined when idle, "steer" for mid-stream interrupts,
// "followUp" for messages queued until the agent finishes
// Transform: rewrite input before expansion
if (event.text.startsWith("?quick "))
return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` };
// Handle: respond without LLM (extension shows its own feedback)
if (event.text === "ping") {
ctx.ui.notify("pong", "info");
return { action: "handled" };
}
// Route by source: skip processing for extension-injected messages
if (event.source === "extension") return { action: "continue" };
// Intercept skill commands before expansion
if (event.text.startsWith("/skill:")) {
// Could transform, block, or let pass through
}
return { action: "continue" }; // Default: pass through to expansion
});Résultats:
continue- transmission inchangée (par défaut si le gestionnaire ne renvoie rien)transform- modifier le texte/les images, puis continuer l'expansionhandled- ignorer complètement l'agent (le premier gestionnaire à renvoyer cela gagne)
Transforme la chaîne entre les gestionnaires. Voir input-transform.ts et input-transform-streaming.ts pour le routage compatible streamingBehavior.
Contexte d'extension
Tous les gestionnaires reçoivent ctx: ExtensionContext.
ctx.ui
Méthodes d’interface utilisateur pour l’interaction utilisateur. Voir Custom UI pour plus de détails.
ctx.mode
Mode d'exécution actuel: "tui", "rpc", "json" ou "print". Utilisez ctx.mode === "tui" pour protéger les fonctionnalités réservées au terminal telles que custom(), les usines de composants, l'entrée du terminal et le rendu direct TUI.
ctx.hasUI
true en modes TUI et RPC. false en mode impression (-p) et JSON en mode. Utilisez-le pour protéger les méthodes de dialogue (select, confirm, input, editor) et les méthodes de déclenchement et d'oubli (notify, setStatus, setWidget, setTitle, setEditorText) qui fonctionnent à la fois en TUI et RPC modes. En mode RPC, certaines méthodes spécifiques à TUI ne fonctionnent pas ou renvoient des valeurs par défaut (voir rpc.md).
ctx.cwd
Répertoire de travail actuel.
Utilisez CONFIG_DIR_NAME au lieu de coder en dur .pi lors de la construction de chemins de configuration locaux du projet. Les distributions renommées peuvent utiliser un nom de répertoire de configuration différent.
import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { join } from "node:path";
export default function (pi: ExtensionAPI) {
pi.on("session_start", (_event, ctx) => {
const projectConfigPath = join(ctx.cwd, CONFIG_DIR_NAME, "my-extension.json");
// ...
});
}ctx.isProjectTrusted()
Indique si l'approbation locale du projet est active pour le contexte de session actuel. Cela inclut les décisions de confiance temporaires et les remplacements de confiance CLI, et pas seulement les décisions enregistrées dans le magasin de confiance global.
Utilisez-le avant de lire la configuration de l'extension locale du projet qui ne doit être respectée que pour les projets approuvés.
ctx.sessionManager
Accès en lecture seule à l'état de la session. Voir Session Format pour le SessionManager complet API et les types d'entrée.
Pour tool_call, cet état est synchronisé via le message de l'assistant actuel avant l'exécution des gestionnaires. En mode d'exécution d'outil parallèle, il n'est toujours pas garanti d'inclure les résultats des outils frères provenant du même message d'assistant.
ctx.sessionManager.getEntries() // All entries
ctx.sessionManager.getBranch() // Current branch
ctx.sessionManager.buildContextEntries() // Active branch entries with compaction applied
ctx.sessionManager.getLeafId() // Current leaf entry IDctx.modelRegistry / ctx.model / ctx.thinkingLevel / ctx.scopedModels
Accès aux modèles, aux fournisseurs et à l'authentification résolue. ctx.modelRegistry.getProvider(id) renvoie le fournisseur pi-ai effectif, tandis que getProviderAuth(id) résout son API key actuel, ses en-têtes, son URL de base et son environnement étendu au fournisseur sans nécessiter de modèle chargé. ctx.model est le modèle actif et ctx.thinkingLevel est son niveau de réflexion efficace actuel.
ctx.scopedModels est la liste en lecture seule des modèles limités à la session en cours — le même ensemble que celui affiché par la commande /scoped-models. Il est résolu au début de la session à partir du drapeau --models CLI et du paramètre enabledModels (en comparaison avec le catalogue disponible avec une mini-match sur provider/modelId ou un simple modelId). Il est vide lorsqu'aucune portée n'est configurée, ce qui signifie que tous les modèles disponibles sont utilisables. Chaque entrée est { model, thinkingLevel? }, où thinkingLevel est défini uniquement lorsqu'un motif l'a épinglée (par exemple anthropic/*:high). Utilisez-le pour remplir un sélecteur de modèle qui reflète celui intégré au lieu d'énumérer l'ensemble du catalogue via ctx.modelRegistry.getAvailable().
signal ctx
Le signal d'abandon de l'agent actuel, ou undefined lorsqu'aucun tour d'agent n'est actif.
Utilisez-le pour les travaux imbriqués prenant en charge l'abandon démarrés par les gestionnaires d'extensions, par exemple:
fetch(..., { signal: ctx.signal })- appels de modèles qui acceptent
signal - classer ou traiter les assistants qui acceptent
AbortSignal
ctx.signal est généralement défini lors d'événements de tour actifs tels que tool_call, tool_result, message_update et turn_end.
Il s'agit généralement de undefined dans des contextes inactifs ou sans tour tels que les événements de session, les commandes d'extension et les raccourcis déclenchés lorsque pi est inactif.
pi.on("tool_result", async (event, ctx) => {
const response = await fetch("https://example.com/api", {
method: "POST",
body: JSON.stringify(event),
signal: ctx.signal,
});
const data = await response.json();
return { details: data };
});ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages()
Aides au flux de contrôle. ctx.isIdle() est faux tandis que Pi traite une exécution d'agent, une nouvelle tentative automatique, une nouvelle tentative de compactage automatique ou une continuation en file d'attente.
ctx.shutdown()
Demandez un arrêt progressif de pi.
- Mode interactif: Différé jusqu'à ce que l'agent devienne inactif (après avoir traité tous les messages de pilotage et de suivi en file d'attente).
- Mode RPC: Différé jusqu'au prochain état d'inactivité (après avoir terminé la réponse à la commande actuelle, en attendant la commande suivante).
- Mode d'impression: Aucune opération. Le processus se termine automatiquement lorsque toutes les invites sont traitées.
Émet l'événement session_shutdown à toutes les extensions avant de quitter. Disponible dans tous les contextes (gestionnaires d'événements, outils, commandes, raccourcis).
pi.on("tool_call", (event, ctx) => {
if (isFatal(event.input)) {
ctx.shutdown();
}
});ctx.getContextUsage()
Renvoie l'utilisation actuelle du contexte pour le modèle actif. Utilise la dernière utilisation de l'assistant lorsqu'il est disponible, puis estime les jetons pour les messages de fin.
const usage = ctx.getContextUsage();
if (usage && usage.tokens > 100_000) {
// ...
}ctx.compact()
Déclenchez le compactage sans attendre la fin. Utilisez onComplete et onError pour les actions de suivi.
ctx.compact({
customInstructions: "Focus on recent changes",
onComplete: (result) => {
ctx.ui.notify("Compaction completed", "info");
},
onError: (error) => {
ctx.ui.notify(`Compaction failed: ${error.message}`, "error");
},
});ctx.getSystemPrompt()
Renvoie la chaîne d'invite système actuelle de Pi.
- Pendant
before_agent_start, cela reflète les modifications enchaînées des invites système effectuées jusqu'à présent pour le tour en cours. - Il n'inclut pas les mutations ultérieures du message
context. - Il n'inclut pas les réécritures de charge utile
before_provider_request. - Si des extensions chargées ultérieurement s'exécutent après la vôtre, elles peuvent toujours modifier ce qui est finalement envoyé.
pi.on("before_agent_start", (event, ctx) => {
const prompt = ctx.getSystemPrompt();
console.log(`System prompt length: ${prompt.length}`);
});ExtensionCommandContextExtensionCommandContext
Les gestionnaires de commandes reçoivent ExtensionCommandContext, qui étend ExtensionContext avec les méthodes de contrôle de session. Ceux-ci ne sont disponibles que dans les commandes car ils peuvent se bloquer s'ils sont appelés à partir des gestionnaires d'événements.
ctx.getSystemPromptOptions()
Renvoie les entrées de base que Pi utilise actuellement pour créer l'invite système.
const options = ctx.getSystemPromptOptions();
const contextPaths = options.contextFiles?.map((file) => file.path) ?? [];Cela a la même forme et la même mutabilité que before_agent_start event.systemPromptOptions: invite personnalisée, outils actifs, extraits d'outils, directives d'invite, texte d'invite système ajouté, cwd, context files chargé et compétences chargées. Il peut inclure le contenu complet du fichier de contexte, alors traitez-le comme des données sensibles locales d'extension et évitez de l'exposer via des listes de commandes, des journaux ou des métadonnées de saisie semi-automatique.
Ceci rapporte les entrées d'invite de base actuelles. Il n'inclut pas before_agent_start modifications d'invite système enchaînées par tour, context mutations ultérieures de message d'événement ou before_provider_request réécritures de charge utile.
ctx.waitForIdle()
Attendez que l'agent se stabilise complètement, y compris les tentatives automatiques, les tentatives de compactage automatique et les continuations en file d'attente:
pi.registerCommand("my-cmd", {
handler: async (args, ctx) => {
await ctx.waitForIdle();
// Agent is now idle, safe to modify session
},
});ctx.newSession(options?)
Créez une nouvelle session:
const parentSession = ctx.sessionManager.getSessionFile();
const kickoff = "Continue in the replacement session";
const result = await ctx.newSession({
parentSession,
setup: async (sm) => {
sm.appendMessage({
role: "user",
content: [{ type: "text", text: "Context from previous session..." }],
timestamp: Date.now(),
});
},
withSession: async (ctx) => {
// Use only the replacement-session ctx here.
await ctx.sendUserMessage(kickoff);
},
});
if (result.cancelled) {
// An extension cancelled the new session
}Possibilités:
parentSession: fichier de session parent à enregistrer dans le nouvel en-tête de sessionsetup: muter leSessionManagerde la nouvelle session avant l'exécution dewithSessionwithSession: exécutez le travail post-changement dans un nouveau contexte de session de remplacement. N'utilisez pas l'ancienpi/ commandectxcapturé; voir Session replacement lifecycle and footguns.
ctx.fork(entryId, options?)
Fork à partir d'une entrée spécifique, créant un nouveau fichier de session:
const result = await ctx.fork("entry-id-123", {
withSession: async (ctx) => {
// Use only the replacement-session ctx here.
ctx.ui.notify("Now in the forked session", "info");
},
});
if (result.cancelled) {
// An extension cancelled the fork
}
const cloneResult = await ctx.fork("entry-id-456", { position: "at" });
if (cloneResult.cancelled) {
// An extension cancelled the clone
}Possibilités:
position:"before"(par défaut) se situe avant le message utilisateur sélectionné, restaurant cette invite dans l'éditeurposition:"at"duplique le chemin actif via l'entrée sélectionnée sans restaurer le texte de l'éditeurwithSession: exécutez le travail post-changement dans un nouveau contexte de session de remplacement. N'utilisez pas l'ancienpi/ commandectxcapturé; voir Session replacement lifecycle and footguns.
ctx.navigateTree(targetId, options?)
Accédez à un autre point dans le session tree:
const result = await ctx.navigateTree("entry-id-456", {
summarize: true,
customInstructions: "Focus on error handling changes",
replaceInstructions: false, // true = replace default prompt entirely
label: "review-checkpoint",
});Possibilités:
summarize: s'il faut générer un résumé de la branche abandonnéecustomInstructions: Instructions personnalisées pour le résuméreplaceInstructions: si vrai,customInstructionsremplace l'invite par défaut au lieu d'être ajoutéelabel: Libellé à attacher à l'entrée récapitulative de la branche (ou à l'entrée cible si elle ne résume pas)
ctx.switchSession (sessionPath, options?)
Basculez vers un autre fichier de session:
const result = await ctx.switchSession("/path/to/session.jsonl", {
withSession: async (ctx) => {
await ctx.sendUserMessage("Resume work in the replacement session");
},
});
if (result.cancelled) {
// An extension cancelled the switch via session_before_switch
}Possibilités:
withSession: exécutez le travail post-changement dans un nouveau contexte de session de remplacement. N'utilisez pas l'ancienpi/ commandectxcapturé; voir Session replacement lifecycle and footguns.
Pour découvrir les sessions disponibles, utilisez les méthodes statiques SessionManager.list() ou SessionManager.listAll():
import { SessionManager } from "@earendil-works/pi-coding-agent";
pi.registerCommand("switch", {
description: "Switch to another session",
handler: async (args, ctx) => {
const sessions = await SessionManager.list(ctx.cwd);
if (sessions.length === 0) return;
const choice = await ctx.ui.select(
"Pick session:",
sessions.map(s => s.file),
);
if (choice) {
await ctx.switchSession(choice, {
withSession: async (ctx) => {
ctx.ui.notify("Switched session", "info");
},
});
}
},
});Cycle de vie de remplacement de session et armes à pied
withSession reçoit un nouveau ReplacedSessionContext, qui étend ExtensionCommandContext avec les assistants asynchrones sendMessage() et sendUserMessage() liés à la session de remplacement.
Cycle de vie et armes à pied:
withSessionne s'exécute qu'après que l'ancienne session a émissession_shutdown, que l'ancien moteur d'exécution a été démoli, que la session de remplacement a été rebondie et que la nouvelle instance d'extension a déjà reçusession_start.- Le rappel s'exécute toujours dans la fermeture d'origine, pas dans la nouvelle instance d'extension. Cela signifie que votre ancienne instance d'extension a peut-être déjà exécuté son nettoyage d'arrêt avant le début de
withSession. - Les anciens objets liés à la session
pi/ ancienne commandectxcapturés sont obsolètes après leur remplacement et seront lancés s'ils sont utilisés. Utilisez uniquement lectxpassé àwithSessionpour le travail lié à la session. - Les objets bruts précédemment extraits restent sous votre responsabilité. Par exemple, si vous capturez
const sm = ctx.sessionManageravant le remplacement,smest toujours l'ancien objetSessionManager. Ne le réutilisez pas après le remplacement. - Le code dans
withSessiondevrait supposer que tout état invalidé par votre gestionnairesession_shutdowna déjà disparu. Capturez uniquement les données simples qui survivent proprement à l'arrêt, telles que les chaînes, les identifiants et la configuration sérialisée.
Modèle sécurisé:
pi.registerCommand("handoff", {
handler: async (_args, ctx) => {
const kickoff = "Continue from the replacement session";
await ctx.newSession({
withSession: async (ctx) => {
await ctx.sendUserMessage(kickoff);
},
});
},
});Modèle dangereux:
pi.registerCommand("handoff", {
handler: async (_args, ctx) => {
const oldSessionManager = ctx.sessionManager;
await ctx.newSession({
withSession: async (_ctx) => {
// stale old objects: do not do this
oldSessionManager.getSessionFile();
pi.sendUserMessage("wrong");
},
});
},
});ctx.reload()
Exécutez le même flux de rechargement que /reload.
pi.registerCommand("reload-runtime", {
description: "Reload extensions, skills, prompts, themes, and context files",
handler: async (_args, ctx) => {
await ctx.reload();
return;
},
});Comportement important:
await ctx.reload()émetsession_shutdownpour le runtime actuel de l'extension- Il recharge ensuite les ressources et émet
session_startavecreason: "reload"etresources_discoveravec raison"reload" - Le gestionnaire de commandes en cours d'exécution continue toujours dans l'ancien cadre d'appel
- Le code après
await ctx.reload()fonctionne toujours à partir de la version de pré-rechargement - Le code après
await ctx.reload()ne doit pas supposer que l'ancien état d'extension en mémoire est toujours valide - Après le retour du gestionnaire, les futurs appels de commandes/événements/outils utilisent la nouvelle version de l'extension
Pour un comportement prévisible, traitez le rechargement comme un terminal pour ce gestionnaire (await ctx.reload(); return;).
Les outils fonctionnent avec ExtensionContext, ils ne peuvent donc pas appeler directement ctx.reload(). Utilisez une commande comme point d’entrée de rechargement, puis exposez un outil qui met cette commande en file d’attente en tant que message utilisateur de suivi.
Exemple d'outil que le LLM peut appeler pour déclencher le rechargement:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
export default function (pi: ExtensionAPI) {
pi.registerCommand("reload-runtime", {
description: "Reload extensions, skills, prompts, themes, and context files",
handler: async (_args, ctx) => {
await ctx.reload();
return;
},
});
pi.registerTool({
name: "reload_runtime",
label: "Reload Runtime",
description: "Reload extensions, skills, prompts, themes, and context files",
parameters: Type.Object({}),
async execute() {
pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" });
return {
content: [{ type: "text", text: "Queued /reload-runtime as a follow-up command." }],
};
},
});
}Méthodes ExtensionAPI
pi.on(événement, gestionnaire)
Abonnez-vous aux événements. Voir Events pour les types d'événements et les valeurs de retour.
pi.registerTool (définition)
Enregistrez un outil personnalisé appelable par le LLM. Voir Custom Tools pour plus de détails.
pi.registerTool() fonctionne à la fois pendant le chargement de l'extension et après le démarrage. Vous pouvez l'appeler à l'intérieur de session_start, de gestionnaires de commandes ou d'autres gestionnaires d'événements. Les nouveaux outils sont actualisés immédiatement dans la même session, ils apparaissent donc en pi.getAllTools() et sont appelables par le LLM sans /reload.
Utilisez pi.setActiveTools() pour activer ou désactiver les outils (y compris les outils ajoutés dynamiquement) au moment de l'exécution.
Utilisez promptSnippet pour opter pour un outil personnalisé dans une entrée d'une seule ligne dans Available tools et promptGuidelines pour ajouter des puces spécifiques à l'outil à la section par défaut Guidelines lorsque l'outil est actif.
Important: Les puces promptGuidelines sont ajoutées à plat à la section Guidelines sans préfixe de nom d'outil. Chaque ligne directrice doit nommer l'outil auquel elle fait référence – évitez « Utilisez cet outil lorsque… » car le LLM ne peut pas dire à quel outil « ce » signifie. Écrivez plutôt "Utiliser my_tool quand...".
Voir dynamic-tools.ts pour un exemple complet.
import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
pi.registerTool({
name: "my_tool",
label: "My Tool",
description: "What this tool does",
promptSnippet: "Summarize or transform text according to action",
promptGuidelines: ["Use my_tool when the user asks to summarize previously generated text."],
parameters: Type.Object({
action: StringEnum(["list", "add"] as const),
text: Type.Optional(Type.String()),
}),
prepareArguments(args) {
// Optional compatibility shim. Runs before schema validation.
// Return the current schema shape, for example to fold legacy fields
// into the modern parameter object.
return args;
},
async execute(toolCallId, params, signal, onUpdate, ctx) {
// Stream progress
onUpdate?.({ content: [{ type: "text", text: "Working..." }] });
return {
content: [{ type: "text", text: "Done" }],
details: { result: "..." },
};
},
// Optional: Custom rendering
renderCall(args, theme, context) { ... },
renderResult(result, options, theme, context) { ... },
});pi.sendMessage(message, options?)
Injectez un message personnalisé dans la session. Les messages personnalisés participent au contexte LLM. Pour un contenu durable uniquement TUI qui ne doit pas être envoyé au LLM, utilisez pi.appendEntry() avec pi.registerEntryRenderer().
pi.sendMessage({
customType: "my-extension",
content: "Message text",
display: true,
details: { ... },
}, {
triggerTurn: true,
deliverAs: "steer",
});Options:
deliverAs- Mode de livraison:"steer"(par défaut) - Met le message en file d'attente pendant la diffusion. Livré après que le tour d'assistant en cours ait fini d'exécuter ses appels d'outil, avant le prochain appel LLM."followUp"- Attend la fin de l'agent. Distribué uniquement lorsque l'agent n'a plus d'appels d'outil."nextTurn": mis en file d'attente pour la prochaine invite utilisateur. N'interrompt ni ne déclenche rien.
triggerTurn: true- Si l'agent est inactif, déclenchez immédiatement une réponse LLM. S'applique uniquement aux modes"steer"et"followUp"(ignoré pour"nextTurn").
pi.sendUserMessage (contenu, options?)
Envoyez un message utilisateur à l'agent. Contrairement à sendMessage() qui envoie des messages personnalisés, cela envoie un message utilisateur réel qui apparaît comme s'il avait été tapé par l'utilisateur. Déclenche toujours un tour.
// Simple text message
pi.sendUserMessage("What is 2+2?");
// With content array (text + images)
pi.sendUserMessage([
{ type: "text", text: "Describe this image:" },
{ type: "image", source: { type: "base64", mediaType: "image/png", data: "..." } },
]);
// During streaming - must specify delivery mode
pi.sendUserMessage("Focus on error handling", { deliverAs: "steer" });
pi.sendUserMessage("And then summarize", { deliverAs: "followUp" });Options:
deliverAs- Obligatoire lorsque l'agent diffuse:"steer"- Met le message en file d'attente pour livraison une fois que le tour de l'assistant actuel a fini d'exécuter ses appels d'outil"followUp"- Attend que l'agent ait terminé tous les outils
Lorsqu'il n'est pas diffusé, le message est envoyé immédiatement et déclenche un nouveau tour. Lors d'une diffusion sans deliverAs, génère une erreur.
Voir send-user-message.ts pour un exemple complet.
pi.appendEntry(customType, données?)
Conserver les données d’extension. Les entrées personnalisées ne participent PAS au contexte LLM. En mode interactif, ils peuvent également s'afficher dans la transcription du chat lorsqu'ils sont associés à pi.registerEntryRenderer().
pi.appendEntry("my-state", { count: 42 });
pi.appendEntry("status-card", { title: "Indexed files", count: 17 });
// Restore on reload
pi.on("session_start", async (_event, ctx) => {
for (const entry of ctx.sessionManager.getEntries()) {
if (entry.type === "custom" && entry.customType === "my-state") {
// Reconstruct from entry.data
}
}
});pi.setSessionName(nom)
Définissez le nom d'affichage de la session (affiché dans le sélecteur de session au lieu du premier message).
pi.setSessionName("Refactor auth module");pi.getSessionName()
Obtenez le nom de la session actuelle, s'il est défini.
const name = pi.getSessionName();
if (name) {
console.log(`Session: ${name}`);
}pi.setLabel (entryId, étiquette)
Définir ou effacer une étiquette sur une entrée. Les étiquettes sont des marqueurs définis par l'utilisateur pour la création de signets et la navigation (affichés dans le sélecteur /tree).
// Set a label
pi.setLabel(entryId, "checkpoint-before-refactor");
// Clear a label
pi.setLabel(entryId, undefined);
// Read labels via sessionManager
const label = ctx.sessionManager.getLabel(entryId);Les étiquettes persistent dans la session et survivent aux redémarrages. Utilisez-les pour marquer les points importants (virages, points de contrôle) dans l'arbre de conversation.
pi.registerCommand(nom, options)
Enregistrez une commande.
Si plusieurs extensions enregistrent le même nom de commande, pi les conserve toutes et attribue des suffixes d'appel numériques dans l'ordre de chargement, par exemple /review:1 et /review:2.
pi.registerCommand("stats", {
description: "Show session statistics",
handler: async (args, ctx) => {
const count = ctx.sessionManager.getEntries().length;
ctx.ui.notify(`${count} entries`, "info");
}
});Facultatif: ajoutez l'auto-complétion de l'argument pour /command...:
import type { AutocompleteItem } from "@earendil-works/pi-tui";
pi.registerCommand("deploy", {
description: "Deploy to an environment",
getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
const envs = ["dev", "staging", "prod"];
const items = envs.map((e) => ({ value: e, label: e }));
const filtered = items.filter((i) => i.value.startsWith(prefix));
return filtered.length > 0 ? filtered : null;
},
handler: async (args, ctx) => {
ctx.ui.notify(`Deploying: ${args}`, "info");
},
});pi.getCommands()
Obtenez le slash commands disponible pour invocation via prompt dans la session en cours. Comprend les commandes d'extension, prompt templates et les commandes de compétences.
La liste correspond à l'ordre RPC get_commands: les extensions d'abord, puis les modèles, puis les compétences.
const commands = pi.getCommands();
const bySource = commands.filter((command) => command.source === "extension");
const userScoped = commands.filter((command) => command.sourceInfo.scope === "user");Chaque entrée a cette forme:
{
name: string; // Invokable command name without the leading slash. May be suffixed like "review:1"
description?: string;
source: "extension" | "prompt" | "skill";
sourceInfo: {
path: string;
source: string;
scope: "user" | "project" | "temporary";
origin: "package" | "top-level";
baseDir?: string;
};
}Utilisez sourceInfo comme champ de provenance canonique. Ne déduisez pas la propriété à partir des noms de commandes ou de l’analyse de chemin ad hoc.
Les commandes interactives intégrées (comme /model et /settings) ne sont pas incluses ici. Ils sont traités uniquement de manière interactive
mode et ne s'exécuterait pas s'il était envoyé via prompt.
pi.registerMessageRenderer (customType, moteur de rendu)
Enregistrez un moteur de rendu TUI personnalisé pour les messages personnalisés avec votre customType. Les messages personnalisés sont créés avec pi.sendMessage() et participent au contexte LLM. Voir Custom UI.
pi.registerMarkdownTransformateur(transformateur)
Enregistrez un transformateur pour le Markdown dans le texte utilisateur normal, le texte de l'assistant et les blocs de réflexion. Les transformateurs fonctionnent dans l'ordre de charge d'extension et chaque transformateur reçoit le Markdown renvoyé par le transformateur précédent. Une fois la chaîne terminée, Pi restitue le contenu transformé avec son moteur de rendu intégré.
Le transformateur reçoit la chaîne Markdown et un contexte avec:
messageType—"user","assistant"ou"assistant-thinking"isStreaming—truepour les mises à jour partielles de l'assistant;falsepour l'utilisateur, l'assistant finalisé et les messages restaurésavailableWidth— colonnes terminales exactes disponibles pour le contenu Markdown transformé
Renvoyez le Markdown transformé:
pi.registerMarkdownTransformer((markdown, { messageType, isStreaming }) => {
if (isStreaming || messageType === "assistant-thinking") return markdown;
return markdown.replaceAll("-->", "→");
});Si un transformateur lance, Pi conserve le Markdown produit jusqu'à présent et continue avec le transformateur suivant. Le hook est en affichage uniquement: le message d'origine reste inchangé dans le contexte de la session et du modèle. Il fonctionne pour les nouveaux messages utilisateur, les mises à jour en streaming de l'assistant, les messages de session restaurés et les changements de largeur de terminal, de sorte que les transformateurs doivent rester synchrones et peu coûteux.
pi.registerEntryRenderer (customType, moteur de rendu)
Enregistrez un moteur de rendu TUI personnalisé pour les entrées personnalisées avec votre customType. Les entrées personnalisées sont créées avec pi.appendEntry() et ne participent pas au contexte LLM.
import { Box, Text } from "@earendil-works/pi-tui";
pi.registerEntryRenderer("status-card", (entry, { expanded }, theme) => {
const data = entry.data as { title: string; count: number };
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
box.addChild(new Text(`${theme.bold(data.title)}: ${data.count}`));
if (expanded) {
box.addChild(new Text(theme.fg("dim", JSON.stringify(data, null, 2))));
}
return box;
});
pi.appendEntry("status-card", { title: "Indexed files", count: 17 });pi.registerShortcut (raccourci, options)
Enregistrez un raccourci clavier. Voir keybindings.md pour le format de raccourci et les raccourcis clavier intégrés.
pi.registerShortcut("ctrl+shift+p", {
description: "Toggle plan mode",
handler: async (ctx) => {
ctx.ui.notify("Toggled!");
},
});pi.registerFlag(nom, options)
Enregistrez un drapeau CLI.
pi.registerFlag("plan", {
description: "Start in plan mode",
type: "boolean",
default: false,
});
// Check value
if (pi.getFlag("plan")) {
// Plan mode enabled
}pi.exec(commande, arguments, options?)
Exécutez une commande shell.
const result = await pi.exec("git", ["status"], { signal, timeout: 5000 });
// result.stdout, result.stderr, result.code, result.killedpi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(noms)
Gérer les outils actifs. Cela fonctionne à la fois pour les outils intégrés et les outils enregistrés dynamiquement. pi.getActiveTools() renvoie les noms d'outils actifs sous la forme string[]; pi.getAllTools() renvoie les métadonnées de tous les outils configurés.
const active = pi.getActiveTools(); // ["read", "bash", ...]
const all = pi.getAllTools();
// all = [{
// name: "read",
// description: "Read file contents...",
// parameters: ...,
// promptGuidelines: ["Use read to examine files instead of cat or sed."],
// sourceInfo: { path: "<builtin:read>", source: "builtin", scope: "temporary", origin: "top-level" }
// }, ...]
const builtinTools = all.filter((t) => t.sourceInfo.source === "builtin");
const extensionTools = all.filter((t) => t.sourceInfo.source !== "builtin" && t.sourceInfo.source !== "sdk");
pi.setActiveTools([...new Set([...active, "my_custom_tool"])]); // Keep current tools and enable my_custom_tool
pi.setActiveTools(["read", "bash"]); // Switch to read-onlypi.getAllTools() renvoie name, description, parameters, promptGuidelines et sourceInfo.
Valeurs sourceInfo.source typiques:
builtinpour les outils intégréssdkpour les outils passés viacreateAgentSession({ customTools })- métadonnées de la source d'extension pour les outils enregistrés par les extensions
pi.setModel (modèle)
Définissez le modèle actuel. Renvoie false si aucun API key n'est disponible pour le modèle. Voir models.md pour configurer des modèles personnalisés.
const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5");
if (model) {
const success = await pi.setModel(model);
if (!success) {
ctx.ui.notify("No API key for this model", "error");
}
}pi.getThinkingLevel() / pi.setThinkingLevel(niveau)
Obtenez ou définissez le niveau de réflexion. Le niveau est limité aux capacités du modèle (les modèles sans raisonnement utilisent toujours "off"). Les changements émettent thinking_level_select.
const current = pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
pi.setThinkingLevel("high");pi.événements
Bus d’événements partagé pour la communication entre extensions:
pi.events.on("my:event", (data) => { ... });
pi.events.emit("my:event", { ... });pi.registerProvider(nom, configuration)
Enregistrez ou remplacez un fournisseur de modèles de manière dynamique. Utile pour les proxys, les points de terminaison personnalisés ou les configurations de modèles à l'échelle de l'équipe.
Les appels effectués pendant la fonction d'usine d'extension sont mis en file d'attente et appliqués une fois que le programme d'exécution s'initialise. Les appels effectués par la suite – par exemple à partir d'un gestionnaire de commandes suivant un flux de configuration utilisateur – prennent effet immédiatement sans nécessiter un /reload.
Les fournisseurs dynamiques peuvent implémenter refreshModels. Pi l'appelle lors de l'actualisation du modèle, publie la liste renvoyée de manière synchrone via le fournisseur et transmet le contexte d'informations d'identification canonique/catalogue stocké/réseau/signal. L'extension décide si elle doit conserver les métadonnées du catalogue via la vérification de génération context.publish({ persist: entry }); les serveurs live tels que llama.cpp peuvent renvoyer des modèles sans les conserver.
context.signal est toujours un signal concret et les rappels du fournisseur doivent le transmettre au blocage des E/S. Les appels publics ModelRuntime.refresh() et ModelRegistry.refresh() acceptent un signal facultatif et sont illimités lorsqu'il est omis; les extensions et les candidatures choisissent leurs propres délais. L'annulation arrête l'appelant en attente même si un fournisseur ignore le signal, mais une coopération est toujours nécessaire pour arrêter le travail sous-jacent.
Extensions qui nécessitent une authentification, un filtrage, une actualisation ou un comportement de flux natif du fournisseur peuvent enregistrer un Provider complet à partir de @earendil-works/pi-ai. Le fournisseur devient la base de composition et les remplacements models.json s'appliquent toujours au-dessus.
import { createProvider, openAICompletionsApi } from "@earendil-works/pi-ai";
const provider = createProvider({
id: "local-server",
name: "Local Server",
baseUrl: "http://localhost:8080/v1",
auth: {
apiKey: {
name: "Local server setup",
async login(interaction) {
return {
type: "api_key",
key: await interaction.prompt({ type: "secret", message: "API key" }),
};
},
async resolve({ credential }) {
return credential?.key
? { auth: { apiKey: credential.key }, source: "stored API key" }
: undefined;
},
},
},
models: [],
api: openAICompletionsApi(),
});
pi.registerProvider(provider);
// Register a new provider with custom models
pi.registerProvider("my-proxy", {
name: "My Proxy",
baseUrl: "https://proxy.example.com",
apiKey: "$PROXY_API_KEY", // env var reference
api: "anthropic-messages",
models: [
{
id: "claude-sonnet-4-20250514",
name: "Claude 4 Sonnet (proxy)",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 16384
}
]
});
// Register a live llama.cpp catalog without persisting discovered models
pi.registerProvider("llama.cpp", {
baseUrl: "http://localhost:8080/v1",
apiKey: "local",
api: "openai-completions",
async refreshModels({ signal }) {
const response = await fetch("http://localhost:8080/v1/models", { signal });
const { data } = await response.json();
return data.map(({ id }) => ({
id,
name: id,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384
}));
}
});
// Override baseUrl for an existing provider (keeps all models)
pi.registerProvider("anthropic", {
baseUrl: "https://proxy.example.com"
});
// Register provider with OAuth support for /login
pi.registerProvider("corporate-ai", {
baseUrl: "https://ai.corp.com",
api: "openai-responses",
models: [...],
oauth: {
name: "Corporate AI (SSO)",
async login(callbacks) {
// Custom OAuth flow
callbacks.onAuth({ url: "https://sso.corp.com/..." });
const code = await callbacks.onPrompt({ message: "Enter code:" });
return { refresh: code, access: code, expires: Date.now() + 3600000 };
},
async refreshToken(credentials, signal) {
signal.throwIfAborted();
// Refresh logic
return credentials;
},
getApiKey(credentials) {
return credentials.access;
}
}
});Le formulaire objet accepte un pi-ai Provider complet, y compris les comportements natifs auth, getModels, refreshModels, filterModels, stream et streamSimple.
Options de configuration héritées:
name- Nom d'affichage du fournisseur dans l'interface utilisateur, tel que/login.baseUrl- API URL du point de terminaison. Obligatoire lors de la définition des modèles.apiKey- API key littéral, interpolation d'environnement ($ENV_VARou${ENV_VAR}), ou leader!command. Requis lors de la définition des modèles (sauf sioauthest fourni).$échappe à ``apiKey- API key littéral, interpolation d'environnement ($ENV_VARou${ENV_VAR}), ou leader!command. Requis lors de la définition des modèles (sauf sioauthest fourni).$échappe à et$!échappe à un!` littéral sans déclencher l'exécution de la commande.api- API tapez:"anthropic-messages","openai-completions","openai-responses", etc.headers- En-têtes personnalisés à inclure dans les requêtes.authHeader- Si vrai, ajoute automatiquement l'en-têteAuthorization: Bearer.models- Tableau de définitions de modèles. S'il est fourni, remplace tous les modèles existants pour ce fournisseur. Les définitions de modèle peuvent définirbaseUrlpour remplacer le point de terminaison du fournisseur pour ce modèle.refreshModels- Rappel de découverte dynamique asynchrone. Ses modèles renvoyés remplacent les modèles fournis par l'extension.context.storedcontient l'instantané persistant du fournisseur; utilisez la génération vérifiéecontext.publish({ persist: entry })uniquement lorsque les données du catalogue mises à jour doivent persister. Utilisezpersist: nullpour supprimer cet instantané.oauth- OAuth configuration du fournisseur pour le support/login. Lorsqu'il est fourni, le fournisseur apparaît dans le menu de connexion.streamSimple- Implémentation de streaming personnalisé pour les API non standard.
Voir custom-provider.md pour les sujets avancés: APIs de streaming personnalisé, détails OAuth, référence de définition de modèle.
pi.unregisterProvider(nom)
Supprimez un fournisseur précédemment enregistré et ses modèles. Les modèles intégrés qui ont été remplacés par le fournisseur sont restaurés. N'a aucun effet si le fournisseur n'est pas enregistré.
Comme registerProvider, cela prend effet immédiatement lorsqu'il est appelé après la phase de chargement initiale, donc un /reload n'est pas requis.
pi.registerCommand("my-setup-teardown", {
description: "Remove the custom proxy provider",
handler: async (_args, _ctx) => {
pi.unregisterProvider("my-proxy");
},
});Gestion de l'État
Extensions with state doit le stocker dans le résultat de l'outil details pour une prise en charge appropriée des branchements:
export default function (pi: ExtensionAPI) {
let items: string[] = [];
// Reconstruct state from session
pi.on("session_start", async (_event, ctx) => {
items = [];
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "message" && entry.message.role === "toolResult") {
if (entry.message.toolName === "my_tool") {
items = entry.message.details?.items ?? [];
}
}
}
});
pi.registerTool({
name: "my_tool",
// ...
async execute(toolCallId, params, signal, onUpdate, ctx) {
items.push("new item");
return {
content: [{ type: "text", text: "Added" }],
details: { items: [...items] }, // Store for reconstruction
};
},
});
}Outils personnalisés
Enregistrez les outils que le LLM peut appeler via pi.registerTool(). Les outils apparaissent dans l'invite système et peuvent avoir un rendu personnalisé.
Utilisez promptSnippet pour une courte entrée d'une ligne dans la section Available tools de l'invite système par défaut. En cas d'omission, les outils personnalisés sont exclus de cette section.
Utilisez promptGuidelines pour ajouter des puces spécifiques à l'outil à la section Guidelines de l'invite système par défaut. Ces puces sont incluses uniquement lorsque l'outil est actif (par exemple, après pi.setActiveTools([...])).
Important: Les puces promptGuidelines sont ajoutées à plat à la section Guidelines sans préfixe ni regroupement de nom d'outil. Chaque ligne directrice doit nommer l'outil auquel elle fait référence – évitez « Utilisez cet outil lorsque… » car le LLM ne peut pas dire à quel outil « ce » signifie. Écrivez plutôt "Utiliser my_tool quand...".
Remarque: Certains modèles sont idiots et incluent le préfixe @ dans les arguments du chemin d'outil. Les outils intégrés suppriment un premier @ avant de résoudre les chemins. Si votre outil personnalisé accepte un chemin, normalisez également un @ initial.
Si votre outil personnalisé mute les fichiers, utilisez withFileMutationQueue() afin qu'il participe à la même file d'attente par fichier que les edit et write intégrés. Cela est important car les appels d’outils s’exécutent en parallèle par défaut. Sans la file d'attente, deux outils peuvent lire le même ancien contenu de fichier, calculer différentes mises à jour, puis la dernière écriture écrase l'autre.
Exemple de cas d'échec: votre outil personnalisé modifie foo.ts tandis que le edit intégré modifie également foo.ts dans le même tour d'assistant. Si votre outil ne participe pas à la file d'attente, les deux peuvent lire le foo.ts original, appliquer des modifications distinctes, et l'une de ces modifications est perdue.
Transmettez le chemin réel du fichier cible à withFileMutationQueue(), pas l'argument utilisateur brut. Résolvez-le d'abord en un chemin absolu, par rapport à ctx.cwd ou au répertoire de travail de votre outil. Pour les fichiers existants, l'assistant canonise via realpath(), donc les alias de liens symboliques pour le même fichier partagent une file d'attente. Pour les nouveaux fichiers, il revient au chemin absolu résolu car il n'y a encore rien à realpath().
Mettez en file d'attente toute la fenêtre de mutation sur ce chemin cible. Cela inclut la logique de lecture-modification-écriture, pas seulement l'écriture finale.
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const absolutePath = resolve(ctx.cwd, params.path);
return withFileMutationQueue(absolutePath, async () => {
await mkdir(dirname(absolutePath), { recursive: true });
const current = await readFile(absolutePath, "utf8");
const next = current.replace(params.oldText, params.newText);
await writeFile(absolutePath, next, "utf8");
return {
content: [{ type: "text", text: `Updated ${params.path}` }],
details: {},
};
});
}Définition de l'outil
import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
import { Text } from "@earendil-works/pi-tui";
pi.registerTool({
name: "my_tool",
label: "My Tool",
description: "What this tool does (shown to LLM)",
promptSnippet: "List or add items in the project todo list",
promptGuidelines: [
"Use my_tool for todo planning instead of direct file edits when the user asks for a task list."
],
parameters: Type.Object({
action: StringEnum(["list", "add"] as const), // Use StringEnum for Google compatibility
text: Type.Optional(Type.String()),
}),
prepareArguments(args) {
if (!args || typeof args !== "object") return args;
const input = args as { action?: string; oldAction?: string };
if (typeof input.oldAction === "string" && input.action === undefined) {
return { ...input, action: input.oldAction };
}
return args;
},
async execute(toolCallId, params, signal, onUpdate, ctx) {
// Check for cancellation
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
// Stream progress updates
onUpdate?.({
content: [{ type: "text", text: "Working..." }],
details: { progress: 50 },
});
// Run commands via pi.exec (captured from extension closure)
const result = await pi.exec("some-command", [], { signal });
// Return result
return {
content: [{ type: "text", text: "Done" }], // Sent to LLM
details: { data: result }, // For rendering & state
// usage: nestedModelResponse.usage, // Optional nested LLM usage
// Optional: stop after this tool batch when every finalized tool result
// in the batch also returns terminate: true.
terminate: true,
};
},
// Optional: Custom rendering
renderCall(args, theme, context) { ... },
renderResult(result, options, theme, context) { ... },
});Comptabilité d'utilisation: Si un outil effectue des appels LLM imbriqués, renvoyez leur Usage combiné sous la forme usage. Pi le conserve dans le résultat de l'outil et l'inclut dans les totaux de session /session et RPC. tool_result les gestionnaires peuvent inspecter ou remplacer cette valeur.
Erreurs de signalisation: Pour marquer l'exécution d'un outil comme ayant échoué (définit isError: true sur le résultat et le signale au LLM), lancez une erreur à partir de execute. Le renvoi d'une valeur ne définit jamais l'indicateur d'erreur, quelles que soient les propriétés que vous incluez dans l'objet de retour.
Résiliation anticipée: Renvoyez terminate: true de execute() pour indiquer que l'appel LLM de suivi automatique doit être ignoré après le lot d'outils en cours. Cela ne prend effet que lorsque chaque résultat d'outil finalisé dans ce lot se termine. Voir examples/extensions/structured-output.ts pour un exemple minimal où l'agent se termine sur un dernier appel d'outil à sortie structurée.
// Correct: throw to signal an error
async execute(toolCallId, params) {
if (!isValid(params.input)) {
throw new Error(`Invalid input: ${params.input}`);
}
return { content: [{ type: "text", text: "OK" }], details: {} };
}Important: Utilisez StringEnum à partir de @earendil-works/pi-ai pour les énumérations de chaînes. Type.Union/Type.Literal ne fonctionne pas avec le API de Google.
Préparation des arguments: prepareArguments(args) est facultatif. S'il est défini, il s'exécute avant la validation du schéma et avant le execute(). Utilisez-le pour imiter une ancienne forme d'entrée acceptée lorsque pi reprend une ancienne session dont les arguments d'appel d'outil stockés ne correspondent plus au schéma actuel. Renvoyez l'objet que vous souhaitez valider par rapport à parameters. Gardez le schéma public strict. N'ajoutez pas de champs de compatibilité obsolètes à parameters juste pour que les anciennes sessions reprises fonctionnent.
Exemple: une ancienne session peut contenir un appel d'outil edit avec des niveaux supérieurs oldText et newText, alors que le schéma actuel n'accepte que edits: [{ oldText, newText }].
pi.registerTool({
name: "edit",
label: "Edit",
description: "Edit a single file using exact text replacement",
parameters: Type.Object({
path: Type.String(),
edits: Type.Array(
Type.Object({
oldText: Type.String(),
newText: Type.String(),
}),
),
}),
prepareArguments(args) {
if (!args || typeof args !== "object") return args;
const input = args as {
path?: string;
edits?: Array<{ oldText: string; newText: string }>;
oldText?: unknown;
newText?: unknown;
};
if (typeof input.oldText !== "string" || typeof input.newText !== "string") {
return args;
}
return {
...input,
edits: [...(input.edits ?? []), { oldText: input.oldText, newText: input.newText }],
};
},
async execute(toolCallId, params, signal, onUpdate, ctx) {
// params now matches the current schema
return {
content: [{ type: "text", text: `Applying ${params.edits.length} edit block(s)` }],
details: {},
};
},
});Remplacement des outils intégrés
Extensions peut remplacer les outils intégrés (read, bash, edit, write, grep, find, ls) en enregistrant un outil avec le même nom. Le mode interactif affiche un avertissement lorsque cela se produit.
# Extension's read tool replaces built-in read
pi -e ./tool-override.tsVous pouvez également utiliser --no-builtin-tools pour démarrer sans aucun outil intégré tout en gardant les outils d'extension activés:
# No built-in tools, only extension tools
pi --no-builtin-tools -e ./my-extension.tsVoir examples/extensions/tool-override.ts pour un exemple complet qui remplace read avec la journalisation et le contrôle d'accès.
Rendu: L'héritage du moteur de rendu intégré est résolu par emplacement. Le remplacement de l'exécution et le remplacement du rendu sont indépendants. Si votre remplacement omet renderCall, le renderCall intégré est utilisé. Si votre remplacement omet renderResult, le renderResult intégré est utilisé. Si votre remplacement omet les deux, le moteur de rendu intégré est utilisé automatiquement (surbrillance de la syntaxe, différences, etc.). Cela vous permet d'encapsuler des outils intégrés pour la journalisation ou le contrôle d'accès sans réimplémenter l'interface utilisateur.
Métadonnées d'invite: promptSnippet et promptGuidelines ne sont pas héritées de l'outil intégré. Si votre remplacement doit conserver ces instructions d'invite, définissez-les explicitement sur le remplacement.
Votre implémentation doit correspondre à la forme exacte du résultat, y compris le type details. L'interface utilisateur et la logique de session dépendent de ces formes pour le rendu et le suivi de l'état.
Implémentations d'outils intégrés:
- read.ts -
ReadToolDetails - bash.ts -
BashToolDetails - edit.ts
- write.ts
- grep.ts -
GrepToolDetails - find.ts -
FindToolDetails - ls.ts -
LsToolDetails
Exécution à distance
Les outils intégrés prennent en charge les opérations enfichables pour la délégation à des systèmes distants (SSH, conteneurs, etc.):
import { createReadTool, createBashTool, type ReadOperations } from "@earendil-works/pi-coding-agent";
// Create tool with custom operations
const remoteRead = createReadTool(cwd, {
operations: {
readFile: (path) => sshExec(remote, `cat ${path}`),
access: (path) => sshExec(remote, `test -r ${path}`).then(() => {}),
}
});
// Register, checking flag at execution time
pi.registerTool({
...remoteRead,
async execute(id, params, signal, onUpdate, _ctx) {
const ssh = getSshConfig();
if (ssh) {
const tool = createReadTool(cwd, { operations: createRemoteOps(ssh) });
return tool.execute(id, params, signal, onUpdate);
}
return localRead.execute(id, params, signal, onUpdate);
},
});Interfaces d'opérations: ReadOperations, WriteOperations, EditOperations, BashOperations, LsOperations, GrepOperations, FindOperations
Pour user_bash, les extensions peuvent réutiliser le backend du shell local de pi via createLocalBashOperations() au lieu de réimplémenter la génération de processus locaux, la résolution du shell et la terminaison de l'arborescence des processus.
L'outil bash prend également en charge un hook d'apparition pour ajuster la commande, cwd ou env avant l'exécution:
import { createBashTool } from "@earendil-works/pi-coding-agent";
const bashTool = createBashTool(cwd, {
spawnHook: ({ command, cwd, env }) => ({
command: `source ~/.profile\n${command}`,
cwd: `/mnt/sandbox${cwd}`,
env: { ...env, CI: "1" },
}),
});createBashTool() expose la session en cours aux commandes via PI_SESSION_ID, PI_SESSION_FILE, PI_PROVIDER, PI_MODEL et PI_REASONING_LEVEL. L'injection a lieu avant spawnHook, donc les hooks reçoivent ces valeurs en env et les préservent lorsqu'ils propagent l'environnement existant comme ci-dessus. Définissez exposeSessionEnvironment: false pour les désactiver:
const bashTool = createBashTool(cwd, {
exposeSessionEnvironment: false,
});Voir Bash tool session environment pour la sémantique des variables. Voir examples/extensions/ssh.ts pour un exemple complet SSH avec l'indicateur --ssh.
Troncature de sortie
Les outils DOIVENT tronquer leur sortie pour éviter de surcharger le contexte LLM. Des sorties importantes peuvent provoquer:
- Erreurs de dépassement de contexte (invite trop longue)
- Échecs de compactage
- Performances du modèle dégradées
La limite intégrée est de 50 Ko (~ 10 000 jetons) et de 2 000 lignes, selon la première éventualité atteinte. Utilisez les utilitaires de troncature exportés:
import {
truncateHead, // Keep first N lines/bytes (good for file reads, search results)
truncateTail, // Keep last N lines/bytes (good for logs, command output)
truncateLine, // Truncate a single line to maxBytes with ellipsis
formatSize, // Human-readable size (e.g., "50KB", "1.5MB")
DEFAULT_MAX_BYTES, // 50KB
DEFAULT_MAX_LINES, // 2000
} from "@earendil-works/pi-coding-agent";
async execute(toolCallId, params, signal, onUpdate, ctx) {
const output = await runCommand();
// Apply truncation
const truncation = truncateHead(output, {
maxLines: DEFAULT_MAX_LINES,
maxBytes: DEFAULT_MAX_BYTES,
});
let result = truncation.content;
if (truncation.truncated) {
// Write full output to temp file
const tempFile = writeTempFile(output);
// Inform the LLM where to find complete output
result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
result += ` Full output saved to: ${tempFile}]`;
}
return { content: [{ type: "text", text: result }] };
}Points clés:
- Utilisez
truncateHeadpour le contenu dont le début est important (résultats de recherche, lectures de fichiers) - Utilisez
truncateTailpour le contenu où la fin compte (journaux, sortie de commande) - Informez toujours le LLM lorsque la sortie est tronquée et où trouver la version complète
- Documentez les limites de troncature dans la description de votre outil
Voir examples/extensions/truncated-tool.ts pour un exemple complet d'encapsulation de rg (ripgrep) avec une troncature appropriée.
Plusieurs outils
Une extension peut enregistrer plusieurs outils avec un état partagé:
export default function (pi: ExtensionAPI) {
let connection = null;
pi.registerTool({ name: "db_connect", ... });
pi.registerTool({ name: "db_query", ... });
pi.registerTool({ name: "db_close", ... });
pi.on("session_shutdown", async () => {
connection?.close();
});
}Rendu personnalisé
Les outils peuvent fournir renderCall et renderResult pour un affichage personnalisé TUI. Voir tui.md pour le composant complet API et tool-execution.ts pour la façon dont les lignes d'outils sont composées.
Par défaut, la sortie de l'outil est entourée d'un Box qui gère le remplissage et l'arrière-plan. Un renderCall ou un renderResult défini doit renvoyer un Component. Si aucun moteur de rendu d'emplacement n'est défini, tool-execution.ts utilise le rendu de secours pour cet emplacement.
Définissez renderShell: "self" lorsque l'outil doit restituer son propre shell au lieu d'utiliser le Box par défaut. Ceci est utile pour les outils qui nécessitent un contrôle complet sur le cadrage ou le comportement de l'arrière-plan, par exemple les grands aperçus qui doivent rester visuellement stables une fois l'outil installé.
pi.registerTool({
name: "my_tool",
label: "My Tool",
description: "Custom shell example",
parameters: Type.Object({}),
renderShell: "self",
async execute() {
return { content: [{ type: "text", text: "ok" }], details: undefined };
},
renderCall(args, theme, context) {
return new Text(theme.fg("accent", "my custom shell"), 0, 0);
},
});renderCall et renderResult reçoivent chacun un objet context avec:
args- les arguments d'appel d'outil actuelsstate- état local de ligne partagé entrerenderCalletrenderResultlastComponent- le composant précédemment renvoyé pour cet emplacement, le cas échéantinvalidate()- demander un nouveau rendu de cette ligne d'outilstoolCallId,cwd,executionStarted,argsComplete,isPartial,expanded,showImages,isError
Utilisez context.state pour l'état partagé entre plusieurs emplacements. Conservez les caches locaux sur l'instance de composant renvoyée lorsque vous souhaitez réutiliser et muter le même composant entre les rendus.
rendreAppel
Affiche l'appel ou l'en-tête de l'outil:
import { Text } from "@earendil-works/pi-tui";
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
let content = theme.fg("toolTitle", theme.bold("my_tool "));
content += theme.fg("muted", args.action);
if (args.text) {
content += " " + theme.fg("dim", `"${args.text}"`);
}
text.setText(content);
return text;
}renduRésultat
Rend le résultat ou la sortie de l'outil:
renderResult(result, { expanded, isPartial }, theme, context) {
if (isPartial) {
return new Text(theme.fg("warning", "Processing..."), 0, 0);
}
if (result.details?.error) {
return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0);
}
let text = theme.fg("success", "✓ Done");
if (expanded && result.details?.items) {
for (const item of result.details.items) {
text += "\n " + theme.fg("dim", item);
}
}
return new Text(text, 0, 0);
}Si un emplacement n'a intentionnellement aucun contenu visible, renvoyez un Component vide tel qu'un Container vide.
Conseils pour les raccourcis clavier
Utilisez keyHint() pour afficher des astuces de raccourcis clavier qui respectent la configuration de raccourcis clavier active:
import { keyHint } from "@earendil-works/pi-coding-agent";
renderResult(result, { expanded }, theme, context) {
let text = theme.fg("success", "✓ Done");
if (!expanded) {
text += ` (${keyHint("app.tools.expand", "to expand")})`;
}
return new Text(text, 0, 0);
}Fonctions disponibles:
keyHint(keybinding, description)- Formate un identifiant de liaison de touches configuré tel que"app.tools.expand"ou"tui.select.confirm"keyText(keybinding)- Renvoie le texte de clé brut configuré pour un identifiant de liaison de touchesrawKeyHint(key, description)- Formater une chaîne de clé brute
Utilisez des identifiants de raccourcis clavier avec espace de noms:
- Les identifiants d'agent de codage utilisent l'espace de noms
app.*, par exempleapp.tools.expand,app.editor.external,app.session.rename - Les identifiants TUI partagés utilisent l'espace de noms
tui.*, par exempletui.select.confirm,tui.select.cancel,tui.input.tab
Pour la liste exhaustive des identifiants de raccourcis clavier et des valeurs par défaut, voir keybindings.md. keybindings.json utilise ces mêmes identifiants d'espace de noms.
Les éditeurs personnalisés et les composants ctx.ui.custom() reçoivent keybindings: KeybindingsManager comme argument injecté. Ils devraient utiliser directement ce gestionnaire injecté au lieu d'appeler getKeybindings() ou setKeybindings().
Meilleures pratiques
- Utilisez
Textavec un remplissage(0, 0). La boîte par défaut gère le remplissage. - Utilisez
\npour le contenu multiligne. - Gérez
isPartialpour la progression du streaming. - Supportez
expandedpour plus de détails sur demande. - Gardez la vue par défaut compacte.
- Lisez
context.argsdansrenderResultau lieu de copier les arguments danscontext.state. - Utilisez
context.stateuniquement pour les données qui doivent être partagées entre les emplacements d'appel et de résultat. - Réutilisez
context.lastComponentlorsque la même instance de composant peut être mise à jour sur place. - Utilisez
renderShell: "self"uniquement lorsque le shell en boîte par défaut gêne. En mode self-shell, l'outil est responsable de son propre cadrage, remplissage et arrière-plan.
Retomber
Si un moteur de rendu de slot n'est pas défini ou génère:
renderCall: affiche le nom de l'outilrenderResult: affiche le texte brut decontent
Chargement dynamique des outils
Extensions peut enregistrer de nombreux outils tout en ne gardant actif qu'un petit ensemble initial. Un outil peut alors ajouter d'autres outils avec pi.setActiveTools() lors de l'exécution. Pi détecte les modifications purement additives, enregistre les noms d'outils nouvellement disponibles sur ce résultat d'outil et applique l'ensemble actif mis à jour avant la prochaine demande de modèle.
Cela fonctionne avec tous les modèles. Models avec la prise en charge native du chargement différé préserve le préfixe d'invite stable et charge les nouvelles définitions à la position du résultat de l'outil. D'autres modèles utilisent la solution de secours décrite ci-dessous.
Le cycle de vie est:
- Enregistrez chaque outil avec
pi.registerTool()pour qu'il apparaisse danspi.getAllTools(). - Gardez les outils de chargement, tels que
search_tools, actifs et laissez les outils de recherche inactifs. - Pendant l'exécution du chargeur, appelez
pi.setActiveTools([...currentTools,...matchingTools]). Le changement doit être additif: ne supprimez pas les outils actuellement actifs dans le même appel. - Pi enregistre les outils qui ont été ajoutés au résultat de l'outil du chargeur.
- Avant la réponse suivante du modèle, Pi expose les définitions ajoutées en utilisant le chargement différé natif lorsqu'il est pris en charge, ou la liste d'outils actifs normaux dans le cas contraire.
Vous n'avez pas besoin de renvoyer des références d'outils spécifiques au fournisseur ni de marquer le chargeur comme outil de recherche spécial. Le changement d'outil actif est le signal. Les noms passés à pi.setActiveTools() doivent déjà être enregistrés; les noms inconnus sont ignorés.
Models avec chargement différé natif
- Anthropique
- Models: Sonnet, Opus, Fable version 4.5 ou plus récente (sans Haiku)
- Représentation native: Les définitions différées utilisent
defer_loading; le point de chargement utilise le contenutool_reference.
- OpenAI
- Models:
gpt-5.4et famille plus récente - Représentation native: Pi ajoute les éléments client
tool_search_callettool_search_outputterminés au point de chargement.
- Models:
Pour un modèle personnalisé ou un proxy vérifié, la gestion native peut être activée avec compat.supportsToolReferences: true pour anthropic-messages, ou compat.supportsToolSearch: true pour openai-responses et openai-codex-responses. Laissez-les désactivés à moins que le point de terminaison et le modèle acceptent le protocole natif correspondant.
Comportement de repli
Pour tous les autres modèles et fournisseurs, l'activation dynamique fonctionne toujours: Pi envoie normalement la liste complète des outils actifs actuels lors de la prochaine demande. Le modèle peut appeler les outils nouvellement activés, mais l'ajout de leurs définitions peut invalider le préfixe d'invite mis en cache du fournisseur.
Pi utilise également cette solution de repli sûre lorsque l'ensemble actif n'est pas purement additif, comme le remplacement d'un groupe d'outils par un autre. Les suppressions d'outils fonctionnent donc, mais elles ne font pas appel au chargement différé.
Pour un meilleur comportement du cache, gardez l'outil de chargement actif pendant toute la session et ajoutez des outils au lieu de remplacer l'ensemble actif. Notez également que l'activation d'un outil avec promptSnippet ou promptGuidelines reconstruit l'invite système; cette modification à l'invite du système peut invalider le préfixe même lorsque le fournisseur prend en charge les schémas différés. Les outils chargés paresseusement doivent généralement s'appuyer sur leur outil description et omettre les métadonnées d'invite actives uniquement.
Exemple d'outil de recherche
L'extension suivante enregistre deux outils consultables, les supprime de l'ensemble actif initial et ne conserve que search_tools comme chargeur. L'exemple utilise une simple correspondance de mots clés, mais l'implémentation de la recherche peut utiliser BM25, des intégrations, un catalogue distant ou un routage spécifique au projet.
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
const SEARCHABLE_TOOL_NAMES = new Set(["lookup_weather", "search_issues"]);
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "lookup_weather",
label: "Lookup Weather",
description: "Look up the current weather for a city",
parameters: Type.Object({ city: Type.String() }),
async execute(_toolCallId, params) {
return {
content: [{ type: "text", text: `Weather for ${params.city}: sunny` }],
details: {},
};
},
});
pi.registerTool({
name: "search_issues",
label: "Search Issues",
description: "Search project issues by keyword",
parameters: Type.Object({ query: Type.String() }),
async execute(_toolCallId, params) {
return {
content: [{ type: "text", text: `No open issues matching ${params.query}` }],
details: {},
};
},
});
pi.registerTool({
name: "search_tools",
label: "Search Tools",
description: "Search for and enable tools relevant to a task",
promptSnippet: "Search for additional tools when the active tools cannot perform the task",
promptGuidelines: [
"Use search_tools when a task requires a capability that is not currently available.",
],
parameters: Type.Object({
query: Type.String({ description: "Capability or task to search for" }),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })),
}),
async execute(_toolCallId, params) {
const terms = params.query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
const matches = pi.getAllTools()
.filter((tool) => SEARCHABLE_TOOL_NAMES.has(tool.name))
.map((tool) => ({
tool,
score: terms.reduce(
(score, term) =>
score + (`${tool.name} ${tool.description}`.toLowerCase().includes(term) ? 1 : 0),
0,
),
}))
.filter((match) => match.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, params.limit ?? 3)
.map((match) => match.tool.name);
if (matches.length === 0) {
return {
content: [{ type: "text", text: `No tools found for: ${params.query}` }],
details: { matches: [] },
};
}
const active = pi.getActiveTools();
const added = matches.filter((name) => !active.includes(name));
pi.setActiveTools([...new Set([...active, ...added])]);
return {
content: [{
type: "text",
text: added.length > 0
? `Loaded tools: ${added.join(", ")}`
: `Matching tools already active: ${matches.join(", ")}`,
}],
details: { matches, added },
};
},
});
pi.on("session_start", () => {
// Keep searchable tools registered but initially inactive. Preserve built-ins
// and tools owned by other extensions, and keep the loader itself active.
const initialTools = pi.getActiveTools().filter(
(name) => !SEARCHABLE_TOOL_NAMES.has(name),
);
pi.setActiveTools([...new Set([...initialTools, "search_tools"])]);
});
}Lorsque search_tools ajoute une correspondance, le modèle reçoit cette définition lors de la requête immédiatement suivante. Sur un modèle compatible natif, la définition est ancrée après le résultat de la recherche sans modifier le préfixe initial du schéma d'outil. Sur d'autres modèles, il apparaît dans la liste d'outils normale sur la même demande suivante.
Interface utilisateur personnalisée
Extensions peut interagir avec les utilisateurs via les méthodes ctx.ui et personnaliser le rendu des messages/outils.
Pour les composants personnalisés, voir tui.md qui propose des modèles de copier-coller pour:
- Boîtes de dialogue de sélection (SelectList)
- Opérations asynchrones avec annulation (BorderedLoader)
- Bascule les paramètres (SettingsList)
- Indicateurs d'état (setStatus)
- Message de travail, visibilité et indicateur pendant le streaming (
setWorkingMessage,setWorkingVisible,setWorkingIndicator) - Éditeur de widgets au-dessus/en-dessous (setWidget)
- Fournisseurs de saisie semi-automatique superposés à la complétion de chemin/slash intégrée (addAutocompleteProvider)
- Pieds de page personnalisés (setFooter)
Boîtes de dialogue
// Select from options
const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]);
// Confirm dialog
const ok = await ctx.ui.confirm("Delete?", "This cannot be undone");
// Text input
const name = await ctx.ui.input("Name:", "placeholder");
// Multi-line editor
const text = await ctx.ui.editor("Edit:", "prefilled text");
// Notification (non-blocking)
ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"Dialogues chronométrés avec compte à rebours
Les boîtes de dialogue prennent en charge une option timeout qui se ferme automatiquement avec un affichage de compte à rebours en direct:
// Dialog shows "Title (5s)" → "Title (4s)" → ... → auto-dismisses at 0
const confirmed = await ctx.ui.confirm(
"Timed Confirmation",
"This dialog will auto-cancel in 5 seconds. Confirm?",
{ timeout: 5000 }
);
if (confirmed) {
// User confirmed
} else {
// User cancelled or timed out
}Valeurs renvoyées en cas d'expiration:
select()renvoieundefinedconfirm()renvoiefalseinput()renvoieundefined
Licenciement manuel avec AbortSignal
Pour plus de contrôle (par exemple, pour distinguer le délai d'attente de l'annulation par l'utilisateur), utilisez AbortSignal:
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const confirmed = await ctx.ui.confirm(
"Timed Confirmation",
"This dialog will auto-cancel in 5 seconds. Confirm?",
{ signal: controller.signal }
);
clearTimeout(timeoutId);
if (confirmed) {
// User confirmed
} else if (controller.signal.aborted) {
// Dialog timed out
} else {
// User cancelled (pressed Escape or selected "No")
}Voir examples/extensions/timed-confirm.ts pour des exemples complets.
Widgets, statut et pied de page
// Status in footer (persistent until cleared)
ctx.ui.setStatus("my-ext", "Processing...");
ctx.ui.setStatus("my-ext", undefined); // Clear
// Working loader (shown during streaming)
ctx.ui.setWorkingMessage("Thinking deeply...");
ctx.ui.setWorkingMessage(); // Restore default
ctx.ui.setWorkingVisible(false); // Hide the built-in working loader row entirely
ctx.ui.setWorkingVisible(true); // Show the built-in working loader row
// Working indicator (shown during streaming)
ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] }); // Static dot
ctx.ui.setWorkingIndicator({
frames: [
ctx.ui.theme.fg("dim", "·"),
ctx.ui.theme.fg("muted", "•"),
ctx.ui.theme.fg("accent", "●"),
ctx.ui.theme.fg("muted", "•"),
],
intervalMs: 120,
});
ctx.ui.setWorkingIndicator({ frames: [] }); // Hide indicator
ctx.ui.setWorkingIndicator(); // Restore default spinner
// Widget above editor (default)
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
// Widget below editor
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });
ctx.ui.setWidget("my-widget", (tui, theme) => new Text(theme.fg("accent", "Custom"), 0, 0));
ctx.ui.setWidget("my-widget", undefined); // Clear
// Custom footer (replaces built-in footer entirely)
ctx.ui.setFooter((tui, theme) => ({
render(width) { return [theme.fg("dim", "Custom footer")]; },
invalidate() {},
}));
ctx.ui.setFooter(undefined); // Restore built-in footer
// Terminal title
ctx.ui.setTitle("pi - my-project");
// Editor text
ctx.ui.setEditorText("Prefill text");
const current = ctx.ui.getEditorText();
// Paste into editor (triggers paste handling, including collapse for large content)
ctx.ui.pasteToEditor("pasted content");
// Stack custom autocomplete behavior on top of the built-in provider
ctx.ui.addAutocompleteProvider((current) => ({
triggerCharacters: ["#"],
async getSuggestions(lines, line, col, options) {
const beforeCursor = (lines[line] ?? "").slice(0, col);
const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/);
if (!match) {
return current.getSuggestions(lines, line, col, options);
}
return {
prefix: `#${match[1] ?? ""}`,
items: [{ value: "#2983", label: "#2983", description: "Extension API for autocomplete" }],
};
},
applyCompletion(lines, line, col, item, prefix) {
return current.applyCompletion(lines, line, col, item, prefix);
},
shouldTriggerFileCompletion(lines, line, col) {
return current.shouldTriggerFileCompletion?.(lines, line, col) ?? true;
},
}));
// Tool output expansion
const wasExpanded = ctx.ui.getToolsExpanded();
ctx.ui.setToolsExpanded(true);
ctx.ui.setToolsExpanded(wasExpanded);
// Custom editor (vim mode, emacs mode, etc.)
ctx.ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(tui, theme, keybindings));
const currentEditor = ctx.ui.getEditorComponent();
ctx.ui.setEditorComponent((tui, theme, keybindings) =>
new WrappedEditor(tui, theme, keybindings, currentEditor?.(tui, theme, keybindings))
);
ctx.ui.setEditorComponent(undefined); // Restore default editor
// Theme management (see themes.md for creating themes)
const themes = ctx.ui.getAllThemes(); // [{ name: "dark", path: "/..." | undefined }, ...]
const lightTheme = ctx.ui.getTheme("light"); // Load without switching
const result = ctx.ui.setTheme("light"); // Switch by name
if (!result.success) {
ctx.ui.notify(`Failed: ${result.error}`, "error");
}
ctx.ui.setTheme(lightTheme!); // Or switch by Theme object
ctx.ui.theme.fg("accent", "styled text"); // Access current themeLes cadres d’indicateurs de travail personnalisés sont rendus textuellement. Si vous voulez des couleurs, ajoutez-les vous-même aux chaînes du cadre, par exemple avec ctx.ui.theme.fg(...).
Saisie semi-automatique Providers
Utilisez ctx.ui.addAutocompleteProvider() pour empiler une logique de saisie semi-automatique personnalisée au-dessus de la commande slash et du fournisseur de chemin intégrés. Définissez triggerCharacters pour les déclencheurs naturels personnalisés tels que Utilisez ctx.ui.addAutocompleteProvider()pour empiler une logique de saisie semi-automatique personnalisée au-dessus de la commande slash et du fournisseur de chemin intégrés. DéfinisseztriggerCharacters` pour les déclencheurs naturels personnalisés tels que.
Modèle typique:
- inspecter le texte avant le curseur
- renvoie vos propres suggestions lorsque la syntaxe spécifique à votre extension correspond
- sinon déléguez à
current.getSuggestions(...) - déléguez
applyCompletion(...)sauf si vous avez besoin d'un comportement d'insertion personnalisé
pi.on("session_start", (_event, ctx) => {
ctx.ui.addAutocompleteProvider((current) => ({
triggerCharacters: ["#"],
async getSuggestions(lines, cursorLine, cursorCol, options) {
const line = lines[cursorLine] ?? "";
const beforeCursor = line.slice(0, cursorCol);
const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/);
if (!match) {
return current.getSuggestions(lines, cursorLine, cursorCol, options);
}
return {
prefix: `#${match[1] ?? ""}`,
items: [
{ value: "#2983", label: "#2983", description: "Extension API for registering custom @ autocomplete providers" },
{ value: "#2753", label: "#2753", description: "Reload stale resource settings" },
],
};
},
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
},
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
},
}));
});Voir github-issue-autocomplete.ts pour un exemple complet qui précharge les derniers problèmes ouverts GitHub avec gh issue list et les filtre localement pour une achèvement rapide #.... Cela nécessite GitHub CLI (gh) et une extraction de référentiel GitHub.
Composants personnalisés
Pour une interface utilisateur complexe, utilisez ctx.ui.custom(). Cela remplace temporairement l'éditeur par votre composant jusqu'à ce que done() soit appelé:
import { Text, Component } from "@earendil-works/pi-tui";
const result = await ctx.ui.custom<boolean>((tui, theme, keybindings, done) => {
const text = new Text("Press Enter to confirm, Escape to cancel", 1, 1);
text.onKey = (key) => {
if (key === "return") done(true);
if (key === "escape") done(false);
return true;
};
return text;
});
if (result) {
// User pressed Enter
}Le rappel reçoit:
- Instance
tui- TUI (pour les dimensions de l'écran, gestion du focus) theme- Thème actuel pour le stylekeybindings- Gestionnaire de raccourcis clavier d'application (pour vérifier les raccourcis)done(value)- Appel pour fermer le composant et renvoyer la valeur
Voir tui.md pour le composant complet API.
Mode superposition (expérimental)
Passez { overlay: true } pour afficher le composant sous forme modale flottante au-dessus du contenu existant, sans effacer l'écran:
const result = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }),
{ overlay: true }
);Pour un positionnement avancé (ancres, marges, pourcentages, visibilité réactive), passez overlayOptions. Utilisez onHandle pour contrôler la mise au point ou la visibilité par programmation:
const result = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }),
{
overlay: true,
overlayOptions: { anchor: "top-right", width: "50%", margin: 2 },
onHandle: (handle) => {
handle.focus(); // focus this overlay and bring it to the visual front
// handle.unfocus({ target: editorComponent }); // release input to a specific component
// handle.setHidden(true/false); // toggle visibility
// handle.hide(); // permanently remove
}
}
);Une superposition visible ciblée peut récupérer les entrées après la fermeture temporaire de l'interface utilisateur personnalisée sans superposition. Si vous souhaitez intentionnellement qu'un autre composant conserve l'entrée pendant que la superposition reste visible, appelez handle.unfocus({ target }). Passer { target: null } libère la superposition sans focaliser un autre composant.
Voir tui.md pour les OverlayOptions et OverlayHandle API et overlay-qa-tests.ts complets pour des exemples.
Éditeur personnalisé
Remplacez l'éditeur d'entrée principal par une implémentation personnalisée (mode vim, mode emacs, etc.):
import { CustomEditor, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { matchesKey } from "@earendil-works/pi-tui";
class VimEditor extends CustomEditor {
private mode: "normal" | "insert" = "insert";
handleInput(data: string): void {
if (matchesKey(data, "escape") && this.mode === "insert") {
this.mode = "normal";
return;
}
if (this.mode === "normal" && data === "i") {
this.mode = "insert";
return;
}
super.handleInput(data); // App keybindings + text editing
}
}
export default function (pi: ExtensionAPI) {
pi.on("session_start", (_event, ctx) => {
ctx.ui.setEditorComponent((tui, theme, keybindings) =>
new VimEditor(tui, theme, keybindings)
);
});
}Points clés:
- Étendez
CustomEditor(pas la baseEditor) pour obtenir les raccourcis clavier de l'application (échappement pour abandonner, ctrl+d, changement de modèle) - Appelez le
super.handleInput(data)pour les clés que vous ne gérez pas - L'usine reçoit
tui,themeetkeybindingsde l'application - Utilisez
ctx.ui.getEditorComponent()avantsetEditorComponent()pour envelopper l'éditeur personnalisé précédemment configuré - Passez
undefinedpour restaurer la valeur par défaut:ctx.ui.setEditorComponent(undefined)
Pour composer avec une autre extension qui a déjà remplacé l'éditeur, capturez l'usine précédente avant de paramétrer la vôtre:
const previous = ctx.ui.getEditorComponent();
ctx.ui.setEditorComponent((tui, theme, keybindings) =>
new MyEditor(tui, theme, keybindings, { base: previous?.(tui, theme, keybindings) })
);Voir tui.md Modèle 7 pour un exemple complet avec indicateur de mode.
Rendu des messages et des entrées
Enregistrez un moteur de rendu personnalisé pour les messages avec votre customType. Utilisez des moteurs de rendu de messages pour le contenu qui doit participer au contexte LLM:
import { Text } from "@earendil-works/pi-tui";
pi.registerMessageRenderer("my-extension", (message, options, theme) => {
const { expanded, outputPad } = options;
let text = theme.fg("accent", `[${message.customType}] `);
text += message.content;
if (expanded && message.details) {
text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2));
}
return new Text(text, outputPad, 0);
});Les messages sont envoyés via pi.sendMessage():
pi.sendMessage({
customType: "my-extension", // Matches registerMessageRenderer
content: "Status update",
display: true, // Show in TUI
details: { ... }, // Available in renderer
});Pour le contenu TUI uniquement qui ne doit pas être envoyé au LLM, affichez plutôt les entrées personnalisées:
pi.registerEntryRenderer("my-card", (entry, options, theme) => {
return new Text(theme.fg("accent", JSON.stringify(entry.data)));
});
pi.appendEntry("my-card", { status: "done" });Couleurs du thème
Toutes les fonctions de rendu reçoivent un objet theme. Voir themes.md pour créer des thèmes personnalisés et la palette de couleurs complète.
// Foreground colors
theme.fg("toolTitle", text) // Tool names
theme.fg("accent", text) // Highlights
theme.fg("success", text) // Success (green)
theme.fg("error", text) // Errors (red)
theme.fg("warning", text) // Warnings (yellow)
theme.fg("muted", text) // Secondary text
theme.fg("dim", text) // Tertiary text
// Text styles
theme.bold(text)
theme.italic(text)
theme.strikethrough(text)Pour la coloration syntaxique dans les moteurs de rendu d'outils personnalisés:
import { highlightCode, getLanguageFromPath } from "@earendil-works/pi-coding-agent";
// Highlight code with explicit language
const highlighted = highlightCode("const x = 1;", "typescript", theme);
// Auto-detect language from file path
const lang = getLanguageFromPath("/path/to/file.rs"); // "rust"
const highlighted = highlightCode(code, lang, theme);Gestion des erreurs
- Les erreurs d'extension sont enregistrées, l'agent continue
- Les erreurs
tool_callbloquent l'outil (sécurité intégrée) - Les erreurs de l'outil
executedoivent être signalées par un lancer; l'erreur générée est détectée, signalée au LLM avecisError: trueet l'exécution continue
Comportement des modes
| Mode | ctx.mode |
ctx.hasUI |
Remarques |
|---|---|---|---|
| Interactif | "tui" |
true |
Complet TUI avec rendu du terminal |
RPC (--mode rpc) |
"rpc" |
true |
Dialogues et notifications via le protocole JSON; custom() renvoie undefined. Voir rpc.md |
JSON (--mode json) |
"json" |
false |
Flux d'événements vers stdout; Les méthodes d'interface utilisateur ne fonctionnent pas |
Imprimer (-p) |
"print" |
false |
Extensions exécuté mais ne peut pas demander |
Utilisez ctx.mode === "tui" avant les fonctionnalités spécifiques à TUI (custom(), usines de composants, entrée de terminal). Utilisez ctx.hasUI avant les méthodes de dialogue et de notification qui fonctionnent dans les modes TUI et RPC.
Exemples de référence
Tous les exemples en examples/extensions/.
| Exemple | Description | Touche API |
|---|---|---|
| Outils | ||
hello.ts |
Enregistrement minimal des outils | registerTool |
question.ts |
Outil avec interaction utilisateur | registerTool, ui.select |
questionnaire.ts |
Outil d'assistant en plusieurs étapes | registerTool, ui.custom |
todo.ts |
Outil avec état avec persistance | registerTool, appendEntry, renderResult, événements de session |
dynamic-tools.ts |
Enregistrez les outils après le démarrage et pendant les commandes | registerTool, session_start, registerCommand |
structured-output.ts |
Outil de sortie structurée final avec terminate: true |
registerTool, fin des résultats de l'outil |
truncated-tool.ts |
Exemple de troncature de sortie | registerTool, truncateHead |
tool-override.ts |
Remplacer l'outil de lecture intégré | registerTool (même nom que celui intégré) |
| Commandes | ||
pirate.ts |
Modifier l'invite du système par tour | registerCommand, before_agent_start |
summarize.ts |
Commande de résumé de conversation | registerCommand, ui.custom |
handoff.ts |
Transfert de modèle entre fournisseurs | registerCommand, ui.editor, ui.custom |
qna.ts |
Questions et réponses avec interface utilisateur personnalisée | registerCommand, ui.custom, setEditorText |
send-user-message.ts |
Injecter les messages des utilisateurs | registerCommand, sendUserMessage |
reload-runtime.ts |
Commande de rechargement et transfert de l'outil LLM | registerCommand, ctx.reload(), sendUserMessage |
shutdown-command.ts |
Commande d'arrêt progressif | registerCommand, shutdown() |
| Événements et portes | ||
permission-gate.ts |
Bloquer les commandes dangereuses | on("tool_call"), ui.confirm |
project-trust.ts |
Décider ou différer l'approbation du projet par un utilisateur/global ou une extension CLI | on("project_trust"), confiance dans l'interface utilisateur, résultat de confiance requis |
protected-paths.ts |
Bloquer les écritures sur des chemins spécifiques | on("tool_call") |
confirm-destructive.ts |
Confirmer les modifications de session | on("session_before_switch"), on("session_before_fork") |
dirty-repo-guard.ts |
Avertir en cas de dépôt git sale | on("session_before_*"), exec |
input-transform.ts |
Transformer la saisie de l'utilisateur | on("input") |
input-transform-streaming.ts |
Transformation d'entrée compatible avec le streaming | on("input"), streamingBehavior |
model-status.ts |
React pour modéliser les changements | on("model_select"), setStatus |
provider-payload.ts |
Inspecter les charges utiles et les en-têtes de réponse du fournisseur | on("before_provider_request"), on("after_provider_response") |
system-prompt-header.ts |
Afficher les informations d'invite du système | on("agent_start"), getSystemPrompt |
claude-rules.ts |
Charger des règles à partir de fichiers | on("session_start"), on("before_agent_start") |
prompt-customizer.ts |
Ajoutez des conseils d'outils contextuels à l'aide de systemPromptOptions |
on("before_agent_start"), BuildSystemPromptOptions |
file-trigger.ts |
L'observateur de fichiers déclenche des messages | sendMessage |
| Compactage et séances | ||
custom-compaction.ts |
Résumé du compactage personnalisé | on("session_before_compact") |
trigger-compact.ts |
Déclencher le compactage manuellement | compact() |
git-checkpoint.ts |
Git réserve aux tours | on("turn_start"), on("session_before_fork"), exec |
git-merge-and-resolve.ts |
Récupérer, fusionner et résoudre les conflits | on("agent_end"), exec, sendUserMessage |
auto-commit-on-exit.ts |
S'engager à l'arrêt | on("session_shutdown"), exec |
| Composants de l'interface utilisateur | ||
status-line.ts |
Indicateur d'état du pied de page | setStatus, événements de session |
working-indicator.ts |
Personnaliser l'indicateur de fonctionnement du streaming | setWorkingIndicator, registerCommand |
github-issue-autocomplete.ts |
Ajoutez la complétion de problèmes #1234 en plus de la saisie semi-automatique intégrée en préchargeant les problèmes ouverts récents à partir de gh issue list |
addAutocompleteProvider, on("session_start"), exec |
custom-footer.ts |
Remplacer entièrement le pied de page | registerCommand, setFooter |
custom-header.ts |
Remplacer l'en-tête de démarrage | on("session_start"), setHeader |
modal-editor.ts |
Éditeur modal de style Vim | setEditorComponent, CustomEditor |
rainbow-editor.ts |
Style d'éditeur personnalisé | setEditorComponent |
widget-placement.ts |
Éditeur de widget au-dessus/en-dessous | setWidget |
overlay-test.ts |
Composants de superposition | ui.custom avec options de superposition |
overlay-qa-tests.ts |
Tests de superposition complets | ui.custom, toutes les options de superposition |
notify.ts |
Notifications simples | ui.notify |
timed-confirm.ts |
Boîtes de dialogue avec timeout | ui.confirm avec délai d'attente/signal |
mac-system-theme.ts |
Thème de changement automatique | setTheme, exec |
| Complexe Extensions | ||
plan-mode/ |
Implémentation du mode plan complet | Tous les types d'événements, registerCommand, registerShortcut, registerFlag, setStatus, setWidget, sendMessage, setActiveTools |
preset.ts |
Préréglages enregistrables (modèle, outils, réflexion) | registerCommand, registerShortcut, registerFlag, setModel, setActiveTools, setThinkingLevel, appendEntry |
tools.ts |
Activer/désactiver les outils de l'interface utilisateur | registerCommand, setActiveTools, SettingsList, événements de session |
| À distance et bac à sable | ||
ssh.ts |
SSH exécution à distance | registerFlag, on("user_bash"), on("before_agent_start"), opérations d'outil |
interactive-shell.ts |
Session shell persistante | on("user_bash") |
sandbox/ |
Exécution d'outils en bac à sable | Opérations sur les outils |
gondolin/ |
Acheminez les outils intégrés et les commandes ! vers une micro-VM Gondolin |
Opérations sur les outils, remplacements d'outils intégrés, on("user_bash") |
subagent/ |
Générer des sous-agents | registerTool, exec |
| Jeux | ||
snake.ts |
Jeu de serpent | registerCommand, ui.custom, manipulation du clavier |
space-invaders.ts |
Jeu Space Invaders | registerCommand, ui.custom |
doom-overlay/ |
Doom en superposition | ui.custom avec superposition |
| Providers | ||
custom-provider-anthropic/ |
Proxy anthropique personnalisé | registerProvider |
custom-provider-gitlab-duo/ |
GitIntégration Lab Duo | registerProvider avec OAuth |
| Messages et communications | ||
message-renderer.ts |
Rendu des messages personnalisé | registerMessageRenderer, sendMessage |
entry-renderer.ts |
Rendu d'entrée personnalisé TUI uniquement | registerEntryRenderer, appendEntry |
event-bus.ts |
Événements inter-extensions | pi.events |
| Métadonnées de session | ||
session-name.ts |
Nommer les sessions pour le sélecteur | setSessionName, getSessionName |
bookmark.ts |
Entrées de favoris pour /tree | setLabel |
| Divers | ||
inline-bash.ts |
Inline bash dans les appels d'outils | on("tool_call") |
bash-spawn-hook.ts |
Ajustez la commande bash, cwd et env avant l'exécution | createBashTool, spawnHook |
with-deps/ |
Extension avec npm dépendances | Structure du paquet avec package.json |