Chương 11Phần 3: Coding agent
Extensions, skills, templates, themes, packages
Nạp extension, bề mặt API, danh mục sự kiện, skills, prompt template, theme và package.
Trong chương này19 mục
- Vai trò
- Mô hình tư duy
- Nạp một extension: jiti, virtual module, built-in
- Nạp tài nguyên và project trust
- Bề mặt API: factory và các hàm register
- Context, UI và các lời gọi tới session
- Tool exposure, namespace và prepareLoadout
- Danh mục sự kiện và cách runner phát chúng
- Lần theo một lời gọi tool bị sửa và bị chặn
- Ba ví dụ thật: plan mode, subagent, todo
- Skills
- Prompt templates
- Themes
- Pi packages
- Keybindings và provider tự viết
- Quyết định thiết kế
- Cạm bẫy và trường hợp biên
- Đọc code ở đâu
- Tóm tắt
Vai trò#
Lõi của Pi cố ý nhỏ: bốn tool mặc định, một system prompt ngắn, không có plan mode, không có sub-agent, không có danh sách việc cần làm (todo) dựng sẵn. Bài viết gốc của Mario Zechner giải thích từng chỗ thiếu đó: danh sách todo thường làm mô hình rối hơn là giúp, plan mode của Claude Code không cho anh quan sát quá trình lập kế hoạch, còn với sub-agent thì người dùng "zero visibility", không thấy gì về việc nó làm (What I learned building an opinionated and minimal coding agent). Nhưng quy trình làm việc của mỗi người một khác. Người này muốn hỏi xác nhận trước mỗi lệnh rm -rf, người kia muốn một chế độ chỉ đọc để lập kế hoạch, một nhóm khác cần provider nội bộ với đăng nhập SSO. Đưa tất cả vào lõi sẽ làm lõi phình ra và áp một lựa chọn cho mọi người.
Câu trả lời của Pi là một hệ thống mở rộng đủ mạnh để dựng lại chính những tính năng nó bỏ qua. Extension là module TypeScript thêm hành vi có thể thực thi vào Pi: tool, lệnh, xử lý sự kiện, provider, trạng thái session, giao diện terminal (docs/extensions.md:3). Nó chạy ngay trong tiến trình Pi với cùng quyền hệ điều hành, nên đọc được prompt, lời gọi tool, tệp, credential và lịch sử session (docs/extensions.md:5). Không có hộp cát: extension là mã bạn tin như tin chính Pi.
Armin Ronacher mô tả cách dùng điển hình: không tự viết extension mà bảo agent viết, rồi nạp lại để thử ngay, vì Pi có sẵn cơ chế nạp lại nóng (hot reload) để agent viết code, nạp lại rồi tự kiểm thử. Các extension anh giới thiệu trong bài (/answer, /todos, /review, /control, /files) đều do agent viết theo đặc tả của anh. Code ở v1.4.0 chuẩn bị cho đúng việc đó. Phần mặc định của system prompt cho mô hình biết tài liệu và ví dụ của Pi nằm ở đâu và phải đọc gì khi người dùng hỏi về extension:
promptSections.docs = `Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):- Main documentation: ${getReadmePath()}- Additional docs: ${getDocsPath()}- Examples: ${getExamplesPath()} (extensions, custom tools, SDK)- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md), MCP servers (docs/mcp.md), codemode scripts and non-LLM models such as classifiers and image models (docs/codemode.md)- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)`;config.ts, nên trỏ đúng tới bản cài đặt đang chạy dù là mã nguồn, gói npm hay binary. Lệnh /reload (mục nạp extension) hoàn tất vòng viết, nạp lại, thử.Chương này gọi chung các thứ có thể thêm vào Pi là tài nguyên (resource). Có bốn loại tài nguyên được khám phá từ đĩa và một cách đóng gói chúng:
| Loại | Dạng | Thêm được gì |
|---|---|---|
| Extension | Tệp .ts/.js export một factory | Tool, lệnh /, phím tắt, cờ CLI, xử lý sự kiện, provider, MCP server, virtual model, renderer, UI |
| Skill | Thư mục chứa SKILL.md | Hướng dẫn chuyên biệt, chỉ nạp vào ngữ cảnh khi cần |
| Prompt template | Tệp Markdown | Lệnh /tên mở rộng thành một prompt có tham số |
| Theme | Tệp JSON | Bảng màu cho TUI và bản xuất HTML |
| Pi package | Thư mục, gói npm hoặc repo git | Phân phối bốn loại trên thành một đơn vị: pi install |
Bên cạnh đó là hai điểm tuỳ biến nhỏ hơn: keybindings.json và provider tự viết (một dạng extension). Ai dùng những thứ này? DefaultResourceLoader khám phá và nạp chúng mỗi khi một phiên được dựng và mỗi lần /reload; AgentSession tạo ExtensionRunner từ kết quả và nối runner vào các hook của vòng lặp agent (chương AgentSession). Ngay cả MCP, codemode, tool search và provider llama.cpp cũng là extension dựng sẵn, nạp qua đúng cơ chế này dưới tên builtin:<name>; phần ruột của chúng thuộc chương Codemode.
Mô hình tư duy#
Hãy hình dung ba tầng. Tầng dưới là các nguồn trên đĩa và trong settings. Tầng giữa giải các nguồn đó thành danh sách đường dẫn đã bật hay tắt, rồi nạp từng loại thành đối tượng trong bộ nhớ. Tầng trên là phiên đang chạy: runner phát sự kiện cho extension, sổ đăng ký tool trộn tool dựng sẵn với tool của extension, system prompt liệt kê skill.
Nguồn Giải và nạp (mỗi lần dựng phiên, mỗi lần /reload)
~/.pi/agent/extensions|skills|... ┐
.pi/extensions|skills|... (cần trust) ├─► DefaultPackageManager.resolve()
settings: packages, extensions,... │ → ResolvedResource { path, enabled, metadata }
-e, --skill, --theme ... (CLI) │
builtin:mcp, builtin:codemode ... ┘ DefaultResourceLoader.reload()
├─ extensions ─► loader.ts (jiti) ─► Extension[] + ExtensionRuntime
├─ skills ─────► loadSkills() ──────► Skill[]
├─ prompts ────► loadPromptTemplates() ► PromptTemplate[]
└─ themes ─────► loadThemeFromPath() ─► Theme[]
│
Phiên đang chạy ▼
AgentSession._buildRuntime()
ExtensionRunner(extensions, runtime) ── bindCore(): action thật thay cho stub
sổ tool: read/bash/edit/write + tool của extension (bọc bởi wrapToolDefinition)
system prompt: snippet tool, guideline, danh sách skill
│ hook cài vào Agent (pi-agent-core)
▼
transformContext ─► context beforeToolCall ─► tool_call finishTurn ─► turn_end
onPayload ───────► before_provider_request afterToolCall ─► tool_result
/reload; các hook của Agent thì cài một lần và luôn đọc runner hiện tại.Từ vựng dùng trong chương:
- factory
- Hàm mà module extension export mặc định, kiểu
(pi: ExtensionAPI) => void | Promise<void>. Pi chờ factory bất đồng bộ chạy xong rồi mới khởi động tiếp. pi(ExtensionAPI)- Đối tượng factory nhận. Các hàm
register*vàonghi vào đối tượngExtensioncủa riêng extension đó; các hàm hành động (sendMessage,appendEntry,setActiveTools...) uỷ cho runtime dùng chung. - Extension
- Kết quả nạp một module: các Map handler, tool, lệnh, cờ, phím tắt, renderer, cùng
sourceInfocho biết nó đến từ đâu. - ExtensionRuntime
- Trạng thái chung của mọi extension trong một lần nạp: giá trị cờ CLI, các đăng ký provider đang chờ, sổ MCP server, và các hàm hành động. Trước khi runner được bind, các hàm hành động là stub ném lỗi.
- ExtensionRunner
- Lớp phát sự kiện cho extension, theo thứ tự nạp, với cách gộp kết quả riêng cho từng loại sự kiện. Mỗi lần
/reloadtạo runner mới. - ctx (ExtensionContext)
- Đối tượng truyền cho mỗi handler: UI, cwd, session (chỉ đọc), mô hình, tín hiệu huỷ. Mọi getter kiểm tra ctx còn hiệu lực hay đã cũ (stale).
- scope, origin
- Siêu dữ liệu của một tài nguyên: scope là
user,projecthoặctemporary(CLI); origin làtop-levelhoặcpackage. - trust
- Quyết định tin cậy dự án. Mọi tài nguyên dưới
.pi/của dự án chỉ được nạp sau khi dự án được tin cậy.
Nạp một extension: jiti, virtual module, built-in#
Ví dụ ngắn nhất trong tài liệu là một tệp ~/.pi/agent/extensions/hello.ts đăng ký lệnh /hello; chạy thử một tệp bất kỳ thì dùng pi --extension ./hello.ts (docs/extensions.md:17). Không có bước biên dịch. Phần này lần theo đường đi từ tệp đó tới một đối tượng Extension trong bộ nhớ.
Tìm ở đâu: các nguồn và một thứ tự#
Việc khám phá không nằm trong loader.ts mà trong DefaultPackageManager.resolve(), hàm giải cùng lúc cả bốn loại tài nguyên. (loader.ts cũng export discoverAndLoadExtensions() (loader.ts:831), nhưng đó là helper cho người dùng SDK; CLI không gọi nó.)
async resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths> { const accumulator = this.createAccumulator(); const globalSettings = this.settingsManager.getGlobalSettings(); const projectSettings = this.settingsManager.getProjectSettings(); // Collect all packages with scope (project first so cwd resources win collisions) const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = []; for (const pkg of projectSettings.packages ?? []) { allPackages.push({ pkg, scope: "project" }); } for (const pkg of globalSettings.packages ?? []) { allPackages.push({ pkg, scope: "user" }); } // Dedupe: project scope wins over global for same package identity const packageSources = this.dedupePackages(allPackages); await this.resolvePackageSources(packageSources, accumulator, onMissing); this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir); // Built-in extensions are enabled unless the user `extensions` setting excludes them, for example // with `-builtin:mcp`. A matching `+`, `-`, or `!` entry in the project setting overrides that. for (const name of this.builtinExtensions) { const path = `${BUILTIN_PATH_PREFIX}${name}`; const projectEnabled = applyAutoloadDisabledPatterns( [path], getOverridePatterns(projectSettings.extensions ?? []), projectBaseDir, ).get(path); this.addResource( accumulator.extensions, path, { source: "builtin", scope: projectEnabled === undefined ? "user" : "project", origin: "top-level" }, projectEnabled ?? isEnabledByOverrides(path, globalSettings.extensions ?? [], globalBaseDir), ); } return this.toResolvedPaths(accumulator);}extensions, skills, prompts, themes ghi tay trong settings.json, dự án trước rồi người dùng. Mỗi tài nguyên mang cờ enabled: tài nguyên bị tắt vẫn được liệt kê (để pi config hiển thị) nhưng không được nạp.Trong một thư mục extension (~/.pi/agent/extensions/ hoặc .pi/extensions/), quy tắc khám phá đơn giản: mỗi tệp .ts/.js trực tiếp là một extension; mỗi thư mục con có package.json với trường pi.extensions, hoặc có index.ts/index.js, là một extension. Không đệ quy sâu hơn một cấp, tên bắt đầu bằng dấu chấm và node_modules bị bỏ qua, các tệp .gitignore, .ignore, .fdignore được tôn trọng (package-manager.ts:599). Thư mục .pi/extensions/ của dự án chỉ được quét khi dự án đã được tin cậy (package-manager.ts:2484).
Kết quả được sắp theo một hạng ưu tiên trước khi trả về:
/** * Compute a numeric precedence rank for a resource based on its metadata. * Lower rank = higher precedence. Used to sort resolved resources so that * name-collision resolution ("first wins") produces the correct outcome. * * Precedence (highest to lowest): * 0 project + settings entry (source: "local", scope: "project") * 1 project + auto-discovered (source: "auto", scope: "project") * 2 user + settings entry (source: "local", scope: "user") * 3 user + auto-discovered (source: "auto", scope: "user") * 4 package resource (origin: "package") */function resourcePrecedenceRank(m: PathMetadata): number { // Built-in extensions load after file and package extensions, whichever scope enables them. if (m.source === "builtin") return 5; if (m.origin === "package") return 4; const scopeBase = m.scope === "project" ? 0 : 2; return scopeBase + (m.source === "local" ? 0 : 1);}Thứ tự này có hai hệ quả. Với skill, prompt và theme, khi hai tài nguyên trùng tên thì cái đứng trước thắng. Với extension, nó là thứ tự nạp, và vì handler chạy theo thứ tự nạp, nó cũng là thứ tự extension nhìn thấy sự kiện. Trước tất cả là các đường dẫn truyền bằng -e trên dòng lệnh: DefaultResourceLoader.reload() ghép cliEnabledExtensions lên đầu, và --no-extensions giữ lại chỉ chúng (resource-loader.ts:577). Nguồn -e có scope temporary và có thể là npm: hoặc git:, được cài vào một thư mục tạm thay vì ghi vào settings (package-manager.ts:997).
jiti và virtual module: một extension, ba kiểu chạy#
Vấn đề: hello.ts viết import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" và có thể import typebox, @earendil-works/pi-tui. Pi lại chạy được theo ba cách: từ mã nguồn TypeScript trong repo, từ gói npm đã build, hoặc từ một binary độc lập (Bun hay Node SEA) không có thư mục node_modules nào trên đĩa. Trong cả ba trường hợp, import của extension phải trỏ tới chính module mà tiến trình Pi đang dùng, không phải một bản sao: theme toàn cục, các lớp như CustomEditor và sổ provider phải là một. Chương trình dịch và nạp TypeScript lúc chạy là jiti:
const isNodeSeaBinary = ("sea" in process.features && process.features.sea === true) || process.getBuiltinModule("node:sea")?.isSea() === true;const isTypeScriptSourceRuntime = !isBunBinary && path.extname(fileURLToPath(import.meta.url)) === ".ts";const usesEmbeddedModules = isBunBinary || isNodeSeaBinary || isBundledNode;async function loadExtensionModule(extensionPath: string, cacheToken?: ExtensionCacheToken) { if (isCurrentCacheToken(cacheToken)) { const cachedFactory = extensionCache.get(extensionPath); if (cachedFactory) { return cachedFactory; } } const createJitiImpl = await getCreateJiti(); // Compiled binaries and the bundled Node distribution use embedded modules. // Source TypeScript reuses host modules and root tsconfig paths. Unbundled // Node builds use dist aliases and do not need the bundled virtual modules. const resolutionOptions = usesEmbeddedModules ? { virtualModules: await getVirtualModules(), tryNative: false } : isTypeScriptSourceRuntime ? { virtualModules: await getVirtualModules(), tsconfigPaths: true } : { alias: getAliases() }; const jiti = createJitiImpl(import.meta.url, { moduleCache: false, ...resolutionOptions, }); const module = await jiti.import(extensionPath, { default: true }); const factory = module as ExtensionFactory; if (typeof factory !== "function") { return undefined; } if (isCurrentCacheToken(cacheToken)) { extensionCache.set(extensionPath, factory); } return factory;}virtualModules với tryNative: false, để mọi import đều đi qua jiti và bị bắt lại. Mã nguồn TypeScript cũng dùng virtual module cộng với paths của tsconfig gốc. Bản npm chưa đóng gói ánh xạ tên gói sang tệp dist đã cài bằng alias (getAliases()). Module không export mặc định một hàm thì bị từ chối với lỗi "does not export a valid factory function".// This import is safe because loader.ts exports are not re-exported from index.ts.// Extensions can therefore import from @earendil-works/pi-coding-agent.import * as bundledPiCodingAgent from "../../index.ts";/** Modules available to extensions in source and compiled binary runtimes. */export const VIRTUAL_MODULES: Record<string, unknown> = { typebox: bundledTypebox, "typebox/compile": bundledTypeboxCompile, "typebox/value": bundledTypeboxValue, "@sinclair/typebox": bundledTypebox, "@sinclair/typebox/compile": bundledTypeboxCompile, "@sinclair/typebox/value": bundledTypeboxValue, "@earendil-works/pi-agent-core": bundledPiAgentCore, "@earendil-works/pi-tui": bundledPiTui, // Extensions resolve the pi-ai root to the compat entrypoint (a strict // superset of the core entrypoint): existing extensions using the old // global API keep working at runtime until compat is removed. "@earendil-works/pi-ai": bundledPiAiCompat, "@earendil-works/pi-ai/compat": bundledPiAiCompat, "@earendil-works/pi-ai/models": bundledPiAiModels, "@earendil-works/pi-ai/oauth": bundledPiAiOauth, "@earendil-works/pi-ai/providers/all": bundledPiAiProviders, "@earendil-works/pi-coding-agent": bundledPiCodingAgent,@mariozechner/* (đổi sang @earendil-works/* ở bản 0.74.0, CHANGELOG.md:2208) và @sinclair/typebox được ánh xạ vào cùng các đối tượng, nên extension viết cho phiên bản cũ vẫn chạy. Gốc @earendil-works/pi-ai trỏ vào entrypoint compat, một tập cha của entrypoint lõi giữ API toàn cục cũ.Rủi ro hai bản sao module là có thật trong repo: biến theme toàn cục được cất trên globalThis dưới một Symbol.for(...) chính vì "node + jiti in dev mode" có thể nạp module theme hai lần (theme.ts:646). Cùng lý do, một Pi package không được liệt kê các gói do host cung cấp trong dependencies: bản cài vật lý có thể đi vòng qua ánh xạ của loader. Resource loader phát hiện cấu hình này và in cảnh báo (resource-loader.ts:53, docs/packages.md:88). Các thư viện khác thì extension tự mang theo trong package.json bên cạnh nó; jiti phân giải chúng từ node_modules của extension, như ví dụ with-deps/ (packages/coding-agent/examples/extensions/with-deps/package.json).
Hai chi tiết nhỏ về chi phí khởi động: jiti và Babel chỉ được import khi có extension đầu tiên cần nạp (getCreateJiti() và getVirtualModules() dùng import động có cache, loader.ts:47); binary dùng jiti/static để bộ đóng gói nhúng được bộ biến đổi Babel, còn Node thường dùng bản lười chỉ tải Babel khi nạp native thất bại (packages/coding-agent/src/core/extensions/jiti-loader.ts, packages/coding-agent/src/core/extensions/jiti-static-loader.ts).
Chạy factory: đăng ký hai pha#
Có module rồi, loader tạo một Extension rỗng, dựng một ExtensionAPI riêng cho nó, và gọi factory:
async function initializeExtension( factory: ExtensionFactory, extensionPath: string, resolvedPath: string, cwd: string, eventBus: EventBus, runtime: ExtensionRuntime,): Promise<Extension> { const extension = createExtension(extensionPath, resolvedPath); const load = createExtensionAPI(extension, runtime, cwd, eventBus); try { await factory(load.api); load.commit(); } catch (error) { load.discard(); throw error; } time(`${extensionPath} factory`, "extensions"); return extension;} try { const factory = await loadExtensionModule(resolvedPath, cacheToken); time(`${extensionPath} module import`, "extensions"); if (!factory) { return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` }; } const extension = await initializeExtension(factory, extensionPath, resolvedPath, cwd, eventBus, runtime); return { extension, error: null }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { extension: null, error: `Failed to load extension: ${message}` }; }errors ("Failed to load extension: ..."), hiển thị lúc khởi động; vòng lặp trong loadExtensionsInternal() chuyển sang tệp kế tiếp (loader.ts:694).commit() và discard() là phần đáng chú ý. Trong lúc factory chạy, API ở trạng thái loading: các thay đổi lên runtime dùng chung (đăng ký provider, MCP server, virtual model) chưa được áp dụng mà xếp vào pendingRuntimeChanges. Factory chạy hết mới commit; factory ném lỗi thì mọi thứ đang chờ bị huỷ, các đăng ký trên event bus làm trong lúc nạp bị gỡ, và API chuyển sang failed để mọi lời gọi sau đó ném lỗi:
const pendingFlagValues = new Map<string, boolean | string>();const pendingRuntimeChanges: Array<() => void> = [];const loadingUnsubscribers: Array<() => void> = [];let state: "loading" | "active" | "failed" = "loading";const assertActive = () => { if (state === "failed") { throw new Error(`Extension "${extension.path}" failed to load and its API is no longer active.`); } runtime.assertActive();};const applyRuntimeChange = (change: () => void) => { if (state === "loading") pendingRuntimeChanges.push(change); else change();};const clearPending = () => { pendingFlagValues.clear(); pendingRuntimeChanges.length = 0; loadingUnsubscribers.length = 0;};return { api, commit: () => { if (state !== "loading") return; runtime.assertActive(); for (const [name, value] of pendingFlagValues) { if (!runtime.flagValues.has(name)) runtime.flagValues.set(name, value); } for (const apply of pendingRuntimeChanges) apply(); state = "active"; clearPending(); }, discard: () => { if (state !== "loading") return; state = "failed"; for (const unsubscribe of loadingUnsubscribers) unsubscribe(); clearPending(); },};Một ràng buộc khác: trong lúc nạp, các hàm hành động của runtime là stub ném lỗi. Runtime được tạo trước mọi extension, và chỉ khi AgentSession dựng runner và gọi bindCore() thì các hàm thật mới được chép vào:
export function createExtensionRuntime(): ExtensionRuntime { const notInitialized = () => { throw new Error("Extension runtime not initialized. Action methods cannot be called during extension loading."); }; const state: { staleMessage?: string } = {}; const eventBusUnsubscribers = new Set<() => void>(); const assertActive = () => { if (state.staleMessage) { throw new Error(state.staleMessage); } }; const runtime: ExtensionRuntime = { sendMessage: notInitialized, sendUserMessage: notInitialized, appendEntry: notInitialized,Vì thế một factory gọi pi.sendMessage() sẽ thất bại và extension bị bỏ. Tài liệu còn khuyên không mở tiến trình, socket, watcher hay timer trong factory, vì có những lần Pi nạp extension mà không mở phiên nào: các lệnh pi config, pi install, pi list nạp extension của người dùng chỉ để chúng trả lời câu hỏi trust (package-manager-cli.ts:782); tài nguyên sống lâu nên mở trong session_start và đóng trong session_shutdown (docs/extensions.md:56).
builtin:<name>: extension dựng sẵn như một tệp#
CLI có bốn extension dựng sẵn, khai báo bằng kiểu InlineExtension với cờ builtin:
export const builtInExtensions: InlineExtension[] = [ { name: "llama.cpp", factory: llamaExtension, builtin: true }, // Replaceable: an extension that registers `codemode`, `tool_search`, or `/mcp` (such as a third-party // MCP extension) takes over instead of running alongside the built-in one. { name: "codemode", factory: codemodeExtension, replaceable: true, builtin: true }, { name: "tool-search", factory: toolSearchExtension, replaceable: true, builtin: true }, { name: "mcp", factory: mcpExtension, replaceable: true, builtin: true },];export type InlineExtension = | ExtensionFactory | { /** * Display name shown as `<inline:name>` in the startup Extensions list and errors. With * `builtin`, the extension is named `builtin:name` in errors and diagnostics. */ name: string; factory: ExtensionFactory; /** Omit this extension from the startup Extensions list. */ hidden?: boolean; /** * Leave this extension out when another extension registers a tool, command, or flag with a * name it registers during loading, instead of reporting a conflict. The CLI's built-in MCP, * codemode, and tool search extensions use it, so for example an MCP extension that registers * `/mcp` replaces the built-in MCP support. The factory still runs, so it should only register * tools, commands, flags, and event handlers. */ replaceable?: boolean; /** * Supply the code of the `builtin:<name>` extension instead of loading as an inline extension. * `builtin:<name>` is an extension resource like a file: it loads by default, `pi config` lists * it, `-builtin:<name>` in the `extensions` setting and `--no-extensions` disable it, and * `-e builtin:<name>` loads it explicitly. It is hidden from the startup Extensions list and * loads after project trust is resolved, so it cannot handle `project_trust`. The CLI's built-in * extensions use it. */ builtin?: boolean; };Ý tưởng là coi một extension dựng sẵn như một tài nguyên có đường dẫn, chỉ khác là đường dẫn đó tổng hợp (synthetic): builtin:mcp không trỏ tới tệp nào. Nhờ vậy mọi công cụ quản lý tài nguyên dùng được cho nó. resolve() thêm mỗi built-in như một extension với source: "builtin", bật trừ khi setting extensions loại nó bằng -builtin:mcp; mục +/- trong settings dự án thắng settings người dùng (dòng 977 đến 991 ở trên). Hạng ưu tiên 5 đặt chúng sau mọi extension tệp và gói. Khi tới lượt nạp, đường dẫn builtin: được tra trong bảng built-in thay vì đọc đĩa:
/** Load extension paths: files from disk and `builtin:<name>` paths from the built-in extensions. */private async loadExtensionPaths(paths: string[], runtime?: ExtensionRuntime): Promise<LoadExtensionsResult> { const isBuiltinPath = (path: string) => path.startsWith(BUILTIN_PATH_PREFIX); const result = await loadExtensionsCached( paths.filter((path) => !isBuiltinPath(path)), this.cwd, this.eventBus, runtime, ); for (const path of paths.filter(isBuiltinPath)) { const builtin = this.builtinExtensions.get(path.slice(BUILTIN_PATH_PREFIX.length)); if (!builtin) { result.errors.push({ path, error: `Unknown built-in extension: ${path}` }); continue; } try { const extension = await loadExtensionFromFactory( builtin.factory, this.cwd, this.eventBus, result.runtime, path, ); extension.hidden = true; extension.replaceable = builtin.replaceable === true; result.extensions.push(extension); } catch (error) { result.errors.push({ path, error: error instanceof Error ? error.message : "failed to load extension" }); } } return result;}Có nhiều cách tắt một built-in: mục -builtin:<name> trong extensions, phần Built-in của pi config, --no-extensions (tắt cả bốn), hay --no-mcp cho riêng MCP (main.ts:788); ngược lại -e builtin:mcp nạp tường minh (docs/settings.md:163). Ba built-in có cờ replaceable: nếu một extension khác đăng ký tool, lệnh hay cờ trùng tên với chúng, built-in bị loại thay vì chạy song song, kèm một cảnh báo hướng dẫn dùng pi config:
function omitReplacedExtensions( extensions: Extension[], warnings?: Array<{ path: string; warning: string }>,): Extension[] { const names = (extension: Extension) => [ ...[...extension.tools.keys()].map((name) => `tool:${name}`), ...[...extension.commands.keys()].map((name) => `command:${name}`), ...[...extension.flags.keys()].map((name) => `flag:${name}`), ]; const taken = new Map( extensions .filter((extension) => !extension.replaceable) .flatMap((extension) => names(extension).map((name) => [name, extension] as const)), ); return extensions.filter((extension) => { if (!extension.replaceable) return true; const replacement = names(extension) .map((name) => ({ name, extension: taken.get(name) })) .find((value): value is { name: string; extension: Extension } => value.extension !== undefined); if (!replacement) return true; if (extension.path.startsWith(BUILTIN_PATH_PREFIX)) { const builtinName = extension.path.slice(BUILTIN_PATH_PREFIX.length); const [kind, rawName] = replacement.name.split(":", 2); const registeredName = kind === "command" ? `/${rawName}` : kind === "flag" ? `--${rawName}` : rawName; warnings?.push({ path: extension.path, warning: `Extension ${replacement.extension.path} registers ${kind} \`${registeredName}\`, so built-in extension \`${builtinName}\` was not loaded. To use \`${builtinName}\`, run \`pi config\` and make sure it is enabled under Built-in extensions, then disable or remove the existing extension. We recommend only having one or the other loaded at a time.`, }); } return false; });}/mcp thì built-in mcp không được dùng, tránh hai bên cùng kết nối một máy chủ. Factory của built-in vẫn chạy (việc so tên cần biết nó đăng ký gì), nên factory của extension replaceable chỉ nên đăng ký, không làm việc có tác dụng phụ (types.ts:2058).wrapper.ts: tool của extension nhận ctx#
Tool đăng ký bằng pi.registerTool() là một ToolDefinition của coding agent, trong khi vòng lặp agent chỉ biết AgentTool của pi-agent-core. wrapper.ts nối hai bên, và chỉ làm đúng một việc:
export function wrapToolDefinition<TDetails = unknown>( definition: ToolDefinition<any, TDetails>, ctxFactory?: ToolContextFactory,): AgentTool<any, TDetails> { return { name: definition.name, label: definition.label, description: definition.description, parameters: definition.parameters, outputSchema: definition.outputSchema, constrainedSampling: definition.constrainedSampling, prepareArguments: definition.prepareArguments, executionMode: definition.executionMode, execute: (toolCallId, params, signal, onUpdate, ctx?: ExtensionToolContext) => definition.execute( toolCallId, params, signal, onUpdate, ctx ?? (ctxFactory?.(toolCallId, signal) as ExtensionToolContext), ), };}wrapRegisteredTool() truyền factory runner.createToolContext(toolCallId, signal) (wrapper.ts:17). Chú thích đầu tệp nói rõ: chặn và sửa lời gọi tool không nằm ở đây mà ở hook của AgentSession (mục lần theo).Tool dựng sẵn (read, bash, ...) cũng được bọc theo cùng cách, rồi tool của extension được ghi đè lên theo tên (agent-session.ts:3607). Hệ quả: một extension đăng ký tool tên bash sẽ thay hẳn tool bash dựng sẵn, như ví dụ sandbox/ cố ý làm (sandbox/index.ts:8). Giữa hai extension cùng đăng ký một tên, cái nạp trước thắng (runner.ts:636), và cả hai vẫn được nạp, xung đột được báo thành lỗi chẩn đoán (resource-loader.ts:1250).
/reload: nạp lại nóng#
Vòng "viết, nạp lại, thử" mà Armin mô tả là lệnh /reload (hoặc ctx.reload() từ một lệnh extension):
async reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void> { const oldRunner = this._extensionRunner; const previousFlagValues = oldRunner.getFlagValues(); await emitSessionShutdownEvent(oldRunner, { type: "session_shutdown", reason: "reload" }); oldRunner.invalidate(); await this.settingsManager.reload(); this.syncQueueModesFromSettings(); resetApiProviders(); await this._resourceLoader.reload(); // Activate tools newly added to defaultTools. Removed ones stay active, and tools disabled // during the session stay disabled unless the setting newly adds them. const addedDefaultTools = getDefaultTools().filter((name) => !previousDefaultTools.has(name)); // Tools the new extensions register later, such as MCP tools, are pending until then. for (const name of this.getActiveToolNames()) this._pendingToolNames.add(name); this._buildRuntime({ activeToolNames: [...this.getActiveToolNames(), ...addedDefaultTools], flagValues: previousFlagValues, includeAllExtensionTools: true, }); if (hasBindings) { await options?.beforeSessionStart?.(); await this._extensionRunner.emit({ type: "session_start", reason: "reload" }); this._extensionRunner.reportUnhandledMcpServers(); await this.extendResourcesFromExtensions("reload"); }/reload khi agent đang chạy hoặc đang compaction (interactive-mode.ts:6403), sau đó nạp lại cả keybindings và theme.Mã mới thực sự được đọc lại từ đĩa vì hai lý do. jiti được tạo với moduleCache: false, nên mỗi lần import đọc và đánh giá lại tệp. Và bộ nhớ đệm factory của loader (Map theo đường dẫn, gắn với cwd và một số thế hệ) bị xoá ở mọi lần reload() trừ lần đầu của một resource loader (resource-loader.ts:516, loader.ts:132). Bộ nhớ đệm đó tồn tại để những lần nạp liền nhau trong cùng cwd không phải dịch lại TypeScript, chẳng hạn khi /new dựng một resource loader mới; hệ quả của nó nằm ở mục cạm bẫy.
Nạp tài nguyên và project trust#
Vấn đề: bạn clone một repo lạ có .pi/extensions/setup.ts rồi gõ pi trong đó. Nếu Pi nạp mọi thứ nó tìm thấy, tệp kia chạy với quyền của bạn trước khi bạn kịp đọc nó. Nhưng chặn mọi thứ cũng không ổn: một công ty có thể muốn một extension cá nhân tự động tin mọi repo dưới ~/work mà không hỏi. Pi giải bằng hai lượt nạp, với quyết định trust nằm giữa. Thứ tự quyết định (cờ dòng lệnh, extension, quyết định đã lưu, setting defaultProjectTrust, hộp chọn) đã được trình bày ở chương AgentSession; phần này tập trung vào cái gì được nạp ở mỗi phía của quyết định.
Chỉ những tài nguyên có thể thực thi hoặc đổi cấu hình mới đòi một quyết định. Nếu thư mục không có gì trong danh sách dưới đây, dự án được coi là tin cậy mà không hỏi (project-trust.ts:51); một thư mục .pi rỗng không đòi gì.
const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [ "settings.json", "mcp.json", "extensions", "skills", "prompts", "themes", "SYSTEM.md", "APPEND_SYSTEM.md",] as const;.agents/skills ở thư mục hiện tại hoặc một thư mục cha, trừ ~/.agents/skills của chính người dùng (trust-manager.ts:186).Khi cần quyết định, main.ts dựng services với projectTrusted: false và truyền vào resource loader một callback resolveProjectTrust (main.ts:743). reload() của resource loader khi đó chạy thành ba bước:
async loadProjectTrustExtensions(): Promise<LoadExtensionsResult> { // Force untrusted project settings for the bootstrap pass. This keeps project-local // extensions/packages out while still loading user/global and temporary CLI extensions. this.settingsManager.setProjectTrusted(false); await this.settingsManager.reload(); return this.loadCurrentExtensionSet({ includeInlineFactories: true });}async reload(options?: ResourceLoaderReloadOptions): Promise<void> { resetTimings("extensions"); if (this.loaded) { clearExtensionCache(); } let preTrustExtensions: LoadExtensionsResult | undefined; if (options?.resolveProjectTrust) { preTrustExtensions = await this.loadProjectTrustExtensions(); const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions }); this.settingsManager.setProjectTrusted(projectTrusted); } // reload() preserves SettingsManager.projectTrusted and reloads settings for that trust state. await this.settingsManager.reload();Lượt đầu nạp tập extension hiện có dưới trạng thái "không tin cậy": settings dự án không được đọc, nên chỉ còn extension của người dùng, extension truyền bằng -e và factory nội tuyến của SDK. Built-in bị hoãn có chủ đích:
private async loadCurrentExtensionSet(options: { includeInlineFactories: boolean }): Promise<LoadExtensionsResult> { const resolvedPaths = await this.packageManager.resolve(); const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { temporary: true, }); const enabledExtensions = resolvedPaths.extensions.filter((r) => r.enabled).map((r) => r.path); const cliEnabledExtensions = cliExtensionPaths.extensions.filter((r) => r.enabled).map((r) => r.path); // Built-in extensions wait for the final pass: project settings can disable them, and a loaded // extension cannot be unloaded. const extensionPaths = ( this.noExtensions ? cliEnabledExtensions : this.mergePaths(cliEnabledExtensions, enabledExtensions) ).filter((path) => !path.startsWith(BUILTIN_PATH_PREFIX)); const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus); mergeExtensionWarnings(extensionsResult, packageWarnings); if (!options.includeInlineFactories) { return extensionsResult; } const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); extensionsResult.extensions.push(...inlineExtensions.extensions); extensionsResult.errors.push(...inlineExtensions.errors); const replacementWarnings: Array<{ path: string; warning: string }> = []; extensionsResult.extensions = omitReplacedExtensions(extensionsResult.extensions, replacementWarnings); mergeExtensionWarnings(extensionsResult, replacementWarnings); return extensionsResult;project_trust.Các extension của lượt đầu nhận sự kiện project_trust. Cách gộp kết quả khác mọi sự kiện khác: handler đầu tiên trả "yes" hoặc "no" quyết định luôn, "undecided" nhường cho handler sau, và lỗi của một handler không chặn các handler còn lại:
export async function emitProjectTrustEvent( extensionsResult: LoadExtensionsResult, event: ProjectTrustEvent, ctx: ProjectTrustContext,): Promise<{ result?: ProjectTrustEventResult; errors: ExtensionError[] }> { const errors: ExtensionError[] = []; for (const { ext, handlers } of snapshotEventHandlers(extensionsResult.extensions, "project_trust")) { // A single extension may register multiple handlers for the same event. // The first project_trust handler that returns yes/no wins; undecided falls through. for (const handler of handlers) { try { const handlerResult = (await handler(event, ctx)) as ProjectTrustEventResult; if (handlerResult.trusted === "undecided") { continue; } return { result: handlerResult, errors }; } catch (error) { errors.push({ extensionPath: ext.path, event: event.type, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); } } } return { errors };}remember: true thì được ghi vào ~/.pi/agent/trust.json (project-trust.ts:55). Ví dụ project-trust.ts cho thấy một extension hỏi người dùng bằng UI riêng thay cho hộp chọn mặc định (project-trust.ts:26).Sau quyết định, settings được đọc lại theo trạng thái trust mới, packageManager.resolve() chạy lần nữa (giờ có thể thấy .pi/extensions, gói dự án, mục -builtin: của dự án), và lượt cuối chỉ nạp phần còn thiếu:
private async loadFinalExtensionSet( extensionPaths: string[], preTrustExtensions: LoadExtensionsResult | undefined,): Promise<LoadExtensionsResult> { // Without a pre-trust pass nothing is preloaded, and inline extensions load here. const preloaded = preTrustExtensions?.extensions ?? []; const preloadedByPath = new Map( preloaded .filter((extension) => !extension.path.startsWith("<inline:")) .map((extension) => [extension.resolvedPath, extension]), ); const failedPreloadPaths = new Set( (preTrustExtensions?.errors ?? []).map((error) => this.resolveExtensionLoadPath(error.path)), ); const remainingPaths = extensionPaths.filter((path) => { const resolvedPath = this.resolveExtensionLoadPath(path); return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath); }); const remainingExtensions = await this.loadExtensionPaths(remainingPaths, preTrustExtensions?.runtime); const loadedByPath = new Map(preloadedByPath); for (const extension of remainingExtensions.extensions) { loadedByPath.set(extension.resolvedPath, extension); } const inlineExtensions = preTrustExtensions ? { extensions: preloaded.filter((extension) => extension.path.startsWith("<inline:")), errors: [] } : await this.loadExtensionFactories(remainingExtensions.runtime); const orderedExtensions = extensionPaths .map((path) => loadedByPath.get(this.resolveExtensionLoadPath(path))) .filter((extension): extension is Extension => extension !== undefined); orderedExtensions.push(...inlineExtensions.extensions);Extension được giữ nguyên, kể cả các handler nó đã đăng ký. Một đường dẫn đã lỗi ở lượt đầu không được thử lại. Phần mới nạp dùng chung runtime của lượt đầu, nên mọi extension thấy cùng giá trị cờ và cùng sổ MCP.Tổng hợp lại, theo code v1.4.0:
| Thời điểm | Được nạp |
|---|---|
| Trước quyết định trust | Settings toàn cục; extension từ -e, từ ~/.pi/agent/extensions/, từ mục extensions và packages trong settings người dùng (gói người dùng còn thiếu được cài luôn); factory nội tuyến của SDK. Riêng setting sessionDir của dự án được đọc còn sớm hơn, lúc chọn session (docs/security.md:31). |
| Sau quyết định, chỉ khi tin cậy | .pi/settings.json, gói dự án (cài nếu thiếu, vào .pi/npm, .pi/git), .pi/extensions/, .pi/skills/, .agents/skills của dự án, .pi/prompts/, .pi/themes/, .pi/SYSTEM.md, .pi/APPEND_SYSTEM.md, .pi/mcp.json. |
| Sau quyết định, dù tin cậy hay không | Extension dựng sẵn builtin:*; skill, prompt template, theme của người dùng; context file AGENTS.md/CLAUDE.md từ cwd và các thư mục cha (resource-loader.ts:646). |
Điểm cuối hay gây bất ngờ: chối trust không ngăn AGENTS.md của repo lạ vào system prompt, và tài liệu nói rõ phải coi nội dung trong thư mục là đầu vào không đáng tin (docs/security.md:57). Extension đọc trạng thái hiện hành bằng ctx.isProjectTrusted(); ví dụ subagent/ dùng nó để hỏi thêm trước khi chạy agent định nghĩa trong repo chưa tin cậy (mục ví dụ). Một lần /reload giữ nguyên quyết định đã có, không hỏi lại: AgentSession.reload() gọi resourceLoader.reload() không kèm callback trust.
Bề mặt API: factory và các hàm register#
Toàn bộ hợp đồng nằm trong packages/coding-agent/src/core/extensions/types.ts, khoảng 2.300 dòng, và tài liệu chính thức cũng bảo người viết extension đọc thẳng các khai báo đó để biết payload chính xác (docs/extensions.md:188). Điểm vào là một kiểu hàm một dòng:
/** Extension factory function type. Supports both sync and async initialization. */export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;ExtensionAPI (dòng 1592 đến 1918) gom thành các nhóm sau:
| Nhóm | Thành viên | Ghi chú |
|---|---|---|
| Sự kiện | on(event, handler) | 41 overload, mỗi sự kiện một kiểu kết quả; trả hàm huỷ đăng ký. |
| Tool | registerTool | Tool mô hình gọi được; xem ToolDefinition bên dưới. |
| Lệnh, phím, cờ | registerCommand, registerShortcut, registerFlag, getFlag | Lệnh /tên; phím tắt TUI; cờ dòng lệnh --tên. |
| Vẽ | registerMessageRenderer, registerEntryRenderer, registerToolRenderer, registerMarkdownTransformer | Chỉ ảnh hưởng hiển thị, không ảnh hưởng ngữ cảnh mô hình. |
| Hành động lên phiên | sendMessage, sendUserMessage, appendEntry, setSessionName, getSessionName, setLabel | Xem mục kế tiếp. |
| Tool và cấu hình | exec, getActiveTools, setActiveTools, getAllTools, getSettings, getCommands | setActiveTools đổi tập tool khai báo cho mô hình. |
| Mô hình | setModel, getThinkingLevel, setThinkingLevel | Đổi cho phiên hiện tại, không đổi mặc định cho phiên mới. |
| Provider | registerProvider (hai dạng), unregisterProvider | Mục provider. |
| MCP | registerMcpServer, unregisterMcpServer, getMcpServers | Cấu hình giống một mục trong mcp.json; phần kết nối thuộc chương Codemode. |
| Virtual model | registerVirtualModel, unregisterVirtualModel | Một mục chọn được trong danh mục, định tuyến từng yêu cầu tới mô hình thật (chương Pi 1.0). |
| Liên lạc | events | Event bus dùng chung giữa các extension. |
Phần đầu của interface cho thấy hai mẫu lặp lại suốt API: handler nhận (event, ctx) và có thể trả về một kết quả có kiểu riêng của sự kiện đó:
/** Handler function type for events */// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statementsexport type ExtensionHandler<E, R = undefined> = (event: E, ctx: ExtensionContext) => Promise<R | void> | R | void;/** * ExtensionAPI passed to extension factory functions. */export interface ExtensionAPI { // ========================================================================= // Event Subscription // ========================================================================= on(event: "project_trust", handler: ProjectTrustHandler): () => void; on( event: "resources_discover", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>, ): () => void; on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): () => void; on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): () => void; on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): () => void; on(event: "user_bash", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): () => void; on(event: "input", handler: ExtensionHandler<InputEvent, InputEventResult>): () => void; // ========================================================================= // Tool Registration // ========================================================================= /** Register a tool that the LLM can call. */ registerTool<TParams extends TSchema = TSchema, TDetails = unknown, TState = any>( tool: ToolDefinition<TParams, TDetails, TState>, ): void;project_trust là ngoại lệ: handler của nó nhận một ProjectTrustContext rút gọn (cwd, mode, hasUI và bốn hàm UI) vì lúc đó chưa có phiên nào (types.ts:712).ToolDefinition: một tool có những gì#
export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = unknown, TState = any> { /** Tool name (used in LLM tool calls) */ name: string; /** Human-readable label for UI */ label: string; /** Description for LLM */ description: string; /** Optional one-line snippet for the Available tools section in the default system prompt. Custom tools are omitted from that section when this is not provided. */ promptSnippet?: string; /** Optional guideline bullets appended to the default system prompt Guidelines section when this tool is active. */ promptGuidelines?: string[]; /** Parameter schema (TypeBox) */ parameters: TParams; /** Optional provider-side constrained sampling request for this tool. Set false to explicitly disable it, equivalent to leaving it undefined. */ constrainedSampling?: false | ConstrainedSamplingConfig; /** Controls whether ToolExecutionComponent renders the standard colored shell or the tool renders its own framing. */ renderShell?: "default" | "self"; /** Optional compatibility shim to prepare raw tool call arguments before schema validation. Must return an object conforming to TParams. */ prepareArguments?: (args: unknown) => Static<TParams>; /** * Per-tool execution mode override. * - "sequential": this tool must execute one at a time with other tool calls. * - "parallel": this tool can execute concurrently with other tool calls. * * If omitted, the default execution mode applies. */ executionMode?: ToolExecutionMode; /** Execute the tool. */ execute( toolCallId: string, params: Static<TParams>, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback<TDetails> | undefined, ctx: ExtensionToolContext, ): Promise<AgentToolResult<TDetails>>; /** Custom rendering for tool call display */ renderCall?: (args: Static<TParams>, theme: Theme, context: ToolRenderContext<TState, Static<TParams>>) => Component; /** Custom rendering for tool result display */ renderResult?: ( result: AgentToolResult<TDetails>, options: ToolRenderResultOptions, theme: Theme, context: ToolRenderContext<TState, Static<TParams>>, ) => Component;}outputSchema, exposure, namespace, reachesTools, annotations, defaultActive, prepareLoadout; chúng được bàn ở mục tool exposure.Mỗi trường gắn với một phần của Pi. description và parameters (schema TypeBox) là phần khai báo gửi provider. promptSnippet là một dòng trong mục "Available tools" của system prompt mặc định; không có nó, tool của extension không xuất hiện trong mục đó, dù mô hình vẫn thấy khai báo. promptGuidelines là các gạch đầu dòng chỉ được thêm vào phần quy tắc khi tool đang bật. prepareArguments chạy trước kiểm tra schema, dùng để chấp nhận dạng tham số cũ (agent-loop.ts:704). executionMode: "sequential" buộc cả lô lời gọi của một message chạy tuần tự nếu có một tool như thế (agent-loop.ts:525); ví dụ tic-tac-toe.ts dùng nó để hai lời gọi không giành nhau con trỏ bàn cờ.
Kết quả của execute() là AgentToolResult của pi-agent-core: content cho mô hình, details cho UI và cho việc dựng lại trạng thái, cùng các trường tuỳ chọn structuredContent, usage, isError, terminate (types.ts:424). Tách content khỏi details là quyết định có từ bài viết đầu tiên của Mario: mô hình cần văn bản gọn, còn UI cần dữ liệu có cấu trúc. Ném lỗi trong execute() thì vòng lặp tạo kết quả lỗi với thông điệp của lỗi (agent-loop.ts:856); muốn báo lỗi mà vẫn giữ details thì trả isError: true.
Đăng ký thì nhẹ: loader chỉ kiểm tra hình dạng tối thiểu rồi ghi vào Map của extension.
registerTool(tool: ToolDefinition): void { assertActive(); if (typeof tool.parameters !== "object" || tool.parameters === null || Array.isArray(tool.parameters)) { throw new Error( `Tool "${tool.name}" registered by extension "${extension.path}" must define an object parameter schema.`, ); } extension.tools.set(tool.name, { definition: tool, sourceInfo: extension.sourceInfo, }); runtime.refreshTools();},registerCommand(name: string, options: Omit<RegisteredCommand, "name" | "sourceInfo">): void { assertActive(); if (typeof name !== "string" || name.length === 0) { throw new Error( `Command registered by extension "${extension.path}" must have a non-empty string name. Use pi.registerCommand("name", { description, handler }).`, ); } if (typeof options?.handler !== "function") { throw new Error(`Command "/${name}" registered by extension "${extension.path}" must define handler().`); } extension.commands.set(name, { name, sourceInfo: extension.sourceInfo, ...options, });},runtime.refreshTools() là no-op trong lúc nạp; sau khi bind, nó dựng lại sổ tool ngay, nên một tool đăng ký từ session_start hay từ một lệnh có hiệu lực ở yêu cầu kế tiếp (ví dụ dynamic-tools.ts).Lệnh, cờ và renderer#
export interface RegisteredCommand { name: string; sourceInfo: SourceInfo; description?: string; getArgumentCompletions?: (argumentPrefix: string) => AutocompleteItem[] | null | Promise<AutocompleteItem[] | null>; handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;}export interface ResolvedCommand extends RegisteredCommand { invocationName: string;}Lệnh được thực thi trước cả sự kiện input và ngay cả khi agent đang chạy (chương AgentSession, bước 1). Hai extension đăng ký cùng tên lệnh không lỗi: runner đặt tên gọi thành /tên:1, /tên:2 theo thứ tự nạp (runner.ts:814). getArgumentCompletions cấp gợi ý tham số cho trình soạn thảo.
registerFlag(name, { type, default }) khai báo một cờ dòng lệnh. Bộ phân tích tham số của CLI chạy trước khi extension được nạp, nên nó cất mọi cờ --x lạ vào unknownFlags (args.ts:249); sau khi nạp, applyExtensionFlagValues() đối chiếu chúng với các cờ đã đăng ký và báo "Unknown option" cho phần còn lại (agent-session-services.ts:82). Hệ quả của cách làm hai bước này nằm ở mục cạm bẫy.
Bốn hàm vẽ chỉ ảnh hưởng hiển thị: transcript của interactive mode, và với renderer của tool thì cả bản xuất HTML (docs/extensions.md:305). registerMessageRenderer(customType, fn) vẽ custom message (thứ sendMessage tạo ra), registerEntryRenderer vẽ custom entry (thứ appendEntry tạo ra, vốn không vào ngữ cảnh mô hình), registerMarkdownTransformer biến đổi Markdown của user và assistant trước khi vẽ. registerToolRenderer(resolver) đáng chú ý hơn: resolver được gọi cho mọi tên tool, kể cả tool chưa đăng ký (tool MCP trong một session vừa mở lại khi máy chủ chưa kết nối), và nhận next() để hỏi các resolver sau nó; viết next() ?? mine nghĩa là "chỉ lấp chỗ trống" (runner.ts:796). Component trả về là component của pi-tui (chương pi-tui).
Cuối cùng là pi.events, một EventEmitter bọc lại: lỗi trong handler bị bắt và in ra console thay vì làm hỏng bên phát (event-bus.ts:12). Bus thuộc về resource loader nên sống qua các lần /reload; vì vậy runtime theo dõi các đăng ký của mình và gỡ chúng khi runtime bị vô hiệu, sau /reload hay khi thay phiên (loader.ts:196); không có cơ chế này, mỗi lần nạp lại sẽ để lại một listener mồ côi.
Context, UI và các lời gọi tới session#
Handler cần biết mình đang ở đâu: thư mục nào, mô hình nào, có hỏi được người dùng không. Nhưng cùng một extension chạy trong cả bốn chế độ (TUI, RPC, JSON, print), và phiên có thể bị thay giữa chừng (/new, /resume, /fork, /reload). Vì thế ctx không phải một ảnh chụp mà là một khung nhìn sống, mỗi lần đọc lại hỏi runner.
export type ExtensionMode = "tui" | "rpc" | "json" | "print";export interface ExtensionContext { /** UI methods for user interaction */ ui: ExtensionUIContext; /** Current run mode. Use "tui" to guard terminal-only UI such as custom components. */ mode: ExtensionMode; /** Whether dialog-capable UI is available (true in TUI and RPC modes) */ hasUI: boolean; /** Current working directory */ cwd: string; /** Session manager (read-only) */ sessionManager: ReadonlySessionManager; /** Model registry for API key resolution */ modelRegistry: ModelRegistry; /** Current model (may be undefined) */ model: Model<any> | undefined; /** Models scoped to this session (resolved from `--models` / * `enabledModels` settings against the available catalogue). Same set * the `/scoped-models` command shows. Empty when no scoping is * configured (all available models are usable). Read-only snapshot. */ scopedModels: readonly ScopedModel[]; /** Current thinking level, when provided by the session runtime. */ thinkingLevel?: ThinkingLevel; /** Whether the agent is idle (not streaming) */ isIdle(): boolean; /** Whether project-local trust is active for this context. */ isProjectTrusted(): boolean; /** The current abort signal, or undefined when the agent is not streaming. */ signal: AbortSignal | undefined; /** Abort the current agent operation */ abort(): void; /** Whether there are queued messages waiting */ hasPendingMessages(): boolean; /** Gracefully shutdown pi and exit. Available in all contexts. */ shutdown(): void; /** Get current context usage for the active model. */ getContextUsage(): ContextUsage | undefined; /** Trigger compaction without awaiting completion. */ compact(options?: CompactOptions): void; /** Get the current effective system prompt. */ getSystemPrompt(): string;}sessionManager là kiểu ReadonlySessionManager, chỉ gồm các hàm đọc (session-manager.ts:245); muốn ghi thì đi qua pi.appendEntry() và các hàm hành động khác. modelRegistry.streamSimple() là cách gọi mô hình lồng nhau không phụ thuộc provider./** * Create an ExtensionContext for use in event handlers and tool execution. * Context values are resolved at call time, so changes via bindCore/bindUI are reflected. */createContext(): ExtensionContext { const runner = this; const getModel = this.getModel; const getScopedModels = this.getScopedModels; return { get ui() { runner.assertActive(); return runner.uiContext; }, get mode() { runner.assertActive(); return runner.mode; }, get hasUI() { runner.assertActive(); return runner.hasUI(); }, get cwd() { runner.assertActive(); return runner.cwd; }, get sessionManager() { runner.assertActive(); return runner.sessionManager; }, get modelRegistry() { runner.assertActive(); return runner.modelRegistry; },withSession. Command context được dựng bằng Object.defineProperties thay vì spread, vì spread sẽ đọc các getter một lần và đóng băng giá trị cũ, bỏ qua kiểm tra stale (runner.ts:997).UI: một interface, bốn cách hiện thực#
export interface ExtensionUIContext { /** Show a selector and return the user's choice. */ select(title: string, options: string[], opts?: ExtensionUIDialogOptions): Promise<string | undefined>; /** Show a confirmation dialog. */ confirm(title: string, message: string, opts?: ExtensionUIDialogOptions): Promise<boolean>; /** Show a text input dialog. */ input(title: string, placeholder?: string, opts?: ExtensionUIDialogOptions): Promise<string | undefined>; /** Show a notification to the user. */ notify(message: string, type?: "info" | "warning" | "error"): void; /** Listen to raw terminal input (interactive mode only). Returns an unsubscribe function. */ onTerminalInput(handler: TerminalInputHandler): () => void; /** Set status text in the footer/status bar. Pass undefined to clear. */ setStatus(key: string, text: string | undefined): void; /** Set a widget to display above or below the editor. Accepts string array or component factory. */ setWidget(key: string, content: string[] | undefined, options?: ExtensionWidgetOptions): void; setWidget( key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined, options?: ExtensionWidgetOptions, ): void; /** Show a custom component with keyboard focus. */ custom<T>( factory: ( tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: T) => void, ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>, options?: { overlay?: boolean; /** Overlay positioning/sizing options. Can be static or a function for dynamic updates. */ overlayOptions?: OverlayOptions | (() => OverlayOptions); /** Called with the overlay handle after the overlay is shown. Use to control visibility. */ onHandle?: (handle: OverlayHandle) => void; }, ): Promise<T>;setFooter, setHeader, setTitle, setEditorText/getEditorText, editor(), setEditorComponent (thay hẳn trình soạn thảo, ví dụ modal-editor.ts), addAutocompleteProvider, và các hàm theme.Mỗi chế độ cấp một hiện thực riêng. Interactive mode cấp UI đầy đủ trên pi-tui. RPC mode biến các hộp thoại (select, confirm, input, editor) thành cặp extension_ui_request/extension_ui_response qua stdin/stdout, còn custom() trả undefined và các hàm thuần TUI là no-op (docs/rpc-extension-ui.md:5). Print và JSON không có UI: runner dùng một đối tượng mà confirm() trả false, select() trả undefined (runner.ts:331). Hai cờ phân biệt các trường hợp: ctx.hasUI đúng cho TUI và RPC (hộp thoại dùng được), ctx.mode === "tui" cho thứ cần terminal thật (docs/extensions.md:363). Một extension an toàn phải có nhánh cho trường hợp không có UI, như permission-gate.ts chặn luôn lệnh nguy hiểm khi không hỏi được ai.
Runner còn bọc các hộp thoại để phát ui_prompt_start và ui_prompt_end quanh hộp thoại ngoài cùng (runner.ts:577). Một extension thông báo trên desktop có thể nghe chúng để báo "Pi đang chờ bạn" mà không cần biết extension nào mở hộp thoại.
Command context và thay phiên an toàn#
export interface ExtensionCommandContext extends ExtensionContext { /** Get the current base system-prompt construction options. */ getSystemPromptOptions(): BuildSystemPromptOptions; /** Wait for the agent to finish streaming */ waitForIdle(): Promise<void>; /** Start a new session, optionally with initialization. */ newSession(options?: { parentSession?: string; setup?: (sessionManager: SessionManager) => Promise<void>; withSession?: (ctx: ReplacedSessionContext) => Promise<void>; }): Promise<{ cancelled: boolean }>; /** Fork from a specific entry, creating a new session file. */ fork( entryId: string, options?: { position?: "before" | "at"; withSession?: (ctx: ReplacedSessionContext) => Promise<void> }, ): Promise<{ cancelled: boolean }>; /** Navigate to a different point in the session tree. */ navigateTree( targetId: string, options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }, ): Promise<{ cancelled: boolean }>; /** Switch to a different session file. */ switchSession( sessionPath: string, options?: { withSession?: (ctx: ReplacedSessionContext) => Promise<void> }, ): Promise<{ cancelled: boolean }>; /** Reload extensions, skills, prompts, themes, and context files. */ reload(): Promise<void>;}export interface ReplacedSessionContext extends ExtensionCommandContext { sendMessage<T = unknown>( message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ): Promise<void>; sendUserMessage( content: string | (TextContent | ImageContent)[], options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean }, ): Promise<void>;}turn_end giữ lại command ctx từ trước để gọi waitForIdle(), nó chờ lần chạy kết thúc, trong khi lần chạy đang chờ chính handler đó.Mẫu dùng đúng: trước khi thay phiên chỉ giữ dữ liệu thuần (chuỗi, số), rồi làm mọi việc gắn với phiên mới trong withSession, với ctx mới được truyền vào. Ctx cũ sau đó ném lỗi. Với reload() không có withSession: mã sau await ctx.reload() vẫn thuộc runtime cũ và không được dùng lại trạng thái của nó (docs/extensions.md:145). Tool thì nhận ExtensionToolContext, tức ctx thường cộng tools và executeTool() để gọi tool khác qua cùng các hook (types.ts:383); đây là nền của codemode, thuộc chương Codemode.
Các hành động lên phiên#
Khi AgentSession bind runner, các stub được thay bằng hàm thật. Phần lớn chỉ là một dòng chuyển tiếp:
runner.bindCore( { sendMessage: (message, options) => { this.sendCustomMessage(message, options).catch((err) => { runner.emitError({ extensionPath: "<runtime>", event: "send_message", error: err instanceof Error ? err.message : String(err), }); }); }, sendUserMessage: (content, options) => { this.sendUserMessage(content, options).catch((err) => { runner.emitError({ extensionPath: "<runtime>", event: "send_user_message", error: err instanceof Error ? err.message : String(err), }); }); }, appendEntry: (customType, data) => { const entryId = this.sessionManager.appendCustomEntry(customType, data); const entry = this.sessionManager.getEntry(entryId); if (entry) { this._emit({ type: "entry_appended", entry }); } getActiveTools: () => this.getActiveToolNames(), getAllTools: () => this.getAllTools(), getSettings: () => this.settingsManager.getSettings(), setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames), refreshTools: () => this._refreshToolRegistry(), getCommands, setModel: async (model) => { if (!this._modelRuntime.hasConfiguredAuth(model.provider)) return false; await this.setModel(model); return true; },sendMessage và sendUserMessage là "bắn rồi quên" (fire-and-forget): lỗi không ném về extension mà được báo qua emitError. setModel trả false nếu provider chưa có credential.appendEntry(customType, data) ghi ngay một entry custom vào session. Entry này không bao giờ vào ngữ cảnh mô hình (session-manager.ts:117); nó tồn tại để extension cất trạng thái và tìm lại khi phiên mở lại. sendMessage() thì tạo một message role custom có vào ngữ cảnh (dưới dạng user sau convertToLlm), và cách nó được giao phụ thuộc trạng thái của agent:
async sendCustomMessage<T = unknown>( message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },): Promise<void> { if (options?.deliverAs === "nextTurn") { this._pendingNextTurnMessages.push(appMessage); } else if (this.isStreaming && options?.triggerTurn !== false) { if (options?.deliverAs === "followUp") { this.agent.followUp(appMessage); } else { this.agent.steer(appMessage); } } else if (options?.triggerTurn) { if (this._isEmittingAgentSettled) { this._deferredSettledActions.push(async () => await this.sendCustomMessage(message, options)); return; } await this._startRun({ messageType: "custom", message: appMessage }); } else if (this.isStreaming) { // Appending now would put the message between an assistant tool call and its // result, which providers that validate message order reject on replay. Defer // to the end of the turn. Nothing is emitted yet: message events must not // describe messages the session tree does not contain. this._pendingCustomMessages.push(appMessage); } else { this._appendCustomMessage(appMessage); }}triggerTurn, message được steer vào lần chạy hiện tại. Chỉ triggerTurn: false mới hoãn nó tới cuối lượt mà không xin thêm lượt nào.Tài liệu chốt bằng một bảng chọn nơi lưu trạng thái (docs/extensions.md:339):
| Trạng thái | Cất ở | Vì sao |
|---|---|---|
| Trạng thái của tool, phải đi theo nhánh đang hoạt động | details của kết quả tool | Fork hay /tree về một điểm cũ thì kết quả tool cũ chính là trạng thái lúc đó. |
| Dữ liệu bền, không cho mô hình thấy | pi.appendEntry() | Entry custom, bị bỏ qua khi dựng ngữ cảnh. |
| Nội dung cần cho mô hình thấy | pi.sendMessage() | Entry custom_message, vào ngữ cảnh. |
| Dữ liệu vượt ra ngoài một phiên | Lưu trữ ngoài | Tệp, cơ sở dữ liệu; Pi không quản lý. |
Và quy tắc đi kèm: dựng lại trạng thái từ ctx.sessionManager.getBranch() (đường từ gốc tới lá hiện tại) trong session_start, không phải từ mọi entry trong tệp, vì các nhánh bị bỏ là những lịch sử khác. Hai hành động còn lại: setActiveTools(names) đổi tập tool khai báo cho mô hình từ yêu cầu kế tiếp, bỏ qua tên chưa đăng ký hoặc hidden, và Pi ghi thay đổi đó vào transcript dưới dạng một message system chứa bản vá (docs/extensions.md:299; cơ chế ở chương AgentSession). exec(command, args, options) chạy một tiến trình con không qua shell, gom stdout/stderr, hỗ trợ timeout và signal (cả hai gửi SIGTERM), và không bao giờ reject: lỗi spawn trả về code: 1 (exec.ts:34).
Tool exposure, namespace và prepareLoadout#
Vấn đề: một vài máy chủ MCP có thể đem tới hàng trăm tool. Khai báo hết cho mô hình tốn token ở mọi yêu cầu, và mỗi lần tập tool đổi thì tiền tố prompt cache bị phá (Earendil, "Prompt Caching In Agents"). Từ bản 0.99.0, ngay trước 1.0 (CHANGELOG.md:441), Pi tách hai khái niệm: một tool có thể được khai báo cho mô hình, và có thể gọi được từ tool khác qua ctx.executeTool(). Trường exposure của ToolDefinition chọn tổ hợp:
/** * How the model reaches a tool. "Callable" means callable from other tools through * `ctx.executeTool()`, as the `codemode` tool does. * * - `direct`: declared to the model while active, and callable while active. * - `model-only`: declared to the model while active, never callable. Use it for orchestrating or * interactive tools. * - `codemode`: callable whenever registered. Not declared to the model unless explicitly * activated. Codemode tools list it in their description. * - `deferred`: like `codemode`, but codemode tools do not list it; tool search can find it. * - `hidden`: registered but unreachable. Activating it has no effect. * * `direct` and `model-only` tools are activated when they are registered; the others are not. * The active tool set (`getActiveTools`/`setActiveTools`) is the set declared to the model. */export type ToolExposure = "direct" | "model-only" | "codemode" | "deferred" | "hidden";exposure | Khai báo cho mô hình | Gọi được qua executeTool | Bật khi đăng ký |
|---|---|---|---|
direct (mặc định) | khi đang bật | khi đang bật | có |
model-only | khi đang bật | không bao giờ | có |
codemode | chỉ khi bật tường minh | luôn, và được tool codemode liệt kê | không |
deferred | chỉ khi bật tường minh | luôn, nhưng chỉ tool_search tìm ra | không |
hidden | không | không | không |
Tập "đang bật" (active) của getActiveTools/setActiveTools chính là tập khai báo cho mô hình. Tập gọi được thì tính riêng:
/** * Tools callable through `ctx.executeTool()`: the active `direct` tools and every registered * `codemode` or `deferred` tool. */private _getCallableTools(active: ReadonlySet<string> = new Set(this.getActiveToolNames())): AgentTool[] { return [...this._toolRegistry.values()].filter((tool) => { const exposure = this._getToolExposure(tool.name); return exposure === "codemode" || exposure === "deferred" || (exposure === "direct" && active.has(tool.name)); });}hidden tồn tại vì tool không gỡ đăng ký được: muốn rút một tool thì đăng ký lại cùng tên với exposure: "hidden" (docs/extensions.md:273). Ba trường khác bổ sung siêu dữ liệu. namespace gom các tool liên quan, như các tool của một máy chủ MCP, để tool codemode liệt kê chúng dưới một tiêu đề, còn instructions dài hơn chỉ được trả khi script hỏi describeNamespace(). annotations mang các gợi ý kiểu MCP (readOnlyHint, destructiveHint, idempotentHint, openWorldHint); chúng không được kiểm chứng, nhưng một extension cấp quyền có thể dựa vào chúng để quyết định lời gọi nào cần xác nhận, và tài liệu có sẵn đoạn handler tool_call làm đúng như vậy (docs/extensions.md:279). reachesTools(input) cho biết một lời gọi của tool điều phối có thể chạm tới những tool nào, để extension đăng ký tool muộn (MCP đang kết nối) kịp hoàn tất trong handler tool_call trước khi lời gọi chạy.
Mảnh cuối là prepareLoadout: một tool điều phối (như codemode) được xem toàn bộ loadout mỗi khi tập tool đổi, và trả lại mô tả mới cho các tool được khai báo cùng danh sách hiddenDeclarations, những tool vẫn bật và gọi được nhưng không được gửi khai báo trong yêu cầu:
/** The tools of a session as {@link ToolDefinition.prepareLoadout} sees them. */export interface ToolLoadout { /** Tools declared to the model (the active tools), in order, with their original descriptions. */ readonly declared: readonly AgentTool[]; /** Tools callable through `ctx.executeTool()`. */ readonly callable: readonly AgentTool[]; /** Every registered tool. */ readonly registered: readonly AgentTool[]; getExposure(name: string): ToolExposure; getNamespace(name: string): ToolNamespace | undefined; /** A tool's `promptGuidelines`. Hidden declarations leave them out of the system prompt. */ getPromptGuidelines(name: string): readonly string[];}/** Changes {@link ToolDefinition.prepareLoadout} makes to what the model sees. */export interface ToolLoadoutChanges { /** Model-facing descriptions of declared tools, by tool name. */ descriptions?: Readonly<Record<string, string>>; /** * Declared tools whose declarations requests leave out. They stay active and callable, and the * transcript still declares them, so the active set survives `/tree` and resume. */ hiddenDeclarations?: readonly string[];}AgentSession._applyToolLoadout() gọi hook này, bắt lỗi của nó thành lỗi extension, rồi đặt agent.state.tools bằng các khai báo đã đổi mô tả (agent-session.ts:1595). Tài liệu nhấn mạnh codemode chỉ dùng hook này cùng exposure, reachesTools và executeTool, nên một extension khác có thể làm lại hành vi đó dưới một tên khác (docs/extensions.md:293). Phần ruột thuộc chương Codemode.Danh mục sự kiện và cách runner phát chúng#
Union ExtensionEvent có 32 nhánh, một số nhánh là union con (mười sự kiện session, chín biến thể tool_call) (types.ts:1404). Đọc từng cái một không giúp nhiều; dễ nhớ hơn là đặt chúng lên dòng thời gian của một prompt. Ví dụ minh hoạ dưới đây theo đúng thứ tự trong code khi người dùng gõ một câu và mô hình gọi một tool rồi trả lời:
Ví dụ minh hoạ: các sự kiện extension quanh một prompt người dùng gõ "sửa test hỏng" ⏎ → AgentSession.prompt() ├─ (/tên của extension? chạy handler lệnh rồi dừng) ├─ input chuỗi biến đổi; "handled" nuốt input ├─ (mở rộng /skill:tên, prompt template) └─ _startRun() ├─ (compaction trước prompt? session_before_compact → session_compact) ├─ before_agent_start sửa systemPromptOptions, thêm custom message ├─ agent_start │ ┌ turn_start │ │ message_start/end (system patch, user, custom) ← ghi entry │ │ context → context_with_system mỗi yêu cầu, trên bản sao │ │ before_provider_headers → before_provider_request │ │ after_provider_response │ │ provider_stream_event*, message_start, message_update*, message_end (assistant) │ │ mỗi lời gọi tool: │ │ tool_execution_start → tool_call → [execute: tool_execution_update*] │ │ → tool_result → tool_execution_end → message_start/end (toolResult) │ └ turn_end (boundary: thêm entry, xin chạy tiếp) │ ┌ turn_start … lượt 2: mô hình trả lời bằng văn bản … │ └ turn_end ├─ agent_end chưa phải lúc rảnh: còn retry, compaction, hàng đợi ├─ agent_before_settle (boundary cuối) └─ agent_settled phiên thực sự rảnh
Ngoài dòng thời gian đó còn các sự kiện của vòng đời phiên và vài sự kiện lẻ:
| Nhóm | Sự kiện | Kết quả handler |
|---|---|---|
| Khởi động | project_trust, session_start, resources_discover, mcp_servers_change | project_trust: người đầu tiên trả yes/no thắng. resources_discover (sau session_start): gom thêm đường dẫn skill, prompt, theme. |
| Thay phiên, cây, compaction | session_before_switch, session_before_fork, session_before_tree, session_before_compact; session_tree, session_compact, session_compact_failed, session_shutdown, session_info_changed | Các sự kiện before là cổng: cancel: true dừng thao tác; session_before_compact còn cấp được bản tóm tắt tự làm (chương Compaction). |
| Đầu vào | input, user_bash | input: chuỗi biến đổi. user_bash (lệnh ! của người dùng): trả operations hoặc result thì thay việc thực thi cục bộ; handler lỗi thì lệnh bị chặn. |
| Mô hình | model_select, thinking_level_select | Thông báo. |
| UI, cache | ui_prompt_start, ui_prompt_end, cache_warming_decision | cache_warming_decision: handler cuối cùng trả action thắng. |
Thứ tự, snapshot và một ctx cho mỗi lần phát#
Mọi hàm phát của runner bắt đầu giống nhau: tạo một ctx, rồi duyệt một bản chụp các handler:
function snapshotEventHandlers(extensions: Extension[], event: ExtensionEvent["type"]) { return extensions.map((ext) => ({ ext, handlers: ext.handlers.get(event)?.slice() ?? [] }));}Bản chụp (slice()) nghĩa là một handler gọi hàm huỷ đăng ký trong lúc đang phát không làm hỏng vòng lặp; thay đổi chỉ có hiệu lực từ lần phát sau (docs/extensions.md:197). Các handler được await lần lượt, không song song, nên handler chậm của một sự kiện luồng như message_update làm chậm cả luồng.
Cô lập lỗi: mặc định báo rồi đi tiếp#
async emit<TEvent extends RunnerEmitEvent>(event: TEvent): Promise<RunnerEmitResult<TEvent>> { const ctx = this.createContext(); let result: SessionBeforeEventResult | undefined; for (const { ext, handlers } of snapshotEventHandlers(this.extensions, event.type)) { for (const handler of handlers) { try { const handlerResult = await handler(event, ctx); if (this.isSessionBeforeEvent(event) && handlerResult) { result = handlerResult as SessionBeforeEventResult; if (result.cancel) { return result as RunnerEmitResult<TEvent>; } } } catch (err) { const message = err instanceof Error ? err.message : String(err); const stack = err instanceof Error ? err.stack : undefined; this.emitError({ extensionPath: ext.path, event: event.type, error: message, stack, }); } } } return result as RunnerEmitResult<TEvent>;}emitError chuyển lỗi cho listener mà chế độ chạy đăng ký; interactive mode hiện nó trong transcript kèm đường dẫn extension (interactive-mode.ts:2036). Với các sự kiện session_before_*, kết quả khác undefined cuối cùng được trả về, trừ khi có handler huỷ trước.Có ba ngoại lệ có chủ đích, tất cả theo hướng an toàn. emitToolCall() không bắt lỗi (mục lần theo): handler cấp quyền mà hỏng thì tool không chạy. emitUserBash() báo lỗi rồi ném tiếp, nên lệnh ! bị chặn thay vì lặng lẽ chạy cục bộ (runner.ts:1269). emitMessageEnd() từ chối một message thay thế đổi role (runner.ts:1163). Tài liệu tóm lại thành một nguyên tắc cho người viết: handler cổng phải đóng (fail closed) khi không quyết định được, handler biến đổi phải giữ nguyên các trường nó không sở hữu (docs/extensions.md:214).
Biến đổi ngữ cảnh: context và context_with_system#
Ngay trước mỗi yêu cầu, vòng lặp agent gọi transformContext (agent-loop.ts:397), mà sdk.ts nối vào runner.emitContext() (sdk.ts:428). Vấn đề cần giải: ở v1.4.0 system prompt và khai báo tool nằm trong transcript dưới dạng message system (chương AgentSession). Một extension cắt bớt lịch sử, chẳng hạn chỉ giữ hai mươi message cuối, sẽ vô tình cắt luôn system prompt.
/** * Run the request-time transforms in two phases. `context` handlers see the conversation * only and Pi restores the prompt and tool state after each; `context_with_system` * handlers then see the full transcript and their output is used as returned. */async emitContext(messages: AgentMessage[]): Promise<AgentMessage[]> { const ctx = this.createContext(); let currentMessages = structuredClone(messages); for (const { ext, handlers } of snapshotEventHandlers(this.extensions, "context")) { for (const handler of handlers) { try { const visibleMessages = currentMessages.filter((message) => message.role !== "system"); const visibleSnapshot = visibleMessages.slice(); const event: ContextEvent = { type: "context", messages: visibleMessages }; const handlerResult = (await handler(event, ctx)) as ContextEventResult | undefined; // Handlers may return a new list or edit event.messages in place. const returned = handlerResult?.messages ?? (sameMessages(visibleMessages, visibleSnapshot) ? undefined : visibleMessages); if (!returned) continue; currentMessages = restoreSystemMessages(currentMessages, visibleSnapshot, returned); for (const { ext, handlers } of snapshotEventHandlers(this.extensions, "context_with_system")) { for (const handler of handlers) { try { const hadLeadingSystemMessage = currentMessages[0]?.role === "system"; const event: ContextWithSystemEvent = { type: "context_with_system", messages: currentMessages }; const handlerResult = (await handler(event, ctx)) as ContextEventResult | undefined; currentMessages = handlerResult?.messages ?? currentMessages; // Providers read the prompt and initial tools from the leading system message. // Losing it is never intended; report it but honor the handler's output. if (hadLeadingSystemMessage && currentMessages[0]?.role !== "system") { this.emitError({ extensionPath: ext.path, event: "context_with_system", error: "Handler removed the leading system message; the request has no prompt or initial tool declarations. Keep it at index 0 or replace a dropped prefix with getCurrentSystemMessage().", }); }structuredClone ở dòng 1307 cho phép handler sửa message thoải mái mà không chạm vào trạng thái agent hay tệp session: biến đổi chỉ sống trong một yêu cầu./** * Re-attach the prompt and tool state after a `context` handler. Handlers only see the * conversation; the system messages belong to Pi. An unchanged conversation keeps every * system message in place, so models with mid-conversation support keep their cached * prefix. A changed one gets the replayed prompt sections and tool declarations as one * leading system message, so pruning, windowing, or slicing from a compaction summary * cannot drop them. */function restoreSystemMessages( current: AgentMessage[], visible: AgentMessage[], returned: AgentMessage[],): AgentMessage[] { if (sameMessages(returned, visible)) return current; const head = getCurrentSystemMessage(current); return head ? [head, ...returned] : returned;}Ví dụ plan-mode/ dùng context đúng kiểu này: khi đã thoát plan mode, nó lọc bỏ các message [PLAN MODE ACTIVE] cũ để chúng không còn ràng buộc mô hình (mục ví dụ). context_with_system dành cho trường hợp hiếm khi handler phải sở hữu cả transcript; bỏ mất message system ở chỉ số 0 bị báo lỗi nhưng vẫn được tôn trọng.
Boundary: turn_end và agent_before_settle#
Hai sự kiện này cho extension ghi thêm entry vào session và xin thêm đúng một yêu cầu mô hình, ở hai điểm mà transcript chắc chắn hợp lệ: cuối một lượt (sau khi mọi kết quả tool đã được ghi) và ngay trước khi phiên rảnh. Mỗi handler thấy đề xuất của các handler trước nó, cùng một bản xem trước ngữ cảnh dựng bằng cách áp các entry nháp lên một bản sao trong bộ nhớ của nhánh:
async emitBoundary( baseEvent: BoundaryBaseEvent, buildContext: (entries: SessionBoundaryDraft[]) => BoundaryContextPreview | Promise<BoundaryContextPreview>,): Promise<BoundaryDispatchResult> { const ctx = this.createContext(); let entries: SessionBoundaryDraft[] = []; let shouldContinue = false; let context = await buildContext(entries); let valid = true; for (const { ext, handlers } of snapshotEventHandlers(this.extensions, baseEvent.type)) { for (const handler of handlers) { const event = { ...baseEvent, entries, continue: shouldContinue, context, } as TurnEndEvent | AgentBeforeSettleEvent; try { const handlerResult = (await handler(event, ctx)) as BoundaryResult | undefined; if (handlerResult?.entries !== undefined) entries = handlerResult.entries; if (handlerResult?.continue !== undefined) shouldContinue = handlerResult.continue; } catch (err) { try { context = await buildContext(entries); valid = true; } catch (err) { valid = false; this.emitError({ extensionPath: ext.path, event: baseEvent.type, error: `Invalid boundary entries: ${err instanceof Error ? err.message : String(err)}`, stack: err instanceof Error ? err.stack : undefined, }); } } } return valid ? { entries, continue: shouldContinue, context, valid: true } : { entries: [], continue: false, context, valid: false };}AgentSession._buildBoundaryContext() dựng trên một SessionManager.inMemory chứa nhánh hiện tại cộng các nháp (agent-session.ts:977); nháp hỏng (ví dụ context_edit trỏ vào entry không tồn tại) làm cả lô bị bỏ và không chạy tiếp.Các nháp hợp lệ thuộc bốn loại: custom, custom_message, context_edit, compaction (types.ts:972). continue: true chỉ được chấp nhận khi ngữ cảnh chạy tiếp được, tức message cuối không phải của assistant hoặc còn message đang chờ; nếu không, Pi báo "requested continuation without runnable model context" thay vì gửi một yêu cầu vô nghĩa. Tài liệu cảnh báo phải đặt điều kiện cho việc xin chạy tiếp, vì một handler luôn trả continue: true sẽ tạo vòng lặp (docs/extensions.md:230).
Lần theo một lời gọi tool bị sửa và bị chặn#
Extension cấp quyền là ví dụ kinh điển của sự kiện tool_call. Repo có sẵn một bản tối giản: hỏi người dùng trước các lệnh bash khớp mẫu nguy hiểm, và chặn luôn khi không có UI để hỏi.
export default function (pi: ExtensionAPI) { const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i]; pi.on("tool_call", async (event, ctx) => { if (event.toolName !== "bash") return undefined; const command = event.input.command as string; const isDangerous = dangerousPatterns.some((p) => p.test(command)); if (isDangerous) { if (!ctx.hasUI) { // In non-interactive mode, block by default return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" }; } const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]); if (choice !== "Yes") { return { block: true, reason: "Blocked by user" }; } } return undefined; });}Để thấy cả hai khả năng của tool_call (chặn và sửa tham số) cùng với tool_result, kịch bản dưới đây dùng một extension viết tay. Nó không có trong repo; mọi hàm nó gọi thì có.
Ví dụ minh hoạ: ~/.pi/agent/extensions/bash-guard.ts (viết cho chương này)
import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event) => {
if (!isToolCallEventType("bash", event)) return; // thu hẹp kiểu: input là BashToolInput
if (/\brm\s+-rf\b/.test(event.input.command)) {
return { block: true, reason: "bash-guard: rm -rf bị chặn" };
}
event.input.timeout ??= 120; // sửa tại chỗ: mặc định 120 giây
});
pi.on("tool_result", async (event) => {
if (event.toolName !== "bash" || event.isError) return;
const text = event.content.map((c) => (c.type === "text" ? c.text : "")).join("");
if (!/AKIA[0-9A-Z]{16}/.test(text)) return;
return { content: [{ type: "text", text: text.replace(/AKIA[0-9A-Z]{16}/g, "[đã che]") }] };
});
}bash dựng sẵn có tham số timeout tính bằng giây, mặc định không có (bash.ts:40). isToolCallEventType cần thiết vì so sánh event.toolName === "bash" không thu hẹp được kiểu (types.ts:1385).Kịch bản: mô hình trả về một message assistant với hai lời gọi tool, call_1 là bash {"command":"npm test"} và call_2 là bash {"command":"rm -rf dist"}, stopReason: "toolUse". Message được ghi vào session ở message_end. Chế độ thực thi mặc định là song song (agent.ts:261) và bash không đòi chạy tuần tự, nên vòng lặp đi vào executeToolCallsParallel().
Bước 1: chuẩn bị tuần tự, thực thi song song#
async function executeToolCallsParallel( currentContext: AgentContext, assistantMessage: AssistantMessage, toolCalls: AgentToolCall[], config: AgentLoopConfig, signal: AbortSignal | undefined, emit: AgentEventSink,): Promise<ExecutedToolCallBatch> { const finalizedCalls: FinalizedToolCallEntry[] = []; for (const toolCall of toolCalls) { await emit({ type: "tool_execution_start", toolCallId: toolCall.id, toolName: toolCall.name, args: toolCall.arguments, }); const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal); if (preparation.kind === "immediate") { const finalized = { toolCall, result: preparation.result, isError: preparation.isError, } satisfies FinalizedToolCallOutcome; await emitToolExecutionEnd(finalized, emit); finalizedCalls.push(finalized); if (signal?.aborted) { break; } continue; } finalizedCalls.push(async () => { if (signal?.aborted) { const finalized = { toolCall, result: createErrorToolResult("Operation aborted"), isError: true, } satisfies FinalizedToolCallOutcome; await emitToolExecutionEnd(finalized, emit); return finalized; } const executed = await executePreparedToolCall(preparation, signal, emitToolExecutionUpdate(toolCall, emit)); const finalized = await finalizeExecutedToolCall( currentContext, assistantMessage, preparation, executed, config, signal, ); await emitToolExecutionEnd(finalized, emit); return finalized; }); const orderedFinalizedCalls = await Promise.all( finalizedCalls.map((entry) => (typeof entry === "function" ? entry() : Promise.resolve(entry))), ); const messages: ToolResultMessage[] = []; for (const finalized of orderedFinalizedCalls) { const toolResultMessage = createToolResultMessage(finalized); await emitToolResultMessage(toolResultMessage, emit); messages.push(toolResultMessage); }for chuẩn bị từng lời gọi theo thứ tự và chờ xong mới sang lời gọi kế; chỉ phần thực thi được gói vào closure để Promise.all chạy cùng lúc. Kết quả tool được phát lại theo thứ tự gốc của các lời gọi.Điều này có hệ quả thực tế cho extension cấp quyền: các hộp thoại xác nhận của một lô hiện ra lần lượt, trước khi bất kỳ lời gọi nào trong lô chạy. Với call_1: phát tool_execution_start với args là tham số gốc của mô hình, rồi gọi prepareToolCall().
Bước 2: kiểm tra schema, rồi hook#
const tool = tools.find((t) => t.name === toolCall.name);if (!tool) { return { kind: "immediate", result: createErrorToolResult(`Tool ${toolCall.name} not found`), isError: true, };}try { const preparedToolCall = prepareToolCallArguments(tool, toolCall); const validatedArgs = validateToolArguments(tool, preparedToolCall); if (config.beforeToolCall) { const beforeResult = await config.beforeToolCall( { assistantMessage, toolCall, args: validatedArgs, context: currentContext, }, signal, ); if (signal?.aborted) { return { kind: "immediate", result: createErrorToolResult("Operation aborted"), isError: true, }; } if (beforeResult?.block) { const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked"); if (beforeResult.terminate === true) { result.terminate = true; } return { kind: "immediate", result, isError: true, }; } } return { kind: "prepared", toolCall, tool, args: validatedArgs, };} catch (error) { return { kind: "immediate", result: createErrorToolResult(error instanceof Error ? error.message : String(error)), isError: true, };}currentContext.tools, tức tập tool đang bật (active); tool codemode/deferred chưa bật thì mô hình gọi trực tiếp sẽ nhận "Tool X not found".Tham số được kiểm tra trước khi hook chạy, và validateToolArguments() làm việc trên một bản sao:
export function validateToolArguments(tool: Tool, toolCall: ToolCall): any { if (toolCall.argumentsError) { throw new Error(`Invalid arguments for tool "${toolCall.name}": ${toolCall.argumentsError}`); } const args = structuredClone(toolCall.arguments); normalizeOptionalNulls(args, tool.parameters as JsonSchemaObject); Value.Convert(tool.parameters, args);Hook beforeToolCall của Agent do AgentSession cài một lần trong constructor:
private _installAgentToolHooks(): void { this.agent.beforeToolCall = (context) => this._beforeToolCall(context); this.agent.afterToolCall = (context) => this._afterToolCall(context);}/** `tool_call` handlers. `parentToolCallId` is set for calls another tool made. */private async _beforeToolCall( { toolCall, args }: BeforeToolCallContext, parentToolCallId?: string,): Promise<BeforeToolCallResult | undefined> { const runner = this._extensionRunner; if (!runner.hasHandlers("tool_call")) { return undefined; } try { return await runner.emitToolCall({ type: "tool_call", toolName: toolCall.name, toolCallId: toolCall.id, ...(parentToolCallId ? { parentToolCallId } : {}), input: args as Record<string, unknown>, }); } catch (err) { if (err instanceof Error) { throw err; } throw new Error(`Extension failed, blocking execution: ${String(err)}`); }}this._extensionRunner lúc chạy, nên sau /reload runner mới tự động được dùng. Lời gọi lồng qua ctx.executeTool() đi qua đúng hàm này với parentToolCallId (agent-session.ts:742), nên bash-guard cũng canh được các lời gọi do script codemode phát ra.async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> { const ctx = this.createContext(); let result: ToolCallEventResult | undefined; for (const { handlers } of snapshotEventHandlers(this.extensions, "tool_call")) { for (const handler of handlers) { const handlerResult = await handler(event, ctx); if (handlerResult) { result = handlerResult as ToolCallEventResult; if (result.block) { return result; } } } } return result;}Với call_1, bash-guard không chặn mà gán event.input.timeout = 120. Vì event.input là chính đối tượng validatedArgs, và mọi handler nhận cùng một event, các handler sau thấy timeout mới, và đối tượng đó được đặt vào args của lời gọi đã chuẩn bị (dòng 778). Không có bước kiểm tra schema thứ hai (types.ts:1250): một handler gán timeout = -1 thì chính tool bash sẽ ném "Invalid timeout" lúc thực thi.
Với call_2, handler trả { block: true, reason }. emitToolCall() trả ngay, không gọi các handler sau, và vòng lặp biến lý do thành một kết quả lỗi (dòng 755 đến 756): content là văn bản "bash-guard: rm -rf bị chặn", details: {}. Kết quả "immediate" này được phát tool_execution_end ngay (dòng 621), trước cả khi npm test bắt đầu.
Bước 3: thực thi, rồi tool_result#
Promise.all gọi closure của call_1. executePreparedToolCall() gọi tool.execute(id, args, signal, onUpdate) (agent-loop.ts:831); vì tool đã được bọc bởi wrapToolDefinition, định nghĩa bash nhận thêm ctx từ runner.createToolContext(). Lệnh chạy với timeout: 120; output được gửi dần qua các tool_execution_update, có giới hạn tần suất (bash.ts:303). Xong thì finalizeExecutedToolCall() gọi hook afterToolCall (agent-loop.ts:869):
private async _afterToolCall( { toolCall, args, result, isError }: AfterToolCallContext, parentToolCallId?: string,): Promise<AfterToolCallResult | undefined> { const runner = this._extensionRunner; const hookResult = runner.hasHandlers("tool_result") ? await runner.emitToolResult({ type: "tool_result", toolName: toolCall.name, toolCallId: toolCall.id, ...(parentToolCallId ? { parentToolCallId } : {}), input: args as Record<string, unknown>, content: result.content, details: result.details, ...(result.structuredContent === undefined ? {} : { structuredContent: result.structuredContent }), isError, usage: result.usage, }) : undefined; const content = hookResult?.content ?? result.content ?? []; // Runs after the extension hook so images injected or replaced by extensions are normalized too. const resizeOptions = this._limitsModel()?.inputLimits?.images?.resize; const normalizedContent = await normalizeToolResultImages(content, { autoResizeImages: this.settingsManager.getImageAutoResize(), ...(resizeOptions ? { resizeOptions } : {}), }); if (!hookResult && normalizedContent === content) { return undefined; } // The hook result already dropped structured content that replaced content no longer matches. return { content: normalizedContent, details: hookResult?.details, structuredContent: hookResult ? hookResult.structuredContent : result.structuredContent, isError: hookResult?.isError ?? isError, usage: hookResult?.usage, };emitToolResult() nối chuỗi: mỗi handler thấy bản đã sửa bởi handler trước, và một handler thay content mà không trả structuredContent sẽ làm mất structuredContent, vì dữ liệu cấu trúc có thể không còn khớp với văn bản (runner.ts:1190). Output của npm test có một khoá dạng AKIA..., nên bash-guard trả content đã che. call_2 thì không bao giờ tới đây: kết quả immediate (bị chặn, không tìm thấy tool, sai schema) không đi qua afterToolCall, nên không có sự kiện tool_result nào cho nó.
Bước 4: ghi vào session#
Khi cả lô xong, vòng lặp tạo hai message toolResult theo thứ tự call_1, call_2 và phát message_start/message_end cho từng cái (dòng 660 đến 663). AgentSession phát message_end cho extension trước rồi mới ghi entry (agent-session.ts:1142). Sau đó finishTurn phát boundary turn_end, và lượt kế tiếp gửi cho mô hình cả hai kết quả. Ví dụ minh hoạ: ba entry message liên quan trong tệp session (đã lược các trường không liên quan):
Ví dụ minh hoạ: ba dòng JSONL sau lượt này (rút gọn)
{"type":"message","id":"a1","parentId":"u0","message":{"role":"assistant","stopReason":"toolUse",
"content":[{"type":"toolCall","id":"call_1","name":"bash","arguments":{"command":"npm test"}},
{"type":"toolCall","id":"call_2","name":"bash","arguments":{"command":"rm -rf dist"}}]}}
{"type":"message","id":"r1","parentId":"a1","message":{"role":"toolResult","toolCallId":"call_1",
"toolName":"bash","isError":false,"content":[{"type":"text","text":"... key=[đã che] ... 12 passed"}]}}
{"type":"message","id":"r2","parentId":"r1","message":{"role":"toolResult","toolCallId":"call_2",
"toolName":"bash","isError":true,"details":{},"content":[{"type":"text","text":"bash-guard: rm -rf bị chặn"}]}}call_1 trong message assistant vẫn ghi {"command":"npm test"}: việc sửa tham số chỉ tác động lên bản sao đem đi thực thi. Định dạng đầy đủ của entry ở chương Session.Dòng thời gian của các sự kiện extension trong kịch bản:
- tool_execution_start(call_1)
argslà tham số gốc, không cótimeout. - tool_call(call_1)bash-guard gán
timeout = 120tại chỗ. - tool_execution_start(call_2)Vẫn trong vòng chuẩn bị tuần tự.
- tool_call(call_2)Trả
block; các handlertool_callsau không chạy. - tool_execution_end(call_2)
isError: true, phát ngay. - tool_execution_update(call_1)*
npm testđang chạy. - tool_result(call_1)bash-guard che khoá trong output.
- tool_execution_end(call_1)Kèm
durationMs. - message_end(toolResult call_1), message_end(toolResult call_2)Thứ tự gốc; mỗi cái thành một entry.
- turn_endBoundary của lượt.
Chú ý thứ tự tool_execution_end (call_2 trước call_1) khác thứ tự message kết quả (call_1 trước call_2). Một extension đếm hay vẽ theo tool_execution_* phải ghép theo toolCallId, không theo thứ tự đến; tài liệu cũng dặn không giả định lời gọi anh em đã tồn tại khi một sự kiện tool chạy (docs/extensions.md:236).
Ba ví dụ thật: plan mode, subagent, todo#
Thư mục examples/extensions/ có hơn bảy mươi ví dụ, được tài liệu gọi là "the implementation reference" (docs/extensions.md:386; danh mục ở packages/coding-agent/examples/extensions/README.md). Ba ví dụ dưới đây đáng đọc nhất vì chúng dựng lại đúng ba tính năng mà bài viết gốc nói Pi không có, và mỗi cái tập trung vào một phần khác của API.
plan-mode/: chế độ chỉ đọc ghép từ sáu sự kiện#
Tính năng: /plan (hoặc Ctrl+Alt+P, hoặc cờ --plan) bật chế độ chỉ đọc; mô hình viết kế hoạch đánh số dưới tiêu đề "Plan:"; người dùng chọn thực thi; trong lúc thực thi, mô hình đánh dấu bước xong bằng [DONE:n] và một widget hiện tiến độ (packages/coding-agent/examples/extensions/plan-mode/README.md). Lõi Pi không chứa logic plan mode nào; mỗi mảnh là một điểm nối của API:
function enablePlanModeTools(): void { if (toolsBeforePlanMode === undefined) { toolsBeforePlanMode = pi.getActiveTools(); } pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));}function restoreNormalModeTools(): void { pi.setActiveTools(toolsBeforePlanMode ?? getNormalModeTools(pi.getActiveTools())); toolsBeforePlanMode = undefined;}function persistState(): void { pi.appendEntry("plan-mode", { enabled: planModeEnabled, todos: todoItems, executing: executionMode, toolsBeforePlanMode, });}// Block destructive bash commands in plan modepi.on("tool_call", async (event) => { if (!planModeEnabled || event.toolName !== "bash") return; const command = event.input.command as string; if (!isSafeCommand(command)) { return { block: true, reason: `Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: ${command}`, }; }});isSafeCommand() đòi lệnh vừa khớp một mẫu an toàn (bắt đầu bằng cat, grep, git status...) vừa không khớp mẫu phá huỷ nào, kể cả chuyển hướng > (utils.ts:97). Đây là danh sách cho phép dựa trên biểu thức chính quy, không phải hộp cát: find . -name "*.tmp" -delete khớp mẫu an toàn find và không chứa từ khoá phá huỷ nào trong danh sách, nên vẫn lọt qua. pi.on("before_agent_start", async (event) => { if (event.messageType !== "user") return; if (planModeEnabled) { return { message: { customType: "plan-mode-context", content: `[PLAN MODE ACTIVE]You are in plan mode - a read-only exploration mode for safe code analysis.Do NOT attempt to make changes - just describe what you would do.`, display: false, }, }; }display: false: message vào ngữ cảnh mô hình nhưng không hiện trong transcript. Lần chạy do custom message khởi động (messageType: "custom") được bỏ qua để chỉ dẫn không bị lặp.Ba mảnh còn lại: handler context lọc bỏ các message [PLAN MODE ACTIVE] cũ khi đã thoát plan mode (index.ts:176); agent_end trích các bước từ câu trả lời, hỏi bằng ctx.ui.select(), rồi bắt đầu thực thi bằng pi.sendMessage(..., { triggerTurn: true, deliverAs: "followUp" }) (index.ts:266); turn_end đếm [DONE:n] sau mỗi lượt và cất lại trạng thái (index.ts:254). Khi phiên mở lại, session_start tìm entry plan-mode cuối cùng và quét các message assistant sau mốc plan-mode-execute để dựng lại tiến độ (index.ts:343).
So với lời phê bình của Mario về plan mode của Claude Code ("I need observability for planning"), cách làm này để kế hoạch là văn bản assistant bình thường trong transcript; extension chỉ hạn chế tool và theo dõi tiến độ, không giấu bước nào.
subagent/: sub-agent là một tiến trình pi khác#
Bài viết gốc gợi ý: "If you need pi to spawn itself, just ask it to run itself via bash." Ví dụ subagent/ biến lời gợi ý đó thành một tool có cấu trúc. Agent được định nghĩa bằng tệp Markdown có frontmatter name, description, tools, model, phần thân là system prompt bổ sung, đặt trong ~/.pi/agent/agents/ hoặc .pi/agents/ (agents.ts:62). Tool subagent có ba chế độ: một tác vụ, song song (tối đa 8, chạy 4 cùng lúc), và chuỗi với chỗ giữ {previous}. Phần lõi là dòng lệnh:
const args: string[] = ["--mode", "json", "-p", "--no-session"];const inheritsDispatchConfig = !agent.model;const model = agent.model ?? dispatchDefaults.model;if (model) args.push("--model", model);if (inheritsDispatchConfig && dispatchDefaults.thinkingLevel) { args.push("--thinking", dispatchDefaults.thinkingLevel);}if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(",")); args.push("--append-system-prompt", tmpPromptPath); } args.push(`Task: ${task}`); let wasAborted = false; const exitCode = await new Promise<number>((resolve) => { const invocation = getPiInvocation(args); const proc = spawn(invocation.command, invocation.args, { cwd: cwd ?? defaultCwd, shell: false, stdio: ["ignore", "pipe", "pipe"], });model trong định nghĩa thì agent con kế thừa mô hình và mức suy nghĩ của phiên cha (lấy từ ctx.model, ctx.thinkingLevel). getPiInvocation() tìm lại đúng binary hoặc script đang chạy để tiến trình con cùng phiên bản (index.ts:247).Tiến trình con chạy ở JSON mode (chương các chế độ), nên stdout là một dòng JSON cho mỗi sự kiện. Extension đọc từng dòng, giữ các message_end, cộng dồn usage, và gọi onUpdate để TUI của phiên cha vẽ tiến trình của con ngay khi nó diễn ra (index.ts:351). Đây là câu trả lời cho lời phê bình "zero visibility": mọi lời gọi tool của agent con hiện trong kết quả tool của cha. Ngữ cảnh thì cô lập thật sự, vì đó là một tiến trình khác với --no-session. Huỷ ở phiên cha gửi SIGTERM cho tiến trình con. Về an toàn, agent của dự án (.pi/agents) chỉ được dùng khi tham số agentScope là "project" hoặc "both", và nếu dự án chưa được tin cậy thì còn phải qua một ctx.ui.confirm() (index.ts:518).
todo.ts: trạng thái đi theo nhánh#
Ví dụ thứ ba nhỏ nhưng chứa bài học quan trọng nhất về trạng thái. Tool todo giữ danh sách trong closure của factory, và mỗi kết quả trả về toàn bộ danh sách trong details:
case "add": { if (!params.text) { return { content: [{ type: "text", text: "Error: text required for add" }], details: { action: "add", todos: [...todos], nextId, error: "text required" } as TodoDetails, }; } const newTodo: Todo = { id: nextId++, text: params.text, done: false }; todos.push(newTodo); return { content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }], details: { action: "add", todos: [...todos], nextId } as TodoDetails, };}export default function (pi: ExtensionAPI) { // In-memory state (reconstructed from session on load) let todos: Todo[] = []; let nextId = 1; /** * Reconstruct state from session entries. * Scans tool results for this tool and applies them in order. */ const reconstructState = (ctx: ExtensionContext) => { todos = []; nextId = 1; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type !== "message") continue; const msg = entry.message; if (msg.role !== "toolResult" || msg.toolName !== "todo") continue; const details = msg.details as TodoDetails | undefined; if (details) { todos = details.todos; nextId = details.nextId; } } }; // Reconstruct state on session events pi.on("session_start", async (_event, ctx) => reconstructState(ctx)); pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));session_tree phát khi người dùng nhảy sang một điểm khác của cây bằng /tree.Ví dụ minh hoạ: mô hình gọi todo add "viết test" (kết quả r1, details.todos = [#1]) rồi todo add "sửa bug" (kết quả r2, [#1, #2]). Người dùng dùng /tree quay về ngay sau r1 để thử hướng khác. Pi phát session_tree; reconstructState() duyệt getBranch(), giờ là đường từ gốc tới r1, và danh sách trong bộ nhớ trở lại [#1]. Nếu trạng thái được lưu trong một tệp ngoài, nó vẫn sẽ là [#1, #2], sai với lịch sử mà mô hình đang thấy. Cũng vì vậy /todos chỉ cần đọc biến trong bộ nhớ rồi vẽ bằng ctx.ui.custom(), sau khi kiểm tra ctx.mode === "tui" (todo.ts:284). Ba ví dụ chọn ba cách lưu khác nhau: todo dùng details (đi theo nhánh), plan-mode dùng appendEntry (không vào ngữ cảnh), subagent không lưu gì của tiến trình con ngoài chính kết quả tool (kể cả details).
Skills#
Vấn đề: repo Pi có hướng dẫn riêng cho việc phát hành, cho việc thêm một provider LLM, cho việc kiểm thử interactive mode trong tmux. Mỗi bộ hướng dẫn chỉ cần cho một loại việc. Nhét tất cả vào system prompt thì mọi yêu cầu đều trả tiền cho chúng, kể cả khi người dùng chỉ hỏi một câu về CSS. Skill giải bằng cách công bố dần (progressive disclosure): system prompt chỉ chứa tên, mô tả và đường dẫn của mỗi skill; khi nhiệm vụ khớp mô tả, mô hình tự đọc tệp bằng tool read (docs/skills.md:41). Pi hiện thực đặc tả Agent Skills, nên skill viết cho các agent khác dùng được ở đây. Chính repo Pi có bốn skill trong .pi/skills/, và AGENTS.md của repo bảo agent "load and follow" .pi/skills/release.md khi làm việc phát hành (AGENTS.md:121).
export interface SkillFrontmatter { name?: string; description?: string; "disable-model-invocation"?: boolean; [key: string]: unknown;}export interface Skill { name: string; description: string; filePath: string; baseDir: string; sourceInfo: SourceInfo; disableModelInvocation: boolean;}baseDir là thư mục chứa tệp skill; đường dẫn tương đối trong skill được giải theo nó. Các trường khác của đặc tả (license, compatibility, metadata, allowed-tools) được đọc vào frontmatter nhưng Pi không dùng.Khám phá#
Skill đến từ ~/.pi/agent/skills/, ~/.agents/skills/, .pi/skills/ và .agents/skills/ của dự án (cần trust), mục skills trong settings, gói, --skill, và các đường dẫn extension trả về trong resources_discover. Với .agents/skills của dự án, Pi xét thư mục hiện tại và các thư mục cha, dừng ở gốc repo git (package-manager.ts:474). Quy tắc quét trong một thư mục: thư mục nào có SKILL.md thì đó là gốc của một skill và không quét sâu hơn; nếu không thì đệ quy vào thư mục con, bỏ qua tên bắt đầu bằng dấu chấm, node_modules và những gì .gitignore/.ignore/.fdignore loại. Tệp .md lẻ được nhận làm skill khi nằm ngay ở gốc thư mục skill kiểu Pi, hoặc ở thư mục con không có SKILL.md với thư mục kiểu .agents (package-manager.ts:377). Bốn tệp .pi/skills/*.md của repo Pi thuộc trường hợp đầu.
Kiểm tra hợp lệ#
Pi theo đặc tả nhưng khoan dung: phần lớn vi phạm chỉ tạo cảnh báo. Luật cho tên và mô tả:
function validateName(name: string): string[] { const errors: string[] = []; if (name.length > MAX_NAME_LENGTH) { errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`); } if (!/^[a-z0-9-]+$/.test(name)) { errors.push(`name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)`); } if (name.startsWith("-") || name.endsWith("-")) { errors.push(`name must not start or end with a hyphen`); } if (name.includes("--")) { errors.push(`name must not contain consecutive hyphens`); } return errors;}/** * Validate description per Agent Skills spec. */function validateDescription(description: unknown): string[] { const errors: string[] = []; if (typeof description !== "string" || description.trim() === "") { errors.push("description is required"); } else if (description.length > MAX_DESCRIPTION_LENGTH) { errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`); } return errors;}Và luật quyết định skill có được nạp hay không:
let frontmatter: SkillFrontmatter; try { ({ frontmatter } = parseFrontmatter<SkillFrontmatter>(rawContent)); } catch (error) { if (isDeclaredSkill) { const message = error instanceof Error ? error.message : "failed to parse skill file"; diagnostics.push({ type: "warning", message, path: filePath }); } return { skill: null, diagnostics }; } const description = frontmatter.description; const hasDescription = typeof description === "string" && description.trim() !== ""; if (!isDeclaredSkill && !hasDescription) { return { skill: null, diagnostics }; } const skillDir = dirname(filePath); const parentDirName = basename(skillDir); // Validate description const descErrors = validateDescription(description); for (const error of descErrors) { diagnostics.push({ type: "warning", message: error, path: filePath }); } // Use name from frontmatter, or fall back to parent directory name const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined; const name = frontmatterName || parentDirName; // Validate name const nameErrors = validateName(name); for (const error of nameErrors) { diagnostics.push({ type: "warning", message: error, path: filePath }); } // Still load the skill even with warnings, unless description is missing or empty. if (!hasDescription) { return { skill: null, diagnostics }; } return { skill: { name, description, filePath, baseDir: skillDir, sourceInfo: createSkillSourceInfo(filePath, skillDir, source), disableModelInvocation: frontmatter["disable-model-invocation"] === true, }, diagnostics, };}isDeclaredSkill (dòng 282) phân biệt SKILL.md với tệp .md lẻ: một tệp lẻ không có mô tả bị bỏ qua lặng lẽ, vì có thể nó chỉ là tài liệu thường nằm cạnh skill.Thư mục test/fixtures/skills/ ghi lại từng trường hợp biên, và packages/coding-agent/test/skills.test.ts khẳng định kết quả của chúng:
| Fixture | Đặc điểm | Kết quả |
|---|---|---|
valid-skill | Đúng chuẩn | Nạp, không cảnh báo |
name-mismatch | name: different-name khác tên thư mục | Nạp với tên trong frontmatter, không cảnh báo |
invalid-name-chars | Invalid_Name | Nạp, cảnh báo ký tự không hợp lệ |
long-name, consecutive-hyphens | Tên quá 64 ký tự; bad--name | Nạp, có cảnh báo |
missing-description, no-frontmatter | Không có mô tả | Bỏ, cảnh báo "description is required" |
invalid-yaml | description: [unclosed bracket | Bỏ, cảnh báo lỗi phân tích YAML |
multiline-description | Mô tả | nhiều dòng | Nạp, giữ nguyên các dòng |
unknown-field | author, version | Nạp, trường lạ bị bỏ qua |
root-skill-preferred | Có SKILL.md ở gốc và một SKILL.md lồng bên trong | Chỉ skill gốc được nạp, tên lấy từ thư mục |
nested/child-skill | Thư mục cha không có SKILL.md | Tìm thấy nhờ đệ quy |
disable-model-invocation | disable-model-invocation: true | Nạp, nhưng không có trong system prompt |
skills-collision/{first,second}/calendar | Hai skill cùng tên calendar | Giữ cái gặp trước, báo collision |
Trùng tên được xử lý trong loadSkills(): một tệp đã nạp qua symlink thì bị bỏ lặng lẽ (so theo đường dẫn thật), còn hai tệp khác nhau trùng tên thì cái đến trước thắng (skills.ts:426). "Đến trước" ở đây là theo hạng ưu tiên của resolver, nên skill của dự án thắng skill cùng tên của người dùng.
Vào system prompt, và đọc khi cần#
export function formatSkillsForPrompt(skills: Skill[], fileReadTool: "read" | "bash" | "indirect" = "read"): string { const visibleSkills = skills.filter((s) => !s.disableModelInvocation); if (visibleSkills.length === 0) { return ""; } const lines = [ "\n\nThe following skills provide specialized instructions for specific tasks.", fileReadTool === "read" ? "Use the read tool to load a skill's file when the task matches its description." : fileReadTool === "bash" ? "Use bash to load a skill's file when the task matches its description." : "Load a skill's file when the task matches its description.", "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", "", "<available_skills>", ]; for (const skill of visibleSkills) { lines.push(" <skill>"); lines.push(` <name>${escapeXml(skill.name)}</name>`); lines.push(` <description>${escapeXml(skill.description)}</description>`); lines.push(` <location>${escapeXml(skill.filePath)}</location>`); lines.push(" </skill>"); } lines.push("</available_skills>"); return lines.join("\n");}Câu hướng dẫn nêu tên tool đọc tệp, nên phải chọn đúng tool đang có:
// A hidden reader is still reachable through another tool, so skills stay but the hint names no tool.const readers = ["read", "bash"] as const;const skillFileReadTool = readers.find((tool) => declaredTools.includes(tool)) ?? (readers.some((tool) => selectedTools.includes(tool)) ? "indirect" : undefined);if (skillFileReadTool && skills.length > 0) { const skillsPrompt = formatSkillsForPrompt(skills, skillFileReadTool).trim(); if (skillsPrompt) promptSections.skills = skillsPrompt;}read lẫn bash đều không bật, mục skill bị bỏ hẳn khỏi system prompt: không có cách nào đọc tệp thì liệt kê skill là vô ích. indirect là trường hợp tool đọc đang bật nhưng bị ẩn khai báo (chẳng hạn chỉ gọi được qua codemode).Mô hình có thể bỏ lỡ một skill liên quan, nên người dùng có thể ép bằng /skill:tên tham số. AgentSession thay lệnh đó bằng phần thân của tệp (đã bỏ frontmatter) bọc trong thẻ <skill name location> kèm câu "References are relative to ...", tham số được nối phía sau như một yêu cầu (agent-session.ts:2211). Lệnh này chạy được cả với skill disable-model-invocation, vì cờ đó chỉ ẩn skill khỏi danh sách cho mô hình; setting enableSkillCommands chỉ quyết định skill có xuất hiện trong gợi ý lệnh hay không (docs/skills.md:53).
Prompt templates#
Prompt template là loại tài nguyên đơn giản nhất: một tệp Markdown trở thành một lệnh /tên, không có mã chạy, không có tệp phụ trợ (docs/prompt-templates.md:3). Trước bản 0.35.0 chúng được gọi là "slash commands"; changelog của bản đó đổi tên chúng cùng lúc với việc gộp hooks và custom tools thành extension (CHANGELOG.md:4769).
export interface PromptTemplate { name: string; description: string; argumentHint?: string; content: string; sourceInfo: SourceInfo; filePath: string; // Absolute path to the template file}const name = basename(filePath).replace(/\.md$/, "");// Get description from frontmatter or first non-empty linelet description = typeof frontmatter.description === "string" ? frontmatter.description : "";if (!description) { const firstLine = body.split("\n").find((line) => line.trim()); if (firstLine) { // Truncate if too long description = firstLine.slice(0, 60); if (firstLine.length > 60) description += "..."; }}const argumentHint = typeof frontmatter["argument-hint"] === "string" ? frontmatter["argument-hint"] : undefined;return { template: { name, description, ...(argumentHint && { argumentHint }), content: body, sourceInfo, filePath, }, diagnostics,};Khám phá theo đúng mô hình của các tài nguyên khác (~/.pi/agent/prompts/, .pi/prompts/ khi tin cậy, settings, gói, --prompt-template, resources_discover), với một khác biệt: thư mục quy ước chỉ nạp các tệp .md nằm trực tiếp trong nó, không đệ quy (prompt-templates.ts:161); muốn lấy tệp lồng sâu thì liệt kê chúng trong settings hay manifest của gói. Hai template trùng tên thì cái đến trước thắng và có một chẩn đoán collision (resource-loader.ts:1160).
Repo Pi tự dùng template cho các việc lặp lại của người bảo trì. Ví dụ /is:
---description: Analyze GitHub issues (bugs or feature requests)argument-hint: "<issue>"---Analyze GitHub issue(s): $ARGUMENTSKhi người dùng gõ /is 1234 5678, expandPromptTemplate() tách tên khỏi phần còn lại, tìm template tên is, tách tham số kiểu shell, rồi thay chỗ giữ (prompt-templates.ts:309). Kết quả, dòng 5 trở thành Analyze GitHub issue(s): 1234 5678. Bảng cú pháp đầy đủ nằm trong chính hàm thay thế:
/** * Substitute argument placeholders in template content * Supports: * - $1, $2, ... for positional args * - $@ and $ARGUMENTS for all args * - ${N:-default} for positional arg N with default when missing/empty * - ${@:-default} and ${ARGUMENTS:-default} for all args with a default when empty * - ${@:N} for args from Nth onwards (bash-style slicing) * - ${@:N:L} for L args starting from Nth * * Note: Replacement happens on the template string only. Argument and default values * containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted. */export function substituteArgs(content: string, args: string[]): string { const allArgs = args.join(" "); return content.replace( /\$\{(\d+|ARGUMENTS|@):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g, (_match, defaultTarget, defaultValue, sliceStart, sliceLength, simple) => { if (defaultTarget) { const value = defaultTarget === "@" || defaultTarget === "ARGUMENTS" ? allArgs : args[parseInt(defaultTarget, 10) - 1]; return value ? value : defaultValue; } if (sliceStart) { let start = parseInt(sliceStart, 10) - 1; // Convert to 0-indexed (user provides 1-indexed) // Treat 0 as 1 (bash convention: args start at 1) if (start < 0) start = 0; if (sliceLength) { const length = parseInt(sliceLength, 10); return args.slice(start, start + length).join(" "); } return args.slice(start).join(" "); } if (simple === "ARGUMENTS" || simple === "@") { return allArgs; } const index = parseInt(simple, 10) - 1; return args[index] ?? ""; }, );}$1 không bị thay tiếp. $3 khi chỉ có hai tham số thành chuỗi rỗng. Tách tham số tôn trọng dấu nháy, nên /review "API compatibility" là một tham số, và "" là một tham số rỗng có thật (parseCommandArgs()).Vị trí của bước mở rộng trong luồng xử lý prompt quan trọng: lệnh extension trùng tên chạy trước và chiếm luôn, sự kiện input thấy văn bản thô /is 1234 5678 trước khi template được mở rộng, và extension gửi tin bằng pi.sendUserMessage() chỉ được mở rộng khi truyền expandPromptTemplates: true (chương AgentSession). Template cũng là cách gọn để đóng gói một quy trình nhiều bước dùng extension khác: thư mục subagent/prompts/ có /implement, chỉ là một đoạn văn bảo mô hình gọi tool subagent theo chuỗi scout, planner, worker với $@ làm đề bài (packages/coding-agent/examples/extensions/subagent/prompts/implement.md).
Themes#
Theme quyết định màu của interactive mode và của bản xuất HTML. Pi có ba theme dựng sẵn: system (mặc định, dựng màu từ chính bảng màu của terminal), dark và light; setting theme nhận một tên, hoặc cặp "light/dark" để đổi theo giao diện terminal (docs/themes.md:40). Thuật toán của theme system (giải độ sáng theo yêu cầu tương phản trong OKLCH) thuộc chương pi-tui. Ở đây ta chỉ quan tâm theme tự viết, một tệp JSON:
export const ThemeJsonSchema = Type.Object( { $schema: Type.Optional(Type.String({ description: "JSON schema reference" })), name: Type.String({ pattern: "^[^/]+$", description: "Theme name. Must not contain '/' because it is reserved for automatic light/dark theme settings.", }), appearance: Type.Optional( Type.Union([Type.Literal("dark"), Type.Literal("light")], { description: "Background the theme is designed for. Detected from the theme colors when omitted.", }), ), vars: Type.Optional( Type.Record(Type.String(), ColorValueSchema, { description: "Reusable color variables", }), ), colors: ThemeColorsSchema, export: Type.Optional( Type.Object( { pageBg: Type.Optional(colorValue("Page background color")), cardBg: Type.Optional(colorValue("Card/container background color")), infoBg: Type.Optional(colorValue("Info sections background (system prompt, notices)")), }, { description: "Optional colors for HTML export (defaults derived from userMessageBg if not specified)", additionalProperties: false, }, ), ), }, { title: "Pi Coding Agent Theme", description: "Theme schema for Pi coding agent", additionalProperties: false, },);theme.ts có chủ đích: theo chú thích đầu tệp, import TypeBox kéo theo khoảng 17 MB module, nên chỉ nơi cần kiểm tra theme do người dùng viết mới cài nó. main.ts của CLI cài nó làm validator trước khi nạp tài nguyên (main.ts:577); mã chỉ dùng theme dựng sẵn thì không phải trả chi phí đó.Màu viết được ở sáu dạng: hex, oklch(...), okhsl(...), chỉ số 256 màu, tên biến trong vars, hoặc chuỗi rỗng cho màu mặc định của terminal; biến có thể tham chiếu biến khác, và biến thiếu hay vòng tham chiếu làm theme không hợp lệ (docs/themes.md:83). Resource loader nạp theme từ ~/.pi/agent/themes/, .pi/themes/ (khi tin cậy), settings, gói, --theme, resources_discover. Tệp hỏng chỉ thành một chẩn đoán cảnh báo, không làm Pi dừng (resource-loader.ts:1121), và tên trùng thì cái đến trước thắng. Khi tra một tên, loadTheme() ưu tiên theo thứ tự: system luôn là theme dựng sẵn (một theme tự viết đặt tên system bị bỏ qua), rồi theme đã đăng ký từ resource loader, rồi theme dựng sẵn, rồi tệp <tên>.json trong thư mục theme của người dùng (theme.ts:557, theme.ts:487).
Nạp lại trực tiếp#
Chỉnh màu là việc thử đi thử lại, nên theme đang dùng được theo dõi trên đĩa và áp dụng ngay khi lưu, không cần /reload:
function startThemeWatcher(): void { stopThemeWatcher(); // Only watch if it's a custom theme (not built-in) if ( !currentThemeName || currentThemeName === "dark" || currentThemeName === "light" || currentThemeName === SYSTEM_THEME_NAME ) { return; } const scheduleReload = () => { if (themeReloadTimer) { clearTimeout(themeReloadTimer); } themeReloadTimer = setTimeout(() => { themeReloadTimer = undefined; // Ignore stale timers after switching themes or stopping the watcher if (currentThemeName !== watchedThemeName) { return; } // Keep the last successfully loaded theme active if the file is temporarily missing if (!fs.existsSync(themeFile)) { return; } try { // Reload the theme from disk and refresh the registry cache const reloadedTheme = loadThemeFromPath(themeFile); registeredThemes.set(watchedThemeName, reloadedTheme); setGlobalTheme(reloadedTheme); // Notify callback (to invalidate UI) if (onThemeChangeCallback) { onThemeChangeCallback(); } } catch (_error) { // Ignore errors (file might be in invalid state while being edited) } }, 100); };~/.pi/agent/themes/ rồi lọc theo tên tệp; sự kiện không kèm tên tệp cũng kích hoạt nạp lại (theme.ts:789). Lỗi phân tích lúc tệp đang sửa dở bị bỏ qua, theme cũ vẫn hiển thị.Watcher chỉ chạy cho theme tự viết nằm đúng ở <agent-dir>/themes/<tên>.json và chỉ trong interactive mode (main.ts:906). Theme từ dự án, từ gói hay từ một đường dẫn trong settings thì phải /reload; tài liệu nói đúng như vậy (docs/themes.md:68). Extension đổi theme bằng ctx.ui.setTheme(tên hoặc Theme); ví dụ mac-system-theme.ts dùng nó để theo chế độ sáng tối của macOS.
Pi packages#
Một extension có thể cần thư viện riêng, đi cùng vài skill và một prompt template. Chép tay từng tệp vào ~/.pi/agent/ thì không cập nhật được, không chia sẻ được. Pi package là đơn vị phân phối: một thư mục bình thường hoặc một gói npm, cài bằng một lệnh (docs/packages.md:3). Chuỗi nguồn được phân loại như sau:
private parseSource(source: string): ParsedSource { if (source.startsWith("npm:")) { const spec = source.slice("npm:".length).trim(); const { name, version } = this.parseNpmSpec(spec); return { type: "npm", spec, name, version, range: getNpmVersionRange(version), pinned: isExactNpmVersion(version), }; } if (isLocalPath(source)) { return { type: "local", path: source }; } // Try parsing as git URL const gitParsed = parseGitUrl(source); if (gitParsed) { return gitParsed; } return { type: "local", path: source };}pinned của nguồn npm đúng khi phiên bản là một semver chính xác (npm:@x/y@1.0.0), không phải một khoảng như @^1.0.0. Nguồn git có tiền tố git: nhận các dạng viết tắt; không có tiền tố thì chỉ nhận URL có giao thức (git.ts:165).pi install <nguồn> gọi installAndPersist(): cài, rồi thêm nguồn vào mảng packages của ~/.pi/agent/settings.json, hoặc của .pi/settings.json với -l (cần dự án đã được tin cậy) (package-manager.ts:1042). Gói npm vào một dự án npm riêng của Pi ở ~/.pi/agent/npm (hoặc .pi/npm); repo git được clone vào ~/.pi/agent/git/<host>/<path> rồi chạy npm install nếu có package.json; đường dẫn cục bộ thì không sao chép gì (package-manager.ts:2091). Lúc cài, việc giải peer dependency bị tắt (--legacy-peer-deps với npm, cấu hình tương đương với pnpm và Bun), để trình quản lý gói không tự kéo về một bản @earendil-works/pi-* thứ hai (package-manager.ts:1851). pi -e npm:@x/y thử một gói cho một lần chạy mà không ghi vào settings, cài vào thư mục tạm.
Manifest và các loại tài nguyên#
export interface PiManifest { extensions?: string[]; skills?: string[]; prompts?: string[]; themes?: string[];}private collectPackageResources( packageRoot: string, accumulator: ResourceAccumulator, filter: PackageFilter | undefined, metadata: PathMetadata,): boolean { if (filter) { for (const resourceType of RESOURCE_TYPES) { const patterns = filter[resourceType]; const target = this.getTargetMap(accumulator, resourceType); if (filter.autoload === false) { this.applyPackageDeltaFilter(packageRoot, patterns ?? [], resourceType, target, metadata); } else if (patterns !== undefined) { this.applyPackageFilter(packageRoot, patterns, resourceType, target, metadata); } else { this.collectDefaultResources(packageRoot, resourceType, target, metadata); } } return true; } const manifest = readPiManifest(join(packageRoot, "package.json")); if (manifest) { for (const resourceType of RESOURCE_TYPES) { const entries = manifest[resourceType as keyof PiManifest]; this.addManifestEntries( entries, packageRoot, resourceType, this.getTargetMap(accumulator, resourceType), metadata, ); } return true; } let hasAnyDir = false; for (const resourceType of RESOURCE_TYPES) { const dir = join(packageRoot, resourceType); if (existsSync(dir)) { // Collect all files from the directory (all enabled by default) const files = collectResourceFiles(dir, resourceType); for (const f of files) { this.addResource(this.getTargetMap(accumulator, resourceType), f, metadata, true); } hasAnyDir = true; } } return hasAnyDir;}extensions/, skills/, prompts/, themes/.Mảng trong manifest nhận glob và mẫu loại trừ. Ví dụ minh hoạ một package.json của gói:
Ví dụ minh hoạ: package.json của một Pi package
{
"name": "@acme/pi-review",
"keywords": ["pi-package"], ← để hiện trong thư viện gói của pi.dev
"pi": {
"extensions": ["./src/index.ts"],
"skills": ["./skills"],
"prompts": ["./prompts/*.md", "!prompts/draft-*.md"]
},
"dependencies": { "diff": "8.0.2" },
"peerDependencies": { "@earendil-works/pi-coding-agent": "*", "typebox": "*" }
}peerDependencies với khoảng "*", không trong dependencies (docs/packages.md:76).Người dùng thu hẹp một gói bằng dạng object trong settings: bỏ một trường thì nạp hết những gì gói cho phép, [] thì không nạp loại đó, !mẫu loại trừ theo glob, +đường_dẫn và -đường_dẫn ép bật hay ép tắt đúng một tệp. Bộ lọc chỉ thu hẹp được những gì manifest đã công bố, không mở ra thứ gói không khai báo (docs/packages.md:94). Bốn bước áp mẫu (include, loại trừ, ép bật, ép tắt) nằm trong applyPatterns() (package-manager.ts:743), và cùng cú pháp đó dùng cho các mảng extensions, skills, prompts, themes ở cấp settings.
Ghim phiên bản và cập nhật#
for (const entry of sources) { const parsed = this.parseSource(entry.source); // Pinned npm versions are fixed. Pinned git refs are configured checkout targets, // so include them to reconcile an existing clone when the configured ref changes. if (parsed.type === "npm") { if (!parsed.pinned) { npmCandidates.push({ ...entry, parsed }); } } else if (parsed.type === "git") { gitCandidates.push({ ...entry, parsed }); }}Với npm, nguồn chưa ghim được so phiên bản đã cài với phiên bản mới nhất thoả khoảng; nguồn không có phiên bản thì cài @latest (package-manager.ts:1187). Với git, nguồn có ref (@v1, một commit, hay một tên nhánh) được fetch đúng ref đó rồi reset --hard về nó; nguồn không có ref thì đi theo upstream của clone (package-manager.ts:1931). Vì thế một tag hay commit không bao giờ dịch chuyển, nhưng một ref là tên nhánh thì vẫn tiến theo nhánh đó. Cập nhật chạy bằng pi update --extensions (mọi gói) hoặc pi update <nguồn> (một gói); pi update không tham số chỉ cập nhật chính Pi (package-manager-cli.ts:359). Biến môi trường PI_OFFLINE=1 tắt cả việc cập nhật lẫn việc tự cài gói còn thiếu lúc khởi động.
Một gói có thể xuất hiện ở cả settings người dùng và settings dự án. Danh tính để so trùng: gói npm theo tên, gói git theo URL repo không kèm ref, gói cục bộ theo đường dẫn tuyệt đối. Mục của dự án thay mục của người dùng; riêng mục dự án có autoload: false thì trở thành một bộ lọc "delta" đè lên mục của người dùng (package-manager.ts:1351).
pi config: bật tắt từng tài nguyên#
pi config mở một TUI liệt kê mọi tài nguyên đã giải, kể cả các tài nguyên đang tắt và bốn built-in. Bật hay tắt một mục không xoá tệp nào; nó chỉ ghi một mẫu +đường_dẫn hoặc -đường_dẫn vào mảng tương ứng trong settings, hoặc vào bộ lọc của gói nếu tài nguyên thuộc một gói (config-selector.ts:538). Mặc định nó sửa settings người dùng; Tab hoặc pi config -l chuyển sang chế độ ghi đè cho dự án, chỉ khả dụng khi dự án được tin cậy (package-manager-cli.ts:814). Ba lệnh còn lại là pi list (liệt kê gói theo scope), pi remove (alias pi uninstall) và pi update.
Keybindings và provider tự viết#
Keybindings: hành động có tên, phím đổi được#
Pi tránh gắn cứng phím vào code. Mỗi hành động có một id ổn định (app.model.select, tui.editor.undo...), một mô tả và phím mặc định; quy tắc của repo cấm viết thẳng kiểu matchesKey(data, "ctrl+x") mà bắt thêm phím mặc định vào bảng để người dùng đổi được (AGENTS.md:27). Bảng của coding agent là bảng của pi-tui cộng các hành động ứng dụng, với phím mặc định khác đi trên Windows và WSL (keybindings.ts:16). Người dùng ghi đè trong <agent-dir>/keybindings.json: mỗi id ánh xạ tới một phím hoặc một mảng phím, mảng rỗng thì tắt hành động (docs/keybindings.md:7).
export class KeybindingsManager extends TuiKeybindingsManager { private configPath: string | undefined; constructor(userBindings: KeybindingsConfig = {}, configPath?: string) { super(KEYBINDINGS, userBindings); this.configPath = configPath; } static create(agentDir: string = getAgentDir()): KeybindingsManager { const configPath = join(agentDir, "keybindings.json"); const userBindings = KeybindingsManager.loadFromFile(configPath); return new KeybindingsManager(userBindings, configPath); } reload(): void { if (!this.configPath) return; this.setUserBindings(KeybindingsManager.loadFromFile(this.configPath)); } getEffectiveConfig(): KeybindingsConfig { return this.getResolvedBindings(); } private static loadFromFile(path: string): KeybindingsConfig { const rawConfig = loadRawConfig(path); if (!rawConfig) return {}; return toKeybindingsConfig(migrateKeybindingsConfig(rawConfig).config); }}toKeybindingsConfig() giữ lại chỉ những giá trị qua được schema TypeBox, nên một mục viết sai bị bỏ lặng lẽ thay vì làm hỏng cả tệp (keybindings.ts:269). Tên kiểu cũ như cursorUp được đổi sang tui.editor.cursorUp. /reload gọi reload() để áp dụng tệp vừa sửa.Phím tắt của extension (pi.registerShortcut("ctrl+shift+p", ...)) phải chung sống với bảng đó. Runner phân xử dựa trên các phím hiệu lực, tức sau khi đã áp keybindings.json:
// Extension shortcuts compete with canonical keybinding ids from keybindings.json.// Only editor-global shortcuts are reserved here. Picker-specific bindings are not.const RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS = [ "app.interrupt", "app.clear", "app.exit", "app.suspend", "app.thinking.cycle", "app.model.cycleForward", "app.model.cycleBackward", "app.model.select", "app.tools.expand", "app.thinking.toggle", "app.editor.external", "app.message.copy", "app.message.followUp", "tui.input.submit", "tui.select.confirm", "tui.select.cancel", "tui.input.copy", "tui.editor.deleteToLineEnd",] as const; for (const ext of this.extensions) { for (const [key, shortcut] of ext.shortcuts) { const normalizedKey = key.toLowerCase() as KeyId; const builtInKeybinding = builtinKeybindings[normalizedKey]; if (builtInKeybinding?.restrictOverride === true) { addDiagnostic( `Extension shortcut '${key}' from ${shortcut.extensionPath} conflicts with built-in shortcut. Skipping.`, shortcut.extensionPath, ); continue; } if (builtInKeybinding?.restrictOverride === false) { addDiagnostic( `Extension shortcut conflict: '${key}' is built-in shortcut for ${builtInKeybinding.keybinding} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`, shortcut.extensionPath, ); } const existingExtensionShortcut = extensionShortcuts.get(normalizedKey); if (existingExtensionShortcut) { addDiagnostic( `Extension shortcut conflict: '${key}' registered by both ${existingExtensionShortcut.extensionPath} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`, shortcut.extensionPath, ); } extensionShortcuts.set(normalizedKey, shortcut); } } return extensionShortcuts;app.model.select khỏi Ctrl+L trong keybindings.json, phím đó không còn bị giữ cho hành động ấy.Provider tự viết#
Một dịch vụ mô hình đã nói đúng một API có sẵn thì chỉ cần khai báo trong models.json. Extension provider dành cho phần còn lại: đăng nhập riêng, danh mục mô hình động, giao thức không có sẵn (docs/custom-provider.md:7). Có hai dạng đăng ký: một Provider hoàn chỉnh của pi-ai (dạng được khuyên cho tích hợp mới), hoặc tên cộng ProviderConfig, dạng cũ:
/** Configuration for registering a provider via pi.registerProvider(). */export interface ProviderConfig { /** Display name for the provider in UI. */ name?: string; /** Base URL for the API endpoint. Required when defining models. */ baseUrl?: string; /** API key literal, env interpolation ($ENV_VAR or ${ENV_VAR}), or leading !command. Required when defining models (unless oauth provided). */ apiKey?: string; /** API type. Required at provider or model level when defining models. */ api?: Api; /** * Optional streamSimple handler for custom APIs. * The context is a normalized transcript: read the prompt and tools from its system messages * (`getCurrentSystemPrompt(context.messages)`, `getCurrentTools(context.messages)`). * Implementations must invoke `options.onPayload` before sending the provider request and use any * returned replacement payload. They must invoke `options.onResponse` after receiving the response * and before consuming its body, matching built-in providers. Implementations may invoke * `options.onProviderStreamEvent(data, model)` with parsed stream events before normalization. * Event data is adapter-owned and must be treated as read-only. */ streamSimple?: ( model: Model<Api>, context: TranscriptContext, options?: SimpleStreamOptions, ) => AssistantMessageEventStream; /** Image-generation implementations keyed by image API. */ images?: Partial<Record<ImageApi, ProviderImages>>; /** Classifier implementations keyed by classifier API. */ classifiers?: Partial<Record<ClassifierApi, ProviderClassifier>>; /** Custom headers to include in requests. */ headers?: Record<string, string>; /** If true, adds Authorization: Bearer header with the resolved API key. */ authHeader?: boolean; /** Models to register. If provided, replaces all existing models for this provider. */ models?: ProviderModelConfig[]; /** * Refresh this provider's model list. The returned list replaces extension-provided models. * Use context.publish({ persist: entry }) when the catalog should persist across sessions. */ refreshModels?(context: RefreshModelsContext): Promise<ProviderModelConfig[]>; /** OAuth provider for /login support. The `id` is set automatically from the provider name. */ oauth?: { /** Display name for the provider in login UI. */apiKey dùng cùng cú pháp với models.json: $NAME hay ${NAME} đọc biến môi trường, !lệnh lấy output của lệnh (docs/custom-provider.md:126). Khối oauth (dòng 1971 đến 1986) gồm login, refreshToken, getApiKey; đăng ký xong, provider xuất hiện trong /login và credential được lưu vào auth.json.Ngữ nghĩa nằm trong chú thích của registerProvider: có models thì thay toàn bộ mô hình của provider đó; chỉ có baseUrl (hay headers) thì giữ danh mục dựng sẵn và chỉ đổi điểm cuối, cách đúng để đi qua một proxy hay gateway (types.ts:1806). Thời điểm cũng quan trọng. Trong lúc nạp, đăng ký được xếp hàng (applyRuntimeChange ở mục nạp), và createAgentSessionServices() áp dụng hàng đợi ngay sau khi resource loader nạp xong, trước khi chọn mô hình khởi đầu (agent-session-services.ts:154). Nhờ vậy pi --model my-proxy/x và pi --list-models thấy được provider do extension thêm, và factory bất đồng bộ được phép await một yêu cầu mạng để lấy danh mục trước khi đăng ký. Sau khi runner được bind, registerProvider và unregisterProvider có hiệu lực ngay, không cần /reload (runner.ts:523).
Một provider tự hiện thực streamSimple phải tôn trọng các móc đo đạc mà pi-ai truyền vào: gọi options.onPayload trước khi gửi và dùng payload thay thế nếu có, gọi options.onResponse trước khi đọc body, và onProviderStreamEvent cho từng sự kiện luồng. Thiếu chúng thì các sự kiện before_provider_request, after_provider_response, provider_stream_event của mọi extension khác sẽ im lặng với provider đó (docs/custom-provider.md:191). Hai ví dụ đầy đủ: custom-provider-gitlab-duo/ (OAuth riêng, nhưng giao phần stream cho hiện thực Anthropic và OpenAI có sẵn của pi-ai) và custom-provider-anthropic/ (tự viết cả stream). Giao thức stream và adapter thuộc chương pi-ai; virtual model, thứ cũng đăng ký qua extension, thuộc chương Pi 1.0.
Quyết định thiết kế#
Tính năng nằm ngoài lõi, kể cả tính năng của chính Pi#
Plan mode, sub-agent, todo, hỏi quyền trước lệnh nguy hiểm: tất cả là ví dụ extension chứ không phải mã lõi. Cái lõi không có thì không làm phình system prompt và không áp một quy trình cho mọi người. Pi 1.0 đi xa hơn với chính các tính năng mới của nó: MCP, codemode và tool search là extension dựng sẵn, nạp qua cùng cơ chế tài nguyên, tắt được, và nhường chỗ được cho một hiện thực của bên thứ ba nhờ cờ replaceable. Bài "You Said No MCP!" của Earendil giải thích vì sao MCP vẫn vào bản phát hành (earendil.com/posts/you-said-no-mcp); cấu trúc builtin:<name> là cách đưa nó vào mà không biến nó thành phần không gỡ được. Bài "Pi, Minimal and Performant" của Earendil, viết quanh kết quả benchmark nội bộ của Databricks, tóm thái độ chung: độ phức tạp phải tự chứng minh giá trị của nó (earendil.com/posts/pi-autoresearch-and-databricks).
Chạy trong tiến trình, không hộp cát#
Extension có quyền như Pi: đọc credential, sửa mọi lời gọi tool, đăng ký provider, vẽ UI. Một API có hộp cát (tiến trình riêng, quyền khai báo trước) sẽ an toàn hơn nhưng không làm được nhiều việc trong examples/: tool_call sửa tham số tại chỗ, context trả về message, ctx.ui.custom() trả về component vẽ trực tiếp. Pi chọn sức mạnh, rồi đặt ranh giới ở chỗ khác: tài nguyên của dự án chỉ chạy sau quyết định trust, và ai cần cô lập thì dùng chính extension để có nó (sandbox/ bọc bash bằng sandbox của hệ điều hành, gondolin/ đưa tool vào một micro-VM). Điều này khớp với lập trường của bài viết gốc: mặc định không hỏi quyền (YOLO), ai cần cô lập thì chạy Pi trong container.
TypeScript không cần build, cùng một bản module với host#
jiti cho phép một tệp .ts chạy ngay, điều kiện để agent tự viết extension rồi /reload mà không cần toolchain. Virtual module và alias bảo đảm extension import đúng đối tượng mà host đang dùng, kể cả khi Pi là một binary không có node_modules; đây là lý do loader có ba nhánh phân giải thay vì một. Cái giá là biến đổi Babel lúc nạp; Pi dời nó tới lúc có extension đầu tiên cần nạp, và changelog ghi lại các lần giảm chi phí khởi động đó (CHANGELOG.md:631, CHANGELOG.md:825).
Trạng thái sống trong session#
Armin gọi khả năng cho extension cất trạng thái vào session là "incredibly powerful". Lý do thấy rõ ở ví dụ todo.ts: vì trạng thái là một phần của cây session, fork, /tree và resume tự động cho trạng thái đúng với điểm lịch sử, không cần extension biết gì về nhánh. Cái giá là extension phải tự dựng lại trạng thái trong session_start và session_tree, và phải chọn đúng nơi cất (details, appendEntry, sendMessage) tuỳ trạng thái có cần vào ngữ cảnh không.
Mỗi sự kiện một luật gộp, không có middleware chung#
Một API kiểu middleware (next() cho mọi thứ) đơn giản hơn để mô tả nhưng khó dùng đúng: handler cấp quyền cần "người đầu tiên chặn thắng", handler biến đổi ngữ cảnh cần chuỗi, boundary cần thấy đề xuất của người trước. Pi khai báo kiểu kết quả riêng cho từng sự kiện và cài luật gộp tương ứng trong runner, với hai mặc định có chủ ý: lỗi trong handler thông báo không làm hỏng lần chạy, còn lỗi trong handler cổng thì chặn. Các điểm cho phép ghi thêm entry (boundary) chỉ ở những chỗ transcript chắc chắn hợp lệ, cùng lý do khiến custom message bị hoãn tới cuối lượt (chương AgentSession).
Bật tắt bằng mẫu, không bằng xoá#
Mọi tài nguyên đi qua một bộ giải duy nhất, mang enabled và siêu dữ liệu nguồn. pi config chỉ ghi mẫu +/-; gói vẫn được cài, tệp vẫn nằm đó, nên mọi thay đổi đảo ngược được và có thể khác nhau giữa người dùng và dự án. Built-in được biểu diễn bằng đường dẫn tổng hợp chính là để dùng lại bộ máy này thay vì thêm một cơ chế cấu hình thứ hai.
Cạm bẫy và trường hợp biên#
- Cờ boolean của extension nuốt mất prompt. Bộ phân tích tham số chạy trước khi biết cờ nào là boolean, nên
pi --plan "giải thích module auth"lấy câu đó làm giá trị của--plan(args.ts:254; test khẳng định hành vi này ở args.test.ts:533). Sau khi nạp, cờ boolean chỉ được đặttruevà chuỗi bị bỏ (agent-session-services.ts:106). Đặt cờ sau prompt (pi "..." --plan) hoặc viết--plan=1. - Gọi hành động trong factory làm extension hỏng.
pi.sendMessage(),pi.appendEntry(),pi.setActiveTools()ném "Extension runtime not initialized" cho tới khi runner được bind; hãy chuyển chúng vàosession_start. - Tham số đã sửa không hiện ở đâu cả.
tool_execution_startmang tham số gốc (agent-loop.ts:607), transcript ghi tham số gốc, và không có bước kiểm tra schema thứ hai. Nếu mô hình cần biết tham số đã bị đổi, extension phải tự nói trong kết quả. tool_resultkhông thấy mọi kết quả. Lời gọi bị chặn, tool không tồn tại, tham số sai schema đều thành kết quả "immediate" và không đi quaafterToolCall. Một extension che dữ liệu nhạy cảm không thể dựa vàotool_resultđể thấy văn bản do handlertool_callkhác trả về trongreason.- Handler
tool_callném lỗi thì im lặng chặn. Không có thông báo lỗi extension; chỉ có một kết quả tool lỗi mang thông điệp của exception. Khi gỡ lỗi một extension cấp quyền, hãy đọc kết quả tool trong transcript. agent_endchưa phải lúc rảnh. Retry, compaction và tin nhắn xếp hàng có thể mở lần chạy mới sau nó; việc chỉ nên chạy khi Pi thực sự dừng (thông báo, commit) thuộc vềagent_settled(docs/extensions.md:145).- Handler chậm làm chậm luồng. Handler được
awaittuần tự, kể cảmessage_updatevàprovider_stream_event(docs/extensions.md:224). - Nhánh SIGKILL dự phòng của
pi.exec()khó xảy ra. Sau SIGTERM, mã hẹn 5 giây rồi gửi SIGKILL nếuproc.killedcònfalse(exec.ts:52). Theo tài liệu Node,killedthànhtruengay khi tín hiệu được gửi thành công, không phải khi tiến trình đã thoát, nên một tiến trình con bỏ qua SIGTERM có thể giữ lời gọiexecchờ mãi. Ví dụsubagent/dùng cùng mẫu này. sendMessage()lúc đang chạy mặc định là steer. Không truyềntriggerTurn: falsethì message chen vào lần chạy hiện tại thay vì chỉ được ghi lại.- Lệnh trùng tên đổi tên gọi. Hai extension cùng đăng ký
/reviewthì người dùng phải gõ/review:1hoặc/review:2. Còn tool trùng tên với tool dựng sẵn thì thay hẳn tool dựng sẵn, không có chẩn đoán nào, vì kiểm tra xung đột chỉ so giữa các extension với nhau. - Skill lẻ không có
namemang tên thư mục. Tên mặc định là tên thư mục cha của tệp (skills.ts:310), nên hai tệp~/.pi/agent/skills/a.mdvàb.mdcódescriptionnhưng thiếunameđều tên làskills, và tệp thứ hai bị loại vì trùng tên. - Không có
readlẫnbashthì không có skill. Chạypi --tools grep,findlàm mục skill biến mất khỏi system prompt;/skill:tênvẫn dùng được. - Theme nạp lại nóng có điều kiện. Chỉ theme
<agent-dir>/themes/<tên>.jsonđang dùng, trong interactive mode; mọi nguồn khác cần/reload. - Ví dụ
reload-runtime.tskhông làm đúng điều chú thích của nó ở v1.4.0. Tool của nó gọipi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" })(reload-runtime.ts:29). Nhưng từ bản 0.84.2,sendUserMessagechỉ chạy lệnh extension khi cóexpandPromptTemplates: true, mặc định làfalse(agent-session.ts:2453, CHANGELOG.md:889). Theo code, tin nhắn xếp hàng vì thế đến mô hình như văn bản/reload-runtimethay vì chạy lệnh. - Ví dụ
plan-mode/khôi phục trạng thái từ mọi entry. Nó lấy entryplan-modecuối cùng tronggetEntries()(index.ts:349), tức có thể từ một nhánh đã bỏ, trái với khuyến nghị dựng lại từgetBranch()(docs/extensions.md:348). Khi chép ví dụ này, hãy theo cách củatodo.ts. - Chi phí của sub-agent không vào thống kê phiên. Tài liệu dặn tool gọi mô hình lồng nhau phải trả
usagetrong kết quả (docs/extensions.md:249);subagent/chỉ ghi usage của tiến trình con vàodetailsđể hiển thị (index.ts:708). - Chối trust không chặn hết.
AGENTS.md/CLAUDE.mdcủa thư mục vẫn vào system prompt, vàsessionDircủa dự án đã được đọc trước khi hỏi trust.
Đọc code ở đâu#
Thứ tự đề xuất: hợp đồng và ví dụ trước, rồi đường nạp, rồi đường phát sự kiện, cuối cùng là bốn loại tài nguyên còn lại.
| # | Tệp và vùng | Đọc để hiểu |
|---|---|---|
| 1 | packages/coding-agent/docs/extensions.md | Vòng đời, luật gộp handler, tool exposure, trạng thái, UI theo chế độ. Đọc hết, 400 dòng. |
| 2 | packages/coding-agent/examples/extensions/README.md, packages/coding-agent/examples/extensions/permission-gate.ts, packages/coding-agent/examples/extensions/todo.ts | Một cổng tool_call; một tool có trạng thái theo nhánh và UI tuỳ biến. |
| 3 | types.ts:1592, 579, 700 | ExtensionAPI, ToolDefinition, rồi toàn bộ sự kiện và kiểu kết quả. |
| 4 | package-manager.ts:926, 2419 | Mọi tài nguyên đến từ đâu, thứ tự ưu tiên, built-in, cổng trust trong lúc khám phá. |
| 5 | resource-loader.ts:505 | Hai lượt nạp quanh quyết định trust; builtin:; skill, prompt, theme, context file. |
| 6 | loader.ts:160, packages/coding-agent/src/core/extensions/virtual-modules.ts | Runtime với stub, API hai pha, jiti và ba cách phân giải module, cache factory. |
| 7 | packages/coding-agent/src/core/project-trust.ts, packages/coding-agent/src/core/trust-manager.ts | Thứ tự quyết định trust, trust.json, tài nguyên đòi trust. |
| 8 | runner.ts:417, 884, 1036 | Bind hành động thật, ctx và kiểm tra stale, từng hàm phát với luật gộp riêng. |
| 9 | agent-session.ts:644, 1303, 3318 | Hook tool, chuyển sự kiện agent sang extension, session_start, resources_discover, bindCore, _buildRuntime, reload. |
| 10 | agent-loop.ts:518, 869 | Tuần tự hay song song, prepareToolCall, kết quả immediate, afterToolCall. |
| 11 | packages/coding-agent/examples/extensions/plan-mode/index.ts, subagent/index.ts:270 | Ghép nhiều sự kiện thành một tính năng; chạy pi con ở JSON mode. |
| 12 | packages/coding-agent/src/core/skills.ts, packages/coding-agent/docs/skills.md, packages/coding-agent/test/skills.test.ts | Frontmatter, kiểm tra hợp lệ, định dạng trong system prompt; các fixture biên. |
| 13 | packages/coding-agent/src/core/prompt-templates.ts, packages/coding-agent/docs/prompt-templates.md, .pi/prompts/is.md, .pi/prompts/pr.md | Tách tham số, thay chỗ giữ; template thật của repo. |
| 14 | theme.ts:646, packages/coding-agent/src/modes/interactive/theme/theme-schema.ts, packages/coding-agent/docs/themes.md | Theme toàn cục dùng chung giữa các bộ nạp module, watcher, schema. |
| 15 | package-manager-cli.ts:747, packages/coding-agent/docs/packages.md | pi install/remove/update/list/config, trust cho lệnh gói. |
| 16 | packages/coding-agent/src/core/keybindings.ts, packages/coding-agent/docs/keybindings.md, packages/coding-agent/docs/custom-provider.md | Bảng hành động và phím; provider tự viết. |
Tóm tắt#
- Lõi Pi nhỏ có chủ ý; plan mode, sub-agent, todo, hỏi quyền được dựng lại bằng extension, và cả MCP, codemode, tool search, llama.cpp cũng là extension dựng sẵn
builtin:<name>, tắt hoặc thay được. System prompt chỉ cho mô hình chỗ tài liệu của Pi để nó tự viết extension, và/reloadnạp lại mã mới. - Extension là module TypeScript export một factory nhận
pi: ExtensionAPI. jiti dịch nó lúc chạy; virtual module hoặc alias bảo đảm import của extension trỏ vào đúng module của host trong cả ba kiểu chạy (mã nguồn, gói npm, binary). - Factory chạy trong trạng thái
loading: đăng ký được ghi vào đối tượngExtension, thay đổi lên runtime chung được xếp hàng tới lúc commit, và hành động lên phiên chỉ dùng được sau khiAgentSessionbind runner. - Mọi tài nguyên đi qua
DefaultPackageManager.resolve(), với hạng ưu tiên dự án trước người dùng, mục settings trước thư mục tự khám phá, gói rồi built-in sau cùng;-eđứng đầu. Thứ tự này cũng là thứ tự extension nhận sự kiện. - Project trust chia việc nạp làm hai lượt: extension của người dùng và CLI nạp trước, có thể quyết định trust qua
project_trust; mọi thứ dưới.pi/và gói của dự án chỉ nạp khi được tin cậy. Context file vẫn nạp bất kể trust. - Runner phát sự kiện theo thứ tự nạp trên một bản chụp handler, với luật gộp riêng từng sự kiện: thông báo, sửa tại chỗ, chuỗi biến đổi, cổng, boundary. Lỗi handler được báo rồi bỏ qua, trừ
tool_callvàuser_bash, nơi lỗi nghĩa là chặn. tool_callđi qua hookbeforeToolCall:event.inputlà bản sao đã kiểm tra schema, sửa tại chỗ thì tool chạy với tham số mới còn transcript giữ tham số gốc;blockhay một exception thành kết quả tool lỗi ngay.tool_resultchỉ thấy lời gọi đã thực sự chạy.- Trạng thái của extension nên sống trong session:
detailscủa kết quả tool khi phải đi theo nhánh,appendEntrykhi không vào ngữ cảnh,sendMessagekhi mô hình cần thấy; dựng lại từgetBranch()trongsession_start. - Skill công bố dần: system prompt chỉ có tên, mô tả, đường dẫn; mô hình đọc
SKILL.mdkhi cần, hoặc người dùng ép bằng/skill:tên. Prompt template là Markdown với$1,$@,${1:-mặc định}. Theme là JSON có schema chặt, nạp lại ngay khi sửa tệp của người dùng. - Pi package đóng gói bốn loại tài nguyên qua npm, git hoặc đường dẫn; manifest
pitrongpackage.jsonchọn tệp, bộ lọc trong settings thu hẹp, phiên bản npm chính xác và ref git thì được ghim, vàpi configbật tắt bằng mẫu+/-thay vì xoá.