Extensions
pi は拡張機能を作成できます。あなたのユースケースに合わせて構築するよう依頼してください。
Extensions は、pi の動作を拡張する TypeScript モジュールです。ライフサイクル イベントのサブスクライブ、LLM によって呼び出し可能なカスタム ツールの登録、コマンドの追加などを行うことができます。
/reload の配置: 自動検出のために拡張機能を
~/.pi/agent/extensions/(グローバル) または.pi/extensions/(プロジェクトローカル) に配置します。pi -e ./path.tsは簡単なテストにのみ使用してください。自動検出された場所の Extensions は、/reloadでホットリロードできます。
主な機能:
- カスタム ツール - LLM が
pi.registerTool()経由で呼び出すことができるツールを登録します。 - イベント インターセプト - ツール呼び出しのブロックまたは変更、コンテキストの挿入、圧縮のカスタマイズ
- ユーザー インタラクション -
ctx.ui経由でユーザーにプロンプトを表示します (選択、確認、入力、通知) - カスタム UI コンポーネント -
ctx.ui.custom()を介したキーボード入力を備えた完全な TUI コンポーネントにより、複雑な操作が可能 - カスタム コマンド -
pi.registerCommand()経由で/mycommandのようなコマンドを登録します - セッションの永続性 - 存続するストア状態は
pi.appendEntry()経由で再起動されます - カスタム レンダリング - ツールの呼び出し/結果およびメッセージが TUI にどのように表示されるかを制御します
使用例の例:
- 許可ゲート(
rm -rf、sudoなどの前に確認してください) - Git チェックポイント設定 (各ターンに隠し、分岐時に復元)
- パス保護 (
.env、node_modules/への書き込みをブロック) - カスタム圧縮 (会話を自分の方法で要約)
- 会話の要約 (
summarize.tsの例を参照) - インタラクティブなツール (質問、ウィザード、カスタム ダイアログ)
- ステートフル ツール (ToDo リスト、接続プール)
- 外部統合 (ファイル ウォッチャー、Webhook、CI トリガー)
- 待っている間のゲーム (
snake.tsの例を参照)
実用的な実装については、examples/extensions/ を参照してください。
目次
- クイックスタート
- 拡張機能の配置場所
- 利用可能なインポート
- 拡張機能の作成
- イベント
- ExtensionContext
- ExtensionCommandContext
- ExtensionAPI メソッド
- 状態管理
- カスタムツール
- カスタム UI
- エラー処理
- モードの動作
- 例のリファレンス
クイックスタート
~/.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");
},
});
}--extension (または -e) フラグを使用してテストします。
pi -e ./my-extension.ts拡張機能の場所
セキュリティ: Extensions は完全なシステム権限で実行され、任意のコードを実行できます。信頼できるソースからのみインストールしてください。
Extensions は信頼できる場所から自動検出されます。プロジェクト ローカル .pi/extensions エントリは、プロジェクトが信頼された後にのみロードされます。
| 位置 | 範囲 |
|---|---|
~/.pi/agent/extensions/*.ts |
グローバル (すべてのプロジェクト) |
~/.pi/agent/extensions/*/index.ts |
グローバル (サブディレクトリ) |
.pi/extensions/*.ts |
プロジェクトローカル |
.pi/extensions/*/index.ts |
プロジェクトローカル (サブディレクトリ) |
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"
]
}npm または git を介して拡張機能を pi パッケージとして共有するには、packages.md を参照してください。
利用可能なインポート
| パッケージ | 目的 |
|---|---|
@earendil-works/pi-coding-agent |
拡張タイプ (ExtensionAPI、ExtensionContext、イベント) |
typebox |
ツールパラメータのスキーマ定義 |
@earendil-works/pi-ai |
AI ユーティリティ (Google 互換の列挙型の場合は StringEnum) |
@earendil-works/pi-tui |
カスタムレンダリング用のTUIコンポーネント |
npm 依存関係も機能します。拡張機能の横 (または親ディレクトリ) に package.json を追加し、npm install を実行すると、node_modules/ からのインポートが自動的に解決されます。
pi install (npm または git) でインストールされた分散 pi パッケージの場合、ランタイム deps は dependencies になければなりません。パッケージのインストールではデフォルトで実稼働インストール (npm install --omit=dev) が使用されるため、実行時には devDependencies は使用できません。 npmCommand が設定されている場合、git パッケージはラッパーとの互換性のためにプレーン install を使用します。
Node.js ビルトイン (node:fs、node:path など) も利用できます。
拡張機能の作成
拡張機能は、ExtensionAPI を受け取るデフォルトのファクトリー関数をエクスポートします。ファクトリは同期または非同期にすることができます。
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 は jiti 経由でロードされるため、TypeScript はコンパイルなしで動作します。
ファクトリーが Promise を返した場合、pi はそれを待ってから起動を続行します。つまり、非同期初期化は、session_start より前、resources_discover より前、そして pi.registerProvider() 経由でキューに入れられたプロバイダー登録がフラッシュされる前に完了します。
非同期ファクトリー関数
リモート構成の取得や利用可能なモデルの動的検出などの 1 回限りの起動作業には、非同期ファクトリーを使用します。
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,
})),
});
}このパターンでは、フェッチされたモデルが通常の起動中および pi --list-models まで利用可能になります。
有効期間の長いリソースとシャットダウン
拡張機能ファクトリーは、セッションを開始しない呼び出しで実行される場合があります。プロセス、ソケット、ファイル ウォッチャー、タイマーなどのバックグラウンド リソースをファクトリー内で開始しないでください。
バックグラウンド リソースの起動を session_start まで、またはリソースを必要とするコマンド/ツール/イベントまで延期します。冪等の session_shutdown ハンドラーを登録して、開始したセッション スコープのリソースを閉じます。
拡張子スタイル
単一ファイル - 最も単純で、小さな拡張子の場合:
~/.pi/agent/extensions/
└── my-extension.tsindex.ts を持つディレクトリ - 複数のファイル拡張子の場合:
~/.pi/agent/extensions/
└── my-extension/
├── index.ts # Entry point (exports default function)
├── tools.ts # Helper module
└── utils.ts # Helper module依存関係のあるパッケージ - npm パッケージを必要とする拡張機能の場合:
~/.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"]
}
}拡張機能ディレクトリで npm install を実行すると、node_modules/ からのインポートが自動的に機能します。
イベント
ライフサイクルの概要
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スタートアップイベント
project_trust
pi が動的構成 (.pi または .agents/skills) を持つプロジェクトを信頼するかどうかを決定する前に起動されます。これは起動時と、現在のプロセスで信頼が解決されていない cwd にセッション置換 (たとえば、/resume) が入ったときに実行されます。ユーザー/グローバル拡張機能および CLI -e 拡張機能のみが参加します。プロジェクトローカル拡張機能は、信頼が解決されるまでロードされません。
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" };
});project_trust ハンドラーは { trusted: "yes" | "no" | "undecided" } を返さなければなりません。 "yes" または "no" を返すユーザー/グローバルまたは CLI 拡張機能が決定を所有します。最初の「はい/いいえ」の決定が優先され、組み込みの信頼プロンプトが抑制されます。 remember: true を使用して、はい/いいえの決定を維持します。それ以外の場合は、現在のプロセスにのみ適用されます。 "undecided" を返して、後のハンドラーまたは組み込みの信頼フローに決定させます。プロンプトを表示する前に、ctx.hasUI を確認してください。どのハンドラーも Yes/No を返さない場合、通常の信頼解決が続行されます。保存された trust.json 決定が最初に適用され、次に defaultProjectTrust がデフォルトで pi が尋ねるか、信頼するか、拒否するかを制御します。
リソースイベント
resources_discover
session_start の後に起動されるため、拡張機能は追加のスキル、プロンプト、テーマのパスを提供できます。
起動パスには reason: "startup" が使用されます。リロードには 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"],
};
});セッションイベント
セッションストレージの内部と SessionManager API については、Session Format を参照してください。
session_start
セッションの開始時、ロード時、またはリロード時に発生します。
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
現在のセッションの表示名が /name、RPC、または 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_before_switch
新しいセッションを開始する前 (/new) またはセッションを切り替える前 (/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 };
}
});切り替えまたは新しいセッションのアクションが成功した後、pi は古い拡張機能インスタンスに対して session_shutdown を発行し、新しいセッションに対して拡張機能をリロードおよび再バインドしてから、reason: "new" | "resume" および previousSessionFile とともに session_start を発行します。
session_shutdown でクリーンアップ作業を実行し、session_start でメモリ内の状態を再確立します。
session_before_fork
/fork 経由でフォークするとき、または /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
});フォークまたはクローンが成功した後、pi は古い拡張機能インスタンスに対して session_shutdown を発行し、新しいセッションに対して拡張機能をリロードおよび再バインドしてから、reason: "fork" および previousSessionFile とともに session_start を発行します。
session_shutdown でクリーンアップ作業を実行し、session_start でメモリ内の状態を再確立します。
session_before_compact / session_compact
圧縮時に発火します。詳細はcompaction.mdをご覧ください。
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_before_tree / session_tree
/tree ナビゲーションで発生します。ツリー ナビゲーションの概念については、Sessions を参照してください。
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
開始されたセッション ランタイムが破棄される前に発生します。これを使用して、session_start または他のセッション スコープのフックから開かれたリソースをクリーンアップします。
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.
});エージェントイベント
before_agent_start
ユーザーがプロンプトを送信した後、エージェント ループの前に発生します。メッセージを挿入したり、システム プロンプトを変更したりできます。
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...",
};
});systemPromptOptions フィールドにより、拡張機能は、システム プロンプトを構築するために Pi が使用するのと同じ構造化データにアクセスできるようになります。これにより、リソースを再検出したりフラグを再解析したりすることなく、Pi がロードしたもの (カスタム プロンプト、ガイドライン、ツール スニペット、context files、スキル) を検査できます。これは、拡張機能がユーザー指定の設定を尊重しながら、情報に基づいてシステム プロンプトに詳細な変更を加える必要がある場合に使用します。
内部の before_agent_start、event.systemPrompt、および ctx.getSystemPrompt() は両方とも、現在のハンドラーの時点でのチェーンされたシステム プロンプトを反映しています。後の before_agent_start ハンドラーでも再度変更できます。
agent_start / agent_end / agent_settled
agent_start は、低レベルのエージェントの実行が開始されるときに起動されます。 agent_end は実行が終了すると起動されますが、Pi は引き続き自動再試行、自動圧縮と再試行、またはキューに入れられたフォローアップ メッセージの続行を行う可能性があります。 Pi が自動的に実行を継続しないことを認識する必要があるステータス統合には、agent_settled を使用します。
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.
});turn_start / turn_end
各ターン (LLM 応答 1 回 + ツール呼び出し) ごとに起動されます。
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
メッセージのライフサイクル更新のために発生します。
message_startとmessage_endは、ユーザー、アシスタント、toolResult メッセージに対して発生します。message_updateはアシスタントのストリーミング更新のために起動されます。message_endハンドラーは、{ message }を返して、確定されたメッセージを置き換えることができます。交換品は同じroleを維持する必要があります。
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
ツール実行ライフサイクルの更新のために起動されます。
パラレルツールモード:
tool_execution_startはプリフライトフェーズ中にアシスタントソースオーダーで放出されますtool_execution_updateイベントはツール間でインターリーブする場合がありますtool_execution_endは各ツールが完了した後、ツール完了順に出力されます- 最終
toolResultメッセージ イベントは引き続きアシスタント ソースの順序で後から出力されます
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
});context
各 LLM 呼び出しの前に発生します。メッセージを非破壊的に変更します。メッセージの種類については、Session Format を参照してください。
pi.on("context", async (event, ctx) => {
// event.messages - deep copy, safe to modify
const filtered = event.messages.filter(m => !shouldPrune(m));
return { messages: filtered };
});before_provider_headers
送信 HTTP ヘッダーが組み立てられた後に起動されます。これを使用して、リクエスト ヘッダーを追加、上書き、または削除します。
ハンドラーはその場で event.headers を変異させます。キーを文字列に設定して追加または上書きするか、null に設定して削除します。
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;
});プロバイダー要求ごとに 1 回実行されます。再試行では、フックを再起動するのではなく、同じヘッダーが再利用されます。
before_provider_request
プロバイダー固有のペイロードが構築された後、リクエストが送信される直前に起動されます。ハンドラーは拡張機能のロード順序で実行されます。 undefined を返すと、ペイロードは変更されません。他の値を返すと、後のハンドラーと実際のリクエストのペイロードが置き換えられます。
このフックは、プロバイダーレベルのシステム命令を書き換えたり、完全に削除したりできます。これらのペイロード レベルの変更は、最終的なシリアル化されたプロバイダー ペイロードではなく、Pi のシステム プロンプト文字列を報告する ctx.getSystemPrompt() には反映されません。
pi.on("before_provider_request", (event, ctx) => {
console.log(JSON.stringify(event.payload, null, 2));
// Optional: replace payload
// return { ...event.payload, temperature: 0 };
});これは主に、プロバイダーのシリアル化とキャッシュの動作をデバッグするのに役立ちます。
after_provider_response
HTTP 応答を受信した後、そのストリーム本体が消費される前に発生します。ハンドラーは拡張機能のロード順序で実行されます。
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"]);
}
});ヘッダーが利用できるかどうかは、プロバイダーとトランスポートによって異なります。 Providers 抽象的な HTTP 応答はヘッダーを公開しない可能性があります。
モデルイベント
model_select
/model コマンド、モデルのサイクリング (Ctrl+P)、またはセッションの復元によってモデルが変更されたときに発生します。
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");
});これを使用して、UI 要素 (ステータス バー、フッター) を更新したり、アクティブなモデルが変更されたときにモデル固有の初期化を実行したりできます。
thinking_level_select
思考レベルが変化すると発火します。これは通知のみです。ハンドラーの戻り値は無視されます。
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}`);
});pi.setThinkingLevel()、モデルの変更、または組み込みの思考レベル コントロールによってアクティブな思考レベルが変更されたときに、これを使用して拡張機能 UI を更新します。
ツールイベント
tool_call
tool_execution_start の後、ツールが実行される前に発生します。 ブロックすることができます。 isToolCallEventType を使用して、入力された入力を絞り込んで取得します。
tool_call が実行される前に、pi は、以前に発行されたエージェント イベントが AgentSession を介して排出し終わるのを待ちます。これは、現在のアシスタント ツール呼び出しメッセージを通じて ctx.sessionManager が最新であることを意味します。
デフォルトの並列ツール実行モードでは、同じアシスタント メッセージからの兄弟ツール呼び出しが順番にプリフライトされ、同時に実行されます。 tool_call では、ctx.sessionManager の同じアシスタント メッセージからの兄弟ツールの結果が表示されるとは限りません。
event.input は変更可能です。実行前にツール引数にパッチを適用するために、適切な場所で変更します。
動作保証:
event.inputへの変更は実際のツールの実行に影響します- 後の
tool_callハンドラーは、以前のハンドラーによって行われた突然変異を認識します - 突然変異後に再検証は実行されません
tool_callからの戻り値は、{ block: true, reason?: string, terminate?: boolean }を介してブロックを制御しますterminateはブロックされた通話にのみ適用されます。エージェントは、バッチ内のすべての最終結果が終了するときにのみ早期に停止します。
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}`);
}
});カスタムツール入力の型付け
カスタム ツールは入力タイプをエクスポートする必要があります。
// my-extension.ts
export type MyToolInput = Static<typeof myToolSchema>;明示的な型パラメータでは isToolCallEventType を使用します。
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
}
});tool_result
ツールの実行終了後、tool_execution_end に加えて最終的なツール結果メッセージ イベントが発行される前に発生します。 結果を変更できます。
並列ツール モードでは、tool_result と tool_execution_end がツールの完了順序でインターリーブされる可能性がありますが、最終の toolResult メッセージ イベントは依然としてアシスタント ソースの順序で後で発行されます。
tool_result ハンドラーはミドルウェアのように連鎖します。
- ハンドラーは拡張機能のロード順序で実行されます
- 各ハンドラーは、以前のハンドラー変更後の最新の結果を参照します。
- ハンドラーは部分的なパッチ (
content、details、isError、またはusage) を返すことができます。省略されたフィールドは現在の値を保持します
ハンドラー内でネストされた非同期作業には ctx.signal を使用します。これにより、Esc でモデル呼び出し、fetch()、および拡張機能によって開始されたその他の中止を認識する操作をキャンセルできるようになります。
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 };
});ユーザー Bash イベント
user_bash
ユーザーが ! または !! コマンドを実行すると発生します。 迎撃可能
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 } };
});入力イベント
input
拡張コマンドがチェックされた後、スキルとテンプレートの展開前に、ユーザー入力が受信されたときに発生します。イベントは生の入力テキストを参照するため、/skill:foo と /template はまだ展開されていません。
処理順序:
- 拡張コマンド (
/cmd) が最初にチェックされます - 見つかった場合、ハンドラーが実行され、入力イベントはスキップされます inputイベントの発火 - 傍受、変換、または処理が可能- 処理されない場合: スキルコマンド (
/skill:name) はスキルコンテンツに展開されます - 処理されない場合: prompt templates (
/template) はテンプレートのコンテンツに展開されます - エージェントの処理が開始されます (
before_agent_startなど)
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
});結果:
continue- 変更せずに通過します (ハンドラーが何も返さない場合のデフォルト)transform- テキスト/画像を変更し、拡張を続けますhandled- エージェントを完全にスキップします (これを返した最初のハンドラーが勝ちます)
ハンドラー間でチェーンを変換します。 streamingBehavior 対応ルーティングについては、input-transform.ts と input-transform-streaming.ts を参照してください。
拡張コンテキスト
すべてのハンドラーは ctx: ExtensionContext を受け取ります。
ctx.ui
ユーザー対話のための UI メソッド。詳細については、Custom UI を参照してください。
ctx.mode
現在の実行モード: "tui"、"rpc"、"json"、または "print"。 ctx.mode === "tui" を使用して、custom()、コンポーネント ファクトリ、ターミナル入力、ダイレクト TUI レンダリングなどのターミナルのみの機能を保護します。
ctx.hasUI
TUI および RPC モードの true。 false 印刷モード (-p) および JSON モード。これを使用して、TUI と TUI 、input、editor の両方で機能するダイアログ メソッド (select、confirm、input、editor) およびファイア アンド フォーゲット メソッド (notify、setStatus、setWidget、setTitle、setEditorText) を保護します。 RPC モード。 RPC モードでは、一部の TUI 固有のメソッドは操作なし、またはデフォルトを返します (rpc.md を参照)。
ctx.cwd
現在の作業ディレクトリ。
プロジェクトローカル構成パスを構築するときは、ハードコーディング .pi の代わりに CONFIG_DIR_NAME を使用してください。ブランド変更されたディストリビューションでは、別の構成ディレクトリ名を使用できます。
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()
現在のセッション コンテキストに対してプロジェクト ローカルの信頼がアクティブかどうかを返します。これには、グローバル トラスト ストアに保存された決定だけでなく、一時的な信頼の決定と CLI の信頼のオーバーライドも含まれます。
これは、信頼できるプロジェクトに対してのみ適用されるプロジェクト ローカル拡張機能の構成を読み取る前に使用してください。
ctx.sessionManager
セッション状態への読み取り専用アクセス。完全な SessionManager API とエントリ タイプについては、Session Format を参照してください。
tool_call の場合、この状態はハンドラーが実行される前に現在のアシスタント メッセージを通じて同期されます。並列ツール実行モードでは、同じアシスタント メッセージからの兄弟ツールの結果が含まれることはまだ保証されていません。
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
モデル、プロバイダー、および解決された認証へのアクセス。 ctx.modelRegistry.getProvider(id) は有効な pi-ai プロバイダーを返しますが、getProviderAuth(id) は、ロードされたモデルを必要とせずに、現在の API key、ヘッダー、ベース URL、およびプロバイダー スコープの環境を解決します。 ctx.model はアクティブなモデル、ctx.thinkingLevel は現在の有効な思考レベルです。
ctx.scopedModels は、現在のセッションをスコープとするモデルの読み取り専用リストです。/scoped-models コマンドで表示されるのと同じセットです。これは、セッション開始時に --models CLI フラグと enabledModels 設定 (provider/modelId のミニマッチまたはベア modelId で利用可能なカタログと照合) から解決されます。スコープが設定されていない場合は空です。これは、利用可能なすべてのモデルが使用できることを意味します。各エントリは { model, thinkingLevel? } で、thinkingLevel はパターンによって固定された場合にのみ設定されます (例: anthropic/*:high)。これを使用して、ctx.modelRegistry.getAvailable() を介してカタログ全体を列挙する代わりに、組み込みのものをミラーリングするモデル ピッカーを設定します。
ctx.signal
現在のエージェントの中止信号、またはアクティブなエージェントのターンがない場合は undefined。
これは、拡張ハンドラーによって開始される中止を認識するネストされた作業に使用します。次に例を示します。
fetch(..., { signal: ctx.signal })signalを受け入れるモデル呼び出しAbortSignalを受け入れるファイルまたはプロセス ヘルパー
ctx.signal は通常、tool_call、tool_result、message_update、turn_end などのアクティブなターン イベント中に定義されます。
通常、セッション イベント、拡張コマンド、pi がアイドル中に起動されるショートカットなどのアイドルまたは非ターン コンテキストでは、これは undefined です。
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()
制御フローヘルパー。 Pi がエージェントの実行、自動再試行、自動圧縮の再試行、またはキューに入れられた継続を処理している間、ctx.isIdle() は false です。
ctx.shutdown()
pi の正常なシャットダウンを要求します。
- 対話型モード: エージェントがアイドル状態になるまで延期されます (キューに入れられたステアリングおよびフォローアップ メッセージをすべて処理した後)。
- RPC モード: 次のアイドル状態まで延期されます (現在のコマンド応答が完了した後、次のコマンドを待機しているとき)。
- 印刷モード: 何もしません。すべてのプロンプトが処理されると、プロセスは自動的に終了します。
終了する前に、すべての拡張機能に session_shutdown イベントを発行します。すべてのコンテキスト (イベント ハンドラー、ツール、コマンド、ショートカット) で使用できます。
pi.on("tool_call", (event, ctx) => {
if (isFatal(event.input)) {
ctx.shutdown();
}
});ctx.getContextUsage()
アクティブなモデルの現在のコンテキストの使用状況を返します。利用可能な場合は最後のアシスタントの使用状況を使用し、後続メッセージのトークンを推定します。
const usage = ctx.getContextUsage();
if (usage && usage.tokens > 100_000) {
// ...
}ctx.compact()
完了を待たずに圧縮をトリガーします。フォローアップアクションにはonCompleteとonErrorを使用します。
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()
Pi の現在のシステム プロンプト文字列を返します。
before_agent_startの間、これは現在のターンでこれまでに行われた連鎖的なシステムプロンプトの変更を反映します。- 後の
contextメッセージの変異は含まれません。 before_provider_requestペイロードの書き換えは含まれません。- 後から読み込まれた拡張機能が拡張機能の後に実行された場合でも、最終的に送信される内容が変更される可能性があります。
pi.on("before_agent_start", (event, ctx) => {
const prompt = ctx.getSystemPrompt();
console.log(`System prompt length: ${prompt.length}`);
});拡張コマンドコンテキスト
コマンド ハンドラーは ExtensionCommandContext を受け取り、セッション制御メソッドで ExtensionContext を拡張します。これらはイベント ハンドラーから呼び出された場合にデッドロックの可能性があるため、コマンドでのみ使用できます。
ctx.getSystemPromptOptions()
Pi がシステム プロンプトを構築するために現在使用している基本入力を返します。
const options = ctx.getSystemPromptOptions();
const contextPaths = options.contextFiles?.map((file) => file.path) ?? [];これは、before_agent_start event.systemPromptOptions と同じ形状と変更可能性を持っています: カスタム プロンプト、アクティブなツール、ツール スニペット、プロンプト ガイドライン、追加されたシステム プロンプト テキスト、CWD、ロードされた context files、およびロードされたスキル。コンテキスト ファイルの完全な内容が含まれる場合があるため、機密性の高い拡張機能ローカル データとして扱い、コマンド リスト、ログ、またはオートコンプリート メタデータを通じて公開することは避けてください。
これは、現在の基本プロンプト入力を報告します。これには、ターンごとの before_agent_start 連鎖したシステム プロンプトの変更、その後の context イベント メッセージの変更、または before_provider_request ペイロードの書き換えは含まれません。
ctx.waitForIdle()
自動再試行、自動圧縮再試行、キューに入れられた継続など、エージェントが完全に解決するまで待ちます。
pi.registerCommand("my-cmd", {
handler: async (args, ctx) => {
await ctx.waitForIdle();
// Agent is now idle, safe to modify session
},
});ctx.newSession(options?)
新しいセッションを作成します。
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
}オプション:
parentSession: 新しいセッションヘッダーに記録する親セッションファイルsetup:withSessionが実行される前に、新しいセッションのSessionManagerを変更します。withSession: 新しい交換セッションのコンテキストに対して切り替え後の作業を実行します。キャプチャされた古いpi/ コマンドctxは使用しないでください。 Session replacement lifecycle and footgunsを参照してください。
ctx.fork(entryId, options?)
特定のエントリからフォークして、新しいセッション ファイルを作成します。
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
}オプション:
position:"before"(デフォルト) 選択したユーザーメッセージの前にフォークし、そのプロンプトをエディターに復元します。position:"at"は、エディターのテキストを復元せずに、選択したエントリを介してアクティブなパスを複製します。withSession: 新しい交換セッションのコンテキストに対して切り替え後の作業を実行します。キャプチャされた古いpi/ コマンドctxは使用しないでください。 Session replacement lifecycle and footgunsを参照してください。
ctx.navigateTree(targetId, options?)
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",
});オプション:
summarize: 放棄されたブランチの概要を生成するかどうかcustomInstructions: サマライザーのカスタム命令replaceInstructions: true の場合、customInstructionsは追加されるのではなく、デフォルトのプロンプトを置き換えます。label: ブランチの要約エントリ (要約していない場合はターゲット エントリ) に付けるラベル
ctx.switchSession(sessionPath, options?)
別のセッション ファイルに切り替えます。
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
}オプション:
withSession: 新しい交換セッションのコンテキストに対して切り替え後の作業を実行します。キャプチャされた古いpi/ コマンドctxは使用しないでください。 Session replacement lifecycle and footgunsを参照してください。
使用可能なセッションを検出するには、静的な SessionManager.list() または 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");
},
});
}
},
});セッション置き換えのライフサイクルと注意点
withSession は新しい ReplacedSessionContext を受け取り、置換セッションにバインドされた非同期ヘルパー sendMessage() および sendUserMessage() で ExtensionCommandContext を拡張します。
ライフサイクルと落とし穴:
withSessionは、古いセッションがsession_shutdownを発行し、古いランタイムが破棄され、置換セッションがリバウンドされ、新しい拡張機能インスタンスがすでにsession_startを受信した後にのみ実行されます。- コールバックは、新しい拡張機能インスタンス内ではなく、元のクロージャ内で引き続き実行されます。つまり、古い拡張機能インスタンスは、
withSessionが開始される前にすでにシャットダウン クリーンアップを実行している可能性があります。 - キャプチャされた古い
pi/ 古いコマンドctxセッションバインド オブジェクトは、置換後は古くなり、使用されるとスローされます。セッションにバインドされた作業には、withSessionに渡されたctxのみを使用してください。 - 以前に抽出された生のオブジェクトは引き続きユーザーの責任です。たとえば、置換前に
const sm = ctx.sessionManagerをキャプチャした場合、smは古いSessionManagerオブジェクトのままです。交換後の再使用はしないでください。 withSessionのコードは、session_shutdownハンドラーによって無効化された状態はすでになくなっていると想定する必要があります。文字列、ID、シリアル化された構成など、シャットダウンしても問題なく残るプレーン データのみをキャプチャします。
安全なパターン:
pi.registerCommand("handoff", {
handler: async (_args, ctx) => {
const kickoff = "Continue from the replacement session";
await ctx.newSession({
withSession: async (ctx) => {
await ctx.sendUserMessage(kickoff);
},
});
},
});安全でないパターン:
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()
/reload と同じリロード フローを実行します。
pi.registerCommand("reload-runtime", {
description: "Reload extensions, skills, prompts, themes, and context files",
handler: async (_args, ctx) => {
await ctx.reload();
return;
},
});重要な行動:
await ctx.reload()は、現在の拡張機能ランタイムに対してsession_shutdownを生成します- 次に、リソースをリロードし、
reason: "reload"でsession_startを、理由"reload"でresources_discoverを発行します。 - 現在実行中のコマンド ハンドラーは引き続き古い呼び出しフレームを継続します。
await ctx.reload()以降のコードはリロード前のバージョンから引き続き実行されますawait ctx.reload()以降のコードは、古いメモリ内拡張機能の状態がまだ有効であると想定してはなりません- ハンドラーが戻った後、以降のコマンド/イベント/ツール呼び出しでは新しい拡張バージョンが使用されます。
予測可能な動作を実現するには、リロードをそのハンドラーの端末として扱います (await ctx.reload(); return;)。
ツールは ExtensionContext で実行されるため、ctx.reload() を直接呼び出すことはできません。コマンドをリロード エントリポイントとして使用し、そのコマンドをフォローアップ ユーザー メッセージとしてキューに入れるツールを公開します。
LLM がリロードをトリガーするために呼び出すことができるツールの例:
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." }],
};
},
});
}ExtensionAPI メソッド
pi.on(event, handler)
イベントを購読します。イベントの種類と戻り値については、Events を参照してください。
pi.registerTool(definition)
LLM によって呼び出し可能なカスタム ツールを登録します。詳細については、Custom Tools を参照してください。
pi.registerTool() は、拡張機能のロード中と起動後の両方で動作します。 session_start、コマンド ハンドラー、またはその他のイベント ハンドラー内で呼び出すことができます。新しいツールは同じセッション内ですぐに更新されるため、pi.getAllTools() に表示され、/reload を使わずに LLM によって呼び出すことができます。
実行時にツール (動的に追加されたツールを含む) を有効または無効にするには、pi.setActiveTools() を使用します。
Available tools の 1 行エントリにカスタム ツールを選択するには promptSnippet を使用し、ツールがアクティブなときにデフォルトの Guidelines セクションにツール固有の箇条書きを追加するには promptGuidelines を使用します。
重要: promptGuidelines の箇条書きは、ツール名のプレフィックスなしで Guidelines セクションにフラットに追加されます。各ガイドラインでは、参照するツールに名前を付ける必要があります。LLM は「これ」がどのツールを意味するかを判断できないため、「次の場合にこのツールを使用する」は避けてください。代わりに「次の場合に my_tool を使用する」と書きます。
完全な例については、dynamic-tools.ts を参照してください。
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?)
カスタム メッセージをセッションに挿入します。カスタム メッセージは LLM コンテキストに参加します。 LLM に送信すべきでない永続的な TUI のみのコンテンツの場合は、pi.appendEntry() を pi.registerEntryRenderer() とともに使用します。
pi.sendMessage({
customType: "my-extension",
content: "Message text",
display: true,
details: { ... },
}, {
triggerTurn: true,
deliverAs: "steer",
});オプション:
deliverAs- 配信モード:"steer"(デフォルト) - ストリーミング中にメッセージをキューに入れます。現在のアシスタント ターンがツール呼び出しの実行を終了した後、次の LLM 呼び出しの前に配信されます。"followUp"- エージェントが終了するのを待ちます。エージェントがツールを呼び出さなくなった場合にのみ配信されます。"nextTurn"- 次のユーザー プロンプトを待機中です。何も中断したりトリガーしたりしません。
triggerTurn: true- エージェントがアイドル状態の場合、直ちに LLM 応答をトリガーします。"steer"および"followUp"モードにのみ適用されます ("nextTurn"では無視されます)。
pi.sendUserMessage(content, options?)
ユーザーメッセージをエージェントに送信します。カスタム メッセージを送信する sendMessage() とは異なり、これはユーザーが入力したかのように表示される実際のユーザー メッセージを送信します。常にターンを誘発します。
// 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" });
// Opt in to extension command dispatch and skill/prompt template expansion
pi.sendUserMessage("/review src/index.ts", { expandPromptTemplates: true });オプション:
deliverAs- エージェントがストリーミングしている場合は必須:"steer"- 現在のアシスタント ターンがツール呼び出しの実行を終了した後、配信するメッセージをキューに入れます。"followUp"- エージェントがすべてのツールを完了するまで待機します
expandPromptTemplates- 拡張機能のコマンドをディスパッチし、Skill コマンドとプロンプト テンプレートを展開します。既定値はfalseです。
ストリーミングしていない場合、メッセージはすぐに送信され、新しいターンがトリガーされます。 deliverAs を使用せずにストリーミングすると、エラーがスローされます。
完全な例については、send-user-message.ts を参照してください。
pi.appendEntry(customType, data?)
拡張データを永続化します。カスタム エントリは LLM コンテキストに参加しません。インタラクティブ モードでは、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(name)
セッションの表示名を設定します (最初のメッセージの代わりにセッション セレクターに表示されます)。
pi.setSessionName("Refactor auth module");pi.getSessionName()
現在のセッション名が設定されている場合は、それを取得します。
const name = pi.getSessionName();
if (name) {
console.log(`Session: ${name}`);
}pi.setLabel(entryId, label)
エントリのラベルを設定またはクリアします。ラベルは、ブックマークとナビゲーション用のユーザー定義のマーカーです (/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);ラベルはセッション内に保持され、再起動しても存続します。これらを使用して、会話ツリー内の重要なポイント (ターン、チェックポイント) をマークします。
pi.registerCommand(name, options)
コマンドを登録します。
複数の拡張機能が同じコマンド名を登録する場合、pi はそれらすべてを保持し、ロード順に数値呼び出しサフィックス (/review:1 や /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");
}
});オプション: /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()
現在のセッションで prompt 経由で呼び出し可能な slash commands を取得します。拡張コマンド、prompt templates、スキルコマンドが含まれます。
リストは、RPC get_commands の順序と一致します。最初に拡張機能、次にテンプレート、次にスキルです。
const commands = pi.getCommands();
const bySource = commands.filter((command) => command.source === "extension");
const userScoped = commands.filter((command) => command.sourceInfo.scope === "user");各エントリは次のような形状になっています。
{
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;
};
}sourceInfo を正規の出所フィールドとして使用します。コマンド名やアドホック パス解析から所有権を推測しないでください。
組み込みの対話型コマンド (/model や /settings など) はここには含まれません。インタラクティブでのみ処理されます
モードであり、prompt 経由で送信された場合は実行されません。
pi.registerMessageRenderer(customType, renderer)
カスタム メッセージ用のカスタム TUI レンダラーを customType に登録します。カスタム メッセージは pi.sendMessage() で作成され、LLM コンテキストに参加します。 Custom UIを参照してください。
pi.registerMarkdownTransformer(transformer)
通常のユーザーテキスト、アシスタントテキスト、思考ブロック内の Markdown に対する Transformer を登録します。Transformer は拡張機能のロード順に実行され、各 Transformer は前の Transformer が返した Markdown を受け取ります。チェーンが完了すると、Pi は組み込み renderer で変換後のコンテンツをレンダリングします。
Transformer は Markdown 文字列と次のコンテキストを受け取ります。
messageType—"user"、"assistant"、または"assistant-thinking"isStreaming—trueアシスタントの部分的な更新。falseユーザー、完了したアシスタント、および復元されたメッセージ用availableWidth— 変換された Markdown コンテンツに使用できる正確な終端列
変換された Markdown を返します。
pi.registerMarkdownTransformer((markdown, { messageType, isStreaming }) => {
if (isStreaming || messageType === "assistant-thinking") return markdown;
return markdown.replaceAll("-->", "→");
});Transformer が例外を投げた場合、Pi はそれまでに生成された Markdown を保持し、次の Transformer に進みます。この Hook は表示専用です。元のメッセージはセッションおよびモデルのコンテキスト内では変更されません。新しいユーザーメッセージ、アシスタントの streaming 更新、復元されたセッションメッセージ、端末幅の変更で実行されるため、Transformer は同期的かつ低コストに保つ必要があります。
pi.registerEntryRenderer(customType, renderer)
カスタム エントリ用のカスタム TUI レンダラーを customType に登録します。カスタム エントリは pi.appendEntry() で作成され、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(shortcut, options)
キーボードショートカットを登録します。ショートカットの形式と組み込みのキーバインドについては、keybindings.md を参照してください。
pi.registerShortcut("ctrl+shift+p", {
description: "Toggle plan mode",
handler: async (ctx) => {
ctx.ui.notify("Toggled!");
},
});pi.registerFlag(name, options)
CLIフラグを登録します。
pi.registerFlag("plan", {
description: "Start in plan mode",
type: "boolean",
default: false,
});
// Check value
if (pi.getFlag("plan")) {
// Plan mode enabled
}pi.exec(command, args, options?)
シェルコマンドを実行します。
const result = await pi.exec("git", ["status"], { signal, timeout: 5000 });
// result.stdout, result.stderr, result.code, result.killedpi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names)
アクティブなツールを管理します。これは、組み込みツールと動的に登録されたツールの両方で機能します。 pi.getActiveTools() はアクティブなツール名を string[] として返します。 pi.getAllTools() は、設定されているすべてのツールのメタデータを返します。
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() は、name、description、parameters、promptGuidelines、sourceInfo を返します。
典型的な sourceInfo.source 値:
builtin組み込みツールの場合sdkcreateAgentSession({ customTools })経由で渡されたツールの場合- 拡張機能によって登録されたツールの拡張機能ソース メタデータ
pi.setModel(model)
現在のモデルを設定します。モデルに使用可能な API key がない場合は、false を返します。カスタム モデルの構成については、models.md を参照してください。
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(level)
思考レベルを取得または設定します。レベルはモデルの能力に固定されます (非推論モデルは常に「オフ」を使用します)。変更すると thinking_level_select が発生します。
const current = pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
pi.setThinkingLevel("high");pi.events
拡張機能間の通信用の共有イベント バス:
pi.events.on("my:event", (data) => { ... });
pi.events.emit("my:event", { ... });pi.registerProvider(name, config)
モデルプロバイダーを動的に登録またはオーバーライドします。プロキシ、カスタム エンドポイント、またはチーム全体のモデル構成に役立ちます。
拡張ファクトリー関数中に行われた呼び出しはキューに入れられ、ランナーが初期化されると適用されます。それ以降に行われた呼び出し (たとえば、ユーザー セットアップ フローに続くコマンド ハンドラーからの呼び出し) は、/reload を必要とせずにすぐに有効になります。
動的プロバイダーは refreshModels を実装できます。 Pi は、モデルの更新中にこれを呼び出し、返されたリストをプロバイダーを通じて同期的に公開し、正規の資格情報/保存されたカタログ/ネットワーク/信号コンテキストを渡します。拡張機能は、世代チェックされた context.publish({ persist: entry }) を通じてカタログ メタデータを永続化するかどうかを決定します。 llama.cpp などのライブサーバーはモデルを永続化せずに返すことができます。
context.signal は常に具体的な信号であり、プロバイダーのコールバックはそれをブロッキング I/O に渡す必要があります。パブリック ModelRuntime.refresh() および ModelRegistry.refresh() 呼び出しはオプションのシグナルを受け入れ、省略された場合は無制限になります。延長と申請は独自の期限を選択します。キャンセルにより、プロバイダーが信号を無視した場合でも呼び出し元は待機しなくなりますが、基礎となる作業を停止するには協力が必要です。
ネイティブプロバイダ認証、フィルタリング、リフレッシュ、またはストリーム動作を必要とする Extensions は、@earendil-works/pi-ai から完全な Provider を登録できます。プロバイダーが構成ベースとなり、models.json オーバーライドは引き続きその上に適用されます。
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;
}
}
});オブジェクト フォームは、ネイティブ auth、getModels、refreshModels、filterModels、stream、streamSimple の動作を含む、完全な pi-ai Provider を受け入れます。
従来の構成オプション:
name- UI でのプロバイダーの表示名 (/loginなど)。baseUrl- API エンドポイント URL。モデルを定義するときに必要です。apiKey- API key リテラル、環境変数展開($ENV_VARまたは${ENV_VAR})、または先頭の!command。モデルを定義する場合は必須です(oauthが提供されている場合を除く)。$は$をエスケープし、$!はコマンド実行をトリガーせずにリテラルの!をエスケープします。api- API タイプ:"anthropic-messages"、"openai-completions"、"openai-responses"など。headers- リクエストに含めるカスタムヘッダー。authHeader- true の場合、Authorization: Bearerヘッダーが自動的に追加されます。models- モデル定義の配列。指定すると、このプロバイダーの既存のモデルがすべて置き換えられます。モデル定義では、baseUrlを設定して、そのモデルのプロバイダー エンドポイントをオーバーライドできます。refreshModels- 非同期動的検出コールバック。返されたモデルは、拡張機能が提供するモデルを置き換えます。context.storedには、永続化されたプロバイダーのスナップショットが含まれます。更新されたカタログ データが保持される必要がある場合にのみ、世代チェック済みcontext.publish({ persist: entry })を使用してください。persist: nullを使用してそのスナップショットを削除します。oauth-/loginサポートのための OAuth プロバイダー構成。プロバイダーを指定すると、ログイン メニューに表示されます。streamSimple- 非標準 API のカスタム ストリーミング実装。
高度なトピックについては、custom-provider.md を参照してください: カスタム ストリーミング API、OAuth の詳細、モデル定義のリファレンス。
pi.unregisterProvider(name)
以前に登録したプロバイダーとそのモデルを削除します。プロバイダーによってオーバーライドされた組み込みモデルが復元されます。プロバイダーが登録されていない場合は効果がありません。
registerProvider と同様、これは初期ロードフェーズの後に呼び出すとすぐに有効になるため、/reload は必要ありません。
pi.registerCommand("my-setup-teardown", {
description: "Remove the custom proxy provider",
handler: async (_args, _ctx) => {
pi.unregisterProvider("my-proxy");
},
});状態管理
適切に分岐をサポートするには、状態を持つ拡張機能はツール結果の details に状態を保存する必要があります。
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
};
},
});
}カスタムツール
LLM が pi.registerTool() 経由で呼び出すことができるツールを登録します。ツールはシステム プロンプトに表示され、カスタム レンダリングを行うことができます。
デフォルトのシステム プロンプトの Available tools セクションで短い 1 行エントリを入力するには、promptSnippet を使用します。省略した場合、カスタム ツールはそのセクションから除外されます。
promptGuidelines を使用して、ツール固有の箇条書きをデフォルトのシステム プロンプト Guidelines セクションに追加します。これらの箇条書きは、ツールがアクティブな間 (たとえば、pi.setActiveTools([...]) の後) にのみ含まれます。
重要: promptGuidelines の箇条書きは、ツール名のプレフィックスやグループ化なしで、Guidelines セクションにフラットに追加されます。各ガイドラインでは、参照するツールに名前を付ける必要があります。LLM は「これ」がどのツールを意味するかを判断できないため、「次の場合にこのツールを使用する」は避けてください。代わりに「次の場合に my_tool を使用する」と書きます。
注: 一部のモデルは愚かで、ツール パスの引数に @ プレフィックスが含まれています。組み込みツールは、パスを解決する前に先頭の @ を削除します。カスタム ツールがパスを受け入れる場合は、先頭の @ も正規化してください。
カスタム ツールがファイルを変更する場合は、withFileMutationQueue() を使用して、組み込みの edit および write と同じファイルごとのキューに参加させます。ツール呼び出しはデフォルトで並行して実行されるため、これは重要です。キューがなければ、2 つのツールが同じ古いファイルの内容を読み取り、異なる更新を計算し、最後に書き込まれた方が他方を上書きする可能性があります。
失敗例: カスタム ツールは foo.ts を編集しますが、同じアシスタント ターンで組み込みの edit も foo.ts を変更します。ツールがキューに参加していない場合、両方が元の foo.ts を読み取り、別々の変更を適用することができ、それらの変更の 1 つが失われます。
生のユーザー引数ではなく、実際のターゲット ファイル パスを withFileMutationQueue() に渡します。まず、ctx.cwd またはツールの作業ディレクトリを基準とした絶対パスに解決します。既存のファイルの場合、ヘルパーは realpath() を通じて正規化するため、同じファイルのシンボリックリンク エイリアスは 1 つのキューを共有します。新しいファイルの場合は、realpath() するものがまだないため、解決された絶対パスに戻ります。
変更ウィンドウ全体をそのターゲット パス上でキューに入れます。これには、最終的な書き込みだけでなく、読み取り、変更、書き込みロジックも含まれます。
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: {},
};
});
}ツール定義
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) { ... },
});使用量アカウンティング: ツールがネストされた LLM 呼び出しを行う場合、それらを組み合わせた Usage を usage として返します。 Pi はツールの結果にそれを保持し、フッター、/session、および RPC セッション合計に含めます。 tool_result ハンドラーは、この値を検査または置き換えることができます。
エラーの通知: ツールの実行を失敗としてマークするには (結果に isError: true を設定し、LLM に報告します)、execute からエラーをスローします。戻りオブジェクトにどのようなプロパティを含めるかに関係なく、値を返すとエラー フラグが設定されることはありません。
早期終了: execute() から terminate: true を返し、現在のツール バッチの後に自動フォローアップ LLM 呼び出しをスキップする必要があることを示唆します。これは、すべての最終的なツールの結果としてそのバッチが終了する場合にのみ有効になります。エージェントが最後の構造化出力ツール呼び出しで終了する最小限の例については、examples/extensions/structured-output.ts を参照してください。
// 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: {} };
}重要: 文字列列挙には @earendil-works/pi-ai から StringEnum を使用します。 Type.Union/Type.Literal は Google の API では機能しません。
引数の準備: prepareArguments(args) はオプションです。定義されている場合、スキーマ検証の前、および execute() の前に実行されます。これを使用して、保存されているツール呼び出し引数が現在のスキーマと一致しなくなった古いセッションを pi が再開するときに、受け入れられた古い入力形状を模倣します。 parameters に対して検証するオブジェクトを返します。パブリックスキーマを厳密に保ちます。再開された古いセッションを動作し続けるためだけに、非推奨の互換性フィールドを parameters に追加しないでください。
例: 古いセッションには、トップレベルの oldText および newText を含む edit ツール呼び出しが含まれている可能性がありますが、現在のスキーマは 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: {},
};
},
});組み込みツールのオーバーライド
Extensions は、同じ名前のツールを登録することで、組み込みツール (read、bash、edit、write、grep、find、ls) をオーバーライドできます。対話型モードでは、これが発生した場合に警告が表示されます。
# Extension's read tool replaces built-in read
pi -e ./tool-override.tsあるいは、--no-builtin-tools を使用して、拡張ツールを有効にしたまま、組み込みツールなしで開始します。
# No built-in tools, only extension tools
pi --no-builtin-tools -e ./my-extension.tsロギングとアクセス制御で read をオーバーライドする完全な例については、examples/extensions/tool-override.ts を参照してください。
レンダリング: 組み込みレンダラーの継承はスロットごとに解決されます。実行オーバーライドとレンダリング オーバーライドは独立しています。オーバーライドで renderCall が省略された場合は、組み込みの renderCall が使用されます。オーバーライドで renderResult が省略された場合は、組み込みの renderResult が使用されます。オーバーライドで両方を省略した場合、組み込みレンダラが自動的に使用されます (構文の強調表示、差分など)。これにより、UI を再実装することなく、ログ記録やアクセス制御用の組み込みツールをラップできます。
プロンプト メタデータ: promptSnippet と promptGuidelines は組み込みツールから継承されません。オーバーライドでこれらのプロンプト指示を維持する必要がある場合は、それらをオーバーライドで明示的に定義します。
実装は、details 型を含む結果の形状と正確に一致する必要があります。 UI とセッション ロジックは、レンダリングと状態追跡のためにこれらの形状に依存します。
組み込みツールの実装:
- read.ts -
ReadToolDetails - bash.ts -
BashToolDetails - edit.ts
- write.ts
- grep.ts -
GrepToolDetails - find.ts -
FindToolDetails - ls.ts -
LsToolDetails
リモート実行
組み込みツールは、リモート システム (SSH、コンテナなど) に委任するためのプラグ可能な操作をサポートします。
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);
},
});操作インターフェイス: ReadOperations、WriteOperations、EditOperations、BashOperations、LsOperations、GrepOperations、FindOperations
user_bash の場合、拡張機能はローカル プロセスの生成、シェル解決、プロセス ツリーの終了を再実装する代わりに、createLocalBashOperations() 経由で pi のローカル シェル バックエンドを再利用できます。
bash ツールは、実行前にコマンド、cwd、または env を調整するスポーンフックもサポートしています。
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() は、現在のセッションを PI_SESSION_ID、PI_SESSION_FILE、PI_PROVIDER、PI_MODEL、PI_REASONING_LEVEL を介してコマンドに公開します。インジェクションは spawnHook より前に行われるため、フックはこれらの値を env で受け取り、上記のように既存の環境を展開するときにそれらの値を保持します。exposeSessionEnvironment: false を設定して無効にします。
const bashTool = createBashTool(cwd, {
exposeSessionEnvironment: false,
});変数のセマンティクスについては、「Bash tool session environment」を参照してください。 --ssh フラグを使用した完全な SSH の例については、examples/extensions/ssh.ts を参照してください。
出力の切り詰め
ツールは、LLM コンテキストの負荷を避けるために、出力を切り詰める必要があります。出力が大きいと、次のような問題が発生する可能性があります。
- コンテキスト オーバーフロー エラー (プロンプトが長すぎます)
- 圧縮の失敗
- モデルのパフォーマンスの低下
組み込みの制限は 50KB (約 10,000 トークン) および 2000 行 のいずれか最初に到達した方です。エクスポートされた切り捨てユーティリティを使用します。
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 }] };
}重要なポイント:
- 始まりが重要なコンテンツ (検索結果、ファイルの読み取り) には
truncateHeadを使用します。 - 終わりが重要なコンテンツ (ログ、コマンド出力) には
truncateTailを使用します。 - 出力が切り詰められた場合と、完全なバージョンの場所を常に LLM に通知します。
- ツールの説明に切り捨て制限を文書化します。
rg (ripgrep) を適切な切り捨てでラップする完全な例については、examples/extensions/truncated-tool.ts を参照してください。
複数のツール
1 つの拡張機能で複数のツールを共有状態に登録できます。
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();
});
}カスタムレンダリング
ツールは、カスタム TUI 表示用に renderCall および renderResult を提供できます。完全なコンポーネント API については tui.md を、ツール行の構成方法については tool-execution.ts を参照してください。
デフォルトでは、ツールの出力はパディングと背景を処理する Box でラップされます。定義された renderCall または renderResult は Component を返す必要があります。スロット レンダラーが定義されていない場合、tool-execution.ts はそのスロットに対してフォールバック レンダリングを使用します。
ツールがデフォルトの Box を使用する代わりに独自のシェルをレンダリングする必要がある場合は、 renderShell: "self" を設定します。これは、フレームや背景の動作を完全に制御する必要があるツール、たとえば、ツールが安定した後も視覚的に安定した状態を維持する必要がある大きなプレビューなどに便利です。
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 と renderResult はそれぞれ、次のような context オブジェクトを受け取ります。
args- 現在のツール呼び出しの引数state-renderCallとrenderResultにわたる共有行ローカル状態lastComponent- そのスロットに対して以前に返されたコンポーネント (存在する場合)invalidate()- このツール行の再レンダリングをリクエストしますtoolCallId,cwd,executionStarted,argsComplete,isPartial,expanded,showImages,isError
クロススロット共有状態には context.state を使用します。レンダリング間で同じコンポーネントを再利用および変更する場合は、返されたコンポーネント インスタンスにスロット ローカル キャッシュを保持します。
renderCall
ツール呼び出しまたはヘッダーをレンダリングします。
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;
}renderResult
ツールの結果または出力をレンダリングします。
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);
}スロットに意図的に表示可能なコンテンツがない場合は、空の Container などの空の Component を返します。
キーバインドのヒント
keyHint() を使用して、アクティブなキーバインド設定を考慮したキーバインドのヒントを表示します。
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);
}利用可能な機能:
keyHint(keybinding, description)-"app.tools.expand"や"tui.select.confirm"などの構成されたキーバインド ID をフォーマットしますkeyText(keybinding)- キーバインド ID の未加工の設定済みキー テキストを返します。rawKeyHint(key, description)- 生のキー文字列をフォーマットします
名前空間付きキーバインド ID を使用します。
- コーディング エージェント ID は、
app.tools.expand、app.editor.external、app.session.renameなど、app.*名前空間を使用します。 - 共有 TUI ID は、
tui.select.confirm、tui.select.cancel、tui.input.tabなど、tui.*名前空間を使用します。
キーバインド ID とデフォルトの完全なリストについては、keybindings.md を参照してください。 keybindings.json は同じ名前空間 ID を使用します。
カスタムエディターと ctx.ui.custom() コンポーネントは、挿入された引数として keybindings: KeybindingsManager を受け取ります。 getKeybindings() や setKeybindings() を呼び出すのではなく、挿入されたマネージャーを直接使用する必要があります。
ベストプラクティス
Textとパディング(0, 0)を使用します。デフォルトのボックスはパディングを処理します。- 複数行のコンテンツには
\nを使用します。 - ストリーミングの進行状況をハンドル
isPartialで確認します。 - 詳細については、オンデマンドで
expandedをサポートしてください。 - デフォルトのビューをコンパクトに保ちます。
- 引数を
context.stateにコピーする代わりに、renderResultのcontext.argsを読み取ります。 context.stateは、呼び出しスロットと結果スロット間で共有する必要があるデータにのみ使用します。- 同じコンポーネント インスタンスを適切な場所で更新できる場合は、
context.lastComponentを再利用します。 renderShell: "self"は、デフォルトのボックス化されたシェルが邪魔になる場合にのみ使用してください。セルフシェル モードでは、ツールは独自のフレーム、パディング、および背景を担当します。
フォールバック
スロット レンダラーが定義されていない場合、またはスローされる場合:
renderCall: ツール名を表示します。renderResult:contentからの生のテキストを表示します
動的ツール読み込み
拡張機能は、少数の初期セットのみをアクティブにしたまま、多くのツールを登録できます。ツールは、実行中に pi.setActiveTools() を使用してさらにツールを追加できます。 Pi は純粋に追加的な変更を検出し、新しく利用可能なツール名をそのツールの結果に記録し、次のモデル要求の前に更新されたアクティブ セットを適用します。
これはどのモデルでも機能します。ネイティブ遅延読み込みをサポートするモデルは、安定したプロンプト プレフィックスを保持し、ツール結果の位置に新しい定義を読み込みます。他のモデルでは、以下で説明するフォールバックが使用されます。
ライフサイクルは次のとおりです。
- すべてのツールを
pi.registerTool()で登録すると、pi.getAllTools()に表示されます。 search_toolsなどのローダー ツールをアクティブのままにして、検索可能なツールを非アクティブのままにします。- ローダーの実行中に、
pi.setActiveTools([...currentTools, ...matchingTools])を呼び出します。変更は追加的である必要があります。同じ呼び出しで現在アクティブなツールを削除しないでください。 - Pi は、ローダーのツール結果にどのツールが追加されたかを記録します。
- 次のモデル応答の前に、Pi は、サポートされている場合はネイティブ遅延読み込みを使用して、サポートされていない場合は通常のアクティブ ツール リストを使用して、追加された定義を公開します。
プロバイダー固有のツール参照を返したり、ローダーを特別な検索ツールとしてマークしたりする必要はありません。アクティブなツールの変更が合図です。 pi.setActiveTools() に渡される名前はすでに登録されている必要があります。不明な名前は無視されます。
ネイティブ遅延読み込みに対応したモデル
- Anthropic
- Models: ソネット、オーパス、寓話バージョン 4.5 以降 (Haiku なし)
- ネイティブ表現: 遅延定義では
defer_loadingを使用します。ロード ポイントはtool_referenceコンテンツを使用します。
- OpenAI
- Models:
gpt-5.4以降のファミリー - ネイティブ表現: Pi は、完了したクライアント
tool_search_callおよびtool_search_outputアイテムをロード ポイントに追加します。
- Models:
検証済みのカスタム モデルまたはプロキシの場合、anthropic-messages の場合は compat.supportsToolReferences: true、openai-responses および openai-codex-responses の場合は compat.supportsToolSearch: true を使用してネイティブ処理を有効にできます。エンドポイントとモデルが対応するネイティブ プロトコルを受け入れない限り、これらは無効のままにしておきます。
フォールバック動作
他のすべてのモデルとプロバイダーでは、動的アクティベーションは引き続き機能します。 Pi は、通常、次のリクエストで現在アクティブなツールの完全なリストを送信します。モデルは新しくアクティブ化されたツールを呼び出すことができますが、その定義を追加すると、プロバイダーのキャッシュされたプロンプト プレフィックスが無効になる可能性があります。
Pi は、ツールのグループを別のグループに置き換える場合など、アクティブ セットが純粋に追加的でない場合にも、この安全なフォールバックを使用します。したがって、ツールの削除は機能しますが、遅延ロードは使用されません。
キャッシュの動作を最適にするには、セッション全体でローダー ツールをアクティブにし、アクティブ セットを置き換えるのではなくツールを追加します。また、promptSnippet または promptGuidelines でツールをアクティブにすると、システム プロンプトが再構築されることにも注意してください。プロバイダーが遅延スキーマをサポートしている場合でも、システム プロンプトの変更によりプレフィックスが無効になる可能性があります。遅延ロードされるツールは通常、ツール description に依存し、アクティブ専用プロンプト メタデータを省略する必要があります。
検索ツールの例
次の拡張機能は 2 つの検索可能なツールを登録し、最初のアクティブ セットからそれらを削除し、ローダーとして search_tools のみを保持します。この例では単純なキーワード マッチングを使用していますが、検索の実装では BM25、埋め込み、リモート カタログ、またはプロジェクト固有のルーティングを使用することもできます。
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"])]);
});
}search_tools が一致を追加すると、モデルは直後のリクエストでその定義を受け取ります。ネイティブ対応モデルでは、最初のツール スキーマ プレフィックスを変更せずに、定義は検索結果の後にアンカーされます。他のモデルでは、同じ次のリクエストの通常のツール リストに表示されます。
カスタムUI
Extensions は、ctx.ui メソッドを介してユーザーと対話し、メッセージ/ツールのレンダリング方法をカスタマイズできます。
カスタム コンポーネントについては、以下のコピー&ペースト パターンが記載されている tui.md を参照してください。
- 選択ダイアログ (SelectList)
- キャンセルを伴う非同期操作 (BorderLoader)
- 設定切り替え (SettingsList)
- ステータスインジケーター (setStatus)
- ストリーミング中の動作メッセージ、可視性、インジケーター (
setWorkingMessage、setWorkingVisible、setWorkingIndicator) - エディターの上/下のウィジェット (setWidget)
- 組み込みのスラッシュ/パス補完の上に重ねられたオートコンプリート プロバイダー (addAutocompleteProvider)
- カスタムフッター (setFooter)
ダイアログ
// 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"カウントダウン付きの時間指定ダイアログ
ダイアログは、ライブカウントダウン表示で自動的に閉じる timeout オプションをサポートしています。
// 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
}タイムアウト時の戻り値:
select()はundefinedを返しますconfirm()はfalseを返しますinput()はundefinedを返します
AbortSignal による手動終了
さらに制御するには (例: タイムアウトとユーザーのキャンセルを区別するため)、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")
}完全な例については、examples/extensions/timed-confirm.ts を参照してください。
ウィジェット、ステータス、フッター
// 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 themeカスタムの作業インジケーター フレームはそのままレンダリングされます。色が必要な場合は、ctx.ui.theme.fg(...) などを使用して、自分でフレーム文字列に色を追加します。
自動補完プロバイダー
ctx.ui.addAutocompleteProvider() を使用して、組み込みのスラッシュ コマンドとパス プロバイダーの上にカスタム オートコンプリート ロジックをスタックします。$ などのカスタム自然トリガーには triggerCharacters を設定します。
典型的なパターン:
- カーソルの前のテキストを検査する
- 拡張機能固有の構文が一致する場合に独自の提案を返す
- それ以外の場合は
current.getSuggestions(...)に委任します - カスタム挿入動作が必要な場合を除き、デリゲート
applyCompletion(...)
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;
},
}));
});gh issue list で最新のオープン GitHub 問題をプリロードし、#... を高速に完了するためにローカルでフィルタリングする完全な例については、github-issue-autocomplete.ts を参照してください。 GitHub CLI (gh) と GitHub リポジトリのチェックアウトが必要です。
カスタムコンポーネント
複雑な UI の場合は、ctx.ui.custom() を使用します。これにより、done() が呼び出されるまで、エディターがコンポーネントに一時的に置き換えられます。
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
}コールバックは以下を受け取ります。
tui- TUI インスタンス (画面サイズ、フォーカス管理用)theme- 現在のスタイリングのテーマkeybindings- アプリのキーバインドマネージャー (ショートカットの確認用)done(value)- コンポーネントを閉じて値を返す呼び出し
完全なコンポーネント API については、tui.md を参照してください。
オーバーレイ モード (実験的)
{ overlay: true } を渡すと、画面をクリアせずにコンポーネントを既存のコンテンツの上にフローティング モーダルとしてレンダリングします。
const result = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }),
{ overlay: true }
);高度な配置 (アンカー、マージン、パーセンテージ、応答性の高い可視性) の場合は、overlayOptions を渡します。 onHandle を使用して、プログラムでフォーカスまたは可視性を制御します。
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
}
}
);フォーカスされた表示オーバーレイは、一時的な非オーバーレイ カスタム UI が閉じた後に入力を再利用できます。オーバーレイが表示されている間、別のコンポーネントが入力を維持するように意図的にしたい場合は、handle.unfocus({ target }) を呼び出します。 { target: null } を渡すと、別のコンポーネントにフォーカスすることなくオーバーレイが解放されます。
完全な OverlayOptions および OverlayHandle API および overlay-qa-tests.ts の例については、tui.md を参照してください。
カスタムエディター
メインの入力エディタをカスタム実装 (vim モード、emacs モードなど) に置き換えます。
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)
);
});
}重要なポイント:
CustomEditor(ベースEditorではない) を拡張してアプリのキーバインドを取得します (エスケープして中止、Ctrl+D、モデル切り替え)- 扱っていない鍵については
super.handleInput(data)までお電話ください。 - Factory はアプリから
tui、theme、keybindingsを受け取ります setEditorComponent()の前にctx.ui.getEditorComponent()を使用して、以前に設定したカスタム エディターをラップします。undefinedを渡してデフォルトに戻します:ctx.ui.setEditorComponent(undefined)
すでにエディターを置き換えている別の拡張機能を使用して作成するには、自分のファクトリーを設定する前に、以前のファクトリーをキャプチャーします。
const previous = ctx.ui.getEditorComponent();
ctx.ui.setEditorComponent((tui, theme, keybindings) =>
new MyEditor(tui, theme, keybindings, { base: previous?.(tui, theme, keybindings) })
);モードインジケーターを使用した完全な例については、tui.md パターン 7 を参照してください。
メッセージとエントリーのレンダリング
customType でメッセージのカスタム レンダラーを登録します。 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);
});メッセージは pi.sendMessage() 経由で送信されます:
pi.sendMessage({
customType: "my-extension", // Matches registerMessageRenderer
content: "Status update",
display: true, // Show in TUI
details: { ... }, // Available in renderer
});LLM に送信すべきではない TUI のみのコンテンツの場合は、代わりにカスタム エントリをレンダリングします。
pi.registerEntryRenderer("my-card", (entry, options, theme) => {
return new Text(theme.fg("accent", JSON.stringify(entry.data)));
});
pi.appendEntry("my-card", { status: "done" });テーマカラー
すべてのレンダリング関数は theme オブジェクトを受け取ります。カスタム テーマとフルカラー パレットの作成については、themes.md を参照してください。
// 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)カスタム ツール レンダラーでの構文強調表示の場合:
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);エラー処理
- 拡張エラーがログに記録され、エージェントは続行します
tool_callエラーによりツールがブロックされる (フェールセーフ)- ツール
executeエラーはスローによって通知する必要があります。スローされたエラーが捕捉され、isError: trueで LLM に報告され、実行が続行されます。
モードの動作
| モード | ctx.mode |
ctx.hasUI |
注意事項 |
|---|---|---|---|
| 相互の作用 | "tui" |
true |
端末レンダリングを備えた完全な TUI |
RPC (--mode rpc) |
"rpc" |
true |
JSON プロトコルによるダイアログと通知。 custom() は undefined を返します。 rpc.md を参照 |
JSON (--mode json) |
"json" |
false |
stdout へのイベント ストリーム。 UI メソッドは何も操作しません |
印刷 (-p) |
"print" |
false |
Extensions 実行してもプロンプトが表示されない |
TUI 固有の機能 (custom()、コンポーネント ファクトリ、端末入力) の前に ctx.mode === "tui" を使用します。 TUI と RPC の両方のモードで機能するダイアログと通知メソッドの前に ctx.hasUI を使用します。
例 参考資料
すべての例は examples/extensions/ にあります。
| 例 | 説明 | キーAPI |
|---|---|---|
| ツール | ||
hello.ts |
最小限のツール登録 | registerTool |
question.ts |
ユーザーインタラクションを備えたツール | registerTool, ui.select |
questionnaire.ts |
マルチステップウィザードツール | registerTool, ui.custom |
todo.ts |
永続性のあるステートフル ツール | registerTool、appendEntry、renderResult、セッションイベント |
dynamic-tools.ts |
起動後およびコマンド中にツールを登録します | registerTool, session_start, registerCommand |
structured-output.ts |
terminate: true による最終的な構造化出力ツール |
registerTool、ツールの結果を終了します |
truncated-tool.ts |
出力の切り捨ての例 | registerTool, truncateHead |
tool-override.ts |
組み込み読み取りツールをオーバーライドする | registerTool (内蔵と同じ名前) |
| コマンド | ||
pirate.ts |
ターンごとにシステムプロンプトを変更する | registerCommand, before_agent_start |
summarize.ts |
会話要約コマンド | registerCommand, ui.custom |
handoff.ts |
クロスプロバイダーモデルのハンドオフ | registerCommand, ui.editor, ui.custom |
qna.ts |
カスタム UI による Q&A | registerCommand, ui.custom, setEditorText |
send-user-message.ts |
ユーザーメッセージを挿入する | registerCommand, sendUserMessage |
reload-runtime.ts |
リロード コマンドと LLM ツールのハンドオフ | registerCommand, ctx.reload(), sendUserMessage |
shutdown-command.ts |
正常なシャットダウン コマンド | registerCommand, shutdown() |
| イベントとゲート | ||
permission-gate.ts |
危険なコマンドをブロックする | on("tool_call"), ui.confirm |
project-trust.ts |
ユーザー/グローバルまたは CLI 拡張機能からのプロジェクトの信頼を決定または延期する | on("project_trust")、信頼 UI、必要な信頼結果 |
protected-paths.ts |
特定のパスへの書き込みをブロックする | on("tool_call") |
confirm-destructive.ts |
セッションの変更を確認する | on("session_before_switch"), on("session_before_fork") |
dirty-repo-guard.ts |
ダーティな git リポジトリについて警告する | on("session_before_*"), exec |
input-transform.ts |
ユーザー入力を変換する | on("input") |
input-transform-streaming.ts |
ストリーミング対応の入力変換 | on("input"), streamingBehavior |
model-status.ts |
機種変更へのReact | on("model_select"), setStatus |
provider-payload.ts |
ペイロードとプロバイダー応答ヘッダーを検査する | on("before_provider_request"), on("after_provider_response") |
system-prompt-header.ts |
システムプロンプト情報を表示する | on("agent_start"), getSystemPrompt |
claude-rules.ts |
ファイルからルールをロードする | on("session_start"), on("before_agent_start") |
prompt-customizer.ts |
systemPromptOptions を使用してコンテキスト認識ツール ガイダンスを追加します |
on("before_agent_start"), BuildSystemPromptOptions |
file-trigger.ts |
ファイルウォッチャーがメッセージをトリガーする | sendMessage |
| コンパクションとセッション | ||
custom-compaction.ts |
カスタム圧縮の概要 | on("session_before_compact") |
trigger-compact.ts |
圧縮を手動でトリガーする | compact() |
git-checkpoint.ts |
Git ターン時に隠します | on("turn_start"), on("session_before_fork"), exec |
git-merge-and-resolve.ts |
競合をフェッチ、マージ、解決する | on("agent_end"), exec, sendUserMessage |
auto-commit-on-exit.ts |
シャットダウン時にコミットする | on("session_shutdown"), exec |
| UI コンポーネント | ||
status-line.ts |
フッターステータスインジケーター | setStatus、セッションイベント |
working-indicator.ts |
ストリーミング動作インジケーターをカスタマイズする | setWorkingIndicator, registerCommand |
github-issue-autocomplete.ts |
gh issue list から最近の未解決の問題をプリロードすることで、組み込みのオートコンプリートに加えて #1234 の問題の完了を追加します |
addAutocompleteProvider, on("session_start"), exec |
custom-footer.ts |
フッターを完全に置き換える | registerCommand, setFooter |
custom-header.ts |
起動ヘッダーを置き換える | on("session_start"), setHeader |
modal-editor.ts |
Vim スタイルのモーダルエディター | setEditorComponent, CustomEditor |
rainbow-editor.ts |
カスタムエディターのスタイル設定 | setEditorComponent |
widget-placement.ts |
エディターの上/下のウィジェット | setWidget |
overlay-test.ts |
オーバーレイコンポーネント | ui.custom オーバーレイ オプションあり |
overlay-qa-tests.ts |
包括的なオーバーレイ テスト | ui.custom、すべてのオーバーレイ オプション |
notify.ts |
簡単な通知 | ui.notify |
timed-confirm.ts |
タイムアウトのあるダイアログ | ui.confirm タイムアウト/シグナルあり |
mac-system-theme.ts |
テーマの自動切り替え | setTheme, exec |
| 複雑 Extensions | ||
plan-mode/ |
フルプランモードの実装 | すべてのイベント タイプ、registerCommand、registerShortcut、registerFlag、setStatus、setWidget、sendMessage、setActiveTools |
preset.ts |
保存可能なプリセット (モデル、ツール、思考) | registerCommand, registerShortcut, registerFlag, setModel, setActiveTools, setThinkingLevel, appendEntry |
tools.ts |
ツールのオン/オフを切り替える UI | registerCommand、setActiveTools、SettingsList、セッションイベント |
| リモートとサンドボックス | ||
ssh.ts |
SSH リモート実行 | registerFlag、on("user_bash")、on("before_agent_start")、ツール操作 |
interactive-shell.ts |
永続的なシェルセッション | on("user_bash") |
sandbox/ |
サンドボックスツールの実行 | ツールの操作 |
gondolin/ |
組み込みツールと ! コマンドを Gondolin マイクロ VM にルーティングします |
ツール操作、組み込みツールオーバーライド、on("user_bash") |
subagent/ |
サブエージェントを生成する | registerTool, exec |
| ゲーム | ||
snake.ts |
ヘビゲーム | registerCommand、ui.custom、キーボードの処理 |
space-invaders.ts |
スペースインベーダーゲーム | registerCommand, ui.custom |
doom-overlay/ |
オーバーレイのドゥーム | ui.custom オーバーレイ付き |
| Providers | ||
custom-provider-anthropic/ |
Custom Anthropic proxy | registerProvider |
custom-provider-gitlab-duo/ |
GitLab Duo integration | registerProvider with OAuth |
| メッセージとコミュニケーション | ||
message-renderer.ts |
カスタムメッセージのレンダリング | registerMessageRenderer, sendMessage |
entry-renderer.ts |
TUI のみのカスタム エントリ レンダリング | registerEntryRenderer, appendEntry |
event-bus.ts |
拡張間イベント | pi.events |
| セッションメタデータ | ||
session-name.ts |
セレクターのセッションに名前を付ける | setSessionName, getSessionName |
bookmark.ts |
/tree のブックマーク エントリ | setLabel |
| その他 | ||
inline-bash.ts |
ツール呼び出しのインライン bash | on("tool_call") |
bash-spawn-hook.ts |
実行前にbash command、cwd、env を調整してください | createBashTool, spawnHook |
with-deps/ |
npm 依存関係のある拡張機能 | package.jsonのパッケージ構造 |