Pi từ mã nguồnpi v1.4.0

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.

  • 43 phút đọc
  • 69 đoạn code
  • 19 mục
Trong chương này19 mục
  1. Vai trò
  2. Mô hình tư duy
  3. Nạp một extension: jiti, virtual module, built-in
  4. Nạp tài nguyên và project trust
  5. Bề mặt API: factory và các hàm register
  6. Context, UI và các lời gọi tới session
  7. Tool exposure, namespace và prepareLoadout
  8. Danh mục sự kiện và cách runner phát chúng
  9. Lần theo một lời gọi tool bị sửa và bị chặn
  10. Ba ví dụ thật: plan mode, subagent, todo
  11. Skills
  12. Prompt templates
  13. Themes
  14. Pi packages
  15. Keybindings và provider tự viết
  16. Quyết định thiết kế
  17. Cạm bẫy và trường hợp biên
  18. Đọc code ở đâu
  19. 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:

Phần docs của system prompt: Pi tự chỉ chỗ tài liệu của mình
pi/packages/coding-agent/src/core/system-prompt.tsdòng 162–169TypeScript
		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)`;
Đường dẫn được giải bằng helper trong 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ạiDạngThêm được gì
ExtensionTệp .ts/.js export một factoryTool, lệnh /, phím tắt, cờ CLI, xử lý sự kiện, provider, MCP server, virtual model, renderer, UI
SkillThư mục chứa SKILL.mdHướng dẫn chuyên biệt, chỉ nạp vào ngữ cảnh khi cần
Prompt templateTệp MarkdownLệnh /tên mở rộng thành một prompt có tham số
ThemeTệp JSONBảng màu cho TUI và bản xuất HTML
Pi packageThư mục, gói npm hoặc repo gitPhâ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
Từ tệp trên đĩa tới hook trong vòng lặp agent. Runner và sổ tool được dựng lại ở mỗi /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à on ghi vào đối tượng Extension củ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 sourceInfo cho 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 /reload tạ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, project hoặc temporary (CLI); origin là top-level hoặc package.
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ó.)

resolve(): gói, mục trong settings, thư mục tự khám phá, rồi built-in
pi/packages/coding-agent/src/core/package-manager.tsdòng 926–942, 975–995TypeScript
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);}
Phần lược (dòng 943 đến 974) giải các mục 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ề:

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á
pi/packages/coding-agent/src/core/package-manager.tsdòng 186–204TypeScript
/** * 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:

loadExtensionModule(): chọn cách phân giải module theo kiểu chạy
pi/packages/coding-agent/src/core/extensions/loader.tsdòng 41–45, 562–593TypeScript
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;}
Binary và bản Node đóng gói dùng 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".
virtual-modules.ts: tên import trỏ thẳng vào module đã nạp của host
pi/packages/coding-agent/src/core/extensions/virtual-modules.tsdòng 10–32TypeScript
// 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,
Tên gói cũ @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:

initializeExtension() và loadExtension(): factory lỗi thì extension bị bỏ, các extension khác vẫn nạp
pi/packages/coding-agent/src/core/extensions/loader.tsdòng 616–635, 646–659TypeScript
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}` };	}
Lỗi của một extension trở thành một mục trong 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:

createExtensionAPI(): trạng thái loading, active, failed
pi/packages/coding-agent/src/core/extensions/loader.tsdòng 253–271, 533–551TypeScript
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();	},};
Nhờ vậy một extension hỏng giữa chừng không để lại một provider đăng ký dở trong danh mục mô hình.

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:

createExtensionRuntime(): hành động chưa dùng được khi đang nạp
pi/packages/coding-agent/src/core/extensions/loader.tsdòng 160–175TypeScript
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:

Bốn extension dựng sẵn
pi/packages/coding-agent/src/extensions/index.tsdòng 7–14TypeScript
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 },];
InlineExtension: hidden, replaceable, builtin
pi/packages/coding-agent/src/core/extensions/types.tsdòng 2047–2075TypeScript
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:

loadExtensionPaths(): tệp qua jiti, builtin: qua factory có sẵn
pi/packages/coding-agent/src/core/resource-loader.tsdòng 718–749TypeScript
/** 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:

omitReplacedExtensions(): extension bên thứ ba thay chỗ built-in
pi/packages/coding-agent/src/core/resource-loader.tsdòng 120–151TypeScript
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;	});}
Ví dụ: một extension MCP của bên thứ ba đăng ký /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:

wrapToolDefinition(): thêm tham số thứ năm, ctx, cho execute()
pi/packages/coding-agent/src/core/tools/tool-definition-wrapper.tsdòng 8–30TypeScript
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):

AgentSession.reload(): tắt runtime cũ, nạp lại tài nguyên, dựng runner mới
pi/packages/coding-agent/src/core/agent-session.tsdòng 3720–3724, 3733–3746, 3753–3758TypeScript
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");	}
Giá trị cờ CLI của runtime cũ được chuyển sang runtime mới. Interactive mode từ chối /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ì.

Tài nguyên dự án đòi quyết định trust
pi/packages/coding-agent/src/core/trust-manager.tsdòng 30–39TypeScript
const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [	"settings.json",	"mcp.json",	"extensions",	"skills",	"prompts",	"themes",	"SYSTEM.md",	"APPEND_SYSTEM.md",] as const;
Thêm vào đó là .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:

Lượt nạp trước trust, quyết định, rồi nạp lại settings theo quyết định
pi/packages/coding-agent/src/core/resource-loader.tsdòng 505–528TypeScript
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:

loadCurrentExtensionSet(): built-in chờ tới lượt cuối
pi/packages/coding-agent/src/core/resource-loader.tsdòng 680–691, 699–711TypeScript
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;
Lý do nằm ngay trong chú thích: settings dự án có thể tắt một built-in, và một extension đã nạp thì không gỡ ra được. Hệ quả là built-in không bao giờ xử lý được 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:

emitProjectTrustEvent(): người quyết định đầu tiên thắng
pi/packages/coding-agent/src/core/extensions/runner.tsdòng 302–329TypeScript
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 };}
Kết quả có 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:

loadFinalExtensionSet(): dùng lại instance của lượt đầu, nạp phần còn lại vào cùng runtime
pi/packages/coding-agent/src/core/resource-loader.tsdòng 751–781TypeScript
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ủa lượt đầu không bị chạy factory lần thứ hai: đối tượng 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 trustSettings 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ôngExtension 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:

ExtensionFactory
pi/packages/coding-agent/src/core/extensions/types.tsdòng 2044–2045TypeScript
/** 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ómThành viênGhi chú
Sự kiệnon(event, handler)41 overload, mỗi sự kiện một kiểu kết quả; trả hàm huỷ đăng ký.
ToolregisterToolTool mô hình gọi được; xem ToolDefinition bên dưới.
Lệnh, phím, cờregisterCommand, registerShortcut, registerFlag, getFlagLệnh /tên; phím tắt TUI; cờ dòng lệnh --tên.
VẽregisterMessageRenderer, registerEntryRenderer, registerToolRenderer, registerMarkdownTransformerChỉ ảnh hưởng hiển thị, không ảnh hưởng ngữ cảnh mô hình.
Hành động lên phiênsendMessage, sendUserMessage, appendEntry, setSessionName, getSessionName, setLabelXem mục kế tiếp.
Tool và cấu hìnhexec, getActiveTools, setActiveTools, getAllTools, getSettings, getCommandssetActiveTools đổi tập tool khai báo cho mô hình.
Mô hìnhsetModel, getThinkingLevel, setThinkingLevelĐổi cho phiên hiện tại, không đổi mặc định cho phiên mới.
ProviderregisterProvider (hai dạng), unregisterProviderMục provider.
MCPregisterMcpServer, unregisterMcpServer, getMcpServersCấu hình giống một mục trong mcp.json; phần kết nối thuộc chương Codemode.
Virtual modelregisterVirtualModel, unregisterVirtualModelMộ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ạceventsEvent 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 đó:

ExtensionHandler và ExtensionAPI: on() và registerTool()
pi/packages/coding-agent/src/core/extensions/types.tsdòng 1585–1602, 1661–1673TypeScript
/** 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ì#

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() và registerCommand(): kiểm tra tối thiểu, ghi vào Extension
pi/packages/coding-agent/src/core/extensions/loader.tsdòng 292–321TypeScript
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#

RegisteredCommand: handler nhận chuỗi tham số thô và một command context
pi/packages/coding-agent/src/core/extensions/types.tsdòng 1569–1579TypeScript
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.

createContext(): mỗi getter kiểm tra ctx còn hiệu lực
pi/packages/coding-agent/src/core/extensions/runner.tsdòng 880–912TypeScript
/** * 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;		},
Sau khi runner bị vô hiệu, mọi lần đọc qua ctx cũ ném lỗi kèm hướng dẫn dùng 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#

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#

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:

bindCore(): hành động thật của pi.*
pi/packages/coding-agent/src/core/agent-session.tsdòng 3446–3471, 3482–3492TypeScript
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:

sendCustomMessage(): nextTurn, steer/followUp, mở lần chạy, hay hoãn tới cuối lượt
pi/packages/coding-agent/src/core/agent-session.tsdòng 2357–2360, 2370–2393TypeScript
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);	}}
Chú ý mặc định: khi agent đang chạy và không truyền 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áiCất ởVì sao
Trạng thái của tool, phải đi theo nhánh đang hoạt độngdetails của kết quả toolFork 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ấypi.appendEntry()Entry custom, bị bỏ qua khi dựng ngữ cảnh.
Nội dung cần cho mô hình thấypi.sendMessage()Entry custom_message, vào ngữ cảnh.
Dữ liệu vượt ra ngoài một phiênLưu trữ ngoàiTệ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:

ToolExposure: năm cách mô hình chạm tới một tool
pi/packages/coding-agent/src/core/extensions/types.tsdòng 501–516TypeScript
/** * 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";
exposureKhai báo cho mô hìnhGọi được qua executeToolBật khi đăng ký
direct (mặc định)khi đang bậtkhi đang bậtcó
model-onlykhi đang bậtkhông bao giờcó
codemodechỉ khi bật tường minhluôn, và được tool codemode liệt kêkhông
deferredchỉ khi bật tường minhluôn, nhưng chỉ tool_search tìm rakhông
hiddenkhôngkhôngkhô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:

_getCallableTools(): direct đang bật, cộng mọi codemode/deferred đã đăng ký
pi/packages/coding-agent/src/core/agent-session.tsdòng 1578–1587TypeScript
/** * 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:

ToolLoadout và ToolLoadoutChanges
pi/packages/coding-agent/src/core/extensions/types.tsdòng 551–574TypeScript
/** 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
Chữ đậm là các sự kiện mà handler có thể đổi diễn biến, bằng giá trị trả về hoặc bằng cách sửa tại chỗ; còn lại chỉ là thông báo. Thứ tự hook của một yêu cầu và của một lời gọi tool lấy từ docs/extensions.md:94.

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ómSự kiệnKết quả handler
Khởi độngproject_trust, session_start, resources_discover, mcp_servers_changeproject_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, compactionsession_before_switch, session_before_fork, session_before_tree, session_before_compact; session_tree, session_compact, session_compact_failed, session_shutdown, session_info_changedCá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àoinput, user_bashinput: 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ìnhmodel_select, thinking_level_selectThông báo.
UI, cacheui_prompt_start, ui_prompt_end, cache_warming_decisioncache_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:

snapshotEventHandlers(): theo thứ tự nạp extension, rồi thứ tự đăng ký
pi/packages/coding-agent/src/core/extensions/runner.tsdòng 276–278TypeScript
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#

emit(): mỗi handler trong try/catch riêng; các sự kiện before có thể huỷ
pi/packages/coding-agent/src/core/extensions/runner.tsdòng 1096–1125TypeScript
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.

emitContext(): hai pha, trên một bản sao
pi/packages/coding-agent/src/core/extensions/runner.tsdòng 1300–1322, 1336–1351TypeScript
/** * 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.
restoreSystemMessages(): không đổi thì giữ nguyên, có đổi thì đặt một system message ở đầu
pi/packages/coding-agent/src/core/extensions/runner.tsdòng 284–300TypeScript
/** * 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;}
Hai nhánh này có lý do khác nhau. Danh sách không đổi thì mọi message system giữ nguyên vị trí, nên mô hình hỗ trợ system giữa hội thoại vẫn trúng tiền tố cache. Danh sách có đổi thì trạng thái prompt và tool được phát lại thành một message system duy nhất ở đầu, nên cắt tỉa hay cửa sổ trượt không làm mất chúng.

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:

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.

permission-gate.ts: chặn bằng { block: true, reason }
pi/packages/coding-agent/examples/extensions/permission-gate.tsdòng 10–34TypeScript
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]") }] };
  });
}
Tool 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#

Đ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#

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:

pi-ai: tham số đã kiểm tra là bản structuredClone của tham số mô hình gửi
pi/packages/ai/src/utils/validation.tsdòng 317–323TypeScript
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:

_beforeToolCall(): event.input chính là đối tượng tham số đã kiểm tra
pi/packages/coding-agent/src/core/agent-session.tsdòng 652–681TypeScript
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)}`);	}}
Hook đọc 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.
emitToolCall(): cùng một event cho mọi handler, block đầu tiên thắng, không bắt lỗi
pi/packages/coding-agent/src/core/extensions/runner.tsdòng 1249–1267TypeScript
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):

_afterToolCall(): chuỗi tool_result, rồi chuẩn hoá ảnh
pi/packages/coding-agent/src/core/agent-session.tsdòng 684–723TypeScript
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,	};
Thu nhỏ ảnh chạy sau hook, nên ảnh do extension chèn vào cũng được chuẩn hoá theo giới hạn của mô hình đang dùng.

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"}]}}
Lời gọi 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:

  1. tool_execution_start(call_1)args là tham số gốc, không có timeout.
  2. tool_call(call_1)bash-guard gán timeout = 120 tại chỗ.
  3. tool_execution_start(call_2)Vẫn trong vòng chuẩn bị tuần tự.
  4. tool_call(call_2)Trả block; các handler tool_call sau không chạy.
  5. tool_execution_end(call_2)isError: true, phát ngay.
  6. tool_execution_update(call_1)*npm test đang chạy.
  7. tool_result(call_1)bash-guard che khoá trong output.
  8. tool_execution_end(call_1)Kèm durationMs.
  9. message_end(toolResult call_1), message_end(toolResult call_2)Thứ tự gốc; mỗi cái thành một entry.
  10. 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:

Tắt edit/write bằng setActiveTools, cất trạng thái bằng appendEntry
pi/packages/coding-agent/examples/extensions/plan-mode/index.tsdòng 104–123TypeScript
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,	});}
Chặn bash không nằm trong danh sách cho phép
pi/packages/coding-agent/examples/extensions/plan-mode/index.tsdòng 163–174TypeScript
// 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.
Chèn chỉ dẫn plan mode vào đầu mỗi lần chạy, ẩn khỏi UI
pi/packages/coding-agent/examples/extensions/plan-mode/index.tsdòng 203–211, 228–232TypeScript
	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:

runSingleAgent(): chạy pi ở JSON mode, không lưu session
pi/packages/coding-agent/examples/extensions/subagent/index.tsdòng 298–305, 336–348TypeScript
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"],		});
Không có 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:

Mỗi kết quả tool mang một ảnh chụp đầy đủ của trạng thái
pi/packages/coding-agent/examples/extensions/todo.tsdòng 157–170TypeScript
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,	};}
Dựng lại trạng thái từ nhánh hiện tại khi phiên mở và khi đi trong cây
pi/packages/coding-agent/examples/extensions/todo.tsdòng 105–133TypeScript
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));
Ảnh chụp cuối cùng trên nhánh thắng, nên việc dựng lại chỉ là "lấy cái cuối". 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).

SkillFrontmatter và Skill
pi/packages/coding-agent/src/core/skills.tsdòng 67–81TypeScript
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ả:

validateName() và validateDescription(): 64 ký tự, a-z0-9 và gạch nối, mô tả bắt buộc
pi/packages/coding-agent/src/core/skills.tsdòng 92–127TypeScript
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:

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ểmKết quả
valid-skillĐúng chuẩnNạp, không cảnh báo
name-mismatchname: different-name khác tên thư mụcNạp với tên trong frontmatter, không cảnh báo
invalid-name-charsInvalid_NameNạp, cảnh báo ký tự không hợp lệ
long-name, consecutive-hyphensTên quá 64 ký tự; bad--nameNạp, có cảnh báo
missing-description, no-frontmatterKhông có mô tảBỏ, cảnh báo "description is required"
invalid-yamldescription: [unclosed bracketBỏ, cảnh báo lỗi phân tích YAML
multiline-descriptionMô tả | nhiều dòngNạp, giữ nguyên các dòng
unknown-fieldauthor, versionNạp, trường lạ bị bỏ qua
root-skill-preferredCó SKILL.md ở gốc và một SKILL.md lồng bên trongChỉ skill gốc được nạp, tên lấy từ thư mục
nested/child-skillThư mục cha không có SKILL.mdTìm thấy nhờ đệ quy
disable-model-invocationdisable-model-invocation: trueNạp, nhưng không có trong system prompt
skills-collision/{first,second}/calendarHai skill cùng tên calendarGiữ 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#

formatSkillsForPrompt(): XML theo chuẩn Agent Skills, bỏ skill disable-model-invocation
pi/packages/coding-agent/src/core/skills.tsdòng 358–388TypeScript
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ó:

Chọn tool đọc skill: read, rồi bash, rồi indirect
pi/packages/coding-agent/src/core/system-prompt.tsdòng 174–182TypeScript
// 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;}
Nếu cả 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).

PromptTemplate
pi/packages/coding-agent/src/core/prompt-templates.tsdòng 12–19TypeScript
export interface PromptTemplate {	name: string;	description: string;	argumentHint?: string;	content: string;	sourceInfo: SourceInfo;	filePath: string; // Absolute path to the template file}
Tên lấy từ tên tệp; mô tả từ frontmatter hoặc dòng đầu, cắt ở 60 ký tự
pi/packages/coding-agent/src/core/prompt-templates.tsdòng 134–158TypeScript
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:

.pi/prompts/is.md: một template thật trong repo Pi
pi/.pi/prompts/is.mddòng 1–5Markdown
---description: Analyze GitHub issues (bugs or feature requests)argument-hint: "<issue>"---Analyze GitHub issue(s): $ARGUMENTS

Khi 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ế:

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:

ThemeJsonSchema: name, appearance, vars, colors, export; không nhận trường lạ
pi/packages/coding-agent/src/modes/interactive/theme/theme-schema.tsdòng 52–90TypeScript
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,	},);
Schema được tách khỏi 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:

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:

parseSource(): npm:, đường dẫn cục bộ, URL git, còn lại coi là đường dẫn
pi/packages/coding-agent/src/core/package-manager.tsdòng 1486–1511TypeScript
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#

PiManifest: trường pi trong package.json
pi/packages/coding-agent/src/core/pi-manifest.tsdòng 4–9TypeScript
export interface PiManifest {	extensions?: string[];	skills?: string[];	prompts?: string[];	themes?: string[];}

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": "*" }
}
Gói do host cung cấp khai báo trong 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#

update(): npm đã ghim bị bỏ qua, git luôn được đối chiếu
pi/packages/coding-agent/src/core/package-manager.tsdòng 1136–1147TypeScript
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).

KeybindingsManager: đọc keybindings.json, đổi tên cũ, bỏ giá trị không hợp lệ
pi/packages/coding-agent/src/core/keybindings.tsdòng 329–357TypeScript
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:

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ũ:

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 đặt true và 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ào session_start.
  • Tham số đã sửa không hiện ở đâu cả. tool_execution_start mang 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_result khô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 qua afterToolCall. Một extension che dữ liệu nhạy cảm không thể dựa vào tool_result để thấy văn bản do handler tool_call khác trả về trong reason.
  • Handler tool_call né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_end chư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 await tuần tự, kể cả message_update và 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ếu proc.killed còn false (exec.ts:52). Theo tài liệu Node, killed thành true ngay 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ọi exec chờ 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ền triggerTurn: false thì 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ý /review thì người dùng phải gõ /review:1 hoặ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ó name mang 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.md và b.md có description nhưng thiếu name đều tên là skills, và tệp thứ hai bị loại vì trùng tên.
  • Không có read lẫn bash thì không có skill. Chạy pi --tools grep,find làm mục skill biến mất khỏi system prompt; /skill:tên vẫ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.ts không làm đúng điều chú thích của nó ở v1.4.0. Tool của nó gọi pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" }) (reload-runtime.ts:29). Nhưng từ bản 0.84.2, sendUserMessage chỉ 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-runtime thay vì chạy lệnh.
  • Ví dụ plan-mode/ khôi phục trạng thái từ mọi entry. Nó lấy entry plan-mode cuối cùng trong getEntries() (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ủa todo.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ả usage trong kết quả (docs/extensions.md:249); subagent/ chỉ ghi usage của tiến trình con vào details để hiển thị (index.ts:708).
  • Chối trust không chặn hết. AGENTS.md/CLAUDE.md của thư mục vẫn vào system prompt, và sessionDir củ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
1packages/coding-agent/docs/extensions.mdVòng đời, luật gộp handler, tool exposure, trạng thái, UI theo chế độ. Đọc hết, 400 dòng.
2packages/coding-agent/examples/extensions/README.md, packages/coding-agent/examples/extensions/permission-gate.ts, packages/coding-agent/examples/extensions/todo.tsMột cổng tool_call; một tool có trạng thái theo nhánh và UI tuỳ biến.
3types.ts:1592, 579, 700ExtensionAPI, ToolDefinition, rồi toàn bộ sự kiện và kiểu kết quả.
4package-manager.ts:926, 2419Mọi tài nguyên đến từ đâu, thứ tự ưu tiên, built-in, cổng trust trong lúc khám phá.
5resource-loader.ts:505Hai lượt nạp quanh quyết định trust; builtin:; skill, prompt, theme, context file.
6loader.ts:160, packages/coding-agent/src/core/extensions/virtual-modules.tsRuntime với stub, API hai pha, jiti và ba cách phân giải module, cache factory.
7packages/coding-agent/src/core/project-trust.ts, packages/coding-agent/src/core/trust-manager.tsThứ tự quyết định trust, trust.json, tài nguyên đòi trust.
8runner.ts:417, 884, 1036Bind 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.
9agent-session.ts:644, 1303, 3318Hook tool, chuyển sự kiện agent sang extension, session_start, resources_discover, bindCore, _buildRuntime, reload.
10agent-loop.ts:518, 869Tuần tự hay song song, prepareToolCall, kết quả immediate, afterToolCall.
11packages/coding-agent/examples/extensions/plan-mode/index.ts, subagent/index.ts:270Ghép nhiều sự kiện thành một tính năng; chạy pi con ở JSON mode.
12packages/coding-agent/src/core/skills.ts, packages/coding-agent/docs/skills.md, packages/coding-agent/test/skills.test.tsFrontmatter, kiểm tra hợp lệ, định dạng trong system prompt; các fixture biên.
13packages/coding-agent/src/core/prompt-templates.ts, packages/coding-agent/docs/prompt-templates.md, .pi/prompts/is.md, .pi/prompts/pr.mdTách tham số, thay chỗ giữ; template thật của repo.
14theme.ts:646, packages/coding-agent/src/modes/interactive/theme/theme-schema.ts, packages/coding-agent/docs/themes.mdTheme toàn cục dùng chung giữa các bộ nạp module, watcher, schema.
15package-manager-cli.ts:747, packages/coding-agent/docs/packages.mdpi install/remove/update/list/config, trust cho lệnh gói.
16packages/coding-agent/src/core/keybindings.ts, packages/coding-agent/docs/keybindings.md, packages/coding-agent/docs/custom-provider.mdBả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à /reload nạ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ượng Extension, 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 khi AgentSession bind 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_call và user_bash, nơi lỗi nghĩa là chặn.
  • tool_call đi qua hook beforeToolCall: event.input là 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; block hay một exception thành kết quả tool lỗi ngay. tool_result chỉ thấy lời gọi đã thực sự chạy.
  • Trạng thái của extension nên sống trong session: details của kết quả tool khi phải đi theo nhánh, appendEntry khi không vào ngữ cảnh, sendMessage khi mô hình cần thấy; dựng lại từ getBranch() trong session_start.
  • Skill công bố dần: system prompt chỉ có tên, mô tả, đường dẫn; mô hình đọc SKILL.md khi 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 pi trong package.json chọ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 config bật tắt bằng mẫu +/- thay vì xoá.

Mục lục

Giao diện