Pi từ mã nguồn pi v1.4.0 · 4a080252f

Tài liệu học · Pi coding agent, Pi 1.0, Pi Durable, Chord

Pi, đọc từ mã nguồn

Pi hoạt động thế nào, được xây ra sao, vì sao lại thiết kế như vậy, và dữ liệu của nó trông ra sao, giải thích bằng tiếng Việt và chứng minh bằng chính code của Pi.

Mã nguồn ghim tại earendil-works/pi v1.4.0, commit 4a080252f (2026-10-22). 812 đoạn code thật (21.049 dòng) từ 591 file. Cập nhật 2026-10-22.

1Cách dùng tài liệu này#

Đây là tài liệu học về Pi, một coding agent chạy trong terminal, cùng các thư viện bên dưới nó: Pi 1.0, Pi Durable và Chord. Mục tiêu không phải hướng dẫn sử dụng, mà là giúp bạn hiểu Pi từ bên trong: nó hoạt động thế nào, được triển khai ra sao, vì sao được thiết kế như vậy, và dữ liệu của nó có hình dạng gì. Mỗi khẳng định kỹ thuật đều trỏ tới code thật, kèm đường dẫn file và số dòng, để bạn mở đúng chỗ đó và tự kiểm chứng.

Bạn sẽ đọc thoải mái nhất nếu đã quen TypeScript (kiểu, async/await, generic) và biết sơ qua cách gọi API của mô hình ngôn ngữ: tin nhắn theo vai trò, gọi tool (tool calling), và stream. Những khái niệm khác được định nghĩa khi xuất hiện lần đầu.

Nguồn sự thật: một bản clone ghim cố định#

Toàn bộ code trong tài liệu được trích tự động từ bản clone của github.com/earendil-works/pi tại tag v1.4.0 (commit 4a080252f, phát hành 2026-10-21), bản phát hành mới nhất lúc viết tài liệu. Pi đổi rất nhanh (từ 1.0 lên 1.4 chỉ trong ba tuần), nên số dòng chỉ đúng với đúng commit này. Bản clone nằm ở thư mục pi/ cạnh tài liệu; nếu cần tạo lại:

git clone https://github.com/earendil-works/pi.git
cd pi
git checkout v1.4.0     # detached HEAD tại 4a080252f; mọi số dòng trong tài liệu khớp với trạng thái này

Thứ tự tin cậy khi các nguồn mâu thuẫn: code ở v1.4.0, rồi tới tài liệu trong repo (README và thư mục docs/ của từng package; trang pi.dev/docs là bản xuất bản của packages/coding-agent/docs), cuối cùng là blog của Earendil và các tác giả. Ý kiến hay số liệu lấy từ blog luôn được ghi rõ nguồn.

Cách đọc một khối code#

Mỗi khối code là một đoạn file thật, được build script đọc thẳng từ bản clone theo số dòng và kiểm tra rằng nó chứa đúng thứ được mô tả. Ví dụ, đây là toàn bộ file điểm vào của pi-agent-core:

Tiêu đề nói đoạn code này cho thấy điều gì
pi/packages/agent/src/index.tsdòng 1–5TypeScript
export * from "./agent.ts";export * from "./agent-loop.ts";export * from "./proxy.ts";export { setDefaultStreamFn } from "./stream-fn.ts";export * from "./types.ts";
Phần ghi chú dưới code (nếu có) giải thích nhanh một chi tiết đáng để ý.
  • Đường dẫn như pi/packages/agent/src/index.ts là vị trí trong bản clone. Bấm vào nó để mở đúng các dòng đó trên GitHub, tại đúng commit được ghim, nên liên kết không bao giờ trỏ lệch dù repo đã đổi.
  • Số dòng bên trái là số dòng thật trong file. Dòng được tô nền (như dòng 2 ở trên) là dòng văn bản đang nói tới.
  • Chép vị trí chép chuỗi dạng pi/packages/agent/src/index.ts:1; dán vào editor để mở đúng chỗ, chẳng hạn code -g pi/packages/agent/src/agent-loop.ts:172 với VS Code hoặc vim +172 pi/packages/agent/src/agent-loop.ts. Chép code chép phần code, không kèm số dòng.
  • Dòng “⋯ lược bớt dòng a–b” nghĩa là đoạn giữa bị bỏ qua để giữ phần đầu và phần cuối của một hàm dài. Khối dài hơn 40 dòng được thu gọn, bấm Hiện đủ để xem hết.
  • Thụt lề chung của đoạn trích được cắt bớt để dễ đọc; trong file gốc, code dùng tab.

Trong văn bản, những tham chiếu nhỏ màu xanh dạng packages/agent/src/agent-loop.ts:172 cũng là liên kết tới đúng dòng trên GitHub; rê chuột để thấy đường dẫn trong bản clone.

Quy ước trong văn bản#

  • Khối chữ đơn cách không có đường dẫn file và được ghi “Ví dụ minh hoạ” là ví dụ viết tay (một dòng JSONL mẫu, một chuỗi sự kiện) để minh hoạ; nó khớp với kiểu dữ liệu thật nhưng không phải code của Pi. Chỉ những khối có đường dẫn pi/… mới là code trích từ repo.
  • Thuật ngữ tiếng Anh chuẩn được giữ nguyên và giải nghĩa ở lần đầu: vòng lặp agent (agent loop), lượt (turn), lời gọi tool (tool call), cửa sổ ngữ cảnh (context window), compaction (nén ngữ cảnh)… Chương cuối gom tất cả thành bảng thuật ngữ.
  • Tên định danh trong code luôn đặt trong khung code và giữ nguyên chữ hoa chữ thường.

Ba lộ trình đọc#

Tài liệu được sắp từ nền lên ngọn, khớp với đồ thị phụ thuộc giữa các package (chương Bản đồ monorepo). Bạn có thể đọc tuần tự, hoặc chọn một lộ trình:

Chương So sánh và lộ trình học ở cuối có thêm các bài tập lần theo code, để bạn kiểm tra mình đã hiểu tới đâu.

2Pi là gì: lịch sử và triết lý#

Pi tự mô tả bằng đúng một câu ở đầu README: “a minimal, extensible agent harness that you can make your own”, một harness cho agent, tối giản, mở rộng được, và để bạn biến thành của riêng mình (README.md:15). Để hiểu mã nguồn, trước hết cần hiểu ba từ trong câu đó, và vì sao những người viết ra Pi chọn chúng.

Harness là phần mềm bao quanh một mô hình ngôn ngữ để biến nó thành agent. Bài “What is a Harness?” của Earendil tóm lại bằng công thức Agent = Model + Harness và liệt kê bốn việc mà mọi harness đều làm: đưa ra chỉ dẫn (system prompt), mô tả và cung cấp công cụ (tools), điều khiển vòng lặp agent (agentic loop), và một lớp phiên dịch để chạy được với nhiều mô hình khác nhau (earendil.com, 2026-08-20). Cả tài liệu này thực chất là đi xem Pi làm bốn việc đó như thế nào, ở mức từng dòng code.

System prompt
Chỉ dẫn ngắn, cộng file ngữ cảnh như AGENTS.md của dự án. Xem chương Công cụ và system prompt.
Tools
Bốn công cụ mặc định read, bash, edit, write; phần còn lại do extension thêm vào.
Agent loop
Gọi mô hình, chạy tool, lặp lại cho tới khi mô hình không gọi tool nữa. Xem chương pi-agent-core.
Lớp phiên dịch
Một API thống nhất cho Anthropic, OpenAI, Google và nhiều provider khác. Xem chương pi-ai.

Dòng thời gian#

Pi bắt đầu là dự án cá nhân của Mario Zechner (tác giả libGDX), trong một monorepo tên pi-mono, các package npm mang scope @mariozechner. Lịch sử git trong bản clone ghi lại khá rõ quá trình đó; các ngày dưới đây lấy từ commit và tag của repo (trừ các bài blog, có ghi nguồn).

NgàySự kiệnBằng chứng
2025-08-09Commit đầu tiên: monorepo với npm workspaces; các package đầu tiên là agent, tui, pods (triển khai vLLM).commit a74c5da11
2025-08-17Ra đời pi-ai, “unified AI package with OpenAI, Anthropic, and Gemini support”.commit f064ea0e1
2025-10-17Package coding-agent xuất hiện (“Agent package + coding agent WIP”).commit ffc9be886
2025-11-30Mario viết “What I learned building an opinionated and minimal coding agent”, bài giải thích triết lý gốc.mariozechner.at
2026-01-31Armin Ronacher viết “Pi: The Minimal Agent Within OpenClaw”: Pi là lõi agent bên trong OpenClaw.lucumr.pocoo.org
2026-04-08Earendil Inc. (công ty public benefit của Armin Ronacher và Colin Daymond Hanna) mua lại dự án Pi; Mario gia nhập Earendil.earendil.com
2026-04-30Gỡ mom (bot Slack) và pods khỏi monorepo.commit 0ed0d4343
2026-05-07Chuyển các package sang scope @earendil-works; repo thành earendil-works/pi.commit 3e5ad67e0
2026-08-05Tách pi-telemetry thành package riêng.commit 6b461b75b
2026-08-28Đặt nền móng Chord (“add Chord runtime foundation”).commit 28b49a6b3
2026-09-18Harness bền mang tên mã Pico được chuyển từ pi-agent-core sang package riêng: pi-durable.commit 080160162
2026-09-29Bản 0.99.0: codemode và MCP vào lõi, theme hệ thống, đăng nhập ChatGPT, virtual models, mô hình phân loại.CHANGELOG 0.99.0
2026-10-01Pi 1.0 phát hành cùng lúc với Pi Durable (thử nghiệm). Cùng ngày, harness thử nghiệm cũ bị gỡ khỏi pi-agent-core.CHANGELOG 1.0.0, commit 7fd478a2e
2026-10-05Thêm pi-env: môi trường thực thi từ xa cho Pi Durable, chạy qua SSH.commit ba03e03f2
2026-10-12Gỡ bộ client/server/protocol thử nghiệm của coding agent, nhường chỗ cho Pi Durable.commit 7472cda0d
2026-10-21Bản 1.4.0, phiên bản mà mọi trích dẫn trong tài liệu này dựa vào.tag v1.4.0

Tính tới v1.4.0, repo có 7.042 commit và 331 tag. Mario Zechner là tác giả của khoảng 3.955 commit, Armin Ronacher khoảng 910 (theo git shortlog -sn trên bản clone). Bài công bố Pi 1.0 nói Pi có “hàng trăm nghìn” người dùng mỗi tuần (earendil.com/posts/pi-1-0); con số này là tuyên bố của Earendil, không có trong repo.

Vì sao Pi ra đời#

Mario viết Pi vì các coding agent thương mại thời điểm đó đi ngược cách anh làm việc: tính năng ngày càng nhiều, system prompt thay đổi liên tục giữa các phiên bản, và người dùng gần như không thấy được mô hình thực sự nhận được gì. Bài blog năm 2025 liệt kê những thứ anh muốn có (mariozechner.at):

  • Kiểm soát ngữ cảnh. “Context engineering is paramount”: cái gì vào cửa sổ ngữ cảnh phải do người dùng quyết định, không bị harness chèn thêm.
  • Nhìn thấy mọi tương tác với mô hình, điều mà theo anh hầu như không harness nào cho phép.
  • Một định dạng session được tài liệu hoá rõ ràng, để có thể xử lý lại bằng công cụ khác.
  • Dễ dựng giao diện khác trên cùng một lõi agent (terminal, web, Slack…).
  • Chạy tốt với mô hình tự host, nhất là phần gọi tool, thứ mà các harness dựa trên lớp trừu tượng chung thường làm kém.

Nguyên tắc chọn tính năng được anh nói thẳng: “if I don't need it, it won't be built”. Bốn năm tháng sau, khi Pi đã là sản phẩm của Earendil, nguyên tắc đó vẫn giữ nhưng được diễn đạt lại thành “độ phức tạp phải tự chứng minh giá trị của nó” (bài “Pi, Minimal and Performant”).

Bảy nguyên tắc thiết kế#

Đọc code Pi sẽ dễ hơn nhiều khi biết các nguyên tắc sau, vì phần lớn quyết định kỹ thuật là hệ quả trực tiếp của chúng.

1. Lõi tối giản, mặc định mạnh

Pi chỉ có bốn công cụ mặc định và một system prompt ngắn; bài blog năm 2025 cho biết system prompt cộng định nghĩa tool lúc đó dưới 1.000 token. README nói rõ Pi “bỏ qua” những tính năng mà các agent khác coi là hiển nhiên, như sub-agent và plan mode, và mời bạn tự xây hoặc cài package làm điều đó theo cách của bạn (README.md:19). Những tính năng bị “bỏ qua” này vẫn tồn tại, nhưng dưới dạng ví dụ extension (chương Extensions đi qua plan-mode và subagent).

2. Người dùng làm chủ ngữ cảnh, và giữ prefix ổn định

Pi tránh tự ý sửa những gì đã gửi cho mô hình. Lý do không chỉ là minh bạch mà còn là tiền: prompt cache của provider chỉ trúng khi phần đầu (prefix) của chuỗi token giống hệt lần trước. Bài “Prompt Caching In Agents” giải thích Pi ưu tiên bản ghi chỉ-nối-thêm (append-oriented), không cắt tỉa ngữ cảnh cũ một cách hung hăng, và coi compaction là một lần “reset cache” có chủ đích (earendil.com/posts/prompt-caching). Nhiều tính năng của bản 1.0, như thông điệp hệ thống giữa hội thoại và nạp tool trì hoãn, tồn tại chính để giữ prefix không đổi (chương Pi 1.0).

3. Dễ uốn nắn: agent tự viết phần mở rộng cho chính nó

Extension là module TypeScript chạy ngay trong tiến trình Pi; factory của nó đăng ký tool, lệnh, phím tắt, provider, trình xử lý sự kiện và cả giao diện terminal (how-pi-works.md:43). Armin Ronacher mô tả cách dùng điển hình: thay vì cài extension của người khác, bạn bảo Pi tự viết hoặc chỉnh một extension có sẵn, tải lại nóng (hot reload), thử, và lặp lại. Để điều đó khả thi, Pi đi kèm tài liệu và ví dụ mà chính agent đọc được. Bài “What is a Harness?” cho biết cộng đồng đã chia sẻ hơn 5.000 extension.

4. Session là dữ liệu của bạn, mang đi được

Session được lưu thành file JSONL; mỗi entry có ID và trỏ tới entry cha, nên cả session là một cái cây (how-pi-works.md:27). Một session có thể chứa tin nhắn của nhiều provider khác nhau, nên Pi tránh phụ thuộc vào các tính năng riêng của từng provider. Bài “The Session You Cannot Take With You” đẩy ý này thành nguyên tắc: nhật ký sự kiện cục bộ là bản gốc, và trạng thái mà provider niêm phong (lý luận mã hoá, compaction mờ đục) không được là nơi duy nhất chứa ý nghĩa; Pi không bao giờ chuyển compaction bị niêm phong của provider này sang provider khác (earendil.com/posts/session-portability).

5. Không có hệ thống phân quyền: an toàn đến từ ranh giới hệ điều hành

Đây là quyết định gây tranh cãi nhất. Pi không có lớp phân quyền nào cho file, tiến trình, mạng hay credential; nó chạy với quyền của người đã khởi động nó (README.md:88–90). Lập luận của Mario: một agent đọc được dữ liệu, chạy được code và truy cập được mạng thì luôn có thể bị prompt injection lợi dụng để rò rỉ dữ liệu; các biện pháp như hỏi xác nhận từng lệnh hay danh sách tên miền chỉ cho cảm giác an toàn. Vì vậy tài liệu bảo mật yêu cầu coi lệnh và code do mô hình sinh ra là không đáng tin, và tìm an toàn ở container, máy ảo hoặc sandbox (security.md:3). Cơ chế project trust chỉ quyết định có nạp tài nguyên của thư mục dự án hay không, chứ không giới hạn những gì tool làm được (security.md:31–33).

6. Trung lập với mô hình

Lớp phiên dịch pi-ai nói chuyện trực tiếp với API của từng provider thay vì đi qua một thư viện trừu tượng chung, và một session có thể đổi mô hình giữa chừng. Earendil coi đây là cách trả quyền lựa chọn cho người dùng: cùng một harness, bạn so được kết quả và chi phí của Anthropic, OpenAI hay mô hình mã nguồn mở trên cùng công việc. Ở v1.4.0, pi-ai định nghĩa sẵn 42 provider: hãng gốc, nền tảng cloud, gateway, gói thuê bao, và cả mô hình chạy trên máy bạn. Chương Provider vẽ bản đồ đó và mọi cách kết nối.

7. Tối giản để rẻ và nhanh

Bài “Pi, Minimal and Performant” dẫn một benchmark nội bộ của Databricks: với cùng mô hình và mức suy nghĩ, Pi có tỷ lệ hoàn thành cao nhất với chi phí thấp hơn đáng kể so với Claude Code và Codex, và gửi ít ngữ cảnh hơn khoảng 3 lần mỗi lượt (earendil.com). Đây là số liệu do Earendil trích dẫn, không phải kết quả bạn kiểm chứng được từ repo.

Những điều Pi đã đổi ý, và những điều giữ nguyên#

Một dự án tối giản vẫn thay đổi khi bằng chứng thay đổi. Hiểu những lần “đổi ý” này giúp bạn nhận ra phần nào trong code là lớp mới đắp lên lõi cũ.

Chủ đềLập trường năm 2025Ở v1.4.0
MCPKhông hỗ trợ: mô tả tool của MCP chiếm 7–9% cửa sổ ngữ cảnh ngay từ đầu (ví dụ Playwright MCP 21 tool, 13,7k token); dùng CLI kèm README thay thế.MCP vào lõi từ 0.99.0, nhưng qua codemode: mô hình viết JavaScript gọi tool, và tool của MCP mặc định không nằm sẵn trong ngữ cảnh mà được tìm khi cần. Lý do được viết trong “You Said No MCP!”. Xem chương Codemode, MCP.
Giao diện terminalGhi nối vào scrollback gốc của terminal, như Claude Code và Codex.Từ 1.0, mặc định là toàn màn hình; chế độ cũ vẫn còn với tuiMode: "regular". Xem chương pi-tui.
Sub-agent, plan mode, todoKhông có trong lõi.Vẫn không có trong lõi coding agent; có trong ví dụ extension. Pi Durable thì xây subagent bằng tool và cuộc hội thoại do task sở hữu.
Chạy lâu, nhiều người cùng láiNgoài phạm vi.Thành một package riêng, thử nghiệm: Pi Durable, thay vì bẻ cong coding agent.

Pi 1.0 và Pi Durable#

Ngày 2026-10-01, Earendil phát hành Pi 1.0 như một harness “đã được tôi luyện” mà người và doanh nghiệp có thể dựa vào. Changelog của bản 1.0.0 khá ngắn, vì những thay đổi lớn đã vào từ 0.99.0 hai ngày trước đó:

Các tính năng nổi bật trong changelog 1.0.0 của coding agent
pi/packages/coding-agent/CHANGELOG.mddòng 324–334Markdown
## [1.0.0] - 2026-10-01### New Features- **Fullscreen by default** — The TUI now runs fullscreen. Set `tuiMode` to `"regular"` to keep the terminal's normal scrollback. See [Terminal and display](docs/settings.md#terminal-and-display).- **Leaner codemode** — About 40% fewer prompt tokens, and errors that tell the model how to recover. See [Codemode](docs/codemode.md).- **Image generation in codemode** — Scripts call `models.generateImages()` with the session's credentials. See [Generate images](docs/codemode.md#generate-images) and [Use image models](docs/models.md#use-image-models).- **Radius in `/login`** — Sign in with Radius and set up its MCP server in one step. See [Radius](docs/providers.md#radius).- **Anthropic copy code login** — Sign in when the browser runs on another machine. See [Authenticate interactively](docs/providers.md#authenticate-interactively).- **MCP OAuth hardening** — `oauth.authServerMetadataUrl`, RFC 9207 `iss` checks, credentials per server, and step-up sign-in that keeps granted scopes. See [Authenticate with OAuth](docs/mcp.md#authenticate-with-oauth).- **Header-only quiet startup** — `quietStartup: "header"` keeps the version and key hints and hides the rest. See [Terminal and display](docs/settings.md#terminal-and-display).

Bài công bố liệt kê các điểm mới tính gộp cả 0.99: codemode với MCP, mô hình không phải LLM (bộ phân loại Jev) và mô hình sinh ảnh, virtual model, nạp tool trì hoãn, làm ấm cache cho Anthropic, thông điệp hệ thống giữa hội thoại, theme mới và chế độ toàn màn hình. Chương Pi 1.0 đi qua từng tính năng và cơ chế của nó trong code.

Cùng ngày, Pi Durable ra mắt như một package riêng, gắn nhãn thử nghiệm (durable/CHANGELOG.md:185). Bài công bố mô tả rõ vấn đề nó giải quyết: coding agent chạy trên một máy, trong một terminal, do một người điều khiển; nếu tiến trình chết, bạn xem chuyện gì đã xảy ra rồi bảo nó làm tiếp. Earendil muốn một harness chạy được ở bất cứ đâu, với được từ nhiều bề mặt, giữ được số cuộc hội thoại gần như không giới hạn, sống sót qua lỗi bên trong lẫn bên ngoài, và cho nhiều người cùng điều khiển một agent. Thay vì bẻ cong coding agent, họ viết một nền mới, nơi “mọi thứ harness chạy, từ gọi mô hình tới chạy một tool, đều là một task” được ghi lại trước khi hiển thị (earendil.com/posts/pi-durable). Pi Durable không thay thế coding agent; nó dùng chung pi-ai và triết lý, còn trạng thái thì dựa trên Chord. Các chương Chord, Pi Durable và So sánh đi sâu vào phần này.

Văn hoá kỹ thuật bạn sẽ thấy trong code#

File AGENTS.md ở gốc repo là quy tắc chung cho cả người lẫn agent đóng góp vào Pi. Vài quy tắc giải thích những đặc điểm bạn sẽ gặp khi đọc code:

  • Cách giải thích thiết kế: vấn đề, ví dụ cụ thể hoặc một vết chạy ngắn, rồi mới tới giải pháp và lý do nó cần thiết (AGENTS.md:10–11). Tài liệu này cũng cố gắng giải thích theo đúng khuôn đó.
  • Chỉ dùng cú pháp TypeScript “xoá được”: không enum, không namespace, không parameter property, để Node chạy thẳng mã nguồn bằng chế độ bỏ kiểu (type stripping) mà không cần biên dịch (AGENTS.md:24). Vì vậy bạn sẽ thấy các class khai báo field tường minh rồi gán trong constructor.
  • Nhiều agent cùng làm việc trong một thư mục: quy tắc git giả định nhiều phiên pi chạy song song trên cùng working tree, nên cấm git add -A, git stash hay reset --hard (AGENTS.md:54). Pi được phát triển phần lớn bằng chính Pi; thư mục .pi/ của repo chứa prompt template và skill mà maintainer dùng hằng ngày (chương Bản đồ monorepo).
  • Issue và PR của người mới bị tự động đóng, maintainer xem lại mỗi ngày và mở lại những cái đạt chất lượng; lý do được ghi thẳng là chống quá tải và chống “slop” do agent sinh ra (CONTRIBUTING.md:79–81).
  • Coi thay đổi dependency là code cần review: ghim phiên bản chính xác, chặn bản phát hành npm trong ngày, khoá dependency bắc cầu cho bộ cài chính thức (README.md:168–180).

Tóm tắt#

  • Pi là một harness: system prompt, tool, vòng lặp agent và lớp phiên dịch mô hình, được giữ tối giản có chủ đích.
  • Dự án đi từ pi-mono cá nhân của Mario Zechner (2025) tới earendil-works/pi sau khi Earendil mua lại (2026-04), rồi 1.0 ngày 2026-10-01.
  • Các nguyên tắc cốt lõi: lõi nhỏ, người dùng kiểm soát ngữ cảnh và giữ prefix ổn định cho prompt cache, mở rộng bằng extension mà agent tự viết, session mang đi được, không có hệ thống phân quyền (an toàn đến từ ranh giới hệ điều hành), trung lập mô hình.
  • Pi Durable là một nền riêng cho agent chạy lâu, bền trước sự cố, nhiều người cùng lái; nó dùng chung pi-ai và xây trạng thái trên Chord.

3Bản đồ monorepo#

Repo earendil-works/pi là một monorepo dùng npm workspaces: mọi package nằm dưới packages/, cùng chung một package-lock.json và cùng số phiên bản (mọi package ở tag này đều là 1.4.0). Chương này vẽ bản đồ để bạn biết mỗi thư mục làm gì, package nào dựa trên package nào, và nên đọc theo thứ tự nào.

Mười một package#

Số dòng dưới đây đếm các file .ts/.tsx trong src/ của từng package ở v1.4.0 (không gồm test); số file test đếm trong test/. README gốc liệt kê bảy package công khai chính (README.md:72–84); bốn package còn lại là phụ trợ.

Thư mụcTên npmVai tròDòng srcFile testChương
ai@earendil-works/pi-aiAPI thống nhất cho mọi provider LLM, danh mục mô hình, OAuth27.854184pi-ai, provider
agent@earendil-works/pi-agent-coreVòng lặp agent: gọi mô hình, chạy tool, hàng đợi steer/follow-up2.5397agent-core
coding-agent@earendil-works/pi-coding-agentCLI pi: session, tool, extension, các chế độ chạy, SDK76.067321từ AgentSession trở đi
tui@earendil-works/pi-tuiThư viện giao diện terminal vẽ lại theo vi sai19.62353pi-tui
codemode@earendil-works/pi-codemodeSandbox JavaScript (QuickJS) mà khả năng duy nhất là gọi tool được tiêm vào3.0378Codemode
mcp@earendil-works/pi-mcpClient Model Context Protocol độc lập (stdio, HTTP, OAuth)3.2776Codemode, MCP
chord@earendil-works/chordRuntime có kiểu cho plugin, contract, state nhân bản và RPC qua JSON14.61262Chord
durable@earendil-works/pi-durableHarness bền: hội thoại, task và document lưu bằng commit nguyên tử27.259121Pi Durable
env@earendil-works/pi-envMôi trường thực thi từ xa cho Pi Durable: daemon nhỏ triển khai qua SSH2.3468pi-env
telemetry@earendil-works/pi-telemetryHợp đồng telemetry trung lập nhà cung cấp: span, event, metric1.5728telemetry
evals@earendil-works/pi-evalsBộ đánh giá coding agent bằng vitest (không phát hành như thư viện)1.4466evals

Bài công bố Pi Durable nói harness này khoảng 15.000 dòng không tính test. Ở v1.4.0, thư mục src/ của nó đã khoảng 27.000 dòng, vì gồm cả các backend storage, bộ kiểm thử tuân thủ trong src/testing/, môi trường Node và code mode bền; phần lõi harness vẫn nhỏ hơn nhiều.

Đồ thị phụ thuộc: hai chồng tách biệt#

Điều quan trọng nhất cần nhận ra khi nhìn các file package.json: Pi không phải một khối, mà là hai chồng dùng chung tầng dưới.

  Chồng coding agent (CLI pi)                    Chồng Pi Durable (thư viện, thử nghiệm)

      pi-telemetry                                    pi-telemetry
           │                                          │          │
           ▼                                          ▼          ▼
         pi-ai                                      pi-ai      chord      pi-codemode
           │                                          │          │            │
           ▼                                          └──────────┼────────────┘
     pi-agent-core    pi-tui    pi-mcp    pi-codemode            ▼
           │            │         │           │              pi-durable
           └────────────┴─────────┴─────┬─────┘                  │
                                        ▼                        ▼
                                 pi-coding-agent              pi-env
Mũi tên đi từ package nền tới package dùng nó. pi-coding-agent không phụ thuộc pi-durable hay chord; pi-durable không phụ thuộc pi-agent-core. Hai chồng gặp nhau ở pi-ai, pi-codemode và pi-telemetry.

Phụ thuộc nội bộ của coding agent nằm ngay đầu khối dependencies:

coding-agent dựa trên agent-core, ai, codemode, mcp và tui
pi/packages/coding-agent/package.jsondòng 43–48JSON
"dependencies": {	"@earendil-works/pi-agent-core": "1.4.0",	"@earendil-works/pi-ai": "1.4.0",	"@earendil-works/pi-codemode": "1.4.0",	"@earendil-works/pi-mcp": "1.4.0",	"@earendil-works/pi-tui": "1.4.0",

Còn Pi Durable dựa trên Chord, pi-ai, pi-codemode và pi-telemetry, không có pi-agent-core:

pi-durable có vòng lặp riêng, không dùng agent-core
pi/packages/durable/package.jsondòng 152–156JSON
"dependencies": {	"@earendil-works/chord": "1.4.0",	"@earendil-works/pi-ai": "1.4.0",	"@earendil-works/pi-codemode": "1.4.0",	"@earendil-works/pi-telemetry": "1.4.0",

Thứ tự build trong package.json gốc chính là một thứ tự topo của đồ thị trên: telemetry, chord, tui, codemode, mcp, ai, durable, env, agent, rồi coding-agent.

Workspaces và lệnh build của monorepo (dòng 16 rất dài, hãy cuộn ngang)
pi/./package.jsondòng 5–16JSON
"workspaces": [	"packages/*",	"apps/*",	"packages/coding-agent/examples/extensions/with-deps",	"packages/coding-agent/examples/extensions/custom-provider-anthropic",	"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",	"packages/coding-agent/examples/extensions/sandbox",	"packages/coding-agent/examples/extensions/gondolin"],"scripts": {	"clean": "npm run clean --workspaces",	"build": "cd packages/telemetry && npm run build && cd ../chord && npm run build && cd ../tui && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",

Phụ thuộc bên ngoài đáng chú ý#

Danh sách phụ thuộc bên ngoài cho biết mỗi package tự làm gì và giao gì cho thư viện khác:

  • pi-ai dùng SDK chính thức của từng provider (@anthropic-ai/sdk, openai, @google/genai, AWS Bedrock), partial-json để đọc đối số tool khi nó còn đang stream, và typebox cho schema của tool (ai/package.json).
  • pi-coding-agent dùng jiti để nạp extension viết bằng TypeScript lúc chạy, quickjs-wasi cho codemode, @silvia-odwyer/photon-node để xử lý ảnh, diff cho tool edit, highlight.js để tô màu code trong terminal, undici cho HTTP, proper-lockfile để khoá file cấu hình khi nhiều tiến trình cùng ghi (coding-agent/package.json).
  • pi-tui chỉ cần marked (parse Markdown) và get-east-asian-width (độ rộng ký tự CJK); phần còn lại, kể cả thuật toán vẽ, là tự viết.
  • pi-durable dùng @silvia-odwyer/photon (bản WebAssembly, chạy được cả trên Cloudflare), diff và typebox; còn lại đều là code của chính nó và Chord.

Các package thư viện công khai nhiều điểm vào con (subpath exports) để ứng dụng chỉ nạp phần mình cần. Ví dụ pi-ai có ./models, ./providers/*, ./api/*, ./oauth (ai/package.json:13), còn pi-durable tách riêng từng backend storage (./storage/sqlite/node, ./storage/sqlite/cloudflare, ./storage/jsonl/node, ./storage/indexeddb), các tool (./tools), code mode (./codemode) và bộ kiểm thử tuân thủ (./testing) (durable/package.json:8). Nhờ vậy một Durable Object trên Cloudflare không phải kéo theo code chỉ dành cho Node.

Ngoài packages/#

Đường dẫnNội dung
AGENTS.md, CONTRIBUTING.mdQuy tắc cho người và agent đóng góp; quy trình duyệt issue/PR.
.pi/Tài nguyên Pi của chính repo: prompt template (/cl kiểm changelog, /is phân tích issue, /pr review PR, /wr “wrap it”: changelog, commit, push), skill (release, add-llm-provider, interactive-testing, github-work) và vài extension nhỏ. Đây là cách maintainer dùng Pi để phát triển Pi.
scripts/Kiểm tra (pin dependency, import, install lock), phát hành, đóng gói, sinh danh mục mô hình.
test.sh, pi-test.shChạy test trong môi trường cô lập (HOME, tmp, cache riêng); chạy Pi từ mã nguồn ở bất kỳ thư mục nào.
flake.nix, nix/Build Pi bằng Nix (thêm ở 1.0.1), kèm bản ghim danh mục mô hình để build offline.
.github/CI và các workflow tự động đóng issue/PR của người mới.

Một prompt template của maintainer trông như sau: frontmatter có mô tả và gợi ý đối số, thân là chỉ dẫn, và $ARGUMENTS được thay bằng những gì gõ sau lệnh. Cơ chế này được giải thích trong chương Extensions.

Prompt template /is mà maintainer dùng để phân tích issue
pi/.pi/prompts/is.mddòng 1–5Markdown
---description: Analyze GitHub issues (bugs or feature requests)argument-hint: "<issue>"---Analyze GitHub issue(s): $ARGUMENTS

Quy ước kỹ thuật#

TypeScript chạy thẳng, không biên dịch khi phát triển
Từ 0.99.0 repo dùng TypeScript 7.0 (target ES2024) để kiểm kiểu và build, còn khi chạy từ mã nguồn thì dùng chế độ bỏ kiểu có sẵn của Node thay cho tsx (CHANGELOG:463). Hệ quả là quy tắc chỉ dùng cú pháp xoá được (AGENTS.md:24), và các import tương đối ghi rõ đuôi .ts.
Định dạng và lint bằng Biome
Thụt lề bằng tab, dòng tối đa 120 ký tự; file models.generated.ts được loại khỏi kiểm tra (biome.json:19–36). Trang này hiển thị tab rộng 2 ký tự.
Danh mục mô hình được sinh ra
Không ai sửa tay packages/ai/src/models.generated.ts; nó được sinh bằng script, còn dữ liệu mô hình chi tiết trong providers/data/ bị gitignore (AGENTS.md:28). Vì vậy bản clone của bạn không có dữ liệu đó; chương pi-ai giải thích nó được nạp thế nào.
Tài nguyên của coding agent đi qua src/config.ts
Code không dùng __dirname trực tiếp mà gọi helper trong config.ts, vì cùng một mã phải chạy từ checkout, từ gói npm và từ binary độc lập (AGENTS.md:22).
Test cô lập
test.sh tạo HOME, thư mục tạm và cache npm riêng, chỉ cho qua các biến môi trường cần thiết, và bỏ qua test cần gọi LLM thật khi không có khoá API (test.sh:4–13). Phần lớn test dùng vitest; riêng pi-tui dùng node:test.

Điểm vào của lệnh pi ngắn đúng sáu dòng; mọi thứ còn lại bắt đầu từ main(), được giải thích trong chương Các chế độ chạy:

packages/coding-agent/src/cli.ts: điểm vào của CLI
pi/packages/coding-agent/src/cli.tsdòng 1–6TypeScript
#!/usr/bin/env nodeimport { setupCli } from "./cli/setup.ts";import { main } from "./main.ts";setupCli();main(process.argv.slice(2));

Những package đã biến mất#

Nếu đọc issue cũ, changelog hay blog, bạn sẽ gặp các package không còn trong packages/. Lịch sử git cho biết chúng đi đâu:

PackageTồn tạiSố phận
pods2025-08-09 → 2026-04-30Công cụ triển khai vLLM; gỡ cùng mom.
mom2025-11-26 → 2026-04-30Bot Slack dựa trên Pi; mảng chat nay ở repo earendil-works/pi-chat (README.md:86).
web-ui2025-10-05 → 2026-05-20Thành phần giao diện web cho chat; gỡ khỏi workspace.
browser-extension2025-10-01 → 2025-10-06Chuyển sang repo riêng (sitegeist).
proxy, agent-old2025-10 → 2025-11/12Thử nghiệm sớm, đã gỡ.
server, protocol, client2026-07 → 2026-10-12Bộ client/server từ xa thử nghiệm của coding agent (giao thức CBOR); gỡ sau khi Pi Durable ra đời.
session-backends/sqlite-node2026-08-05 → 2026-10-01Backend SQLite cho harness thử nghiệm trong agent-core; gỡ cùng harness đó.

Nên đọc theo thứ tự nào#

Tài liệu này đi từ dưới lên, khớp với đồ thị phụ thuộc. Nếu bạn có mục tiêu cụ thể, có thể nhảy thẳng:

4pi-ai: một API cho mọi mô hình#

Vai trò#

Gói @earendil-works/pi-ai (thư mục packages/ai) là tầng thấp nhất của Pi. Nó làm một việc: nhận một mô hình (model) cùng một hội thoại, gửi hội thoại đó tới nhà cung cấp, rồi trả về một luồng (stream) sự kiện đã chuẩn hoá, kết thúc bằng một AssistantMessage. Chính package.json tự mô tả gói là "Unified LLM API with automatic model discovery and provider configuration" (packages/ai/package.json:4).

pi-ai không chạy tool, không lưu session và không vẽ giao diện. Khi mô hình muốn gọi tool, pi-ai chỉ trả về một khối toolCall trong message; chạy tool rồi nối kết quả tool vào hội thoại là việc của người gọi. Trong monorepo có ba người gọi chính:

Chú thích của StreamFn đặt ra một hợp đồng mà mọi tầng phía trên dựa vào, và pi-ai lặp lại nó trong kiểu StreamFunction của chính mình: hàm stream nhận một transcript đã chuẩn hoá, phải trả về AssistantMessageEventStream, và khi đã trả luồng về thì mọi lỗi request, mô hình hay runtime phải đi qua luồng dưới dạng một message có stopReason là "error" hoặc "aborted".

StreamFunction: hợp đồng chung của mọi adapter
pi/packages/ai/src/types.tsdòng 387–402TypeScript
// Generic StreamFunction with typed options.//// Contract:// - Receives a normalized transcript: the system prompt and tools live in the//   leading system message, never on the context itself.// - Must return an AssistantMessageEventStream.// - Direct streamSimple() calls may throw synchronously when request auth is//   missing. Once a stream is returned, request/model/runtime failures should//   be encoded in that stream.// - Error termination must produce an AssistantMessage with stopReason//   "error" or "aborted" and errorMessage, emitted via the stream protocol.export type StreamFunction<TApi extends Api = Api, TOptions extends StreamOptions = StreamOptions> = (	model: Model<TApi>,	context: TranscriptContext,	options?: TOptions,) => AssistantMessageEventStream;

pi-ai nói chuyện với nhà cung cấp qua SDK chính thức của từng hãng, ghim phiên bản chính xác (@anthropic-ai/sdk, openai, @google/genai, AWS SDK cho Bedrock), thay vì qua một thư viện trừu tượng trung gian. Ngoài các SDK chỉ có hai phụ thuộc đáng chú ý: partial-json để đọc tham số tool đang truyền dở và typebox cho lược đồ tool (package.json).

Mô hình tư duy: API, provider và mô hình#

Ba danh từ xuất hiện trong gần như mọi file của pi-ai, và hai trong số đó rất dễ nhầm với nhau:

API (giao thức trên dây, wire protocol)
Hình dạng request và luồng phản hồi mà một máy chủ hiểu, ví dụ anthropic-messages hay openai-completions. Mỗi API có đúng một module triển khai trong src/api/, xuất hai hàm stream và streamSimple (ProviderStreams).
Provider (nhà cung cấp)
Một endpoint cụ thể cùng cách xác thực và danh mục mô hình của nó: anthropic, groq, openrouter. Nhiều provider dùng chung một API, và một provider có thể dùng nhiều API: GitHub Copilot chọn adapter theo model.api trong một bảng ba mục (github-copilot.ts).
Mô hình (model)
Một bản ghi dữ liệu thuần: id, api, provider, baseUrl, giá, cửa sổ ngữ cảnh (context window). Trường api quyết định module giao thức nào chạy; trường provider quyết định xác thực và endpoint.
Mười API chat mà pi-ai biết sẵn
pi/packages/ai/src/types.tsdòng 64–76TypeScript
export type KnownApi =	| "openai-completions"	| "mistral-conversations"	| "openai-responses"	| "azure-openai-responses"	| "openai-codex-responses"	| "anthropic-messages"	| "bedrock-converse-stream"	| "google-generative-ai"	| "google-vertex"	| "pi-messages";export type Api = KnownApi | (string & {});
KnownApi | (string & {}) là một mẹo TypeScript: editor vẫn gợi ý các giá trị đã biết, nhưng một provider tự viết vẫn được dùng một chuỗi API mới mà không cần sửa kiểu.

Request đi qua năm lớp. Hai lớp Models và Provider là runtime mới từ bản 0.80 (CHANGELOG 0.80.0); lớp adapter có từ đầu và là nơi chứa nhiều code nhất.

Người gọi
Vòng lặp agent, AgentSession của coding agent, harness của Pi Durable. Truyền một Context và các option.
Models
createModels() trong models.ts: chuẩn hoá Context thành transcript, phân giải xác thực của provider, gộp header và identity, mở span telemetry, chuyển request tới provider sở hữu mô hình (model.provider).
Provider
createProvider(): danh mục mô hình, cách xác thực, và bảng chọn adapter theo model.api.
Lazy wrapper
api/*.lazy.ts qua lazyApi(): chỉ import module adapter (và SDK của nó) ở request đầu tiên.
Adapter của API
api/anthropic-messages.ts, api/openai-responses.ts, ...: đổi message sang định dạng của hãng, gọi SDK, đọc luồng phản hồi, phát sự kiện đã chuẩn hoá.
SDK hoặc HTTP
@anthropic-ai/sdk, openai, @google/genai, AWS SDK, hoặc fetch thuần (pi-messages).

Bảng dưới đây lấy từ các lệnh import trong src/providers/*.ts ở v1.4.0. Cột cuối chỉ là ví dụ, không liệt kê hết.

APIModuleProvider dùng nó (ví dụ)
anthropic-messagesapi/anthropic-messages.tsanthropic, minimax, vercel-ai-gateway; theo từng mô hình trong github-copilot, openrouter, fireworks, opencode
openai-responsesapi/openai-responses.tsopenai, xai, meta; theo từng mô hình trong github-copilot, opencode
openai-completionsapi/openai-completions.tsgroq, cerebras, deepseek, together, nvidia, zai, kimi-coding, huggingface; theo từng mô hình trong openrouter, azure
google-generative-ai, google-vertexapi/google-*.tsgoogle, google-vertex
azure-openai-responses, openai-codex-responsesbiến thể của Responsesazure, openai-codex
mistral-conversationsapi/mistral-conversations.ts: gọi thẳng endpoint Chat Completions gốc của Mistral bằng fetch, không qua SDK (mistral-conversations.ts:304–315)mistral
bedrock-converse-streamapi/bedrock-converse-stream.ts: AWS SDK (ConverseStream)amazon-bedrock
pi-messagesapi/pi-messages.tsradius: giao thức riêng của Pi, POST { model, context, options } và nhận lại chính các sự kiện của pi-ai (pi-messages.ts)

Bài viết của Mario Zechner năm 2025 (What I learned building an opinionated and minimal coding agent) kể pi-ai nói chuyện với bốn API: OpenAI Completions, OpenAI Responses, Anthropic Messages và Google. Ở v1.4.0 danh sách chat đã lên mười, cộng thêm các API ảnh và classifier, nhưng ý tưởng không đổi: mỗi giao thức một adapter viết tay, và mọi adapter phát ra cùng một bộ sự kiện.

Các từ vựng còn lại của chương:

  • Context: đầu vào công khai, gồm systemPrompt, messages, tools.
  • TranscriptContext: đầu vào mà adapter nhận, chỉ có messages; system prompt và tool nằm trong system message ở đầu.
  • AssistantMessageEventStream: luồng sự kiện vừa là async iterable vừa có result() trả về message cuối.
  • stream và streamSimple: biến thể nhận option riêng của từng API, và biến thể nhận option thống nhất như reasoning.

Data model#

Gần như mọi kiểu dữ liệu nằm trong một file, src/types.ts. Một tính chất chạy xuyên suốt: mọi giá trị ở đây đều là JSON. Không có class, không có hàm gắn vào message hay mô hình. Vì thế một hội thoại lưu xuống file rồi đọc lại vẫn gửi tiếp được, kể cả cho một mô hình khác (README, Context Serialization).

Mô hình: BaseModel và Model

Các trường của một mô hình chat
pi/packages/ai/src/types.tsdòng 832–867TypeScript
/** Fields shared by every catalog entry, regardless of what you can do with it. */export interface BaseModel<TApi extends string> {	id: string;	name: string;	api: TApi;	provider: ProviderId;	baseUrl: string;	input: ModelInputModality[];	/** Provider input limits and cache-safe preprocessing metadata. */	inputLimits?: ModelInputLimits;	cost: ModelCost;	headers?: Record<string, string>;}/** Chat model: usable with `stream()` and friends. */export interface Model<TApi extends Api> extends BaseModel<TApi> {	/**	 * Optional: chat is the default model type, so models without `type` are chat	 * models. Narrow mixed model lists with `isModelType()` instead of comparing	 * `type` directly.	 */	type?: "chat";	reasoning: boolean;	/**	 * Maps pi thinking levels to provider/model-specific values.	 * Missing keys use provider defaults. null marks a level as unsupported.	 */	thinkingLevelMap?: ThinkingLevelMap;	/** Prompt cache lifetimes per retention tier. Unset when the provider's cache behavior is unknown. */	promptCache?: ModelPromptCache;	contextWindow: number;	maxTokens: number;	/** Default sampling parameters for this model. See {@link StreamOptions.samplingParams}; per-request keys override these. */	samplingParams?: SamplingParams;	/** Sampling parameter overrides selected by the effective pi thinking level. */	samplingParamsByThinkingLevel?: SamplingParamsByThinkingLevel;
  • api, provider, baseUrl: ba trường định tuyến. Models tìm provider theo provider; provider tìm adapter theo api; adapter gửi tới baseUrl.
  • input: ["text"] hoặc ["text", "image"]. Nếu mô hình không nhận ảnh, ảnh trong lịch sử không bị bỏ lặng lẽ như README nói (packages/ai/README.md:756) mà được thay bằng một dòng chữ giữ chỗ như (image omitted: model does not support images) (transform-messages.ts).
  • cost: giá USD cho mỗi triệu token, có thể kèm các bậc giá theo độ dài prompt.
  • reasoning và thinkingLevelMap: mô hình có suy nghĩ hay không, và mỗi mức suy nghĩ (thinking level) của Pi được gửi đi thành giá trị gì. null nghĩa là mức đó không được hỗ trợ.
  • promptCache: tuổi thọ (giây) của prompt cache cho từng bậc lưu giữ; Pi dùng nó để quyết định có làm ấm cache hay không (chương Pi 1.0).
  • contextWindow, maxTokens: cửa sổ ngữ cảnh và giới hạn token đầu ra.
  • type: tuỳ chọn; mô hình không có type là mô hình chat. Mô hình ảnh và classifier dùng chung BaseModel nhưng bắt buộc có type (packages/ai/src/types.ts:882–895).

Trường compat có kiểu phụ thuộc vào TApi: mô hình openai-completions nhận các cờ của Chat Completions, ba API họ Responses nhận cờ của Responses, anthropic-messages nhận cờ của Anthropic, Bedrock và Mistral có bộ cờ nhỏ riêng; Google, pi-messages và API tự đặt nhận kiểu never, tức không có compat.

compat là kiểu điều kiện theo API
pi/packages/ai/src/types.tsdòng 868–880TypeScript
	/** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */	compat?: TApi extends "openai-completions"		? OpenAICompletionsCompat		: TApi extends "openai-responses" | "azure-openai-responses" | "openai-codex-responses"			? OpenAIResponsesCompat			: TApi extends "anthropic-messages"				? AnthropicMessagesCompat				: TApi extends "bedrock-converse-stream"					? BedrockCompat					: TApi extends "mistral-conversations"						? MistralConversationsCompat						: never;}

Mức suy nghĩ, bảng ánh xạ và giá được khai báo bằng TypeBox trong providers/model-schema.ts, để cùng một lược đồ vừa sinh ra kiểu TypeScript vừa kiểm tra được models.json của người dùng: coding agent ghép chúng vào lược đồ của file cấu hình và biên dịch thành validator (model-config.ts).

Mức suy nghĩ, thinkingLevelMap và giá theo bậc
pi/packages/ai/src/providers/model-schema.tsdòng 3–7, 14–18, 28–49TypeScript
export const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;export const MODEL_THINKING_LEVELS = ["off", ...THINKING_LEVELS] as const;export const MODEL_INPUT_MODALITIES = ["text", "image"] as const;export const CACHE_RETENTIONS = ["none", "short", "long"] as const;export const MODEL_PROMPT_CACHE_RETENTIONS = ["short", "long"] as const;const ThinkingLevelMapValueSchema = Type.Union([Type.String(), Type.Null()]);export const ThinkingLevelMapSchema = Type.Partial(Type.Record(ModelThinkingLevelSchema, ThinkingLevelMapValueSchema), {	description:		"Provider value sent for each thinking level. null marks a level unsupported. Levels the model does not map use the provider's default value through high; xhigh and max are unsupported.",});const ModelCostRatesProperties = {	input: Type.Number({ description: "Input cost in USD per million tokens." }),	output: Type.Number({ description: "Output cost in USD per million tokens." }),	cacheRead: Type.Number({ description: "Cache-read cost in USD per million tokens." }),	cacheWrite: Type.Number({ description: "Cache-write cost in USD per million tokens." }),};export const ModelCostRatesSchema = Type.Object(ModelCostRatesProperties);export const ModelCostTierSchema = Type.Object({	inputTokensAbove: Type.Number({		description: "Use this tier when total request input exceeds this token count.",	}),	...ModelCostRatesProperties,});export const ModelCostSchema = Type.Object({	...ModelCostRatesProperties,	tiers: Type.Optional(		Type.Array(ModelCostTierSchema, {			description: "Request-wide pricing tiers. The highest matching input threshold applies to the full request.",		}),	),});

Một mô hình tự định nghĩa trông như sau. Đây là ví dụ trong README cho Ollama: chỉ là dữ liệu, không có code nào đi kèm.

Ví dụ trong README: mô hình Ollama tự khai báo
pi/packages/ai/README.mddòng 1276–1287TypeScript
const ollamaModel: Model<'openai-completions'> = {  id: 'llama-3.1-8b',  name: 'Llama 3.1 8B (Ollama)',  api: 'openai-completions',  provider: 'ollama',  baseUrl: 'http://localhost:11434/v1',  reasoning: false,  input: ['text'],  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },  contextWindow: 128000,  maxTokens: 32000};

Context và TranscriptContext

Đầu vào công khai và đầu vào đã chuẩn hoá
pi/packages/ai/src/types.tsdòng 773–796TypeScript
/** * Request input accepted by the public stream entry points (`Models.stream()`, * `streamSimple()`, ...). `systemPrompt` and `tools` are shorthand for a leading * system message; `normalizeContext()` folds them into one before the request * reaches a provider. */export interface Context {	systemPrompt?: string;	messages: Message[];	tools?: Tool[];}declare const transcriptContextBrand: unique symbol;/** * Normalized request context passed to providers and API implementations. The * prompt and tool declarations are carried by the transcript's system messages. * Only `normalizeContext()` produces this type, so a raw `Context` cannot reach * provider code by accident. */export type TranscriptContext = {	messages: Message[];	readonly [transcriptContextBrand]: true;};

Context.systemPrompt và Context.tools chỉ là cách viết tắt. Trước khi tới provider, normalizeContext() gộp chúng thành một system message đứng đầu transcript, với timestamp: 0. TranscriptContext mang một trường kiểu unique symbol chỉ tồn tại ở mức kiểu, nên không thể vô tình truyền một Context thô vào code của adapter: chỉ normalizeContext() tạo ra được giá trị này.

normalizeContext(): đưa system prompt và tool vào transcript
pi/packages/ai/src/utils/transcript.tsdòng 10–34TypeScript
export function createInitialSystemMessage(	systemPrompt: string | undefined,	tools: Tool[] | undefined,): SystemMessage | undefined {	const hasSystemPrompt = systemPrompt !== undefined && systemPrompt.length > 0;	const hasTools = tools !== undefined && tools.length > 0;	if (!hasSystemPrompt && !hasTools) return undefined;	return {		role: "system",		content: systemPrompt ?? "",		...(hasTools ? { toolsAdded: tools } : {}),		timestamp: 0,	};}/** * Fold `Context.systemPrompt` and `Context.tools` into a leading system message. * This is the only entry point that produces a {@link TranscriptContext}; every * provider-facing function expects the result. */export function normalizeContext(context: Context): TranscriptContext {	const initialMessage = createInitialSystemMessage(context.systemPrompt, context.tools);	const messages = initialMessage ? [initialMessage, ...context.messages] : context.messages;	return { messages } as TranscriptContext;}

Message: bốn vai

Message là hợp của bốn kiểu: SystemMessage | UserMessage | AssistantMessage | ToolResultMessage. System message là phần được thêm ở bản 0.86 (packages/ai/CHANGELOG.md:279) và là nền cho việc đổi prompt hoặc bộ tool giữa hội thoại mà không viết lại lịch sử.

SystemMessage và UserMessage
pi/packages/ai/src/types.tsdòng 544–576TypeScript
/** * System instructions and tool declarations at one point in the transcript. * * The leading system message is the system prompt. Later system messages change it: * `content` adds instructions from that point on, `sections` replace or remove named * prompt sections, and `toolsAdded`/`toolsRemoved` change the tool set. Replaying * every system message in order yields the current prompt and tools. Providers that * accept system messages mid-conversation send each one in place; other providers * rebuild the leading system message from the replayed state. */export interface SystemMessage {	role: "system";	/** Instruction text. On the leading message this is the base prompt; later, additional instructions. */	content: string | TextContent[];	/**	 * Named, ordered prompt sections rendered verbatim after `content`. The leading message	 * declares them; later messages replace sections by name, and `null` removes one. Keep	 * each section self-delimiting (a tag, a heading) so the model can relate an update to	 * the original. Avoid integer-like names; JSON objects reorder those.	 */	sections?: Record<string, string | null>;	/** Complete definitions of tools that become available at this point. */	toolsAdded?: Tool[];	/** Tools that stop being available at this point. */	toolsRemoved?: ToolReference[];	timestamp: number; // Unix timestamp in milliseconds}export interface UserMessage {	role: "user";	content: string | (TextContent | ImageContent)[];	timestamp: number; // Unix timestamp in milliseconds}

System message đầu tiên là system prompt. Các system message sau thay đổi nó: content thêm chỉ dẫn, sections thay hoặc xoá các đoạn có tên, toolsAdded và toolsRemoved đổi bộ tool. Phát lại (replay) mọi system message theo thứ tự sẽ ra prompt và bộ tool hiện tại, đúng như getCurrentTools() và getCurrentSystemMessage() làm (transcript.ts). Mô hình nào không nhận system message giữa hội thoại thì nhận bản đã gộp từ collapseSystemMessages(); chi tiết thiết kế này thuộc chương Pi 1.0.

AssistantMessage: kết quả của một lần gọi mô hình
pi/packages/ai/src/types.tsdòng 578–609TypeScript
export interface AssistantMessage {	role: "assistant";	content: (TextContent | ThinkingContent | ToolCall)[];	api: Api;	provider: ProviderId;	model: string;	responseModel?: string; // Concrete model reported by the provider when different from the requested `model`	responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one	/** Exact provider-native effort level used for this response. Absent for legacy or unmanaged responses. */	providerThinkingLevel?: string;	/** Pi thinking level the agent loop requested for this response. Absent outside the agent loop and for legacy responses. */	thinkingLevel?: ModelThinkingLevel;	diagnostics?: AssistantMessageDiagnostic[]; // Redacted provider/runtime diagnostics for failures and recoveries.	usage: Usage;	stopReason: StopReason;	deferred?: DeferredHandle;	errorMessage?: string;	rawStopReason?: string;	/**	 * Provider indication of whether the model explicitly ended its turn.	 * Preserved for debugging and does not currently affect agent control flow.	 */	endTurn?: boolean;	/** Unix timestamp in milliseconds when the request started. */	timestamp: number;	/**	 * Milliseconds from `timestamp` until the response ended, measured with a monotonic clock. Set by	 * `AssistantMessageEventStream` on the final message of a response it saw start; absent for legacy messages and	 * for deferred results fetched later.	 */	durationMs?: number;}
  • api, provider, model ghi lại ai đã sinh ra message. Khi đổi mô hình giữa chừng, transformMessages() dựa vào ba trường này để biết khối nào còn dùng lại được (xem phần chuyển giao).
  • responseModel chỉ có khi máy chủ thực sự trả lời bằng mô hình khác mô hình được yêu cầu, ví dụ fallback phía máy chủ của Anthropic.
  • providerThinkingLevel lưu mức effort đúng như đã gửi cho nhà cung cấp, để các request sau phát lại được mà không làm hỏng prompt cache.
  • usage và stopReason luôn có mặt, kể cả khi lỗi. errorMessage có khi stopReason là error hoặc aborted; rawStopReason giữ lý do gốc của nhà cung cấp để gỡ lỗi.
  • timestamp là lúc request bắt đầu; durationMs do luồng đo bằng đồng hồ đơn điệu (monotonic clock).
ToolResultMessage và hợp Message
pi/packages/ai/src/types.tsdòng 633–652TypeScript
export type ToolResultMessage<TDetails = JsonValue> = IsJsonCompatible<TDetails> extends true	? {			role: "toolResult";			toolCallId: string;			toolName: string;			content: (TextContent | ImageContent)[]; // Supports text and images			details?: JsonRepresentation<TDetails>;			/** Usage from the tool execution itself, if available. Not part of main LLM context accounting. */			usage?: Usage;			/** Calls this tool made to other tools. Kept for the session record; not sent to the model. */			nestedCalls?: NestedToolCalls;			isError: boolean;			/** Unix timestamp in milliseconds when the result was created. */			timestamp: number;			/** Milliseconds the tool's execution took, measured with a monotonic clock. Absent for legacy results. */			durationMs?: number;		}	: never;export type Message = SystemMessage | UserMessage | AssistantMessage | ToolResultMessage;

Kết quả tool tách làm hai phần, đúng như bài viết của Mario mô tả: content (chữ và ảnh) là thứ gửi cho mô hình, còn details là dữ liệu cho giao diện và session, không bao giờ được gửi đi; adapter Anthropic chỉ đọc msg.content khi dựng tool_result (anthropic-messages.ts). Kiểu điều kiện IsJsonCompatible<TDetails> biến ToolResultMessage thành never nếu details không phải JSON, nên lỗi lưu session bị bắt ngay lúc biên dịch.

Content block

Bốn loại khối nội dung
pi/packages/ai/src/types.tsdòng 422–457TypeScript
export interface TextContent {	type: "text";	text: string;	textSignature?: string; // e.g., for OpenAI responses, message metadata (legacy id string or TextSignatureV1 JSON)}export interface ThinkingContent {	type: "thinking";	thinking: string;	thinkingSignature?: string; // Provider-specific opaque or serialized reasoning replay data	/** When true, the thinking content was redacted by safety filters. The opaque	 *  encrypted payload is stored in `thinkingSignature` so it can be passed back	 *  to the API for multi-turn continuity. */	redacted?: boolean;}export interface ImageContent {	type: "image";	data: string; // base64 encoded image data	mimeType: string; // e.g., "image/jpeg", "image/png"}export interface ToolCall {	type: "toolCall";	id: string;	name: string;	arguments: JsonObject;	thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context	/** OpenAI Responses namespace for calls to dynamically loaded or namespaced tools. */	namespace?: string;	/**	 * Set when the model's complete arguments text was not a valid JSON object. `arguments` is then `{}`.	 * Describes the problem and includes the text the model sent; tool argument validation fails with it.	 */	argumentsError?: string;}
  • TextContent.textSignature: siêu dữ liệu để phát lại. OpenAI Responses lưu ở đây id và phase của message dưới dạng JSON TextSignatureV1 (packages/ai/src/types.ts:416–420, openai-responses-shared.ts).
  • ThinkingContent.thinkingSignature: dữ liệu mờ (opaque) để gửi lại suy nghĩ cho đúng nhà cung cấp đã sinh ra nó. Với Anthropic đó là chuỗi signature; với OpenAI Responses là nguyên reasoning item dạng JSON, kể cả encrypted_content; với Chat Completions là tên trường reasoning hoặc mảng reasoning_details. redacted: true đánh dấu suy nghĩ bị bộ lọc an toàn che đi: chữ hiển thị là [Reasoning redacted], phần mã hoá nằm trong signature (anthropic-messages.ts).
  • ImageContent: base64 cùng mimeType.
  • ToolCall: id, name và arguments là một JsonObject. thoughtSignature chỉ Google dùng; namespace chỉ OpenAI Responses dùng; argumentsError xuất hiện khi chuỗi tham số hoàn chỉnh không phải JSON object hợp lệ (thêm ở 1.3.0).

Usage, chi phí và StopReason

Usage và các lý do dừng
pi/packages/ai/src/types.tsdòng 459–482TypeScript
export interface Usage {	input: number;	output: number;	cacheRead: number;	cacheWrite: number;	/** Subset of `cacheWrite` written with 1h retention. Only Anthropic reports this split. */	cacheWrite1h?: number;	/**	 * Reasoning/thinking tokens, when the provider reports them. This is a subset of	 * `output`: `output` already includes these tokens. Set to a number (possibly 0) by	 * providers that expose a reasoning breakdown; left undefined by providers that don't.	 */	reasoning?: number;	totalTokens: number;	cost: {		input: number;		output: number;		cacheRead: number;		cacheWrite: number;		total: number;	};}export type StopReason = "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred";

Quy ước quan trọng: input không gồm token đọc hay ghi cache. OpenAI tính cả token cache vào input_tokens, nên adapter phải trừ ra (openai-responses-shared.ts); Anthropic báo riêng từng loại nên chỉ cần cộng lại thành totalTokens. reasoning là tập con của output. Chi phí luôn được tính lại từ giá trong catalog:

calculateCost(): chọn bậc giá rồi nhân theo từng loại token
pi/packages/ai/src/models.tsdòng 1236–1256TypeScript
export function calculateCost(model: AnyModel, usage: Usage): Usage["cost"] {	const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite;	let rates: ModelCostRates = model.cost;	let matchedThreshold = -1;	for (const tier of model.cost.tiers ?? []) {		if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) {			rates = tier;			matchedThreshold = tier.inputTokensAbove;		}	}	// Anthropic charges 2x base input for 1h cache writes.	const longWrite = usage.cacheWrite1h ?? 0;	const shortWrite = usage.cacheWrite - longWrite;	usage.cost.input = (rates.input / 1000000) * usage.input;	usage.cost.output = (rates.output / 1000000) * usage.output;	usage.cost.cacheRead = (rates.cacheRead / 1000000) * usage.cacheRead;	usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1000000;	usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;	return usage.cost;}

Bậc giá được chọn theo tổng input của cả request (gồm token cache), lấy ngưỡng cao nhất mà request vượt qua, rồi áp cho toàn bộ request. Ghi cache 1 giờ của Anthropic được tính bằng hai lần giá input. Hàm ghi thẳng vào usage.cost nên adapter gọi nó mỗi khi usage thay đổi.

StopReason có bảy giá trị. pending chỉ xuất hiện trên message đang dở; toolUse nghĩa là mô hình đang chờ kết quả tool; deferred nghĩa là nhà cung cấp trả về một DeferredHandle để lấy kết quả sau (packages/ai/src/types.ts:532–542), thứ Pi Durable dùng qua fetchDeferred và cancelDeferred. Mục Stop Reasons của README chưa liệt kê deferred (packages/ai/README.md:1101–1110).

Tool và lược đồ TypeBox

Tool: tên, mô tả, lược đồ tham số và tuỳ chọn sampling ràng buộc
pi/packages/ai/src/types.tsdòng 740–771TypeScript
/** OpenAI grammar variants for constrained sampling. */export type GrammarFormat = "openai_lark" | "openai_regex";export type GrammarVariants = Partial<Record<GrammarFormat, string>>;/** * Optional provider-side constrained sampling configs for a tool. * * The `json_schema` value roughly maps to the concept of `strict` in APIs which is * implemented as json-schema constrained sampling by APIs. Grammar variants let * callers provide provider-specific encodings of the same intended language. */export type ConstrainedSamplingConfig =	| {			type: "json_schema";			strict: "prefer" | "require";	  }	| {			type: "grammar";			variants: GrammarVariants;	  };export interface Tool<TParameters extends TSchema = TSchema> {	name: string;	description: string;	parameters: TParameters;	constrainedSampling?: false | ConstrainedSamplingConfig;}export interface ToolReference {	name: string;}

parameters là một lược đồ TypeBox, mà lúc chạy chính là một object JSON Schema thuần, nên một tool cũng serialize được như mọi thứ khác. Gói gốc tái xuất Type từ TypeBox (index.ts). constrainedSampling cho phép yêu cầu nhà cung cấp ép đầu ra theo lược đồ (strict: "prefer" thì lùi về gọi tool thường khi không được hỗ trợ, "require" thì báo lỗi) hoặc theo văn phạm Lark/regex của OpenAI (README).

Tham số mà mô hình gửi về chưa được kiểm tra. Người gọi dùng validateToolCall(), hàm tìm tool theo tên rồi gọi validateToolArguments():

validateToolArguments(): ép kiểu, kiểm tra, và báo lỗi mà mô hình đọc được
pi/packages/ai/src/utils/validation.tsdòng 317–353TypeScript
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);	const validator = getValidator(tool.parameters);	if (!Object.getOwnPropertySymbols(tool.parameters).includes(TYPEBOX_KIND)) {		const coerced = coerceWithJsonSchema(args, tool.parameters as JsonSchemaObject);		if (coerced !== args) {			if (typeof args === "object" && args !== null && typeof coerced === "object" && coerced !== null) {				for (const key of Object.keys(args)) {					delete args[key];				}				Object.assign(args, coerced);			} else {				return validator.Check(coerced) ? coerced : args;			}		}	}	if (validator.Check(args)) {		return args;	}	const errors =		validator			.Errors(args)			.map((error) => `  - ${formatValidationPath(error)}: ${error.message}`)			.join("\n") || "Unknown validation error";	const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`;	throw new Error(errorMessage);}

Thứ tự đáng chú ý: tham số hỏng JSON (argumentsError) bị từ chối trước tiên; tham số được sao chép rồi ép kiểu bằng Value.Convert của TypeBox (ví dụ chuỗi "5" thành số khi lược đồ đòi số); validator đã biên dịch được cache theo object lược đồ trong một WeakMap. Thông báo lỗi liệt kê đường dẫn từng trường sai kèm tham số đã nhận, vì người gọi thường trả nó lại cho mô hình dưới dạng kết quả tool có isError: true để mô hình tự sửa (README).

Giao thức luồng sự kiện#

Mỗi nhà cung cấp truyền phản hồi theo một kiểu. Anthropic gửi các sự kiện SSE content_block_start/delta/stop đánh số theo chỉ số khối của Anthropic. OpenAI Responses gửi response.output_item.added/done theo output_index. Chat Completions nhét mọi thứ vào choices[0].delta, tham số tool đi theo tool_calls[i].index. Google trả về cả lời gọi hàm trong một lần. Trong khi đó bên tiêu thụ (vòng lặp agent, giao diện terminal) chỉ muốn một bộ từ vựng: chữ đang chạy tới, suy nghĩ đang chạy tới, một lời gọi tool đã xong, và câu trả lời đã xong. pi-ai định nghĩa bộ từ vựng đó một lần:

AssistantMessageEvent: mười hai loại sự kiện
pi/packages/ai/src/types.tsdòng 798–830TypeScript
/** * Event protocol for AssistantMessageEventStream. * * Successful streams emit `start` before partial updates and terminate with * `done`. A stream may terminate directly with `error` when request setup fails * before generation starts; after `start`, failures also terminate with `error`. * Direct `streamSimple()` calls throw synchronously when request auth is missing. * Updates and `done` must never appear before `start`. * * `partial` is the shared live response-so-far helper, not an event-time * snapshot. Text and thinking blocks are empty when their `*_start` event is * emitted and grow only through their corresponding `*_delta` events until the * authoritative `*_end`. Redacted thinking may be complete at start and emit no * deltas. Tool-call arguments at `toolcall_start` are provider-specific; * `toolcall_delta` carries subsequent JSON updates. */export type AssistantMessageEvent =	| { type: "start"; partial: AssistantMessage }	| { type: "text_start"; contentIndex: number; partial: AssistantMessage }	| { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage }	| { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }	| { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }	| { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }	| { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }	| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }	| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }	| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }	| {			type: "done";			reason: Extract<StopReason, "stop" | "length" | "toolUse" | "deferred">;			message: AssistantMessage;	  }	| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };

Các quy tắc trong chú thích trên (và trong README, Complete Event Reference) là hợp đồng mà mọi adapter phải giữ:

  1. Mở đầuLuồng thành công luôn bắt đầu bằng start. Các adapter chỉ phát start sau khi máy chủ đã chấp nhận request và luồng phản hồi đã mở, nên lỗi dựng request (thiếu khoá, sai URL, HTTP 400) có thể kết thúc luồng bằng một sự kiện error duy nhất, không có start.
  2. Các khốiMỗi khối có bộ ba *_start, *_delta, *_end. Khối chữ và khối suy nghĩ rỗng lúc *_start và chỉ lớn lên qua *_delta; giá trị chính thức là content của *_end. Khối suy nghĩ bị che có thể đầy đủ ngay lúc bắt đầu và không có delta.
  3. Xen kẽSự kiện của các khối khác nhau có thể xen nhau. Người dùng phải ghép theo contentIndex, không được giả định một khối chạy liền mạch.
  4. Kết thúcĐúng một sự kiện cuối: done với reason là stop, length, toolUse hoặc deferred, hoặc error với reason là error hoặc aborted. Message trong sự kiện cuối là kết quả của result().

Hàng đợi bên dưới: EventStream

Luồng là một hàng đợi nhỏ viết tay, không dùng thư viện stream nào. Bên sản xuất (adapter) gọi push() đồng bộ; bên tiêu thụ dùng for await.

push(), vòng lặp async và result()
pi/packages/ai/src/utils/event-stream.tsdòng 43–58, 72–88TypeScript
push(event: T): void {	if (this.done) return;	if (this.isComplete(event)) {		this.done = true;		this.resolveFinalResult(this.extractResult(event));	}	// Deliver to waiting consumer or queue it	const waiter = this.waiting.dequeue();	if (waiter) {		waiter({ value: event, done: false });	} else {		this.queue.enqueue(event);	}}async *[Symbol.asyncIterator](): AsyncIterator<T> {	while (true) {		if (this.queue.length > 0) {			yield this.queue.dequeue()!;		} else if (this.done) {			return;		} else {			const result = await new Promise<IteratorResult<T>>((resolve) => this.waiting.enqueue(resolve));			if (result.done) return;			yield result.value;		}	}}result(): Promise<R> {	return this.finalResultPromise;}
  • push() không bao giờ chờ bên tiêu thụ. Nếu có người đang chờ thì giao thẳng, không thì xếp vào hàng. Không có áp lực ngược (backpressure): adapter chạy hết tốc độ mạng, sự kiện dồn trong bộ nhớ nếu không ai đọc.
  • Sự kiện kết thúc (done hoặc error) bật cờ this.done và resolve promise của result() ngay lúc được push. Vì vậy await s.result() chạy được dù không ai lặp qua luồng: đó chính là cách complete() hoạt động.
  • Sau khi đã kết thúc, mọi push() bị bỏ qua, nên adapter không thể vô tình phát hai sự kiện cuối.
  • FifoQueue dùng hai mảng (đẩy vào một mảng, lấy ra từ mảng kia, đảo khi mảng lấy ra rỗng) để dequeue có chi phí khấu hao O(1) thay vì Array.shift() O(n) (event-stream.ts).

Lớp con AssistantMessageEventStream chỉ thêm hai điều: định nghĩa đâu là sự kiện cuối (done hoặc error), và gắn durationMs cho message cuối, đo từ lúc tạo luồng bằng performance.now() (event-stream.ts). Điều kiện message.timestamp < this.#startedAt giúp một luồng chỉ chuyển tiếp một phản hồi đã bắt đầu ở nơi khác (ví dụ kết quả deferred lấy về sau) không ghi đè thời lượng. Từ bản 1.1.0 mọi hàm stream bắt buộc trả về đúng lớp này (packages/ai/CHANGELOG.md:88).

Trả luồng ngay, làm việc chậm phía sau: lazyStream()

Phân giải xác thực có thể cần làm mới token OAuth; module adapter có thể chưa được nạp. Cả hai đều bất đồng bộ, nhưng stream() phải trả về luồng đồng bộ. lazyStream() giải quyết bằng cách tạo luồng ngoài ngay lập tức, chạy phần chuẩn bị phía sau, rồi chuyển tiếp từng sự kiện của luồng trong ra ngoài:

lazyStream(): mọi lỗi chuẩn bị thành một sự kiện error
pi/packages/ai/src/api/lazy.tsdòng 60–86TypeScript
export function lazyStream(	model: Model<Api>,	setup: () => Promise<AsyncIterable<AssistantMessageEvent>>,	observer?: LazyStreamObserver,): AssistantMessageEventStream {	const startedAt = Date.now();	const outer = new AssistantMessageEventStream();	let observedResult = false;	const observeResult = (result: AssistantMessage | undefined): void => {		if (observedResult) return;		observedResult = true;		observer?.onResult?.(result);	};	setup()		.then((inner) => forwardStream(outer, inner, observer, observeResult))		.catch((error) => {			const message = createSetupErrorMessage(model, error, startedAt);			const event: AssistantMessageEvent = { type: "error", reason: "error", error: message };			outer.push(event);			observer?.onEvent?.(event);			outer.end(message);			observeResult(message);		});	return outer;}

Lỗi trong setup() (provider không có, chưa cấu hình xác thực, nạp module thất bại) biến thành một message lỗi có timestamp là lúc request bắt đầu, phát dưới dạng sự kiện error. Đây là lý do Models.stream() không bao giờ ném lỗi. lazyApi() dùng cùng cơ chế để bọc một import() động: module adapter, và cùng với nó là SDK của hãng, chỉ được nạp ở request đầu tiên (lazy.ts, anthropic-messages.lazy.ts).

stream, complete và hai biến thể "simple"

Models.stream() và Models.complete()
pi/packages/ai/src/models.tsdòng 901–927TypeScript
stream<TApi extends Api>(	model: Model<TApi>,	context: Context,	options?: ModelsApiStreamOptions<TApi>,): AssistantMessageEventStream {	const transcript = normalizeContext(context);	return lazyStream(		model,		async () => {			const provider = this.requireChatProvider(model);			const { requestModel, requestOptions } = await this.applyAuth(				model,				options as ModelsApiStreamOptions<Api> | undefined,			);			return provider.stream(requestModel, transcript, requestOptions as ApiStreamOptions<TApi>);		},		observeModelRequest(model, options, false),	);}async complete<TApi extends Api>(	model: Model<TApi>,	context: Context,	options?: ModelsApiStreamOptions<TApi>,): Promise<AssistantMessage> {	return this.stream(model, context, options).result();}

complete() chỉ là stream(...).result(). Khác biệt thật nằm giữa stream và streamSimple:

  • stream(model, context, options) nhận option đầy đủ của đúng API đó, ví dụ thinkingEnabled, thinkingBudgetTokens, effort cho Anthropic, hay reasoningEffort, reasoningSummary, serviceTier cho OpenAI Responses. Kiểu option được suy ra từ model.api qua ApiOptionsMap (packages/ai/src/types.ts:290–309); với mô hình tra cứu động, dùng hasApi() để thu hẹp kiểu.
  • streamSimple(model, context, options) nhận một bộ option chung cho mọi API, trong đó quan trọng nhất là reasoning, một mức suy nghĩ của Pi. Mỗi adapter tự dịch mức này sang tham số của hãng. Đây là biến thể vòng lặp agent và Pi Durable dùng.
SimpleStreamOptions
pi/packages/ai/src/types.tsdòng 376–385TypeScript
// Unified options with reasoning passed to streamSimple() and completeSimple()export interface SimpleStreamOptions extends StreamOptions {	/** Provider-neutral tool selection for simple requests. When omitted, adapters use provider-specific behavior. */	toolChoice?: ToolChoice;	reasoning?: ThinkingLevel;	/** Ask a capable provider to return a durable handle and continue the request asynchronously. */	deferred?: boolean | { window?: "15m" | "1h" | "24h" };	/** Custom token budgets for thinking levels (token-based providers only) */	thinkingBudgets?: ThinkingBudgets;}

Trước khi dịch, mức được yêu cầu bị kẹp vào những mức mô hình thật sự hỗ trợ. Mô hình không suy nghĩ chỉ có off; xhigh và max chỉ có khi thinkingLevelMap khai báo chúng; giá trị null loại một mức. Nếu mức được yêu cầu không có, clampThinkingLevel() tìm lên trước rồi mới tìm xuống:

Mức suy nghĩ được hỗ trợ và cách kẹp
pi/packages/ai/src/models.tsdòng 1258–1290TypeScript
const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];export function getSupportedThinkingLevels<TApi extends Api>(model: Model<TApi>): ModelThinkingLevel[] {	if (!model.reasoning) return ["off"];	return EXTENDED_THINKING_LEVELS.filter((level) => {		const mapped = model.thinkingLevelMap?.[level];		if (mapped === null) return false;		if (level === "xhigh" || level === "max") return mapped !== undefined;		return true;	});}export function clampThinkingLevel<TApi extends Api>(	model: Model<TApi>,	level: ModelThinkingLevel,): ModelThinkingLevel {	const availableLevels = getSupportedThinkingLevels(model);	if (availableLevels.includes(level)) return level;	const requestedIndex = EXTENDED_THINKING_LEVELS.indexOf(level);	if (requestedIndex === -1) return availableLevels[0] ?? "off";	for (let i = requestedIndex; i < EXTENDED_THINKING_LEVELS.length; i++) {		const candidate = EXTENDED_THINKING_LEVELS[i];		if (availableLevels.includes(candidate)) return candidate;	}	for (let i = requestedIndex - 1; i >= 0; i--) {		const candidate = EXTENDED_THINKING_LEVELS[i];		if (availableLevels.includes(candidate)) return candidate;	}	return availableLevels[0] ?? "off";}

Ví dụ: yêu cầu minimal với một mô hình đánh dấu minimal: null và low: null sẽ thành medium, không phải off. Ngoài ra buildBaseOptions() chép các option chung và kẹp maxTokens sao cho ước lượng của context cộng 4.096 token an toàn không vượt cửa sổ ngữ cảnh (simple-options.ts); ước lượng dùng 3,5 ký tự mỗi token (estimate.ts).

Các option chung

Mọi request, simple hay không, nhận một bộ option chung khai báo trong ProviderRequestOptions và StreamOptions. Những option quan trọng:

OptionDùng để làm gì
signalHuỷ request. Adapter kiểm tra nó trong vòng đọc luồng; message cuối có stopReason: "aborted" và giữ phần nội dung đã nhận.
apiKey, headers, envXác thực và cấu hình theo request. Giá trị tường minh thắng giá trị provider tự phân giải; env được dùng trước process.env.
sessionIdKhoá phiên dùng cho prompt cache và định tuyến (xem Prompt caching).
cacheRetention"none", "short" (mặc định) hoặc "long"; biến môi trường PI_CACHE_RETENTION=long đổi mặc định.
onPayload, onResponse, onProviderStreamEventXem hoặc thay payload trước khi gửi, xem status và header phản hồi, xem từng sự kiện thô của hãng trước khi chuẩn hoá.
timeoutMs, maxRetries, maxRetryDelayMsTimeout HTTP và chính sách thử lại phía client (xem phần lỗi).
identity, telemetry, metadataUser-Agent và header ghi nhận ứng dụng; span cha cho telemetry; metadata mà hãng hiểu (Anthropic dùng user_id).

Runtime Models và Provider#

Từ bản 0.80, cách dùng pi-ai là dựng một tập provider rồi gửi request qua nó. Trước đó có một API toàn cục (stream(model, ...) tra một registry theo model.api và tự lấy khoá từ biến môi trường); API đó vẫn còn, nhưng chuyển sang @earendil-works/pi-ai/compat và sẽ bị xoá (compat.ts).

Cách dùng tối thiểu: runtime cộng đúng một provider
pi/packages/ai/README.mddòng 1716–1720TypeScript
import { createModels } from '@earendil-works/pi-ai/models';import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';const models = createModels();models.setProvider(openaiProvider());

createModels() và đường đi của một request

createModels() nhận bốn thứ có thể thay thế: kho credential (CredentialStore, mặc định trong bộ nhớ), kho catalog động (ModelsStore), AuthContext (cách đọc biến môi trường và kiểm tra file) và identity mặc định (packages/ai/src/models.ts:376–382). Trả về MutableModels, tức Models cộng setProvider, deleteProvider, clearProviders. Mọi phép đọc mô hình là đồng bộ và trả về danh sách biết gần nhất; tải danh sách mới là một động từ bất đồng bộ riêng, refresh().

Khi gọi models.stream(), normalizeContext() chạy ngay, đồng bộ. Phần việc chậm nằm trong hàm setup của lazyStream(): requireChatProvider() kiểm tra mô hình là chat và tìm provider theo model.provider, applyAuth() phân giải xác thực, rồi provider.stream() trả về luồng bên trong. applyAuth() là chỗ các nguồn cấu hình gặp nhau:

applyAuth(): gộp xác thực, header, env và identity
pi/packages/ai/src/models.tsdòng 866–899TypeScript
	this.requireProvider(model);	const resolution = await this.getAuth(model, {		apiKey: options?.apiKey,		env: options?.env,		signal: options?.signal,	});	if (!resolution) {		throw new ModelsError("auth", `Provider is not configured: ${model.provider}`);	}	const auth = resolution.auth;	// Explicit request options win per-field; the Models-only transform runs last.	const apiKey = options?.apiKey ?? auth.apiKey;	const requestModel: TModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;	const sessionId =		options && "sessionId" in options && typeof options.sessionId === "string" ? options.sessionId : undefined;	let headers = mergeHeaders(		mergeHeaders(getOpenCodeSessionHeaders(requestModel, sessionId), auth.headers),		options?.headers,	);	if (options?.transformHeaders) headers = await options.transformHeaders(headers ?? {});	const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined;	const identity = mergeClientIdentity(this.identity, options?.identity);	const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {};	const requestOptions = {		...providerOptions,		apiKey,		headers,		env,		...(identity === undefined ? {} : { identity }),	} as Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>;	return { requestModel, requestOptions };}
  • Khoá tường minh trong option thắng khoá provider tự tìm. Xác thực có thể trả về baseUrl (GitHub Copilot lấy URL theo tài khoản từ token OAuth), và khi đó mô hình được sao ra với URL mới.
  • Thứ tự gộp header: header phiên của OpenCode, rồi header xác thực (đã gồm model.headers vì getAuth(model) gộp sẵn), rồi options.headers, rồi transformHeaders. Tên header so sánh không phân biệt hoa thường, và giá trị null xoá một header mặc định (README).
  • transformHeaders chỉ tồn tại ở tầng Models; nó bị gỡ khỏi option trước khi tới provider.

Xác thực được phân giải theo một nguyên tắc: credential đã lưu "sở hữu" provider. Biến môi trường chỉ được xét khi không có gì được lưu, và làm mới OAuth thất bại không lặng lẽ lùi về khoá trong biến môi trường:

resolveProviderAuth(): khoá tường minh, credential đã lưu, rồi mới tới môi trường
pi/packages/ai/src/auth/resolve.tsdòng 56–92TypeScript
if (overrides?.apiKey !== undefined && provider.auth.apiKey) {	return resolveApiKey(		requestAuthContext,		provider.auth.apiKey,		provider.id,		{			type: "api_key",			key: overrides.apiKey,			env: overrides.env,		},		signal,	);}const stored = await readCredential(credentials, provider.id, signal);if (stored) {	if (stored.type === "oauth" && provider.auth.oauth) {		return resolveStoredOAuth(			credentials,			provider.id,			provider.auth.oauth,			stored,			signal,			overrides?.minOAuthValidityMs,		);	}	if (stored.type === "api_key" && provider.auth.apiKey) {		const credential = overrides?.env ? { ...stored, env: { ...stored.env, ...overrides.env } } : stored;		return resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, credential, signal);	}	return undefined;}// Ambient (env vars, AWS profiles, ADC files).return provider.auth.apiKey	? resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, undefined, signal)	: undefined;

Provider và createProvider()

Interface Provider (packages/ai/src/models.ts:158–247) gom những gì thuộc về một nhà cung cấp: id, name, auth (bắt buộc, kể cả với máy chủ cục bộ không cần khoá), getModels() đồng bộ và không được ném lỗi, refreshModels() tuỳ chọn cho danh sách động, filterModels() để ẩn mô hình theo credential, và các thao tác stream, streamSimple, fetchDeferred, cancelDeferred, generateImages, classify. Hầu như không ai viết tay interface này: cả provider có sẵn lẫn provider tự định nghĩa trong models.json đều đi qua createProvider().

createProvider(): một adapter hay một bảng theo model.api
pi/packages/ai/src/models.tsdòng 1077–1089, 1095–1119TypeScript
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> {	const single =		input.api && typeof (input.api as ProviderStreams).stream === "function"			? (input.api as ProviderStreams)			: undefined;	const byApi = single || !input.api ? undefined : (input.api as Partial<Record<string, ProviderStreams>>);	const images = input.images as Partial<Record<string, ProviderImages>> | undefined;	const classifiers = input.classifiers as Partial<Record<string, ProviderClassifier>> | undefined;	const streams = single ? [single] : Object.values(byApi ?? {}).filter((entry) => entry !== undefined);	const imageImplementations = Object.values(images ?? {}).filter((entry) => entry !== undefined);	const classifierImplementations = Object.values(classifiers ?? {}).filter((entry) => entry !== undefined);	if (streams.length === 0 && imageImplementations.length === 0 && classifierImplementations.length === 0) {		throw new Error(`Provider ${input.id}: at least one of "api", "images", or "classifiers" is required.`);	const currentModels = (): readonly ProviderModel<TApi>[] => {		const merged = [...baselineModels];		for (const model of dynamicModels) {			const index = merged.findIndex(				(entry) => getModelType(entry) === getModelType(model) && entry.id === model.id,			);			if (index >= 0) merged[index] = model;			else merged.push(model);		}		return merged;	};	const apiFor = (model: Model<Api>): ProviderStreams | undefined => single ?? byApi?.[model.api];	const dispatch = (		model: Model<Api>,		run: (streams: ProviderStreams) => AssistantMessageEventStream,	): AssistantMessageEventStream => {		const streams = apiFor(model);		if (!streams) {			return lazyStream(model, async () => {				throw new ModelsError("stream", `Provider ${input.id} has no API implementation for "${model.api}"`);			});		}		return run(streams);	};

Ba điểm đáng nhớ. Một, api có thể là một ProviderStreams duy nhất hoặc một bảng tra theo model.api; mô hình có API không nằm trong bảng nhận một luồng lỗi chứ không ném ngoại lệ. Hai, danh sách mô hình là danh sách tĩnh cộng một lớp phủ (overlay) động, ghép theo cặp (loại, id), nên mô hình tải về thay thế bản tĩnh cùng id. Ba, factory của provider có sẵn chỉ là vài dòng: catalog, cách xác thực, và một lazy wrapper.

Factory của provider anthropic
pi/packages/ai/src/providers/anthropic.tsdòng 74–90TypeScript
export function anthropicProvider(): Provider<"anthropic-messages"> {	return createProvider({		id: "anthropic",		name: "Anthropic",		baseUrl: "https://api.anthropic.com",		auth: {			apiKey: anthropicApiKeyAuth(),			oauth: lazyOAuth({				name: "Anthropic (Claude Pro/Max)",				isSubscription: true,				load: loadAnthropicOAuth,			}),		},		models: Object.values(ANTHROPIC_MODELS),		api: anthropicMessagesApi(),	});}

refresh() chạy song song cho các provider động. Mỗi lần làm mới mang một số thế hệ (generation); kết quả chỉ được ghi vào ModelsStore và vào bộ nhớ nếu thế hệ đó vẫn là mới nhất, nên một lần làm mới cũ về muộn không đè lên kết quả mới (publishProviderModels). Bước đầu tiên của mỗi lần làm mới luôn là khôi phục catalog đã lưu với allowNetwork: false, trước cả khi xác thực, nên ứng dụng khởi động ngoại tuyến vẫn thấy danh sách cũ.

Subpath exports: chỉ nạp thứ cần dùng

Các entry point của gói
pi/packages/ai/package.jsondòng 13–50JSON
"exports": {	".": {		"types": "./dist/index.d.ts",		"import": "./dist/index.js"	},	"./models": {		"types": "./dist/models.d.ts",		"import": "./dist/models.js"	},	"./compat": {		"types": "./dist/compat.d.ts",		"import": "./dist/compat.js"	},	"./providers/*": {		"types": "./dist/providers/*.d.ts",		"import": "./dist/providers/*.js"	},	"./api/*": {		"types": "./dist/api/*.d.ts",		"import": "./dist/api/*.js"	},	"./utils/*": {		"types": "./dist/utils/*.d.ts",		"import": "./dist/utils/*.js"	},	"./oauth": {		"types": "./dist/oauth.d.ts",		"import": "./dist/oauth.js"	},	"./bedrock-provider": {		"types": "./dist/bedrock-provider.d.ts",		"import": "./dist/bedrock-provider.js"	},	"./bun-oauth": {		"types": "./dist/bun-oauth.d.ts",		"import": "./dist/bun-oauth.js"	}},
Entry pointChứa gì
.Lõi, không có side effect: kiểu, runtime, tiện ích, faux provider. Không có catalog, factory hay SDK, nhưng có nạp TypeBox (packages/ai/src/index.ts:4–8).
./modelsChỉ runtime (createModels, createProvider, helper mô hình), không TypeBox, không catalog, không SDK; thêm ở 0.99.2 (packages/ai/CHANGELOG.md:180).
./providers/<id>Factory của một provider, kéo theo đúng catalog của nó và lazy wrapper; ./providers/all nạp tất cả.
./api/<api-id>, ./api/<api-id>.lazyAdapter (nạp SDK ngay) và lazy wrapper của nó. Gọi thẳng adapter thì tự lo khoá và tự gọi normalizeContext().
./utils/*, ./oauth, ./bedrock-provider, ./bun-oauthTiện ích lẻ, kiểu OAuth cho extension, và hai entry cho bản đóng gói đơn file.
./compatAPI toàn cục cũ; là tập cha của gốc nên đổi đường import là đủ để chuyển (packages/ai/README.md:1877–1889).

Ba file được khai báo có side effect trong sideEffects (packages/ai/package.json:8–12) vì chúng đăng ký vào registry toàn cục lúc import: compat.js đăng ký mười API có sẵn (packages/ai/src/compat.ts:200–215), images.js và register-builtins.js đăng ký API ảnh openrouter-images. Ở đường compat, khoá API còn được chèn từ biến môi trường bằng getEnvApiKey() (packages/ai/src/compat.ts:224–232), thứ mà runtime mới thay bằng xác thực của từng provider.

Pi Durable là ví dụ rõ nhất của cách dùng mới: harness không tự tạo provider nào, nó nhận một Models (hoặc một hàm trả về Models) từ host. Worker thử nghiệm trên Cloudflare dựng runtime với đúng một faux provider (worker.ts), và phần sinh câu trả lời dùng fetchDeferred, cancelDeferred khi nhà cung cấp trả về handle deferred (generation.ts:257).

Mô hình ảnh và mô hình classifier

Từ 0.99, mô hình ảnh và classifier sống chung trong Models và Provider với mô hình chat, phân biệt bằng trường type (packages/ai/CHANGELOG.md:199). Chúng không đi qua luồng sự kiện: generateImages() và classify() là thao tác một lần, chờ phản hồi rồi trả kết quả, và không bao giờ reject. Toàn bộ thân hàm nằm trong một try, nên mọi lỗi, kể cả provider không tồn tại hay chưa cấu hình xác thực, thành một kết quả có stopReason: "error" (models.ts). Đây là cùng tinh thần "lỗi là dữ liệu" của luồng chat, áp cho một thao tác không có luồng.

Classifier nhận một trạng thái JSON có cấu trúc (và tuỳ chọn ảnh) cùng một tập câu hỏi có kiểu, rồi trả về xác suất cho từng đáp án. Đây là loại "mô hình không phải LLM" mà bài Pi 1.0 nhắc tới, ví dụ Jev của TypeSafe.

Câu hỏi và ngữ cảnh của classifier
pi/packages/ai/src/types.tsdòng 675–703TypeScript
export interface ClassifierChoiceQuestion {	type: "choice";	instructions: string;	criteria: Record<string, string>;}export interface ClassifierScoreQuestion {	type: "score";	instructions: string;	criteria: string[];}export interface ClassifierBoolQuestion {	type: "bool";	instructions: string;	criteria: { true: string; false: string };}export type ClassifierQuestion = ClassifierChoiceQuestion | ClassifierScoreQuestion | ClassifierBoolQuestion;export interface ClassifierContext {	state: JsonObject;	/**	 * Images judged together with `state`. Only models whose `input` includes `"image"` accept them;	 * other models return an error result.	 */	images?: ImageContent[];	questions: Record<string, ClassifierQuestion>;}

Ở v1.4.0, sinh ảnh chỉ có qua OpenRouter (packages/ai/README.md:889); classifier có ở typesafe, openrouter, cloudflare-workers-ai, vercel-ai-gateway, opencode và openai (packages/ai/README.md:891–902).

Telemetry

Request qua Models.stream() và streamSimple() được bọc bởi một observer telemetry (dòng 917 trong excerpt Models.stream() ở trên). Observer đi chung đường chuyển tiếp sự kiện của lazyStream(), không thêm một bên tiêu thụ nào: nó mở span pi.ai.request, ghi sự kiện pi.ai.first_chunk ở sự kiện đầu tiên và pi.ai.block mỗi khi một khối kết thúc, rồi khi có kết quả thì gắn usage, chi phí, lý do dừng, đặt trạng thái lỗi provider_error với tối đa 500 ký tự thông báo, và đếm metric thời lượng, token, chi phí (observeModelRequest). Request bị huỷ giữ trạng thái ok với stop: "aborted".

Observer chỉ ghi số ký tự và tên tool, không bao giờ ghi prompt, chữ sinh ra hay tham số (packages/ai/README.md:1146). Không truyền telemetry thì dùng span rỗng; gọi thẳng adapter thì không có telemetry. Gói pi-telemetry được giải thích ở chương pi-env và telemetry.

Adapter Anthropic Messages từ đầu đến cuối#

src/api/anthropic-messages.ts (1.690 dòng) là adapter điển hình: các adapter khác có cùng bộ khung, chỉ khác phần đổi message và phần đọc luồng. Phần này đi theo thứ tự một request chạy qua file.

Khung: trả luồng ngay, làm việc trong một hàm async

stream(): tạo luồng, chuẩn bị transcript, khởi tạo message rỗng
pi/packages/ai/src/api/anthropic-messages.tsdòng 571–597TypeScript
export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (	model: Model<"anthropic-messages">,	context: TranscriptContext,	options?: AnthropicOptions,): AssistantMessageEventStream => {	const stream = new AssistantMessageEventStream();	const normalizedContext = resolveTranscript(context, getAnthropicCompat(model).supportsMidConvoSystemMessages);	const currentTools = getCurrentTools(normalizedContext.messages);	(async () => {		const output: AssistantMessage = {			role: "assistant",			content: [],			api: model.api as Api,			provider: model.provider,			model: model.id,			usage: {				input: 0,				output: 0,				cacheRead: 0,				cacheWrite: 0,				totalTokens: 0,				cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },			},			stopReason: "pending",			timestamp: Date.now(),		};

Ba việc xảy ra trước cả khi có mạng. Luồng được tạo và sẽ được trả về ở cuối hàm; công việc thật chạy trong một hàm async tự gọi (IIFE), nên mọi lỗi bên trong rơi vào một catch duy nhất thay vì thoát ra người gọi. resolveTranscript() gộp các system message giữa hội thoại nếu mô hình không hỗ trợ chúng. output là message sẽ được lấp dần: content rỗng, usage bằng 0, stopReason: "pending". Chính object này là partial của mọi sự kiện.

Chọn client và xác thực

Có bốn cách dựng client SDK. Người gọi có thể truyền sẵn options.client (ví dụ AnthropicVertex). Với GitHub Copilot, khoá được gửi dưới dạng Bearer cùng các header động. Với token OAuth của gói Claude Pro/Max (nhận ra bằng chuỗi sk-ant-oat), client đóng vai Claude Code. Còn lại là khoá API, header xác thực tự đặt, hoặc liên kết danh tính workload (workload identity federation). Mọi client do adapter tự dựng đều là PiAnthropic, một lớp con tắt chuỗi tìm credential riêng của SDK để chỉ bộ phân giải của pi quyết định (anthropic-messages.ts).

Nhánh OAuth: Bearer và danh tính của Claude Code
pi/packages/ai/src/api/anthropic-messages.tsdòng 1048–1069TypeScript
// OAuth: Bearer auth, Claude Code identity headers instead of the client identity.if (apiKey && isOAuthToken(apiKey)) {	const client = new PiAnthropic({		apiKey: null,		authToken: apiKey,		baseURL: model.baseUrl,		dangerouslyAllowBrowser: true,		fetch,		defaultHeaders: mergeHeaders(			{				accept: "application/json",				"anthropic-dangerous-direct-browser-access": "true",				"user-agent": `claude-cli/${claudeCodeVersion}`,				"x-app": "cli",			},			model.headers,			optionsHeaders,		),	});	return { client, isOAuthToken: true };}

Ở nhánh OAuth, client gửi user-agent: claude-cli/<phiên bản> thay cho identity của ứng dụng, và về sau buildParams() chèn câu "You are Claude Code, Anthropic's official CLI for Claude." vào đầu system prompt, thêm beta claude-code-20250219 và oauth-2025-04-20, đổi tên tool sang đúng cách viết hoa của Claude Code như Read, Bash (anthropic-messages.ts). Tên tool trả về được đổi ngược lại theo danh sách tool hiện tại.

Gửi request: SDK không tự thử lại, pi tự lo
pi/packages/ai/src/api/anthropic-messages.tsdòng 638–663TypeScript
const requestOptions = {	...(options?.signal ? { signal: options.signal } : {}),	...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),	maxRetries: 0,};let disablePreferredStrictTools = false;let response: Response;for (;;) {	options?.signal?.throwIfAborted();	const built = buildParams(model, normalizedContext, isOAuth, options, disablePreferredStrictTools);	if (built.providerThinkingLevel) output.providerThinkingLevel = built.providerThinkingLevel;	let params = built.params;	const nextParams = await options?.onPayload?.(params, model);	if (nextParams !== undefined) {		params = { ...(nextParams as MessageCreateParamsStreaming), stream: true };	}	try {		response = await retryProviderRequest(			() => client.beta.messages.create(params, requestOptions).asResponse(),			{				maxRetries: options?.maxRetries,				maxRetryDelayMs: options?.maxRetryDelayMs,				signal: options?.signal,			},		);		break;

SDK được gọi với maxRetries: 0; việc thử lại do retryProviderRequest() của pi làm, vì thời gian chờ thử lại của SDK không dừng được bằng AbortSignal (provider-retry.ts). .asResponse() lấy Response thô để adapter tự đọc SSE. Vòng for (;;) chỉ lặp lại trong một trường hợp: Anthropic từ chối bộ tool strict (văn phạm quá lớn, quá nhiều tool strict) thì request được gửi lại một lần với các tool strict: "prefer" chuyển về không strict, và message nhận một diagnostic ghi lại việc này (anthropic-messages.ts).

Dựng request: buildParams()

buildParams(): message, giới hạn token, system prompt và cache_control
pi/packages/ai/src/api/anthropic-messages.tsdòng 1172–1177, 1219–1252TypeScript
const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env);const compat = getAnthropicCompat(model);const initialSystemMessage = getInitialSystemMessage(context.messages);const initialSystemText = initialSystemMessage ? getSystemMessageText(initialSystemMessage) : "";const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);const conversationMessages = initialSystemMessage ? transformedMessages.slice(1) : transformedMessages;const params: MessageCreateParamsStreaming = {	model: model.id,	messages: effortMessages ? insertThinkingLevelMessages(converted, activeEffort) : converted.messages,	max_tokens: options?.maxTokens ?? model.maxTokens,	stream: true,	...(betaFeatures.length > 0 ? { betas: betaFeatures } : {}),};// For OAuth tokens, we MUST include Claude Code identityif (isOAuthToken) {	params.system = [		{			type: "text",			text: "You are Claude Code, Anthropic's official CLI for Claude.",			...(cacheControl ? { cache_control: cacheControl } : {}),		},	];	if (initialSystemText) {		params.system.push({			type: "text",			text: sanitizeSurrogates(initialSystemText),			...(cacheControl ? { cache_control: cacheControl } : {}),		});	}} else if (initialSystemText) {	// Add cache control to system prompt for non-OAuth tokens	params.system = [		{			type: "text",			text: sanitizeSurrogates(initialSystemText),			...(cacheControl ? { cache_control: cacheControl } : {}),		},	];}

transformMessages() chạy đầu tiên (chuẩn hoá lịch sử cho mô hình đích, xem phần chuyển giao), kèm hàm normalizeToolCallId thay ký tự lạ bằng _ và cắt còn 64 ký tự, vì Anthropic chỉ nhận id khớp ^[a-zA-Z0-9_-]+$ (anthropic-messages.ts). System prompt đi riêng trong trường system, mang một điểm ngắt cache (cache_control). Tool được gửi theo danh sách hiện tại; khi mô hình hỗ trợ thay đổi tool gốc (native) giữa hội thoại, danh sách ban đầu được giữ cố định cùng một tool giữ chỗ bị hoãn (deferred) không bao giờ dùng, và các tool sau được khai báo trong khối tool_addition để tiền tố cache không đổi (anthropic-messages.ts).

Mỗi định nghĩa tool gồm tên (đổi cách viết hoa nếu là OAuth), mô tả, input_schema, cờ strict khi được bật, và hai trường đáng chú ý (convertTools). eager_input_streaming yêu cầu Anthropic truyền tham số tool ngay khi sinh ra; provider tương thích nào không nhận trường này thì đặt compat.supportsEagerToolInputStreaming: false và adapter quay về beta header fine-grained-tool-streaming. cache_control chỉ gắn vào tool cuối cùng: một điểm ngắt cache phủ cả danh sách tool phía trước nó.

Cấu hình thinking: tắt, adaptive hay theo ngân sách
pi/packages/ai/src/api/anthropic-messages.tsdòng 1278–1309TypeScript
if (thinkingDisabled) {	// No effort is sent, so the model default applies. Anthropic rejects `block_binding`	// with disabled thinking.	params.thinking = { type: "disabled" };} else if (effortMessages) {	// Managed effort models use adaptive thinking so prefix mismatches can	// be dropped instead of surfacing as persistent 400 responses.	params.thinking = {		type: "adaptive",		display: options?.thinkingDisplay ?? "summarized",		block_binding: { prefix_mismatch_behavior: "drop_block" },	};	params.output_config = { effort: "high" };} else if (model.reasoning && options?.thinkingEnabled) {	// Default to "summarized" so Opus 4.7 and Mythos Preview behave like	// older Claude 4 models (whose API default is also "summarized").	const display: AnthropicThinkingDisplay = options.thinkingDisplay ?? "summarized";	if (model.compat?.forceAdaptiveThinking === true) {		// Adaptive thinking: Claude decides when and how much to think.		params.thinking = { type: "adaptive", display };		if (options.effort) {			params.output_config = { effort: options.effort };		}	} else {		// Budget-based thinking for older models		params.thinking = {			type: "enabled",			budget_tokens: options.thinkingBudgetTokens || 1024,			display,		};	}}

Có bốn nhánh. Mô hình có suy nghĩ mà người gọi tắt (và thinkingLevelMap.off không phải null) nhận thinking: { type: "disabled" }. Mô hình đổi được effort giữa hội thoại (compat.supportsMidConvoEffort) luôn dùng adaptive với effort: "high" cố định ở cấp request; mức thật đi trong các system message chỉ chứa output_config, một cái trước mỗi câu trả lời cũ có ghi providerThinkingLevel và một cái ở cuối với mức hiện tại, để đổi mức suy nghĩ không làm đổi phần đầu đã cache (insertThinkingLevelMessages). Mô hình "adaptive" khác (compat.forceAdaptiveThinking) tự quyết định nghĩ bao nhiêu và chỉ nhận một mức effort trong output_config. Mô hình cũ nhận budget_tokens. Pi mặc định display: "summarized" dù API của các Claude mới mặc định là "omitted", để mọi mô hình Claude 4 trở lên cư xử như nhau (AnthropicOptions).

Đổi message: convertMessages()

Hàm này đi qua transcript một lần và dịch từng vai. User message bỏ khối chữ rỗng. Assistant message giữ chữ, tool call thành tool_use, còn suy nghĩ cần xử lý cẩn thận nhất:

Phát lại suy nghĩ: redacted, có chữ ký, hoặc mất chữ ký
pi/packages/ai/src/api/anthropic-messages.tsdòng 1442–1476TypeScript
} else if (block.type === "thinking") {	// Redacted thinking: pass the opaque payload back as redacted_thinking	if (block.redacted) {		blocks.push({			type: "redacted_thinking",			data: block.thinkingSignature!,		});		continue;	}	const thinkingSignature = block.thinkingSignature;	const hasThinkingSignature = !!thinkingSignature && thinkingSignature.trim().length > 0;	if (block.thinking.trim().length === 0 && !hasThinkingSignature) continue;	// If thinking signature is missing/empty (e.g., from aborted stream),	// convert to plain text for Anthropic. Some compatible providers emit	// and accept empty signatures, so let marked models preserve the block.	if (!hasThinkingSignature) {		blocks.push(			allowEmptySignature				? {						type: "thinking",						thinking: sanitizeSurrogates(block.thinking),						signature: "",					}				: {						type: "text",						text: sanitizeSurrogates(block.thinking),					},		);	} else {		blocks.push({			type: "thinking",			thinking: sanitizeSurrogates(block.thinking),			signature: thinkingSignature,		});	}

Anthropic chỉ nhận lại khối thinking có chữ ký hợp lệ. Khối bị che được gửi lại nguyên dạng redacted_thinking. Khối có chữ nhưng không có chữ ký (chú thích trong code lấy ví dụ luồng bị huỷ trước khi signature_delta tới; một số provider tương thích cũng trả chữ ký rỗng) bị đổi thành chữ thường, trừ khi provider khai báo allowEmptySignature.

Gom các kết quả tool liên tiếp vào một user message
pi/packages/ai/src/api/anthropic-messages.tsdòng 1500–1524TypeScript
} else if (msg.role === "toolResult") {	// Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint.	const toolResults: ContentBlockParam[] = [];	// Anthropic rejects error tool results that contain anything but text, so their images follow	// all tool results of the turn, which must come first in the user message (#10794).	const errorImages: ContentBlockParam[] = [];	let j = i;	while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") {		const toolMsg = transformedMessages[j] as ToolResultMessage;		const split = splitErrorToolResultImages(toolMsg);		toolResults.push(convertToolResult(toolMsg, split?.content));		if (split) {			errorImages.push({ type: "text", text: split.caption }, ...split.images.map(convertImageBlock));		}		j++;	}	// Skip the messages we've already processed.	i = j - 1;	params.push({		role: "user",		content: [...toolResults, ...errorImages],	});}

Ở Anthropic, kết quả tool là khối tool_result nằm trong một message vai user. Nhiều kết quả tool liên tiếp được gom vào cùng một message (endpoint Anthropic của z.ai cần điều này). Anthropic từ chối kết quả tool lỗi có chứa ảnh, nên ảnh của chúng được chuyển ra sau mọi tool_result của lượt, kèm một dòng chú thích (lỗi #10794). System message giữa hội thoại thì được giữ lại và chỉ chèn ngay trước assistant message kế tiếp, vì Anthropic yêu cầu tool_result phải đứng ngay sau tool_use tương ứng (anthropic-messages.ts).

Điểm ngắt cache thứ ba: khối cuối của message cuối
pi/packages/ai/src/api/anthropic-messages.tsdòng 1529–1555TypeScript
// Add cache_control to the last user or system message to cache conversation historyif (cacheControl && params.length > 0) {	const lastMessage = params[params.length - 1];	if (lastMessage.role === "user" || lastMessage.role === "system") {		if (Array.isArray(lastMessage.content)) {			const lastBlock = lastMessage.content[lastMessage.content.length - 1];			if (				lastBlock &&				(lastBlock.type === "text" ||					lastBlock.type === "image" ||					lastBlock.type === "tool_result" ||					lastBlock.type === "tool_addition" ||					lastBlock.type === "tool_removal")			) {				(lastBlock as any).cache_control = cacheControl;			}		} else if (typeof lastMessage.content === "string") {			lastMessage.content = [				{					type: "text",					text: lastMessage.content,					cache_control: cacheControl,				},			] as any;		}	}}

Đọc luồng: từ SSE tới sự kiện chuẩn hoá

Adapter tự đọc Server-Sent Events từ response.body: iterateSseMessages() tách dòng (chấp nhận cả \r, \n, \r\n), ghép các dòng data:, kiểm tra signal trước mỗi lần đọc (anthropic-messages.ts). Lớp trên lọc ra sự kiện của Anthropic:

iterateAnthropicEvents(): lỗi SSE, JSON có sửa lỗi, và luồng bị cắt
pi/packages/ai/src/api/anthropic-messages.tsdòng 530–569TypeScript
async function* iterateAnthropicEvents(	response: Response,	signal?: AbortSignal,): AsyncGenerator<RawMessageStreamEvent> {	if (!response.body) {		throw new Error("Attempted to iterate over an Anthropic response with no body");	}	let sawMessageStart = false;	let sawMessageEnd = false;	for await (const sse of iterateSseMessages(response.body, signal)) {		if (sse.event === "error") {			throw new Error(sse.data);		}		if (!ANTHROPIC_MESSAGE_EVENTS.has(sse.event ?? "")) {			continue;		}		try {			const event = parseJsonWithRepair<RawMessageStreamEvent>(sse.data);			if (event.type === "message_start") {				sawMessageStart = true;			} else if (event.type === "message_stop") {				sawMessageEnd = true;			}			yield event;		} catch (error) {			const message = error instanceof Error ? error.message : String(error);			throw new Error(				`Could not parse Anthropic SSE event ${sse.event}: ${message}; data=${sse.data}; raw=${sse.raw.join("\\n")}`,			);		}	}	if (sawMessageStart && !sawMessageEnd) {		throw new Error("Anthropic stream ended before message_stop");	}}

Ba hành vi đáng chú ý: sự kiện SSE error giữa luồng thành ngoại lệ; sự kiện không quen như ping bị bỏ qua; JSON hỏng được thử sửa trước khi bỏ cuộc. Nếu đã thấy message_start mà luồng hết trước message_stop, đó là lỗi chứ không phải một câu trả lời ngắn.

Vòng lặp chính dịch từng sự kiện. message_start không phát sự kiện nào ra ngoài, nhưng ghi lại usage ban đầu, để một request bị huỷ sớm vẫn có số token input:

message_start: responseId, mô hình thực, usage ban đầu
pi/packages/ai/src/api/anthropic-messages.tsdòng 696–721TypeScript
for await (const event of iterateAnthropicEvents(response, options?.signal)) {	await options?.onProviderStreamEvent?.(event, model);	if (event.type === "message_start") {		output.responseId = event.message.id;		const transformations = event.message.input_transformations;		if (Array.isArray(transformations)) inputTransformations = transformations;		const responseModel = event.message.model;		if (responseModel !== model.id) output.responseModel = responseModel;		const fallbackCost =			responseModel === model.id				? undefined				: model.compat?.allowedFallbackModels?.find(						(fallback) => fallback.provider === model.provider && fallback.model === responseModel,					)?.cost;		usageModel = fallbackCost ? { ...model, id: responseModel, cost: fallbackCost } : model;		// Capture initial token usage from message_start event		// This ensures we have input token counts even if the stream is aborted early		output.usage.input = event.message.usage.input_tokens || 0;		output.usage.output = event.message.usage.output_tokens || 0;		output.usage.cacheRead = event.message.usage.cache_read_input_tokens || 0;		output.usage.cacheWrite = event.message.usage.cache_creation_input_tokens || 0;		output.usage.cacheWrite1h = event.message.usage.cache_creation?.ephemeral_1h_input_tokens || 0;		// Anthropic doesn't provide total_tokens, compute from components		output.usage.totalTokens =			output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;		calculateCost(usageModel, output.usage);
content_block_start: mở một khối và phát *_start
pi/packages/ai/src/api/anthropic-messages.tsdòng 722–745, 756–769TypeScript
} else if (event.type === "content_block_start") {	if (event.content_block.type === "fallback") {		if (output.content.length > 0) {			throw new Error("Anthropic performed an unsupported mid-output model fallback");		}		continue;	}	if (event.content_block.type === "text") {		const block: Block = {			type: "text",			text: event.content_block.text ?? "",			index: event.index,		};		output.content.push(block);		stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output });	} else if (event.content_block.type === "thinking") {		const block: Block = {			type: "thinking",			thinking: event.content_block.thinking ?? "",			thinkingSignature: event.content_block.signature ?? "",			index: event.index,		};		output.content.push(block);		stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output });	} else if (event.content_block.type === "tool_use") {		const block: Block = {			type: "toolCall",			id: event.content_block.id,			name: isOAuth				? fromClaudeCodeName(event.content_block.name, currentTools)				: event.content_block.name,			arguments: (event.content_block.input as Record<string, any>) ?? {},			partialJson: "",			index: event.index,		};		output.content.push(block);		stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });	}

Mỗi khối nhận thêm một trường tạm index, là chỉ số khối của Anthropic. Chỉ số này không nhất thiết trùng với vị trí trong mảng content của pi: khối fallback bị bỏ qua, khối có kiểu lạ không được thêm vào content. Vì vậy mọi delta đều tìm khối bằng blocks.findIndex((b) => b.index === event.index) rồi phát sự kiện với contentIndex là vị trí trong mảng của pi.

Delta của tham số tool và của chữ ký suy nghĩ
pi/packages/ai/src/api/anthropic-messages.tsdòng 795–815TypeScript
} else if (event.delta.type === "input_json_delta") {	const index = blocks.findIndex((b) => b.index === event.index);	const block = blocks[index];	if (block && block.type === "toolCall") {		block.partialJson += event.delta.partial_json;		block.arguments = parseStreamingJson(block.partialJson);		stream.push({			type: "toolcall_delta",			contentIndex: index,			delta: event.delta.partial_json,			partial: output,		});	}} else if (event.delta.type === "signature_delta") {	const index = blocks.findIndex((b) => b.index === event.index);	const block = blocks[index];	if (block && block.type === "thinking") {		block.thinkingSignature = block.thinkingSignature || "";		block.thinkingSignature += event.delta.signature;	}}

Mỗi mảnh JSON của tham số được nối vào bộ đệm partialJson và phân tích lại toàn bộ bằng parseStreamingJson(), nên arguments luôn là một object dùng được ngay cả khi JSON còn dở (xem phần partial JSON). signature_delta chỉ nối vào chữ ký, không phát sự kiện nào.

content_block_stop: chốt khối, phát *_end
pi/packages/ai/src/api/anthropic-messages.tsdòng 816–847TypeScript
} else if (event.type === "content_block_stop") {	const index = blocks.findIndex((b) => b.index === event.index);	const block = blocks[index];	if (block) {		delete (block as any).index;		if (block.type === "text") {			stream.push({				type: "text_end",				contentIndex: index,				content: block.text,				partial: output,			});		} else if (block.type === "thinking") {			stream.push({				type: "thinking_end",				contentIndex: index,				content: block.thinking,				partial: output,			});		} else if (block.type === "toolCall") {			Object.assign(block, parseToolCallArguments(block.partialJson));			// Finalize in-place and strip the scratch buffer so replay only			// carries parsed arguments.			delete (block as { partialJson?: string }).partialJson;			stream.push({				type: "toolcall_end",				contentIndex: index,				toolCall: block,				partial: output,			});		}	}

Ở cuối khối tool, tham số được phân tích lần cuối bằng parseToolCallArguments(), nghiêm ngặt hơn: JSON hỏng thì arguments thành {} và có argumentsError. Bộ đệm partialJson và trường index bị xoá để message lưu vào session không mang rác của quá trình stream.

message_delta: lý do dừng và usage cuối
pi/packages/ai/src/api/anthropic-messages.tsdòng 848–858, 881–891TypeScript
} else if (event.type === "message_delta") {	const transformations = event.input_transformations;	if (Array.isArray(transformations)) inputTransformations = transformations;	if (event.delta.stop_reason) {		output.rawStopReason = event.delta.stop_reason;		const stopReasonResult = mapStopReason(event.delta.stop_reason, event.delta.stop_details);		output.stopReason = stopReasonResult.stopReason;		if (stopReasonResult.errorMessage) {			output.errorMessage = stopReasonResult.errorMessage;		}	}		// Anthropic reports reasoning tokens as a subset of output tokens.		const thinkingTokens = event.usage.output_tokens_details?.thinking_tokens;		if (thinkingTokens != null) {			output.usage.reasoning = thinkingTokens;		}	}	// Anthropic doesn't provide total_tokens, compute from components	output.usage.totalTokens =		output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;	calculateCost(usageModel, output.usage);}

Anthropic gửi usage cuối trong message_delta. Code chỉ ghi đè những trường khác null, vì một số proxy bỏ input_tokens ở sự kiện này. totalTokens được cộng từ bốn thành phần vì Anthropic không trả tổng, và chi phí được tính lại sau mỗi lần cập nhật.

Kết thúc, lỗi và huỷ

Từ vòng đọc tới done hoặc error
pi/packages/ai/src/api/anthropic-messages.tsdòng 894–930TypeScript
	if (options?.signal?.aborted) {		throw new Error("Request was aborted");	}	if (output.stopReason === "pending") {		throw new Error("Anthropic stream ended without a stop reason");	}	if (output.stopReason === "aborted" || output.stopReason === "error") {		throw new Error(output.errorMessage || "An unknown error occurred");	}	if (inputTransformations && inputTransformations.length > 0) {		appendAssistantMessageDiagnostic(output, {			type: "anthropic_input_transformations",			timestamp: Date.now(),			details: {				transformations: inputTransformations.map((transformation) => ({					type: transformation.type ?? undefined,					path: transformation.path ?? undefined,					reason: transformation.reason ?? undefined,				})),			},		});	}	stream.push({ type: "done", reason: output.stopReason, message: output });	stream.end();} catch (error) {	for (const block of output.content) {		delete (block as { index?: number }).index;		// partialJson is only a streaming scratch buffer; never persist it.		delete (block as { partialJson?: string }).partialJson;	}	output.stopReason = options?.signal?.aborted ? "aborted" : "error";	output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);	stream.push({ type: "error", reason: output.stopReason, error: output });	stream.end();}

Mọi đường thất bại đều đổ về một catch: dọn trường tạm, đặt stopReason là aborted nếu tín hiệu huỷ đã bật, ngược lại là error, rồi phát sự kiện error với chính output. Vì output được lấp dần trong lúc đọc, message lỗi giữ nguyên phần chữ, suy nghĩ và usage đã nhận. Đó là "abort trả về message dở dang" mà README nói tới.

Lý do dừng của Anthropic được ánh xạ trong mapStopReason(): end_turn thành stop, max_tokens thành length, tool_use thành toolUse; refusal và sensitive thành lỗi có thông báo; pause_turn được coi như stop; một lý do mới chưa biết làm request thất bại thay vì bị đoán bừa.

streamSimple(): dịch mức suy nghĩ

Từ reasoning của Pi sang effort hoặc budget_tokens
pi/packages/ai/src/api/anthropic-messages.tsdòng 974–1008TypeScript
if (!options?.reasoning) {	return stream(model, context, {		...base,		thinkingEnabled: false,	} satisfies AnthropicOptions);}// For models with adaptive thinking: use an effort level.// For older models: use budget-based thinking.if (model.compat?.forceAdaptiveThinking === true) {	const effort = mapThinkingLevelToEffort(model, options.reasoning);	return stream(model, context, {		...base,		thinkingEnabled: true,		effort,	} satisfies AnthropicOptions);}// Undefined means the caller did not request an output cap; let the helper use the model cap.// Do not coerce to 0 here, or the thinking budget would become the entire max_tokens value.const adjusted = adjustMaxTokensForThinking(	base.maxTokens,	model.maxTokens,	options.reasoning,	options.thinkingBudgets,);const maxTokens = clampMaxTokensToContext(model, context, adjusted.maxTokens);return stream(model, context, {	...base,	maxTokens,	thinkingEnabled: true,	thinkingBudgetTokens: Math.min(adjusted.thinkingBudget, Math.max(0, maxTokens - 1024)),} satisfies AnthropicOptions);

Không có reasoning thì tắt suy nghĩ. Mô hình adaptive nhận một effort: lấy từ thinkingLevelMap nếu có, không thì minimal và low thành low, medium thành medium, còn lại thành high (mapThinkingLevelToEffort). Mô hình cũ nhận một ngân sách lấy từ bảng mặc định 1.024, 2.048, 8.192 và 16.384 token cho minimal tới high (simple-options.ts); max_tokens được nới để chứa cả ngân sách lẫn câu trả lời, và ngân sách luôn chừa ít nhất 1.024 token cho câu trả lời.

Trace: một request có một lời gọi tool#

Ghép các mảnh trên lại bằng một kịch bản cụ thể. Ứng dụng có một tool read và hỏi mô hình Claude đọc package.json, với mức suy nghĩ low. Giả sử mô hình dùng thinking theo ngân sách và khoá nằm trong ANTHROPIC_API_KEY.

Ví dụ minh hoạ: lời gọi
const s = models.streamSimple(model, {
  systemPrompt: "Bạn là trợ lý lập trình.",
  messages: [{ role: "user", content: "Đọc package.json", timestamp: 1761100000000 }],
  tools: [readTool],                 // name "read", parameters { path: string }
}, { reasoning: "low", sessionId: "sess-1" });
Lời gọi ở tầng ứng dụng. Các giá trị là minh hoạ.
  1. Chuẩn hoánormalizeContext() biến context thành transcript hai message: system message { content: "Bạn là trợ lý...", toolsAdded: [read], timestamp: 0 } và user message. streamSimple() trả về luồng ngoài ngay lập tức.
  2. Setup trong lazyStreamrequireChatProvider() tìm provider anthropic; applyAuth() đọc credential đã lưu (không có) rồi tới các biến môi trường, ở đây là ANTHROPIC_API_KEY; createProvider chọn adapter duy nhất; lazyApi import anthropic-messages.ts lần đầu.
  3. streamSimple của adapterbuildBaseOptions() kẹp maxTokens; mức low thành thinkingEnabled: true với ngân sách 2.048 token.
  4. buildParams()Dựng payload bên dưới, gọi onPayload nếu có, gửi qua client.beta.messages.create(...).asResponse().
  5. HTTP 200Gọi onResponse, phát start, bắt đầu đọc SSE.
Ví dụ minh hoạ: payload gửi Anthropic (rút gọn)
{
  "model": "claude-sonnet-4-5",
  "messages": [{ "role": "user", "content": [
      { "type": "text", "text": "Đọc package.json", "cache_control": { "type": "ephemeral" } } ] }],
  "max_tokens": …,                                  // model.maxTokens sau khi kẹp
  "stream": true,
  "betas": ["interleaved-thinking-2025-05-14"],
  "system": [{ "type": "text", "text": "Bạn là trợ lý lập trình.",
               "cache_control": { "type": "ephemeral" } }],
  "tools": [{ "name": "read", "description": "…", "eager_input_streaming": true,
              "input_schema": { "type": "object", "properties": { "path": … }, "required": ["path"] },
              "cache_control": { "type": "ephemeral" } }],
  "thinking": { "type": "enabled", "budget_tokens": 2048, "display": "summarized" }
}
Ba điểm ngắt cache: system prompt, tool cuối, khối cuối của message cuối. User message dạng chuỗi được đổi thành mảng khối để gắn được cache_control.

Anthropic trả về một chuỗi SSE. Bảng dưới ghép từng sự kiện thô với những gì pi-ai phát ra:

SSE từ Anthropicpi-ai phát raTrạng thái của output
message_startkhông córesponseId, usage input và cache
content_block_start #0 thinkingthinking_start (0)content[0] = suy nghĩ rỗng
content_block_delta thinking_deltathinking_delta (0)chữ suy nghĩ lớn dần
content_block_delta signature_deltakhông cónối thinkingSignature
content_block_stop #0thinking_end (0)xoá trường tạm index
content_block_start #1 text, rồi text_delta, content_block_stoptext_start, text_delta, text_end (1)content[1] = "Để tôi đọc file."
content_block_start #2 tool_use readtoolcall_start (2)arguments: {}, partialJson: ""
input_json_delta {"path": "packtoolcall_delta (2)arguments: { path: "pack" }
input_json_delta age.json"}toolcall_delta (2)arguments: { path: "package.json" }
content_block_stop #2toolcall_end (2)phân tích nghiêm ngặt, xoá partialJson
message_delta stop_reason: tool_usekhông cóstopReason: "toolUse", usage output, chi phí
message_stop, rồi luồng HTTP đóngdone (reason: "toolUse")message cuối, có durationMs
Ví dụ minh hoạ: await s.result()
{
  "role": "assistant",
  "content": [
    { "type": "thinking", "thinking": "Cần đọc file trước…", "thinkingSignature": "EqQB…" },
    { "type": "text", "text": "Để tôi đọc file." },
    { "type": "toolCall", "id": "toolu_01…", "name": "read", "arguments": { "path": "package.json" } }
  ],
  "api": "anthropic-messages", "provider": "anthropic", "model": "claude-sonnet-4-5",
  "responseId": "msg_01…",
  "usage": { "input": 18, "output": 96, "cacheRead": 0, "cacheWrite": 1450, "cacheWrite1h": 0,
             "totalTokens": 1564, "cost": { "input": …, "output": …, "cacheRead": 0, "cacheWrite": …, "total": … } },
  "stopReason": "toolUse",
  "timestamp": 1761100000000,
  "durationMs": 2140
}
Message cuối. Không còn index hay partialJson; các con số là minh hoạ.

pi-ai dừng ở đây. Vòng lặp agent đọc khối toolCall, kiểm tra tham số, chạy read, rồi nối hai message vào transcript: chính message trên và một toolResult có toolCallId bằng id của lời gọi. Ở request tiếp theo, convertMessages() dịch assistant message thành các khối thinking (có chữ ký), text và tool_use, dịch kết quả tool thành một user message chứa tool_result, và điểm ngắt cache thứ ba dời sang khối tool_result đó. Nhờ prefix cũ không đổi, request thứ hai đọc lại phần lớn prompt từ cache.

Adapter OpenAI Responses để đối chiếu#

Khung của api/openai-responses.ts giống Anthropic: output rỗng với stopReason: "pending", gửi request qua SDK với maxRetries: 0 bọc trong retryProviderRequest(), phát start sau khi có phản hồi, một catch cho mọi lỗi (openai-responses.ts). Phần đọc luồng nằm trong openai-responses-shared.ts vì Azure và Codex dùng lại nó. Khác biệt nằm ở mô hình dữ liệu của API: Responses không có "khối nội dung" mà có các output item (reasoning, message, function_call), và nó có thể giữ trạng thái hội thoại phía máy chủ.

buildParams(): không lưu phía máy chủ, xin lại suy nghĩ đã mã hoá
pi/packages/ai/src/api/openai-responses.tsdòng 369–377, 402–420TypeScript
const params: ResponseCreateParamsStreaming = {	model: model.id,	input: messages,	stream: true,	prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId),	prompt_cache_retention: omitUnsupportedFields ? undefined : getPromptCacheRetention(compat, cacheRetention),	prompt_cache_options: omitUnsupportedFields ? undefined : getPromptCacheOptions(compat, cacheRetention),	store: false,};const reasoningEffort = options?.reasoningEffort ?? (options?.reasoningSummary ? "medium" : undefined);if (model.reasoning) {	if (reasoningEffort) {		const requestedEffort = options?.reasoningEffort			? (model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort)			: reasoningEffort;		const effort = configurationUpdateEffort === undefined ? requestedEffort : CONFIGURATION_UPDATE_REQUEST_EFFORT;		params.reasoning = {			effort: effort as NonNullable<typeof params.reasoning>["effort"],			summary: options?.reasoningSummary || "auto",		};		params.include = ["reasoning.encrypted_content"];	} else if (model.provider !== "github-copilot" && model.thinkingLevelMap?.off !== null) {		params.reasoning = {			effort: (model.thinkingLevelMap?.off ?? "none") as NonNullable<typeof params.reasoning>["effort"],		};	}	if (model.provider === "xai") params.include = ["reasoning.encrypted_content"];}

Đây là quyết định quan trọng nhất của adapter. Pi gửi store: false, tức không để OpenAI giữ hội thoại, và xin reasoning.encrypted_content để tự giữ phần suy nghĩ đã mã hoá. Mỗi lượt, toàn bộ lịch sử được gửi lại từ transcript cục bộ; reasoning item cũ được phát lại nguyên vẹn từ thinkingSignature (openai-responses-shared.ts). Transcript cục bộ vì thế là nguồn sự thật duy nhất: adapter này không gửi previous_response_id trỏ vào trạng thái mà chỉ máy chủ OpenAI biết. Adapter Codex có một ngoại lệ được kiểm soát: trên một kết nối WebSocket được dùng lại, nó gửi previous_response_id cùng phần input mới, nhưng chỉ khi request mới đúng bằng request trước cộng output của nó; lệch bất kỳ chỗ nào thì gửi lại toàn bộ từ transcript (openai-codex-responses.ts).

Output item thành khối của pi: id ghép và chữ ký là cả reasoning item
pi/packages/ai/src/api/openai-responses-shared.tsdòng 564–582, 766–778TypeScript
if (item.type === "function_call") {	const block: StreamingToolCall = {		type: "toolCall",		id: `${item.call_id}|${item.id}`,		name: item.name,		arguments: {},		...(item.namespace !== undefined ? { namespace: item.namespace } : {}),		partialJson: item.arguments || "",	};	output.content.push(block);	const slot = {		type: "toolCall",		block,		contentIndex: output.content.length - 1,	} satisfies ResponsesOutputSlot;	outputSlots.set(outputIndex, slot);	stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output });	return slot;}	if (item.type === "reasoning" && slot?.type === "thinking") {		const summaryText = item.summary?.map((s) => s.text).join("\n\n") || "";		const contentText = item.content?.map((c) => c.text).join("\n\n") || "";		slot.block.thinking = summaryText || contentText || slot.block.thinking;		slot.block.thinkingSignature = JSON.stringify(item);		reasoningBlocksById.set(item.id, slot.block);		stream.push({			type: "thinking_end",			contentIndex: slot.contentIndex,			content: slot.block.thinking,			partial: output,		});		outputSlots.delete(event.output_index);
  • Mỗi output item được ánh xạ vào một "slot" theo output_index; delta tìm slot theo chỉ số này, giống vai trò của trường tạm index bên Anthropic.
  • Id của lời gọi tool là call_id|item_id: call_id để ghép với kết quả tool, item_id (fc_…) để phát lại đúng item. Khi phát lại, id được tách ở dấu |. Với message của một mô hình khác cùng provider và API, item_id bị bỏ để OpenAI không kiểm tra cặp reasoning và tool call; với lời gọi tool đến từ provider khác, item_id được thay bằng fc_ cộng một hash (openai-responses-shared.ts, dòng 360).
  • Suy nghĩ hiển thị là phần tóm tắt (reasoning_summary_text.delta), các phần tóm tắt cách nhau bằng \n\n; ở output_item.done, chữ được thay bằng bản cuối và cả item được lưu làm chữ ký.
finalizeResponse(): usage và lý do dừng
pi/packages/ai/src/api/openai-responses-shared.tsdòng 638–675TypeScript
	if (response?.usage) {		const inputDetails = response.usage.input_tokens_details as			| { cached_tokens?: number; cache_write_tokens?: number }			| undefined;		const cachedTokens = inputDetails?.cached_tokens || 0;		const cacheWriteTokens = inputDetails?.cache_write_tokens || 0;		output.usage = {			// OpenAI includes cached and cache-write tokens in input_tokens, so subtract both.			input: Math.max(0, (response.usage.input_tokens || 0) - cachedTokens - cacheWriteTokens),			output: response.usage.output_tokens || 0,			cacheRead: cachedTokens,			cacheWrite: cacheWriteTokens,			reasoning: response.usage.output_tokens_details?.reasoning_tokens || 0,			totalTokens: response.usage.total_tokens || 0,			cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },		};	}	calculateCost(model, output.usage);	if (options?.applyServiceTierPricing) {		const serviceTier = options.resolveServiceTier			? options.resolveServiceTier(response?.service_tier, options.serviceTier)			: (response?.service_tier ?? options.serviceTier);		options.applyServiceTierPricing(output.usage, serviceTier);	}	// Map status to stop reason. For incomplete responses, retain the provider's	// specific reason so max-output truncation and content filtering stay distinct.	const status = response?.status;	const incompleteDetails = response?.incomplete_details as { reason?: unknown } | null | undefined;	const incompleteReason = typeof incompleteDetails?.reason === "string" ? incompleteDetails.reason : undefined;	output.rawStopReason = incompleteReason ? `${status}.${incompleteReason}` : status;	const mappedStop = mapStopReason(status, incompleteReason);	output.stopReason = mappedStop.stopReason;	if (mappedStop.errorMessage === undefined) delete output.errorMessage;	else output.errorMessage = mappedStop.errorMessage;	if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") {		output.stopReason = "toolUse";	}};

Hai điểm khác Anthropic. Usage chỉ có ở sự kiện cuối (response.completed hoặc response.incomplete), và input_tokens đã gồm token cache nên phải trừ ra. Responses không có lý do dừng "gọi tool": trạng thái completed thành stop, rồi đổi thành toolUse nếu message có lời gọi tool; incomplete vì max_output_tokens thành length, còn incomplete vì lý do khác là lỗi. Ở bản 0.80 changelog ghi response.incomplete là "length stop" (packages/ai/CHANGELOG.md:880); code v1.4.0 đã hẹp hơn thế.

Adapter còn hai hàng rào an toàn. Luồng hết mà chưa thấy sự kiện kết thúc của response thì là lỗi. Và nếu message dừng với toolUse mà còn lời gọi tool chưa nhận output_item.done, cả request thất bại, vì vòng lặp agent sẽ chạy mọi lời gọi tool trong message cuối và không được chạy một lệnh có tham số bị cắt (openai-responses-shared.ts). Cuối cùng, chi phí được nhân theo service tier: flex một nửa, priority và fast gấp đôi (2,5 lần với gpt-5.5) (openai-responses.ts).

Đào sâu: Sign in with ChatGPT dùng chung adapter này

Provider openai có OAuth "Sign in with ChatGPT". Token OAuth tới thẳng api.openai.com nhưng không bắt đầu bằng sk-, và adapter nhận ra điều đó để bỏ các trường mà đường này từ chối: prompt_cache_retention, prompt_cache_options, max_output_tokens, temperature, và các item configuration_update (isChatGPTSignIn, buildParams). Lỗi hết hạn mức của gói được nối thêm đường dẫn tới trang usage của ChatGPT.

OpenAI Completions và các cờ compat#

Chat Completions là API mà hầu như mọi máy chủ "tương thích OpenAI" nói: Groq, DeepSeek, Together, vLLM, llama.cpp, Ollama, LM Studio, các gateway. "Tương thích" ở đây chỉ gần đúng. Có máy chủ không nhận vai developer, có máy chủ đòi max_tokens thay vì max_completion_tokens, có máy chủ trả suy nghĩ trong reasoning_content, có máy chủ khác gọi nó là reasoning. Adapter giải quyết bằng một bộ cờ compat: tự đoán từ provider và baseUrl, rồi để model.compat ghi đè từng cờ.

detectCompat(): giá trị mặc định theo provider và URL
pi/packages/ai/src/api/openai-completions.tsdòng 1654–1676TypeScript
return {	supportsStore: !isNonStandard,	supportsDeveloperRole: isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter),	supportsReasoningEffort:		!isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isAntLing,	supportsUsageInStreaming: true,	supportsFinishReason: true,	maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",	requiresToolResultName: false,	requiresAssistantAfterToolResult: false,	requiresThinkingAsText: false,	requiresReasoningContentOnAssistantMessages: isDeepSeek,	thinkingFormat: isDeepSeek		? "deepseek"		: isZai			? "zai"			: isTogether				? "together"				: isAntLing					? "ant-ling"					: isOpenRouter						? "openrouter"						: "openai",

getCompat() lấy kết quả này làm nền và ghi đè bằng mọi cờ có trong model.compat (getCompat), nên một mô hình tự định nghĩa chỉ cần khai báo những gì khác mặc định (README). Các cờ hay gặp và nơi chúng có hiệu lực:

CờHành vi
supportsDeveloperRoleMô hình có suy nghĩ nhận system prompt ở vai developer; tắt cờ thì dùng system.
maxTokensFieldmax_completion_tokens hay max_tokens (packages/ai/src/api/openai-completions.ts:848–855).
supportsUsageInStreaming, supportsStoreGửi stream_options.include_usage và store: false hay không (packages/ai/src/api/openai-completions.ts:840–846).
supportsFinishReasonMáy chủ không gửi finish_reason thì pi tự suy ra stop hay toolUse khi luồng hết (packages/ai/src/api/openai-completions.ts:698–706).
requiresToolResultName, requiresAssistantAfterToolResultThêm trường name vào kết quả tool; chèn một assistant message giả "I have processed the tool results." giữa kết quả tool và user message (packages/ai/src/api/openai-completions.ts:1250–1255).
requiresThinkingAsText, requiresReasoningContentOnAssistantMessagesPhát lại suy nghĩ thành chữ thường; thêm reasoning_content: "" vào mọi assistant message (DeepSeek).
thinkingFormatMức suy nghĩ đi vào trường nào của request (bảng dưới).
cacheControlFormat: "anthropic"Gắn cache_control kiểu Anthropic cho Claude qua OpenRouter (packages/ai/src/api/openai-completions.ts:1095–1103).
supportsMidConvoSystemMessages, supportsMidConvoToolAdditionsGiữ system message giữa hội thoại; thêm tool bằng system message có trường tools kiểu Kimi (packages/ai/src/api/openai-completions.ts:1257–1266).

Riêng mức suy nghĩ có tới mười một cách gửi, chọn bằng thinkingFormat (buildParams):

thinkingFormatTrường gửi điTự chọn cho
openai (mặc định)reasoning_effortOpenAI và các máy chủ chưa biết
openrouterreasoning: { effort }OpenRouter
deepseekthinking: { type } cộng reasoning_effortDeepSeek
zaithinking: { type, clear_thinking }z.ai
togetherreasoning: { enabled }Together
ant-lingreasoning: { effort } chỉ khi được ánh xạAnt Ling
qwen, qwen-chat-template, chat-template, baseten, string-thinkingenable_thinking, chat_template_kwargs, chat_template_args, hoặc thinking dạng chuỗichỉ khi khai báo trong compat

Phần đọc luồng cũng phải đoán. Suy nghĩ có thể nằm ở reasoning_content (llama.cpp), reasoning hay reasoning_text; code lấy trường đầu tiên khác rỗng để không nhân đôi khi máy chủ gửi cả hai, và lưu tên trường vào thinkingSignature để lần sau phát lại đúng trường đó:

Tìm suy nghĩ trong delta: ba tên trường, xử lý trước chữ
pi/packages/ai/src/api/openai-completions.tsdòng 593–626TypeScript
// Handle reasoning before content: some proxies (e.g. LiteLLM) aggregate reasoning and// answer into one delta, and the thinking block must precede the text block.// Some endpoints return reasoning in reasoning_content (llama.cpp),// or reasoning (other openai compatible endpoints)// Use the first non-empty reasoning field to avoid duplication// (e.g., chutes.ai returns both reasoning_content and reasoning with same content)const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"];const deltaFields = choice.delta as Record<string, unknown>;let foundReasoningField: string | null = null;for (const field of reasoningFields) {	const value = deltaFields[field];	if (typeof value === "string" && value.length > 0) {		foundReasoningField = field;		break;	}}if (foundReasoningField) {	const delta = deltaFields[foundReasoningField];	if (typeof delta === "string" && delta.length > 0) {		const thinkingSignature =			model.provider === "opencode-go" && foundReasoningField === "reasoning"				? "reasoning_content"				: foundReasoningField;		const block = ensureThinkingBlock(thinkingSignature);		block.thinking += delta;		stream.push({			type: "thinking_delta",			contentIndex: getContentIndex(block),			delta,			partial: output,		});	}}

Suy nghĩ được xử lý trước nội dung của cùng delta, vì một số proxy như LiteLLM gộp cả hai vào một chunk và khối suy nghĩ phải đứng trước khối chữ. Lời gọi tool thì được ghép theo tool_calls[i].index, hoặc theo id khi máy chủ không gửi index. Id lời gọi tool đến từ Responses (dạng call|item, có thể dài hơn 400 ký tự) phải được rút gọn khi phát lại vào Chat Completions:

Rút gọn id lời gọi tool cho Chat Completions: tối đa 40 ký tự
pi/packages/ai/src/api/openai-completions.tsdòng 1208–1232TypeScript
const normalizeToolCallId = (id: string): string => {	// Handle pipe-separated IDs from OpenAI Responses API	// Format: {call_id}|{id} where {id} can be 400+ chars with special chars (+, /, =)	// These come from providers like github-copilot, openai-codex, opencode	// Extract just the call_id part and normalize it	// Multiple tool calls in the same turn can share call_id but differ by item_id.	// Preserve item-level uniqueness when replaying into Chat Completions, which	// requires distinct tool call ids.	if (id.includes("|")) {		// Sanitize to allowed chars and truncate to 40 chars (OpenAI limit)		const separatorIndex = id.indexOf("|");		const callId = id.slice(0, separatorIndex).replace(/[^a-zA-Z0-9_-]/g, "_");		const itemId = id.slice(separatorIndex + 1).replace(/[^a-zA-Z0-9_-]/g, "_");		const combinedId = itemId.length > 0 ? `${callId}_${itemId}` : callId;		if (combinedId.length <= 40) {			return combinedId;		}		const hash = shortHash(id).slice(0, 8);		const prefix = callId.slice(0, Math.max(1, 40 - hash.length - 1));		return `${prefix}_${hash}`;	}	if (model.provider === "openai") return id.length > 40 ? id.slice(0, 40) : id;	return id;};

Usage là chỗ các máy chủ khác nhau nhiều nhất. OpenAI và OpenRouter đặt token đọc cache trong prompt_tokens_details.cached_tokens, DeepSeek dùng prompt_cache_hit_tokens, Kimi đặt cached_tokens ở cấp trên cùng, Moonshot có khi đặt usage trong choice.usage:

parseChunkUsage(): một định nghĩa usage cho nhiều cách báo
pi/packages/ai/src/api/openai-completions.tsdòng 1540–1569TypeScript
const promptTokens = rawUsage.prompt_tokens || 0;const cacheReadTokens =	rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? rawUsage.cached_tokens ?? 0;const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0;// Follow documented OpenAI/OpenRouter semantics: cached_tokens is cache-read// tokens (hits). Providers disagree on placement: OpenAI/OpenRouter use// prompt_tokens_details.cached_tokens, DeepSeek uses prompt_cache_hit_tokens,// and Kimi documents top-level usage.cached_tokens on the final usage chunk.// OpenAI does not document or emit cache_write_tokens, but// OpenRouter-compatible providers can include it as a separate write count.// OpenRouter's own provider/tests affirm the separate mapping:// https://github.com/OpenRouterTeam/ai-sdk-provider/pull/409// Do not subtract writes from cached_tokens, otherwise spec-compliant// providers are under-reported. DS4 mirrors this contract too:// https://github.com/antirez/ds4/pull/29const input = Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens);// OpenAI completion_tokens already includes reasoning_tokens.const outputTokens = rawUsage.completion_tokens || 0;const usage: AssistantMessage["usage"] = {	input,	output: outputTokens,	cacheRead: cacheReadTokens,	cacheWrite: cacheWriteTokens,	reasoning: rawUsage.completion_tokens_details?.reasoning_tokens || 0,	totalTokens: input + outputTokens + cacheReadTokens + cacheWriteTokens,	cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },};calculateCost(model, usage);return usage;

Khi transcript có lịch sử tool nhưng request hiện tại không còn tool nào, adapter vẫn gửi tools: [], vì Anthropic đứng sau một proxy kiểu LiteLLM từ chối message có tool_calls mà không có tham số tools (packages/ai/src/api/openai-completions.ts:866–869).

Tham số tool truyền dần: partial JSON#

Khi mô hình gọi tool write với một file 300 dòng, tham số tới thành hàng trăm mảnh JSON trong nhiều giây. Nếu chỉ phân tích JSON khi lời gọi xong, giao diện phải đứng im suốt thời gian đó. Bài viết của Mario nêu đây là một lý do pi-ai phân tích JSON dở dang: giao diện có thể hiện đường dẫn file và nội dung đang được viết ngay khi chúng xuất hiện. Có hai hàm, cho hai thời điểm khác nhau:

Một hàm nghiêm ngặt cho lúc kết thúc, một hàm khoan dung cho lúc đang truyền
pi/packages/ai/src/utils/json-parse.tsdòng 106–120, 129–149TypeScript
export function parseToolCallArguments(json: string | undefined): Pick<ToolCall, "arguments" | "argumentsError"> {	if (!json?.trim()) return { arguments: {} };	try {		const parsed = parseJsonWithRepair<ToolCall["arguments"] | null>(json);		if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {			throw new Error("Tool arguments must be a JSON object");		}		return { arguments: parsed };	} catch (error) {		return {			arguments: {},			argumentsError: `${error instanceof Error ? error.message : String(error)}\n\nReceived arguments:\n${json}`,		};	}}export function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T {	if (!partialJson || partialJson.trim() === "") {		return {} as T;	}	try {		return parseJsonWithRepair<T>(partialJson);	} catch {		try {			const result = partialParse(partialJson);			return (result ?? {}) as T;		} catch {			try {				const result = partialParse(repairJson(partialJson));				return (result ?? {}) as T;			} catch {				return {} as T;			}		}	}}
  • parseStreamingJson() chạy sau mỗi delta và không bao giờ thất bại: thử JSON.parse (có sửa lỗi), rồi thư viện partial-json, rồi partial-json trên chuỗi đã sửa, cuối cùng trả {}.
  • parseToolCallArguments() chạy một lần khi khối tool kết thúc. Nó không chấp nhận tiền tố dở: chuỗi cuối không phải JSON object thì arguments là {} và argumentsError ghi lỗi kèm chuỗi đã nhận. Trước bản 1.3.0, lời gọi hỏng chạy với những trường đã phân tích được trước chỗ lỗi (packages/ai/CHANGELOG.md:67).
  • repairJson() sửa hai lỗi hay gặp trong chuỗi JSON mà mô hình sinh ra: ký tự điều khiển chưa escape (xuống dòng thật bên trong chuỗi) và escape không hợp lệ, ví dụ \usepackage trong mã LaTeX, bằng cách nhân đôi dấu gạch ngược (repairJson).
Ví dụ minh hoạ: partialJson sau từng delta  →  arguments
{"path": "src/ma                       →  { path: "src/ma" }
{"path": "src/main.ts", "con           →  { path: "src/main.ts" }
{"path": "src/main.ts", "content": "a\nb →  { path: "src/main.ts", content: "a\nb" }
{"path": "src/main.ts", "content": "a\nb"}  →  toolcall_end: parseToolCallArguments() chốt
Trường có thể thiếu, chuỗi có thể bị cắt giữa từ. Code đọc arguments lúc đang truyền phải phòng thủ (packages/ai/README.md:686–693).

Google là ngoại lệ: API của nó trả nguyên lời gọi hàm trong một lần, nên adapter phát toolcall_start, đúng một toolcall_delta chứa toàn bộ JSON, rồi toolcall_end liền nhau. Nếu Google không gửi id hoặc id bị trùng, adapter tự tạo id dạng <tên>_<thời gian>_<số đếm> (google-generative-ai.ts).

Lỗi, abort và tràn ngữ cảnh#

Thông báo lỗi đọc được

Lỗi HTTP sau một proxy thường mất phần body: SDK chỉ còn lại "403 status code (no body)". normalizeProviderError() dò các trường mà từng SDK dùng để cất status và body (Mistral, openai, @google/genai, AWS), và formatProviderError() ghép chúng thành "OpenAI API error (403): <body>", cắt body ở 4.000 ký tự (error-body.ts). Adapter Anthropic dùng thẳng error.message vì SDK của nó đã gộp body vào message. Thông báo lỗi quan trọng vì các tầng trên phân loại lỗi bằng chính chuỗi này: thử lại hay không, có phải tràn ngữ cảnh hay không.

Hai tầng thử lại

Tầng HTTP: thử lại trước khi luồng bắt đầu
pi/packages/ai/src/utils/provider-retry.tsdòng 25–37, 107–128TypeScript
function isRetryableProviderError(error: ProviderError): boolean {	const shouldRetry = error.headers?.get("x-should-retry");	if (shouldRetry === "true") return true;	if (shouldRetry === "false") return false;	if (error.status === undefined) return true;	return (		error.status === 408 ||		error.status === 409 ||		error.status === 429 ||		(typeof error.status === "number" && error.status >= 500)	);}export async function retryProviderRequest<T>(	request: () => Promise<T>,	options: ProviderRetryOptions = {},): Promise<T> {	const maxRetries = options.maxRetries ?? 0;	let retriesRemaining = maxRetries;	for (;;) {		try {			// Each retry is a fresh SDK request, so X-Stainless-Retry-Count remains zero.			return await request();		} catch (error) {			if (options.signal?.aborted) throw createAbortError();			if (retriesRemaining <= 0 || !isProviderError(error) || !isRetryableProviderError(error)) throw error;			if (error.status !== undefined && options.noRetryStatuses?.includes(error.status)) throw error;			const retryIndex = maxRetries - retriesRemaining;			retriesRemaining--;			await abortableSleep(getRetryDelayMs(error, retryIndex, options.maxRetryDelayMs), options.signal);		}	}}

Tầng thứ nhất nằm trong adapter và chỉ bọc lời gọi tạo request, trước khi có byte nào của luồng: thử lại với 408, 409, 429, 5xx hoặc lỗi kết nối mà SDK báo không kèm status (header x-should-retry của máy chủ được ưu tiên), tôn trọng retry-after, và thất bại ngay nếu máy chủ đòi chờ lâu hơn maxRetryDelayMs (mặc định 60 giây). Mặc định của maxRetries ở đây là 0: không truyền gì thì không thử lại. Tầng thứ hai làm việc trên message lỗi đã hoàn chỉnh: isRetryableAssistantError() đọc errorMessage, loại các lỗi hết hạn mức hay hết tiền, rồi khớp với các mẫu tạm thời như overloaded hay rate limit; retryAssistantCall() chạy lại cả lượt theo một RetryPolicy giống settings.retry của coding agent (retry.ts).

Huỷ giữa chừng

Gọi controller.abort() làm SDK dừng đọc, vòng đọc ném lỗi, và catch của adapter phát error với reason: "aborted". Message giữ lại mọi khối đã nhận và usage đến lúc đó, có thể cất vào session để hiển thị. Nhưng khi gửi lịch sử cho mô hình ở request sau, transformMessages() bỏ hẳn mọi assistant message có stopReason là error hoặc aborted, vì phát lại một lượt dở (suy nghĩ không có câu trả lời, lời gọi tool bị cắt) có thể khiến API báo lỗi (transform-messages.ts). Ví dụ "Continuing After Abort" trong README đẩy message dở vào context rồi bảo mô hình "Please continue" (packages/ai/README.md:1208–1231); với code v1.4.0, mô hình mới không thấy phần chữ dở đó.

Tràn ngữ cảnh

Khi prompt vượt cửa sổ ngữ cảnh, mỗi hãng báo một kiểu. pi-ai không ném một loại lỗi riêng mà cung cấp một hàm nhận diện, để người gọi (compaction của coding agent, xem chương Compaction) quyết định nén rồi thử lại:

isContextOverflow(): ba cách một request tràn ngữ cảnh
pi/packages/ai/src/utils/overflow.tsdòng 142–176TypeScript
export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean {	// Case 1: Check error message patterns	if (message.stopReason === "error" && message.errorMessage) {		// Skip messages matching known non-overflow patterns (e.g. throttling / rate-limit)		const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!));		if (!isNonOverflow) {			if (OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!))) {				return true;			}			if (message.provider === "cerebras" && CEREBRAS_BODYLESS_OVERFLOW_PATTERN.test(message.errorMessage)) {				return true;			}		}	}	// Case 2: Silent overflow (z.ai style) - successful but usage exceeds context	if (contextWindow && message.stopReason === "stop") {		const inputTokens = message.usage.input + message.usage.cacheRead;		if (inputTokens > contextWindow) {			return true;		}	}	// Case 3: Length-stop overflow (Xiaomi MiMo style) - server truncates oversized input	// to fit the context window, leaving no room for output. Returns stopReason "length"	// with output=0 and input+cacheRead filling the context window.	if (contextWindow && message.stopReason === "length" && message.usage.output === 0) {		const inputTokens = message.usage.input + message.usage.cacheRead;		if (inputTokens >= contextWindow * 0.99) {			return true;		}	}	return false;}

Cách thứ nhất là khớp errorMessage với 26 mẫu regex, mỗi mẫu ghi chú hãng tương ứng, sau khi loại các lỗi giới hạn tốc độ có chữ trùng (Bedrock báo throttling bằng "Too many tokens"). Mọi HTTP 413 đều được coi là tràn (OVERFLOW_PATTERNS). Cách thứ hai bắt "tràn lặng lẽ" của z.ai: request thành công nhưng input + cacheRead lớn hơn cửa sổ. Cách thứ ba bắt Xiaomi MiMo: máy chủ tự cắt input cho vừa cửa sổ, rồi dừng với length và 0 token output. Hai cách sau chỉ chạy khi người gọi truyền contextWindow.

Chuyển giao giữa các provider#

Một session của Pi có thể bắt đầu với Claude, chuyển sang GPT ở giữa, rồi sang Gemini. Transcript chỉ có một, nhưng mỗi nhà cung cấp có ràng buộc riêng: chữ ký suy nghĩ chỉ hợp lệ với mô hình đã tạo ra nó, id lời gọi tool của OpenAI Responses có thể dài hàng trăm ký tự và chứa | trong khi Anthropic chỉ nhận 64 ký tự [a-zA-Z0-9_-], và có API từ chối lời gọi tool không có kết quả. Mọi adapter vì thế gọi transformMessages() trước khi đổi định dạng:

Lượt thứ nhất: so mô hình nguồn với mô hình đích
pi/packages/ai/src/api/transform-messages.tsdòng 121–138, 148–166TypeScript
const assistantMsg = msg as AssistantMessage;const isSameModel =	assistantMsg.provider === model.provider &&	assistantMsg.api === model.api &&	assistantMsg.model === model.id;const transformedContent = assistantMsg.content.flatMap((block) => {	if (block.type === "thinking") {		// Thinking from another model is dropped. Signatures and encrypted reasoning are		// only valid for the model that produced them, and sending readable thinking as		// plain text makes the new model treat it as part of the visible reply.		if (!isSameModel) return [];		// Keep redacted and signed blocks even if the thinking text is empty		// (OpenAI encrypted reasoning); they are needed for replay.		if (block.redacted || block.thinkingSignature) return block;		if (!block.thinking || block.thinking.trim() === "") return [];		return block;	}	if (block.type === "toolCall") {		const toolCall = block as ToolCall;		let normalizedToolCall: ToolCall = toolCall;		if (!isSameModel && toolCall.thoughtSignature) {			normalizedToolCall = { ...toolCall };			delete (normalizedToolCall as { thoughtSignature?: string }).thoughtSignature;		}		if (!isSameModel && normalizeToolCallId) {			const normalizedId = normalizeToolCallId(toolCall.id, model, assistantMsg);			if (normalizedId !== toolCall.id) {				toolCallIdMap.set(toolCall.id, normalizedId);				normalizedToolCall = { ...normalizedToolCall, id: normalizedId };			}		}		return normalizedToolCall;	}

"Cùng mô hình" nghĩa là trùng cả provider, api và id. Với message của mô hình khác: khối suy nghĩ bị bỏ hẳn; khối chữ mất textSignature; lời gọi tool mất thoughtSignature của Google và được đổi id theo quy tắc của adapter đích, với ánh xạ id cũ sang id mới được nhớ lại để sửa toolCallId của kết quả tool phía sau. Trước bản 1.3.0, suy nghĩ của mô hình khác được gửi thành chữ thường; điều đó làm mô hình mới coi suy nghĩ cũ là một phần câu trả lời và bắt đầu viết suy nghĩ của chính nó ra phần hiển thị (packages/ai/CHANGELOG.md:53).

Lượt thứ hai: lấp kết quả tool bị thiếu, bỏ lượt hỏng
pi/packages/ai/src/api/transform-messages.tsdòng 188–207, 216–224TypeScript
const closePendingToolCalls = () => {	if (pendingToolCalls.length > 0) {		for (const tc of pendingToolCalls) {			if (!existingToolResultIds.has(tc.id)) {				result.push({					role: "toolResult",					toolCallId: tc.id,					toolName: tc.name,					content: [{ type: "text", text: "No result provided" }],					isError: true,					timestamp: Date.now(),				} as ToolResultMessage);			}		}		pendingToolCalls = [];		existingToolResultIds = new Set();	}	result.push(...heldSystemMessages);	heldSystemMessages.length = 0;};		// Skip errored/aborted assistant messages entirely.		// These are incomplete turns that shouldn't be replayed:		// - May have partial content (reasoning without message, incomplete tool calls)		// - Replaying them can cause API errors (e.g., OpenAI "reasoning without following item")		// - The model should retry from the last valid state		const assistantMsg = msg as AssistantMessage;		if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {			continue;		}

Lượt thứ hai sửa cấu trúc. Một lời gọi tool không có kết quả (ví dụ tiến trình dừng khi tool đang chạy, nên session có lời gọi mà không có kết quả) nhận một kết quả giả "No result provided" với isError: true, vì hầu hết API từ chối tool_use mồ côi. System message nằm giữa lời gọi tool và kết quả của nó được dời ra sau các kết quả. Assistant message lỗi hoặc bị huỷ bị bỏ, như đã nói ở phần trên.

Đổi từSangĐiều xảy ra với lời gọi tool cũ
OpenAI Responses (call_abc|fc_…, hơn 400 ký tự)AnthropicKý tự lạ thành _, cắt còn 64 ký tự
OpenAI ResponsesChat CompletionscallId_itemId nếu vừa 40 ký tự, không thì tiền tố cộng hash 8 ký tự
Anthropic (toolu_…)OpenAI ResponsesGiữ id sau khi làm sạch; không có item_id để phát lại
Bất kỳBất kỳSuy nghĩ của mô hình cũ bị bỏ; chữ và lời gọi tool được giữ

Catalog mô hình, khoá API và OAuth#

Catalog sinh tự động

Danh sách mô hình có sẵn không được viết tay. scripts/generate-models.ts tải dữ liệu từ models.dev (generate-models.ts), danh sách của OpenRouter và Vercel AI Gateway, áp các điều chỉnh riêng của từng hãng (cờ compat, bảng mức suy nghĩ, bậc giá), rồi ghi một file JSON cho mỗi provider vào src/providers/data/. Thư mục này nằm trong .gitignore (.gitignore:11), nên bản clone ở tag v1.4.0 không có dữ liệu mô hình; nó được sinh lại khi build. Phần được commit là các wrapper ổn định như anthropic.models.ts, dùng chính khoá JSON để suy ra kiểu chính xác của từng id mô hình (anthropic.models.ts), và models.generated.ts gom tất cả lại. Chỉ mô hình gọi được tool mới vào catalog chat (packages/ai/README.md:5).

Có ba cách đọc catalog: models.getModel(provider, id) trên một Models (gồm cả mô hình động), getBuiltinModel() từ providers/all với kiểu literal đầy đủ (packages/ai/src/providers/all.ts:61–69), và getModel() cũ của compat, nay chỉ là tên khác đã deprecated của getBuiltinModel (packages/ai/src/compat.ts:64–71).

Lớp phủ catalog từ pi.dev

Catalog đóng gói theo bản phát hành sẽ cũ đi: một mô hình mới ra hôm nay không có trong bản Pi cài tuần trước. Coding agent bọc mỗi provider có sẵn (trừ radius, và openrouter được cá nhân hoá theo khoá) bằng withRemoteCatalog() (model-runtime.ts). Code này thuộc coding agent, nhưng nó chỉ dùng các hook công khai của pi-ai: Provider.refreshModels(), RefreshModelsContext.publish() và ModelsStore.

withRemoteCatalog(): khôi phục bản đã lưu, hỏi pi.dev tối đa bốn giờ một lần, ETag
pi/packages/coding-agent/src/core/remote-catalog-provider.tsdòng 119–155TypeScript
refreshModels: async (context) => {	const stored = context.stored;	const restored = remoteModels(stored, localGeneratedAt).filter((model) => model.provider === provider.id);	if (		!(await context.publish({			update: () => {				dynamicModels = restored;			},		}))	) {		return;	}	if (!context.allowNetwork || context.signal.aborted) return;	// Skip the request if pi.dev was checked less than REMOTE_CATALOG_REFRESH_INTERVAL_MS ago.	// An entry without lastModified (saved by an older pi, or after a failed first request) is	// always refetched: remoteModels() ignores it, so waiting would leave the overlay empty.	if (		!context.force &&		stored?.checkedAt !== undefined &&		stored.lastModified !== undefined &&		Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS	) {		return;	}	// Only revalidate when a cached body backs the validator, so a 304 can never	// leave the overlay empty.	const validator = stored && stored.models.length > 0 ? stored.etag : undefined;	const response = await requestCatalog(provider.id, catalogBaseUrl, context.signal, validator);	if (context.signal.aborted) return;	const checkedAt = Date.now();	// Unchanged: dynamicModels already holds the stored overlay, so only the	// freshness window moves.	if (response.status === 304 && stored) {		await context.publish({ persist: { ...stored, checkedAt } });		return;	}

Mỗi lần làm mới, bản đã lưu được khôi phục trước tiên. Request tới https://pi.dev/api/models/providers/<id>?types=chat,image,classifier chỉ được gửi nếu lần kiểm tra trước đã quá bốn giờ (packages/coding-agent/src/core/remote-catalog-provider.ts:16), kèm If-None-Match để máy chủ trả 304 khi không có gì mới. Lớp phủ chỉ có hiệu lực khi Last-Modified của catalog từ xa mới hơn thời điểm sinh dữ liệu đóng gói (isNewer), nên một bản Pi mới không bị catalog cũ trên mạng kéo lùi. Mô hình từ xa ghi đè mô hình tĩnh cùng loại và id.

Mô hình tự định nghĩa

Mô hình là dữ liệu, nên thêm một máy chủ cục bộ chỉ cần một object Model cùng createProvider() với một lazy wrapper có sẵn, như ví dụ Ollama ở phần Data model. Danh sách động (llama.cpp, gateway) dùng fetchModels. Coding agent đọc các khai báo này từ models.json; phần đó thuộc chương AgentSession.

Khoá API từ môi trường

Trong runtime mới, mỗi provider tự khai báo cách tìm khoá. Phần lớn dùng envApiKeyAuth():

envApiKeyAuth(): credential đã lưu thắng, rồi biến môi trường đầu tiên có giá trị
pi/packages/ai/src/auth/helpers.tsdòng 9–31TypeScript
export function envApiKeyAuth(name: string, envVars: readonly string[]): ApiKeyAuth {	return {		name,		login: async (interaction) => {			interaction.signal.throwIfAborted();			const key = await interaction.prompt({ type: "secret", message: `Enter ${name}` });			interaction.signal.throwIfAborted();			return { type: "api_key", key };		},		resolve: async ({ ctx, credential, signal }) => {			signal.throwIfAborted();			if (credential?.key) {				return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" };			}			for (const envVar of envVars) {				const value = await ctx.env(envVar);				signal.throwIfAborted();				if (value) return { auth: { apiKey: value }, source: envVar };			}			return undefined;		},	};}

Provider có nguồn xác thực đặc biệt tự viết resolve(). Anthropic là ví dụ: ANTHROPIC_AUTH_TOKEN thành header Authorization: Bearer, rồi tới ANTHROPIC_OAUTH_TOKEN và ANTHROPIC_API_KEY, cuối cùng là bộ biến của workload identity federation, khi đó SDK tự đổi token (anthropic.ts). Bedrock và Vertex đọc credential môi trường của AWS và Google. Bảng tên biến đầy đủ ở README; hàm cũ getEnvApiKey() chỉ còn ở compat (env-api-keys.ts). Biến môi trường được đọc qua AuthContext, và options.env phủ lên trên nó, nên một tiến trình có thể dùng cấu hình khác nhau cho từng request.

Đăng nhập OAuth bằng gói thuê bao

Anthropic (Claude Pro/Max), OpenAI (Sign in with ChatGPT), OpenAI Codex, GitHub Copilot, xAI, Kimi, Meta, OpenRouter và Radius có đăng nhập OAuth. Mỗi cái là một OAuthAuth với ba thao tác: login() tương tác với người dùng qua giao thức trung lập prompt()/notify(), refresh() đổi refresh token lấy token mới, toAuth() suy ra xác thực của request từ credential (packages/ai/src/auth/types.ts:216–245).

OAuthAuth của Anthropic: hai cách đăng nhập, refresh, và toAuth
pi/packages/ai/src/auth/oauth/anthropic.tsdòng 278–307TypeScript
export const anthropicOAuth: OAuthAuth = {	name: "Anthropic (Claude Pro/Max)",	isSubscription: true,	async login(interaction) {		const method = await interaction.prompt({			type: "select",			message: "Select Anthropic login method:",			options: [				{ id: ANTHROPIC_BROWSER_LOGIN_METHOD, label: "Browser login (default)" },				{ id: ANTHROPIC_COPY_CODE_LOGIN_METHOD, label: "Copy code login (headless)" },			],		});		if (method === ANTHROPIC_COPY_CODE_LOGIN_METHOD) {			return loginAnthropicCopyCode(interaction);		}		if (method !== ANTHROPIC_BROWSER_LOGIN_METHOD) {			throw new Error(`Unknown Anthropic login method: ${method}`);		}		return loginAnthropic(interaction);	},	refresh: (credential, signal) => refreshAnthropicToken(credential.refresh, signal),	async toAuth(credential) {		return { apiKey: credential.access };	},};

Đăng nhập trên trình duyệt dùng PKCE và một máy chủ callback cục bộ trên cổng 53692, lùi về một cổng trống bất kỳ, hoặc về việc dán URL chuyển hướng nếu không mở được cổng (loginAnthropic). Cách "copy code" để Anthropic hiển thị mã, hợp với máy không có trình duyệt. Token lưu với thời hạn bị trừ trước năm phút. toAuth() trả access token như một khoá API thường; adapter nhận ra nó vì chuỗi chứa sk-ant-oat và chuyển sang chế độ Claude Code đã tả ở trên.

Ba chi tiết thiết kế làm phần này an toàn khi chạy song song. Các flow OAuth được nạp qua import() với tên module là biến, để bundler cho trình duyệt không kéo code chỉ chạy trên Node vào (load.ts). Làm mới token chạy bên trong CredentialStore.modify(), là đường ghi duy nhất và được tuần tự hoá, với điều kiện hết hạn được kiểm tra lại sau khi đã giữ khoá, nên hai request hay hai tiến trình không bao giờ cùng làm mới một refresh token đã xoay vòng. Và một lần làm mới đã bắt đầu sẽ chạy tới cùng dù người gọi huỷ, vì nhà cung cấp có thể đã vô hiệu refresh token cũ (refreshStoredOAuthCredential).

Danh tính của client

Từ bản 1.4.0, request tự giới thiệu bằng User-Agent: pi/<phiên bản> (<nền tảng>; <runtime>; <kiến trúc>), và có thể kèm header ghi nhận ứng dụng (attribution) cho các gateway có bảng xếp hạng ứng dụng:

Header attribution theo từng gateway, và header mặc định của identity
pi/packages/ai/src/utils/identity.tsdòng 106–124, 137–143TypeScript
/** Provider attribution headers of an identity for a request target. Empty when attribution is off. */export function getAttributionHeaders(target: ClientIdentityTarget, identity?: ClientIdentity): Record<string, string> {	const attribution = identity?.attribution;	if (!attribution) return {};	const { name, url, categories } = attribution;	if (isOpenRouter(target)) {		return {			"HTTP-Referer": url,			"X-OpenRouter-Title": name,			...(categories === undefined || categories.length === 0				? {}				: { "X-OpenRouter-Categories": categories.join(",") }),		};	}	// NVIDIA expects a capitalized origin, such as `Pi`.	if (isNvidiaNim(target)) return { "X-BILLING-INVOKE-ORIGIN": name.charAt(0).toUpperCase() + name.slice(1) };	if (isOpenCode(target)) return { "x-opencode-client": name };	return {};}/** * Default headers an identity adds to a request: `User-Agent` and provider attribution. APIs apply them below model, * provider, and request headers, so configured headers override them and `null` request headers remove them. */export function getIdentityHeaders(target: ClientIdentityTarget, identity?: ClientIdentity): Record<string, string> {	return { "User-Agent": formatUserAgent(identity), ...getAttributionHeaders(target, identity) };}

Mặc định không có attribution. pi-agent-core, Pi Durable và coding agent đặt PI_ATTRIBUTION, tức { name: "pi", url: "https://pi.dev", categories: ["cli-agent"] } (packages/ai/src/utils/identity.ts:23–24). Header của identity nằm dưới header của provider, mô hình và request, nên header cấu hình luôn thắng. Một số provider giữ danh tính cố định của riêng mình, ví dụ GitHub Copilot và Anthropic OAuth (packages/ai/README.md:454).

Prompt caching#

Prompt cache của nhà cung cấp là cache của một tiền tố token chính xác: request sau chỉ đọc được cache nếu phần đầu của nó trùng từng token với request trước (Prompt Caching In Agents). Với một agent gửi lại cả lịch sử mỗi lượt, trúng cache nghĩa là chỉ trả tiền đầy đủ cho phần đuôi mới. pi-ai phơi ra hai nút chung, sessionId và cacheRetention, và mỗi adapter dịch chúng theo cơ chế của hãng.

Anthropic: từ cacheRetention sang cache_control
pi/packages/ai/src/api/anthropic-messages.tsdòng 65–93TypeScript
/** * Resolve cache retention preference. * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility. */function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention {	if (cacheRetention) {		return cacheRetention;	}	if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") {		return "long";	}	return "short";}function getCacheControl(	model: Model<"anthropic-messages">,	cacheRetention?: CacheRetention,	env?: ProviderEnv,): { retention: CacheRetention; cacheControl?: CacheControlEphemeral } {	const retention = resolveCacheRetention(cacheRetention, env);	if (retention === "none") {		return { retention };	}	const ttl = retention === "long" && getAnthropicCompat(model).supportsLongCacheRetention ? "1h" : undefined;	return {		retention,		cacheControl: { type: "ephemeral", ...(ttl && { ttl }) },	};}
APIcacheRetentionsessionId
Anthropic Messagesnone: không có cache_control. short: { type: "ephemeral" } (mặc định của Anthropic là 5 phút, theo bài Prompt Caching In Agents). long: thêm ttl: "1h" nếu provider hỗ trợ.Header x-session-affinity, hoặc x-session-id với OpenRouter, khi bật sendSessionAffinityHeaders.
OpenAI Responseslong: prompt_cache_retention: "24h", hoặc prompt_cache_options.ttl: "30m" trên mô hình hỗ trợ chế độ tường minh (packages/ai/src/api/openai-responses.ts:124–141).prompt_cache_key (cắt còn 64 ký tự) cùng header session_id và x-client-request-id (packages/ai/src/api/openai-responses.ts:320–329).
OpenAI Completionslong: prompt_cache_retention: "24h"; với cacheControlFormat: "anthropic" thì gắn cache_control như Anthropic.prompt_cache_key chỉ gửi cho api.openai.com hoặc khi long được hỗ trợ (packages/ai/src/api/openai-completions.ts:832–837); header affinity theo sessionAffinityFormat.

Ở Anthropic, cache được điều khiển bằng điểm ngắt: một khối mang cache_control nghĩa là "cache mọi thứ tới đây". pi-ai đặt ba điểm, đều đã thấy ở trên: khối system prompt, tool cuối cùng, khối cuối của message cuối. Điểm thứ ba dịch dần về cuối sau mỗi lượt, nên lượt sau đọc được prefix mà lượt trước đã ghi. Mọi thứ khiến prefix thay đổi đều làm trượt cache: đổi system prompt, thêm bớt tool ở danh sách đầu. Đó là lý do có system message giữa hội thoại và khối tool_addition: chúng cho phép đổi prompt và bộ tool bằng cách nối thêm chứ không sửa phần đầu.

Kết quả đọc được trong usage: cacheRead là token trúng cache, cacheWrite là token được ghi vào cache, cacheWrite1h là phần ghi với thời hạn một giờ mà chỉ Anthropic báo riêng. Ghi một giờ được tính giá gấp đôi input trong calculateCost(). Biến môi trường PI_CACHE_RETENTION=long đổi mặc định cho mọi request không đặt cacheRetention. Thông tin tuổi thọ cache theo mô hình (Model.promptCache) được dùng cho việc làm ấm cache, giải thích ở chương Pi 1.0.

Quyết định thiết kế#

Adapter viết tay cho từng giao thức, không dùng một thư viện trừu tượng chung. Bài viết kể Mario không dùng Vercel AI SDK vì nó không chạy tốt với mô hình tự host, nhất là khi gọi tool. Ở v1.4.0, lý do mạnh hơn nằm ngay trong code: những gì làm agent chạy rẻ và đúng đều là chi tiết riêng của từng hãng. Vị trí điểm ngắt cache của Anthropic, reasoning item mã hoá của OpenAI, mười một cách gửi mức suy nghĩ của các máy chủ tương thích OpenAI, id lời gọi tool 40 hay 64 ký tự. Một lớp trừu tượng chung phải che đi đúng những thứ đó. Cái giá là mỗi adapter dài khoảng một đến hai nghìn dòng và phải được kiểm thử với từng hãng (README liệt kê bộ test bắt buộc cho provider mới, packages/ai/README.md:1941–1958).

Mọi thứ là dữ liệu JSON. Context, message, mô hình, tool, credential đều serialize được. Vì vậy entry message trong session file của coding agent lưu thẳng một AgentMessage, tức Message của pi-ai cộng các vai riêng (session-manager.ts), entry của Pi Durable mang phần cho mô hình dưới dạng một mảng Message (durable/src/types.ts), và đổi mô hình giữa chừng chỉ là gửi cùng một mảng message cho adapter khác. Kiểu IsJsonCompatible trên ToolResultMessage và JsonObject trên ToolCall.arguments biến tính chất này thành lỗi biên dịch thay vì quy ước.

Lỗi là dữ liệu, không phải ngoại lệ. Sau khi luồng đã trả về, không gì được ném ra: lỗi mạng, lỗi máy chủ, huỷ, tràn ngữ cảnh đều thành một AssistantMessage có stopReason và errorMessage. Người gọi có một đường xử lý duy nhất, message lỗi vẫn có usage để tính tiền, và có thể được lưu, hiển thị, phân loại để thử lại. Bài viết của Mario nhấn mạnh việc huỷ: nhiều API hợp nhất bỏ qua nó, còn pi-ai hỗ trợ huỷ từ đầu tới cuối.

Transcript cục bộ là bản chính. Không có trạng thái hội thoại phía máy chủ (store: false), dữ liệu niêm phong chỉ đi kèm khối như một chữ ký mờ, và chỉ được phát lại cho đúng mô hình đã sinh ra nó. Phương án bị loại là để nhà cung cấp giữ hội thoại: request nhỏ hơn, nhưng session không còn mang sang nhà cung cấp khác hay dựng lại từ log được.

Tập provider thay cho registry toàn cục. API toàn cục cũ (registry theo model.api, khoá lấy từ biến môi trường của tiến trình) không cho hai cấu hình cùng sống trong một tiến trình. createModels() cho mỗi host một tập provider, một kho credential và một identity riêng; Pi Durable còn nhận một hàm trả về Models để host thay cấu hình mà không phải mở lại harness. API cũ được giữ ở /compat làm đường chuyển tiếp thay vì bị xoá ngay (packages/ai/CHANGELOG.md:817).

Chỉ nạp thứ được dùng. Lõi không có side effect; mỗi provider một entry point; SDK nằm sau import() động; flow OAuth chỉ chạy trên Node nằm sau một tên module là biến. Một ứng dụng trình duyệt chỉ dùng Anthropic không phải tải SDK của AWS hay Google.

Thay đổi prompt bằng cách nối thêm. Từ 0.86, system prompt và tool sống trong transcript, nên đổi chúng giữa hội thoại là thêm một system message chứ không viết lại phần đầu, và prefix đã cache được giữ nguyên. Đây là nền cho cách Pi 1.0 nạp tool hoãn lại và giữ cache, mô tả ở chương Pi 1.0 và chương Codemode.

Usage là ước lượng tốt nhất. Bài viết của Mario ghi rằng các hãng báo token ở những thời điểm khác nhau, có hãng ngay đầu luồng, có hãng chỉ ở cuối, nên pi-ai theo dõi token và cache theo kiểu best-effort. Adapter Anthropic ghi usage ngay ở message_start chính vì lý do này: request bị huỷ sớm vẫn có số input.

Cạm bẫy và trường hợp biên#

  • Không có thử lại mặc định ở tầng HTTP. Chú thích của maxRetries nói SDK của OpenAI và Anthropic mặc định thử lại 2 lần (packages/ai/src/types.ts:205–209), nhưng adapter luôn gọi SDK với maxRetries: 0 và retryProviderRequest() mặc định 0 lần. Muốn thử lại thì truyền maxRetries, hoặc thử lại cả lượt bằng retryAssistantCall().
  • Message bị huỷ không được phát lại. Có thể cất nó vào context, nhưng transformMessages() bỏ nó khi gửi đi, nên "Please continue" không có gì để tiếp tục.
  • Tham số tool chưa được kiểm tra. toolcall_end mang tham số đã phân tích nhưng chưa đối chiếu lược đồ, và có thể là {} kèm argumentsError. Luôn gọi validateToolCall() trước khi chạy.
  • Gọi thẳng adapter là bỏ qua hai bước. Import @earendil-works/pi-ai/api/<id> thì phải tự truyền apiKey và tự bọc context bằng normalizeContext(); streamSimple() của adapter còn ném lỗi đồng bộ khi thiếu khoá (packages/ai/src/api/anthropic-messages.ts:966–968). Hợp đồng StreamFunction cho phép điều này, nhưng nó khác với Models, nơi lỗi thiếu khoá cũng thành một sự kiện error.
  • input không phải kích thước ngữ cảnh. Nó không gồm token cache. Muốn biết context đã dùng bao nhiêu thì cộng cả bốn loại, như calculateContextTokens() làm (packages/ai/src/utils/estimate.ts:18–20).
  • Đổi mô hình là mất suy nghĩ. Mô hình mới thấy chữ và lời gọi tool của mô hình cũ nhưng không thấy suy nghĩ của nó, kể cả khi hai mô hình cùng hãng mà khác id.
  • OAuth của Anthropic đổi tên tool trên dây. Tool tên read được gửi thành Read; tên trả về được ánh xạ ngược không phân biệt hoa thường theo danh sách tool hiện tại. Hai tool chỉ khác nhau ở chữ hoa, chữ thường sẽ bị nhầm.
  • Lý do dừng lạ làm request thất bại. mapStopReason() của Anthropic ném lỗi với lý do chưa biết thay vì đoán (packages/ai/src/api/anthropic-messages.ts:1686–1688): an toàn hơn, nhưng một giá trị mới phía máy chủ có thể làm hỏng mọi request cho tới khi cập nhật.
  • getModels() chỉ trả mô hình chat. Mô hình ảnh và classifier phải đọc bằng getModelsOfType() hoặc getAllModels().
  • Clone không có dữ liệu mô hình. src/providers/data/ bị gitignore; đọc code trong clone sẽ không thấy giá, cửa sổ ngữ cảnh hay cờ compat của từng mô hình cụ thể.
  • README lệch code ở vài chỗ: API của xAI và Kimi For Coding, deferred thiếu trong danh sách lý do dừng, ảnh gửi cho mô hình không nhận ảnh bị thay bằng chữ chứ không "silently ignored", và mô tả của requiresThinkingAsText.

Đọc code ở đâu#

Thứ tự đề nghị: kiểu dữ liệu, rồi luồng, rồi runtime, rồi một adapter, rồi các phần cắt ngang. Đường dẫn tính từ gốc repo.

#FileĐọc để hiểu
1packages/ai/README.mdQuick Start, Providers and Models, Complete Event Reference, Cross-Provider Handoffs, System Messages. Đối chiếu với code vì có vài chỗ đã cũ.
2src/types.tsToàn bộ data model: Model, Message, khối nội dung, Usage, AssistantMessageEvent, option.
3providers/model-schema.ts, providers/compat-schema.tsMức suy nghĩ, giá, và mọi cờ compat kèm mô tả.
4utils/event-stream.ts, api/lazy.tsHàng đợi sự kiện, result(), cách lỗi chuẩn bị thành sự kiện.
5utils/transcript.tsnormalizeContext(), phát lại system message, tách tool cho từng kiểu transport.
6src/models.tsModels, applyAuth(), refresh(), createProvider(), calculateCost(), kẹp mức suy nghĩ.
7api/anthropic-messages.tsMột adapter đầy đủ: client, buildParams(), convertMessages(), vòng đọc SSE, streamSimple().
8api/transform-messages.tsChuyển giao giữa mô hình, lấp kết quả tool thiếu.
9utils/json-parse.tsPartial JSON và sửa JSON.
10api/openai-responses.ts, api/openai-responses-shared.tsOutput item, reasoning mã hoá, store: false.
11api/openai-completions.tsĐọc detectCompat() và getCompat() ở cuối file trước, rồi buildParams() và vòng đọc chunk.
12utils/overflow.ts, utils/provider-retry.ts, utils/retry.ts, utils/error-body.tsTràn ngữ cảnh, hai tầng thử lại, thông báo lỗi.
13auth/resolve.ts, auth/types.ts, auth/helpers.ts, auth/oauth/anthropic.tsPhân giải xác thực, khoá làm mới OAuth, một flow OAuth hoàn chỉnh.
14providers/all.ts, providers/anthropic.ts, providers/github-copilot.tsFactory provider, catalog tĩnh, provider nhiều API.
15utils/identity.ts, src/telemetry.tsHeader danh tính và attribution, schema telemetry.
16src/compat.ts, package.jsonAPI toàn cục cũ, entry point và sideEffects.
17coding-agent/src/core/remote-catalog-provider.tsLớp phủ catalog từ pi.dev dựng trên refreshModels().
18packages/ai/CHANGELOG.mdCác mốc kiến trúc: 0.80.0 (runtime Models), 0.86.0 (transcript và system message), 0.99.0 (mô hình ảnh và classifier), 1.3.0 (bỏ suy nghĩ khi đổi mô hình), 1.4.0 (identity, telemetry).

Tóm tắt#

  • pi-ai biến "một mô hình cộng một hội thoại" thành một luồng sự kiện chuẩn hoá kết thúc bằng AssistantMessage. Nó không chạy tool, không lưu session.
  • API là giao thức, provider là nhà cung cấp, mô hình là dữ liệu. Models định tuyến theo model.provider, Provider định tuyến theo model.api, adapter nói đúng giao thức của hãng qua SDK chính thức.
  • Mọi thứ là JSON, và lỗi cũng là dữ liệu: sau khi luồng đã trả về, mọi thất bại thành message có stopReason error hoặc aborted, giữ phần nội dung đã nhận.
  • Luồng bắt đầu bằng start, mỗi khối có *_start/*_delta/*_end ghép theo contentIndex, kết thúc bằng đúng một done hoặc error; partial là object sống, không phải ảnh chụp.
  • Tham số tool được phân tích dần bằng partial JSON lúc đang truyền và phân tích nghiêm ngặt lúc kết thúc; kiểm tra lược đồ là việc của người gọi qua validateToolCall().
  • transformMessages() làm cho một transcript dùng được với mọi mô hình: bỏ suy nghĩ của mô hình khác, đổi id lời gọi tool, lấp kết quả thiếu, bỏ lượt hỏng. Transcript cục bộ luôn là bản chính.
  • Prompt caching đi qua sessionId và cacheRetention; Anthropic dùng ba điểm ngắt cache_control, OpenAI dùng prompt_cache_key; kết quả hiện trong cacheRead, cacheWrite, cacheWrite1h.
  • Runtime createModels() cùng các entry point ./models, ./providers/*, ./api/* thay API toàn cục cũ, giờ ở ./compat; Pi Durable và coding agent đều dựng trên nó.

5pi-ai: provider và mọi cách kết nối#

Vai trò#

Chương pi-ai giải thích lõi: mô hình dữ liệu, giao thức luồng sự kiện, runtime Models và Provider, hai adapter Anthropic và OpenAI Responses. Chương này đi theo chiều rộng. Câu hỏi của nó là: ở v1.4.0, Pi chạm được tới một mô hình bằng những con đường nào, và mỗi con đường được hiện thực ở đâu trong code.

Mỗi request tới một mô hình phải trả lời ba câu hỏi. Ai phục vụ nó: provider (nhà cung cấp), tức một họ endpoint cùng danh mục mô hình. Nói thế nào: API, tức giao thức trên dây mà một module adapter trong src/api/ hiện thực. Bạn là ai: credential, có thể là một khoá API, một token OAuth của gói thuê bao, credential của nền tảng đám mây, hoặc không gì cả với một máy chủ cục bộ. Ba trục này độc lập với nhau, và phần lớn sự đa dạng của Pi đến từ việc ghép chúng theo nhiều cách.

Ở v1.4.0, builtinProviders() dựng 42 provider có sẵn (providers/all.ts), cộng thêm faux cho test. Coding agent đăng ký thêm llama.cpp qua một extension dựng sẵn, và người dùng thêm bao nhiêu provider tuỳ ý qua models.json hoặc extension. Về phía API có mười API chat, một API sinh ảnh và bốn API classifier (types.ts:64).

Trách nhiệm chia theo gói. pi-ai sở hữu provider, adapter của API và việc phân giải xác thực lúc gửi request. Coding agent thêm các nguồn cấu hình của người dùng (auth.json, models.json, cờ --api-key), lớp phủ catalog từ pi.dev và provider do extension đăng ký, tất cả gói trong ModelRuntime (xem thêm mục dịch vụ của chương AgentSession). Pi Durable không tự tạo provider nào: host đưa cho nó một Models đã cấu hình sẵn.

Mô hình tư duy: ba trục provider, API, credential#

Một mô hình trong catalog là dữ liệu thuần. Ba trường của nó nối ba trục lại với nhau: provider chọn provider, api chọn adapter, baseUrl là nơi adapter gửi tới. Credential không nằm trong mô hình; nó được provider phân giải ở từng request rồi đổ vào option.

Ví dụ minh hoạ: một mô hình và ba trục
  model = { id: "moonshotai/kimi-k2.6", provider: "openrouter",
            api: "openai-completions", baseUrl: "https://openrouter.ai/api/v1", ... }

  Models.streamSimple(model, ...)
    │ 1. tìm Provider theo model.provider ───────────► openrouterProvider()
    │ 2. Provider.auth phân giải credential ─────────► { apiKey } từ OPENROUTER_API_KEY
    │ 3. Provider chọn adapter theo model.api ───────► api/openai-completions.ts (nạp lười)
    ▼
  adapter: SDK openai, baseURL = model.baseUrl ──────► POST /chat/completions
Provider lo "ai" và "bạn là ai", API lo "nói thế nào". Cùng một API phục vụ hàng chục provider; cùng một provider có thể dùng nhiều API.

Credential đến từ nhiều nguồn, và mỗi nguồn ứng với một cơ chế trong code. Chương này đi qua từng cơ chế theo thứ tự trong bảng dưới.

Cách kết nốiVí dụCơ chế chính
Khoá APIOPENROUTER_API_KEY, auth.json, --api-keyenvApiKeyAuth(), CredentialStore, AuthStorage
OAuth của gói thuê bao hay tài khoảnClaude Pro/Max, Sign in with ChatGPT, CopilotOAuthAuth: trình duyệt cộng callback cục bộ, device code, dán mã
Credential của nền tảng đám mâyAWS profile, gcloud ADC, workload identity của AnthropicApiKeyAuth.resolve() chỉ báo "đã cấu hình", SDK tự lấy credential; Azure dùng khoá cộng endpoint theo resource
Gateway, aggregatorOpenRouter, Vercel, Cloudflare, OpenCode, RadiusProvider có catalog của nhiều hãng, header riêng, đôi khi giao thức riêng
Binding trong Workerenv.AI của CloudflarecreateAiBindingFetch() thay fetch
Máy chủ cục bộllama.cpp router, Ollama, LM Studio, vLLMExtension llama.cpp, hoặc models.json cộng cờ compat
ExtensionGitLab Duo, giao thức tự viếtpi.registerProvider()
Host cung cấpPi DurablecreateModels() cộng setProvider() do host gọi

Trong coding agent, một provider mà người dùng thấy là kết quả của nhiều lớp chồng lên nhau. Lớp nào vắng thì lớp dưới được dùng nguyên vẹn:

Factory của pi-ai
42 provider dựng sẵn: catalog sinh tự động, xác thực, adapter nạp lười.
Lớp phủ catalog
withRemoteCatalog() lấy catalog mới hơn từ pi.dev; riêng OpenRouter dùng withPersonalizedCatalog() để lọc theo khoá.
Native provider của extension
Một Provider hoàn chỉnh do pi.registerProvider(provider) đăng ký, thay cho provider dựng sẵn cùng id. llama.cpp đến từ đây.
models.json
Thêm hoặc thay mô hình, đổi baseUrl, header, compat, khoá; tạo provider hoàn toàn mới.
ProviderConfig của extension
Dạng đăng ký cũ theo tên: mô hình, OAuth, streamSimple tự viết.
Virtual model
Mục chọn được, định tuyến từng request tới mô hình thật (chương Pi 1.0).

Từ vựng dùng trong phần còn lại:

Credential
Bản ghi lưu cho một provider: { type: "api_key", key?, env? } hoặc { type: "oauth", access, refresh, expires, ... }. Mỗi id provider có tối đa một credential.
ModelAuth
Thứ thật sự đi vào một request: apiKey, headers, baseUrl. Chú thích của kiểu này đặt ra một ranh giới rõ: cái gì không biểu diễn được bằng ba trường đó thì là cấu hình của provider, không phải xác thực.
Credential môi trường (ambient)
Credential đọc từ biến môi trường hoặc file của máy (AWS profile, gcloud ADC), không bao giờ được chép vào kho credential của Pi.
Provider env
Một Record<string, string> cấu hình theo từng provider, ví dụ CLOUDFLARE_ACCOUNT_ID hay AWS_PROFILE. Nó đi kèm credential, và được ưu tiên hơn process.env.
ModelAuth và ApiKeyCredential: ranh giới giữa xác thực và cấu hình
pi/packages/ai/src/auth/types.tsdòng 3–21TypeScript
/** * Request auth for a single model request. If a value cannot be expressed as * `apiKey`, `headers`, or `baseUrl`, it is provider config, not auth. */export interface ModelAuth {	apiKey?: string;	headers?: ProviderHeaders;	baseUrl?: string;}/** * Stored api-key credential. `env` holds provider-scoped environment/config * values such as Cloudflare account/gateway ids. */export interface ApiKeyCredential {	type: "api_key";	key?: string;	env?: ProviderEnv;}

Một provider được định nghĩa thế nào#

Phần lớn provider là một file mười lăm dòng. Groq là ví dụ điển hình: một id, một tên hiển thị, một baseUrl, một cách xác thực chuẩn, một catalog và một lazy wrapper của API.

groq.ts: provider tối giản
pi/packages/ai/src/providers/groq.tsdòng 1–15TypeScript
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";import { envApiKeyAuth } from "../auth/helpers.ts";import { createProvider, type Provider } from "../models.ts";import { GROQ_MODELS } from "./groq.models.ts";export function groqProvider(): Provider<"openai-completions"> {	return createProvider({		id: "groq",		name: "Groq",		baseUrl: "https://api.groq.com/openai/v1",		auth: { apiKey: envApiKeyAuth("Groq API key", ["GROQ_API_KEY"]) },		models: Object.values(GROQ_MODELS),		api: openAICompletionsApi(),	});}

Bốn thứ trong factory có bốn nguồn khác nhau:

  • models đến từ groq.models.ts, một wrapper sinh tự động import ./data/groq.json rồi làm phẳng theo loại mô hình. File JSON gom mô hình theo API, khoá dạng chat:<id>, để TypeScript suy được API của từng id; flattenModelCatalog() bỏ lớp nhóm và lọc theo type (model-catalog.ts:53). Thư mục data/ bị gitignore và được sinh lại khi build (chương pi-ai, catalog).
  • auth là một ProviderAuth: apiKey, oauth, hoặc cả hai. envApiKeyAuth() là cách chuẩn; provider có nguồn credential đặc biệt tự viết resolve().
  • api là một ProviderStreams, ở đây là lazy wrapper openAICompletionsApi(), nên SDK openai chỉ được nạp ở request đầu tiên.
  • baseUrl cấp provider gần như chỉ là siêu dữ liệu. Request đi tới model.baseUrl của từng mô hình; Provider.baseUrl chỉ được chuyển vào RefreshModelsContext.baseUrl khi làm mới catalog (models.ts:555), nhờ đó một baseUrl ghi đè trong models.json cũng áp cho request lấy danh sách mô hình của OpenRouter.

Provider phức tạp hơn vẫn chỉ ghép thêm các mảnh. OpenCode Zen nói bốn API, mỗi API được bọc để chèn header định tuyến theo phiên, và có thêm một classifier:

opencode.ts: bảng API theo model.api, wrapper header, classifier
pi/packages/ai/src/providers/opencode.tsdòng 13–28TypeScript
export function opencodeProvider(): Provider<OpenCodeApi> {	return createProvider<OpenCodeApi>({		id: "opencode",		name: "OpenCode Zen",		auth: { apiKey: envApiKeyAuth("OpenCode API key", ["OPENCODE_API_KEY"]) },		models: [...Object.values(OPENCODE_MODELS), ...Object.values(OPENCODE_CLASSIFIER_MODELS)],		api: {			"anthropic-messages": withOpenCodeSessionHeader(anthropicMessagesApi()),			"google-generative-ai": withOpenCodeSessionHeader(googleGenerativeAIApi()),			"openai-completions": withOpenCodeSessionHeader(openAICompletionsApi()),			"openai-responses": withOpenCodeSessionHeader(openAIResponsesApi()),		},		// OpenCode Zen serves TypeSafe's System One protocol at /zen/v1/systemone.		classifiers: { "typesafe-system-one": typesafeSystemOneApi() },	});}
withOpenCodeSessionHeader() chép sessionId vào header x-opencode-session nếu người gọi chưa đặt (opencode-headers.ts).

Mọi factory dựng sẵn và mọi provider tự định nghĩa trong models.json đều đi qua cùng một hàm, createProvider(). Đây là toàn bộ bề mặt mà một provider có thể khai báo:

CreateProviderOptions: mọi thứ một provider có thể khai báo
pi/packages/ai/src/models.tsdòng 1032–1066TypeScript
export interface CreateProviderOptions<TApi extends Api = Api> {	id: string;	/** Display name. Default: `id`. */	name?: string;	baseUrl?: string;	headers?: ProviderHeaders;	/** Required — every provider has auth semantics, even ambient/keyless ones. */	auth: ProviderAuth;	/**	 * Static baseline models of every type (empty for purely dynamic providers).	 * Models without `type` are chat models.	 */	models: readonly ProviderModel<TApi>[];	/**	 * Fetch a dynamic model overlay of every type. createProvider restores and	 * publishes it transactionally and drops models of unknown types.	 */	fetchModels?: (context: RefreshModelsContext) => Promise<readonly ProviderModel<TApi>[]>;	/** Credential-specific chat model availability. See `Provider.filterModels`. */	filterModels?: (models: readonly Model<TApi>[], credential: Credential | undefined) => readonly Model<TApi>[];	/** Credential-specific availability across every model type. See `Provider.filterAllModels`. */	filterAllModels?: (		models: readonly ProviderModel<TApi>[],		credential: Credential | undefined,	) => readonly ProviderModel<TApi>[];	/**	 * Chat implementation: a single one for all chat models, or a map keyed by	 * `model.api` for mixed-API providers. Optional when `images` or	 * `classifiers` is given.	 */	api?: ProviderStreams | Partial<Record<TApi, ProviderStreams>>;	/** Image-generation implementations keyed by `model.api`. */	images?: Partial<Record<ImageApi, ProviderImages>>;	/** Classifier implementations keyed by `model.api`. */	classifiers?: Partial<Record<ClassifierApi, ProviderClassifier>>;

Đối chiếu với 42 factory ở v1.4.0, có sáu khuôn mẫu:

Khuôn mẫuProvider
Một API, envApiKeyAuth()20 provider tương thích OpenAI Completions (groq, deepseek, together, ...), cùng google, mistral, minimax, minimax-cn, vercel-ai-gateway
Một API, ApiKeyAuth tự viếtanthropic (Bearer, federation), amazon-bedrock, google-vertex, cloudflare-workers-ai; cloudflare-ai-gateway cũng tự viết nhưng có bảng API
Thêm OAuth qua lazyOAuth()anthropic, openai, openai-codex (chỉ OAuth), github-copilot, kimi-coding, meta, xai, openrouter, radius
Bảng API theo model.apifireworks, github-copilot, openrouter, opencode, opencode-go, azure, cloudflare-ai-gateway
Bọc ProviderStreams để biến đổi request trước khi gửiazureStreams(), cloudflareStreams(), withOpenCodeSessionHeader()
Viết tay interface Providerradius (catalog động thay hẳn bản dựng sẵn); llama.cpp ở coding agent

typesafe là trường hợp riêng: không có api chat, chỉ có classifiers, điều createProvider() cho phép vì nó chỉ đòi ít nhất một trong ba bảng api, images, classifiers (models.ts:1087).

Lược đồ của mô hình và cờ compat

Các trường của mô hình (mức suy nghĩ, giá, giới hạn input) được khai báo bằng TypeBox trong model-schema.ts, đã đi qua ở phần Data model. compat-schema.ts khai báo cờ compat cho từng API. Mỗi API có lược đồ riêng với mặc định và mô tả riêng, nhưng models.json cho phép đặt compat ở cấp provider, nơi chưa biết mô hình nói API nào. Lời giải là một lược đồ hợp (superset):

Lược đồ compat theo API và lược đồ hợp cho models.json
pi/packages/ai/src/providers/compat-schema.tsdòng 619–640TypeScript
/** Compatibility schema for each API whose models accept compat. Mirrors the compat type of Model. */export const CompatSchemasByApi = {	"openai-completions": OpenAICompletionsCompatSchema,	"openai-responses": OpenAIResponsesCompatSchema,	"azure-openai-responses": OpenAIResponsesCompatSchema,	"openai-codex-responses": OpenAIResponsesCompatSchema,	"anthropic-messages": AnthropicMessagesCompatSchema,	"bedrock-converse-stream": BedrockCompatSchema,	"mistral-conversations": MistralConversationsCompatSchema,	"typesafe-system-one": SystemOneCompatSchema,	"cloudflare-workers-ai-system-one": SystemOneCompatSchema,} as const;const ProviderCompatProperties = mergeCompatProperties(	[...new Set(Object.values(CompatSchemasByApi))].map((schema) => schema.properties),	ProviderCompatPropertyOverrides,);export const ProviderCompatSchema = Type.Object(ProviderCompatProperties, {	description: "Provider and model compatibility overrides.",	additionalProperties: true,});

Thuộc tính trùng tên giữa các API (ví dụ supportsDeveloperRole có ở cả Completions lẫn Responses với mặc định khác nhau) phải có một bản ghi đè chung trong ProviderCompatPropertyOverrides; thiếu thì mergeCompatProperties() ném lỗi ngay lúc nạp module (compat-schema.ts:600). Nhờ vậy lược đồ hợp không thể lặng lẽ lấy mặc định của một API này áp cho API khác. additionalProperties: true giữ cho models.json cũ không bị từ chối khi một cờ bị gỡ.

Bản đồ provider ở v1.4.0#

Các bảng dưới đây dựng từ code ở tag v1.4.0, không từ trí nhớ hay từ README: cột API lấy từ lệnh import lazy wrapper trong từng factory, và với provider nhiều API thì từ luật chọn API trong scripts/generate-models.ts; cột xác thực lấy từ auth của factory. Tên id là liên kết tới file factory. Danh sách mô hình cụ thể của từng provider không có trong bản clone, vì src/providers/data/ bị gitignore; tra cứu chúng ở pi.dev/models. Cách chia nhóm là của tài liệu này, code không có khái niệm nhóm.

Hãng làm mô hình, gọi API trực tiếp (11)

ProviderAPIXác thựcĐáng chú ý
anthropicanthropic-messagesANTHROPIC_AUTH_TOKEN (gửi dạng Bearer), ANTHROPIC_OAUTH_TOKEN, ANTHROPIC_API_KEY, rồi workload identity federation; OAuth Claude Pro/MaxToken OAuth chuyển adapter sang danh tính Claude Code (chương pi-ai)
openaiopenai-responses; classifier openai-decisionsOPENAI_API_KEY; OAuth Sign in with ChatGPTKhi đăng nhập OAuth, classifier bị ẩn vì Decisions API từ chối token ChatGPT (openai.ts:27)
googlegoogle-generative-aiGEMINI_API_KEYAdapter từ chối options.fetch tuỳ biến (google-generative-ai.ts:87)
mistralmistral-conversationsMISTRAL_API_KEYKhông dùng SDK: adapter tự POST tới v1/chat/completions bằng fetch (mistral-conversations.ts:298); id lời gọi tool 9 ký tự
xaiopenai-responsesXAI_API_KEY; OAuth device code (SuperGrok, X Premium)README ghi Completions; code dùng Responses
metaopenai-responsesMETA_API_KEY; OAuth device code (gói Muse)Identity token của OAuth không dùng để suy luận được; Pi đổi nó lấy một khoá API sống khoảng một ngày
deepseekopenai-completionsDEEPSEEK_API_KEYthinkingFormat: "deepseek", reasoning_content trên mọi assistant message, max_tokens (detectCompat)
moonshotaiopenai-completionsMOONSHOT_API_KEYapi.moonshot.ai; không gửi reasoning_effort, dùng max_tokens
minimaxanthropic-messagesMINIMAX_API_KEYEndpoint tương thích Anthropic api.minimax.io/anthropic
xiaomiopenai-completionsXIAOMI_API_KEYEndpoint tính tiền theo API; MiMo tự cắt input khi tràn (chương pi-ai)
ant-lingopenai-completionsANT_LING_API_KEYthinkingFormat: "ant-ling"

Nền tảng suy luận cho mô hình mở (7)

ProviderAPIXác thựcĐáng chú ý
groqopenai-completionsGROQ_API_KEYapi.groq.com/openai/v1
cerebrasopenai-completionsCEREBRAS_API_KEYKhông gửi store, không dùng vai developer
togetheropenai-completionsTOGETHER_API_KEYthinkingFormat: "together", max_tokens, không có lưu cache dài
fireworksanthropic-messages cho đa số; openai-completions cho GLM và Kimi K3FIREWORKS_API_KEYCache theo session affinity; không nhận cache_control trên tool (generate-models.ts:1721)
basetenopenai-completionsBASETEN_API_KEYMức suy nghĩ qua chat_template_args; header session affinity (CHANGELOG 0.86.0)
huggingfaceopenai-completionsHF_TOKENRouter router.huggingface.co/v1
nvidiaopenai-completionsNVIDIA_API_KEYNIM công khai; header ghi nhận X-BILLING-INVOKE-ORIGIN (identity.ts:120)

Nền tảng đám mây (4)

ProviderAPIXác thựcĐáng chú ý
amazon-bedrockbedrock-converse-streamBearer token, AWS profile, khoá IAM, ECS task role, web identity; không có OAuthSDK AWS tự chạy chuỗi credential; vùng lấy từ ARN của mô hình (mục đám mây)
google-vertexgoogle-vertexGOOGLE_CLOUD_API_KEY, hoặc ADC cộng project và locationGemini qua Vertex AI
azureazure-openai-responses cho mô hình OpenAI; openai-completions cho mô hình FoundryAZURE_OPENAI_API_KEY cộng AZURE_OPENAI_BASE_URL hoặc AZURE_OPENAI_RESOURCE_NAMEMô hình có baseUrl: "", được điền theo từng request; tên deployment ánh xạ được
cloudflare-workers-aiopenai-completions; classifier cloudflare-workers-ai-system-oneCLOUDFLARE_API_KEY cộng CLOUDFLARE_ACCOUNT_IDURL chứa placeholder {CLOUDFLARE_ACCOUNT_ID} (mục Cloudflare)

Gateway và aggregator (6)

ProviderAPIXác thựcĐáng chú ý
openrouteranthropic-messages cho anthropic/*, openai-completions cho phần còn lại; ảnh openrouter-images; classifier typesafe-system-oneOPENROUTER_API_KEY; OAuth PKCE trả về một khoá vĩnh viễnCatalog lọc theo khoá qua /models/user; header ghi nhận; x-session-id
vercel-ai-gatewayanthropic-messages cho mọi mô hình chat; classifier typesafe-system-oneAI_GATEWAY_API_KEYMọi hãng đi qua endpoint tương thích Anthropic của gateway
cloudflare-ai-gatewayopenai-responses (OpenAI), anthropic-messages (Anthropic), openai-completions (workers-ai/*)CLOUDFLARE_API_KEY, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_GATEWAY_IDKhoá đi trong cf-aig-authorization, header xác thực của SDK bị xoá
opencode (Zen)bốn API: anthropic-messages, google-generative-ai, openai-completions, openai-responses; classifier typesafe-system-oneOPENCODE_API_KEYx-opencode-session theo phiên, x-opencode-client ghi nhận
opencode-gobốn API như ZenOPENCODE_API_KEY (dùng chung)Endpoint opencode.ai/inference/go
radiuspi-messagesRADIUS_API_KEY; OAuth trình duyệt hoặc device codeGateway của Earendil; catalog lấy từ /v1/config thay hẳn bản dựng sẵn

Truy cập bằng gói thuê bao (2)

Hai provider tồn tại chỉ để dùng một gói thuê bao. Các gói khác (Claude Pro/Max, ChatGPT, SuperGrok, Muse, Kimi Code) là phương thức OAuth gắn thêm vào provider của hãng ở các bảng trên.

ProviderAPIXác thựcĐáng chú ý
github-copilotanthropic-messages cho Claude, openai-responses cho GPT, Grok, OSWE, MAI, openai-completions cho phần còn lại (generate-models.ts:2338)COPILOT_GITHUB_TOKEN; OAuth device code, có GitHub EnterprisebaseUrl theo tài khoản lấy từ token; lọc mô hình theo danh sách tài khoản được dùng
openai-codexopenai-codex-responsesChỉ OAuth (ChatGPT Plus/Pro): trình duyệt hoặc device codeTên "OpenAI Codex (legacy)"; chatgpt.com/backend-api; SSE hoặc WebSocket; header chatgpt-account-id

Gói coding, token plan và biến thể theo vùng (11)

Cùng một hãng thường có nhiều endpoint: một cho tính tiền theo API, một cho gói coding, một cho Trung Quốc đại lục, một cho từng vùng của token plan. Mỗi endpoint là một provider riêng, vì credential và catalog của chúng khác nhau. Tất cả nói openai-completions, trừ minimax-cn nói anthropic-messages.

ProviderEndpointXác thựcĐáng chú ý
kimi-codingapi.kimi.com/coding/v1KIMI_API_KEY; OAuth device code (Kimi Code)Token OAuth đi dưới dạng header Authorization, không phải apiKey
zaiapi.z.ai/api/coding/paas/v4ZAI_API_KEYCoding Plan quốc tế; thinkingFormat: "zai"; có thể tràn ngữ cảnh lặng lẽ
zai-coding-cnopen.bigmodel.cn/api/coding/paas/v4ZAI_CODING_CN_API_KEYCoding Plan Trung Quốc
qwen-token-plantoken-plan.ap-southeast-1.maas.aliyuncs.comQWEN_TOKEN_PLAN_API_KEYCatalog rộng, giữ để tương thích ngược
qwen-token-plan-individualcùng endpoint trênQWEN_TOKEN_PLAN_API_KEY (dùng chung)Chỉ mô hình của gói Individual (README)
qwen-token-plan-cntoken-plan.cn-beijing.maas.aliyuncs.comQWEN_TOKEN_PLAN_CN_API_KEY
xiaomi-token-plan-cntoken-plan-cn.xiaomimimo.com/v1XIAOMI_TOKEN_PLAN_CN_API_KEY
xiaomi-token-plan-amstoken-plan-ams.xiaomimimo.com/v1XIAOMI_TOKEN_PLAN_AMS_API_KEYAmsterdam
xiaomi-token-plan-sgptoken-plan-sgp.xiaomimimo.com/v1XIAOMI_TOKEN_PLAN_SGP_API_KEYSingapore
minimax-cnapi.minimaxi.com/anthropicMINIMAX_CN_API_KEYanthropic-messages
moonshotai-cnapi.moonshot.cn/v1MOONSHOT_API_KEY (dùng chung với moonshotai)

Không phải chat (1), và những provider ngoài danh sách dựng sẵn

ProviderAPIXác thựcĐáng chú ý
typesafechỉ classifier typesafe-system-oneTYPESAFE_API_KEYJev của TypeSafe; không có api chat
fauxfaux (tự đặt)không cần: resolve luôn trả { auth: {} }Phát lại các phản hồi soạn sẵn, giả lập tốc độ token và deferred; dùng trong test, kể cả worker thử nghiệm của Pi Durable
llama.cppopenai-completions; classifier llama-cpp-classify, typesafe-system-oneURL máy chủ cộng khoá tuỳ chọnExtension dựng sẵn của coding agent, không nằm trong pi-ai (mục cục bộ)
tự định nghĩabất kỳ API nào có sẵn, hoặc API tự viếttuỳ cấu hìnhQua models.json hoặc pi.registerProvider()

Đếm lại: 11 cộng 7 cộng 4 cộng 6 cộng 2 cộng 11 cộng 1 là 42, khớp với builtinProviders(). Vài chi tiết dễ bỏ sót: openai-codex là provider duy nhất không có cách xác thực bằng khoá; moonshotai và moonshotai-cn, qwen-token-plan và qwen-token-plan-individual, opencode và opencode-go dùng chung biến môi trường nhưng credential lưu theo id provider, nên một lần /login chỉ cấu hình một provider.

Các API trên dây và lazy loading#

Thư mục packages/ai/src/api/ có 43 file: mười lăm adapter, mỗi adapter cho một API (mười chat, một ảnh, bốn classifier), mười lăm lazy wrapper *.lazy.ts tương ứng, và mười ba helper dùng chung. Ba kiểu API được khai báo riêng: KnownApi cho chat (chương pi-ai), cùng hai kiểu dưới đây.

API ảnh và API classifier mà pi-ai biết sẵn
pi/packages/ai/src/types.tsdòng 78–88TypeScript
export type KnownImageApi = "openrouter-images";export type ImageApi = KnownImageApi | (string & {});export type KnownClassifierApi =	| "typesafe-system-one"	| "cloudflare-workers-ai-system-one"	| "llama-cpp-classify"	| "openai-decisions";export type ClassifierApi = KnownClassifierApi | (string & {});

Bảng các adapter

ModuleNói gì trên dâyĐi quaProvider dùng nó
anthropic-messagesAnthropic Messages, SSE@anthropic-ai/sdk (lớp con PiAnthropic)anthropic, minimax, minimax-cn, vercel-ai-gateway; theo mô hình ở fireworks, github-copilot, openrouter, opencode, opencode-go, cloudflare-ai-gateway
openai-responsesOpenAI Responses, store: falseSDK openaiopenai, xai, meta; theo mô hình ở github-copilot, opencode, opencode-go, cloudflare-ai-gateway
openai-completionsChat Completions và mọi phương ngữ "tương thích OpenAI", điều chỉnh bằng cờ compatSDK openai20 provider một API ở bảng trên, cloudflare-workers-ai; theo mô hình ở fireworks, github-copilot, openrouter, opencode, opencode-go, azure, cloudflare-ai-gateway; llama.cpp và máy chủ cục bộ
openai-codex-responsesResponses của backend ChatGPT ở /codex/responses, qua SSE hoặc WebSocketfetch và WebSocket; chỉ dùng kiểu của SDKopenai-codex
azure-openai-responsesResponses trên Azure, tên deployment thay cho id mô hìnhAzureOpenAI của SDK openaiazure
bedrock-converse-streamBedrock ConverseStream@aws-sdk/client-bedrock-runtime, ký SigV4 hoặc Beareramazon-bedrock
google-generative-aiGemini API@google/genaigoogle; theo mô hình ở opencode, opencode-go
google-vertexGemini trên Vertex AI@google/genai với vertexai: truegoogle-vertex
mistral-conversationsChat completions theo phương ngữ của Mistralfetch thuầnmistral
pi-messagesGiao thức riêng của Pi (bên dưới)fetch thuầnradius; bất kỳ provider nào trong models.json khai báo "api": "pi-messages"
openrouter-imagesSinh ảnh qua Chat Completions không stream, với modalitiesSDK openaiopenrouter
typesafe-system-oneSystem One: POST <baseUrl>/systemonefetch qua classifier-shared.tstypesafe, openrouter, vercel-ai-gateway, opencode; mô hình quyết định của llama.cpp
cloudflare-workers-ai-system-oneSystem One bọc trong phong bì /ai/run của Workers AIfetchcloudflare-workers-ai
openai-decisionsOpenAI Decisions: POST /v1/decisionsfetchopenai (chỉ với khoá API)
llama-cpp-classifyDùng mô hình chat làm classifier: /tokenize, /apply-template, /completion với n_probsfetchllama.cpp

Các helper dùng chung, theo người dùng chúng:

  • openai-responses-shared.ts: đổi message và đọc luồng của họ Responses, dùng chung cho openai-responses, azure-openai-responses, openai-codex-responses. google-shared.ts: phần chung của hai adapter Gemini. system-one-shared.ts và classifier-shared.ts: định dạng System One và HTTP của classifier, mỗi dịch vụ chỉ khai báo một "transport" gồm URL, phong bì request và cách bóc output (system-one-shared.ts:56).
  • github-copilot-headers.ts: ba header động mà Copilot đòi, X-Initiator (user hay agent tuỳ message cuối), Openai-Intent và Copilot-Vision-Request khi có ảnh (github-copilot-headers.ts:23); ba adapter Anthropic, Completions, Responses gọi nó khi model.provider === "github-copilot".
  • openai-prompt-cache.ts: cắt prompt_cache_key còn 64 ký tự, cho bốn adapter họ OpenAI. constrained-sampling.ts: làm lược đồ JSON thành dạng strict và chuẩn bị tool văn phạm, dùng ở mọi adapter chat trừ pi-messages (hai adapter Gemini dùng qua google-shared.ts). azure-openai-config.ts: endpoint và deployment của Azure (mục đám mây). cloudflare-ai-binding.ts: fetch qua binding của Worker (mục Cloudflare). cloudflare.ts chỉ chứa các hằng URL có placeholder mà bộ sinh catalog dùng.
  • transform-messages.ts (chuyển giao giữa mô hình, chương pi-ai), simple-options.ts (option chung, ngân sách suy nghĩ), lazy.ts (bên dưới).

Lazy loading: bốn cách giữ SDK ngoài bundle

Một ứng dụng trình duyệt chỉ dùng Anthropic không nên phải tải SDK của AWS. pi-ai giữ điều này bằng cách để factory chỉ import một lazy wrapper, còn module adapter (và SDK của nó) chỉ được import() ở request đầu tiên. Có bốn biến thể, tuỳ việc module có chạy được trong trình duyệt hay không:

  1. Adapter chat bình thườnglazyApi(() => import("./anthropic-messages.ts")). Specifier là chuỗi hằng nên bundler tách được thành một chunk lười; lazyStream() biến lỗi nạp thành sự kiện error (chương pi-ai).
  2. Adapter chỉ chạy trên NodeBedrock nạp qua một specifier là biến, để bundler (bản thử trình duyệt, bản biên dịch Bun) không đi theo được vào AWS SDK; bản Bun đăng ký module tĩnh qua setBedrockProviderModule() (excerpt dưới).
  3. API một lần (ảnh, classifier)Không có luồng nên không cần lazyStream(): wrapper là một object có phương thức await import(...) rồi gọi tiếp (typesafe-system-one.lazy.ts).
  4. Flow OAuthlazyOAuth() nhận một hàm load; load.ts cũng dùng specifier là biến vì flow cần node:http, node:crypto, và bản Bun đăng ký flow tĩnh qua registerBundledOAuthFlowLoaders() (bun-oauth.ts).
Bedrock: specifier là biến, và cửa cho bản biên dịch Bun
pi/packages/ai/src/api/bedrock-converse-stream.lazy.tsdòng 4–30TypeScript
/** * Loads the bedrock implementation through a variable specifier so bundlers * (browser smoke, Bun compile) cannot follow the import into the Node-only * AWS SDK. The `.ts`/`.js` rewrite keeps the trick working from both source * and built output. */const importNodeOnlyApi = (specifier: string): Promise<unknown> => {	const runtimeSpecifier = import.meta.url.endsWith(".js") ? specifier.replace(/\.ts$/, ".js") : specifier;	return import(runtimeSpecifier);};let bedrockModuleOverride: ProviderStreams | undefined;/** * Overrides the dynamically imported bedrock implementation. Used by the Bun * binary build, where the variable-specifier import cannot be bundled; the * build registers a statically imported module instead. */export function setBedrockProviderModule(module: ProviderStreams): void {	bedrockModuleOverride = module;}export const bedrockConverseStreamApi = (): ProviderStreams =>	lazyApi(		async () =>			bedrockModuleOverride ?? ((await importNodeOnlyApi("./bedrock-converse-stream.ts")) as ProviderStreams),	);

Mẹo đổi đuôi .ts thành .js theo import.meta.url giúp cùng một dòng chạy được cả khi chạy thẳng mã nguồn TypeScript lẫn khi chạy bản build. Cái giá của specifier là biến là bundler không biết module đó tồn tại, nên bản phân phối nào đóng gói đơn file phải tự đăng ký nó, đúng như entry ./bedrock-provider và ./bun-oauth trong package.json làm.

pi-messages: giao thức riêng của Pi

Mọi adapter khác dịch transcript của Pi sang định dạng của một hãng, rồi dịch luồng của hãng về sự kiện của Pi. pi-messages bỏ cả hai bước dịch: client gửi chính transcript đã chuẩn hoá và nhận lại chính các sự kiện của pi-ai, đã serialize. Việc nói chuyện với nhà cung cấp thật nằm ở máy chủ. Đó là giao thức của gateway Radius, nhưng chú thích đầu file nói rõ mọi backend hiện thực nó đều dùng được (pi-messages.ts:1).

Request của pi-messages: một POST mang model, context và option thống nhất
pi/packages/ai/src/api/pi-messages.tsdòng 371–406TypeScript
const url = new URL(`${model.baseUrl.replace(/\/+$/u, "")}/messages`);if (options?.debug) {	url.searchParams.set("debug", "1");}let payload: unknown = {	model: model.id,	context,	options: {		temperature: options?.temperature,		maxTokens: options?.maxTokens,		reasoning: options?.reasoning,		cacheRetention: resolveCacheRetention(options?.cacheRetention, options?.env),		sessionId: options?.sessionId,		toolChoice: options?.toolChoice,	},};const nextPayload = await options?.onPayload?.(payload, model);if (nextPayload !== undefined) {	payload = nextPayload;}const response = await (options?.fetch ?? globalThis.fetch)(url, {	method: "POST",	headers: providerHeadersToRecord(		getIdentityHeaders(model, options?.identity),		{			authorization: `Bearer ${apiKey}`,			accept: "text/event-stream",			"content-type": "application/json",		},		options?.headers,	),	body: JSON.stringify(payload),	signal: options?.signal,});

Request là một POST tới <baseUrl>/messages. context là TranscriptContext nguyên vẹn, tức system prompt và tool nằm trong các system message; options chỉ gồm option thống nhất của Pi (reasoning, cacheRetention, sessionId, toolChoice), không có tham số riêng của hãng nào. Header identity được gộp dưới header xác thực và header của người gọi, như mọi adapter. Phản hồi là một luồng SSE mà mỗi bản tin là một sự kiện:

PiMessagesEvent: sự kiện của pi-ai, bỏ trường partial
pi/packages/ai/src/api/pi-messages.tsdòng 54–88TypeScript
/** Serialized assistant-message event as sent by a pi-messages backend. */export type PiMessagesEvent =	| { type: "start" }	| { type: "text_start"; contentIndex: number }	| { type: "text_delta"; contentIndex: number; delta: string }	| { type: "text_end"; contentIndex: number; content: string; contentSignature?: string }	| { type: "thinking_start"; contentIndex: number }	| { type: "thinking_delta"; contentIndex: number; delta: string }	| {			type: "thinking_end";			contentIndex: number;			content: string;			contentSignature?: string;			redacted?: boolean;	  }	| { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }	| { type: "toolcall_delta"; contentIndex: number; delta: string }	| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall }	| {			type: "done";			reason: Extract<PiMessagesStopReason, "stop" | "length" | "toolUse">;			usage: PiMessagesUsage;			responseId?: string;			providerThinkingLevel?: string;			rewrite?: PiMessagesRewriteImpact;	  }	| {			type: "error";			reason: Extract<PiMessagesStopReason, "aborted" | "error">;			usage: PiMessagesUsage;			errorMessage?: string;			responseId?: string;			providerThinkingLevel?: string;			rewrite?: PiMessagesRewriteImpact;	  };

Đây là bộ sự kiện của giao thức luồng, bỏ trường partial. Client tự dựng lại message dở bằng một bộ chuyển đổi nhỏ: delta chữ và suy nghĩ được nối vào khối theo contentIndex, mảnh JSON của tham số tool được phân tích dần bằng parseStreamingJson(), toolcall_end mang lời gọi tool đã chốt từ máy chủ (createEventConverter). Có ba khác biệt đáng nhớ so với adapter thường:

  • Usage và chi phí đến nguyên từ sự kiện done hay error của máy chủ; client không gọi calculateCost(), nên giá là giá gateway tính.
  • Sự kiện cuối có thể mang rewrite: tóm tắt việc một chính sách của gateway đã sửa message (số token, số message thay đổi, system prompt có bị đổi không). Client ghi nó thành một diagnostic pi_messages_rewrite trên message, để người dùng biết request đã bị sửa phía máy chủ (pi-messages.ts:44).
  • Không có SDK nào, nên module này nhỏ (448 dòng) và chạy ở mọi môi trường có fetch. Luồng hết mà không có sự kiện cuối là lỗi.

Credential trong pi-ai: môi trường, kho và thứ tự#

pi-ai biết ba nguồn credential: giá trị tường minh trong option của request, kho credential (CredentialStore) mà ứng dụng tiêm vào createModels(), và môi trường xung quanh, đọc qua một AuthContext. Thứ tự giữa chúng do resolveProviderAuth() quyết định, đã trích ở chương pi-ai; tóm lại:

  1. options.apiKeyNếu có, nó được bọc thành một credential api_key tạm rồi vẫn đi qua resolve() của provider. Đi đường vòng này là có chủ ý: một provider như Cloudflare cần dựng URL theo account từ cùng lần phân giải, kể cả khi khoá đến từ lời gọi (CHANGELOG 0.80.2).
  2. Credential đã lưuOAuth thì làm mới nếu còn dưới năm phút rồi gọi toAuth(); api_key thì đưa vào resolve(). Credential đã lưu "sở hữu" provider: lưu OAuth mà provider không có oauth, hoặc làm mới thất bại, thì không có đường lùi về biến môi trường.
  3. Môi trườngKhông có gì được lưu thì resolve() chạy với credential: undefined và tự đọc biến môi trường, file, profile. Kết quả undefined nghĩa là provider chưa được cấu hình.

options.env phủ lên môi trường của tiến trình cho riêng request đó. Cơ chế chỉ là một AuthContext bọc ngoài:

overlayEnvAuthContext(): biến môi trường của request đứng trước
pi/packages/ai/src/auth/resolve.tsdòng 95–100TypeScript
function overlayEnvAuthContext(base: AuthContext, env: ProviderEnv): AuthContext {	return {		env: async (name) => env[name] || (await base.env(name)),		fileExists: (path) => base.fileExists(path),	};}

Bản thân AuthContext chỉ có hai thao tác bất đồng bộ, env(name) và fileExists(path) (auth/types.ts:96). Bản mặc định đọc process.env, coi chuỗi rỗng hoặc toàn khoảng trắng là không đặt, và kiểm tra file bằng node:fs với ~ được mở rộng. Trong trình duyệt, env() luôn trả undefined và fileExists() luôn false, nên ở đó chỉ còn khoá truyền tường minh hoặc kho credential.

AuthContext mặc định: process.env và node:fs, nạp lười
pi/packages/ai/src/auth/context.tsdòng 23–45TypeScript
export function defaultProviderAuthContext(): AuthContext {	return {		async env(name: string): Promise<string | undefined> {			const value = getProcessEnv()?.[name];			return typeof value === "string" && value.trim().length > 0 ? value : undefined;		},		async fileExists(path: string): Promise<boolean> {			try {				const fs = (await importNodeModule("node:fs/promises")) as NodeFsModule;				let resolved = path;				if (resolved.startsWith("~")) {					const os = (await importNodeModule("node:os")) as NodeOsModule;					resolved = os.homedir() + resolved.slice(1);				}				await fs.access(resolved);				return true;			} catch {				return false;			}		},	};}

Vì AuthContext tiêm được, một host chạy nhiều người dùng trong một tiến trình có thể cho mỗi Models một nguồn biến môi trường riêng, và test không phải sửa process.env. Adapter thì đọc cấu hình không phải xác thực (vùng của AWS, phiên bản API của Azure, PI_CACHE_RETENTION) qua một hàm khác, getProviderEnvValue(): options.env trước, rồi process.env, rồi một đường lùi đọc /proc/self/environ cho bản biên dịch Bun, nơi process.env có thể rỗng trong sandbox Linux (provider-env.ts).

Kiểm tra mà không phân giải

Models cần biết provider nào "có credential" để liệt kê mô hình dùng được (getAvailable()), và việc đó chạy thường xuyên. checkProviderAuth() coi một OAuth đã lưu là đủ, không làm mới; với khoá, nó dùng ApiKeyAuth.check() nếu provider có, không thì phân giải thật (models.ts:664). check() tồn tại cho những provider mà resolve() có tác dụng phụ: models.json có thể khai báo khoá là một lệnh shell, và kiểm tra không được chạy lệnh đó (auth/types.ts:177).

env-api-keys.ts ở v1.4.0

Tên biến môi trường của từng provider giờ nằm trong factory của nó, trong envApiKeyAuth("...", ["GROQ_API_KEY"]). File env-api-keys.ts giữ một bảng song song từ thời API toàn cục (env-api-keys.ts:73), và ở v1.4.0 chỉ còn đường /compat gọi getEnvApiKey() (compat.ts:224). Hai bảng khớp nhau cho mọi provider; đáng chú ý là với Vertex và Bedrock, bảng cũ trả về chuỗi đánh dấu "<authenticated>" khi chỉ có credential môi trường, và compat bỏ qua đúng chuỗi này thay vì gửi nó đi như một khoá. Cách mới không cần mẹo đó: resolve() trả { auth: {} }, nghĩa là "đã cấu hình, nhưng không có gì để chèn vào request".

Credential trong coding agent: auth.json, --api-key, pi auth#

pi-ai mặc định giữ credential trong bộ nhớ. Coding agent tiêm vào createModels() một kho bền là AuthStorage, đọc ghi file <agent-dir>/auth.json (mặc định ~/.pi/agent/auth.json), rồi bọc nó trong RuntimeCredentials để chứa khoá của cờ --api-key (model-runtime.ts:219). File có dạng một object, khoá là id provider:

Ví dụ minh hoạ: auth.json (giá trị bí mật thay bằng …)
{
  "anthropic":  { "type": "oauth", "access": "…", "refresh": "…", "expires": 1761100000000 },
  "openrouter": { "type": "api_key", "key": "!pass show openrouter" },
  "cloudflare-ai-gateway": {
    "type": "api_key", "key": "…",
    "env": { "CLOUDFLARE_ACCOUNT_ID": "…", "CLOUDFLARE_GATEWAY_ID": "…" }
  }
}
Một credential cho mỗi provider. key bắt đầu bằng ! là một lệnh, chạy khi cần lần đầu; env là cấu hình theo provider, được ưu tiên hơn biến môi trường của tiến trình.

Ba chi tiết của AuthStorage làm file này an toàn khi nhiều tiến trình Pi chạy cùng lúc. File được tạo với quyền 0600, thư mục cha 0700 (auth-storage.ts:36). Mọi lần ghi đi qua một khoá file (proper-lockfile, coi là cũ sau 30 giây), và modify() đọc lại file bên trong khoá trước khi gộp, nên một /login ở tiến trình khác không bị ghi đè. Mỗi lần đọc so sánh "revision" của file với lần trước và đọc lại khi khác, nên một tiến trình thấy ngay token do tiến trình khác vừa làm mới.

AuthStorage: đọc có phân giải giá trị, ghi gộp dưới khoá file
pi/packages/coding-agent/src/core/auth-storage.tsdòng 442–472TypeScript
async read(provider: string, options?: AuthOperationOptions): Promise<Credential | undefined> {	const credential = (await this.readLatestData(options))[provider];	options?.signal?.throwIfAborted();	if (credential?.type !== "api_key") return credential;	if (credential.key === undefined) return credential;	return { ...credential, key: resolveConfigValue(credential.key, credential.env) };}async modify(	provider: string,	fn: (current: Credential | undefined) => Promise<Credential | undefined>,	options?: AuthOperationOptions,): Promise<Credential | undefined> {	let latestData = this.readState.data;	let revision: string | undefined;	const result = await this.storage.withLockAsync(async (content) => {		const currentData = this.parseStorageData(content);		const next = await fn(currentData[provider]);		if (next === undefined) {			latestData = currentData;			revision = this.authPath ? getFileRevision(this.authPath) : undefined;			return { result: currentData[provider] };		}		const merged: AuthStorageData = { ...currentData, [provider]: next };		latestData = merged;		return { result: next, next: JSON.stringify(merged, null, 2) };	}, options);	this.updateReadState(latestData, revision);	return result;}

read() đưa key qua resolveConfigValue(), nên trong auth.json có thể viết !lệnh hoặc $TÊN_BIẾN, với biến lấy từ env của chính credential trước rồi mới tới process.env. Kết quả của lệnh được cache suốt đời tiến trình; lệnh lỗi, hết 10 giây hay in ra chuỗi rỗng thì khoá chưa được phân giải cho tới khi khởi động lại (docs/providers.md:71, resolve-config-value.ts:185). Đây là cách dùng một trình quản lý bí mật mà không ghi khoá ra đĩa.

--api-key: một lớp phủ không bền

RuntimeCredentials: khoá lúc chạy đứng trước auth.json
pi/packages/coding-agent/src/core/runtime-credentials.tsdòng 3–28TypeScript
/** Async credential store overlay for non-persistent runtime API keys. */export class RuntimeCredentials implements CredentialStore {	private readonly store: CredentialStore;	private readonly overrides = new Map<string, string>();	constructor(store: CredentialStore) {		this.store = store;	}	setRuntimeApiKey(providerId: string, apiKey: string): void {		this.overrides.set(providerId, apiKey);	}	removeRuntimeApiKey(providerId: string): void {		this.overrides.delete(providerId);	}	hasRuntimeApiKey(providerId: string): boolean {		return this.overrides.has(providerId);	}	async read(providerId: string, options?: AuthOperationOptions): Promise<Credential | undefined> {		options?.signal?.throwIfAborted();		const override = this.overrides.get(providerId);		return override ? { type: "api_key", key: override } : this.store.read(providerId, options);	}

main.ts gắn khoá của --api-key cho đúng provider của mô hình đã chọn, sau khi phân giải --model (main.ts:831). Vì lớp phủ nằm ở read() của kho, pi-ai thấy nó như một credential đã lưu, và nó thắng mọi thứ khác. Nó không bao giờ được ghi xuống file.

Thứ tự đầy đủ trong coding agent

Khi models.json hoặc một extension cấu hình provider, composeApiKeyAuth() trong provider-composer.ts bọc ApiKeyAuth của provider dựng sẵn (gọi là "inherited") và thêm khoá cấu hình vào giữa chuỗi:

composeApiKeyAuth(): credential đã lưu, rồi khoá cấu hình, rồi môi trường
pi/packages/coding-agent/src/core/provider-composer.tsdòng 462–485TypeScript
	resolve: async (input) => {		let result: AuthResult | undefined;		if (input.credential) {			result = inherited				? await inherited.resolve(input)				: input.credential.key					? { auth: { apiKey: input.credential.key }, env: input.credential.env, source: "stored credential" }					: undefined;		} else if (rawKey !== undefined) {			const env = await configContextEnv([rawKey], input.ctx);			const key = resolveConfigValueOrThrow(rawKey, `API key for provider "${providerId}"`, env);			result = inherited				? await inherited.resolve({ ...input, credential: { type: "api_key", key } })				: { auth: { apiKey: key }, source: "configured API key" };		} else {			result = await inherited?.resolve(input);		}		if (!result) return undefined;		const explicitEnv = { ...(input.credential?.env ?? {}), ...(result.env ?? {}) };		const headerEnv = await configContextEnv(Object.values(rawHeaders ?? {}), input.ctx, explicitEnv);		const headers = resolveHeadersOrThrow(rawHeaders, `provider "${providerId}"`, headerEnv);		return { ...result, auth: withConfiguredAuth(result.auth, headers, authHeader) };	},};

Ghép với lớp --api-key và với resolveProviderAuth() của pi-ai, ta có đúng thứ tự mà tài liệu mô tả: --api-key, rồi auth.json, rồi apiKey trong models.json, cuối cùng là biến môi trường hoặc credential môi trường của provider (docs/models.md:23). Hai chi tiết đáng để ý. Khoá cấu hình không thay resolve() dựng sẵn mà được đưa vào nó như một credential, nên Cloudflare vẫn thêm account id, Anthropic vẫn xử lý khoá như thường. Và header cấu hình được phân giải sau cùng, với biến môi trường lấy cả từ env mà provider vừa trả về; authHeader: true thêm Authorization: Bearer <khoá> và báo lỗi nếu không có khoá (withConfiguredAuth). Header khai báo trên từng mô hình được ModelRuntime.getAuth() gộp thêm ở bước cuối (model-runtime.ts:557).

pi auth: xuất credential cho client bên ngoài

Từ 0.83.0, pi auth print-api-key và pi auth print-bearer-token in ra credential đã cấu hình, để một công cụ khác (một script, một client HTTP) dùng chung đăng nhập của Pi; pi auth check, thêm ở 0.84.1, chỉ báo ready, not_ready hay invalid qua mã thoát (docs/cli.md:293). Cơ chế cố ý dùng lại đường phân giải của request:

Chọn đúng loại credential, đòi thời hạn tối thiểu cho token OAuth
pi/packages/coding-agent/src/cli/credential-print.tsdòng 59–73TypeScript
const credentials: Array<{ providerId: string; value: string }> = [];for (const provider of providers) {	const type = credentialTypes.get(provider.id);	if (kind === "api_key" && type === "oauth") continue;	if (kind === "bearer_token" && type !== "oauth") continue;	const authOptions = {		...(kind === "bearer_token" ? { minOAuthValidityMs: minExpiryMs ?? DEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS } : {}),		signal,	};	const auth = provider.model		? await modelRuntime.getAuth(provider.model, authOptions)		: await modelRuntime.getAuth(provider.id, authOptions);	const value = getAuthCredential(auth);	if (value) credentials.push({ providerId: provider.id, value });}

print-api-key bỏ qua provider đăng nhập bằng OAuth, print-bearer-token chỉ nhận OAuth. Token OAuth còn dưới 30 phút (hoặc dưới giá trị của --min-expiry) được làm mới trước khi in, qua minOAuthValidityMs của resolveProviderAuth(); nếu sau khi làm mới token vẫn sắp hết hạn, lệnh báo lỗi thay vì in ra một token sắp chết. getAuthCredential() lấy apiKey, hoặc phần sau Bearer của header Authorization với những provider xác thực bằng header như Kimi Code (auth-command.ts:120). Lệnh ghi bí mật ra stdout, nên đừng chạy nó trong một phiên được ghi log.

Đăng nhập OAuth và gói thuê bao#

Một gói thuê bao (Claude Pro/Max, ChatGPT, Copilot, SuperGrok) không đi kèm khoá API. Muốn dùng nó từ một chương trình, chương trình phải đăng nhập như ứng dụng chính thức của hãng làm, lấy một token, và làm mới token đó khi hết hạn. pi-ai mô tả mọi flow như vậy bằng cùng một interface OAuthAuth với ba thao tác: login(), refresh() và toAuth() (auth/types.ts:216). login() không biết gì về giao diện: nó chỉ gọi prompt() (bốn loại: text, secret, select, manual_code) và notify() (bốn sự kiện: info, auth_url, device_code, progress), nên cùng một flow chạy được trong TUI, trong CLI nhỏ của pi-ai, hay trong một ứng dụng web (auth/types.ts:119).

Chín flow ở v1.4.0, dùng ba kiểu đăng nhập: trình duyệt với máy chủ callback cục bộ, device code (RFC 8628: máy hiện một mã, người dùng nhập mã đó trên một trang web), và dán mã hay URL thủ công.

FlowCách đăng nhậpCredential lưurefresh() và toAuth()
Anthropic (Claude Pro/Max)Chọn: trình duyệt cộng PKCE, callback ở cổng 53692 (lùi về cổng trống, rồi về dán URL); hoặc "copy code" cho máy không có trình duyệtaccess, refresh, hạn trừ trước năm phútGrant refresh_token; toAuth trả access token như khoá API
OpenAI (Sign in with ChatGPT)PKCE, đăng ký client động: gửi dynamic_agent_client, nhận client id thật trong callback; cổng cố định 1455 hoặc dán URLThêm clientId, scopes; hạn trừ ba phútRefresh bằng client id đã cấp; token đi thẳng tới api.openai.com
OpenAI Codex (legacy)Chọn: trình duyệt cộng PKCE (cổng 1455, dùng chung với Codex CLI) hoặc device code ở auth.openai.com/codex/deviceThêm accountId đọc từ JWTGrant refresh_token; adapter tự đọc lại account id từ token
GitHub CopilotDevice code ở github.com hoặc miền GitHub Enterpriserefresh là token GitHub, access là token Copilot; thêm availableModelIds"Refresh" là đổi lại token GitHub lấy token Copilot rồi tải lại danh sách mô hình; toAuth trả cả baseUrl
Kimi CodeDevice code ở auth.kimi.com (đổi được bằng KIMI_CODE_OAUTH_HOST)access, refreshThử lại tới ba lần khi 429 hay 5xx; toAuth trả header Authorization
Meta (Muse)Device code ở auth.meta.com, rồi đổi identity token lấy một khoá Model APIrefresh là identity token, access là khoá sống 24 giờ"Refresh" là xin khoá mới; 401 hay 403 nghĩa là phải đăng nhập lại
xAI (SuperGrok, X Premium)Device code ở auth.x.aiaccess, refresh, hạn trừ năm phútGiữ refresh token cũ nếu máy chủ không xoay vòng nó
OpenRouterPKCE, callback ở cổng ngẫu nhiên với đường dẫn ngẫu nhiên, hoặc dán URL; hết hạn sau năm phút chờMột khoá API vĩnh viễn trong access, expires: Number.MAX_SAFE_INTEGERrefresh trả nguyên credential
RadiusChọn: trình duyệt cộng PKCE (cổng 1456, endpoint ủy quyền tra ở /v1/oauth của gateway) hoặc device codeaccess, refresh, hạn trừ 60 giâyGrant refresh_token tới chính gateway

Ba khối dựng chung

PKCE. generatePKCE() sinh 32 byte ngẫu nhiên bằng Web Crypto làm verifier và băm SHA-256 thành challenge, nên chạy được cả trên Node lẫn trình duyệt (pkce.ts:21). Anthropic dùng luôn verifier làm tham số state; OpenRouter không gửi state, nên flow của nó đặt callback ở một đường dẫn chứa UUID ngẫu nhiên để một request lạc không hoàn tất được việc đăng nhập (openrouter.ts:115).

Máy chủ callback. startOAuthCallbackServer() mở một máy chủ HTTP trên loopback, chỉ nhận đúng đường dẫn và đúng state, chỉ xử lý callback đầu tiên, và đổi mã lấy token trước khi trả trang kết quả, để lỗi đổi mã hiện được trên trình duyệt (callback-server.ts:50). Khi trình duyệt chạy trên máy khác (qua SSH, trong container), callback không bao giờ tới; vì thế mọi flow trình duyệt đua callback với một lời nhắc dán mã:

Đua callback với việc dán mã: bên nào xong trước thì huỷ bên kia
pi/packages/ai/src/auth/oauth/callback-server.tsdòng 155–183TypeScript
export async function waitForCallbackOrManualInput<T>(	interaction: ProviderAuthInteraction,	callback: OAuthCallbackServer<T> | undefined,	prompt: { message: string; placeholder: string },): Promise<{ type: "callback"; value: T } | { type: "manual"; input: string }> {	const manualAbort = new AbortController();	let manualError: Error | undefined;	const manual = interaction		.prompt({ type: "manual_code", ...prompt, signal: manualAbort.signal })		.then((input) => {			callback?.cancel();			return input;		})		.catch((error: unknown) => {			manualError = error instanceof Error ? error : new Error(String(error));			callback?.cancel();			return undefined;		});	try {		const value = await callback?.wait();		if (manualError) throw manualError;		if (value !== undefined) return { type: "callback", value };		const input = await manual;		if (manualError) throw manualError;		return { type: "manual", input: input ?? "" };	} finally {		manualAbort.abort();	}}

Địa chỉ lắng nghe mặc định là 127.0.0.1; biến PI_OAUTH_CALLBACK_HOST đổi được nó cho các flow Anthropic, ChatGPT, Codex và OpenRouter (anthropic.ts:17), còn Radius cố định ở 127.0.0.1:1456. Cổng cố định có lý do: Anthropic chọn 53692 để người dùng chuyển tiếp được cổng vào container hay qua SSH, và lùi về cổng trống vì Anthropic nhận mọi cổng loopback; Sign in with ChatGPT thì không lùi được, và báo lỗi rõ khi cổng 1455 đang bị một phiên Pi khác hay Codex CLI giữ, thay vì để callback rơi vào tiến trình kia rồi hỏng vì sai state (openai-chatgpt.ts:241).

Device code. Bốn flow chỉ dùng device code (Copilot, Kimi, Meta, xAI), hai flow có nó như một lựa chọn (Codex, Radius). Phần hỏi vòng dùng chung một hàm:

pollOAuthDeviceCodeFlow(): hỏi vòng theo RFC 8628, tôn trọng slow_down
pi/packages/ai/src/auth/oauth/device-code.tsdòng 64–97TypeScript
while (Date.now() < deadline) {	if (options.signal.aborted) {		throw new Error(CANCEL_MESSAGE);	}	const result = await options.poll();	if (result.status === "complete") {		return result.value;	}	if (result.status === "failed") {		throw new Error(result.message);	}	if (result.status === "slow_down") {		slowDownResponses += 1;		// Use the server-provided interval when given (GitHub reports the new required minimum		// in `interval`); trusting only a client-tracked value risks polling early forever under		// WSL/VM clock drift. Otherwise apply RFC 8628 section 3.5: increase by 5 seconds.		intervalMs =			typeof result.intervalSeconds === "number" &&			Number.isFinite(result.intervalSeconds) &&			result.intervalSeconds > 0				? Math.max(MINIMUM_INTERVAL_MS, Math.floor(result.intervalSeconds * 1000))				: Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS);	}	const remainingMs = deadline - Date.now();	if (remainingMs <= 0) {		break;	}	await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE);}throw new Error(slowDownResponses > 0 ? SLOW_DOWN_TIMEOUT_MESSAGE : TIMEOUT_MESSAGE);

Mặc định hỏi năm giây một lần, tối thiểu một giây. Khi máy chủ trả slow_down, khoảng cách lấy theo giá trị máy chủ gửi nếu có, không thì cộng năm giây như RFC quy định. Chú thích trong code giải thích vì sao tin máy chủ: trên WSL hay máy ảo bị lệch đồng hồ, một khoảng cách chỉ do client tự tính có thể hỏi sớm mãi mãi; lỗi hết giờ sau nhiều lần slow_down vì thế gợi ý đồng bộ lại đồng hồ. Với Copilot, Kimi, Meta và xAI, URL xác minh mà máy chủ gửi về được kiểm tra là http(s) (riêng xAI đòi https) trước khi đưa cho trình mở URL, để một phản hồi độc không khiến open chạy một file thực thi (github-copilot.ts:242).

Khi "refresh" không phải là refresh

Interface tách refresh() (tạo credential mới) khỏi toAuth() (suy ra xác thực của request), và sự tách bạch đó đủ rộng để chứa những thứ không phải OAuth chuẩn. Copilot là ví dụ rõ nhất: token GitHub nằm ở trường refresh, "làm mới" là đổi nó lấy một token Copilot ngắn hạn, và toAuth() đọc endpoint của tài khoản ngay trong token:

Copilot: endpoint theo tài khoản nằm trong token (proxy-ep)
pi/packages/ai/src/auth/oauth/github-copilot.tsdòng 64–76, 493–507TypeScript
/** * Parse the proxy-ep from a Copilot token and convert to API base URL. * Token format: tid=...;exp=...;proxy-ep=proxy.individual.githubcopilot.com;... * Returns API URL like https://api.individual.githubcopilot.com */function getBaseUrlFromToken(token: string): string | null {	const match = token.match(/proxy-ep=([^;]+)/);	if (!match) return null;	const proxyHost = match[1];	// Convert proxy.xxx to api.xxx	const apiHost = proxyHost.replace(/^proxy\./, "api.");	return `https://${apiHost}`;}export const githubCopilotOAuth: OAuthAuth = {	name: "GitHub Copilot",	isSubscription: true,	login: loginGitHubCopilot,	refresh: (credential, signal) =>		refreshGitHubCopilotToken(credential.refresh, copilotEnterpriseDomain(credential), signal),	/** Derive the credential-specific proxy endpoint for each request. */	async toAuth(credential) {		return {			apiKey: credential.access,			baseUrl: getGitHubCopilotBaseUrl(credential.access, copilotEnterpriseDomain(credential)),		};	},};

Lúc đăng nhập, flow Copilot còn gọi /models để biết tài khoản được dùng mô hình nào, và bật chính sách cho những mô hình còn ở trạng thái "unconfigured" (một số mô hình như Claude, Grok phải bật trước khi dùng); filterModels của provider sau đó chỉ cho hiện các id đó (loginGitHubCopilot). Meta đi xa hơn: identity token của nó không dùng để suy luận được và cũng không làm mới được, nên Pi lưu nó vào refresh và "làm mới" bằng cách xin một khoá API mới mỗi ngày; chú thích đầu file nói rõ cách này để bộ lập lịch OAuth chuẩn tự xin lại khoá mà không cần cơ chế riêng (meta.ts:1). OpenRouter thì ngược lại: OAuth chỉ để tạo một khoá API do người dùng kiểm soát, nên không có gì để làm mới.

Sign in with ChatGPT có hai đòi hỏi riêng. Nó cần một id cài đặt ổn định dạng UUID, gửi đi như ext_agent_host_id; coding agent đưa vào qua LoginOptions.getDeviceId, lấy từ settings (interactive-mode.ts:6296). Và grant phải có scope chatgpt.tokens.use.direct, nếu không credential bị từ chối ngay khi đăng nhập (openai-chatgpt.ts:162).

Làm mới an toàn

Việc làm mới chạy bên trong CredentialStore.modify(), với điều kiện hết hạn được kiểm tra lại sau khi đã giữ khoá, và một lần làm mới đã bắt đầu sẽ chạy tới cùng dù người gọi huỷ; cơ chế này đã được giải thích ở chương pi-ai. Changelog cho thấy nó được dựng dần qua các sự cố thật: khoá với kiểm tra kép từ 0.80.0 (CHANGELOG 0.80.0), làm mới khi còn dưới năm phút thay vì đợi hết hạn từ 0.83.0 (CHANGELOG 0.83.0), giới hạn thời gian làm mới để request treo không giữ khoá mãi từ 0.84.0 (CHANGELOG 0.84.0), và từ 1.0.3 một lần làm mới bị huỷ giữa chừng không còn vứt mất refresh token vừa xoay vòng, lỗi từng khiến Sign in with ChatGPT báo refresh_token_invalidated (CHANGELOG 1.0.3, coding-agent CHANGELOG).

Lưu ý thêm: nhiều flow đã tự trừ một khoảng an toàn vào expires lúc đăng nhập (năm phút với Anthropic, xAI, Copilot; ba phút với ChatGPT; 60 giây với Radius), rồi resolveStoredOAuth() lại làm mới khi còn dưới năm phút. Hai khoảng an toàn cộng dồn, nên token thực tế được làm mới sớm hơn hạn thật từ sáu (Radius) tới mười phút (Anthropic, xAI, Copilot).

Nền tảng đám mây: Bedrock, Vertex, Azure, workload identity#

Nền tảng đám mây không cấp "một khoá API" theo nghĩa thông thường. Credential của chúng là một chuỗi nguồn (profile, khoá IAM, vai trò của container, file tài khoản dịch vụ) mà SDK chính thức đã biết cách đi qua. pi-ai không lặp lại chuỗi đó. Với Bedrock và Vertex, resolve() của provider chỉ làm hai việc: báo provider "đã được cấu hình" để mô hình hiện lên, và chuyển cấu hình (profile, project, location) vào env của request; việc lấy credential thật thuộc về SDK lúc gửi. Azure thì ngược lại: xác thực là một khoá thường, cái khó nằm ở endpoint riêng của từng resource.

Amazon Bedrock

bedrockAuth.resolve(): nhận ra credential AWS mà không chép chúng
pi/packages/ai/src/providers/amazon-bedrock.tsdòng 54–79TypeScript
resolve: async ({ ctx, credential, signal }) => {	const env = async (name: string) => {		signal.throwIfAborted();		const value = await ctx.env(name);		signal.throwIfAborted();		return value;	};	if (credential?.key) {		return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" };	}	if (await env("AWS_BEARER_TOKEN_BEDROCK")) return { auth: {}, source: "AWS_BEARER_TOKEN_BEDROCK" };	if (credential?.env?.AWS_PROFILE ?? (await env("AWS_PROFILE"))) {		return {			auth: {},			env: credential?.env,			source: credential?.env?.AWS_PROFILE ? "stored credential" : "AWS_PROFILE",		};	}	if ((await env("AWS_ACCESS_KEY_ID")) && (await env("AWS_SECRET_ACCESS_KEY"))) {		return { auth: {}, source: "AWS access keys" };	}	if (await env("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")) return { auth: {}, source: "ECS task role" };	if (await env("AWS_CONTAINER_CREDENTIALS_FULL_URI")) return { auth: {}, source: "ECS task role" };	if (await env("AWS_WEB_IDENTITY_TOKEN_FILE")) return { auth: {}, source: "web identity token" };	return undefined;},

Thứ tự là: khoá đã lưu (một bearer token của Bedrock), AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE (bản lưu trong env của credential trước), cặp AWS_ACCESS_KEY_ID và AWS_SECRET_ACCESS_KEY, vai trò của ECS, web identity (IRSA trên Kubernetes). Ngoài khoá đã lưu, mọi nhánh trả auth: {}: không có gì để chèn vào request. Lệnh đăng nhập cho chọn một trong ba cách và chỉ lưu đúng thứ người dùng nhập: token, tên profile, hoặc không gì cả với lựa chọn "chuỗi credential có sẵn" (amazon-bedrock.ts:11). Adapter quyết định phần còn lại khi dựng client:

Dựng BedrockRuntimeClient: profile, bearer, vùng, proxy
pi/packages/ai/src/api/bedrock-converse-stream.tsdòng 163–166, 185–191, 198–206, 217–230, 242–245TypeScript
const optionsProfile = options.profile || options.env?.AWS_PROFILE;const config: BedrockRuntimeClientConfig = {	profile: optionsProfile || getProviderEnvValue("AWS_PROFILE", options.env),	customUserAgent: bedrockUserAgent(options.identity),const skipAuth = getProviderEnvValue("AWS_BEDROCK_SKIP_AUTH", options.env) === "1";const bearerToken =	options.bearerToken ||	options.apiKey ||	getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", options.env) ||	undefined;const useBearerToken = bearerToken !== undefined && !skipAuth;	const arnRegionMatch = model.id.match(/^arn:aws(?:-[a-z0-9-]+)?:bedrock:([a-z0-9-]+):/);	if (arnRegionMatch) {		config.region = arnRegionMatch[1];	} else if (configuredRegion) {		config.region = configuredRegion;	} else if (endpointRegion && useExplicitEndpoint) {		config.region = endpointRegion;	} else if (!hasAmbientConfiguredProfile) {		config.region = "us-east-1";	const credentials = getConfiguredBedrockCredentials(options.env);	if (!skipAuth && credentials && !optionsProfile) {		config.credentials = credentials;	}	const proxyUrl = resolveHttpProxyUrlForTarget(model.baseUrl, options.env);	if (proxyUrl) {		// Bedrock runtime uses NodeHttp2Handler by default since v3.798.0, which is based		// on `http2` module and has no support for http agent.		// Use NodeHttpHandler to support HTTP(S) proxy agents.		config.requestHandler = new NodeHttpHandler({			httpAgent: new HttpProxyAgent(proxyUrl),			httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent,		});if (useBearerToken) {	config.token = { token: bearerToken };	config.authSchemePreference = ["httpBearerAuth"];}
  • Bearer hay SigV4. Có bearer token (từ option, từ khoá đã lưu đi qua apiKey, hoặc từ biến môi trường) thì client dùng httpBearerAuth và bỏ qua chữ ký SigV4. Không có thì SDK AWS tự đi chuỗi credential mặc định của nó.
  • Profile thắng khoá môi trường. Profile chọn qua luồng đăng nhập của Pi phải thắng cặp khoá IAM trong môi trường, mà chuỗi mặc định của SDK chỉ làm vậy khi credentials không được đặt; vì thế khoá IAM chỉ được gán tường minh khi không có profile tường minh (lỗi #6957).
  • Vùng. Vùng nhúng trong ARN của mô hình (inference profile) thắng tất cả, để một AWS_REGION đặt cho dịch vụ khác không phá request; rồi tới region, AWS_REGION, AWS_DEFAULT_REGION; cuối cùng là us-east-1 khi không có profile nào.
  • Endpoint tuỳ biến. Một baseUrl không phải endpoint chuẩn của Bedrock (VPC endpoint, proxy) luôn được giữ; endpoint chuẩn chỉ được ghim khi không có vùng hay profile cấu hình. AWS_BEDROCK_SKIP_AUTH=1 gửi credential giả cho proxy không cần xác thực; AWS_BEDROCK_FORCE_HTTP1=1 cho endpoint không nói HTTP/2.
  • Proxy. Từ v3.798.0 SDK dùng handler HTTP/2 không hỗ trợ agent, nên khi có biến proxy, adapter thay bằng NodeHttpHandler với HttpProxyAgent/HttpsProxyAgent (mục mạng).

Google Vertex AI

vertexAuth.resolve(): khoá API, hoặc ADC cộng project và location
pi/packages/ai/src/providers/google-vertex.tsdòng 64–89TypeScript
resolve: async ({ ctx, credential, signal }) => {	const env = async (name: string) => {		signal.throwIfAborted();		const value = await ctx.env(name);		signal.throwIfAborted();		return value;	};	const key = credential?.key ?? (await env("GOOGLE_CLOUD_API_KEY"));	if (key) return { auth: { apiKey: key }, source: credential?.key ? "stored credential" : "GOOGLE_CLOUD_API_KEY" };	const adcPath = credential?.env?.GOOGLE_APPLICATION_CREDENTIALS ?? (await env("GOOGLE_APPLICATION_CREDENTIALS"));	signal.throwIfAborted();	const hasCredentials = await ctx.fileExists(adcPath ?? VERTEX_ADC_PATH);	signal.throwIfAborted();	const project =		credential?.env?.GOOGLE_CLOUD_PROJECT ?? (await env("GOOGLE_CLOUD_PROJECT")) ?? (await env("GCLOUD_PROJECT"));	const location = credential?.env?.GOOGLE_CLOUD_LOCATION ?? (await env("GOOGLE_CLOUD_LOCATION"));	if (hasCredentials && project && location) {		return {			auth: {},			env: credential?.env,			source: credential ? "stored credential" : "gcloud application default credentials",		};	}	return undefined;},

Vertex nhận hoặc một khoá Google Cloud (GOOGLE_CLOUD_API_KEY), hoặc Application Default Credentials (ADC). Với ADC, provider chỉ xác nhận ba điều: có file credential (đường dẫn trong GOOGLE_APPLICATION_CREDENTIALS, hoặc file mặc định mà gcloud auth application-default login ghi), có project, có location. Adapter khi đó dựng new GoogleGenAI({ vertexai: true, project, location }), thêm googleAuthOptions.keyFilename nếu có file tài khoản dịch vụ, và để thư viện của Google tự lấy và làm mới token (google-vertex.ts:364). Khoá dạng giữ chỗ như <authenticated> bị bỏ qua thay vì gửi đi (google-vertex.ts:442).

Azure OpenAI và Microsoft Foundry

Mỗi người dùng Azure có một resource riêng, nên catalog không thể chứa URL. Bộ sinh catalog chép các mô hình OpenAI sang provider azure với baseUrl: "" (và DeepSeek V4 Pro với giá riêng của Azure), còn URL được tính ở từng request (generate-models.ts:3340). Thứ tự: azureBaseUrl trong option, AZURE_OPENAI_BASE_URL, azureResourceName hoặc AZURE_OPENAI_RESOURCE_NAME (thành https://<tên>.openai.azure.com/openai/v1), rồi mới tới model.baseUrl; URL gốc của *.openai.azure.com, *.cognitiveservices.azure.com, *.ai.azure.com được chuẩn hoá về /openai/v1 (azure-openai-config.ts:72). Mô hình OpenAI đi qua azure-openai-responses; mô hình Foundry đi qua Chat Completions, bọc như sau:

azureStreams(): điền endpoint và đổi tên deployment trước khi gửi
pi/packages/ai/src/providers/azure.tsdòng 14–42TypeScript
/** Send the deployment name as the request's model, keeping `model.id` as the catalog id. */function withDeploymentName<T extends StreamOptions>(model: Model<Api>, options: T | undefined): T | undefined {	const deploymentName = resolveDeploymentName(model, options);	if (deploymentName === model.id) return options;	return {		...options,		onPayload: async (payload, payloadModel) => {			const params = { ...(payload as object), model: deploymentName };			return (await options?.onPayload?.(params, payloadModel)) ?? params;		},	} as T;}/** * Resolve the Azure endpoint and deployment before dispatch, inside `lazyStream` so an * unconfigured endpoint errors on the stream instead of throwing out of `stream()`. */function azureStreams(streams: ProviderStreams): ProviderStreams {	return {		stream: (model, context, options) =>			lazyStream(model, async () =>				streams.stream(resolveAzureModel(model, options), context, withDeploymentName(model, options)),			),		streamSimple: (model, context, options) =>			lazyStream(model, async () =>				streams.streamSimple(resolveAzureModel(model, options), context, withDeploymentName(model, options)),			),	};}

Hai mẹo nhỏ đáng học. Tên deployment được thay vào payload qua onPayload, nên model.id vẫn là id trong catalog (để tính giá, hiển thị, khôi phục session) còn máy chủ nhận tên deployment; ánh xạ đến từ azureDeploymentName hoặc AZURE_OPENAI_DEPLOYMENT_NAME_MAP=id=deployment,.... Và việc tính endpoint nằm trong lazyStream(), nên thiếu cấu hình thành một sự kiện error trên luồng chứ không ném ra khỏi stream().

Anthropic workload identity federation

Từ 0.99.2, một máy chủ CI hay một pod không có khoá Anthropic vẫn gọi được API bằng cách đổi một identity token của nền tảng (OIDC) lấy token truy cập ngắn hạn (CHANGELOG 0.99.2). Provider xét nó cuối cùng, sau mọi khoá và ANTHROPIC_AUTH_TOKEN, và chỉ chuyển các id qua env vì chúng là cấu hình chứ không phải xác thực (anthropic.ts:49). Adapter dựng cấu hình federation cho SDK:

getAnthropicFederation(): cấu hình oidc_federation cho SDK Anthropic
pi/packages/ai/src/api/anthropic-messages.tsdòng 343–370TypeScript
/** * Workload identity federation config from the ANTHROPIC_* variables the * Anthropic SDK documents; the SDK performs the token exchange and refresh. * Only for the anthropic provider, since the exchange is an Anthropic API * endpoint, and only when no key or auth header was resolved. */function getAnthropicFederation(	model: Model<"anthropic-messages">,	apiKey: string | undefined,	headers: ProviderHeaders | undefined,	env: ProviderEnv | undefined,): AnthropicFederationConfig | undefined {	if (model.provider !== "anthropic" || hasRequestAuth(apiKey, headers)) return undefined;	const federationRuleId = getProviderEnvValue(ANTHROPIC_FEDERATION_RULE_ID_ENV, env);	const organizationId = getProviderEnvValue(ANTHROPIC_ORGANIZATION_ID_ENV, env);	const identityTokenFile = getProviderEnvValue(ANTHROPIC_IDENTITY_TOKEN_FILE_ENV, env);	if (!federationRuleId || !organizationId || !identityTokenFile) return undefined;	return {		organization_id: organizationId,		workspace_id: getProviderEnvValue(ANTHROPIC_WORKSPACE_ID_ENV, env),		authentication: {			type: "oidc_federation",			federation_rule_id: federationRuleId,			service_account_id: getProviderEnvValue(ANTHROPIC_SERVICE_ACCOUNT_ID_ENV, env),			identity_token: { source: "file", path: identityTokenFile },		},	};}

SDK của Anthropic thực hiện việc đổi token và tự làm mới, đọc lại file identity token mỗi lần, nên tài liệu nhắc giữ file đó luôn mới với phiên dài (docs/providers.md:69). Vì pi dựng một client cho mỗi request mà SDK cache token theo client, adapter giữ một client federation duy nhất cho mỗi cấu hình và nhân bản nó bằng withOptions(), cách dùng chung bộ cache token (anthropic-messages.ts:1088). Mọi client khác là PiAnthropic, lớp con tắt chuỗi tìm credential riêng của SDK: federation chỉ chạy khi chính pi đã quyết định dùng nó.

Cloudflare: REST, AI Gateway và AI binding trong Worker#

Cloudflare có hai provider, và cả hai gặp cùng một vấn đề với catalog: URL chứa id tài khoản (và id gateway) của từng người dùng. Catalog vì thế lưu URL có placeholder, ví dụ https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1 cho mô hình chat của Workers AI (api/cloudflare.ts), và lời giải chia làm hai nửa. Nửa thứ nhất là xác thực: resolve() lấy khoá, account id và gateway id, mỗi trường ưu tiên giá trị trong credential rồi mới tới môi trường, để một credential chỉ lưu khoá vẫn lấy được account id từ biến môi trường (cloudflare-auth.ts:10). Các id đi ra trong env của kết quả. Nửa thứ hai là một wrapper quanh API, thay placeholder bằng giá trị trong options.env ngay trước khi gửi:

cloudflareStreams(): điền id tài khoản và gateway vào URL lúc gửi
pi/packages/ai/src/providers/cloudflare-stream.tsdòng 6–28TypeScript
export function resolveCloudflareModel<TModel extends { baseUrl: string }>(	model: TModel,	env: ProviderEnv | undefined,): TModel {	if (!env) return model;	const baseUrl = model.baseUrl		.replaceAll(`{${CLOUDFLARE_ACCOUNT_ID}}`, env[CLOUDFLARE_ACCOUNT_ID] ?? `{${CLOUDFLARE_ACCOUNT_ID}}`)		.replaceAll(`{${CLOUDFLARE_GATEWAY_ID}}`, env[CLOUDFLARE_GATEWAY_ID] ?? `{${CLOUDFLARE_GATEWAY_ID}}`);	return baseUrl === model.baseUrl ? model : { ...model, baseUrl };}/** * Wrap an API implementation so Cloudflare account/gateway endpoint * placeholders materialize from the resolved provider env before dispatch. */export function cloudflareStreams(streams: ProviderStreams): ProviderStreams {	return {		stream: (model, context, options) =>			streams.stream(resolveCloudflareModel(model, options?.env), context, options),		streamSimple: (model, context, options) =>			streams.streamSimple(resolveCloudflareModel(model, options?.env), context, options),	};}

Thiết kế này giữ đúng ranh giới của ModelAuth: account id không phải xác thực, nên nó đi qua env, và chỉ phần API của provider biết URL được dựng thế nào. Changelog của 0.80.4, 0.80.7 và 0.80.8 ghi lại ba lần sửa lỗi khiến placeholder sống sót tới request và trả về 404, ví dụ khi credential lưu bằng /login chỉ có khoá (CHANGELOG 0.80.4).

Workers AI qua REST

Mô hình chat của cloudflare-workers-ai nói Chat Completions ở endpoint /ai/v1 tương thích OpenAI, xác thực bằng khoá Cloudflare như một Bearer thông thường. Classifier (Jev của TypeSafe, Clef của Cloudflare) đi qua API REST gốc: POST /accounts/{account}/ai/run với { model, input }, nhận về phong bì { success, result } của Cloudflare. Adapter cloudflare-workers-ai-system-one chỉ khác typesafe-system-one ở cách bọc và bóc phong bì đó, kể cả hai hình dạng kết quả khác nhau giữa mô hình bên thứ ba và mô hình do Cloudflare host (cloudflare-workers-ai-system-one.ts:17).

AI Gateway qua HTTPS

cloudflare-ai-gateway chọn passthrough theo hãng gốc: mô hình OpenAI đi tới .../{gateway}/openai bằng Responses, mô hình Anthropic tới .../anthropic bằng Messages (id được đổi dấu chấm thành gạch ngang vì Anthropic chỉ nhận dạng đó), mô hình workers-ai/* tới .../compat bằng Chat Completions (generate-models.ts:1988). Khoá Cloudflare xác thực Pi với gateway, không phải với hãng gốc:

cloudflareAIGatewayAuth(): khoá đi trong cf-aig-authorization, header của SDK bị xoá
pi/packages/ai/src/providers/cloudflare-auth.tsdòng 87–101TypeScript
resolve: async ({ ctx, credential, signal }) => {	const resolved = await resolveCloudflareEnv("ai-gateway", ctx, credential, signal);	if (!resolved) return undefined;	return {		auth: {			headers: {				"cf-aig-authorization": `Bearer ${resolved.apiKey}`,				Authorization: null,				"x-api-key": null,			},		},		env: resolved.env,		source: resolved.source,	};},

Giá trị null xoá header mà SDK tự đặt (Authorization của SDK openai, x-api-key của SDK Anthropic). Nếu để lại, gateway sẽ coi chúng là khoá của chính người dùng gửi cho hãng gốc (bring your own key) và dùng thay cho khoá đã lưu trong gateway. Việc xác thực với hãng gốc vì thế thuộc về gateway: tính tiền hợp nhất của Cloudflare, khoá lưu trong gateway, hoặc một header Authorization mà người dùng cấu hình trong models.json (docs/providers.md:212).

AI binding: khi code chạy bên trong một Worker

Gọi gateway qua HTTPS đòi một token API của Cloudflare, kể cả khi người gọi là một Worker nằm trong chính tài khoản đó. Một Worker có cách khác: binding env.AI có phương thức fetch() phục vụ passthrough của gateway ở https://workers-binding.ai/ai-gateway/gateways/{gateway}/{provider}/..., cùng hình dạng với URL HTTPS nhưng không có account id, vì kênh binding đã mang danh tính (cloudflare-ai-binding.ts:1). Phản hồi là định dạng gốc của hãng, nên mọi adapter chạy y như qua HTTPS. pi-ai chỉ thêm một hàm:

createAiBindingFetch(): một fetch đi qua env.AI
pi/packages/ai/src/api/cloudflare-ai-binding.tsdòng 42–45, 57, 80–90TypeScript
export interface AiBinding {	aiGatewayLogId: string | null;	fetch?(input: Request | string | URL, init?: RequestInit): Promise<Response>;}export const CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL = "cloudflare-gateway-binding";export function createAiBindingFetch(binding: AiBinding): FetchFunction {	// `fetch` is optional on the type, so its presence is checked here — early, rather than as a	// confusing failure on the first inference request.	if (typeof binding.fetch !== "function") {		throw new TypeError("createAiBindingFetch: the AI binding does not expose fetch()");	}	// Bound eagerly: `fetch` is a mutable property, so the narrowing above would not survive into	// the returned closure.	const bindingFetch = binding.fetch.bind(binding);	return (input, init) => bindingFetch(input, init);}

Cách dùng không đi qua provider dựng sẵn nào: host tự khai báo một mô hình có baseUrl trỏ vào route của binding, truyền fetch: createAiBindingFetch(env.AI) trong option, và đặt cf-aig-authorization: Bearer cloudflare-gateway-binding cùng Authorization: null, x-api-key: null. Giá trị "sentinel" kia chỉ để qua được bước kiểm tra "có xác thực" của adapter (anthropic-messages.ts:316); gateway bỏ nó đi với request qua binding. Hàm thay cho createGatewayBindingFetch() cũ từ 0.85.0: thay vì viết lại request, nó chuyển nguyên phương thức, header và body dạng luồng tới binding (CHANGELOG 0.85.0). Hai adapter Gemini từ chối options.fetch tuỳ biến, nên đường này dùng được với các adapter dựa trên SDK openai và Anthropic.

Gateway và aggregator#

Với pi-ai, một gateway chỉ là một provider có catalog chứa mô hình của nhiều hãng. Không có lớp "gateway" nào trong code. Cái làm chúng khác provider thường là năm việc nhỏ, mỗi việc một chỗ trong code: chọn API theo từng mô hình, cá nhân hoá catalog theo khoá, header ghi nhận ứng dụng, header định tuyến theo phiên, và tuỳ chọn định tuyến tới hãng gốc.

OpenRouter: hai API trong một catalog

OpenRouter phục vụ cả endpoint tương thích OpenAI lẫn endpoint tương thích Anthropic. Bộ sinh catalog cho mô hình anthropic/* (trừ biến thể :batch) đi đường Anthropic, còn mọi mô hình khác đi Chat Completions. Hệ quả là Claude qua OpenRouter dùng chung adapter với mọi mô hình Claude khác, kể cả điểm ngắt cache và suy nghĩ có chữ ký:

openrouter-catalog.ts: API và baseUrl chọn theo id mô hình
pi/packages/ai/scripts/openrouter-catalog.tsdòng 47–62TypeScript
const thinkingLevelMap = getOpenRouterThinkingLevelMap(model.reasoning);const useAnthropicMessages = /^anthropic\//.test(model.id) && !model.id.endsWith(":batch");chat.push({	type: "chat",	id: model.id,	name: model.name,	api: useAnthropicMessages ? "anthropic-messages" : "openai-completions",	baseUrl: useAnthropicMessages ? "https://openrouter.ai/api" : "https://openrouter.ai/api/v1",	provider: "openrouter",	reasoning: model.supported_parameters?.includes("reasoning") || false,	...(thinkingLevelMap && { thinkingLevelMap }),	input,	cost: openRouterCost(model.pricing),	contextWindow: model.top_provider?.context_length || model.context_length || 4096,	maxTokens: model.top_provider?.max_completion_tokens || 4096,});

Một mô hình anthropic/* của provider openrouter mà vẫn đi Chat Completions (biến thể :batch, hoặc một mục thêm vào openrouter trong models.json) vẫn được gắn cache_control kiểu Anthropic, vì detectCompat() đặt cacheControlFormat: "anthropic" cho nó (openai-completions.ts:1650). Trên đường Completions, mức suy nghĩ đi dạng reasoning: { effort } (thinkingFormat: "openrouter"). Trên cả hai đường, sessionId thành header x-session-id, để OpenRouter đưa các request cùng phiên về cùng nơi cho cache trúng (anthropic-messages.ts:218).

Catalog theo khoá

Một khoá OpenRouter có thể bị giới hạn bởi guardrail của tổ chức: chỉ một số mô hình, chỉ một số hãng gốc, với giá và giới hạn riêng. pi-ai có hàm đọc đúng danh sách đó:

personalizeOpenRouterCatalog(): lọc catalog theo GET /models/user
pi/packages/ai/src/providers/openrouter.tsdòng 24–35, 46–59TypeScript
export async function personalizeOpenRouterCatalog(	catalog: readonly AnyModel[],	context: RefreshModelsContext,): Promise<readonly AnyModel[]> {	const key = context.credential?.type === "oauth" ? context.credential.access : context.credential?.key;	if (!key) return catalog;	const response = await fetch(`${(context.baseUrl ?? OPENROUTER_BASE_URL).replace(/\/+$/, "")}/models/user`, {		headers: { accept: "application/json", authorization: `Bearer ${key}` },		signal: AbortSignal.any([context.signal, AbortSignal.timeout(OPENROUTER_USER_MODELS_TIMEOUT_MS)]),	});	if (response.status === 401 || response.status === 403 || response.status === 404) return catalog;	if (!response.ok) throw new Error(`OpenRouter model list request failed: ${response.status}`);	return catalog.flatMap((model): AnyModel[] => {		if (!isModelType(model, "chat")) return [model];		const entry = allowed.get(model.id);		if (!entry) return [];		return [			{				...model,				// `||`: OpenRouter reports a missing limit as absent, null or 0, like the generator's fallback.				contextWindow: entry.top_provider?.context_length || entry.context_length || model.contextWindow,				maxTokens: entry.top_provider?.max_completion_tokens || model.maxTokens,				cost: entry.pricing ? openRouterCost(entry.pricing) : model.cost,			},		];	});

Mô hình chat không có trong danh sách của khoá bị bỏ; mô hình còn lại nhận cửa sổ ngữ cảnh, output tối đa và giá theo khoá. Mô hình ảnh và classifier giữ nguyên. Request dùng baseUrl đang cấu hình, nên endpoint vùng như https://us.openrouter.ai/api/v1 trả về danh sách đã lọc theo vùng; endpoint từ chối khoá hoặc không có route này (một proxy chẳng hạn) thì catalog giữ nguyên. Coding agent bọc provider bằng withPersonalizedCatalog(), lưu danh sách đã cá nhân hoá vào models-store.json để dùng khi ngoại tuyến, ở chế độ pi -p và RPC (docs/providers.md:116). Giá của OpenRouter tính theo USD mỗi token dạng chuỗi; openRouterCost() đổi sang USD mỗi triệu token, biến giá theo độ dài prompt thành các bậc giá, và bỏ qua giá theo giờ trong ngày vì ModelCost không biểu diễn được (openrouter-api.ts:45).

Header ghi nhận ứng dụng (attribution)

OpenRouter có bảng xếp hạng ứng dụng, NVIDIA và OpenCode ghi nhận ứng dụng gọi tới. Header tương ứng do identity của request quyết định (chương pi-ai, danh tính của client). Một chi tiết quan trọng là cách nhận ra đích: theo id provider hoặc theo host của baseUrl, nên một provider tự định nghĩa trỏ vào openrouter.ai cũng gửi đúng header.

Nhận ra gateway theo id hoặc theo host
pi/packages/ai/src/utils/identity.tsdòng 92–104TypeScript
function isOpenRouter(target: ClientIdentityTarget): boolean {	return target.provider === "openrouter" || target.baseUrl.includes("openrouter.ai");}function isNvidiaNim(target: ClientIdentityTarget): boolean {	return target.provider === "nvidia" || matchesHost(target.baseUrl, NVIDIA_NIM_HOST);}function isOpenCode(target: ClientIdentityTarget): boolean {	return (		target.provider === "opencode" || target.provider === "opencode-go" || matchesHost(target.baseUrl, OPENCODE_HOST)	);}

Coding agent không gửi attribution vô điều kiện. Nó gắn PI_ATTRIBUTION chỉ khi install telemetry đang bật, đọc lại ở mỗi request:

getClientIdentity(): attribution đi theo cài đặt telemetry
pi/packages/coding-agent/src/core/client-identity.tsdòng 6–16TypeScript
/** * How pi's requests identify it to providers: a detailed `User-Agent` with pi's version, and attribution to pi as a * CLI agent while install telemetry is enabled. */export function getClientIdentity(settingsManager: SettingsManager): ClientIdentity {	return {		userAgent: "detailed",		version: VERSION,		attribution: isInstallTelemetryEnabled(settingsManager) ? PI_ATTRIBUTION : null,	};}

enableInstallTelemetry: false trong settings hoặc PI_TELEMETRY=0 tắt cả báo cáo cài đặt lẫn header ghi nhận (docs/settings.md:170); User-Agent chi tiết vẫn được gửi.

Vercel AI Gateway, OpenCode, định tuyến tới hãng gốc

  • Vercel AI Gateway có một endpoint tương thích Anthropic cho mọi hãng, nên provider chỉ cần một API cho toàn bộ catalog, và đánh dấu allowEmptySignature cho các mô hình của nó (generate-models.ts:1432). Classifier Jev đi qua endpoint System One của gateway ở /typesafe/v1.
  • OpenCode Zen và Go chọn API theo gói SDK mà models.dev ghi cho từng mô hình (@ai-sdk/anthropic thành Messages, @ai-sdk/openai thành Responses, @ai-sdk/google thành Gemini, còn lại là Completions), có sửa tay những chỗ models.dev ghi sai (generate-models.ts:2234). Header x-opencode-session là bắt buộc để OpenCode định tuyến hội thoại; nó được chèn ở hai tầng: wrapper của provider dựng sẵn, và tầng runtime (Models.applyAuth() của pi-ai, prepareRequest() của coding agent), tầng sau phủ cả provider tự định nghĩa trỏ vào opencode.ai (identity.ts:126).
  • Định tuyến tới hãng gốc là cờ compat: openRouterRouting thành trường provider của request (danh sách hãng, cho phép fallback, chính sách thu thập dữ liệu...), vercelGatewayRouting thành providerOptions.gateway với only và order (openai-completions.ts:993). Đặt chúng trong models.json bằng modelOverrides hoặc compat cấp provider.

Radius: gateway nói giao thức của Pi

Radius là gateway do Earendil làm cho Pi, có kiểm soát theo tổ chức và thống kê (docs/providers.md:104). Nó nói pi-messages, nên client không cần SDK của hãng nào. Provider được viết tay vì catalog của nó khác mọi provider khác: chủ tổ chức có thể tắt mô hình, nên một khi đã biết catalog của tài khoản (từ GET /v1/config của gateway), catalog đó thay thế bản dựng sẵn thay vì phủ lên nó; bản dựng sẵn chỉ dùng khi chưa có gì (radius.ts:21). Một gateway Radius riêng được khai báo trong models.json bằng "oauth": "radius" cộng baseUrl; coding agent dựng một radiusProvider() mới cho id đó, với đăng nhập OAuth và catalog trỏ vào gateway ấy (model-runtime.ts:271).

Một mô hình, nhiều đường

Vì gateway và nền tảng suy luận bán lại mô hình của hãng khác, cùng một mô hình gốc thường có nhiều mục trong catalog, mỗi mục một id, một API, một giá và một giới hạn. Lấy Kimi K2.6 của Moonshot: bảng mô hình mặc định của coding agent đã cho thấy ít nhất bảy đường (model-resolver.ts:20):

Tham chiếu provider/idĐi quaCredential
moonshotai/kimi-k2.6API của hãng, api.moonshot.aiMOONSHOT_API_KEY
moonshotai-cn/kimi-k2.6API của hãng ở Trung Quốc, api.moonshot.cnMOONSHOT_API_KEY
openrouter/moonshotai/kimi-k2.6OpenRouter, rồi một hãng gốc do OpenRouter chọnOPENROUTER_API_KEY
huggingface/moonshotai/Kimi-K2.6Router của Hugging FaceHF_TOKEN
opencode/kimi-k2.6OpenCode ZenOPENCODE_API_KEY
cloudflare-workers-ai/@cf/moonshotai/kimi-k2.6Workers AI của Cloudflarekhoá và account id Cloudflare
cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.6AI Gateway, rồi Workers AIkhoá, account id, gateway id

Với Claude, các đường khác nhau cả ở API: anthropic gọi thẳng bằng Messages, amazon-bedrock bằng ConverseStream (id dạng us.anthropic.claude-...), còn github-copilot, openrouter, vercel-ai-gateway và cloudflare-ai-gateway đều nói Messages tới endpoint tương thích của mình, theo các luật chọn API đã thấy ở trên. Session của Pi lưu cả provider lẫn model, nên đổi đường giữa hội thoại là một lần chuyển giao giữa hai mô hình khác nhau theo nghĩa của transformMessages(): suy nghĩ của đường cũ bị bỏ (chương pi-ai).

Mô hình cục bộ và tự host#

Có hai đường cho mô hình chạy trên máy của mình. Với llama.cpp, coding agent có sẵn một provider biết router của nó. Với mọi máy chủ khác nói một API có sẵn (Ollama, LM Studio, vLLM, SGLang, một proxy), người dùng khai báo nó trong models.json (docs/models.md:37).

Extension dựng sẵn llama.cpp

Provider này không nằm trong pi-ai. Nó là một trong bốn extension dựng sẵn của coding agent (extensions/index.ts:7), đăng ký một Provider hoàn chỉnh qua pi.registerProvider() và một lệnh /llama để nạp, gỡ, tải mô hình về router (llama/index.ts:42). Router của llama.cpp phát hiện các file GGUF trong một thư mục và nạp chúng theo yêu cầu, nên danh mục mô hình của provider là danh mục động của router chứ không phải một catalog sinh sẵn (docs/llama-cpp.md).

Xác thực của llama.cpp: URL máy chủ là cấu hình, khoá là tuỳ chọn
pi/packages/coding-agent/src/extensions/llama/provider.tsdòng 187–189, 204–226TypeScript
apiKey: {	name: "llama.cpp server",	login: async (interaction): Promise<ApiKeyCredential> => {		await new LlamaClient(serverUrl, apiKey || undefined).list({ signal: interaction.signal });		return {			type: "api_key",			key: apiKey || undefined,			env: { LLAMA_BASE_URL: serverUrl },		};	},	check: async ({ ctx, credential }) => {		const serverUrl = await resolveServerUrl(ctx, credential);		return serverUrl			? { type: "api_key", source: credential ? "stored credential" : "LLAMA_BASE_URL" }			: undefined;	},	resolve: async ({ ctx, credential }): Promise<AuthResult | undefined> => {		const serverUrl = await resolveServerUrl(ctx, credential);		if (!serverUrl) return undefined;		const apiKey = credential?.key ?? (await ctx.env("LLAMA_API_KEY")) ?? "local";		return {			auth: { apiKey, baseUrl: llamaInferenceUrl(serverUrl) },			env: { ...credential?.env, LLAMA_BASE_URL: serverUrl },			source: credential ? "stored credential" : "LLAMA_BASE_URL",		};	},
  • /login llama.cpp hỏi URL và một khoá tuỳ chọn, gọi thử GET /models để chắc máy chủ ở chế độ router, rồi lưu URL vào env.LLAMA_BASE_URL của credential. Không đăng nhập thì LLAMA_BASE_URL và LLAMA_API_KEY trong môi trường cũng đủ.
  • resolve() trả cả baseUrl, nên URL thật của request được quyết định lúc phân giải xác thực, cùng cơ chế mà Copilot dùng cho endpoint theo tài khoản. Khoá mặc định là chuỗi "local", vì SDK openai không chấp nhận khoá rỗng.
  • check() chỉ xem đã có URL hay chưa, không gọi mạng, nên việc liệt kê mô hình dùng được không chạm tới máy chủ.

refreshModels() đọc danh sách của router. Mô hình chọn được là mô hình đang nạp, đang ngủ (request sẽ đánh thức nó), hoặc preset chưa nạp khi router bật tự nạp. Với mỗi mô hình đang nạp, provider gọi thêm /props để đọc chat template và cấu hình thật của nó:

toPiModel(): một mô hình của router thành một mô hình Completions
pi/packages/coding-agent/src/extensions/llama/provider.tsdòng 127–158TypeScript
function toPiModel(	model: LlamaModelInfo,	serverUrl: string,	props?: LlamaServerProps,	cachedContextWindow?: number,): Model<"openai-completions"> {	const contextWindow = contextWindowOf(model, cachedContextWindow);	const reasoning = props?.chat_template?.includes("enable_thinking") === true;	return {		id: model.id,		name: model.id,		api: "openai-completions",		provider: LLAMA_PROVIDER_ID,		baseUrl: llamaInferenceUrl(serverUrl),		reasoning,		...(reasoning && {			thinkingLevelMap: { off: "off", minimal: null, low: null, medium: "medium", high: null, xhigh: null },		}),		input: model.architecture?.input_modalities?.includes("image") ? ["text", "image"] : ["text"],		cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },		contextWindow,		maxTokens: contextWindow,		compat: {			supportsStore: false,			supportsDeveloperRole: false,			supportsReasoningEffort: false,			supportsUsageInStreaming: true,			supportsStrictMode: false,			maxTokensField: "max_tokens",			...(reasoning && { thinkingFormat: "qwen-chat-template" }),		},	};

Mô hình được coi là có suy nghĩ khi chat template chứa enable_thinking, và khi đó mức suy nghĩ đi qua chat_template_kwargs (thinkingFormat: "qwen-chat-template") với đúng hai mức off và medium. Cửa sổ ngữ cảnh lấy theo thứ tự: n_ctx lúc chạy, cờ --ctx-size trong tham số của mô hình, giá trị đã cache, n_ctx_train, cuối cùng là 128.000 (provider.ts:71). Các cờ compat đều là khác biệt đã kiểm chứng của llama-server: không có store, không có vai developer, không có reasoning_effort, dùng max_tokens. Mỗi mô hình còn được liệt kê làm classifier: mô hình quyết định (router báo decisions trong output_modalities, từ llama.cpp 0.6.0) đi qua endpoint System One gốc của llama.cpp, mô hình chat đi qua llama-cpp-classify, đọc xác suất của nhãn đáp án ở token kế tiếp (docs/llama-cpp.md:89).

Phần stream của provider gọi stream và streamSimple của @earendil-works/pi-ai/compat, tức API toàn cục chọn adapter theo model.api (provider.ts:288). Lệnh /llama chỉ có trong TUI; sau mỗi lần nạp, gỡ hay tải, nó làm mới catalog của riêng provider này với allowNetwork: true, kể cả khi PI_OFFLINE được đặt, vì người dùng vừa chủ động liên lạc với máy chủ (llama/index.ts:46). Không muốn provider này thì tắt nó bằng "extensions": ["-builtin:llama.cpp"]. Tài liệu nvidia-rtx-llama-cpp.md hướng dẫn đổi một lệnh llama-server tối ưu cho GPU NVIDIA thành một preset của router.

Ollama, LM Studio, vLLM và máy chủ tương thích khác

Những máy chủ này đi qua models.json với "api": "openai-completions", một baseUrl và danh sách mô hình khai báo tay. Không có bước phát hiện mô hình: muốn danh sách động như llama.cpp thì phải viết một extension có refreshModels. Ví dụ trong tài liệu dùng một khoá giả "ollama": Ollama bỏ qua nó, nhưng không có khoá thì provider bị coi là chưa cấu hình và mô hình không hiện trong /model (docs/models.md:45). Lần theo cách khai báo đó tới request ở trace thứ hai.

Với một máy chủ lạ, detectCompat() không nhận ra gì và trả về mặc định của chính OpenAI: gửi store: false, vai developer cho mô hình có suy nghĩ, reasoning_effort, max_completion_tokens. README ghi rằng Ollama, vLLM và SGLang thường cần tắt supportsDeveloperRole và supportsReasoningEffort (README). Các cờ dành cho máy chủ tự host gồm: thinkingFormat "qwen", "qwen-chat-template" hay "chat-template" với chatTemplateKwargs chứa biến $var do Pi điền; thinkingTokenBudgetField để giới hạn token suy nghĩ (thinking_token_budget của vLLM, thinking_budget của Qwen và SGLang, thinking_budget_tokens của llama.cpp); vllmPriority cho bộ lập lịch ưu tiên của vLLM (docs/models-configuration.md:123). Tài liệu nhấn mạnh một nguyên tắc: chỉ bật cờ compat cho khác biệt đã kiểm chứng, không bật chỉ vì máy chủ quảng cáo "tương thích OpenAI" (docs/models.md:140).

models.json và cách ghép với catalog dựng sẵn#

<agent-dir>/models.json là nơi người dùng sửa thế giới provider mà không viết code. Mỗi khoá dưới providers là một id provider: id của provider dựng sẵn thì sửa provider đó, id khác thì tạo provider mới, khi ấy cần baseUrl và một api ở cấp provider hoặc cấp mô hình (docs/models-configuration.md:9). Lược đồ viết bằng TypeBox, ghép lại các lược đồ mô hình và compat của pi-ai:

ProviderConfigSchema: mọi trường của một provider trong models.json
pi/packages/coding-agent/src/core/model-config.tsdòng 89–115TypeScript
const ProviderConfigSchema = Type.Object({	name: Type.Optional(Type.String({ minLength: 1, description: "Display name." })),	baseUrl: Type.Optional(		Type.String({ minLength: 1, description: "API base URL. Also applies to the provider's built-in models." }),	),	apiKey: Type.Optional(		Type.String({			minLength: 1,			description: 'Literal key, "$NAME" environment interpolation, or "!command" output.',		}),	),	api: Type.Optional(		Type.String({			minLength: 1,			description:				'API for the provider\'s models, such as "openai-completions", "openai-responses", "anthropic-messages", or "google-generative-ai".',		}),	),	oauth: Type.Optional(Type.Literal("radius", { description: "OAuth login flow. Requires baseUrl." })),	headers: Type.Optional(HeadersSchema),	compat: Type.Optional(ProviderCompatSchema),	authHeader: Type.Optional(		Type.Boolean({ default: false, description: "Whether to send the resolved API key as Authorization: Bearer." }),	),	models: Type.Optional(Type.Array(ModelDefinitionSchema)),	modelOverrides: Type.Optional(Type.Record(Type.String(), ModelOverrideSchema)),});

ModelConfig.load() bỏ comment và BOM, kiểm tra cả file bằng validator đã biên dịch, rồi đóng băng sâu kết quả. File hỏng không làm Pi dừng: cấu hình rỗng được dùng, lỗi được giữ lại để hiện cho người dùng (model-config.ts:168). File được đọc lại ở mỗi ModelRuntime.refresh(), ví dụ mỗi lần mở /model, nên sửa xong không cần khởi động lại.

Ghép theo từng provider

Nguyên tắc đầu tiên của ModelRuntime: không có lớp phủ thì dùng nguyên provider gốc, để đăng nhập, xác thực và stream của nó chạy đúng như pi-ai định nghĩa.

composeProvider(): native provider của extension, rồi provider dựng sẵn, rồi mới ghép
pi/packages/coding-agent/src/core/model-runtime.tsdòng 309–325TypeScript
private composeProvider(providerId: string): Provider | undefined {	const base = this.nativeExtensionProviders.get(providerId) ?? this.builtins.get(providerId);	const extension = this.extensionProviders.get(providerId);	if (!this.config.getProvider(providerId) && !extension) {		// No overlays: use the builtin untouched so its auth/login/stream behavior is exact.		this.compositionErrors.delete(providerId);		return base;	}	try {		const provider = composeModelProvider(providerId, base, this.config, extension);		this.compositionErrors.delete(providerId);		return provider;	} catch (error) {		this.compositionErrors.set(providerId, error instanceof Error ? error.message : String(error));		return base;	}}

Ghép thất bại (ví dụ một mô hình mới thiếu api) thì lỗi được ghi theo provider và provider gốc vẫn chạy. Khi có lớp phủ, composeModelProvider() dựng một provider mới quanh provider gốc. Danh sách mô hình của nó được tính lại ở mỗi lần đọc, theo thứ tự: mô hình gốc, models.json, mô hình của extension, phép chiếu OAuth cũ của extension, cuối cùng là modelOverrides:

applyModelsJson(): baseUrl và compat phủ lên mô hình gốc, models thêm hoặc thay theo id
pi/packages/coding-agent/src/core/provider-composer.tsdòng 327–343TypeScript
const models: AnyModel[] = baseModels.map((model) => {	const baseUrl = config.oauth === "radius" ? model.baseUrl : (config.baseUrl ?? model.baseUrl);	if (isModelType(model, "chat")) return { ...model, baseUrl, compat: mergeCompat(model.compat, config.compat) };	// Classifier APIs read their own compat fields, such as how a System One service takes images.	if (isModelType(model, "classifier") && config.compat) {		return { ...model, baseUrl, compat: { ...model.compat, ...config.compat } };	}	return { ...model, baseUrl };});for (const definition of config.models ?? []) {	const existingIndex = models.findIndex((model) => isModelType(model, "chat") && model.id === definition.id);	const defaults = findModelDefaults(models, definition.id, definition.api ?? config.api);	const model = modelFromJson(providerId, definition, config, defaults);	if (existingIndex >= 0) models[existingIndex] = model;	else models.push(model);}return models;
  • baseUrl cấp provider áp lên mọi mô hình gốc của provider, trừ khi là gateway Radius. Đây là cách đi qua một proxy mà vẫn giữ catalog và xác thực dựng sẵn: { "anthropic": { "baseUrl": "https://proxy.example.com/v1" } } (docs/models.md:69).
  • compat cấp provider gộp vào mọi mô hình chat (bốn cờ dạng object như openRouterRouting được gộp từng trường), và vào classifier, nơi các cờ System One sống.
  • Một mục models trùng id thì thay mô hình gốc, khác id thì được thêm. Trường bỏ trống lấy mặc định của modelFromJson(): cửa sổ 128.000 token, output 16.384, giá bằng 0, input chỉ chữ, không suy nghĩ (provider-composer.ts:212). api và baseUrl nếu thiếu thì lấy từ một mô hình gốc tương tự: cùng id, hoặc cùng API, hoặc mô hình Completions đầu tiên, cuối cùng là mô hình chat đầu tiên (provider-composer.ts:253).
  • modelOverrides là lớp trên cùng: sửa siêu dữ liệu của một mô hình có sẵn mà không thay danh sách; object như cost được gộp từng trường; id không tồn tại bị bỏ qua.

Phần stream của provider ghép chọn adapter theo ba bậc:

streamWith(): stream của extension, rồi của provider gốc, rồi registry toàn cục
pi/packages/coding-agent/src/core/provider-composer.tsdòng 587–608TypeScript
const supportsBaseApi = (model: Model<Api>) => base?.getModels().some((entry) => entry.api === model.api) ?? false;const streamWith = (	model: Model<Api>,	context: TranscriptContext,	options: StreamOptions | undefined,	simple: boolean,): AssistantMessageEventStream =>	lazyStream(model, async () => {		if (extension?.streamSimple && model.api === extension.api) {			return extension.streamSimple(model, context, options as SimpleStreamOptions);		}		if (base && supportsBaseApi(model)) {			return simple				? base.streamSimple(model, context, options as SimpleStreamOptions)				: base.stream(model, context, options);		}		const api = getApiProvider(model.api);		if (!api) throw new Error(`No API provider registered for api: ${model.api}`);		return simple			? api.streamSimple(model, context, options as SimpleStreamOptions)			: api.stream(model, context, options);	});

Bậc thứ ba cho phép một mô hình trong models.json dùng một API mà provider gốc không có. Ví dụ, thêm vào provider azure một mô hình Foundry với "api": "openai-completions" là trường hợp tài liệu Azure mô tả (docs/providers.md:161); một provider hoàn toàn mới thì không có provider gốc, nên luôn đi bậc này.

Giá trị lấy từ môi trường hay từ lệnh

apiKey và mọi giá trị header trong models.json có cùng cú pháp: chữ thường là giá trị thật; $TÊN hoặc ${TÊN} là biến môi trường, chen được giữa chữ khác ("Bearer $TOKEN"); !lệnh lấy stdout của lệnh shell; $$ và $! cho ký tự $ và ! thật (resolve-config-value.ts:138). Khác với auth.json, giá trị của models.json được phân giải ở mỗi request và không cache, qua resolveConfigValueOrThrow(); biến thiếu thì request thất bại với thông báo nêu đúng tên biến (resolve-config-value.ts:229). Biến được tra trong env của credential trước rồi mới tới môi trường của tiến trình, nên một header "X-Account: $CLOUDFLARE_ACCOUNT_ID" nhận được giá trị lưu trong auth.json.

Các lớp catalog và models-store.json

Trước khi models.json được ghép, mỗi provider dựng sẵn đã được bọc một lớp catalog: withRemoteCatalog() cho mọi provider trừ Radius và OpenRouter, withPersonalizedCatalog() cho OpenRouter:

ModelRuntime.create(): kho credential, models.json, models-store.json và lớp catalog
pi/packages/coding-agent/src/core/model-runtime.tsdòng 218–241TypeScript
static async create(options: CreateModelRuntimeOptions = {}): Promise<ModelRuntime> {	const credentials = new RuntimeCredentials(options.credentials ?? DefaultAuthStorage.create(options.authPath));	const modelsPath =		options.modelsPath === null ? undefined : (options.modelsPath ?? join(getAgentDir(), "models.json"));	const config = await ModelConfig.load(modelsPath);	const modelsStore =		options.modelsStore ??		(modelsPath			? new FileModelsStore(options.modelsStorePath ?? join(dirname(modelsPath), "models-store.json"))			: new InMemoryCodingAgentModelsStore());	const builtinModelDataGeneratedAt = builtinProviderCatalog.getBuiltinModelDataGeneratedAt();	const providers = builtinProviderCatalog.builtinProviders().map((provider) => {		if (provider.id === "radius") return provider;		// OpenRouter narrows the catalog to the models, limits and prices of the user's key.		if (provider.id === "openrouter") {			return withPersonalizedCatalog(				provider,				personalizeOpenRouterCatalog,				options.catalogBaseUrl,				builtinModelDataGeneratedAt,			);		}		return withRemoteCatalog(provider, options.catalogBaseUrl, builtinModelDataGeneratedAt);	});

Cơ chế của lớp phủ pi.dev (kiểm tra tối đa bốn giờ một lần, ETag, chỉ dùng khi mới hơn dữ liệu đóng gói) đã ở chương pi-ai. Mọi catalog động được lưu chung trong models-store.json cạnh models.json: lớp phủ pi.dev, danh sách đã cá nhân hoá của OpenRouter, catalog của llama.cpp và của Radius. FileModelsStore dùng lại đúng backend có khoá file của auth.json (models-store.ts:46). Lúc khởi động, runtime chỉ khôi phục các catalog này từ đĩa, không gọi mạng (allowModelNetwork mặc định là false), để Pi mở nhanh và chạy được khi ngoại tuyến; chế độ interactive làm mới qua mạng ở nền, và PI_OFFLINE tắt hẳn việc đó (model-runtime.ts:248).

Provider do extension đăng ký#

Khi một dịch vụ cần đăng nhập riêng, danh mục động hay một giao thức chưa có, provider được viết trong một extension. Hai dạng đăng ký, thời điểm áp dụng và ngữ nghĩa "có models thì thay, chỉ có baseUrl thì giữ" đã được trình bày ở chương Extensions. Ở đây là vị trí của chúng trong chuỗi ghép đã thấy ở mục trước:

  • Native provider, pi.registerProvider(provider): một Provider hoàn chỉnh của pi-ai. Nó trở thành provider gốc của id đó, thay provider dựng sẵn cùng id, và models.json vẫn ghép được lên trên nó (model-runtime.ts:899). llama.cpp đi đường này.
  • ProviderConfig theo tên, pi.registerProvider("id", config): một lớp nằm sau models.json trong getAllModels(). Đăng ký lại cùng tên thì gộp các trường đã định nghĩa lên lần trước. OAuth kiểu cũ (login(callbacks), refreshToken, getApiKey) được adaptOAuth() dịch sang OAuthAuth, nối các callback cũ onAuth, onDeviceCode, onPrompt vào notify() và prompt() (provider-composer.ts:358). Khai báo streamSimple thì phải khai báo api, vì đó là khoá để streamWith() nhận ra mô hình nào đi stream tự viết.

Ủy quyền cho adapter có sẵn

Tài liệu khuyên dùng lại adapter của pi-ai bất cứ khi nào giao thức khớp, vì như vậy giữ được việc đổi message, xử lý tool, tính usage, huỷ và compat (docs/custom-provider.md:162). Ví dụ GitLab Duo làm đúng như thế: nó tự lo đăng nhập GitLab và đổi token GitLab lấy một token truy cập trực tiếp ngắn hạn của AI Gateway của GitLab, rồi giao phần còn lại cho adapter Anthropic hoặc OpenAI Responses:

GitLab Duo: xác thực riêng, stream của pi-ai
const gitlabAccessToken = options?.apiKey;if (!gitlabAccessToken) throw new Error("No GitLab access token. Run /login gitlab-duo or set GITLAB_TOKEN");const cfg = MODEL_MAP.get(model.id);if (!cfg) throw new Error(`Unknown model: ${model.id}`);const directAccess = await getDirectAccessToken(gitlabAccessToken);const modelWithBaseUrl = { ...model, baseUrl: cfg.baseUrl };const headers = { ...directAccess.headers, Authorization: `Bearer ${directAccess.token}` };const streamOptions = { ...options, apiKey: "gitlab-duo", headers };const innerStream =	cfg.backend === "anthropic"		? anthropicMessagesApi().streamSimple(				{					...(modelWithBaseUrl as Model<"anthropic-messages">),					compat: {						...(modelWithBaseUrl as Model<"anthropic-messages">).compat,						forceAdaptiveThinking: true,					},				},				context,				streamOptions,			)		: openAIResponsesApi().streamSimple(				modelWithBaseUrl as Model<"openai-responses">,				context,				streamOptions,			);for await (const event of innerStream) stream.push(event);

Ba điều đáng học từ đoạn này. Credential mà Pi phân giải (token GitLab, từ /login gitlab-duo hoặc $GITLAB_TOKEN) chỉ là nguyên liệu; credential thật của request là token gateway, đi trong header. apiKey: "gitlab-duo" là giá trị giữ chỗ để qua bước kiểm tra "có xác thực" của adapter, vì header Authorization đã có. Và mô hình được sao ra với baseUrl của đúng backend trước khi giao, nên một provider có thể trộn mô hình của hai giao thức mà không cần bảng API.

Stream tự viết

Ví dụ custom-provider-anthropic tự hiện thực cả luồng bằng SDK Anthropic, dưới một API tự đặt custom-anthropic-api. Hợp đồng mà một stream tự viết phải giữ được tài liệu liệt kê thành sáu bước: tạo message với stopReason: "pending" và usage bằng 0, phát start sau khi request đã được chấp nhận, phát các khối cân bằng, chốt usage và chi phí, phát đúng một sự kiện cuối, đổi việc huỷ thành aborted (docs/custom-provider.md:172). Đây chính là giao thức luồng của pi-ai, cộng thêm ba móc đo đạc onPayload, onResponse, onProviderStreamEvent.

Mạng: proxy, base URL và fetch tuỳ biến#

Proxy

Coding agent cài một dispatcher toàn cục của undici ngay khi khởi động, nên mọi lời gọi fetch trong tiến trình, kể cả của SDK openai và Anthropic, đi qua nó:

http-dispatcher.ts: cài đặt httpProxy thành biến môi trường, EnvHttpProxyAgent làm dispatcher
pi/packages/coding-agent/src/core/http-dispatcher.tsdòng 45–50, 81–100TypeScript
export function applyHttpProxySettings(httpProxy: string | undefined): void {	const proxy = httpProxy?.trim();	if (!proxy) return;	process.env.HTTP_PROXY ??= proxy;	process.env.HTTPS_PROXY ??= proxy;}export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS): void {	const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs);	if (normalizedTimeoutMs === undefined) {		throw new Error(`Invalid HTTP idle timeout: ${String(timeoutMs)}`);	}	const dispatcher = withUndiciErrorListener(		new undici.EnvHttpProxyAgent({			allowH2: false,			// Keep HTTP origins on CONNECT tunnels as they were before Undici 8.7.			proxyTunnel: true,			bodyTimeout: normalizedTimeoutMs,			connect: {				autoSelectFamilyAttemptTimeout: DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS,			},			headersTimeout: normalizedTimeoutMs,			clientFactory: createUndiciClient,			factory: createUndiciOriginDispatcher,		}),	);	undici.setGlobalDispatcher(dispatcher);

EnvHttpProxyAgent đọc HTTP_PROXY, HTTPS_PROXY và NO_PROXY. Cài đặt httpProxy (chỉ đặt được trong settings của agent-directory) được chép vào hai biến đó bằng ??=, nên biến môi trường đã có thì thắng (docs/settings.md:125). Cùng dispatcher đặt thời gian chờ header và body theo httpIdleTimeoutMs (mặc định năm phút, 0 là tắt), và tắt HTTP/2.

Có hai chỗ không đi qua fetch, và pi-ai tự lo proxy ở đó bằng resolveHttpProxyUrlForTarget(): đọc http_proxy, https_proxy, all_proxy (chữ thường hoặc hoa, cả trong options.env), so khớp no_proxy theo miền và cổng, và từ chối proxy SOCKS hay PAC (node-http-proxy.ts:118). Chỗ thứ nhất là Bedrock, vì SDK AWS dùng handler HTTP riêng (mục đám mây ở trên). Chỗ thứ hai là WebSocket của Codex khi chạy trên Bun, vì WebSocket của Bun bỏ qua biến proxy (openai-codex-responses.ts:1016).

Base URL

URL của một request là model.baseUrl, nhưng giá trị đó có thể bị thay ở năm chỗ, theo thứ tự từ cấu hình tới lúc gửi: baseUrl của mục mô hình trong models.json; baseUrl cấp provider trong models.json hay trong registerProvider(), áp cho mọi mô hình của provider; baseUrl mà xác thực trả về (Copilot, llama.cpp), được applyAuth() hay prepareRequest() chép đè vào mô hình; wrapper của provider (placeholder của Cloudflare, endpoint của Azure); và cuối cùng là logic riêng của adapter, ví dụ Bedrock chỉ ghim endpoint chuẩn khi chưa cấu hình vùng, Vertex bỏ qua URL còn chứa {location}.

fetch tuỳ biến

options.fetch thay hàm fetch cho một request: hữu ích để chạy qua binding của Cloudflare, để ghi log, hay trong test. Adapter dựa trên SDK openai và Anthropic, adapter Mistral, pi-messages và các classifier truyền nó xuống. Hai adapter Gemini từ chối thẳng một fetch khác globalThis.fetch thay vì lặng lẽ bỏ qua nó, và Bedrock đi qua handler của SDK AWS nên không có khái niệm này.

Phía Pi Durable: host tự chọn provider#

Pi Durable (thử nghiệm) không có auth.json, models.json hay lớp phủ pi.dev nào. Harness nhận một Models, hoặc một hàm trả về Models, do host dựng (chương pi-ai, HarnessOptions). Host chọn provider một cách tường minh:

README của Pi Durable: đúng một provider, chọn bằng tay
pi/packages/durable/README.mddòng 49–60TypeScript
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";import { createModels } from "@earendil-works/pi-ai/models";import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";import { AssistantEntry, createRegistry, Harness, MemoryStorage } from "@earendil-works/pi-durable";const context = BACKGROUND_CONTEXT;const models = createModels();models.setProvider(openaiProvider()); // reads OPENAI_API_KEYconst harness = await Harness.open(new MemoryStorage(), { models, registry: createRegistry() }, context);const root = await harness.root(context, { agent: { model: { provider: "openai", modelId: "gpt-6-sol" } } });

Đường import @earendil-works/pi-ai/models chỉ có runtime, không TypeBox, không catalog, không SDK; providers/openai chỉ kéo theo catalog của OpenAI và lazy wrapper của nó. Một Worker hay một ứng dụng trình duyệt vì thế chỉ trả giá cho đúng provider nó dùng. Xác thực ở đây là của pi-ai thuần: không truyền credentials thì kho credential là kho trong bộ nhớ, và khoá đến từ biến môi trường qua AuthContext mặc định; host muốn lưu token OAuth thì tiêm một CredentialStore bền của mình.

Conversation không lưu object mô hình mà lưu một tham chiếu { provider, modelId } (harness/types.ts:50), phân giải lại qua runtime.models.getModel() ở mỗi lượt sinh; tham chiếu không còn phân giải được thì input của lượt chạy được kết thúc là unanswered với lý do không có mô hình (generation.ts:218). Nhờ vậy host đổi được cấu hình provider (thêm khoá, đổi sang gateway) mà không phải sửa dữ liệu đã lưu. HarnessOptions.identity gộp lên DEFAULT_HARNESS_IDENTITY, tức attribution của pi theo mặc định; host đặt attribution của ứng dụng mình hoặc { attribution: null } (README).

Mô hình ảnh và classifier qua cùng provider#

Mô hình ảnh và classifier dùng chung provider, xác thực và catalog với mô hình chat; chỉ khác type và bảng thao tác (images, classifiers) trong createProvider(). Một OPENROUTER_API_KEY vì vậy phục vụ cả ba loại. Bản đồ ở v1.4.0:

LoạiAPIProvider
Sinh ảnhopenrouter-imagesopenrouter (danh sách lấy từ listing output_modalities=image của OpenRouter)
Classifier System Onetypesafe-system-onetypesafe, openrouter, vercel-ai-gateway, opencode; mô hình quyết định của llama.cpp
Classifier trên Workers AIcloudflare-workers-ai-system-onecloudflare-workers-ai (Jev, Clef)
Classifier Decisionsopenai-decisionsopenai, chỉ với khoá API
Mô hình chat làm classifierllama-cpp-classifyllama.cpp

Cùng một giao thức System One được phục vụ ở các đường dẫn khác nhau tuỳ dịch vụ, và mỗi dịch vụ nhận ảnh theo một cách (assets của TypeSafe, danh sách images của Clef, phần image_url của OpenRouter); cờ compat.imageInput chọn cách, được tự nhận diện theo provider và host (typesafe-system-one.ts:5). Trong coding agent, các mô hình này không hiện trong /model; mô hình chat chạm tới chúng qua script codemode, extension qua ctx.modelRegistry.classify() và generateImages(). Phần đó thuộc chương Pi 1.0 và chương Codemode.

Trace: pi --model openrouter/... chỉ với OPENROUTER_API_KEY#

Kịch bản: máy chưa từng chạy /login, không có models.json, chỉ có biến OPENROUTER_API_KEY. Người dùng chạy pi --model openrouter/moonshotai/kimi-k2.6 (id mặc định của OpenRouter trong bảng của coding agent), gõ một câu hỏi, mức suy nghĩ để mặc định. Mỗi bước dưới đây trỏ vào code đã đọc ở các mục trước.

  1. Khởi động mạngmain.ts đọc settings toàn cục, chép httpProxy (nếu có) vào biến môi trường rồi cài dispatcher undici toàn cục (main.ts:595).
  2. Dựng runtimeModelRuntime.create(): kho credential là RuntimeCredentials bọc auth.json (rỗng), models.json không có, 42 provider dựng sẵn, riêng openrouter bọc withPersonalizedCatalog(). Vì không có lớp phủ nào cho openrouter, composeProvider() trả nguyên provider đã bọc. Lần làm mới đầu tiên chạy với allowNetwork: false: khôi phục danh sách cá nhân hoá từ models-store.json nếu lần chạy trước đã lưu, không thì dùng catalog đóng gói.
  3. Ai có credentialLượt kiểm tra khả dụng gọi checkAuth() cho từng provider. Với openrouter: không có credential đã lưu, envApiKeyAuth không có check(), nên resolve() chạy thật và thấy OPENROUTER_API_KEY. Provider được đánh dấu đã cấu hình với nguồn "OPENROUTER_API_KEY"; trong kịch bản này các provider khác thì không.
  4. Phân giải --modelresolveCliModel() dùng mọi mô hình, kể cả mô hình chưa có credential, để --api-key dùng được cho lần đầu (model-resolver.ts:445). Trong selectReference(), tiền tố openrouter khớp một provider, phần còn lại moonshotai/kimi-k2.6 khớp đúng một id của OpenRouter, nên kết quả là mô hình đó với api: "openai-completions", baseUrl: "https://openrouter.ai/api/v1".
  5. Request của vòng lặp agentMỗi lượt, streamFn của session dựng option (timeout, chính sách thử lại, identity đọc lại theo cài đặt telemetry, transformHeaders cho hook before_provider_headers) rồi gọi modelRuntime.streamSimple() (sdk.ts:340, sdk.ts:412).
  6. Phân giải credential lúc gửiprepareRequest() gọi getAuth(model): không có apiKey tường minh; RuntimeCredentials.read() không có khoá --api-key nên hỏi auth.json, cũng không có; nhánh môi trường trả { auth: { apiKey } }. Header được gộp: header OpenCode (không áp dụng), header xác thực (không có), header của option, rồi transformHeaders (model-runtime.ts:659).
  7. Chọn adapterprovider.streamSimple() của createProvider() tra bảng theo model.api và lấy lazy wrapper openAICompletionsApi() (models.ts:1106). Ở request đầu tiên, module openai-completions.ts và SDK openai được nạp.
  8. Cờ compatdetectCompat() nhận ra OpenRouter: mức suy nghĩ dạng reasoning: { effort }, vai system thay cho developer (chỉ mô hình anthropic/* và openai/* được dùng developer), gửi store: false, max_completion_tokens, và bật header session affinity dạng x-session-id.
  9. Dựng client và headerExcerpt dưới đây. Identity cho User-Agent và ba header ghi nhận, x-session-id mang id phiên, SDK thêm Authorization: Bearer.
  10. Gửi và đọcPOST https://openrouter.ai/api/v1/chat/completions; thử lại tầng HTTP theo cài đặt; luồng SSE được đọc như ở chương pi-ai; chi phí tính bằng giá trong catalog, tức giá theo khoá nếu danh sách đã được cá nhân hoá.
createClient(): identity, header của Copilot, session affinity, rồi header của người gọi
pi/packages/ai/src/api/openai-completions.tsdòng 760, 770–803TypeScript
function createClient(	const headers: ProviderHeaders = { ...getIdentityHeaders(model, identity), ...model.headers };	if (model.provider === "github-copilot") {		const hasImages = hasCopilotVisionInput(context.messages);		const copilotHeaders = buildCopilotDynamicHeaders({			messages: context.messages,			hasImages,		});		Object.assign(headers, copilotHeaders);	}	if (sessionId && compat.sendSessionAffinityHeaders) {		if (compat.sessionAffinityFormat === "openrouter") {			headers["x-session-id"] = sessionId;		} else {			if (compat.sessionAffinityFormat === "openai") {				headers.session_id = sessionId;			}			headers["x-client-request-id"] = sessionId;			headers["x-session-affinity"] = sessionId;		}	}	// Merge options headers last so they can override defaults	if (optionsHeaders) {		Object.assign(headers, optionsHeaders);	}	return new OpenAI({		apiKey,		baseURL: model.baseUrl,		dangerouslyAllowBrowser: true,		fetch,		defaultHeaders: headers,	});
Ví dụ minh hoạ: request gửi đi (rút gọn, giá trị là minh hoạ)
POST https://openrouter.ai/api/v1/chat/completions
Authorization: Bearer …                         ← OPENROUTER_API_KEY, do SDK openai thêm
User-Agent: pi/1.4.0 (linux; node/v24.x; x64)   ← identity "detailed" của coding agent
HTTP-Referer: https://pi.dev                     ┐
X-OpenRouter-Title: pi                           ├ PI_ATTRIBUTION, chỉ khi bật install telemetry
X-OpenRouter-Categories: cli-agent               ┘
x-session-id: 019a…                             ← sessionId, vì sendSessionAffinityHeaders
{ "model": "moonshotai/kimi-k2.6", "stream": true,
  "messages": [{ "role": "system", ... }, { "role": "user", ... }],
  "stream_options": { "include_usage": true }, "store": false,
  "max_completion_tokens": …,
  "reasoning": { "effort": … } }                ← chỉ khi mô hình có suy nghĩ
Thứ tự gộp header: identity ở dưới cùng, rồi model.headers, rồi header động, rồi header của người gọi. Một header cấu hình luôn thắng identity.

Gateway khác hãng gốc ở đâu

Đổi lệnh thành pi --model moonshotai/kimi-k2.6 với MOONSHOT_API_KEY, cùng một mô hình gốc, và gần như mọi bước ở trên khác đi:

Qua OpenRouterGọi thẳng Moonshot
Endpoint, khoáopenrouter.ai/api/v1, OPENROUTER_API_KEYapi.moonshot.ai/v1, MOONSHOT_API_KEY
CatalogLọc theo khoá qua /models/user, giá và giới hạn theo khoáCatalog đóng gói cộng lớp phủ pi.dev
Cờ compatthinkingFormat: "openrouter", store: false, max_completion_tokensKhông store, không reasoning_effort, max_tokens
Header thêmAttribution, x-session-idKhông có
Ai phục vụ thậtMột hãng gốc do OpenRouter chọn, trừ khi openRouterRouting giới hạnMoonshot
Usagechunk.usage, token cache trong prompt_tokens_details.cached_tokensCó khi nằm trong choice.usage; adapter có đường lùi cho trường hợp này (openai-completions.ts:576)

Còn nếu người dùng gõ --model moonshotai/kimi-k2.6 mà chỉ có OPENROUTER_API_KEY? Tiền tố moonshotai cũng là một provider, nên selectReference() có một ứng viên "theo tên" (kimi-k2.6 của moonshotai) và những ứng viên "nguyên văn" có id đúng bằng moonshotai/kimi-k2.6 ở provider khác, so khớp không phân biệt hoa thường. Chú thích của hàm nói rõ luật: provider/phần còn lại chỉ là một gợi ý; provider được nêu tên thắng nếu có credential, không thì một ứng viên nguyên văn duy nhất có credential thắng (model-resolver.ts:223). Ở đây moonshotai không có khoá còn openrouter có, nên lệnh lặng lẽ chạy qua OpenRouter. Nếu HF_TOKEN cũng được đặt, mục moonshotai/Kimi-K2.6 của Hugging Face trở thành ứng viên có credential thứ hai, và lệnh dừng với lỗi "ambiguous across providers" yêu cầu dùng --provider.

Cuối cùng, một id không có trong catalog (một mô hình OpenRouter vừa ra mắt hôm nay) không làm lệnh thất bại: resolveCliModel() sao mô hình mặc định của provider, đổi id, và in cảnh báo "Using custom model id" (model-resolver.ts:296). Request sẽ đi được, nhưng cửa sổ ngữ cảnh, giá và cờ compat là của mô hình mặc định chứ không phải của mô hình thật.

Trace ngắn: một mô hình Ollama khai báo trong models.json#

Ví dụ minh hoạ: ~/.pi/agent/models.json
{
  "providers": {
    "ollama": {
      "baseUrl": "http://localhost:11434/v1",
      "api": "openai-completions",
      "apiKey": "ollama",
      "compat": { "supportsDeveloperRole": false, "supportsReasoningEffort": false },
      "models": [{ "id": "qwen2.5-coder:7b" }]
    }
  }
}
Theo ví dụ của docs/models.md, thêm hai cờ compat mà README khuyên cho Ollama.
  1. Nạp và kiểm traModelConfig.load() kiểm tra file bằng lược đồ đã biên dịch. ollama không phải id dựng sẵn, nên nó là một provider mới.
  2. GhépcomposeProvider("ollama"): không có provider gốc, có cấu hình, nên composeModelProvider(..., base = undefined, ...). applyModelsJson() trên danh sách gốc rỗng tạo đúng một mô hình bằng modelFromJson(): API và baseUrl lấy từ cấp provider, cửa sổ 128.000 token, output 16.384, giá 0, compat gộp từ cấp provider.
  3. Xác thựccomposeApiKeyAuth() không có xác thực kế thừa, có khoá cấu hình "ollama": một chuỗi thường, phân giải thành chính nó. check() báo đã cấu hình, nên mô hình hiện trong /model. Bỏ apiKey đi thì provider không có cách xác thực nào trả về kết quả, và mô hình biến mất khỏi danh sách dùng được.
  4. Phân giải tên--model ollama/qwen2.5-coder:7b: id chứa dấu hai chấm, nhưng hậu tố :<mức suy nghĩ> chỉ được xét khi cả chuỗi không khớp mô hình nào, và 7b cũng không phải một mức suy nghĩ, nên id được giữ nguyên.
  5. Chọn adapterstreamWith(): không có stream của extension, không có provider gốc, nên lấy openai-completions từ registry toàn cục của /compat.
  6. RequestdetectCompat() không nhận ra provider hay host nên trả mặc định của OpenAI; model.compat tắt vai developer và reasoning_effort. Header chỉ có User-Agent (host không phải gateway nào cần attribution) và Authorization: Bearer ollama, không có session affinity. POST tới http://localhost:11434/v1/chat/completions. Chi phí bằng 0 vì giá bằng 0.

So với llama.cpp, ba khác biệt nằm ở tầng provider chứ không ở tầng API: llama.cpp có danh sách mô hình động đọc từ router, baseUrl đến từ bước phân giải xác thực thay vì từ catalog, và cờ compat được đặt từ những gì router báo về chat template. Còn API và adapter thì giống hệt.

Quyết định thiết kế#

Provider và API là hai trục riêng. Ở v1.4.0 có 42 provider dựng sẵn nhưng chỉ mười API chat. Nếu provider và giao thức là một, mỗi endpoint mới sẽ đòi một adapter mới, mà một adapter dài một đến hai nghìn dòng và phải được kiểm thử riêng. Tách hai trục ra, một endpoint "tương thích OpenAI" hay "tương thích Anthropic" chỉ còn là một factory mười lăm dòng cộng vài cờ compat, và một provider có thể trộn giao thức khi hãng phía sau dùng giao thức khác nhau cho các họ mô hình (Copilot, OpenCode, Fireworks, OpenRouter). README tóm lại đúng như vậy: provider là đơn vị runtime sở hữu catalog, xác thực và hành vi stream, còn API là giao thức dùng chung (README). Cái giá là số cờ compat phình ra; pi-ai kìm nó bằng ba cách: detectCompat() tự đoán theo provider và host, bộ sinh catalog ghi sẵn cờ đã kiểm chứng cho từng mô hình, và quy tắc chỉ bật cờ cho khác biệt đã kiểm chứng.

SDK nạp lười. pi-ai phụ thuộc vào SDK của Anthropic, OpenAI, Google và AWS, cộng các flow OAuth cần node:http. Phần lớn ứng dụng (một Worker, một trang web, một host của Pi Durable) chỉ dùng một hai provider. Lời giải là factory chỉ import lazy wrapper, entry ./models không kéo TypeBox hay catalog, và module chỉ chạy trên Node được nạp qua specifier là biến (README). Phương án bị loại là import tĩnh mọi SDK, đơn giản hơn nhưng buộc mọi bundle mang theo AWS SDK. Cái giá có hai phần: request đầu tiên của mỗi API chịu thêm thời gian nạp module, và bản đóng gói đơn file phải tự đăng ký những module mà bundler không nhìn thấy.

Credential phân giải lúc gửi request. Không provider nào giữ credential từ lúc được tạo. Lý do nằm rải rác khắp chương này: token Copilot sống ngắn, khoá của Meta sống một ngày, một tiến trình Pi khác có thể vừa làm mới token, khoá dạng !lệnh có thể đã xoay vòng, credential của ECS hay IRSA tự đổi, và Cloudflare cần account id của đúng request. Phân giải ở mỗi request, dưới khoá của kho credential, xử lý được tất cả những trường hợp đó mà không có cơ chế riêng nào. Nó cũng cho phép một Models phục vụ nhiều host (Pi Durable nhận cả một hàm trả về Models). Cái giá là mỗi request phải đọc kho credential; AuthStorage giảm nó bằng cách chỉ đọc lại file khi revision đổi.

Không chép credential của môi trường. Với AWS và Google, Pi không bao giờ đọc rồi lưu credential; resolve() chỉ báo "đã cấu hình" và SDK tự đi chuỗi credential của nó. Một bản chép sẽ cũ đi khi vai trò được xoay vòng, và sẽ đưa bí mật của đám mây vào auth.json.

Catalog vừa sinh sẵn vừa làm mới được. Catalog được sinh lúc build từ models.dev cùng danh sách của OpenRouter và Vercel, cộng các sửa tay theo từng hãng, nên id mô hình có kiểu chính xác và Pi chạy được khi ngoại tuyến. Nhưng mô hình mới ra mỗi tuần, nên trên đó có bốn lớp làm mới: lớp phủ pi.dev (chỉ khi mới hơn dữ liệu đóng gói), danh sách theo khoá của OpenRouter, catalog động của provider như Radius, llama.cpp, Copilot, và cuối cùng là đường lùi "Using custom model id" cho id chưa ai biết. Phương án chỉ dùng catalog từ xa bị loại vì Pi phải khởi động được khi không có mạng (PI_OFFLINE, chế độ -p).

Đăng nhập bằng gói thuê bao và cái giá của nó. Một số flow đóng vai ứng dụng chính thức của hãng: OAuth của Anthropic gửi danh tính của Claude Code (chương pi-ai), mô hình Copilot mang sẵn header của VS Code Copilot Chat trong catalog (generate-models.ts:163), Codex gửi originator. Điều đó nghĩa là hãng có thể đổi luật bất cứ lúc nào, và mức sử dụng được tính theo quy định của gói. Code nói thẳng một hệ quả: khi phiên dùng xác thực thuê bao của Anthropic, chế độ interactive cảnh báo rằng dùng qua harness bên thứ ba bị tính vào "extra usage" theo token, không theo hạn mức của gói Claude (interactive-mode.ts:323), tắt được bằng warnings.anthropicExtraUsage (docs/settings.md:172). Cờ isSubscription của OAuthAuth cho footer hiện chi phí kèm nhãn (sub), và Kimi For Coding được coi là thuê bao ngay cả khi dùng khoá (footer.ts:189). OpenAI Codex được giữ lại với nhãn "legacy", vì Sign in with ChatGPT trên provider openai đã thay nó (README).

Cạm bẫy và trường hợp biên#

  • Cùng một mô hình, khác provider, khác giá và giới hạn. DeepSeek V4 Pro trên Azure có giá riêng của Azure và bộ cờ compat riêng (không vai developer, không tham số cache) (generate-models.ts:3357); mô hình GPT trên Azure có cửa sổ ngữ cảnh khác OpenAI (generate-models.ts:3331); giới hạn của OpenRouter phụ thuộc vào khoá. Chi phí trong footer luôn là giá catalog của đường đang dùng.
  • provider/id chỉ là gợi ý. --model moonshotai/kimi-k2.6 có thể chạy qua OpenRouter nếu chỉ OpenRouter có credential, như đã thấy trong trace. Muốn chắc chắn, dùng --provider.
  • Biến môi trường dùng chung, credential thì không. MOONSHOT_API_KEY làm cả moonshotai lẫn moonshotai-cn hiện là đã cấu hình; đó là do bảng biến môi trường, không có nghĩa khoá dùng được ở cả hai endpoint. Ngược lại, /login lưu credential theo id, nên đăng nhập qwen-token-plan không cấu hình qwen-token-plan-individual (README).
  • Credential đã lưu thắng biến môi trường. Một khoá cũ trong auth.json thắng khoá mới vừa export. /logout chỉ xoá credential đã lưu; nó không gỡ biến môi trường, không gỡ apiKey trong models.json, không thu hồi credential ở phía hãng (docs/providers.md:16). Hệ quả lạ nhất: đã đăng nhập openai bằng ChatGPT thì classifier GPT-6 Luna biến mất dù OPENAI_API_KEY có đặt, vì OAuth đã lưu sở hữu provider (docs/models.md:157).
  • Lệnh trong auth.json và models.json cư xử khác nhau. Lệnh của auth.json chạy một lần và cache cả kết quả lỗi cho tới khi khởi động lại; lệnh của models.json chạy ở mỗi request, nên một lệnh chậm làm chậm mọi request (giới hạn 10 giây).
  • Máy chủ "tương thích OpenAI" lạ nhận mặc định của OpenAI. store: false, vai developer, reasoning_effort và max_completion_tokens có thể bị từ chối. Bật đúng cờ đã kiểm chứng, ở cấp provider hoặc cấp mô hình.
  • Nhận diện theo host không đồng đều. Một provider tự đặt id trỏ vào openrouter.ai nhận thinkingFormat, x-session-id và attribution của OpenRouter vì các phép nhận diện đó xét cả host, nhưng không nhận cacheControlFormat: "anthropic" cho mô hình anthropic/*, vì phép đó chỉ xét provider === "openrouter". Đặt cờ đó tay trong compat.
  • Khoá giả là cần thiết. Provider không có cách xác thực nào trả kết quả thì mô hình của nó không hiện ra, nên máy chủ cục bộ không cần khoá vẫn phải có "apiKey": "ollama"; llama.cpp tự dùng "local".
  • Id chưa có trong catalog vẫn chạy, với siêu dữ liệu sai. Đường lùi "Using custom model id" chép cửa sổ ngữ cảnh, giá và compat của mô hình mặc định của provider. Compaction và tính tiền sẽ dựa trên các số đó; khai báo mô hình trong models.json nếu cần số đúng.
  • Cổng của callback OAuth. Sign in with ChatGPT và Codex dùng cổng 1455, trùng với Codex CLI; ChatGPT báo lỗi khi cổng bận, Codex lùi về dán URL. Chạy trong container thì đặt PI_OAUTH_CALLBACK_HOST và chuyển tiếp cổng, hoặc dùng cách dán mã hay device code.
  • Thiếu cấu hình nền tảng chỉ lộ ra lúc gửi. Mô hình Azure có baseUrl rỗng: thiếu AZURE_OPENAI_BASE_URL lẫn AZURE_OPENAI_RESOURCE_NAME thì request kết thúc bằng một sự kiện error. ADC của Vertex cần cả project lẫn location, còn khoá GOOGLE_CLOUD_API_KEY thì không.
  • Proxy. Chỉ proxy HTTP và HTTPS được hỗ trợ, SOCKS và PAC bị từ chối; httpProxy trong settings không ghi đè biến môi trường đã có.
  • Changelog là nơi đọc các lỗi đã gặp. Ba lần sửa placeholder của Cloudflare, bốn lần làm chắc việc làm mới OAuth, đổi API của Kimi For Coding sang Completions ở 1.3.0 (chương pi-ai): phần lớn hành vi tinh tế trong chương này có một mục changelog giải thích vì sao.

Đọc code ở đâu#

Thứ tự đề nghị: bắt đầu từ tài liệu người dùng để biết có những đường nào, rồi đi từ pi-ai (provider, xác thực, flow) lên coding agent (cấu hình, ghép, extension). Đường dẫn tính từ gốc repo.

#FileĐọc để hiểu
1docs/providers.md, docs/models.md, docs/models-configuration.mdMọi cách kết nối từ phía người dùng, thứ tự credential, mọi trường của models.json.
2providers/all.tsDanh sách 42 provider dựng sẵn và các hàm đọc catalog có kiểu.
3groq.ts, opencode.ts, openrouter.ts, radius.tsBốn khuôn mẫu factory: tối giản, nhiều API, catalog theo khoá, viết tay.
4src/models.tsCreateProviderOptions, createProvider(), getAuth(), applyAuth(), checkProviderAuth().
5auth/types.ts, auth/resolve.ts, auth/helpers.ts, auth/context.tsHợp đồng xác thực, thứ tự phân giải, envApiKeyAuth(), lazyOAuth(), AuthContext.
6callback-server.ts, device-code.ts, rồi anthropic.ts, github-copilot.ts, meta.tsKhối dựng của OAuth, rồi ba flow tiêu biểu: trình duyệt, đổi token, xin khoá.
7amazon-bedrock.ts, bedrock-converse-stream.ts:155, google-vertex.ts, azure.ts, azure-openai-config.tsCredential của nền tảng đám mây, vùng, endpoint, deployment.
8cloudflare-auth.ts, cloudflare-stream.ts, cloudflare-ai-binding.tsPlaceholder theo tài khoản, header của gateway, fetch qua binding.
9api/pi-messages.tsGiao thức riêng của Pi, gọn nhất trong các adapter.
10api/lazy.ts, bedrock-converse-stream.lazy.ts, auth/oauth/load.tsBốn cách nạp lười.
11utils/identity.ts, utils/node-http-proxy.ts, utils/provider-env.tsAttribution theo gateway, proxy ngoài fetch, biến môi trường theo provider.
12scripts/generate-models.ts, scripts/openrouter-catalog.tsLuật chọn API theo mô hình cho các provider nhiều API, cờ compat sinh sẵn.
13core/model-runtime.tscreate(), composeProvider(), prepareRequest(), getAuth(), đăng ký provider của extension.
14core/provider-composer.ts, core/model-config.ts, core/resolve-config-value.tsGhép models.json và extension lên provider gốc; cú pháp $VAR và !lệnh.
15core/auth-storage.ts, core/runtime-credentials.ts, cli/credential-print.tsauth.json với khoá file, --api-key, pi auth.
16core/remote-catalog-provider.ts, core/models-store.tsLớp phủ pi.dev, catalog theo khoá, models-store.json.
17extensions/llama/provider.ts, extensions/llama/index.tsMột native provider hoàn chỉnh có catalog động và classifier.
18core/model-resolver.tsCách --model chọn provider khi cùng một id có ở nhiều nơi.
19core/http-dispatcher.ts, core/client-identity.tsProxy và timeout toàn cục, attribution theo telemetry.
20custom-provider-gitlab-duo, custom-provider-anthropic, docs/custom-provider.mdProvider của extension: ủy quyền cho adapter có sẵn và stream tự viết.
21durable/README.md, harness/generation.tsHost của Pi Durable chọn provider; ModelRef phân giải ở mỗi lượt.

Tóm tắt#

  • Mỗi request trả lời ba câu hỏi độc lập: provider nào phục vụ, nói API nào, xác thực bằng gì. Ở v1.4.0 có 42 provider dựng sẵn, mười API chat, một API ảnh, bốn API classifier; provider và API ghép nhiều với nhiều.
  • Provider phần lớn là một factory mười lăm dòng qua createProvider(): catalog sinh tự động, ProviderAuth, lazy wrapper của API. Provider nhiều API chọn adapter theo model.api; wrapper quanh ProviderStreams biến đổi request trước khi gửi.
  • pi-ai phân giải credential ở mỗi request: khoá tường minh, rồi credential đã lưu (sở hữu provider, OAuth làm mới dưới khoá), rồi môi trường. Coding agent thêm --api-key, auth.json và apiKey của models.json vào đúng chuỗi đó.
  • Chín flow OAuth dùng ba khối chung (PKCE, máy chủ callback đua với dán mã, hỏi vòng device code), và tách refresh() khỏi toAuth() đủ rộng để chứa đổi token của Copilot, xin khoá của Meta, khoá vĩnh viễn của OpenRouter.
  • Nền tảng đám mây không bị chép credential: resolve() chỉ báo đã cấu hình, SDK của AWS và Google tự lấy credential; Azure và Cloudflare dựng URL theo tài khoản lúc gửi; Anthropic có workload identity federation qua SDK.
  • Gateway là provider có catalog đa hãng cộng vài việc riêng: chọn API theo mô hình, catalog theo khoá, header attribution và định tuyến theo phiên. Cùng một mô hình gốc có nhiều đường, mỗi đường một giá, một giới hạn, một API.
  • Mô hình cục bộ đi qua extension llama.cpp (catalog động, baseUrl từ xác thực) hoặc qua models.json với cờ compat đã kiểm chứng; extension đăng ký provider hoàn chỉnh hoặc ProviderConfig, và nên ủy quyền phần stream cho adapter có sẵn.
  • Pi Durable để host tự chọn provider bằng createModels() và setProvider(), và chỉ lưu tham chiếu { provider, modelId }, nên đổi cấu hình provider không phải sửa dữ liệu.

6pi-agent-core: vòng lặp agent#

Vai trò của pi-agent-core#

pi-ai (chương pi-ai) làm đúng một việc: gửi một context tới một mô hình (model) và trả về một luồng (stream) gồm các sự kiện (event) của một câu trả lời. Một agent cần nhiều hơn thế. Khi câu trả lời chứa lời gọi tool, phải có ai đó kiểm tra đối số, chạy tool, gói kết quả thành message, nối vào transcript rồi gọi mô hình lần nữa, cứ thế cho tới khi mô hình trả lời mà không gọi tool. Trong lúc đó giao diện cần biết từng bước đang diễn ra, và người dùng vẫn có thể gõ thêm tin nhắn. Phần “cứ thế cho tới khi” là vòng lặp agent (agent loop), và nó nằm trong gói @earendil-works/pi-agent-core, thư mục packages/agent.

README mô tả gói là “Stateful agent with tool execution and event streaming. Built on @earendil-works/pi-ai” (README.md:3). Gói rất nhỏ: sáu file trong src/, tổng 2.539 dòng. agent-loop.ts (959 dòng) là vòng lặp, agent.ts (623) là lớp bọc có state, types.ts (539) chứa toàn bộ data model, proxy.ts (393) và stream-fn.ts (20) là phần vận chuyển. Gói cung cấp hai tầng API:

  • Hàm cấp thấp agentLoop() và agentLoopContinue(): không giữ state, nhận context, cấu hình, một AbortSignal và một hàm stream, trả về luồng AgentEvent.
  • Lớp Agent: bọc vòng lặp, sở hữu transcript, hai hàng đợi tin nhắn (steer và follow-up), danh sách subscriber và AbortController của lần chạy hiện tại.

Trong monorepo ở v1.4.0, chỉ pi-coding-agent phụ thuộc vào gói này; Pi Durable có vòng lặp riêng dựng thẳng trên pi-ai (xem Bản đồ monorepo và Pi Durable). Từ 1.0.0 gói chỉ còn Agent, vòng lặp, proxy stream và các kiểu của chúng; harness thử nghiệm cũ (session, compaction, skills, telemetry...) đã bị gỡ ra (CHANGELOG.md:35).

Ranh giới trách nhiệm vì thế rất rõ. agent-core không biết provider (nhà cung cấp) nào, vì hàm stream được truyền từ ngoài vào; nó không ghi file session, không làm compaction (nén ngữ cảnh), không dựng system prompt và không hỏi quyền chạy tool. Nó chỉ biết transcript, bộ tool và các hook. Coding agent lấp các chỗ trống đó bằng cách cấu hình một Agent duy nhất trong createAgentSession():

Coding agent cấu hình Agent: transcript từ session, convertToLlm, streamFn qua ModelRuntime, transformContext qua extension
pi/packages/coding-agent/src/core/sdk.tsdòng 403–438TypeScript
const agent = new Agent({	initialState: {		systemPrompt: "",		model,		thinkingLevel,		tools: [],		messages: existingSession.messages,	},	convertToLlm: convertToLlmWithBlockImages,	streamFn: async (model, context, options) => {		const requestOptions = buildRequestOptions(options);		// Compaction and summaries use their own routing ids; only session requests		// replace the cache entry, so warming restarts from them. Keep warming while		// the current transcript still extends the request's prefix. Agent state may		// shallow-copy the messages array or refresh the model object without changing		// the provider request, so top-level object identity is not a valid cache key.		if (options?.sessionId === sessionManager.getSessionId()) {			cacheWarmer.start({ model, context, options: requestOptions }, cacheContextIsCurrent(model));		}		return modelRuntime.streamSimple(model, context, requestOptions);	},	onPayload: transformProviderPayload,	onResponse: handleProviderResponse,	onProviderStreamEvent: handleProviderStreamEvent,	sessionId: sessionManager.getSessionId(),	transformContext: async (messages) => {		const runner = extensionRunnerRef.current;		if (!runner) return messages;		return runner.emitContext(messages);	},	steeringMode: settingsManager.getSteeringMode(),	followUpMode: settingsManager.getFollowUpMode(),	transport: settingsManager.getTransport(),	thinkingBudgets: settingsManager.getThinkingBudgets(),	maxRetryDelayMs: settingsManager.getProviderRetrySettings().maxRetryDelayMs,});
Transcript ban đầu lấy từ session đã lưu (dòng 409). streamFn là chỗ duy nhất agent-core chạm tới provider; ở đây nó đi qua ModelRuntime của coding agent và bộ làm ấm cache (cache warmer).

Phần còn lại do AgentSession gắn vào sau khi tạo: một subscriber nhận mọi sự kiện để lưu session, chạy extension, tự compaction và thử lại (agent-session.ts:511–517), cặp hook beforeToolCall/afterToolCall (:652), prepareRequest để thay context bằng phép chiếu chuẩn của session (:787), finishTurn (:886) và prepareNextTurnWithContext để compaction và cập nhật system prompt giữa các lượt (:898). Chương này giải thích các hook đó có nghĩa gì với vòng lặp; nội dung bên trong chúng thuộc về chương AgentSession.

AgentSession (coding agent)
Session JSONL, compaction, extension, system prompt, quyền tool. Cắm vào Agent qua hook và subscriber.
Agent
Transcript, hàng đợi steer/follow-up, subscriber được await, abort, waitForIdle().
agentLoop / runLoop
Lượt, batch tool, hook, sự kiện. Không giữ state giữa các lần chạy.
StreamFn
Một hàm (model, context, options) trả về luồng sự kiện: Models.streamSimple, streamProxy, hoặc hàm tự viết.
pi-ai
Provider, message chuẩn hoá, luồng AssistantMessageEvent.

Mô hình tư duy: lần chạy, lượt và hai vòng lặp#

Phần còn lại của chương dùng các từ sau với nghĩa chính xác như trong code:

Lần chạy (run)
Một lần gọi prompt() hoặc continue(), mở bằng sự kiện agent_start và đóng bằng agent_end. Một lần chạy có thể gồm nhiều lượt.
Lượt (turn)
Một câu trả lời của mô hình cộng với các tool mà câu trả lời đó gọi, mở bằng turn_start, đóng bằng turn_end (types.ts:520).
Batch tool
Mọi lời gọi tool nằm trong một assistant message. Mô hình có thể gọi nhiều tool cùng lúc; vòng lặp chạy xong cả batch rồi mới gọi lại mô hình.
Transcript
Mảng AgentMessage[] theo thời gian: system, user, assistant, toolResult và các message tuỳ biến của ứng dụng.
Tin nhắn steer
Tin nhắn chen vào lượt đang chạy. Nó không cắt ngang tool nào: nó được chèn sau khi batch tool của lượt hiện tại xong, trước lần gọi mô hình kế tiếp.
Follow-up
Tin nhắn xếp hàng sau, chỉ được chèn khi agent lẽ ra đã dừng.

Cấu trúc điều khiển là hai vòng while lồng nhau trong runLoop(). Vòng trong chạy các lượt chừng nào mô hình còn gọi tool hoặc còn tin nhắn chờ. Vòng ngoài chỉ có một nhiệm vụ: khi vòng trong muốn dừng, hỏi hàng đợi follow-up; có tin thì quay lại vòng trong.

prompt() hoặc continue()
│ agent_start, turn_start, message_start/end cho các message của prompt
│
├─ vòng ngoài: while (true)
│  │
│  ├─ vòng trong: while (còn tool call || còn tin nhắn chờ)
│  │    1. từ lượt thứ hai: prepareNextTurn, hỏi lại steer nếu chưa có, turn_start
│  │    2. chèn tin nhắn chờ (steer hoặc follow-up)   message_start / message_end
│  │    3. prepareRequest
│  │    4. gọi mô hình qua StreamFn                    message_start / update... / end
│  │       stopReason "error" hoặc "aborted"  →  turn_end, agent_end, thoát
│  │    5. chạy batch tool                             tool_execution_*, toolResult
│  │    6. finishTurn                                  turn_end
│  │       {action: "end"}  →  agent_end, thoát
│  │    7. hỏi hàng đợi steer
│  │
│  └─ vòng trong muốn dừng: hỏi hàng đợi follow-up
│       có tin    →  thành tin nhắn chờ, quay lại vòng trong
│       không tin →  thoát vòng ngoài (trừ khi finishTurn đã đòi {action: "continue"})
│
└ agent_end
Hai vòng lặp của runLoop(). Mỗi dòng bên phải là các sự kiện phát ra ở bước đó.

Data model: AgentMessage, AgentContext và AgentState#

Vấn đề. Transcript của một ứng dụng thực tế chứa nhiều thứ hơn những gì mô hình hiểu. Coding agent lưu kết quả của lệnh !ls mà người dùng tự gõ, bản tóm tắt sau compaction, tin nhắn do extension chèn vào. Nếu kiểu của transcript chỉ là Message của pi-ai (system, user, assistant, toolResult, xem ai/types.ts:652), các thứ đó phải sống ở chỗ khác và transcript không còn là nguồn sự thật duy nhất.

Giải pháp. agent-core làm việc với AgentMessage, là hợp của Message với một interface rỗng mà ứng dụng tự mở rộng:

CustomAgentMessages và AgentMessage
pi/packages/agent/src/types.tsdòng 351–374TypeScript
/** * Extensible interface for custom app messages. * Apps can extend via declaration merging: * * @example * ```typescript * declare module "@mariozechner/agent" { *   interface CustomAgentMessages { *     artifact: ArtifactMessage; *     notification: NotificationMessage; *   } * } * ``` */export interface CustomAgentMessages {	// Empty by default - apps extend via declaration merging}/** * AgentMessage: Union of LLM messages + custom messages. * This abstraction allows apps to add custom message types while maintaining * type safety and compatibility with the base LLM messages. */export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];
Ví dụ trong chú thích vẫn ghi tên gói cũ @mariozechner/agent; khi mở rộng thật phải dùng tên module hiện tại, @earendil-works/pi-agent-core.

Mặc định CustomAgentMessages rỗng, nên keyof CustomAgentMessages là never và AgentMessage chỉ là Message. Khi một ứng dụng viết declare module "@earendil-works/pi-agent-core" { interface CustomAgentMessages { ... } }, TypeScript gộp (declaration merging) khai báo đó vào interface gốc, và mỗi thuộc tính thêm một thành viên vào hợp. Tên khoá chỉ là nhãn; điều quan trọng là kiểu giá trị, và trên thực tế mỗi kiểu tuỳ biến cần một role riêng vì cả vòng lặp lẫn convertToLlm phân biệt message bằng role. Coding agent mở rộng đúng theo cách đó với bốn loại message:

Coding agent thêm bốn loại message vào AgentMessage
pi/packages/coding-agent/src/core/messages.tsdòng 69–77TypeScript
// Extend CustomAgentMessages via declaration mergingdeclare module "@earendil-works/pi-agent-core" {	interface CustomAgentMessages {		bashExecution: BashExecutionMessage;		custom: CustomMessage;		branchSummary: BranchSummaryMessage;		compactionSummary: CompactionSummaryMessage;	}}

Mô hình không bao giờ thấy các role này. Trước mỗi lần gọi mô hình, vòng lặp gọi convertToLlm để biến AgentMessage[] thành Message[]. Bản của coding agent chuyển các loại tuỳ biến thành message user và bỏ qua lệnh bash có tiền tố !!:

convertToLlm của coding agent (rút gọn)
pi/packages/coding-agent/src/core/messages.tsdòng 148–161, 184–195TypeScript
export function convertToLlm(messages: AgentMessage[]): Message[] {	return messages		.map((m): Message | undefined => {			switch (m.role) {				case "bashExecution":					// Skip messages excluded from context (!! prefix)					if (m.excludeFromContext) {						return undefined;					}					return {						role: "user",						content: [{ type: "text", text: bashExecutionToText(m) }],						timestamp: m.timestamp,					};				case "system":				case "user":				case "assistant":				case "toolResult":					return m;				default:					// biome-ignore lint/correctness/noSwitchDeclarations: fine					const _exhaustiveCheck: never = m;					return undefined;			}		})		.filter((m) => m !== undefined);
Các nhánh custom, branchSummary, compactionSummary ở giữa cũng trả về message user; bản tóm tắt được bọc trong thẻ <summary>.

Nếu ứng dụng không truyền convertToLlm, lớp Agent dùng một bộ lọc giữ lại đúng bốn role chuẩn (agent.ts:39–47).

Thứ vòng lặp nhận vào là một AgentContext: transcript và các tool chạy được.

AgentContext: ảnh chụp đưa vào vòng lặp
pi/packages/agent/src/types.tsdòng 501–507TypeScript
/** Context snapshot passed into the low-level agent loop. */export interface AgentContext {	/** Transcript visible to the model. */	messages: AgentMessage[];	/** Tools available for execution in this run. */	tools?: AgentTool<any>[];}

Lưu ý tools ở đây là các AgentTool có hàm execute, tức những gì runtime chạy được. Những gì mô hình được phép gọi lại được khai báo trong các system message của transcript; mục declareToolChanges giải thích vì sao hai thứ này tách nhau.

Lớp Agent phơi state công khai qua AgentState:

AgentState
pi/packages/agent/src/types.tsdòng 382–421TypeScript
export interface AgentState {	/**	 * Current system prompt, replayed from the transcript's system messages.	 *	 * Read-only: to change the prompt, append a system message with `content` or `sections`.	 * In `initialState`, this seeds the leading system message.	 */	readonly systemPrompt: string;	/** Active model used for future turns. */	model: Model<any>;	/** Requested reasoning level for future turns. */	thinkingLevel: ThinkingLevel;	/**	 * Executable tools. Assigning a new array copies the top-level array.	 *	 * Differences from the tools declared in the transcript are announced to the model	 * with a system message before the next request.	 */	set tools(tools: AgentTool<any>[]);	get tools(): AgentTool<any>[];	/**	 * Conversation transcript. Assigning a new array copies the top-level array.	 *	 * System messages in the transcript carry the prompt and tool declarations.	 */	set messages(messages: AgentMessage[]);	get messages(): AgentMessage[];	/**	 * True while the agent is processing a prompt or continuation.	 *	 * This remains true until awaited `agent_end` listeners settle.	 */	readonly isStreaming: boolean;	/** Partial assistant message for the current streamed response, if any. */	readonly streamingMessage?: AgentMessage;	/** Tool call ids currently executing. */	readonly pendingToolCalls: ReadonlySet<string>;	/** Error message from the most recent failed or aborted assistant turn, if any. */	readonly errorMessage?: string;}
  • systemPrompt chỉ đọc. Nó được tính lại bằng cách phát lại (replay) mọi system message trong transcript (getCurrentSystemPrompt). Muốn đổi prompt giữa hội thoại thì nối thêm một system message có content (thêm chỉ dẫn) hoặc sections (thay một mục theo tên).
  • model và mức suy nghĩ (thinking level) thinkingLevel chỉ được đọc khi một lần chạy bắt đầu, lúc Agent dựng cấu hình cho vòng lặp. Muốn giá trị mới có hiệu lực ngay từ lượt kế tiếp của lần chạy đang diễn ra, một hook như prepareNextTurn phải trả chúng về; AgentSession làm đúng như vậy.
  • tools và messages là accessor: gán một mảng mới thì mảng cấp ngoài cùng được sao chép (agent.ts:95–106), còn push() vào mảng trả về thì sửa thẳng state hiện tại.
  • isStreaming, streamingMessage, pendingToolCalls, errorMessage chỉ đọc với bên ngoài; chúng do chính các sự kiện của vòng lặp cập nhật (xem lớp Agent).

Khi tạo Agent với systemPrompt và tools, hai thứ này trở thành system message đầu tiên của transcript, mang toolsAdded và timestamp: 0 (agent.ts:86–87, transcript.ts:10–23). Sau một lượt có gọi tool, transcript trông như sau:

Ví dụ minh hoạ: agent.state.messages sau một prompt có một lời gọi tool
[
  { role: "system", content: "You are helpful.",
    toolsAdded: [{ name: "read", description: "...", parameters: {...} }], timestamp: 0 },
  { role: "user", content: [{ type: "text", text: "Đọc a.txt" }], timestamp: 1761100000000 },
  { role: "assistant", stopReason: "toolUse", thinkingLevel: "off",
    content: [{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "a.txt" } }],
    api: "...", provider: "...", model: "...", usage: {...}, timestamp: 1761100000100 },
  { role: "toolResult", toolCallId: "call_1", toolName: "read", isError: false,
    content: [{ type: "text", text: "nội dung a.txt" }], details: {}, durationMs: 2,
    timestamp: 1761100001200 },
  { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "a.txt chứa ..." }], ... }
]
System message đầu tiên mang prompt và khai báo tool. thinkingLevel trên assistant message là mức suy nghĩ mà vòng lặp đã yêu cầu.

Data model: AgentTool và kết quả tool#

AgentTool mở rộng Tool của pi-ai (name, description, parameters là một schema TypeBox) bằng những gì runtime cần để chạy tool:

AgentTool
pi/packages/agent/src/types.tsdòng 465–499TypeScript
/** Tool definition used by the agent runtime. */export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {	/** Human-readable label for UI display. */	label: string;	/**	 * Optional compatibility shim for raw tool-call arguments before schema validation.	 * Must return an object that matches `TParameters`.	 */	prepareArguments?: (args: unknown) => Static<TParameters>;	/**	 * JSON Schema of `structuredContent` in successful results. Tools that declare it should always	 * set `structuredContent`.	 */	outputSchema?: TSchema;	/**	 * Execute the tool call. Throw on failure, or return a result with `isError: true`; do not only	 * describe the failure in `content`.	 */	execute: (		toolCallId: string,		params: Static<TParameters>,		signal?: AbortSignal,		onUpdate?: AgentToolUpdateCallback<TDetails>,	) => Promise<AgentToolResult<TDetails>>;	/** Recovery policy for an effect whose durable intent exists but whose outcome is unknown. */	replay?: "never" | "safe";	/**	 * Per-tool execution mode override.	 * - "sequential": this tool must execute one at a time with other tool calls.	 * - "parallel": this tool can execute concurrently with other tool calls.	 *	 * If omitted, the default execution mode applies.	 */	executionMode?: ToolExecutionMode;}
  • label là tên hiển thị cho giao diện, không gửi cho mô hình.
  • prepareArguments là lớp tương thích chạy trước khi kiểm tra schema, ví dụ để nhận đối số kiểu cũ từ một session được mở lại sau khi schema đã đổi (CHANGELOG 0.64.0, CHANGELOG.md:481).
  • execute(toolCallId, params, signal, onUpdate) nhận đối số đã kiểm tra, tín hiệu huỷ của lần chạy, và một callback để phát kết quả từng phần.
  • executionMode cho phép một tool ép cả batch chạy tuần tự (xem thực thi tool).
  • replay mô tả chính sách phục hồi cho một hiệu ứng đã có ý định bền (durable intent) nhưng chưa rõ kết quả. Vòng lặp ở v1.4.0 không đọc trường này, và coding agent cũng không; Pi Durable có khái niệm tương tự trong harness riêng.

Kết quả của một tool tách hai phần cho hai người đọc khác nhau:

AgentToolResult: content cho mô hình, details cho giao diện
pi/packages/agent/src/types.tsdòng 423–446TypeScript
/** Final or partial result produced by a tool. */export interface AgentToolResult<T = JsonValue | undefined> {	/** Text or image content returned to the model. */	content: (TextContent | ImageContent)[];	/** Arbitrary structured details for logs or UI rendering. */	details: T;	/**	 * Machine-readable result matching the tool's `outputSchema`, for programmatic callers. Not sent	 * to the model; `content` remains the model-facing result.	 */	structuredContent?: JsonValue;	/** Usage from the final tool execution itself, if available. Not used for main LLM context accounting. */	usage?: Usage;	/**	 * Report a failure without throwing. The model sees `content` as an error result, like a thrown	 * error, but `details` and `structuredContent` are kept for the UI and programmatic callers.	 */	isError?: boolean;	/**	 * Hint that the agent should stop after the current tool batch.	 * Early termination only happens when every finalized tool result in the batch sets this to true.	 */	terminate?: boolean;}

content là thứ mô hình đọc. details là dữ liệu có cấu trúc tuỳ ý cho log và giao diện, không bao giờ gửi cho mô hình. Ví dụ tool read của coding agent trả văn bản trong content, còn thông tin cắt bớt có cấu trúc thì nằm trong details: { truncation } (read.ts:40–42) để giao diện vẽ ghi chú riêng. Bài blog đầu tiên của Mario gọi đây là “structured split tool results”: “splitting tool results into a portion handed to the LLM and a portion for UI display” (What I learned building an opinionated and minimal coding agent).

Các trường còn lại phục vụ những người khác. structuredContent dành cho caller lập trình (ví dụ một script codemode gọi tool lồng nhau), khớp với outputSchema. usage là lượng token mà chính việc chạy tool tiêu thụ, không tính vào ngữ cảnh chính. isError báo lỗi mà không cần throw: mô hình vẫn thấy một kết quả lỗi, còn details được giữ lại. terminate là gợi ý dừng sau batch hiện tại. Khi kết quả được đóng gói thành message toolResult cho transcript, chỉ content, details, usage, isError và durationMs được giữ; terminate và structuredContent chỉ sống trong sự kiện tool_execution_end:

Từ kết quả tool tới message toolResult
pi/packages/agent/src/agent-loop.tsdòng 940–954TypeScript
function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage {	return {		role: "toolResult",		toolCallId: finalized.toolCall.id,		toolName: finalized.toolCall.name,		// Untyped tools (JS extensions) can return results without content; normalize		// so the null never enters session history or provider payloads.		content: finalized.result.content ?? [],		details: finalized.result.details,		usage: finalized.result.usage,		isError: finalized.isError,		...(finalized.durationMs === undefined ? {} : { durationMs: finalized.durationMs }),		timestamp: Date.now(),	};}

Tool phát kết quả từng phần qua onUpdate, mỗi lần gọi thành một sự kiện tool_execution_update. Callback chỉ có hiệu lực trong lúc execute() còn chạy; gọi sau khi promise đã xong thì bị bỏ qua (types.ts:457–463), một sửa lỗi ở 0.79.2 để giao diện không nhận cập nhật cũ (CHANGELOG.md:295).

Data model: AgentLoopConfig và các hook#

Mọi hành vi tuỳ biến của vòng lặp đi qua một đối tượng cấu hình. AgentLoopConfig kế thừa SimpleStreamOptions của pi-ai (API key, reasoning, sessionId, transport, ...) nên chính nó cũng là options truyền cho hàm stream. Đây là các trường riêng của nó, đã lược chú thích:

AgentLoopConfig, chỉ phần chữ ký
pi/packages/agent/src/types.tsdòng 193–194, 222, 244, 254, 264, 271, 278–280, 293, 306, 317, 326, 341–342TypeScript
export interface AgentLoopConfig extends SimpleStreamOptions {	model: Model<any>;	convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;	transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;	getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;	finishTurn?: FinishTurn;	prepareRequest?: PrepareRequest;	prepareNextTurn?: (		context: PrepareNextTurnContext,	) => AgentLoopTurnUpdate | undefined | Promise<AgentLoopTurnUpdate | undefined>;	getSteeringMessages?: () => Promise<AgentMessage[]>;	getFollowUpMessages?: () => Promise<AgentMessage[]>;	toolExecution?: ToolExecutionMode;	beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;	afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;}
TrườngVòng lặp gọi khi nàoTrả về và hợp đồng
convertToLlm (bắt buộc)Trước mỗi lần gọi mô hình, sau transformContextMessage[]. Không được throw (types.ts:203)
transformContextTrước convertToLlm, trên AgentMessage[]Transcript đã biến đổi, ví dụ cắt bớt hoặc chèn ngữ cảnh. Không được throw
getApiKeyMỗi lần gọi mô hìnhKey hoặc undefined; hữu ích cho token OAuth hết hạn giữa lúc tool chạy lâu
prepareRequestNgay trước mọi request hội thoại, kể cả lần đầu, sau khi tin nhắn chờ đã được chènContext, model, mức suy nghĩ thay thế cho request này và các request sau. Không hỏi hàng đợi
finishTurnSau assistant message và mọi toolResult, trước turn_end{ action: "end" }, { action: "continue" } hoặc undefined
prepareNextTurnSau turn_end, khi chắc chắn còn lượt tiếp, ngay trước lượt đóContext, model, mức suy nghĩ, hoặc message cần nối thêm
getSteeringMessagesĐầu lần chạy, sau mỗi lượt, và sau prepareNextTurn nếu lần hỏi trước rỗngAgentMessage[], rỗng nếu không có gì. Không được throw
getFollowUpMessagesKhi agent lẽ ra đã dừngAgentMessage[]. Không được throw
toolExecutionMỗi batch tool"parallel" (mặc định) hoặc "sequential"
beforeToolCallSau tool_execution_start và sau khi đối số đã được kiểm tra{ block, reason, terminate } để chặn tool
afterToolCallSau execute(), trước tool_execution_endGhi đè từng trường của kết quả (không gộp sâu)

Hợp đồng “không được throw” có lý do cụ thể: một exception từ các hàm này cắt ngang vòng lặp cấp thấp mà không phát chuỗi sự kiện bình thường (types.ts:203–204). Lớp Agent có một lưới an toàn cho trường hợp này (xem dưới), còn người dùng agentLoop() trực tiếp thì không.

Ba hook điều khiển lịch trình có kiểu riêng:

AgentTurnDecision, FinishTurn và AgentLoopTurnUpdate
pi/packages/agent/src/types.tsdòng 146–170TypeScript
/** Decision returned by {@link FinishTurn}. Returning undefined preserves normal scheduling. */export type AgentTurnDecision = { action: "continue" } | { action: "end" };/** * Called after a completed assistant turn and all of its tool-result messages, but before `turn_end`. * On a normal turn, `{ action: "continue" }` ensures one next provider request. Tool-result, steering, or * follow-up scheduling can satisfy that request and adds no extra request; otherwise the loop continues once * with the current context. Error and aborted responses remain hard exits. */export type FinishTurn = (	turn: AgentTurnContext,	signal?: AbortSignal,) => AgentTurnDecision | void | Promise<AgentTurnDecision | undefined> | Promise<void>;/** Replacement runtime state used by the agent loop before starting another provider request. */export interface AgentLoopTurnUpdate {	/** Context for the next provider request. */	context?: AgentContext;	/** Messages to append before the next provider request, with normal lifecycle events. */	messages?: AgentMessage[];	/** Model for the next provider request. */	model?: Model<any>;	/** Thinking level for the next provider request. */	thinkingLevel?: ThinkingLevel;}

Ngữ nghĩa của { action: "continue" } tinh tế hơn tên gọi: nó bảo đảm ít nhất một request tiếp theo, chứ không thêm một request. Nếu lượt vừa rồi có tool call, hoặc có tin steer hay follow-up, thì request tự nhiên kế tiếp đã thoả yêu cầu; chỉ khi không có gì, vòng lặp mới chạy thêm đúng một lượt với context hiện tại (types.ts:149–154). Phản hồi lỗi hoặc bị huỷ luôn là lối ra cứng; quyết định của finishTurn khi đó bị bỏ qua. README cảnh báo: trả về continue vô điều kiện sẽ tạo vòng lặp vô tận, vì finishTurn chạy lại sau request kế tiếp (README.md:146).

Data model: AgentEvent#

Vòng lặp không trả về giá trị trung gian nào; mọi thứ nó làm đều được báo qua sự kiện:

AgentEvent: bốn nhóm sự kiện
pi/packages/agent/src/types.tsdòng 509–539TypeScript
/** * Events emitted by the Agent for UI updates. * * `agent_end` is the last event emitted for a run, but awaited `Agent.subscribe()` * listeners for that event are still part of run settlement. The agent becomes * idle only after those listeners finish. */export type AgentEvent =	// Agent lifecycle	| { type: "agent_start" }	| { type: "agent_end"; messages: AgentMessage[] }	// Turn lifecycle - a turn is one assistant response + any tool calls/results	| { type: "turn_start" }	| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }	// Message lifecycle - emitted for system, user, assistant, and toolResult messages	| { type: "message_start"; message: AgentMessage }	// Only emitted for assistant messages during streaming	| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }	| { type: "message_end"; message: AgentMessage }	// Tool execution lifecycle	| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }	| { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }	| {			type: "tool_execution_end";			toolCallId: string;			toolName: string;			result: any;			isError: boolean;			/** Milliseconds `execute()` took, measured with a monotonic clock; absent when the tool did not run. */			durationMs?: number;	  };
  • Lần chạy: agent_start và agent_end. agent_end.messages là các message mới của lần chạy: với prompt() gồm cả message của prompt, với continue() thì không gồm các message đã có (types.ts:142).
  • Lượt: turn_start và turn_end; turn_end mang assistant message và các toolResult của lượt.
  • Message: message_start/message_end phát cho mọi message, kể cả system, user và toolResult. message_update chỉ có với assistant message đang stream, kèm assistantMessageEvent gốc của pi-ai (text_delta, toolcall_delta, ...).
  • Tool: tool_execution_start, _update, _end. _end mang toàn bộ AgentToolResult (gồm cả structuredContent và terminate) và durationMs đo bằng đồng hồ đơn điệu, không tính thời gian chạy hook (CHANGELOG 1.1.0, CHANGELOG.md:17).

Hai chi tiết đáng nhớ. Message trong message_start và message_update của assistant là bản sao nông { ...partialMessage } chụp tại thời điểm đó, còn message_end mang đối tượng cuối cùng (agent-loop.ts:430, :464). Và agent_end chỉ có nghĩa là vòng lặp sẽ không phát thêm sự kiện nào; lớp Agent chỉ coi là rảnh sau khi các listener của chính sự kiện đó chạy xong (types.ts:509–515).

StreamFn và proxy: lớp vận chuyển#

package.json vẫn mô tả gói là “General-purpose agent with transport abstraction” (package.json:4), nhưng ở v1.4.0 lớp vận chuyển không còn là một interface hay một lớp nào. Ở 0.31.0, các interface ProviderTransport, AppTransport, AgentTransport bị xoá và thay bằng một tuỳ chọn duy nhất, streamFn (CHANGELOG.md:757). Cũng ở phiên bản đó, agentLoop() và agentLoopContinue() chuyển từ pi-ai sang gói này (CHANGELOG.md:770); vì vậy bài blog tháng 11/2025 của Mario còn mô tả vòng lặp dưới mục pi-ai, còn lớp Agent với “transport abstraction” thuộc pi-agent-core. Từ 0.31.0, “vận chuyển” chỉ là một kiểu hàm:

StreamFn: hợp đồng giữa vòng lặp và mô hình
pi/packages/agent/src/types.tsdòng 19–37TypeScript
/** * Stream function used by the agent loop. `Models.streamSimple` satisfies * this shape. * * The loop passes a normalized transcript: the system prompt and tool * declarations are carried by the transcript's system messages, never by * `context.systemPrompt` or `context.tools`. * * Contract: * - Must not throw or return a rejected promise for request/model/runtime failures. * - Must return an AssistantMessageEventStream. * - Failures must be encoded in the returned stream via protocol events and a *   final AssistantMessage with stopReason "error" or "aborted" and errorMessage. */export type StreamFn = (	model: Model<Api>,	context: TranscriptContext,	options?: SimpleStreamOptions,) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;

Hợp đồng có ba điểm. Hàm nhận một TranscriptContext đã chuẩn hoá, trong đó system prompt và khai báo tool nằm trong các system message, không nằm ở context.systemPrompt hay context.tools. Hàm không được throw: mọi lỗi request, lỗi mô hình hay bị huỷ đều phải được mã hoá thành sự kiện trong luồng, kết thúc bằng một assistant message có stopReason là "error" hoặc "aborted". Và Models.streamSimple của pi-ai khớp với kiểu này, nên trường hợp thường gặp chỉ là streamFn: models.streamSimple.bind(models) (README.md:23–29).

Bản thân agent-core không import provider nào. Nếu caller bỏ qua streamFn (các consumer biên dịch từ phiên bản cũ có thể làm vậy, xem agent.ts:239), nó dùng một hàm mặc định do host cài:

stream-fn.ts: hàm stream mặc định do host cài
pi/packages/agent/src/stream-fn.tsdòng 3–20TypeScript
let defaultStreamFn: StreamFn | undefined;/** * Configure the fallback used by Agent and low-level loops when callers omit streamFn. * * Hosts that provide a default model runtime can install its stream function here * without making pi-agent-core depend on a provider catalog or compatibility layer. */export function setDefaultStreamFn(streamFn: StreamFn | undefined): void {	defaultStreamFn = streamFn;}export function getDefaultStreamFn(): StreamFn {	if (!defaultStreamFn) {		throw new Error("No default stream function configured. Pass streamFn explicitly or call setDefaultStreamFn().");	}	return defaultStreamFn;}

Coding agent cài streamSimple của pi-ai/compat làm mặc định ngay khi module sdk.ts được nạp, để extension nào tự tạo Agent mà quên streamFn vẫn chạy (sdk.ts:43–46). Cách sắp xếp này có chủ đích: ở 0.81.0, streamFn từng bị bắt buộc để pi-ai/compat và mọi provider dựng sẵn không lọt vào các bundle chỉ chọn vài provider (CHANGELOG.md:193); bản sửa sau đó đưa lại fallback nhưng do host cấu hình (:185).

streamProxy: khi mô hình nằm sau một server

Ứng dụng trình duyệt không nên giữ API key của provider. streamProxy() là một StreamFn gửi request tới server của chính ứng dụng; server giữ khoá, gọi provider, rồi stream sự kiện ngược lại. Để tiết kiệm băng thông, server bỏ trường partial (assistant message dựng dần) khỏi mỗi sự kiện, nên giao thức proxy là một phiên bản rút gọn của AssistantMessageEvent:

Sự kiện proxy: không có partial
pi/packages/agent/src/proxy.tsdòng 20–46TypeScript
/** * Proxy event types - server sends these with partial field stripped to reduce bandwidth. */export type ProxyAssistantMessageEvent =	| { type: "start" }	| { type: "text_start"; contentIndex: number }	| { type: "text_delta"; contentIndex: number; delta: string }	| { type: "text_end"; contentIndex: number; contentSignature?: string }	| { type: "thinking_start"; contentIndex: number }	| { type: "thinking_delta"; contentIndex: number; delta: string }	| { type: "thinking_end"; contentIndex: number; contentSignature?: string }	| { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }	| { type: "toolcall_delta"; contentIndex: number; delta: string }	| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall }	| {			type: "done";			reason: Extract<StopReason, "stop" | "length" | "toolUse">;			usage: AssistantMessage["usage"];			providerThinkingLevel?: string;	  }	| {			type: "error";			reason: Extract<StopReason, "aborted" | "error">;			errorMessage?: string;			usage: AssistantMessage["usage"];			providerThinkingLevel?: string;	  };
Một POST tới /api/stream với Bearer token
pi/packages/agent/src/proxy.tsdòng 146–159TypeScript
try {	const response = await fetch(`${options.proxyUrl}/api/stream`, {		method: "POST",		headers: {			Authorization: `Bearer ${options.authToken}`,			"Content-Type": "application/json",		},		body: JSON.stringify({			model,			context,			options: buildProxyRequestOptions(options),		}),		signal: options.signal,	});

Phía client đọc từng dòng data: {...} và dựng lại partial trong processProxyEvent(). Đoạn khó nhất là đối số tool: chúng tới dưới dạng các mảnh JSON chưa hoàn chỉnh, nên client cộng dồn chuỗi và dùng bộ đọc JSON từng phần của pi-ai sau mỗi mảnh:

Dựng lại đối số tool từ JSON từng phần
pi/packages/agent/src/proxy.tsdòng 339–353TypeScript
case "toolcall_delta": {	const content = partial.content[proxyEvent.contentIndex];	if (content?.type === "toolCall") {		(content as any).partialJson += proxyEvent.delta;		content.arguments = parseStreamingJson((content as any).partialJson) || {};		partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity		return {			type: "toolcall_delta",			contentIndex: proxyEvent.contentIndex,			delta: proxyEvent.delta,			partial,		};	}	throw new Error("Received toolcall_delta for non-toolCall content");}

streamProxy() giữ đúng hợp đồng không-throw của StreamFn: lỗi HTTP, lỗi mạng và abort đều thành một sự kiện error với stopReason tương ứng (proxy.ts:233–243). Nó cũng bắt một trường hợp dễ quên: server đóng kết nối sạch sẽ mà chưa gửi done hay error. Khi đó nó tự phát một sự kiện error với thông báo “Connection closed by proxy server before the response completed” (proxy.ts:219–230); không có kiểm tra này, vòng lặp sẽ chờ mãi một kết quả không bao giờ tới.

runLoop: vòng ngoài và vòng trong#

Hai cửa vào: agentLoop và agentLoopContinue

agentLoop() thêm prompt mới vào context rồi chạy; agentLoopContinue() chạy tiếp từ context có sẵn mà không thêm gì, dùng cho thử lại. Cả hai trả về ngay một EventStream và để vòng lặp chạy nền:

agentLoop(): khởi động vòng lặp, trả về luồng sự kiện
pi/packages/agent/src/agent-loop.tsdòng 47–70TypeScript
export function agentLoop(	prompts: AgentMessage[],	context: AgentContext,	config: AgentLoopConfig,	signal: AbortSignal | undefined,	streamFn: StreamFn,): EventStream<AgentEvent, AgentMessage[]> {	const stream = createAgentStream();	void runAgentLoop(		prompts,		context,		config,		async (event) => {			stream.push(event);		},		signal,		streamFn,	).then((messages) => {		stream.end(messages);	});	return stream;}

Luồng kết thúc khi gặp agent_end, và stream.result() trả về danh sách message mới (agent-loop.ts:162–167). Chú ý hàm emit ở dòng 60–62 chỉ push vào luồng rồi trả về ngay, nên vòng lặp không chờ người tiêu thụ xử lý xong một sự kiện. README gọi các luồng cấp thấp này là “observational”: giữ đúng thứ tự, nhưng không phải rào chắn (README.md:565). Lớp Agent thì gọi thẳng runAgentLoop() với một emit có await, nên mỗi sự kiện trở thành một rào chắn.

runAgentLoop(): phát sự kiện cho prompt rồi vào runLoop
pi/packages/agent/src/agent-loop.tsdòng 111–135TypeScript
export async function runAgentLoop(	prompts: AgentMessage[],	context: AgentContext,	config: AgentLoopConfig,	emit: AgentEventSink,	signal: AbortSignal | undefined,	streamFn: StreamFn,): Promise<AgentMessage[]> {	const initialMessages = declareToolChanges(context, prompts);	const newMessages: AgentMessage[] = [...initialMessages];	const currentContext: AgentContext = {		...context,		messages: [...context.messages, ...initialMessages],	};	await emit({ type: "agent_start" });	await emit({ type: "turn_start" });	for (const message of initialMessages) {		await emit({ type: "message_start", message });		await emit({ type: "message_end", message });	}	await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn());	return newMessages;}

Các message của prompt đi qua declareToolChanges() trước (dòng 119; giải thích ở mục sau), được nối vào một bản sao của transcript, rồi phát thành cặp message_start/message_end ngay sau agent_start và turn_start đầu tiên. runAgentLoopContinue() giống vậy nhưng không có message mới, và từ chối chạy nếu transcript rỗng hoặc kết thúc bằng assistant message (agent-loop.ts:144–150), vì provider sẽ từ chối một request kết thúc bằng câu trả lời của chính mô hình.

Khởi tạo và hai vòng while

runLoop(): state cục bộ và hai vòng lặp
pi/packages/agent/src/agent-loop.tsdòng 172–192TypeScript
async function runLoop(	initialContext: AgentContext,	newMessages: AgentMessage[],	initialConfig: AgentLoopConfig,	signal: AbortSignal | undefined,	emit: AgentEventSink,	streamFunction: StreamFn,): Promise<void> {	let currentContext = initialContext;	let config = initialConfig;	let lastCompletedTurn: PrepareNextTurnContext | undefined;	let explicitContinuation = false;	// Check for steering messages at start (user may have typed while waiting)	let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];	// Outer loop: continues when queued follow-up messages arrive after agent would stop	while (true) {		let hasMoreToolCalls = true;		// Inner loop: process tool calls and steering messages		while (hasMoreToolCalls || pendingMessages.length > 0) {

Toàn bộ state của vòng lặp là vài biến cục bộ. lastCompletedTurn nhớ lượt vừa xong để truyền cho prepareNextTurn. explicitContinuation nhớ rằng finishTurn đã đòi thêm một request. pendingMessages là các tin nhắn sẽ được chèn trước request kế tiếp, và nó được khởi tạo bằng một lần hỏi hàng đợi steer ngay từ đầu (dòng 185), vì người dùng có thể đã gõ trong lúc chờ. hasMoreToolCalls bắt đầu là true nên lần lặp đầu tiên luôn gọi mô hình.

Đầu mỗi lượt: chuẩn bị và chèn tin nhắn chờ

prepareNextTurn, hỏi lại steer, chèn tin nhắn chờ
pi/packages/agent/src/agent-loop.tsdòng 193–226TypeScript
let preparedMessages: AgentMessage[] = [];if (lastCompletedTurn) {	const nextTurnSnapshot = await config.prepareNextTurn?.(lastCompletedTurn);	if (nextTurnSnapshot) {		currentContext = nextTurnSnapshot.context ?? currentContext;		preparedMessages = nextTurnSnapshot.messages ?? [];		config = {			...config,			model: nextTurnSnapshot.model ?? config.model,			reasoning:				nextTurnSnapshot.thinkingLevel === undefined					? config.reasoning					: nextTurnSnapshot.thinkingLevel === "off"						? undefined						: nextTurnSnapshot.thinkingLevel,		};	}	// Preparation can be long-running (for example, compaction). Pick up steering	// queued while it ran. Only poll again if the earlier poll returned nothing;	// otherwise one-at-a-time mode would deliver two messages in this turn.	if (pendingMessages.length === 0) {		pendingMessages = (await config.getSteeringMessages?.()) || [];	}	await emit({ type: "turn_start" });}// Process prepared and queued messages before the next assistant response.for (const message of declareToolChanges(currentContext, [...preparedMessages, ...pendingMessages])) {	await emit({ type: "message_start", message });	await emit({ type: "message_end", message });	currentContext.messages.push(message);	newMessages.push(message);}pendingMessages = [];

Từ lượt thứ hai trở đi (khi lastCompletedTurn đã có), vòng lặp gọi prepareNextTurn. Hook này có thể thay context, model, mức suy nghĩ, hoặc trả về message cần nối thêm; coding agent dùng nó để compaction trước câu trả lời kế tiếp và gắn system message cập nhật prompt. Mức suy nghĩ "off" được dịch thành reasoning: undefined (dòng 202–207). Vì chuẩn bị có thể lâu, vòng lặp hỏi lại hàng đợi steer sau đó, nhưng chỉ khi lần hỏi trước rỗng: chú thích ở dòng 210–212 giải thích rằng hỏi hai lần sẽ khiến chế độ one-at-a-time giao hai tin trong một lượt. Rồi mới phát turn_start.

Dòng 220–224 là chỗ duy nhất tin nhắn chờ, dù là steer, follow-up hay message từ prepareNextTurn, đi vào transcript: mỗi message được phát message_start/message_end, rồi nối vào cả context lẫn newMessages.

Gọi mô hình và lối ra cứng

prepareRequest, gọi mô hình, thoát khi lỗi hoặc bị huỷ
pi/packages/agent/src/agent-loop.tsdòng 228–265TypeScript
const requestUpdate = await config.prepareRequest?.(	{		context: currentContext,		model: config.model,		thinkingLevel: config.reasoning ?? "off",	},	signal,);if (requestUpdate) {	currentContext = requestUpdate.context ?? currentContext;	config = {		...config,		model: requestUpdate.model ?? config.model,		reasoning:			requestUpdate.thinkingLevel === undefined				? config.reasoning				: requestUpdate.thinkingLevel === "off"					? undefined					: requestUpdate.thinkingLevel,	};}// Stream assistant responseconst message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);newMessages.push(message);if (message.stopReason === "error" || message.stopReason === "aborted") {	lastCompletedTurn = {		message,		toolResults: [],		context: currentContext,		newMessages,	};	await config.finishTurn?.(lastCompletedTurn, signal);	await emit({ type: "turn_end", message, toolResults: [] });	await emit({ type: "agent_end", messages: newMessages });	return;}

prepareRequest chạy ngay trước mỗi request, sau khi tin nhắn chờ đã được phát. Coding agent dùng nó để thay toàn bộ context bằng phép chiếu chuẩn dựng từ SessionManager, vốn là nguồn có thẩm quyền cho context của mô hình (sdk.md:40, agent-session.ts:787–800). Kết quả của streamAssistantResponse() được nối vào newMessages bất kể thành công hay không. Nếu stopReason là "error" hoặc "aborted", vòng lặp vẫn gọi finishTurn (để hook quan sát), phát turn_end và agent_end, rồi return: không chạy tool nào, không hỏi hàng đợi nào, và giá trị trả về của finishTurn bị bỏ qua.

Cuối lượt: tool, finishTurn và hàng đợi steer

Batch tool được chạy bởi executeToolCalls(), trừ khi assistant message dừng vì chạm giới hạn token đầu ra (stopReason === "length"); khi đó mọi lời gọi tool trong message bị đánh lỗi mà không chạy (dòng 273–279, xem mục thực thi tool). Sau batch, hasMoreToolCalls là true trừ khi cả batch xin dừng (dòng 281): kết quả tool phải được đưa lại cho mô hình, nên có tool call nghĩa là có thêm một lượt.

Thứ tự ở dòng 295–307 là cố ý: finishTurn chạy trước turn_end nhưng quyết định của nó được áp dụng sau. Với { action: "end" }, vòng lặp phát agent_end ngay, để nguyên hai hàng đợi và không gọi prepareNextTurn (CHANGELOG.md:68). Nếu không, nó hỏi hàng đợi steer (dòng 304). Đây là điểm mà tin nhắn steer đi vào: sau khi mọi tool của lượt đã xong và đã có toolResult, trước lần gọi mô hình kế tiếp. Nếu đã có tool call hoặc tin steer, request tự nhiên kế tiếp đã thoả một { action: "continue" }, nên cờ explicitContinuation được xoá (dòng 305–307).

Khi vòng trong muốn dừng: follow-up

Vòng ngoài: follow-up, continuation tường minh, hoặc dừng
pi/packages/agent/src/agent-loop.tsdòng 310–330TypeScript
		// Agent would stop here. Check for follow-up messages.		const followUpMessages = (await config.getFollowUpMessages?.()) || [];		if (followUpMessages.length > 0) {			// Set as pending so inner loop processes them			explicitContinuation = false;			pendingMessages = followUpMessages;			continue;		}		// No natural request was selected, so fulfill the continuation decision with one context-only turn.		if (explicitContinuation) {			explicitContinuation = false;			continue;		}		// No more messages, exit		break;	}	await emit({ type: "agent_end", messages: newMessages });}

Vòng trong thoát khi lượt vừa rồi không có tool call (hoặc cả batch xin dừng) và hàng đợi steer rỗng. Lúc đó, và chỉ lúc đó, hàng đợi follow-up được hỏi. Có tin thì chúng trở thành pendingMessages và vòng ngoài quay lại vòng trong; không có thì xét continuation tường minh từ finishTurn, cuối cùng là break và agent_end. Đây chính là khác biệt giữa steer và follow-up: steer được hỏi sau mỗi lượt, follow-up chỉ được hỏi khi agent đã xong việc.

streamAssistantResponse: nơi AgentMessage thành Message#

Chú thích đầu file nói rõ nguyên tắc: vòng lặp làm việc với AgentMessage ở mọi chỗ và chỉ biến đổi sang Message[] tại ranh giới gọi mô hình (agent-loop.ts:1–4). Ranh giới đó là nửa đầu của streamAssistantResponse():

Từ transcript tới request: transformContext, convertToLlm, normalizeContext
pi/packages/agent/src/agent-loop.tsdòng 390–419TypeScript
async function streamAssistantResponse(	context: AgentContext,	config: AgentLoopConfig,	signal: AbortSignal | undefined,	emit: AgentEventSink,	streamFunction: StreamFn,): Promise<AssistantMessage> {	// Apply context transform if configured (AgentMessage[] → AgentMessage[])	let messages = context.messages;	if (config.transformContext) {		messages = await config.transformContext(messages, signal);	}	// Convert to LLM-compatible messages (AgentMessage[] → Message[])	const llmMessages = await config.convertToLlm(messages);	const llmContext = normalizeContext({ messages: llmMessages });	// Resolve API key (important for expiring tokens)	const resolvedApiKey =		(config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;	const response = await streamFunction(config.model, llmContext, {		...config,		apiKey: resolvedApiKey,		identity: mergeClientIdentity(DEFAULT_AGENT_IDENTITY, config.identity),		signal,	});	// Record the requested level, whichever stream function answered.	const result = async () => Object.assign(await response.result(), { thinkingLevel: config.reasoning ?? "off" });
README tóm tắt đường đi này là AgentMessage[] → transformContext() → AgentMessage[] → convertToLlm() → Message[] → LLM (README.md:54).
  1. transformContextTuỳ chọn, trên AgentMessage[]: cắt bớt, chèn ngữ cảnh. Coding agent chuyển nó cho sự kiện context của extension.
  2. convertToLlmBắt buộc: lọc message chỉ dành cho UI, đổi message tuỳ biến thành user.
  3. normalizeContextGắn nhãn kiểu TranscriptContext; prompt và tool đã nằm trong system message.
  4. getApiKeyHỏi key cho từng request, rơi về config.apiKey.
  5. streamFunctionGọi với { ...config, apiKey, identity, signal }.

Vì AgentLoopConfig kế thừa SimpleStreamOptions, cả đối tượng cấu hình được rải vào options (dòng 413), nên reasoning, sessionId, transport, thinkingBudgets, onPayload... tự đi tới pi-ai. identity là danh tính client (User-Agent, header attribution), mặc định ghi công cho pi và được gộp từng trường với giá trị của caller (agent-loop.ts:37–41), tính năng mới của 1.4.0. Dòng 419 bọc result() để ghi mức suy nghĩ đã yêu cầu lên assistant message cuối, dù hàm stream nào trả lời.

Nửa sau đọc luồng sự kiện của pi-ai và giữ cho transcript luôn có một assistant message đang lớn dần:

Ở sự kiện start, message dở dang được push thẳng vào context.messages; mỗi sự kiện delta thay phần tử cuối bằng bản partial mới; done hoặc error thay nó bằng message cuối cùng. Nhờ vậy, khi batch tool bắt đầu, context đã chứa đúng assistant message vừa yêu cầu các tool đó, và các hook beforeToolCall thấy nó. Lưu ý message_update chỉ được phát khi đã có start (dòng 442); một luồng chỉ có một sự kiện error (lỗi trước khi sinh) vẫn có đủ cặp message_start/message_end nhờ dòng 461–463.

Thực thi tool: tuần tự, song song, hook và terminate#

Mỗi lời gọi tool đi qua cùng một đường ống bốn bước, bất kể chế độ: chuẩn bị (tìm tool, chuẩn hoá và kiểm tra đối số, beforeToolCall), chạy (execute()), hoàn tất (afterToolCall), rồi phát (tool_execution_end và message toolResult). Chế độ thực thi chỉ quyết định các bước đó của nhiều lời gọi xen kẽ nhau thế nào.

Chọn chế độ cho cả batch
pi/packages/agent/src/agent-loop.tsdòng 518–533TypeScript
async function executeToolCalls(	currentContext: AgentContext,	assistantMessage: AssistantMessage,	config: AgentLoopConfig,	signal: AbortSignal | undefined,	emit: AgentEventSink,): Promise<ExecutedToolCallBatch> {	const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");	const hasSequentialToolCall = toolCalls.some(		(tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential",	);	if (config.toolExecution === "sequential" || hasSequentialToolCall) {		return executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit);	}	return executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit);}

Mặc định là song song (Agent.toolExecution mặc định "parallel", agent.ts:261). Chỉ cần một lời gọi trong batch nhắm vào tool có executionMode: "sequential" là cả batch chạy tuần tự.

Chuẩn bị: từ khối toolCall tới đối số hợp lệ

prepareToolCall(): tìm tool, kiểm tra đối số, beforeToolCall
pi/packages/agent/src/agent-loop.tsdòng 726–765TypeScript
const tool = tools.find((t) => t.name === toolCall.name);if (!tool) {	return {		kind: "immediate",		result: createErrorToolResult(`Tool ${toolCall.name} not found`),		isError: true,	};}try {	const preparedToolCall = prepareToolCallArguments(tool, toolCall);	const validatedArgs = validateToolArguments(tool, preparedToolCall);	if (config.beforeToolCall) {		const beforeResult = await config.beforeToolCall(			{				assistantMessage,				toolCall,				args: validatedArgs,				context: currentContext,			},			signal,		);		if (signal?.aborted) {			return {				kind: "immediate",				result: createErrorToolResult("Operation aborted"),				isError: true,			};		}		if (beforeResult?.block) {			const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked");			if (beforeResult.terminate === true) {				result.terminate = true;			}			return {				kind: "immediate",				result,				isError: true,			};		}

Mọi thất bại ở bước này đều thành một kết quả "immediate" có isError: true, không bao giờ là exception: tool không tồn tại (“Tool X not found”), đối số không qua schema, hook chặn, hoặc run đã bị huỷ. Phần cuối hàm (agent-loop.ts:767–786) kiểm tra abort lần nữa, và bọc mọi exception thành kết quả lỗi mang thông báo của exception, nên mô hình đọc được lý do và có thể sửa lời gọi.

Thứ tự bên trong có ý nghĩa. prepareArguments của tool chạy trước (agent-loop.ts:704–716), rồi validateToolArguments() của pi-ai sao chép đối số, ép kiểu theo schema TypeBox và kiểm tra; nếu JSON của mô hình hỏng, pi-ai đã ghi argumentsError lên khối toolCall và hàm này throw ngay (validation.ts:317–323). beforeToolCall nhận đối số đã kiểm tra. Nó có thể sửa đối tượng đó tại chỗ, và tool chạy với bản đã sửa mà không kiểm tra lại; có hẳn một test cho hành vi này (agent-loop.test.ts:528). Riêng sự kiện tool_execution_start luôn mang toolCall.arguments thô, chưa qua chuẩn hoá.

Chạy và hoàn tất

executePreparedToolCall(): đo thời gian, nhận cập nhật, bắt exception
pi/packages/agent/src/agent-loop.tsdòng 831–867TypeScript
async function executePreparedToolCall(	prepared: PreparedToolCall,	signal: AbortSignal | undefined,	onUpdate: ToolUpdateSink,): Promise<ExecutedToolCallOutcome> {	const updateEvents: Promise<void>[] = [];	let acceptingUpdates = true;	const startedAt = performance.now();	const elapsed = () => Math.round(performance.now() - startedAt);	try {		const result = await prepared.tool.execute(			prepared.toolCall.id,			prepared.args as never,			signal,			(partialResult) => {				if (!acceptingUpdates) return;				updateEvents.push(Promise.resolve(onUpdate(partialResult)));			},		);		const durationMs = elapsed();		acceptingUpdates = false;		await Promise.all(updateEvents);		return { result, isError: result.isError === true, durationMs };	} catch (error) {		const durationMs = elapsed();		acceptingUpdates = false;		await Promise.all(updateEvents);		return {			result: createErrorToolResult(error instanceof Error ? error.message : String(error)),			isError: true,			durationMs,		};	} finally {		acceptingUpdates = false;	}}

Cờ acceptingUpdates là cơ chế khiến các lời gọi onUpdate muộn bị bỏ qua. Thời gian chỉ đo riêng execute(). Exception thành kết quả lỗi với thông báo của nó, đúng như README dặn: tool hỏng thì cứ throw, đừng trả thông báo lỗi trong content (README.md:495).

finalizeExecutedToolCall(): afterToolCall ghi đè từng trường
pi/packages/agent/src/agent-loop.tsdòng 880–911TypeScript
if (config.afterToolCall) {	try {		const afterResult = await config.afterToolCall(			{				assistantMessage,				toolCall: prepared.toolCall,				args: prepared.args,				result,				isError,				context: currentContext,			},			signal,		);		if (afterResult) {			// Structured content not replaced along with the content may no longer match it.			const structuredContent =				afterResult.structuredContent ?? (afterResult.content ? undefined : result.structuredContent);			result = {				...result,				content: afterResult.content ?? result.content,				details: afterResult.details ?? result.details,				usage: afterResult.usage ?? result.usage,				terminate: afterResult.terminate ?? result.terminate,			};			if (structuredContent === undefined) delete result.structuredContent;			else result.structuredContent = structuredContent;			isError = afterResult.isError ?? isError;		}	} catch (error) {		result = createErrorToolResult(error instanceof Error ? error.message : String(error));		isError = true;	}

afterToolCall ghi đè theo từng trường, không gộp sâu: trả về content thì thay cả mảng content. Có một ngoại lệ cố ý ở dòng 894–896: nếu hook thay content mà không trả structuredContent, thì structuredContent cũ bị bỏ, vì có thể nó không còn khớp với content mới. Hook throw thì kết quả trở thành lỗi thay vì làm hỏng cả batch, một sửa lỗi ở 0.67.67 (CHANGELOG.md:419).

Tuần tự và song song khác nhau ở thứ tự sự kiện

Tuần tự: mỗi lời gọi đi trọn bốn bước rồi mới tới lời gọi sau
pi/packages/agent/src/agent-loop.tsdòng 551–588TypeScript
for (const toolCall of toolCalls) {	await emit({		type: "tool_execution_start",		toolCallId: toolCall.id,		toolName: toolCall.name,		args: toolCall.arguments,	});	const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);	let finalized: FinalizedToolCallOutcome;	if (preparation.kind === "immediate") {		finalized = {			toolCall,			result: preparation.result,			isError: preparation.isError,		};	} else {		const executed = await executePreparedToolCall(preparation, signal, emitToolExecutionUpdate(toolCall, emit));		finalized = await finalizeExecutedToolCall(			currentContext,			assistantMessage,			preparation,			executed,			config,			signal,		);	}	await emitToolExecutionEnd(finalized, emit);	const toolResultMessage = createToolResultMessage(finalized);	await emitToolResultMessage(toolResultMessage, emit);	finalizedCalls.push(finalized);	messages.push(toolResultMessage);	if (signal?.aborted) {		break;	}}

Ở chế độ tuần tự, mỗi lời gọi phát tool_execution_start, chạy, phát tool_execution_end và message toolResult, rồi mới tới lời gọi sau. Nếu run bị huỷ, vòng for dừng sau lời gọi đang chạy (dòng 585–587); các lời gọi chưa tới không có kết quả nào trong transcript.

Song song, pha 1: chuẩn bị tuần tự
pi/packages/agent/src/agent-loop.tsdòng 606–627TypeScript
for (const toolCall of toolCalls) {	await emit({		type: "tool_execution_start",		toolCallId: toolCall.id,		toolName: toolCall.name,		args: toolCall.arguments,	});	const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);	if (preparation.kind === "immediate") {		const finalized = {			toolCall,			result: preparation.result,			isError: preparation.isError,		} satisfies FinalizedToolCallOutcome;		await emitToolExecutionEnd(finalized, emit);		finalizedCalls.push(finalized);		if (signal?.aborted) {			break;		}		continue;	}

Ở chế độ song song có hai pha. Pha 1 chuẩn bị tuần tự: mọi tool_execution_start và mọi beforeToolCall chạy lần lượt theo thứ tự trong assistant message. Lời gọi bị chặn hay lỗi đối số được phát tool_execution_end ngay. Lời gọi hợp lệ được gói thành một closure chưa chạy. Pha 2 chạy mọi closure cùng lúc bằng Promise.all; mỗi closure phát tool_execution_end ngay khi nó xong, tức theo thứ tự hoàn thành. Chỉ khi tất cả đã xong, các message toolResult mới được phát, theo thứ tự nguồn (dòng 660–664). Test ở agent-loop.test.ts:668 khẳng định đúng điều đó: tool_execution_end ra ["tool-2", "tool-1"] còn toolResult ra ["tool-1", "tool-2"].

Tuần tự

start #1
end #1    toolResult #1
start #2
end #2    toolResult #2

Song song (#2 xong trước)

start #1   start #2      (pha 1)
end #2     end #1        (thứ tự xong)
toolResult #1
toolResult #2            (thứ tự nguồn)

Vì sao chuẩn bị lại tuần tự? beforeToolCall là chỗ coding agent chạy hook tool_call của extension, nơi kiểm tra quyền và hỏi người dùng. Khi chuyển sang chạy song song ở 0.58.0, CHANGELOG của coding agent ghi rõ preflight tuần tự được giữ lại cho việc chặn của extension (coding-agent/CHANGELOG.md:3296). Hệ quả thấy được: các hộp thoại hỏi quyền hiện lần lượt, theo thứ tự lời gọi trong message. Còn ghi transcript theo thứ tự nguồn thì làm cho transcript được lưu ra là tất định, không phụ thuộc tool nào chạy nhanh hơn.

terminate: dừng sau batch

Một tool có thể trả terminate: true để gợi ý rằng không cần gọi mô hình thêm lần nào sau batch này, ví dụ một tool “báo xong việc”. Hook beforeToolCall (khi chặn) và afterToolCall cũng đặt được cờ này. Quy tắc mang tính cả batch: chỉ khi mọi kết quả đã hoàn tất đều đặt terminate, vòng lặp mới không tự gọi mô hình tiếp (shouldTerminateToolBatch, agent-loop.ts:700–702). Một batch lẫn lộn chạy bình thường. Và terminate chỉ bỏ qua lần gọi tự động; tin steer hay follow-up đang chờ vẫn được xử lý.

Lời gọi tool bị cắt cụt vì giới hạn token

stopReason length: không chạy lời gọi nào trong message
pi/packages/agent/src/agent-loop.tsdòng 481–513TypeScript
/** * Fail all tool calls from an assistant message that was truncated by the * output token limit. Streamed tool-call arguments are finalized with a * best-effort JSON salvage parser, so a truncated message can yield tool calls * whose arguments parse and validate but are silently incomplete. None of them * are safe to execute; report each as an error so the model can re-issue them. */async function failToolCallsFromTruncatedMessage(	toolCalls: AgentToolCall[],	emit: AgentEventSink,): Promise<ExecutedToolCallBatch> {	const messages: ToolResultMessage[] = [];	for (const toolCall of toolCalls) {		await emit({			type: "tool_execution_start",			toolCallId: toolCall.id,			toolName: toolCall.name,			args: toolCall.arguments,		});		const finalized: FinalizedToolCallOutcome = {			toolCall,			result: createErrorToolResult(				`Tool call "${toolCall.name}" was not executed: the response hit the output token limit, so its arguments may be truncated. Re-issue the tool call with complete arguments.`,			),			isError: true,		};		await emitToolExecutionEnd(finalized, emit);		const toolResultMessage = createToolResultMessage(finalized);		await emitToolResultMessage(toolResultMessage, emit);		messages.push(toolResultMessage);	}	return { messages, terminate: false };}

Khi mô hình chạm giới hạn token đầu ra giữa chừng, đối số tool đang stream bị cắt. Bộ đọc JSON cứu vãn của pi-ai vẫn có thể dựng ra một đối tượng hợp lệ, chỉ là thiếu nội dung, và nó còn qua được schema. Chạy lời gọi đó (ví dụ một lệnh write với nội dung bị cụt) là nguy hiểm. Vì vậy mọi lời gọi trong message có stopReason === "length" đều bị trả lỗi, kèm lời nhắn bảo mô hình gửi lại lời gọi với đối số đầy đủ, và terminate: false bảo đảm mô hình được gọi lại để làm việc đó. Test ở agent-loop.test.ts:456 kiểm tra rằng tool không bao giờ chạy và vòng lặp gọi mô hình lần thứ hai.

runToolCall: cùng đường ống cho lời gọi lồng nhau

runToolCall(): chuẩn bị, chạy, hoàn tất, không phát sự kiện
pi/packages/agent/src/agent-loop.tsdòng 812–829TypeScript
/** * Run one tool call through the same steps as a model-issued call: argument preparation, schema * validation, `beforeToolCall`, execution, and `afterToolCall`. Emits no events and adds no * messages. Tools that call other tools use this so the hooks (for example permission checks) * apply to those calls too. * * Never rejects for tool failures: unknown tools, validation errors, blocked calls, and thrown * errors come back as `isError: true`. */export async function runToolCall(toolCall: AgentToolCall, options: RunToolCallOptions): Promise<AgentToolCallOutcome> {	const { assistantMessage, context, signal } = options;	const preparation = await prepareToolCall(context, assistantMessage, toolCall, options, signal, options.tools);	if (preparation.kind === "immediate") {		return { toolCall, result: preparation.result, isError: preparation.isError };	}	const executed = await executePreparedToolCall(preparation, signal, options.onUpdate ?? (() => {}));	return finalizeExecutedToolCall(context, assistantMessage, preparation, executed, options, signal);}

Một tool đôi khi gọi tool khác, ví dụ script codemode gọi tools.read(...). Nếu nó gọi thẳng execute(), các hook kiểm tra quyền bị bỏ qua. runToolCall() chạy đúng các bước chuẩn bị, chạy và hoàn tất như lời gọi của mô hình, nhưng không phát sự kiện và không thêm message. Ví dụ examples/mcp-codemode dùng nó cho tool codemode (tools.ts:83–103); chi tiết ở chương Codemode.

declareToolChanges: báo thay đổi bộ tool giữa hội thoại#

Vấn đề. Bộ tool chạy được có thể đổi giữa hội thoại: extension bật một tool, người dùng tắt bash, một server MCP nạp thêm tool. Nếu mỗi request gửi danh sách tool hiện tại ở một trường riêng, thì mọi thay đổi đều làm đổi tiền tố của request và prompt cache bị trượt. Bài “Prompt Caching In Agents” của Earendil nói đúng điều này: thay đổi bộ tool phá cache, và cách sửa là nạp tool kiểu cộng dồn, neo vào transcript (Prompt Caching In Agents). Bài đó (07/2026) mô tả cơ chế lúc ấy là ghi tên tool mới lên kết quả tool (addedToolNames, 0.80.7); từ 0.86.0, prompt và khai báo tool chuyển hẳn vào các system message của transcript (ai/CHANGELOG.md:284), và đó là cơ chế ở v1.4.0.

Giải pháp. SystemMessage của pi-ai có toolsAdded (định nghĩa đầy đủ) và toolsRemoved (chỉ tên) (ai/types.ts:544–570). Phát lại mọi system message theo thứ tự cho ra bộ tool hiện hành (getCurrentTools). Còn context.tools là thứ runtime chạy được. Trước mỗi request, vòng lặp so hai thứ đó và, nếu khác, chèn một system message ghi phần chênh lệch:

Hai trường hợp. Nếu trong các tin nhắn chờ không có system message nào, một system message mới với content: "" và phần chênh lệch được chèn trước tin nhắn chờ đầu tiên không phải system (dòng 367–371). Nếu caller đã tự gửi kèm một system message (chẳng hạn để đổi một mục prompt), phần tool của message đó bị viết lại cho khớp bộ chạy được: bộ chạy được luôn thắng, dù message có “xin” thêm hay bớt tool nào (dòng 351–355, 362–366; xem test ở agent.test.ts:247). Một tool đổi định nghĩa được tính là bỏ rồi thêm (transcript.ts:149).

Hàm được gọi ở hai chỗ: cho các message của prompt (agent-loop.ts:119) và trước mỗi request trong vòng trong (:220), kể cả khi không có tin nhắn chờ nào. Test ở agent.test.ts:164–198 cho thấy hiệu quả: sau khi đổi agent.state.tools từ [first] sang [second], request kế tiếp mang thêm một system message +second, -first, còn các message trước đó, kể cả system message đầu tiên, không bị sửa. Provider nào nhận được system message giữa hội thoại thì gửi tại chỗ; provider khác được pi-ai gộp lại thành một system message đầu (chương pi-ai).

Lớp Agent: state, hàng đợi và vòng đời một lần chạy#

Agent thêm vào vòng lặp ba thứ mà một ứng dụng tương tác cần: state sống lâu hơn một lần chạy, hai hàng đợi tin nhắn, và một vòng đời rõ ràng (đang chạy, huỷ, chờ rảnh).

State chỉ đổi qua sự kiện

Ảnh chụp context và cấu hình cho mỗi lần chạy
pi/packages/agent/src/agent.tsdòng 469–480, 503–515TypeScript
private createContextSnapshot(): AgentContext {	return {		messages: this._state.messages.slice(),		tools: this._state.tools.slice(),	};}private createLoopConfig(options: { skipInitialSteeringPoll?: boolean } = {}): AgentLoopConfig {	let skipInitialSteeringPoll = options.skipInitialSteeringPoll === true;	return {		model: this._state.model,		reasoning: this._state.thinkingLevel === "off" ? undefined : this._state.thinkingLevel,		convertToLlm: this.convertToLlm,		transformContext: this.transformContext,		getApiKey: this.getApiKey,		getSteeringMessages: async () => {			if (skipInitialSteeringPoll) {				skipInitialSteeringPoll = false;				return [];			}			return this.steeringQueue.drain();		},		getFollowUpMessages: async () => this.followUpQueue.drain(),	};}

Mỗi lần chạy, Agent đưa cho vòng lặp một bản sao của transcript và bộ tool (dòng 469–474). Vòng lặp chỉ sửa bản sao đó. State của Agent thay đổi ở một chỗ duy nhất, hàm processEvents(), như một reducer chạy trên từng sự kiện, rồi mới await từng listener theo thứ tự đăng ký:

processEvents(): cập nhật state, rồi await listener
pi/packages/agent/src/agent.tsdòng 575–588, 604–621TypeScript
private async processEvents(event: AgentEvent): Promise<void> {	switch (event.type) {		case "message_start":			this._state.streamingMessage = event.message;			break;		case "message_update":			this._state.streamingMessage = event.message;			break;		case "message_end":			this._state.streamingMessage = undefined;			this._state.messages.push(event.message);			break;		case "turn_end":			if (event.message.role === "assistant" && event.message.errorMessage) {				this._state.errorMessage = event.message.errorMessage;			}			break;		case "agent_end":			this._state.streamingMessage = undefined;			break;	}	const signal = this.activeRun?.abortController.signal;	if (!signal) {		throw new Error("Agent listener invoked outside active run");	}	for (const listener of this.listeners) {		await listener(event, signal);	}
Sự kiệnThay đổi trong agent.state
message_start, message_updatestreamingMessage = message đang stream
message_endxoá streamingMessage, messages.push(message)
tool_execution_start / _endthêm / bớt id trong pendingToolCalls (mỗi lần một Set mới, agent.ts:590–602)
turn_enderrorMessage nếu assistant message có lỗi
agent_endxoá streamingMessage

Vì emit của Agent await toàn bộ chuỗi trên, mỗi sự kiện là một rào chắn: vòng lặp không đi tiếp cho tới khi state đã cập nhật và mọi listener đã xong. README nêu hệ quả quan trọng nhất: khi beforeToolCall chạy, state đã chứa assistant message yêu cầu tool đó (README.md:122). Listener còn nhận AbortSignal của lần chạy để huỷ việc của chính nó.

prompt() và continue()

prompt() và continue()
pi/packages/agent/src/agent.tsdòng 382–420TypeScript
async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]): Promise<void> {	if (this.activeRun) {		throw new Error(			"Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.",		);	}	const messages = this.normalizePromptInput(input, images);	await this.runPromptMessages(messages);}/** Continue from the current transcript. The last message must be a user or tool-result message. */async continue(): Promise<void> {	if (this.activeRun) {		throw new Error("Agent is already processing. Wait for completion before continuing.");	}	const lastMessage = this._state.messages[this._state.messages.length - 1];	if (!lastMessage || this._state.messages.every((message) => message.role === "system")) {		throw new Error("No messages to continue from");	}	if (lastMessage.role === "assistant") {		const queuedSteering = this.steeringQueue.drain();		if (queuedSteering.length > 0) {			await this.runPromptMessages(queuedSteering, { skipInitialSteeringPoll: true });			return;		}		const queuedFollowUps = this.followUpQueue.drain();		if (queuedFollowUps.length > 0) {			await this.runPromptMessages(queuedFollowUps);			return;		}		throw new Error("Cannot continue from message role: assistant");	}	await this.runContinuation();}

prompt() nhận một chuỗi (kèm ảnh tuỳ chọn), một message hoặc một mảng message; chuỗi được bọc thành một message user (agent.ts:422–439). Gọi prompt() khi đang chạy là lỗi, với lời nhắn chỉ rõ cách đúng: dùng steer() hoặc followUp(). Quy tắc này có từ 0.32.0 để tránh hai lần chạy giẫm lên cùng một transcript (CHANGELOG.md:749).

continue() có ba nhánh. Transcript rỗng hoặc chỉ có system message thì từ chối. Đuôi không phải assistant (thường là user hoặc toolResult) thì chạy runAgentLoopContinue(), ví dụ để thử lại sau lỗi. Đuôi là assistant thì không thể gửi thẳng, nên nó lấy một lô từ hàng đợi steer, nếu rỗng thì một lô từ hàng đợi follow-up, và chạy lô đó như một prompt. Khi lô đến từ hàng đợi steer, lần hỏi steer đầu tiên của vòng lặp bị bỏ qua một lần (cờ skipInitialSteeringPoll), nếu không chế độ one-at-a-time sẽ giao hai tin steer trong cùng một lượt.

Hai hàng đợi

PendingMessageQueue: all hoặc one-at-a-time
pi/packages/agent/src/agent.tsdòng 149–180TypeScript
class PendingMessageQueue {	private messages: AgentMessage[] = [];	public mode: QueueMode;	constructor(mode: QueueMode) {		this.mode = mode;	}	enqueue(message: AgentMessage): void {		this.messages.push(message);	}	hasItems(): boolean {		return this.messages.length > 0;	}	peek(): AgentMessage[] {		if (this.mode === "all") return this.messages.slice();		const first = this.messages[0];		return first ? [first] : [];	}	drain(): AgentMessage[] {		const drained = this.peek();		this.messages = this.messages.slice(drained.length);		return drained;	}	clear(): void {		this.messages = [];	}}

Cả hai hàng đợi mặc định "one-at-a-time" (agent.ts:255–256): mỗi điểm rút chỉ lấy tin cũ nhất, tin còn lại chờ điểm rút sau. Với "all", mọi tin chờ được chèn cùng lúc. steer() và followUp() chỉ là enqueue đồng bộ (agent.ts:307–315); vòng lặp tự hỏi hàng đợi ở các điểm đã thấy trong runLoop, thông qua hai closure getSteeringMessages/getFollowUpMessages mà createLoopConfig() nối vào drain(). peekQueuedMessages() cho biết lô nào sẽ được chọn kế tiếp mà không lấy ra, với steer được ưu tiên hơn follow-up (agent.ts:338–342).

Vòng đời: chạy, huỷ, chờ rảnh, reset

runWithLifecycle() và finishRun()
pi/packages/agent/src/agent.tsdòng 517–540, 560–566TypeScript
private async runWithLifecycle(executor: (signal: AbortSignal) => Promise<void>): Promise<void> {	if (this.activeRun) {		throw new Error("Agent is already processing.");	}	const abortController = new AbortController();	let resolvePromise = () => {};	const promise = new Promise<void>((resolve) => {		resolvePromise = resolve;	});	this.activeRun = { promise, resolve: resolvePromise, abortController };	this._state.isStreaming = true;	this._state.streamingMessage = undefined;	this._state.errorMessage = undefined;	try {		await executor(abortController.signal);	} catch (error) {		await this.handleRunFailure(error, abortController.signal.aborted);	} finally {		this.finishRun();	}}private finishRun(): void {	this._state.isStreaming = false;	this._state.streamingMessage = undefined;	this._state.pendingToolCalls = new Set<string>();	this.activeRun?.resolve();	this.activeRun = undefined;}

Mỗi lần chạy có một AbortController riêng và một promise được resolve trong finishRun(). abort() chỉ gọi abortController.abort() (agent.ts:349–352); tín hiệu đó tới hàm stream, tới execute() của tool và tới các hook, và mỗi bên tự tôn trọng nó. waitForIdle() trả về chính promise kia (agent.ts:359–361), nên nó resolve sau khi listener của agent_end đã xong, cùng lúc với prompt().

Nếu vòng lặp throw (ví dụ convertToLlm vi phạm hợp đồng), handleRunFailure() dựng một assistant message rỗng với stopReason "aborted" hoặc "error" và phát đủ message_start, message_end, turn_end, agent_end (agent.ts:542–558), để giao diện không bao giờ bị treo ở trạng thái “đang chạy”. reset() từ chối khi đang chạy, xoá hai hàng đợi và thu transcript về đúng một system message phát lại từ mọi system message cũ, nên prompt và bộ tool hiện hành được giữ (agent.ts:363–377).

Trace: một prompt, hai lời gọi tool và một tin nhắn steer#

Ghép mọi mảnh lại bằng một kịch bản cụ thể. Một Agent dùng cấu hình mặc định (tool chạy song song, hàng đợi one-at-a-time, không hook nào), có một tool read. Người dùng gửi một prompt; mô hình gọi read hai lần trong cùng một câu trả lời; trong lúc hai tool đang chạy, người dùng gõ thêm một tin steer.

Ví dụ minh hoạ: cách gọi
const run = agent.prompt("Đọc a.txt và b.txt rồi tóm tắt");
// ... trong lúc hai tool read đang chạy, từ một handler của giao diện:
agent.steer({ role: "user", content: "Chỉ tóm tắt a.txt thôi", timestamp: Date.now() });
await run;

Đây là chuỗi sự kiện chính xác mà subscriber nhận được, cùng với dòng code phát ra mỗi sự kiện. Giả sử b.txt đọc xong trước a.txt.

Ví dụ minh hoạ: chuỗi AgentEvent của lần chạy
 #   sự kiện                                   nguồn (agent-loop.ts)    agent.state sau reducer
 1   agent_start                               runAgentLoop :126        (isStreaming đã true từ trước)
 2   turn_start                                :127                     lượt 1
 3   message_start  user "Đọc a.txt và b.txt…"   :129
 4   message_end    user                       :130                     messages += user
     runLoop: hỏi steer lần đầu → rỗng; declareToolChanges → không đổi; gọi mô hình
 5   message_start  assistant (partial rỗng)   :430                     streamingMessage = partial
 6   message_update toolcall_start/delta/end   :445   (#0 call_1 read {path:"a.txt"})
 7   message_update toolcall_start/delta/end   :445   (#1 call_2 read {path:"b.txt"})
 8   message_end    assistant, stopReason "toolUse"  :464               messages += assistant
     executeToolCallsParallel, pha 1: chuẩn bị tuần tự
 9   tool_execution_start call_1               :607                     pendingToolCalls {call_1}
10   tool_execution_start call_2               :607                     {call_1, call_2}
     pha 2: hai execute() chạy cùng lúc; người dùng gọi agent.steer(...) → vào hàng đợi
11   tool_execution_end   call_2               :648  thứ tự hoàn thành   {call_1}
12   tool_execution_end   call_1               :648                     {}
13   message_start  toolResult call_1          :957  thứ tự nguồn
14   message_end    toolResult call_1          :958                     messages += toolResult 1
15   message_start  toolResult call_2          :957
16   message_end    toolResult call_2          :958                     messages += toolResult 2
17   turn_end       {assistant, toolResults: [call_1, call_2]}  :296
     hỏi hàng đợi steer → [tin steer]; hasMoreToolCalls = true
18   turn_start                                :216                     lượt 2
19   message_start  user "Chỉ tóm tắt a.txt thôi"  :221
20   message_end    user                       :222                     messages += tin steer
21   message_start  assistant (partial)        :430
22   message_update text_start, text_delta…, text_end   :445
23   message_end    assistant, stopReason "stop"  :464                  messages += assistant
24   turn_end       {assistant, toolResults: []}  :296
     hỏi steer → rỗng, vòng trong thoát; hỏi follow-up → rỗng; không có continue → dừng
25   agent_end      messages: [user, asst₁, toolResult₁, toolResult₂, steer, asst₂]  :329
     listener của agent_end chạy xong → finishRun(): isStreaming = false; prompt() resolve
Một lần chạy, hai lượt. Số sau dấu hai chấm là dòng trong agent-loop.ts phát sự kiện; số lần message_update thực tế tuỳ provider.

Những điều trace này cho thấy:

  • Tin steer không cắt ngang tool. Nó nằm trong hàng đợi suốt lúc hai tool chạy và chỉ được rút ở dòng 304, sau turn_end của lượt 1. Không tool nào bị bỏ qua.
  • Tin steer không tốn thêm request. Lượt 2 vốn đã xảy ra vì có tool call (hasMoreToolCalls); tin steer chỉ được chèn vào trước request đó. Mô hình thấy theo thứ tự: hai toolResult, rồi chỉ dẫn mới.
  • agent.state.messages lớn dần đúng theo các message_end; lúc listener xử lý sự kiện 9, state đã có assistant message của sự kiện 8.
  • Nếu thay steer() bằng followUp(), tin nhắn sẽ chờ tới sau sự kiện 24, khi agent lẽ ra đã dừng, và tạo ra lượt thứ ba. Nếu câu trả lời đầu không gọi tool nào, tin steer vẫn tạo ra lượt 2, vì vòng trong chạy tiếp khi còn tin nhắn chờ.
  • Ở chế độ tuần tự, sự kiện 9–16 sẽ thành start 1, end 1, toolResult 1, start 2, end 2, toolResult 2.

Quyết định thiết kế#

Lặp cho tới khi mô hình thôi gọi tool, không có max steps

Code ở v1.4.0 giữ đúng lựa chọn đó: AgentLoopConfig không có trường đếm bước nào, và điều kiện của vòng trong chỉ là hasMoreToolCalls || pendingMessages.length > 0. Đánh đổi là một mô hình mắc kẹt có thể lặp mãi. Thay vì một con số tuỳ tiện, các điểm dừng là có chủ đích: người dùng huỷ, finishTurn trả end, cả batch xin terminate, hoặc beforeToolCall chặn tool. Một ứng dụng muốn giới hạn số lượt hoàn toàn viết được giới hạn đó trong finishTurn.

Hàng đợi tin nhắn giữa các lượt, không cắt ngang giữa lượt

Bài blog mô tả ý tưởng gốc: “after each turn, it asks for queued messages and injects them before the next assistant response”, với hai chế độ “one-at-a-time or all-at-once”. Thiết kế này đã đổi hai lần. Ở 0.32.0, một hàng đợi duy nhất tách thành steer() và followUp(), và khi đó steer còn bỏ qua các tool còn lại của batch (CHANGELOG.md:737–739). Ở 0.58.4, steer chuyển sang chờ cả batch tool chạy xong (CHANGELOG.md:507). Hệ quả có thể thấy trực tiếp trong code: mọi lời gọi tool đều có một kết quả thật, và transcript không bao giờ chứa nửa batch. Cái giá là độ trễ: tin steer phải chờ tool chậm nhất của batch. Muốn dừng ngay thì đã có abort().

AgentMessage đi tới tận ranh giới gọi mô hình

Ở 0.31.0, messageTransformer đổi tên thành convertToLlm, và việc xử lý tệp đính kèm chuyển hẳn sang hàm này (CHANGELOG.md:761, :768). Lý do là transcript phải chứa được mọi thứ ứng dụng cần nhớ, còn mỗi request chỉ cần phần mô hình hiểu. Giữ hai kho riêng sẽ buộc ứng dụng tự đồng bộ thứ tự giữa chúng; một kho với một phép chiếu ở ranh giới thì không. Lệnh !!cmd của coding agent là ví dụ: nó nằm trong transcript và hiện trên giao diện, nhưng convertToLlm bỏ nó khỏi request.

State dựng từ sự kiện, listener là rào chắn

Ở 0.65.0, listener của subscribe() bắt đầu được await và nhận AbortSignal; agent_end thôi là ranh giới rảnh (CHANGELOG.md:471–475). Đánh đổi rõ ràng: một listener chậm làm chậm cả vòng lặp. Đổi lại, mọi việc listener làm cho một sự kiện, như ghi session hay chạy extension, đã xong trước khi vòng lặp sang bước kế tiếp, và người gọi await prompt() biết chắc mọi việc đó đã xong khi promise resolve.

Song song là mặc định, nhưng chuẩn bị và transcript thì tuần tự

Chế độ song song có từ 0.58.0 và là mặc định ngay từ đầu (CHANGELOG.md:523); ở 0.68.1, tool_execution_end chuyển sang phát theo thứ tự hoàn thành để giao diện cập nhật ngay khi từng tool xong (:405). Thiết kế ba tầng (chuẩn bị tuần tự, chạy song song, ghi transcript theo thứ tự nguồn) giữ được tốc độ mà không đánh đổi tính tất định của những gì được lưu và gửi lại cho mô hình. Tool nào không an toàn khi chạy đồng thời thì tự khai báo executionMode: "sequential".

Hook là trường public, có thể bọc chồng

Từ 0.65.0, Agent bỏ các setter và để mọi hook là trường public (CHANGELOG.md:454–466). Nhờ vậy nhiều lớp có thể bọc chồng lên nhau mà không cần kế thừa: AgentSession giữ hook cũ trong previousPrepareRequest và previousFinishTurn, rồi gọi nó bên trong hook mới (agent-session.ts:887–895). agent-core vì thế giữ được kích thước nhỏ: nó chỉ định nghĩa khi nào một hook chạy, còn làm gì trong hook thuộc về ứng dụng. Bài “What is a Harness?” của Earendil đặt vòng lặp agent cạnh system prompt, bộ tool và lớp dịch như các phần của harness (What is a Harness?); trong Pi, chỉ phần vòng lặp nằm ở gói này.

Cạm bẫy và trường hợp biên#

  • Throw trong hook cấp thấp làm treo luồng. agentLoop() chạy runAgentLoop(...).then(...) mà không có catch (agent-loop.ts:56–67). Nếu convertToLlm hay transformContext throw, promise bị reject mà không ai bắt và luồng không bao giờ kết thúc. Lớp Agent bắt được trường hợp này nhờ handleRunFailure().
  • agentLoopContinue() sửa mảng của bạn. Nó chỉ sao chép nông context (agent-loop.ts:153), nên các message mới được push thẳng vào context.messages mà caller truyền vào. agentLoop() thì tạo mảng mới (dòng 121–124). Agent luôn truyền một bản sao nên không bị ảnh hưởng.
  • Đổi state giữa lần chạy không có hiệu lực ngay. Model, mức suy nghĩ và bộ tool được chụp lúc bắt đầu chạy (createContextSnapshot(), createLoopConfig()). Gán agent.state.tools trong lúc chạy chỉ có tác dụng ở lần chạy sau, trừ khi một hook như prepareNextTurn trả về context mới; đó chính là việc AgentSession làm (agent-session.ts:919–925).
  • Abort không làm vòng lặp dừng ngay. Vòng lặp chỉ tự kiểm tra tín hiệu quanh các lời gọi tool: khi chuẩn bị, ngay trước khi chạy một closure song song, và sau mỗi tool tuần tự. Nếu huỷ trong lúc tool chạy, nó vẫn phát kết quả, phát turn_end, rồi gửi request kế tiếp và dựa vào hợp đồng của StreamFn để nhận về ngay một message "aborted". Một StreamFn tự viết mà phớt lờ signal sẽ khiến lần chạy tiếp tục. Ở bước đó hàng đợi steer cũng đã bị hỏi (dòng 304), nên một tin steer đang chờ có thể được ghi vào transcript ngay trước câu trả lời bị huỷ.
  • Lời gọi tool không có kết quả. Huỷ giữa batch tuần tự để lại các lời gọi chưa chạy mà không có toolResult. pi-ai xử lý việc này khi dựng request: nó bỏ qua assistant message bị lỗi hoặc bị huỷ, và chèn kết quả lỗi “No result provided” cho lời gọi mồ côi (transform-messages.ts:179–224).
  • { action: "continue" } vô điều kiện là vòng lặp vô tận, vì finishTurn chạy lại sau request mà chính nó đòi.
  • continue() với đuôi assistant chỉ chạy được khi có tin chờ; nếu không, nó throw “Cannot continue from message role: assistant”. Đuôi là message tuỳ biến thì vòng lặp cấp thấp không kiểm tra được; convertToLlm phải biến nó thành user hoặc toolResult, nếu không provider sẽ từ chối (agent-loop.ts:76–78).
  • terminate không lưu vào transcript. Nó chỉ là gợi ý lúc chạy; message toolResult được lưu vẫn là kết quả tool chuẩn (README.md:509).
  • README còn sót cách nói cũ. README viết “LLMs only understand user, assistant, and toolResult” và ví dụ convertToLlm cấp thấp lọc bỏ cả role system (README.md:49, :546). Ở v1.4.0 system message mang prompt và khai báo tool, nên bộ lọc như vậy làm mất cả hai; bộ lọc mặc định của Agent giữ lại system.

Đọc code ở đâu#

Thứ tựFileĐọc để hiểu
1agent/README.mdChuỗi sự kiện mẫu, các hook, hàng đợi. Đọc trước để có bức tranh.
2agent/src/types.tsToàn bộ data model và hợp đồng của từng hook, nằm trong chú thích.
3agent-loop.ts:172runLoop(): hai vòng lặp, chỗ hỏi hàng đợi, lối ra.
4agent-loop.ts:390Ranh giới AgentMessage → Message và partial message trong context.
5agent-loop.ts:518Đường ống tool, hai chế độ, hook, terminate, runToolCall().
6agent-loop.ts:342Khai báo thay đổi bộ tool bằng system message; đọc cùng ai/src/utils/transcript.ts.
7agent/src/agent.tscreateLoopConfig(), processEvents(), continue(), vòng đời một lần chạy.
8agent/src/proxy.ts, stream-fn.tsLớp vận chuyển: hàm stream mặc định và proxy.
9test/agent-loop.test.ts, test/agent.test.tsĐặc tả chạy được cho thứ tự sự kiện, steer, terminate, khai báo tool.
10sdk.ts:403, agent-session.ts:511Coding agent cấu hình và bọc Agent thế nào; tiếp tục ở chương AgentSession.
11agent/CHANGELOG.mdLịch sử từng quyết định: steer/follow-up, hook, chế độ song song, finishTurn.

Tóm tắt#

  • pi-agent-core là một vòng lặp agent nhỏ (khoảng 2.500 dòng) trên pi-ai: gọi mô hình, chạy batch tool, lặp cho tới khi mô hình thôi gọi tool. Không có max steps.
  • Transcript là AgentMessage[], mở rộng được bằng declaration merging; chỉ ở ranh giới gọi mô hình nó mới thành Message[] qua transformContext và convertToLlm.
  • Vận chuyển chỉ là một hàm StreamFn không được throw; streamProxy() là một cài đặt qua server.
  • Mỗi tool call đi qua chuẩn bị, chạy, hoàn tất; chế độ song song chuẩn bị tuần tự, chạy đồng thời và ghi transcript theo thứ tự nguồn. Lời gọi từ message bị cắt cụt ("length") không bao giờ chạy.
  • Tin steer được chèn sau batch tool của lượt hiện tại, follow-up chỉ khi agent sắp dừng; cả hai mặc định one-at-a-time.
  • Bộ tool chạy được và bộ tool mô hình thấy được giữ khớp nhau bằng system message toolsAdded/toolsRemoved trong transcript, để thay đổi giữa hội thoại không phá prompt cache.
  • Lớp Agent dựng state từ sự kiện, await listener như rào chắn, và cung cấp prompt(), continue(), steer(), followUp(), abort(), waitForIdle(), reset().

7AgentSession: vòng đời một prompt#

Vai trò#

Agent của pi-agent-core chỉ biết chạy vòng lặp agent (agent loop) trên một mảng message nằm trong bộ nhớ: gửi yêu cầu, nhận luồng (stream) phản hồi, chạy tool, lặp lại cho đến khi mô hình thôi gọi tool (xem chương pi-agent-core). Nó không biết tệp session, settings, extension hay lệnh /compact. Một coding agent dùng hằng ngày cần nhiều hơn: mỗi message phải được ghi xuống đĩa ngay khi hoàn tất, prompt phải được mở rộng (prompt template, skill), lỗi provider (nhà cung cấp) tạm thời phải được thử lại, ngữ cảnh gần đầy phải được nén, đổi mô hình (model) phải được ghi lại để lần mở phiên sau khôi phục đúng.

AgentSession trong packages/coding-agent/src/core/agent-session.ts là lớp làm tất cả những việc đó. Chú thích đầu tệp liệt kê đúng phạm vi của nó:

Chú thích đầu tệp agent-session.ts
pi/packages/coding-agent/src/core/agent-session.tsdòng 1–14TypeScript
/** * AgentSession - Core abstraction for agent lifecycle and session management. * * This class is shared between all run modes (interactive, print, rpc). * It encapsulates: * - Agent state access * - Event subscription with automatic session persistence * - Model and thinking level management * - Compaction (manual and auto) * - Bash execution * - Session switching and branching * * Modes use this class and add their own I/O layer on top. */
Lớp này dùng chung cho mọi chế độ chạy; mỗi chế độ chỉ thêm lớp vào/ra của riêng mình.

Ai gọi nó? Mọi chế độ chạy trong chương các chế độ: interactive gọi session.prompt(text) khi người dùng nhấn Enter (vòng lặp chính ở interactive-mode.ts:1253), RPC mode chuyển lệnh JSON thành các lời gọi tương tự, print mode gửi một prompt rồi thoát, còn SDK trả thẳng đối tượng AgentSession cho mã của bạn. Ba cộng sự cốt lõi được giữ dưới dạng trường readonly: agent, sessionManager, settingsManager (agent-session.ts:379).

Ngoài ba trường đó, nó giữ ModelRuntime (tra mô hình và credential), ResourceLoader (extension, skill, prompt template, context file), ExtensionRunner (phát sự kiện cho extension) và sổ đăng ký tool. Vị trí của nó trong cả hệ thống:

Chế độ chạy
interactive, print, JSON, RPC, SDK: đọc đầu vào, vẽ sự kiện ra màn hình (chương các chế độ, pi-tui).
AgentSessionRuntime
Sở hữu phiên hiện tại và các dịch vụ gắn với cwd; thay phiên khi /new, /resume, /fork.
AgentSession
Chương này: mở rộng prompt, ghi session, retry, kích hoạt compaction, đổi mô hình, hàng đợi, lệnh !.
Agent (pi-agent-core)
Vòng lặp agent: lượt, lời gọi tool, hàng đợi steer/follow-up (chương pi-agent-core).
ModelRuntime → pi-ai
Giải quyết credential, gửi yêu cầu tới provider (chương pi-ai).

Bên cạnh trục dọc đó là SessionManager (tệp JSONL, chương Session), SettingsManager, ExtensionRunner (chương Extensions) và các tool cùng system prompt (chương Công cụ).

Mô hình tư duy#

Có một câu đủ để giữ trong đầu suốt chương: tệp session là bản gốc, mảng message của Agent chỉ là bản chiếu. AgentSession nối hai bên theo hai chiều. Chiều đi lên: nó đăng ký nghe mọi sự kiện (event) của Agent và ghi từng message đã hoàn tất thành một entry trong session. Chiều đi xuống: nó cài các hook vào Agent để ngay trước mỗi yêu cầu gửi tới mô hình, ngữ cảnh được dựng lại từ session, chứ không lấy mảng trong bộ nhớ của Agent.

   Chế độ chạy ── prompt(text) / steer / followUp / abort / executeBash
        │
┌───────▼──────────────┐   subscribe(_handleAgentEvent)   ┌──────────────────────┐
│     AgentSession     │◄──────── sự kiện của Agent ──────│        Agent         │
│                      │                                   │   (pi-agent-core)    │
│  hook cài lúc khởi   │── prepareRequest ────────────────►│  vòng lặp agent:     │
│  tạo (constructor):  │── prepareNextTurnWithContext ────►│  lượt → tool → lượt  │
│                      │── finishTurn ────────────────────►│                      │
│                      │── beforeToolCall / afterToolCall ►│                      │
│                      │── transformContext (2 lớp) ──────►│                      │
└──┬────────┬───────┬──┘                                   └──────────┬───────────┘
   │append*()│emit()  │get*()                                          │ streamFn
   ▼        ▼       ▼                                                 ▼
SessionManager  ExtensionRunner  SettingsManager              ModelRuntime → provider
 (tệp JSONL)
AgentSession nghe sự kiện để ghi session, và cài hook để mỗi yêu cầu được dựng từ session.

Các hook được cài đúng một lần trong constructor. Chúng đọc this._extensionRunner lúc chạy, nên /reload thay runner mới mà không phải cài lại hook:

Constructor: đăng ký nghe sự kiện và cài hook
pi/packages/coding-agent/src/core/agent-session.tsdòng 511–525TypeScript
// Always subscribe to agent events for internal handling// (session persistence, extensions, auto-compaction, retry logic)this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);this._installAgentToolHooks();this._installAgentNextTurnRefresh();this._installAgentRequestProjection();this._installAgentBoundaryHooks();this._installHiddenDeclarationsProjection();this._installAgentForcedPromptProjection();this._buildRuntime({	activeToolNames: this._initialActiveToolNames,	includeAllExtensionTools: true,});if (this._usesDefaultTools) this._restoreToolsFromTranscript(this._defaultToolModifiers);
_buildRuntime() tạo định nghĩa tool, ExtensionRunner và sổ đăng ký tool; _restoreToolsFromTranscript() khôi phục bộ tool mà nhánh session đã ghi lại.

Từ vựng dùng trong chương:

lần chạy (run)
Từ _startRun() đến sự kiện agent_settled. Một lần chạy có thể gọi agent.prompt() một lần rồi agent.continue() nhiều lần (sau retry, sau compaction, khi còn tin nhắn xếp hàng).
lượt (turn)
Một phản hồi assistant cùng các kết quả tool của nó, kết thúc bằng sự kiện turn_end.
entry
Một dòng JSON trong tệp session, có id và parentId (chương Session).
projection
Kết quả sessionManager.buildSessionProjection(): danh sách message mô hình sẽ thấy, đi từ gốc tới lá (leaf) của nhánh đang hoạt động, đã áp dụng compaction và context edit, mỗi message kèm entry nguồn.
boundary
Hai điểm dừng turn_end và agent_before_settle, nơi extension có thể ghi thêm entry và xin chạy tiếp.

Data model: cấu hình, sự kiện, message#

AgentSessionConfig: những gì cần để dựng một phiên

Constructor nhận một đối tượng cấu hình. Các trường bắt buộc cho thấy những cộng sự chính đều đến từ bên ngoài: Agent, SessionManager, SettingsManager, ResourceLoader và ModelRuntime do bên gọi (thường là createAgentSession() trong sdk.ts) truyền vào. Chỉ ExtensionRunner và sổ đăng ký tool được dựng bên trong, trong _buildRuntime() (agent-session.ts:3666), vì /reload phải dựng lại chúng.

AgentSessionConfig (lược phần cấu hình tool)
pi/packages/coding-agent/src/core/agent-session.tsdòng 252–268, 296–300TypeScript
export interface AgentSessionConfig {	agent: Agent;	sessionManager: SessionManager;	settingsManager: SettingsManager;	cwd: string;	/** Models to cycle through with Ctrl+P (from --models flag) */	scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;	/** Resource loader for extensions, skills, prompts, themes, context files, and system prompt */	resourceLoader: ResourceLoader;	/** SDK custom tools registered outside extensions */	customTools?: ToolDefinition[];	/** Canonical model/auth runtime used by coding-agent internals. */	modelRuntime: ModelRuntime;	/** Keeps the prompt cache entry of the last session request warm. */	cacheWarmer?: Pick<CacheWarmer, "cancel" | "status" | "onAgentSettled" | "onModeChanged" | "onWarmed">;	/** Initial active built-in tool names. Default: [read, bash, edit, write] */	initialActiveToolNames?: string[];	/** Mutable ref used by Agent to access the current ExtensionRunner */	extensionRunnerRef?: { current?: ExtensionRunner };	/** Session start event metadata emitted when extensions bind to this runtime. */	sessionStartEvent?: SessionStartEvent;}
Phần bị lược (dòng 269 đến 295) là các tuỳ chọn lọc tool: usesDefaultTools, allowedToolNames, excludedToolNames, baseToolsOverride; chúng thuộc chương Công cụ.

AgentSessionEvent: sự kiện công khai

Người dùng của AgentSession (TUI, RPC, SDK) đăng ký bằng session.subscribe(listener). Họ nhận mọi sự kiện của Agent cộng với các sự kiện riêng của phiên:

AgentSessionEvent (lược các sự kiện retry của bước tóm tắt)
pi/packages/coding-agent/src/core/agent-session.tsdòng 196–222, 237TypeScript
export type AgentSessionEvent =	| WithParentToolCallId<Exclude<AgentEvent, { type: "agent_end" }>>	| {			type: "agent_end";			messages: AgentMessage[];			willRetry: boolean;	  }	| { type: "agent_settled"; aborted: boolean }	| {			type: "queue_update";			steering: readonly string[];			followUp: readonly string[];	  }	| { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" }	| { type: "entry_appended"; entry: SessionEntry }	| { type: "session_info_changed"; name: string | undefined }	| { type: "thinking_level_changed"; level: ThinkingLevel }	| {			type: "compaction_end";			reason: "manual" | "threshold" | "overflow";			result: CompactionResult | undefined;			aborted: boolean;			willRetry: boolean;			errorMessage?: string;	  }	| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }	| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }	| { type: "bash_execution_update"; id?: string; delta: string };
agent_end được bọc lại để thêm willRetry: UI biết lần dừng này là tạm thời. agent_settled mới là điểm phiên thực sự rảnh. entry_appended báo các entry ghi ngoài luồng message (context edit, trạng thái router, entry của extension).

Sự kiện được phát cho extension trước, rồi mới tới listener công khai (agent-session.ts:1142). Thứ tự này có lý do, sẽ thấy ở bước 3.

PromptOptions

Tuỳ chọn của prompt()
pi/packages/coding-agent/src/core/agent-session.tsdòng 314–326TypeScript
/** Options for AgentSession.prompt() */export interface PromptOptions {	/** Whether to dispatch extension commands and expand skill commands and prompt templates (default: true) */	expandPromptTemplates?: boolean;	/** Image attachments */	images?: ImageContent[];	/** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). Required if streaming. */	streamingBehavior?: "steer" | "followUp";	/** Source of input for extension input event handlers. Defaults to "interactive". */	source?: InputSource;	/** Internal hook used by RPC mode to observe how an accepted prompt was dispatched. Not called if the prompt is rejected. */	preflightResult?: (disposition: PromptDisposition) => void;}
streamingBehavior là bắt buộc khi agent đang chạy: không có nó, prompt() ném lỗi thay vì đoán người dùng muốn steer hay follow-up.

Bốn role message riêng của coding agent

pi-ai chỉ biết bốn role mà provider hiểu: system, user, assistant, toolResult. Coding agent cần thêm các loại message mà UI hiển thị khác đi nhưng vẫn phải vào ngữ cảnh. Nó khai báo chúng bằng declaration merging trên interface CustomAgentMessages của pi-agent-core (docs/message-types.md):

BashExecutionMessage và CustomMessage
pi/packages/coding-agent/src/core/messages.tsdòng 26–53TypeScript
/** * Message type for bash executions via the ! command. */export interface BashExecutionMessage {	role: "bashExecution";	command: string;	output: string;	exitCode: number | undefined;	cancelled: boolean;	truncated: boolean;	fullOutputPath?: string;	timestamp: number;	/** If true, this message is excluded from LLM context (!! prefix) */	excludeFromContext?: boolean;}/** * Message type for extension-injected messages via sendMessage(). * These are custom messages that extensions can inject into the conversation. */export interface CustomMessage<T = unknown> {	role: "custom";	customType: string;	content: string | (TextContent | ImageContent)[];	display: boolean;	details?: T;	timestamp: number;}
bashExecution sinh ra từ lệnh ! của người dùng; custom do extension gửi qua pi.sendMessage(). display chỉ điều khiển việc vẽ; details không bao giờ gửi cho mô hình.
Hai message tóm tắt và phần mở rộng union
pi/packages/coding-agent/src/core/messages.tsdòng 55–77TypeScript
export interface BranchSummaryMessage {	role: "branchSummary";	summary: string;	fromId: string | null;	timestamp: number;}export interface CompactionSummaryMessage {	role: "compactionSummary";	summary: string;	tokensBefore: number;	timestamp: number;}// Extend CustomAgentMessages via declaration mergingdeclare module "@earendil-works/pi-agent-core" {	interface CustomAgentMessages {		bashExecution: BashExecutionMessage;		custom: CustomMessage;		branchSummary: BranchSummaryMessage;		compactionSummary: CompactionSummaryMessage;	}}
Hai role tóm tắt không bao giờ được ghi thành entry message; chúng được sinh lại từ entry compaction và branch_summary mỗi lần dựng ngữ cảnh (chương Session).

Trước khi gửi, convertToLlm() hạ cấp các role riêng về user. Hàm này được truyền vào Agent trong sdk.ts và được vòng lặp agent gọi một lần cho mỗi yêu cầu:

convertToLlm(): hạ các role riêng về user
pi/packages/coding-agent/src/core/messages.tsdòng 148–183TypeScript
export function convertToLlm(messages: AgentMessage[]): Message[] {	return messages		.map((m): Message | undefined => {			switch (m.role) {				case "bashExecution":					// Skip messages excluded from context (!! prefix)					if (m.excludeFromContext) {						return undefined;					}					return {						role: "user",						content: [{ type: "text", text: bashExecutionToText(m) }],						timestamp: m.timestamp,					};				case "custom": {					const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content;					return {						role: "user",						content,						timestamp: m.timestamp,					};				}				case "branchSummary":					return {						role: "user",						content: [{ type: "text" as const, text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX }],						timestamp: m.timestamp,					};				case "compactionSummary":					return {						role: "user",						content: [							{ type: "text" as const, text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX },						],						timestamp: m.timestamp,					};
Lệnh !! đặt excludeFromContext nên bị bỏ hẳn. Các nhánh còn lại (dòng 184 đến 188) trả nguyên system, user, assistant, toolResult.

Tóm tắt compaction và tóm tắt nhánh được bọc trong thẻ <summary> với câu dẫn cố định (messages.ts:11). sdk.ts còn bọc thêm một lớp: khi setting images.blockImages bật, mọi ảnh trong message user và toolResult được thay bằng dòng chữ "Image reading is disabled." (sdk.ts:296), và setting được đọc lại mỗi lần nên đổi giữa phiên có hiệu lực ngay.

Dựng một phiên: services, SDK và runtime#

Việc dựng một phiên chia làm ba tầng, mỗi tầng một tệp. Tách như vậy vì có những thứ phụ thuộc vào thư mục làm việc (cwd): settings dự án, extension dự án, provider do extension đăng ký. Khi /resume mở một session của dự án khác, cwd đổi, và mọi thứ gắn với cwd phải được tạo lại.

  1. Services theo cwdcreateAgentSessionServices() tạo ModelRuntime, SettingsManager, DefaultResourceLoader cho một cwd.
  2. PhiêncreateAgentSession() trong sdk.ts chọn mô hình, mức suy nghĩ (thinking level), tạo Agent và AgentSession.
  3. RuntimeAgentSessionRuntime giữ phiên hiện tại và thay nó khi người dùng đổi phiên.

Tầng 1: services gắn với cwd

Bộ services cho một cwd
pi/packages/coding-agent/src/core/agent-session-services.tsdòng 67–80TypeScript
/** * Coherent cwd-bound runtime services for one effective session cwd. * * This is infrastructure only. The AgentSession itself is created separately so * session options can be resolved against these services first. */export interface AgentSessionServices {	cwd: string;	agentDir: string;	modelRuntime: ModelRuntime;	settingsManager: SettingsManager;	resourceLoader: ResourceLoader;	diagnostics: AgentSessionRuntimeDiagnostic[];}
createAgentSessionServices(): auth.json, models.json, settings, resource loader
pi/packages/coding-agent/src/core/agent-session-services.tsdòng 135–154, 194–205TypeScript
export async function createAgentSessionServices(	options: CreateAgentSessionServicesOptions,): Promise<AgentSessionServices> {	const cwd = resolvePath(options.cwd);	const agentDir = options.agentDir ? resolvePath(options.agentDir) : getAgentDir();	const modelRuntime =		options.modelRuntime ??		(await ModelRuntime.create({			authPath: join(agentDir, "auth.json"),			modelsPath: join(agentDir, "models.json"),			signal: options.modelRuntimeSignal,		}));	const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);	const resourceLoader = new DefaultResourceLoader({		...(options.resourceLoaderOptions ?? {}),		cwd,		agentDir,		settingsManager,	});	await resourceLoader.reload(options.resourceLoaderReloadOptions);	await modelRuntime.refresh({ allowNetwork: false });	diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues));	return {		cwd,		agentDir,		modelRuntime,		settingsManager,		resourceLoader,		diagnostics,	};}
Phần lược đăng ký các provider, native provider và virtual model mà extension khai báo trong lúc nạp. Danh mục mô hình được làm mới với allowNetwork: false: khởi động không chờ mạng.

Tầng 2: createAgentSession() khôi phục và tạo Agent

Nếu SessionManager đã có dữ liệu (đang --continue hay --resume), mô hình được khôi phục từ nhánh session, với điều kiện provider của nó vẫn có credential:

sdk.ts: khôi phục mô hình từ session
pi/packages/coding-agent/src/core/sdk.tsdòng 209–232TypeScript
// Check if session has existing data to restoreconst existingSession = sessionManager.buildSessionContext();const hasExistingSession = existingSession.messages.length > 0;const hasThinkingEntry = sessionManager.getBranch().some((entry) => entry.type === "thinking_level_change");let model = options.model;let modelFallbackMessage: string | undefined;// Assistant messages name the physical model that answered, so a virtual selection is only in// model_change entries.const sessionModel = getBranchSelection(sessionManager.getBranch(), (provider, modelId) =>	modelRuntime.getModel(provider, modelId),);// If session has data, try to restore model from itif (!model && hasExistingSession && sessionModel) {	const restoredModel = modelRuntime.getModel(sessionModel.provider, sessionModel.modelId);	if (restoredModel && modelRuntime.hasConfiguredAuth(restoredModel.provider)) {		model = restoredModel;	}	if (!model) {		modelFallbackMessage = `Could not restore model ${sessionModel.provider}/${sessionModel.modelId}`;	}}
Nếu không khôi phục được, findInitialModel() chọn theo settings rồi theo provider có credential (mục dịch vụ). Mức suy nghĩ đi theo thứ tự tương tự ở sdk.ts:253, và cuối cùng bị kẹp (clamp) theo khả năng của mô hình.

Rồi Agent được tạo. Bốn điểm nối quan trọng nằm gọn trong lời gọi này:

sdk.ts: Agent nhận message từ session và các callback của coding agent
pi/packages/coding-agent/src/core/sdk.tsdòng 403–438TypeScript
const agent = new Agent({	initialState: {		systemPrompt: "",		model,		thinkingLevel,		tools: [],		messages: existingSession.messages,	},	convertToLlm: convertToLlmWithBlockImages,	streamFn: async (model, context, options) => {		const requestOptions = buildRequestOptions(options);		// Compaction and summaries use their own routing ids; only session requests		// replace the cache entry, so warming restarts from them. Keep warming while		// the current transcript still extends the request's prefix. Agent state may		// shallow-copy the messages array or refresh the model object without changing		// the provider request, so top-level object identity is not a valid cache key.		if (options?.sessionId === sessionManager.getSessionId()) {			cacheWarmer.start({ model, context, options: requestOptions }, cacheContextIsCurrent(model));		}		return modelRuntime.streamSimple(model, context, requestOptions);	},	onPayload: transformProviderPayload,	onResponse: handleProviderResponse,	onProviderStreamEvent: handleProviderStreamEvent,	sessionId: sessionManager.getSessionId(),	transformContext: async (messages) => {		const runner = extensionRunnerRef.current;		if (!runner) return messages;		return runner.emitContext(messages);	},	steeringMode: settingsManager.getSteeringMode(),	followUpMode: settingsManager.getFollowUpMode(),	transport: settingsManager.getTransport(),	thinkingBudgets: settingsManager.getThinkingBudgets(),	maxRetryDelayMs: settingsManager.getProviderRetrySettings().maxRetryDelayMs,});
systemPrompt để trống: ở v1.4.0 system prompt nằm trong transcript dưới dạng message system (bước 2). messages lấy từ buildSessionContext(). streamFn đi qua ModelRuntime.streamSimple(), nơi credential được giải quyết cho từng yêu cầu. transformContext là sự kiện context của extension.

Một phiên mới được ghi sẵn mô hình và mức suy nghĩ ban đầu, để lần resume sau khôi phục đúng:

sdk.ts: entry đầu tiên của một phiên mới
pi/packages/coding-agent/src/core/sdk.tsdòng 440–451TypeScript
// Restore missing settings metadata for older sessions.if (hasExistingSession) {	if (!hasThinkingEntry) {		sessionManager.appendThinkingLevelChange(thinkingLevel);	}} else {	// Save initial model and thinking level for new sessions so they can be restored on resume	if (model) {		sessionManager.appendModelChange(model.provider, model.id);	}	sessionManager.appendThinkingLevelChange(thinkingLevel);}

Hai entry này chưa chạm đĩa: SessionManager chỉ tạo tệp khi có message user hoặc assistant đầu tiên (chương Session). Mở pi rồi thoát ngay không để lại tệp rỗng.

Tầng 3: AgentSessionRuntime thay phiên

AgentSession không tự chuyển sang tệp khác. Đổi phiên nghĩa là huỷ phiên cũ và dựng phiên mới bằng một factory do main.ts cung cấp (main.ts:733):

Huỷ phiên cũ rồi dựng phiên mới khi resume
pi/packages/coding-agent/src/core/agent-session-runtime.tsdòng 167–178, 209–224TypeScript
private async teardownCurrent(reason: SessionShutdownEvent["reason"], targetSessionFile?: string): Promise<void> {	// Settle any active response first so the aborted turn (including tool	// results) is persisted to the outgoing session before it is replaced.	await this.session.abort();	await emitSessionShutdownEvent(this.session.extensionRunner, {		type: "session_shutdown",		reason,		targetSessionFile,	});	this.beforeSessionInvalidate?.();	this.session.dispose();}	const previousSessionFile = this.session.sessionFile;	const sessionManager = SessionManager.open(sessionPath, undefined, options?.cwdOverride);	assertSessionCwdExists(sessionManager, this.cwd);	await this.teardownCurrent("resume", sessionManager.getSessionFile());	this.apply(		await this.createRuntime({			cwd: sessionManager.getCwd(),			agentDir: this.services.agentDir,			sessionManager,			sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },			projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()),		}),	);	await this.finishSessionReplacement(options?.withSession);	return { cancelled: false };}
abort() chạy trước để lượt đang dở (kể cả kết quả tool) được ghi vào phiên cũ trước khi nó bị thay. Sau đó extension nhận session_shutdown, phiên cũ bị dispose(), và factory tạo services cho cwd của session đích.

newSession(), fork() và importFromJsonl() đi theo cùng khuôn đó (agent-session-runtime.ts:226); chi tiết fork nằm ở chương Session. Mọi ctx mà extension giữ từ phiên cũ trở thành stale: dispose() gọi invalidate() với thông báo hướng dẫn dùng withSession (agent-session.ts:1423).

Bước 1: prompt() xử lý đầu vào#

Phần còn lại của chương lần theo một prompt từ lúc người dùng gõ đến lúc phiên rảnh trở lại. Bước đầu tiên xảy ra trước cả AgentSession, và chỉ áp dụng cho dòng lệnh.

Đối số @file trên dòng lệnh

Với pi @src/app.ts "giải thích tệp này", main.ts đọc tệp trước khi tạo prompt đầu tiên. Tệp văn bản được nhúng nguyên văn trong thẻ <file>; tệp ảnh trở thành ImageContent đính kèm, còn văn bản chỉ giữ một thẻ <file> rỗng (hoặc chứa gợi ý về việc xử lý ảnh) làm chỗ tham chiếu:

processFileArguments(): tệp thành văn bản hoặc ảnh
pi/packages/coding-agent/src/cli/file-processor.tsdòng 49–78TypeScript
const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);if (mimeType) {	// Handle image file	const content = await readFile(absolutePath);	const processed = await processImage(content, mimeType, { autoResizeImages });	if (!processed.ok) {		text += `<file name="${absolutePath}">${processed.message}</file>\n`;		continue;	}	const attachment: ImageContent = {		type: "image",		mimeType: processed.mimeType,		data: processed.data,	};	images.push(attachment);	// Add text reference to image with optional processing hints	if (processed.hints.length > 0) {		text += `<file name="${absolutePath}">${processed.hints.join("\n")}</file>\n`;	} else {		text += `<file name="${absolutePath}"></file>\n`;	}} else {	// Handle text file	try {		const content = stripBom(await readFile(absolutePath, "utf-8"));		text += `<file name="${absolutePath}">\n${content}\n</file>\n`;
main.ts gọi hàm này với autoResizeImages: false (main.ts:222): việc thu nhỏ ảnh để AgentSession làm sau, khi extension đã có cơ hội đổi mô hình.

Trong trình soạn thảo của interactive mode, gõ @ chỉ mở gợi ý đường dẫn và chèn chuỗi @path vào văn bản (autocomplete.ts:443); nội dung tệp không được đọc sẵn, mô hình tự dùng tool read nếu cần.

prompt(): bốn cửa trước khi chạy

Đi theo từng cửa:

  1. Đang phát agent_settled?Nếu một listener của agent_settled gọi prompt(), lời gọi được hoãn vào _deferredSettledActions và chạy sau khi sự kiện phát xong (dòng 2093 đến 2096).
  2. Lệnh extensionVăn bản bắt đầu bằng / được tra trong các lệnh extension. Tìm thấy thì chạy handler với command context và dừng; lỗi của handler được báo qua emitError chứ không ném ra (agent-session.ts:2180).
  3. Sự kiện inputExtension có thể trả handled (nuốt input), transform (thay văn bản, ảnh) hoặc để nguyên (agent-session.ts:2041).
  4. Skill và prompt template/skill:tên args được thay bằng nội dung SKILL.md; /tên args khớp một prompt template được thay bằng template đã điền tham số.
Mở rộng /skill:tên thành một khối skill
pi/packages/coding-agent/src/core/agent-session.tsdòng 2211–2225TypeScript
private _expandSkillCommand(text: string): string {	if (!text.startsWith("/skill:")) return text;	const spaceIndex = text.indexOf(" ");	const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);	const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();	const skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName);	if (!skill) return text; // Unknown skill, pass through	try {		const content = readFileSync(skill.filePath, "utf-8");		const body = stripFrontmatter(content).trim();		const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${skill.baseDir}.\n\n${body}\n</skill>`;		return args ? `${skillBlock}\n\n${args}` : skillBlock;
Frontmatter bị bỏ, phần thân được bọc trong thẻ <skill> có đường dẫn tệp. TUI nhận ra khối này nhờ parseSkillBlock() (agent-session.ts:177) để vẽ gọn lại. Skill không tồn tại thì văn bản đi qua nguyên vẹn.

Prompt template đơn giản hơn: expandPromptTemplate() tách /tên khỏi phần tham số, tìm template trùng tên, và nếu có thì trả về nội dung template đã thay $1, $@, ${1:-mặc định}... bằng các tham số (prompt-templates.ts:309, substituteArgs()). Không khớp template nào thì văn bản giữ nguyên. Cách khai báo template thuộc chương Extensions.

Nửa sau quyết định giữa xếp hàng và bắt đầu một lần chạy:

prompt(), nửa sau: xếp hàng hoặc bắt đầu chạy
pi/packages/coding-agent/src/core/agent-session.tsdòng 2136–2175TypeScript
	// If streaming, queue via steer() or followUp() based on option	if (this.isStreaming) {		if (!options?.streamingBehavior) {			throw new Error(				"Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.",			);		}		if (options.streamingBehavior === "followUp") {			await this._queueFollowUp(expandedText, currentImages);		} else {			await this._queueSteer(expandedText, currentImages);		}		preflightResult?.("queued");		return;	}	// Validate model	if (!this.model) {		throw new Error(formatNoModelSelectedMessage());	}	const hasConfiguredAuth =		this._modelRuntime.hasConfiguredAuth(this.model.provider) ||		(await this._modelRuntime.checkAuth(this.model.provider)) !== undefined;	if (!hasConfiguredAuth) {		const isOAuth = this._modelRuntime.isUsingOAuth(this.model.provider);		if (isOAuth) {			throw new Error(				`Authentication failed for "${this.model.provider}". ` +					`Credentials may have expired or network is unavailable. ` +					`Run '/login ${this.model.provider}' to re-authenticate.`,			);		}		throw new Error(formatNoApiKeyFoundMessage(this.model.provider));	}	await this._startRun({ messageType: "user", prompt: expandedText, images: currentImages }, () =>		preflightResult?.("started"),	);}
Khi agent đang chạy, văn bản đã mở rộng đi vào hàng đợi steer hoặc follow-up (mục hàng đợi). Khi rảnh, phiên kiểm tra có mô hình và có credential cho provider rồi mới gọi _startRun().

Bước 2: chuẩn bị và chạy một lần chạy#

_startRun() là khung của mọi lần chạy, dù khởi đầu từ prompt của người dùng hay từ sendCustomMessage(..., { triggerTurn: true }) của extension. Nửa đầu chuẩn bị:

_startRun(), nửa đầu: đánh dấu bận, chuẩn bị message
pi/packages/coding-agent/src/core/agent-session.tsdòng 1873–1897TypeScript
private async _startRun(trigger: BeforeAgentStartTrigger, onStarted?: () => void): Promise<void> {	this._isAgentRunActive = true;	this._agentRunAbortRequested = false;	let messages: AgentMessage[];	try {		messages = await this._prepareRun(trigger);	} catch (error) {		this._isAgentRunActive = false;		this._flushPendingBashMessages();		this._flushPendingCustomMessages();		this._resolveIdleWaitIfIdle();		throw error;	}	// Messages held while the run was starting go before its starting messages, as they would	// have while idle. No run exists yet, so they cannot split a tool call from its result.	this._flushPendingBashMessages();	this._flushPendingCustomMessages();	onStarted?.();	// Compaction before the prompt may have scheduled a retry; the new prompt replaces it.	this._failedResponse = undefined;	this._recordSelection();	// The run records the loadout in the transcript; restored tools that did not register by now	// are dropped, so a tool that never registers does not stay pending.	this._pendingToolNames.clear();
Phiên bị coi là bận ngay từ dòng 1874, trước cả before_agent_start. Nhờ vậy một tin nhắn gửi tới trong lúc handler của extension đang chạy được xếp vào lần chạy này thay vì mở lần chạy thứ hai, và một lệnh abort lúc đó sẽ huỷ lần chạy ngay khi nó tồn tại.

Nửa sau là vòng lặp sau run: agent.prompt() chạy vòng lặp agent cho tới agent_end; rồi AgentSession tự hỏi có cần chạy tiếp không.

_startRun(), nửa sau: vòng lặp sau run và agent_settled
pi/packages/coding-agent/src/core/agent-session.tsdòng 1898–1917TypeScript
try {	await this.agent.prompt(messages);	while (!this._agentRunAbortRequested) {		if (await this._handlePostAgentRun()) {			if (this._agentRunAbortRequested) break;			await this.agent.continue();			continue;		}		if (this._agentRunAbortRequested || !(await this._runBeforeSettleBoundary())) break;		if (this._agentRunAbortRequested) break;		await this.agent.continue();	}} finally {	if (this._agentRunAbortRequested) this._finishCancelledRetry();	this._failedResponse = undefined;	this._runSystemPromptOptions = undefined;	this._flushPendingBashMessages();	this._flushPendingCustomMessages();	await this._emitAgentSettled();}
agent.continue() tiếp tục từ transcript hiện có, không thêm message mới. Khối finally luôn đổ các message bash và custom còn treo rồi phát agent_settled, kể cả khi lần chạy bị abort.

_prepareRun(): before_agent_start và message khởi đầu

_prepareRun(), phần đầu: compaction trước prompt, before_agent_start
pi/packages/coding-agent/src/core/agent-session.tsdòng 1926–1942TypeScript
private async _prepareRun(trigger: BeforeAgentStartTrigger): Promise<AgentMessage[]> {	// Check if we need to compact before sending (catches aborted responses).	// The starting message is sent below, so do not call agent.continue() here.	const lastAssistant = this._findLastAssistantMessage();	if (lastAssistant) {		await this._checkCompaction(lastAssistant, false);	}	const selectedToolsBefore = this._baseSystemPromptOptions.selectedTools;	const result = await this._extensionRunner.emitBeforeAgentStart(trigger, this._baseSystemPromptOptions);	// Handlers may edit event.systemPromptOptions.selectedTools or call setActiveTools(),	// which updates the live loadout instead. An explicit edit wins; otherwise the live	// loadout is authoritative, so a setActiveTools() call is not undone here.	const handlerEditedTools =		result.systemPromptOptions.selectedTools.length !== selectedToolsBefore.length ||		result.systemPromptOptions.selectedTools.some((name, index) => name !== selectedToolsBefore[index]);	if (!handlerEditedTools) result.systemPromptOptions.selectedTools = this.getActiveToolNames();
_checkCompaction(lastAssistant, false) xét cả phản hồi bị abort: nếu lượt trước bị người dùng ngắt khi ngữ cảnh đã gần đầy, compaction chạy trước khi gửi prompt mới. Handler before_agent_start nhận bản sao các tuỳ chọn system prompt và có thể sửa chúng hoặc chèn message (chương Extensions).
_prepareRun(), phần sau: message user, nextTurn, custom, bản vá system
pi/packages/coding-agent/src/core/agent-session.tsdòng 1944–1976TypeScript
const messages: AgentMessage[] = [];if (trigger.messageType === "custom") {	messages.push(trigger.message);} else {	// Normalize images after before_agent_start so extension-driven model selection	// determines the resize profile used for the request and history.	const normalized = await this._normalizePromptImages(trigger.images);	const text =		normalized.hints.length > 0 ? `${trigger.prompt}\n\n${normalized.hints.join("\n")}` : trigger.prompt;	messages.push({		role: "user",		content: [{ type: "text", text }, ...normalized.images],		timestamp: Date.now(),	});}// Inject any pending "nextTurn" messages as context alongside the starting messagemessages.push(...this._pendingNextTurnMessages);this._pendingNextTurnMessages = [];for (const msg of result.messages) {	messages.push({		role: "custom",		customType: msg.customType,		// Untyped extensions can pass null/missing content; normalize at ingestion.		content: msg.content ?? [],		display: msg.display,		details: msg.details,		timestamp: Date.now(),	});}const updateMessage = this._preparePromptAndToolLoadout(result.systemPromptOptions);this._runSystemPromptOptions = result.systemPromptOptions;if (updateMessage) messages.unshift(updateMessage);return messages;
Ảnh được chuẩn hoá ở đây, sau before_agent_start, vì extension có thể đã đổi mô hình và giới hạn kích thước ảnh đi theo mô hình. Lỗi xử lý ảnh không chặn prompt: nó thành dòng gợi ý nối vào văn bản.

Kết quả là một mảng theo thứ tự: [bản vá system nếu prompt thay đổi], message user, các message "nextTurn" đang chờ, các message custom do handler trả về.

System prompt là message trong transcript

Ở v1.4.0 system prompt không phải một chuỗi riêng truyền theo mỗi yêu cầu. Nó được chia thành các phần có tên (sections) và ghi vào transcript dưới dạng message system. Message đầu tiên mang mọi phần; các message sau chỉ mang phần đã đổi (docs/session-format.md:80). _preparePromptAndToolLoadout() tạo bản vá đó:

Bản vá system prompt = hiệu giữa các phần đã phát lại và các phần mới
pi/packages/coding-agent/src/core/agent-session.tsdòng 1758–1770TypeScript
private _preparePromptAndToolLoadout(	options: NormalizedBuildSystemPromptOptions,	messages: AgentMessage[] = this.agent.state.messages,): SystemMessage | undefined {	options.selectedTools = this._applyToolLoadout(options.selectedTools).map((tool) => tool.name);	// The tool list and rules must match the declarations the request carries.	options.hiddenTools = [...this._hiddenDeclarations];	const sections = diffSystemPromptSections(		getCurrentSystemMessage(messages)?.sections ?? {},		buildSystemPromptSections(options),	);	return sections ? { role: "system", content: "", sections, timestamp: Date.now() } : undefined;}
getCurrentSystemMessage() của pi-ai phát lại (replay) mọi message system trong transcript thành một trạng thái hiện tại (transcript.ts:73); diffSystemPromptSections() trả undefined khi không có gì đổi (system-prompt.ts:217). Nội dung từng phần (preamble, tools, skills, context file) thuộc chương Công cụ và system prompt.

Tool đi theo cùng cơ chế. Ngay trước khi phát các message khởi đầu, vòng lặp agent so bộ tool đã khai báo trong transcript với bộ tool thực thi được và ghi chênh lệch vào toolsAdded/toolsRemoved của message system (agent-loop.ts:342). Vì vậy prompt đầu tiên của một phiên mới ghi một message system chứa mọi phần prompt và mọi khai báo tool; prompt thứ hai, nếu không có gì đổi, không ghi message system nào.

Một ngoại lệ: handler before_agent_start trả về systemPrompt muốn đúng chuỗi đó đứng đầu yêu cầu. Chuỗi ép buộc này không ghi vào transcript; nó được chiếu lên yêu cầu bằng một lớp transformContext gộp mọi message system thành một đầu duy nhất (agent-session.ts:1807). Lớp còn lại AgentSession cài lên transformContext bỏ các khai báo tool mà hook prepareLoadout muốn ẩn (agent-session.ts:1787). Cả hai chạy sau sự kiện context của extension, lớp do sdk.ts cài.

_handlePostAgentRun(): có chạy tiếp không

Quyết định sau agent_end: retry, compaction, hàng đợi
pi/packages/coding-agent/src/core/agent-session.tsdòng 1979–2015TypeScript
private async _handlePostAgentRun(): Promise<boolean> {	const message = this._lastAssistantMessage;	this._lastAssistantMessage = undefined;	if (this._agentRunAbortRequested) {		this._finishCancelledRetry();		return false;	}	if (!message) return this.agent.hasQueuedMessages();	if (this._isRetryableError(message) && (await this._prepareRetry(message))) {		if (this._agentRunAbortRequested) this._finishCancelledRetry();		this._failedResponse = message;		return !this._agentRunAbortRequested;	}	if (this._agentRunAbortRequested) {		this._finishCancelledRetry();		return false;	}	if (message.stopReason === "error" && this._retryAttempt > 0) {		this._emit({			type: "auto_retry_end",			success: false,			attempt: this._retryAttempt,			finalError: message.errorMessage,		});		this._retryAttempt = 0;	}	if (await this._checkCompaction(message)) {		return !this._agentRunAbortRequested;	}	// The low-level loop drains both queues before agent_end. Messages queued by	// agent_end handlers require a fresh run before pre-settlement handlers fire.	return !this._agentRunAbortRequested && this.agent.hasQueuedMessages();}
Ba lý do để gọi agent.continue(): lỗi có thể thử lại (retry), compaction do tràn ngữ cảnh cần thử lại lượt (compaction), hoặc còn tin nhắn xếp hàng do handler agent_end thêm vào.

Khi không còn lý do nào, _runBeforeSettleBoundary() phát boundary agent_before_settle: extension có thể ghi entry (một custom message, một context edit, thậm chí một compaction) và xin chạy tiếp. Lời xin chỉ được chấp nhận khi ngữ cảnh sau khi ghi còn chạy tiếp được: có message ngoài system và message cuối không phải assistant, hoặc vẫn còn message đang chờ (hàng đợi steer/follow-up, custom message bị hoãn) (agent-session.ts:999).

Bước 3: sự kiện và ghi session#

Trong lúc vòng lặp agent chạy, mọi sự kiện đi qua một hàm duy nhất, _handleAgentEvent. Phần đầu của nó làm ba việc trước khi ghi bất cứ thứ gì:

_handleAgentEvent(), phần đầu: abort trễ, gỡ khỏi hàng đợi, phát sự kiện
pi/packages/coding-agent/src/core/agent-session.tsdòng 1103–1106, 1120–1144TypeScript
private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {	// An abort requested while the run was starting, e.g. during before_agent_start, stops it	// here. agent_start is awaited before the first request.	if (event.type === "agent_start" && this._agentRunAbortRequested) this.agent.abort();	// When a user message starts, check if it's from either queue and remove it BEFORE emitting	// This ensures the UI sees the updated queue state	if (event.type === "message_start" && event.message.role === "user") {		this._overflowRecoveryAttempted = false;		const messageText = contentText(event.message.content, "");		if (messageText) {			// Check steering queue first			const steeringIndex = this._steeringMessages.indexOf(messageText);			if (steeringIndex !== -1) {				this._steeringMessages.splice(steeringIndex, 1);				this._emitQueueUpdate();			} else {				// Check follow-up queue				const followUpIndex = this._followUpMessages.indexOf(messageText);				if (followUpIndex !== -1) {					this._followUpMessages.splice(followUpIndex, 1);					this._emitQueueUpdate();				}			}		}	}	// Emit to extensions first, then notify public listeners.	await this._emitExtensionEvent(event);	this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
Khi một message user bắt đầu, văn bản của nó được gỡ khỏi danh sách steer hoặc follow-up đang hiển thị, rồi mới phát sự kiện, để UI thấy hàng đợi đã đúng. Phần lược (1107 đến 1119) gắn bản ghi các lời gọi tool lồng nhau (ctx.executeTool()) vào message toolResult.

Extension được phát trước listener công khai vì handler message_end của extension có quyền thay message:

message_end: extension có thể thay message trước khi nó được ghi
pi/packages/coding-agent/src/core/agent-session.tsdòng 1334–1352TypeScript
} else if (event.type === "message_end") {	const extensionEvent: MessageEndEvent = {		type: "message_end",		message: event.message,	};	const replacement = await this._extensionRunner.emitMessageEnd(extensionEvent);	if (replacement) {		// Untyped extension handlers can return messages with null/missing content;		// normalize so it never enters agent state or session history.		const normalized =			(replacement.role === "user" ||				replacement.role === "assistant" ||				replacement.role === "toolResult" ||				replacement.role === "custom") &&			replacement.content == null				? ({ ...replacement, content: [] } as AgentMessage)				: replacement;		this._replaceMessageInPlace(event.message, normalized);	}
_replaceMessageInPlace() xoá mọi khoá của đối tượng cũ rồi gán khoá của đối tượng mới (agent-session.ts:1286). Agent state, các listener và lệnh ghi session ngay sau đó đều giữ cùng một tham chiếu, nên cả ba thấy bản đã thay.

Rồi tới phần ghi. Chỉ message_end dẫn tới ghi; message_start và message_update (từng mẩu văn bản đang stream) chỉ để vẽ.

_handleAgentEvent(), phần ghi: message_end thành entry
pi/packages/coding-agent/src/core/agent-session.tsdòng 1146–1168, 1190–1197TypeScript
// Handle session persistenceif (event.type === "message_end") {	let entryId: string | undefined;	// Check if this is a custom message from extensions	if (event.message.role === "custom") {		// Persist as CustomMessageEntry		entryId = this.sessionManager.appendCustomMessageEntry(			event.message.customType,			event.message.content,			event.message.display,			event.message.details,		);	} else if (		event.message.role === "system" ||		event.message.role === "user" ||		event.message.role === "assistant" ||		event.message.role === "toolResult"	) {		// Regular LLM message - persist as SessionMessageEntry		entryId = this.sessionManager.appendMessage(event.message);	}	if (entryId) this._entryIdsByMessage.set(event.message, entryId);	// Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere// A turn ends after its assistant message and every tool result has been appended,// so this is the first point in the run where a context-only custom message can be// inserted without landing between a tool call and its result. Flushing after the// extension and listener dispatch above also picks up messages that turn_end// handlers queued.if (event.type === "turn_end") {	this._flushPendingCustomMessages();}
Message custom thành entry custom_message; bốn role của provider thành entry message. bashExecution và hai role tóm tắt không bao giờ đi qua đây. Phần lược (1170 đến 1187) đặt lại bộ đếm retry khi có một phản hồi assistant thành công.

Ghi xong không có nghĩa là xong. Các hook cài trên Agent bảo đảm yêu cầu kế tiếp nhìn thấy đúng nội dung tệp session, không phải mảng message trong bộ nhớ. Trước mỗi yêu cầu, prepareRequest thay toàn bộ context.messages bằng projection của session:

prepareRequest: mỗi yêu cầu được dựng lại từ projection của session
pi/packages/coding-agent/src/core/agent-session.tsdòng 787–814TypeScript
private _installAgentRequestProjection(): void {	const previousPrepareRequest = this.agent.prepareRequest;	this.agent.prepareRequest = async (request, signal) => {		const failed = this._failedResponse;		this._failedResponse = undefined;		const prepare = async () => {			const projection = this.sessionManager.buildSessionProjection();			const canonicalContext = {				...request.context,				messages: projection.messages,				// Messages declare the provider-visible loadout; context.tools keeps executable implementations.				tools: this.agent.state.tools.slice(),			};			const previous = await previousPrepareRequest?.(				{					...request,					context: canonicalContext,					model: this.agent.state.model,					thinkingLevel: this.agent.state.thinkingLevel,				},				signal,			);			return { previous, context: previous?.context ?? canonicalContext, projection };		};		let { previous, context, projection } = await prepare();		const model = previous?.model ?? this.agent.state.model;		const thinkingLevel = previous?.thinkingLevel ?? this.agent.state.thinkingLevel;		if (!isVirtualModel(model)) return { ...previous, context, model, thinkingLevel };
Nếu extension đã ghi một context_edit hay một compaction giữa hai lượt, yêu cầu sau phản ánh ngay. Phần sau dòng 814 chỉ chạy khi mô hình đang chọn là virtual model: router chọn mô hình vật lý cho riêng yêu cầu này, có thể ghi trạng thái router thành entry custom và có thể kích hoạt compaction theo cửa sổ ngữ cảnh của mô hình được chọn.

Giữa hai lượt trong cùng một lần chạy, prepareNextTurnWithContext làm thêm hai việc: kiểm tra ngưỡng compaction và tính lại bản vá system prompt (ví dụ một tool vừa được bật):

prepareNextTurnWithContext: compaction giữa lượt và bản vá system
pi/packages/coding-agent/src/core/agent-session.tsdòng 904–931TypeScript
this.agent.prepareNextTurnWithContext = async (turn, signal) => {	const context = await this._compactBeforeNextAssistantResponse(turn.context);	const previousSnapshot = await previousPrepareNextTurnWithContext?.({ ...turn, context }, signal);	const nextContext = previousSnapshot?.context ?? context;	const runOptions = this._runSystemPromptOptions ?? this._baseSystemPromptOptions;	const options = normalizeBuildSystemPromptOptions({		...runOptions,		selectedTools: this.getActiveToolNames(),		toolSnippets: { ...this._baseSystemPromptOptions.toolSnippets, ...runOptions.toolSnippets },		toolGuidelines: { ...this._baseSystemPromptOptions.toolGuidelines, ...runOptions.toolGuidelines },	});	const updateMessage = this._preparePromptAndToolLoadout(options, nextContext.messages);	// Keep session.systemPrompt and ctx.getSystemPrompt() in step with what the provider sees.	this._runSystemPromptOptions = options;	return {		...previousSnapshot,		context: {			...nextContext,			tools: this.agent.state.tools.slice(),		},		messages: updateMessage			? [...(previousSnapshot?.messages ?? []), updateMessage]			: previousSnapshot?.messages,		model: this.agent.state.model,		thinkingLevel: this.agent.state.thinkingLevel,	};};
Bản vá được trả trong messages; vòng lặp agent phát nó như message mới trước phản hồi kế tiếp, nên nó cũng đi qua message_end và được ghi thành entry.

Ngược lại, khi AgentSession tự ghi entry ngoài luồng sự kiện (sau compaction, retry, điều hướng cây), nó gọi _refreshFinalizedContext() để chép projection mới vào agent.state.messages và nhớ entry nguồn của từng message (agent-session.ts:938).

Entry nào được ghi khi nào

Thời điểmEntryNơi ghi
Tạo phiên mới (SDK)model_change, thinking_level_changesdk.ts:440
--name lúc khởi độngsession_infomain.ts:716
message_end của system, user, assistant, toolResultmessageagent-session.ts:1158
message_end của customcustom_messageagent-session.ts:1150
Custom message gửi khi rảnh hoặc hoãn tới cuối lượtcustom_messageagent-session.ts:2395
Lệnh ! khi rảnh, hoặc sau lần chạymessage (role bashExecution)agent-session.ts:3957, 3989
Đổi hoặc xoay vòng mô hìnhmodel_changeagent-session.ts:2549, 2616, 2651
Mức suy nghĩ thực sự thay đổithinking_level_changeagent-session.ts:2691
Retry hoặc phục hồi sau tràn ngữ cảnhcontext_edit với replacement: nullagent-session.ts:1267
Compaction thủ công hoặc tự độngcompactionagent-session.ts:2911, 3237
Router của virtual model đổi trạng tháicustom (pi.virtual-model-state)agent-session.ts:829
Extension gọi pi.appendEntry()customagent-session.ts:3466
Extension ghi ở boundarycustom, custom_message, context_edit, compactionagent-session.ts:946
Đặt tên phiênsession_infoagent-session.ts:4003
Gắn nhãnlabelagent-session.ts:3480
/tree có tóm tắt nhánhbranch_summaryagent-session.ts:4170
Làm ấm prompt cacheusage (kind: "cache_warm")cache-warmer.ts:342

Cấu trúc của từng loại entry nằm ở chương Session.

Lần theo một prompt có một lời gọi tool#

Kịch bản: một phiên mới, mô hình thường (không phải virtual model), không extension nào can thiệp. Người dùng gõ "package.json khai báo version nào?". Mô hình gọi tool read một lần rồi trả lời. Sơ đồ dưới đây ghép ba bước ở trên thành một dòng thời gian; mũi tên trái là sự kiện hoặc lời gọi hook từ Agent, mũi tên phải là lời gọi của AgentSession.

TUI          AgentSession                       Agent (pi-agent-core)         SessionManager
 │                 │                                     │                           │
 ├─ prompt(text) ─►│                                     │                           │
 │                 │ lệnh extension? input? skill?       │                           │
 │                 │ template? đang chạy? credential?    │                           │
 │                 │ _startRun() → _prepareRun():        │                           │
 │                 │ compaction trước prompt?            │                           │
 │                 │ before_agent_start                  │                           │
 │                 ├─ agent.prompt([system, user]) ─────►│                           │
 │                 │                                     │ declareToolChanges()      │
 │                 │◄─ agent_start, turn_start ──────────┤                           │
 │                 │◄─ message_end(system) ──────────────┤                           │
 │                 ├─ appendMessage(system) ─────────────┼──────────────────────────►│ ① message
 │                 │◄─ message_end(user) ────────────────┤                           │
 │                 ├─ appendMessage(user) ───────────────┼──────────────────────────►│ ② message, tạo tệp
 │                 │◄─ hook prepareRequest ──────────────┤                           │
 │                 ├─ buildSessionProjection() ──────────┼──────────────────────────►│
 │                 ├─ context.messages = projection ────►│                           │
 │                 │                                     │ transformContext()        │
 │                 │                                     │ convertToLlm()            │
 │                 │                                     │ streamFn → provider       │
 │◄────────────────┼── message_start, message_update ────┤                           │
 │                 │◄─ message_end(assistant: toolCall) ─┤                           │
 │                 ├─ appendMessage(assistant) ──────────┼──────────────────────────►│ ③ message
 │                 │◄─ hook beforeToolCall: tool_call ───┤                           │
 │                 │                                     │ tool_execution_start      │
 │                 │                                     │ chạy tool read            │
 │                 │                                     │ tool_execution_end        │
 │                 │◄─ hook afterToolCall: tool_result ──┤                           │
 │                 │◄─ message_end(toolResult) ──────────┤                           │
 │                 ├─ appendMessage(toolResult) ─────────┼──────────────────────────►│ ④ message
 │                 │◄─ hook finishTurn: turn_end ────────┤                           │
 │                 │◄─ turn_end ─────────────────────────┤                           │
 │                 │◄─ hook prepareNextTurnWithContext ──┤                           │
 │                 │ ngưỡng compaction, bản vá system    │                           │
 │                 │◄─ hook prepareRequest (lần 2) ──────┤                           │
 │                 │                                     │ streamFn → provider       │
 │                 │◄─ message_end(assistant: văn bản) ──┤                           │
 │                 ├─ appendMessage(assistant) ──────────┼──────────────────────────►│ ⑤ message
 │                 │◄─ turn_end, agent_end ──────────────┤                           │
 │                 │ _handlePostAgentRun(): không        │                           │
 │                 │ retry, không compaction, hàng       │                           │
 │                 │ đợi rỗng; agent_before_settle       │                           │
 │◄─ agent_settled ┤                                     │                           │
Một prompt, hai yêu cầu tới provider, năm entry message. Mỗi mũi tên từ Agent được await: Agent cập nhật state của nó rồi chờ từng listener xong mới đi tiếp (agent.ts:575), nên mỗi entry đã nằm trên đĩa trước khi vòng lặp bước sang việc kế tiếp.

Đọc theo thứ tự:

  1. Đầu vàoprompt() không gặp lệnh extension, không có handler input, văn bản không phải skill hay template. Agent đang rảnh, mô hình có credential: gọi _startRun().
  2. Chuẩn bịPhiên mới chưa có assistant nào nên không kiểm tra compaction. before_agent_start không đổi gì. Transcript chưa có message system nào, nên bản vá chứa mọi phần prompt; declareToolChanges() gắn khai báo của bốn tool mặc định vào nó.
  3. Hai message đầumessage_end(system) rồi message_end(user). Entry ① chỉ nằm trong bộ nhớ cùng model_change và thinking_level_change mà sdk.ts ghi lúc tạo phiên; khi entry ② (message user) được ghi, SessionManager mới tạo tệp và viết header cùng mọi entry đang có (chương Session).
  4. Yêu cầu thứ nhấtprepareRequest thay context bằng projection của session. Ba lớp transformContext chạy (sự kiện context, ẩn khai báo, prompt ép buộc), convertToLlm() hạ role, streamFn gửi qua ModelRuntime. TUI vẽ dần từ message_update.
  5. Lời gọi toolPhản hồi kết thúc với stopReason: "toolUse"; entry ③ được ghi. Hook beforeToolCall phát tool_call cho extension (có thể chặn), tool read chạy, hook afterToolCall phát tool_result (có thể sửa) và thu nhỏ ảnh nếu có. Entry ④ là kết quả tool.
  6. Hết lượt 1finishTurn phát boundary turn_end cho extension; sự kiện turn_end đổ các custom message bị hoãn (không có).
  7. Lượt 2prepareNextTurnWithContext kiểm tra ngưỡng compaction và tính bản vá system (không đổi, nên không có message system mới). Yêu cầu thứ hai trả về văn bản với stopReason: "stop"; entry ⑤.
  8. Kết thúcKhông còn lời gọi tool, không có tin nhắn xếp hàng: agent_end với willRetry: false. _handlePostAgentRun() trả false, boundary agent_before_settle không xin chạy tiếp, khối finally phát agent_settled.

Ví dụ minh hoạ: tệp session sau kịch bản này có header và bảy entry, nối nhau thành một chuỗi qua parentId.

Thứ tựEntryNội dung chínhGhi bởi
0session (header)version: 3, id, cwdSessionManager.newSession()
1model_changeprovider, modelId ban đầusdk.ts
2thinking_level_changemức suy nghĩ ban đầusdk.ts
3 ①message, role systemsections đầy đủ, toolsAdded bốn tool_handleAgentEvent
4 ②message, role uservăn bản prompt_handleAgentEvent
5 ③message, role assistantkhối toolCall gọi read, usage, stopReason: "toolUse"_handleAgentEvent
6 ④message, role toolResulttoolCallId, nội dung tệp_handleAgentEvent
7 ⑤message, role assistantcâu trả lời, stopReason: "stop"_handleAgentEvent

Dạng JSON của từng dòng có ở ví dụ JSONL trong chương Session.

Tự động thử lại khi provider lỗi#

Vấn đề: giữa chừng một lượt, provider trả "overloaded" (HTTP 529) hoặc kết nối bị đứt. Vòng lặp agent không ném lỗi; nó kết thúc lần chạy bằng một message assistant có stopReason: "error" và errorMessage. Không có retry, người dùng phải gõ "tiếp tục". Pi thử lại ở hai tầng:

  • Tầng provider: SDK của provider tự thử lại một yêu cầu trước khi có phản hồi. AgentSession chỉ truyền tham số vào từ nhóm setting retry.provider (timeoutMs, maxRetries, maxRetryDelayMs) qua buildRequestOptions (sdk.ts:340).
  • Tầng phiên: AgentSession xét phản hồi lỗi sau agent_end và chạy lại cả lượt. Phần này nằm trọn trong agent-session.ts.
Lỗi nào được thử lại
pi/packages/coding-agent/src/core/agent-session.tsdòng 3765–3773TypeScript
/** * Check if an error is retryable (overloaded, rate limit, server errors). * Context overflow errors are NOT retryable (handled by compaction instead). */private _isRetryableError(message: AssistantMessage): boolean {	// Context overflow is handled by compaction, not retry.	if (isContextOverflow(message, (this._modelForMessage(message) ?? this.model)?.contextWindow ?? 0)) return false;	return isRetryableAssistantError(message);}
Tràn cửa sổ ngữ cảnh (context window) không được thử lại vì gửi lại y nguyên sẽ tràn tiếp; nó đi sang nhánh compaction. Phần còn lại do pi-ai phân loại bằng biểu thức chính quy trên errorMessage: "overloaded", "rate limit", mã 429/500/502/503/504, lỗi mạng... (retry.ts:33), trừ các lỗi hết hạn mức hoặc hết tiền, loại lỗi không tự khỏi sau vài giây (retry.ts:266).
_prepareRetry(): đếm lần, chờ lùi dần, loại lần hỏng khỏi ngữ cảnh
pi/packages/coding-agent/src/core/agent-session.tsdòng 3822–3861TypeScript
private async _prepareRetry(message: AssistantMessage): Promise<boolean> {	const settings = this.settingsManager.getRetrySettings();	if (!settings.enabled) {		return false;	}	this._retryAttempt++;	if (this._retryAttempt > settings.maxRetries) {		// Preserve the completed attempt count so post-run handling can emit the final failure.		this._retryAttempt--;		return false;	}	const delayMs = retryDelayMs(settings, this._retryAttempt);	this._emit({		type: "auto_retry_start",		attempt: this._retryAttempt,		maxAttempts: settings.maxRetries,		delayMs,		errorMessage: message.errorMessage || "Unknown error",	});	// Keep the failed attempt in raw history while durably omitting it from model projection.	this._omitRecoveryAttempt(message);	// Wait with exponential backoff (abortable)	this._retryAbortController = new AbortController();	try {		await sleep(delayMs, this._retryAbortController.signal);	} catch {		// Aborted during sleep - emit end event so UI can clean up		this._finishCancelledRetry();		return false;	} finally {		this._retryAbortController = undefined;	}	return true;
Độ trễ là baseDelayMs * 2^(attempt-1), chặn trên bởi maxAgentDelayMs (retry.ts:142). Mặc định maxRetries: 3, baseDelayMs: 2000, tức 2, 4, 8 giây (settings-defaults.ts:18). Thời gian chờ dùng một AbortController riêng, nên Esc huỷ được ngay trong lúc chờ.

Dòng 3847 là phần đáng học nhất. Lần thử hỏng không bị xoá khỏi tệp, vì session chỉ ghi thêm. Thay vào đó AgentSession ghi một entry context_edit với replacement: null cho message lỗi và cho mọi kết quả tool đi kèm nó:

_omitRecoveryAttempt(): bỏ lần thử khỏi projection, giữ trong lịch sử thô
pi/packages/coding-agent/src/core/agent-session.tsdòng 1239–1272TypeScript
/** * Persistently omit a failed attempt and its tool results from model context. Results are * found in the projected context, so the pair stays together whether the attempt comes from * the current run or a resumed session. An unpaired result fails the next request (#11227). */private _omitRecoveryAttempt(message: AssistantMessage): void {	const toolCallIds = new Set(message.content.flatMap((block) => (block.type === "toolCall" ? [block.id] : [])));	// A context edit replaces the projected copy, so match it by source entry as well.	const entryId = this._findPersistedMessageEntryId(message);	const messages = this.agent.state.messages;	const index = messages.findIndex(		(m) => m === message || (entryId !== undefined && this._entryIdsByMessage.get(m) === entryId),	);	const toolResults: AgentMessage[] = [];	for (const m of index < 0 ? [] : messages.slice(index + 1)) {		if (m.role === "assistant") break;		if (m.role === "toolResult" && toolCallIds.has(m.toolCallId)) toolResults.push(m);	}	const targets = [message, ...toolResults];	const targetIds = targets.map((target) => this._findPersistedMessageEntryId(target));	const unresolvedProjectedTarget = targets.some(		(target, index) => targetIds[index] === undefined && this.agent.state.messages.includes(target),	);	if (unresolvedProjectedTarget) {		throw new Error("Cannot persist recovery omission because a projected message has no source entry");	}	for (const targetId of targetIds) {		if (!targetId) continue;		const editId = this.sessionManager.appendContextEdit(targetId, null);		const entry = this.sessionManager.getEntry(editId);		if (entry) this._emit({ type: "entry_appended", entry });	}	this._refreshFinalizedContext();}
Kết quả tool được tìm trong ngữ cảnh đã chiếu, nên cặp lời gọi và kết quả luôn bị bỏ cùng nhau, kể cả khi lần thử nằm trong một phiên vừa resume. Một kết quả tool mồ côi khiến mọi yêu cầu sau bị provider từ chối: đó là lỗi #11227 được sửa ở 1.4.0 (CHANGELOG.md:45).

Sau khi _prepareRetry() trả true, _handlePostAgentRun() lưu phản hồi hỏng vào _failedResponse rồi _startRun() gọi agent.continue(). Yêu cầu kế tiếp không còn thấy lần hỏng trong ngữ cảnh; nếu mô hình đang chọn là virtual model, router nhận nó qua failed với lý do "retry" (agent-session.ts:790). Khi một phản hồi assistant thành công đến, bộ đếm về 0 và auto_retry_end báo thành công (agent-session.ts:1177). Khi hết lượt thử, auto_retry_end mang success: false và lỗi cuối. UI biết trước có thử lại hay không nhờ trường willRetry trên agent_end, tính bằng cùng các điều kiện (agent-session.ts:1200).

Nơi compaction được kích hoạt#

Thuật toán compaction (nén ngữ cảnh): chọn điểm cắt, giữ khoảng 20.000 token gần nhất, viết bản tóm tắt; tất cả thuộc chương Compaction. Mục này chỉ trả lời câu hỏi: AgentSession gọi nó ở đâu và khi nào. Có năm điểm:

ĐiểmHàmLý doChạy lại lượt?
Trước prompt mới_prepareRun() gọi _checkCompaction(lastAssistant, false)tràn hoặc vượt ngưỡng, kể cả khi lượt trước bị abortkhông
Sau agent_end_handlePostAgentRun() gọi _checkCompaction(message)overflow hoặc thresholdcó, nếu phản hồi lỗi vì tràn
Giữa hai lượt của một lần chạy_compactBeforeNextAssistantResponse()thresholdkhông cần: lượt chưa bắt đầu
Trước yêu cầu của virtual modelprepareRequestthreshold, theo mô hình được routekhông cần
Thủ côngcompact() (/compact, RPC, ctx.compact())manualkhông bao giờ
_checkCompaction(): ba trường hợp tự động và các điều kiện bỏ qua
pi/packages/coding-agent/src/core/agent-session.tsdòng 2994–3000, 3011–3035TypeScript
 * Automatic cases: * 1. Overflow with retry: a context-overflow error or recoverable length stop; *    remove the failed assistant message, compact, and retry the turn once. * 2. Overflow without retry: a successful response exceeded the configured *    context window; compact but preserve the completed response. * 3. Threshold without retry: valid or estimated context usage crossed the *    configured threshold; compact without retrying the completed response.private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<boolean> {	const settings = this.settingsManager.getCompactionSettings(this.model);	if (!settings.enabled) return false;	// Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false	if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false;	// Skip overflow check if the message came from a different model.	// This handles the case where user switched from a smaller-context model (e.g. opus)	// to a larger-context model (e.g. codex) - the overflow error from the old model	// shouldn't trigger compaction for the new model. Under a virtual selection, the	// physical model that produced the message supplies the limits.	const messageModel = this._modelForMessage(assistantMessage);	const sameModel = messageModel !== undefined;	const contextWindow = (messageModel ?? this.model)?.contextWindow ?? 0;	// Skip compaction checks if this assistant message is older than the latest	// compaction boundary. This prevents a stale pre-compaction usage/error	// from retriggering compaction on the first prompt after compaction.	const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());	const assistantIsFromBeforeCompaction =		compactionEntry !== null && assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime();	if (assistantIsFromBeforeCompaction) {		return false;	}
Ngoài setting compaction.enabled, có ba phép lọc trước khi xét: phản hồi bị người dùng abort (trừ lần kiểm tra trước prompt); phản hồi của một mô hình khác (đổi từ mô hình cửa sổ nhỏ sang mô hình cửa sổ lớn thì lỗi tràn cũ không còn nghĩa); phản hồi cũ hơn compaction gần nhất (usage của nó phản ánh ngữ cảnh trước khi nén).
Tràn ngữ cảnh: compact rồi thử lại lượt đúng một lần
pi/packages/coding-agent/src/core/agent-session.tsdòng 3070–3106TypeScript
if (contextOverflow || recoverableLength) {	const willRetry = assistantMessage.stopReason !== "stop";	// Case 2: the response completed successfully. Compact, but do not retry because	// agent.continue() cannot continue from a completed assistant response.	if (!willRetry) {		return await this._runAutoCompaction("overflow", false);	}	if (this._overflowRecoveryAttempted) {		const errorMessage = contextOverflow			? "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model."			: "Truncated response recovery failed after one compact-and-retry attempt.";		this._emit({			type: "compaction_end",			reason: "overflow",			result: undefined,			aborted: false,			willRetry: false,			errorMessage,		});		await this._emitSessionCompactFailed({			reason: "overflow",			errorMessage,			aborted: false,			willRetry: false,			fromExtension: false,		});		return false;	}	// Persistently omit the selected final attempt before post-run recovery compaction.	this._overflowRecoveryAttempted = true;	this._omitRecoveryAttempt(assistantMessage);	const retry = await this._runAutoCompaction("overflow", willRetry);	if (retry) this._failedResponse = assistantMessage;	return retry;
Nếu phản hồi đã hoàn tất (stopReason: "stop") mà vẫn vượt cửa sổ, chỉ nén, không thử lại: không thể continue() từ một phản hồi assistant đã xong. Nếu phản hồi lỗi vì tràn, lần thử hỏng bị loại khỏi ngữ cảnh bằng _omitRecoveryAttempt() như khi retry, rồi nén và chạy lại. _overflowRecoveryAttempted bảo đảm chỉ một lần; lần tràn thứ hai báo lỗi gợi ý giảm ngữ cảnh hoặc đổi mô hình lớn hơn.

Trường hợp ngưỡng (agent-session.ts:3109) dùng số token ngữ cảnh từ usage của phản hồi; khi phản hồi lỗi hoặc usage bằng 0, nó ước lượng từ phản hồi hợp lệ gần nhất, và khi có context edit trên nhánh, nó ước lượng trên projection. Điều kiện cuối cùng đơn giản: contextTokens > contextWindow - reserveTokens (compaction.ts:267), với reserveTokens mặc định 16.384.

Điểm giữa hai lượt quan trọng với các lần chạy dài nhiều tool: không có nó, một chuỗi hai mươi lời gọi read có thể làm tràn ngữ cảnh trước khi lần chạy kết thúc.

Nén giữa hai lượt, trước phản hồi assistant kế tiếp
pi/packages/coding-agent/src/core/agent-session.tsdòng 776–785TypeScript
private async _compactBeforeNextAssistantResponse(context: AgentContext): Promise<AgentContext> {	const projection = this.sessionManager.buildSessionProjection();	// A virtual selection is checked in prepareRequest, against the model the request is routed to.	const model = this.model;	if (!model || isVirtualModel(model) || !this._exceedsCompactionThreshold(model, projection)) {		return { ...context, messages: projection.messages };	}	await this._runAutoCompaction("threshold", false);	return { ...context, messages: this.sessionManager.buildSessionProjection().messages };}

Mọi đường tự động đều đi qua _runAutoCompaction(): phát compaction_start, cho extension cơ hội huỷ hoặc tự cung cấp bản tóm tắt qua session_before_compact, ghi entry compaction, làm mới context, phát compaction_end (agent-session.ts:3157). Nó trả true (gọi agent.continue()) khi cần chạy lại lượt hoặc khi còn tin nhắn xếp hàng. Compaction thủ công thì khác ở một điểm: compact() gọi abort() trước tiên, nên lượt đang chạy bị dừng và không được tiếp tục (agent-session.ts:2828).

Đổi mô hình và mức suy nghĩ#

Đổi mô hình bằng /model hay xoay vòng bằng Ctrl+P là thao tác trên phiên, không phải trên settings. Mặc định nó chỉ có hiệu lực trong phiên hiện tại; tuỳ chọn persist mới ghi thêm vào settings toàn cục (agent-session.ts:328). Điều phiên cần là ghi lại lựa chọn vào transcript, để resume khôi phục đúng mô hình mà không đụng tới mặc định cho phiên mới.

setModel(): kiểm tra credential, ghi model_change, áp mức suy nghĩ
pi/packages/coding-agent/src/core/agent-session.tsdòng 2541–2561TypeScript
async setModel(model: Model<any>, options: ModelMutationOptions = {}): Promise<void> {	if (!(await this._modelRuntime.checkAuth(model.provider))) {		throw new Error(`No API key for ${model.provider}/${model.id}`);	}	const previousModel = this.model;	const thinkingLevel = this._getThinkingLevelForModelSwitch(model);	this.agent.state.model = model;	this.sessionManager.appendModelChange(model.provider, model.id);	if (options.persist) {		this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);		this._addPersistedDefaultToNonEmptyScope(model);	}	// Apply thinking level for the new model.	// Per-model thinking level overrides take priority over the global default.	// Model persistence does not implicitly rewrite the global thinking default.	this.setThinkingLevel(thinkingLevel);	await this._emitModelSelect(model, previousModel, "set");}
Mô hình mới vào agent.state.model. Nếu đổi khi đang chạy, nó có hiệu lực từ yêu cầu kế tiếp: cả prepareRequest lẫn prepareNextTurnWithContext đọc lại agent.state.model trước mỗi yêu cầu (agent-session.ts:928).

Mức suy nghĩ khi đổi mô hình được chọn theo thứ tự: mức ghi kèm trong pattern --models (ví dụ sonnet:high), rồi mức riêng của mô hình trong modelThinkingLevels, rồi defaultThinkingLevel, rồi mức hiện tại (agent-session.ts:2733). cycleModel() xoay trong danh sách --models nếu có, nếu không thì trong mọi mô hình có credential (agent-session.ts:2583); cả hai nhánh cũng ghi model_change.

setThinkingLevel(): kẹp theo mô hình, chỉ ghi khi thực sự đổi
pi/packages/coding-agent/src/core/agent-session.tsdòng 2676–2699TypeScript
setThinkingLevel(level: ThinkingLevel, options: ModelMutationOptions = {}): void {	const availableLevels = this.getAvailableThinkingLevels();	const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels);	// Only persist if actually changing	const previousLevel = this.agent.state.thinkingLevel;	const isChanging = effectiveLevel !== previousLevel;	this.agent.state.thinkingLevel = effectiveLevel;	if (options.persist) {		this.settingsManager.setDefaultThinkingLevel(level);	}	if (isChanging) {		this.sessionManager.appendThinkingLevelChange(effectiveLevel);		this._emit({ type: "thinking_level_changed", level: effectiveLevel });		void this._extensionRunner.emit({			type: "thinking_level_select",			level: effectiveLevel,			previousLevel,		});	}}
Mức được kẹp theo khả năng của mô hình (clampThinkingLevel của pi-ai). Entry thinking_level_change chỉ được ghi khi mức hiệu lực thay đổi, nên chuyển qua lại giữa hai mô hình không làm phình tệp. Khi persist, settings nhận mức người dùng yêu cầu, không phải mức đã kẹp.

Lúc mở lại phiên, hai giá trị được đọc ngược từ nhánh: mức suy nghĩ lấy từ thinking_level_change cuối cùng, mô hình lấy từ model_change cuối cùng hoặc từ message assistant gần nhất (session-manager.ts:418). Virtual model là ngoại lệ: message assistant chỉ ghi tên mô hình vật lý đã trả lời, nên lựa chọn ảo chỉ tìm thấy trong model_change. _recordSelection() chạy trước mỗi lần chạy: khi lựa chọn hiện tại hoặc lựa chọn mà nhánh đang ghi là một virtual model và hai lựa chọn khác nhau, nó ghi thêm một model_change lên nhánh hiện tại, chẳng hạn sau khi /tree đưa lá sang một nhánh mà model_change cuối nằm ở nhánh kia (agent-session.ts:621).

Steer, follow-up, abort và tin nhắn tuỳ biến#

Người dùng không phải chờ agent làm xong mới gõ tiếp. Nhấn Enter khi agent đang chạy gửi một tin nhắn steer (chen vào lượt đang chạy); Alt+Enter (phím mặc định của app.message.followUp, keybindings.ts:67) gửi một follow-up (xếp hàng sau) (interactive-mode.ts:3335, 4462). Hai hàng đợi thật nằm trong Agent: vòng lặp agent lấy tin steer sau mỗi lượt, khi kết quả tool đã vào ngữ cảnh và trước yêu cầu kế tiếp; tin follow-up chỉ được lấy khi agent sắp dừng (agent-loop.ts:303, chi tiết ở chương pi-agent-core). Chế độ one-at-a-time (mặc định) lấy từng tin một, all lấy hết (agent.ts:165); AgentSession đọc chế độ từ setting steeringMode, followUpMode.

Xếp hàng: một bản văn bản cho UI, một message thật cho Agent
pi/packages/coding-agent/src/core/agent-session.tsdòng 2302–2327TypeScript
private async _queueSteer(text: string, images?: ImageContent[]): Promise<void> {	this._steeringMessages.push(text);	this._emitQueueUpdate();	const content: (TextContent | ImageContent)[] = [{ type: "text", text }];	if (images) {		content.push(...images);	}	this.agent.steer({		role: "user",		content,		timestamp: Date.now(),	});}/** * Internal: Queue a follow-up message (already expanded, no extension command check). */private async _queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {	this._followUpMessages.push(text);	this._emitQueueUpdate();	const content: (TextContent | ImageContent)[] = [{ type: "text", text }];	if (images) {		content.push(...images);	}	this.agent.followUp({ role: "user", content, timestamp: Date.now() });}
AgentSession giữ bản sao văn bản trong _steeringMessages/_followUpMessages chỉ để hiển thị và phát queue_update. Bản sao được gỡ khi message user tương ứng bắt đầu, khớp theo nội dung văn bản (agent-session.ts:1120).

Văn bản được mở rộng skill và template trước khi xếp hàng, nhưng một lệnh extension thì không xếp hàng được: steer() và followUp() ném lỗi nếu gặp nó (agent-session.ts:2332). Đi qua prompt() thì khác: lệnh extension được chạy ngay, cả khi agent đang bận.

Abort

abort(): dừng mọi thứ đang chạy rồi chờ phiên rảnh
pi/packages/coding-agent/src/core/agent-session.tsdòng 2498–2508TypeScript
async abort(): Promise<void> {	if (this._isAgentRunActive) {		this._agentRunAbortRequested = true;	}	this.abortRetry();	this.abortCompaction();	this.abortBranchSummary();	if (this._isBeforeSettle) this._abortDuringBeforeSettle = true;	this.agent.abort();	await this.waitForIdle();}
Cờ _agentRunAbortRequested chặn vòng lặp sau run trong _startRun() gọi agent.continue() thêm lần nào. Phản hồi đang stream kết thúc với stopReason: "aborted" và vẫn được ghi như mọi message khác.

Ở interactive mode, Esc không chỉ gọi abort(): nó rút mọi tin đang xếp hàng bằng clearQueue() và đặt chúng lại vào trình soạn thảo, nối trước văn bản đang gõ, để người dùng sửa rồi gửi lại (interactive-mode.ts:4711).

Tin nhắn tuỳ biến của extension

Extension gửi message vào ngữ cảnh bằng pi.sendMessage(), đi tới sendCustomMessage(). Hàm này phải chọn giữa năm cách giao, tuỳ phiên đang chạy hay rảnh:

sendCustomMessage(): nextTurn, steer/follow-up, chạy mới, hoãn, ghi ngay
pi/packages/coding-agent/src/core/agent-session.tsdòng 2357–2393TypeScript
async sendCustomMessage<T = unknown>(	message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,	options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },): Promise<void> {	const appMessage = {		role: "custom" as const,		customType: message.customType,		// Untyped extensions can pass null/missing content; normalize at ingestion.		content: message.content ?? [],		display: message.display,		details: message.details,		timestamp: Date.now(),	} satisfies CustomMessage<T>;	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);	}}
nextTurn: đính kèm vào prompt kế tiếp của người dùng. Đang chạy và được phép kích hoạt lượt: xếp vào hàng steer hoặc follow-up. Rảnh và triggerTurn: mở một lần chạy mới bắt đầu từ message này. Đang chạy nhưng không kích hoạt lượt: hoãn tới cuối lượt. Rảnh và không kích hoạt: ghi ngay.

Trường hợp hoãn có lý do cụ thể. Nếu ghi ngay khi một lượt đang chạy, message custom có thể chen giữa message assistant chứa lời gọi tool và message toolResult của nó. Provider kiểm tra thứ tự message sẽ từ chối transcript đó khi phát lại. Vì vậy message nằm trong _pendingCustomMessages cho tới turn_end, thời điểm đầu tiên mà mọi kết quả tool của lượt đã được ghi (agent-session.ts:1190). Kết quả lệnh ! đi theo cùng nguyên tắc, như mục sau cho thấy. sendUserMessage() của extension thì đi qua prompt() với source: "extension" và mặc định không mở rộng template (agent-session.ts:2429).

Lệnh ! của người dùng#

Gõ !git status trong trình soạn thảo chạy lệnh ngay trên máy, không qua mô hình, rồi đưa kết quả vào ngữ cảnh để lượt sau mô hình biết. !!git status chạy y hệt nhưng không cho mô hình thấy. Interactive mode tách hai tiền tố:

Interactive mode: ! và !!
pi/packages/coding-agent/src/modes/interactive/interactive-mode.tsdòng 3303–3319TypeScript
// Handle bash command (! for normal, !! for excluded from context)if (text.startsWith("!")) {	const isExcluded = text.startsWith("!!");	const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim();	if (command) {		if (this.session.isBashRunning) {			this.showWarning("A bash command is already running. Press Esc to cancel it first.");			this.editor.setText(text);			return;		}		this.editor.addToHistory?.(text);		await this.handleBashCommand(command, isExcluded);		this.isBashMode = false;		this.updateEditorBorderColor();		return;	}}
Mỗi lúc chỉ một lệnh ! chạy. Trước khi chạy, extension nhận sự kiện user_bash và có thể tự trả kết quả hoặc cung cấp BashOperations khác, ví dụ để chạy qua SSH hay trong container (interactive-mode.ts:6960).
executeBash(): tiền tố shell, chạy, stream từng mẩu, ghi kết quả
pi/packages/coding-agent/src/core/agent-session.tsdòng 3901–3933TypeScript
async executeBash(	command: string,	onChunk?: (chunk: string) => void,	options?: { excludeFromContext?: boolean; id?: string; operations?: BashOperations },): Promise<BashResult> {	const abortController = new AbortController();	this._bashAbortControllers.add(abortController);	// Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)	const prefix = this.settingsManager.getShellCommandPrefix();	const shellPath = this.settingsManager.getShellPath();	const resolvedCommand = prefix ? `${prefix}\n${command}` : command;	try {		const result = await executeBashWithOperations(			resolvedCommand,			this.sessionManager.getCwd(),			options?.operations ?? createLocalBashOperations({ shellPath }),			{				onChunk: (delta) => {					onChunk?.(delta);					this._emit({ type: "bash_execution_update", id: options?.id, delta });				},				signal: abortController.signal,			},		);		this.recordBashResult(command, result, options);		return result;	} finally {		this._bashAbortControllers.delete(abortController);	}}
Setting shellCommandPrefix (ví dụ shopt -s expand_aliases) được nối trước lệnh; mỗi mẩu output đi ra listener qua sự kiện bash_execution_update. RPC mode dùng cùng hàm này cho lệnh bash của nó.

Việc chạy thật nằm trong bash-executor.ts. Nó phải chịu được lệnh in ra hàng trăm MB mà không giữ hết trong RAM:

Bộ đệm cuộn, tệp tạm, cắt phần đuôi
pi/packages/coding-agent/src/core/bash-executor.tsdòng 83–98, 130–145TypeScript
	// Start writing to temp file if exceeds threshold	if (totalBytes > DEFAULT_MAX_BYTES) {		ensureTempFile();	}	if (tempFileStream) {		tempFileStream.write(text);	}	// Keep rolling buffer	outputChunks.push(text);	outputBytes += text.length;	while (outputBytes > maxOutputBytes && outputChunks.length > 1) {		const removed = outputChunks.shift()!;		outputBytes -= removed.length;	}flushOutput();const fullOutput = outputChunks.join("");const truncationResult = truncateTail(fullOutput);if (truncationResult.truncated) {	ensureTempFile();}tempFileStream?.end();const cancelled = options?.signal?.aborted ?? false;return {	output: truncationResult.truncated ? truncationResult.content : fullOutput,	exitCode: cancelled ? undefined : (exitCode ?? undefined),	cancelled,	truncated: truncationResult.truncated,	fullOutputPath: tempFilePath,};
Output được làm sạch (bỏ mã ANSI, thay byte nhị phân, bỏ \r), giữ trong một bộ đệm cuộn khoảng 100 KB (DEFAULT_MAX_BYTES * 2, dòng 56). Quá 50 KB thì toàn bộ output còn được ghi ra tệp tạm. Cuối cùng truncateTail() giữ phần đuôi trong giới hạn 2.000 dòng hoặc 50 KB, và fullOutputPath trỏ tới tệp đầy đủ.

Kết quả trở thành một BashExecutionMessage. Điểm tinh tế: nếu agent đang chạy, message bị hoãn, cùng lý do với custom message ở mục trước:

recordBashResult(): ghi ngay khi rảnh, hoãn khi đang chạy
pi/packages/coding-agent/src/core/agent-session.tsdòng 3939–3960TypeScript
recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {	const bashMessage: BashExecutionMessage = {		role: "bashExecution",		command,		output: result.output,		exitCode: result.exitCode,		cancelled: result.cancelled,		truncated: result.truncated,		fullOutputPath: result.fullOutputPath,		timestamp: Date.now(),		excludeFromContext: options?.excludeFromContext,	};	// If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering	if (this.isStreaming) {		// Queue for later - will be flushed on agent_end		this._pendingBashMessages.push(bashMessage);	} else {		this.sessionManager.appendMessage(bashMessage);		this._refreshFinalizedContext();	}}
Message hoãn được đổ ra trong khối finally của _startRun() (agent-session.ts:3985). Hàm này là public: một extension tự chạy lệnh trong user_bash vẫn gọi nó để kết quả vào session.

Entry được ghi là một entry message thường với role bashExecution. Khi dựng yêu cầu, convertToLlm() biến nó thành message user với văn bản do bashExecutionToText() tạo: dòng "Ran `lệnh`", output trong khối code, mã thoát khác 0 nếu có, và đường dẫn output đầy đủ khi bị cắt (messages.ts:82). Đây không phải một kết quả tool: mô hình không gọi nó, nên không có toolCallId nào để ghép.

Các dịch vụ xung quanh: settings, auth, model, trust, đường dẫn#

AgentSession dựa vào các dịch vụ dưới đây. Mỗi dịch vụ đủ lớn để có tài liệu riêng; mục này chỉ nêu điều cần để hiểu vòng đời một prompt.

Settings: toàn cục và dự án

Có hai tệp: ~/.pi/agent/settings.json (toàn cục) và <cwd>/.pi/settings.json (dự án) (settings-manager.ts:186). Settings hiệu lực là hai tệp trộn sâu, tệp dự án thắng:

Trộn settings: object trộn đệ quy, giá trị khác bị thay
pi/packages/coding-agent/src/core/settings-manager.tsdòng 61–82, 142–147TypeScript
function isMergeableObject(value: unknown): value is Record<string, unknown> {	return typeof value === "object" && value !== null && !Array.isArray(value);}function deepMergeObjects(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, unknown> {	const result = { ...base };	for (const key of Object.keys(overrides)) {		const overrideValue = overrides[key];		if (overrideValue === undefined) {			continue;		}		const baseValue = base[key];		result[key] =			isMergeableObject(baseValue) && isMergeableObject(overrideValue)				? deepMergeObjects(baseValue, overrideValue)				: overrideValue;	}	return result;}/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */function deepMergeSettings(base: Settings, overrides: Settings): Settings {	const merged = deepMergeObjects(base as Record<string, unknown>, overrides as Record<string, unknown>) as Settings;	const defaultTools = mergeDefaultTools(base.defaultTools, overrides.defaultTools);	return defaultTools === undefined ? merged : { ...merged, defaultTools };}
Object lồng nhau như compaction được trộn từng khoá, nên dự án có thể chỉ đặt compaction.reserveTokens mà vẫn giữ compaction.enabled toàn cục. Mảng bị thay nguyên, trừ defaultTools: một danh sách chỉ gồm +tên/-tên được nối vào danh sách kế thừa thay vì thay nó (settings-manager.ts:122).

Ba chi tiết vận hành đáng biết. Thứ nhất, khi dự án chưa được tin cậy, tệp dự án không được đọc (settings-manager.ts:366) và mọi lệnh ghi vào nó bị từ chối. Thứ hai, các setter như setDefaultModelAndProvider() ghi vào phạm vi toàn cục; lúc ghi, tệp được đọc lại dưới file lock và chỉ những trường mà tiến trình này đã sửa được ghi đè, nên hai tiến trình pi đổi hai setting khác nhau không xoá thay đổi của nhau (settings-manager.ts:598). Thứ ba, lỗi ghi được ghi nhận vào danh sách lỗi chứ không ném ra giữa một lượt (settings-manager.ts:576). Kiểu Settings sinh từ schema TypeBox trong settings-schema.ts (settings-schema.ts:479), cùng schema mà settings.json tham chiếu qua $schema để editor gợi ý (docs/settings.md:5); giá trị mặc định nằm ở settings-defaults.ts.

Credential: auth.json và làm mới OAuth

AuthStorage cài đặt interface CredentialStore của pi-ai trên tệp ~/.pi/agent/auth.json, mỗi provider một credential: hoặc api_key, hoặc oauth với access, refresh, expires (auth/types.ts:17). Tệp được tạo với quyền 0600, thư mục cha 0700 (auth-storage.ts:37), mọi lần đọc ghi đi qua proper-lockfile. Khi đọc một api_key, giá trị được giải qua resolveConfigValue(): $NAME đọc biến môi trường, !lệnh chạy lệnh shell (auth-storage.ts:442).

Interface CredentialStore chỉ có một đường ghi, modify(provider, fn) (ngoài delete để đăng xuất): fn nhận credential hiện tại bên trong lock (auth/types.ts:78). Thiết kế đó phục vụ đúng một việc khó, làm mới OAuth khi nhiều tiến trình pi cùng chạy:

pi-ai: làm mới OAuth với khoá kiểm tra hai lần
pi/packages/ai/src/auth/resolve.tsdòng 119–154TypeScript
export async function refreshStoredOAuthCredential(	credentials: CredentialStore,	providerId: string,	oauth: OAuthAuth,	needsRefresh: (credential: OAuthCredential) => boolean,	signal: AbortSignal,): Promise<OAuthCredential | undefined> {	let post: Credential | undefined;	const lockWait = new AbortController();	const cancelLockWait = () => lockWait.abort(signal.reason);	signal.addEventListener("abort", cancelLockWait, { once: true });	if (signal.aborted) cancelLockWait();	try {		post = await credentials.modify(			providerId,			async (current) => {				signal.removeEventListener("abort", cancelLockWait);				signal.throwIfAborted();				if (current?.type !== "oauth") return undefined; // logged out meanwhile				if (!needsRefresh(current)) return undefined; // another process/request refreshed				try {					return await oauth.refresh(current, AbortSignal.timeout(DEFAULT_OAUTH_REFRESH_TIMEOUT_MS));				} catch (error) {					throw new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error });				}			},			{ signal: lockWait.signal },		);	} catch (error) {		if (error instanceof ModelsError) throw error;		signal.throwIfAborted();		throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error });	} finally {		signal.removeEventListener("abort", cancelLockWait);	}	return post?.type === "oauth" ? post : undefined;
Token còn dưới năm phút được làm mới. Điều kiện cần làm mới được kiểm tra lại bên trong lock: nếu một tiến trình khác vừa làm mới xong, fn trả undefined và giữ nguyên. Lời gọi refresh bỏ qua tín hiệu huỷ của người gọi, chỉ bị giới hạn bởi timeout 15 giây, vì provider có thể đã xoay vòng refresh token: huỷ giữa chừng sẽ làm mất token hợp lệ duy nhất.

Thứ tự nguồn credential: --api-key lúc chạy, rồi auth.json, rồi apiKey trong models.json, cuối cùng biến môi trường của provider (docs/models.md:23). Các bản cài cũ dùng oauth.json và apiKeys trong settings; migrateAuthToAuthJson() chuyển chúng sang auth.json một lần lúc khởi động (migrations.ts:21).

Mô hình: ModelRuntime, ModelRegistry và model-resolver

ModelRuntime là bộ sưu tập Models của pi-ai đã cấu hình cho coding agent (model-runtime.ts:173): provider dựng sẵn với danh mục mô hình có thể cập nhật từ pi.dev, provider khai báo trong models.json, provider và virtual model do extension đăng ký, credential từ AuthStorage. Mỗi lần streamSimple(), nó giải credential cho đúng yêu cầu đó rồi mới gọi provider (model-runtime.ts:659), nên một token OAuth hết hạn giữa phiên được làm mới trong suốt. Định tuyến virtual model, mô hình ảnh và classifier thuộc chương Pi 1.0. ModelRegistry chỉ là một mặt tiền đồng bộ bọc ModelRuntime cho extension (model-registry.ts:44).

--model nhận một pattern, không cần id chính xác. resolveCliModel() xét trên mọi mô hình, kể cả mô hình chưa có credential, để --api-key dùng được cho lần cài đặt đầu:

resolveCliModel(): --provider, provider/id, mờ, hậu tố thinking
pi/packages/coding-agent/src/core/model-resolver.tsdòng 459–486TypeScript
// Important: use *all* models here, not just models with pre-configured auth.// This allows "--api-key" to be used for first-time setup.const models = [...modelRuntime.getModels()];if (models.length === 0) {	return {		model: undefined,		warning: undefined,		error: "No models available. Check your installation or add models to models.json.",	};}const provider = cliProvider	? models.find((m) => m.provider.toLowerCase() === cliProvider.toLowerCase())?.provider	: undefined;if (cliProvider && !provider) {	return {		model: undefined,		warning: undefined,		error: `Unknown provider "${cliProvider}". Use --list-models to see available providers/models.`,	};}const result = resolveModelReference(	{ text: cliModel, provider, thinkingSuffix: "strict", fuzzy: true },	models,	// An inline key goes to whichever provider is selected, so stored credentials must not choose it	inlineApiKey ? () => false : (providerId) => modelRuntime.hasConfiguredAuth(providerId),);
Quy tắc trong resolveModelReference(): provider/id chỉ là gợi ý vì chính chuỗi đó có thể là id trên provider khác; khớp chính xác thắng khớp mờ; khớp mờ so chuỗi con trên id và tên, ưu tiên alias hơn bản có ngày (claude-sonnet-4-5 hơn claude-sonnet-4-5-20250929) (model-resolver.ts:286); hậu tố :high chỉ được tách khi cả chuỗi không khớp mô hình nào. Khớp ở nhiều provider có credential thì báo mơ hồ thay vì đoán; không khớp nhưng provider đã rõ thì dùng id tuỳ biến kèm cảnh báo.

Khi không có --model và session không có gì để khôi phục, findInitialModel() chọn mô hình khởi đầu:

findInitialModel(): mặc định trong settings, rồi mô hình mặc định của provider có credential
pi/packages/coding-agent/src/core/model-resolver.tsdòng 576–606TypeScript
// 2. Try saved default from settings if auth is configured.if (defaultProvider && defaultModelId) {	const found = modelRuntime.getModel(defaultProvider, defaultModelId);	if (found && modelRuntime.hasConfiguredAuth(found.provider)) {		model = found;		const perModel = modelThinkingLevels?.[`${defaultProvider}/${defaultModelId}`];		if (perModel) {			thinkingLevel = perModel;		} else if (defaultThinkingLevel) {			thinkingLevel = defaultThinkingLevel;		}		return { model, thinkingLevel, fallbackMessage: undefined };	}}// 3. Try first available model with valid API keyconst availableModels = [...modelRuntime.getAvailableSnapshot()];if (availableModels.length > 0) {	// Try to find a default model from known providers	for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {		const defaultId = defaultModelPerProvider[provider];		const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);		if (match) {			return { model: match, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };		}	}	// If no default found, use first available	return { model: availableModels[0], thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };}
Bước 1 (lược) dùng mô hình đầu tiên của --models khi không phải đang tiếp tục một phiên. Ở main.ts, --models được xử lý trước: nếu mô hình mặc định trong settings nằm trong phạm vi thì dùng nó, không thì dùng mô hình đầu tiên (main.ts:500).

Trust: quyết định trước khi nạp tài nguyên dự án

Một thư mục lạ có thể chứa .pi/extensions/ là mã TypeScript chạy trong tiến trình pi. Vì vậy quyết định tin cậy dự án phải có trước khi những thứ đó được nạp (docs/how-pi-works.md:49). Chỉ những tài nguyên có thể thực thi hoặc đổi cấu hình mới đòi quyết định: .pi/settings.json, .pi/mcp.json, các thư mục extensions, skills, prompts, themes, SYSTEM.md, APPEND_SYSTEM.md, và .agents/skills ở thư mục hiện tại hoặc thư mục cha (trust-manager.ts:186). Thứ tự nạp trong ResourceLoader.reload() cho thấy cách làm:

Nạp extension không có tài nguyên dự án, hỏi trust, rồi mới nạp đầy đủ
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 nạp đầu ép trạng thái chưa tin cậy, nên chỉ extension dựng sẵn, extension của người dùng và extension truyền từ dòng lệnh được chạy. Chúng có thể trả lời sự kiện project_trust.
Thứ tự quyết định trust
pi/packages/coding-agent/src/core/project-trust.tsdòng 47–85TypeScript
export async function resolveProjectTrusted(options: ResolveProjectTrustedOptions): Promise<boolean> {	if (options.trustOverride !== undefined) {		return options.trustOverride;	}	if (!hasTrustRequiringProjectResources(options.cwd)) {		return true;	}	if (options.extensionsResult) {		const { result, errors } = await emitProjectTrustEvent(			options.extensionsResult,			{ type: "project_trust", cwd: options.cwd },			options.projectTrustContext,		);		for (const error of errors) {			options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);		}		if (result) {			const trusted = result.trusted === "yes";			if (result.remember === true) {				options.trustStore.set(options.cwd, trusted);			}			return trusted;		}	}	const decision = options.trustStore.get(options.cwd);	if (decision !== null) {		return decision;	}	switch (options.defaultProjectTrust ?? SETTINGS_DEFAULTS.defaultProjectTrust) {		case "always":			return true;		case "never":			return false;		case "ask":			break;	}
--approve/--no-approve thắng tất cả. Sau đó là extension, rồi quyết định đã lưu cho thư mục này hoặc thư mục cha gần nhất trong ~/.pi/agent/trust.json, rồi setting defaultProjectTrust. Với "ask", interactive mode hiện hộp chọn; các chế độ không có UI coi như không tin cậy.

Trust không phải hộp cát: tool vẫn chạy với quyền hệ điều hành của tiến trình, và context file như AGENTS.md vẫn được nạp dù không tin cậy (docs/security.md:27). Tài liệu cũng nói rõ một kẽ hở: setting sessionDir của dự án được đọc lúc chọn session, trước khi trust được giải quyết (docs/security.md:31). Trong code, main.ts dùng một SettingsManager mở ở cwd khởi động để lấy sessionDir, rồi mới dựng runtime với trạng thái trust đã quyết định (main.ts:686).

Đường dẫn: config.ts

Thư mục agent: ~/.pi/agent, đổi được bằng PI_CODING_AGENT_DIR
pi/packages/coding-agent/src/config.tsdòng 604–611TypeScript
/** Get the agent config directory (e.g., ~/.pi/agent/) */export function getAgentDir(): string {	const envDir = process.env[ENV_AGENT_DIR];	if (envDir) {		return expandTildePath(envDir);	}	return join(homedir(), CONFIG_DIR_NAME, "agent");}
Tên biến môi trường suy ra từ tên ứng dụng (config.ts:584), nên một bản phân phối đổi tên qua piConfig trong package.json có thư mục và biến riêng. Quy tắc của repo: mọi đường dẫn tới tài nguyên của gói phải đi qua helper trong config.ts, không dùng __dirname, vì pi chạy được từ mã nguồn, từ gói npm và từ binary độc lập (AGENTS.md:22).
Đường dẫnNội dung
~/.pi/agent/settings.jsonsettings toàn cục
~/.pi/agent/auth.jsoncredential, quyền 0600
~/.pi/agent/models.jsonprovider và mô hình tuỳ biến
~/.pi/agent/trust.jsonquyết định trust theo thư mục
~/.pi/agent/sessions/tệp session, một thư mục con cho mỗi cwd (chương Session)
~/.pi/agent/prompts/, themes/, bin/prompt template, theme tuỳ biến, fd/rg do pi quản lý
<cwd>/.pi/settings.json, mcp.json, extensions/, skills/, prompts/, themes/, SYSTEM.md, APPEND_SYSTEM.md của dự án (cần trust)

Quyết định thiết kế#

Tệp session là bản gốc, mỗi yêu cầu được dựng lại từ nó

Cách đơn giản hơn là để Agent giữ lịch sử trong bộ nhớ và thỉnh thoảng lưu ra đĩa. Pi không làm vậy vì nhiều thứ ghi vào session mà không đi qua mảng message của Agent: extension ghi context_edit hay compaction ở boundary, retry loại một lần thử hỏng, /tree dời lá sang nhánh khác. Nếu yêu cầu kế tiếp đọc từ bộ nhớ, nó sẽ khác cái mà một lần resume dựng ra từ tệp. Dựng lại từ projection trước mọi yêu cầu (prepareRequest) loại bỏ khả năng lệch đó. Bài "The Session You Cannot Take With You" của Earendil nói thẳng lập trường này: nhật ký sự kiện cục bộ là bản chuẩn (earendil.com/posts/session-portability).

System prompt là bản vá trong transcript

Prompt cache của provider khớp theo tiền tố token chính xác: đổi system prompt hay danh sách tool ở đầu yêu cầu làm trượt cache cho toàn bộ phần sau (Earendil, "Prompt Caching In Agents"). Ghi thay đổi thành một message system mới ở cuối transcript giữ nguyên tiền tố cũ trên các mô hình hỗ trợ message system giữa hội thoại, và vì nó là một entry, thay đổi đó sống sót qua resume và qua điều hướng cây. Changelog 0.86.0 ghi đúng hai lý do này (CHANGELOG.md:609). Đổi lại, bản thân "system prompt hiện tại" không còn là một trường; muốn biết nó phải phát lại các message system, việc getCurrentSystemMessage() làm.

Không bao giờ chen message giữa lời gọi tool và kết quả

Custom message và kết quả lệnh ! đến lúc agent đang chạy đều bị hoãn (agent-session.ts:2385). Cái giá là người dùng thấy output của ! ở vùng chờ cho tới cuối lần chạy, và mô hình chỉ biết tới nó ở lần chạy sau. Cái được là transcript luôn hợp lệ khi phát lại cho bất kỳ provider nào, kể cả provider khắt khe về thứ tự.

Bỏ khỏi ngữ cảnh thay vì xoá

Retry và phục hồi sau tràn ngữ cảnh ghi context_edit thay vì sửa entry cũ. Lịch sử thô, UI, bản xuất HTML và thống kê token vẫn giữ lần thử hỏng (docs/session-format.md:141); getSessionStats() cộng usage trên mọi entry, kể cả phần đã bị compaction, để con số phản ánh đúng số tiền đã trả (agent-session.ts:4238). Context edit được thêm ở 0.87.0 (CHANGELOG.md:549).

Retry ở tầng phiên, tách khỏi retry của SDK

SDK của provider chỉ thử lại được trước khi có phản hồi. Một luồng đứt giữa chừng đã sinh ra nửa message assistant, có thể cả lời gọi tool; thử lại đúng nghĩa là bỏ nửa đó khỏi ngữ cảnh rồi chạy lại cả lượt. Chỉ tầng biết về session mới làm được việc này, nên nó nằm ở AgentSession chứ không ở pi-ai.

Extension trước, listener sau

Handler message_end của extension được phép thay cả message, chẳng hạn để che một khoá API lỡ xuất hiện trong nội dung. Phát cho extension trước và sửa đối tượng tại chỗ bảo đảm UI, agent state và tệp session cùng thấy một phiên bản (agent-session.ts:1287).

Thay cả phiên thay vì sửa phiên tại chỗ

Settings dự án, extension dự án, provider do extension đăng ký đều gắn với cwd. Khi /resume mở một session của dự án khác, sửa từng thứ tại chỗ dễ sót. AgentSessionRuntime chọn cách thô hơn nhưng chắc chắn: abort để lượt dở được ghi vào phiên cũ, huỷ, rồi dựng lại toàn bộ services cho cwd mới (agent-session-runtime.ts:168). Cái giá là extension phải hiểu rằng ctx cũ đã chết.

Cạm bẫy và trường hợp biên#

  • Compaction thủ công chặn prompt. Trong lúc /compact chạy, prompt() ném lỗi (agent-session.ts:2110); interactive mode tự xếp tin nhắn chờ thay vì gọi nó (interactive-mode.ts:3321). SDK và RPC phải tự xử lý.
  • isIdle khác !isStreaming. Compaction và tóm tắt nhánh đang chạy làm phiên không rảnh dù không có lần chạy nào (agent-session.ts:1478).
  • @ trong trình soạn thảo không đính kèm tệp. Chỉ đối số @file trên dòng lệnh được đọc vào prompt; ở interactive mode, @path chỉ là văn bản.
  • sendUserMessage() của extension không mở rộng template trừ khi truyền expandPromptTemplates: true (agent-session.ts:2427).
  • Lỗi trong hook thành một phản hồi lỗi. Một ngoại lệ trong lúc chạy (credential hỏng, router của virtual model từ chối) không lan ra ngoài prompt(): Agent tạo một message assistant với stopReason: "error" và đi qua message_end như mọi message (agent.ts:542). Nó được ghi vào session, và được thử lại nếu thông điệp lỗi khớp mẫu.
  • Phục hồi tràn ngữ cảnh chỉ một lần. Nếu ngữ cảnh sau compaction vẫn tràn, phiên dừng với thông báo lỗi thay vì lặp (agent-session.ts:3079).
  • Mức suy nghĩ lưu vào settings là mức yêu cầu. setThinkingLevel("xhigh", { persist: true }) trên một mô hình chỉ có high ghi thinking_level_change: high vào session nhưng defaultThinkingLevel: xhigh vào settings.
  • Phiên cũ được vá metadata. Mở một session không có thinking_level_change nào sẽ ghi thêm một entry như vậy ngay lúc tạo phiên (sdk.ts:440), nên tệp thay đổi dù người dùng chưa gõ gì.
  • ctx cũ của extension. Sau ctx.newSession(), ctx.fork(), ctx.switchSession() hay ctx.reload(), mọi lời gọi qua ctx cũ ném lỗi stale; phần việc sau khi thay phiên phải nằm trong withSession.

Đọc code ở đâu#

Thứ tự đề xuất: dựng phiên trước, rồi lần theo một prompt, rồi các nhánh phụ.

#Tệp và vùngĐọc để hiểu
1sdk.ts:191Khôi phục mô hình, mức suy nghĩ; tạo Agent với convertToLlm, streamFn, transformContext.
2agent-session.ts:379Trạng thái của lớp và các hook cài trong constructor.
3agent-session.ts:2083prompt(): lệnh extension, input, skill, template, hàng đợi, kiểm tra auth.
4agent-session.ts:1863_startRun(), _prepareRun(), _handlePostAgentRun(), boundary trước khi rảnh.
5agent-session.ts:1102Ghi session từ sự kiện.
6agent-session.ts:766prepareRequest, finishTurn, prepareNextTurnWithContext.
7agent-loop.ts:172Các hook trên được gọi ở đâu trong vòng lặp agent.
8agent-session.ts:3761, 1239Retry và context_edit loại lần thử hỏng.
9agent-session.ts:2990Các điểm kích hoạt compaction tự động.
10agent-session.ts:2517Đổi mô hình, mức suy nghĩ, chế độ hàng đợi.
11agent-session.ts:2299Hàng đợi, custom message, abort.
12packages/coding-agent/src/core/bash-executor.ts, agent-session.ts:3888Lệnh !.
13packages/coding-agent/src/core/messages.ts, packages/coding-agent/docs/message-types.mdRole riêng và convertToLlm().
14packages/coding-agent/src/core/agent-session-services.ts, packages/coding-agent/src/core/agent-session-runtime.tsServices theo cwd, thay phiên.
15packages/coding-agent/src/core/settings-manager.ts, packages/coding-agent/src/core/settings-schema.ts, packages/coding-agent/docs/settings.mdHai tầng settings, trộn, ghi an toàn.
16packages/coding-agent/src/core/auth-storage.ts, packages/ai/src/auth/resolve.tsauth.json, lock, làm mới OAuth.
17packages/coding-agent/src/core/model-resolver.ts, packages/coding-agent/src/core/model-runtime.ts, packages/coding-agent/docs/models.mdPattern --model, mô hình khởi đầu, credential theo yêu cầu.
18packages/coding-agent/src/core/project-trust.ts, packages/coding-agent/src/core/trust-manager.ts, packages/coding-agent/docs/security.mdTrust trước khi nạp tài nguyên dự án.
19config.ts:577Các đường dẫn dưới ~/.pi/agent.

Tóm tắt#

  • AgentSession bọc Agent của pi-agent-core và dùng chung cho mọi chế độ chạy; nó nghe mọi sự kiện để ghi session và cài hook để mỗi yêu cầu được dựng lại từ projection của session.
  • Một prompt đi qua: lệnh extension, sự kiện input, skill, prompt template, rồi hoặc vào hàng đợi (nếu đang chạy) hoặc mở một lần chạy: compaction trước prompt, before_agent_start, bản vá system prompt, agent.prompt(), vòng lặp sau run.
  • Chỉ message_end dẫn tới ghi: bốn role của provider thành entry message, custom thành custom_message; các entry khác (mô hình, mức suy nghĩ, compaction, context edit, nhãn) được ghi bởi lời gọi tương ứng.
  • System prompt và bộ tool sống trong transcript dưới dạng message system chứa bản vá, để giữ prompt cache và sống sót qua resume.
  • Retry ở tầng phiên: lỗi tạm thời được thử lại tối đa 3 lần với độ trễ 2, 4, 8 giây; lần hỏng bị loại khỏi ngữ cảnh bằng context_edit, không bị xoá.
  • Compaction được kích hoạt ở năm điểm: trước prompt, sau run, giữa hai lượt, trước yêu cầu của virtual model, và thủ công.
  • Custom message và output ! không bao giờ chen giữa lời gọi tool và kết quả: chúng được hoãn tới cuối lượt hoặc cuối lần chạy.
  • Xung quanh là settings hai tầng (dự án thắng, trộn sâu), auth.json có lock và làm mới OAuth kiểm tra hai lần, bộ giải pattern mô hình, và quyết định trust có trước khi nạp tài nguyên dự án.

8Session: cây JSONL, nhánh và context#

Vai trò#

Một session là bản ghi đầy đủ của một cuộc trò chuyện: mọi message, lời gọi tool và kết quả tool, mọi lần đổi mô hình (model), compaction (nén ngữ cảnh), nhãn, trạng thái extension. Pi lưu nó thành một tệp JSONL (mỗi dòng một đối tượng JSON) và tài liệu hoá định dạng đó trong packages/coding-agent/docs/session-format.md. Đó là một lựa chọn có chủ ý từ ngày đầu: Mario Zechner viết pi một phần vì muốn nhìn thấy và kiểm soát toàn bộ ngữ cảnh, với một định dạng session được tài liệu hoá để công cụ khác đọc được ("What I learned building an opinionated and minimal coding agent").

SessionManager trong packages/coding-agent/src/core/session-manager.ts sở hữu tệp đó. Chú thích của lớp tóm tắt toàn bộ mô hình:

SessionManager: cây chỉ ghi thêm trong tệp JSONL
pi/packages/coding-agent/src/core/session-manager.tsdòng 976–986TypeScript
/** * Manages conversation sessions as append-only trees stored in JSONL files. * * Each session entry has an id and parentId forming a tree structure. The "leaf" * pointer tracks the current position. Appending creates a child of the current leaf. * Branching moves the leaf to an earlier entry, allowing new branches without * modifying history. * * Use buildSessionContext() to get the resolved message list for the LLM, which * handles compaction summaries and follows the path from root to current leaf. */
Ở v1.4.0, hàm dựng ngữ cảnh chính là buildSessionProjection(); buildSessionContext() mà chú thích nhắc tới chỉ còn là lớp bọc mỏng bên trên nó.

Ai gọi nó: AgentSession ghi entry qua các hàm append*() và dựng ngữ cảnh mỗi yêu cầu (chương AgentSession); main.ts chọn cách mở tệp theo cờ dòng lệnh; AgentSessionRuntime tạo tệp mới khi /new, /fork, /clone; bộ chọn cây của TUI đọc getTree() (chương pi-tui); bộ xuất HTML đọc mọi entry. Extension chỉ được cầm một bản đọc (ReadonlySessionManager, session-manager.ts:245); muốn ghi, chúng đi qua API của extension (chương Extensions).

Mô hình tư duy#

Ba ý cần giữ trong đầu:

  1. Chỉ ghi thêmEntry không bao giờ bị sửa hay xoá. Muốn "sửa" ngữ cảnh thì ghi thêm một entry mới (compaction, context edit, nhãn) mô tả thay đổi.
  2. Cây qua parentIdMỗi entry trỏ tới cha bằng parentId. Quay lại một điểm cũ rồi gõ tiếp tạo một nhánh mới trong cùng tệp.
  3. Con trỏ láleafId là vị trí hiện tại. Entry mới luôn là con của lá, và lá dời sang nó. Ngữ cảnh gửi cho mô hình là đường đi từ gốc tới lá.
header {type:"session", version:3, id, cwd}          (dòng đầu, không thuộc cây)

[model_change] ─ [thinking_level_change] ─ [system] ─ [user U1] ─ [assistant A1]
                                                                        │
                        ┌───────────────────────────────────────────────┴──────┐
                        │                                                      │
                  [user U2] ─ [assistant A2]                     [branch_summary] ─ [user U3] ─ [assistant A3]
                     nhánh cũ, vẫn nằm trong tệp                                              ▲
                                                                                     lá (leafId)

nhánh đang hoạt động = model_change → … → A1 → branch_summary → U3 → A3
Người dùng quay về sau A1 bằng /tree, kèm tóm tắt nhánh vừa rời. Nhánh U2, A2 vẫn còn trong tệp nhưng không còn trong ngữ cảnh.

Từ vựng của chương:

entry
Một dòng JSON sau header, có type, id, parentId, timestamp.
gốc (root)
Entry có parentId: null. Thường chỉ có một, nhưng resetLeaf() cho phép tạo gốc thứ hai.
nhánh đang hoạt động (active branch)
Đường đi từ gốc tới lá, trả về bởi getBranch().
context entries
Nhánh đang hoạt động sau khi áp dụng compaction mới nhất (buildContextEntries()).
projection
Context entries đã đổi thành message mô hình sẽ thấy, sau khi áp dụng context edit, mỗi message giữ entry nguồn (buildSessionProjection()).

Tệp session nằm ở đâu#

Mỗi thư mục làm việc (cwd) có một thư mục session riêng dưới ~/.pi/agent/sessions/. Tên thư mục mã hoá đường dẫn cwd:

Thư mục session mặc định cho một cwd
pi/packages/coding-agent/src/core/session-manager.tsdòng 585–602TypeScript
/** * Compute the default session directory for a cwd. * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/. */function getDefaultSessionDirPath(cwd: string, agentDir: string = getDefaultAgentDir()): string {	const resolvedCwd = resolvePath(cwd);	const resolvedAgentDir = resolvePath(agentDir);	const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;	return join(resolvedAgentDir, "sessions", safePath);}export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string {	const sessionDir = getDefaultSessionDirPath(cwd, agentDir);	if (!existsSync(sessionDir)) {		mkdirSync(sessionDir, { recursive: true });	}	return sessionDir;}
Bỏ dấu phân cách đầu, thay /, \, : bằng -, bọc trong --. Cwd /home/phuc/projects/my-research thành ~/.pi/agent/sessions/--home-phuc-projects-my-research--/.

Tên tệp ghép thời điểm tạo và id phiên: 2026-10-22T05-04-11-123Z_<id>.jsonl, với : và . của timestamp ISO đổi thành - (session-manager.ts:1078). Id mặc định là một UUID phiên bản 7, loại UUID có phần đầu là dấu thời gian (session-manager.ts:264); tài liệu chỉ nói "UUID". Vì tên tệp bắt đầu bằng thời điểm tạo, sắp xếp tên tệp giảm dần cho ra phiên mới nhất trước, đúng thứ tự mà listSessionsFromDir() dùng để nạp dần danh sách (session-manager.ts:950). --session-id cho đặt id tuỳ ý, miễn chỉ gồm chữ, số, ., _, - và bắt đầu, kết thúc bằng chữ hoặc số (session-manager.ts:268).

Thư mục gốc có thể đổi, theo thứ tự ưu tiên: --session-dir, biến PI_CODING_AGENT_SESSION_DIR, setting sessionDir, rồi mặc định (main.ts:691). Khi thư mục là tuỳ biến, nhiều cwd có thể dùng chung một thư mục, nên các hàm tìm kiếm lọc thêm theo cwd trong header (session-manager.ts:1793).

--no-session (và --help, --list-models) dùng SessionManager.inMemory() (main.ts:364): cùng cấu trúc cây, cùng mọi hàm, chỉ là persist = false nên không gì chạm đĩa. Phiên kiểu này không thể resume sau khi pi thoát.

Header và các loại entry#

Dòng đầu tiên của tệp là header. Nó không có id cây hay parentId; mọi dòng sau đều kế thừa SessionEntryBase:

Header, tuỳ chọn phiên mới, phần chung của mọi entry
pi/packages/coding-agent/src/core/session-manager.tsdòng 41–62TypeScript
export const CURRENT_SESSION_VERSION = 3;export interface SessionHeader {	type: "session";	version?: number; // v1 sessions don't have this	id: string;	timestamp: string;	cwd: string;	parentSession?: string;}export interface NewSessionOptions {	id?: string;	parentSession?: string;}export interface SessionEntryBase {	type: string;	id: string;	parentId: string | null;	timestamp: string;}
cwd trong header quyết định phiên thuộc dự án nào khi resume. parentSession trỏ tới tệp gốc khi phiên được tạo bằng fork hoặc clone. Timestamp của entry là chuỗi ISO 8601, còn timestamp bên trong một message là số mili giây Unix (docs/session-format.md:47).

Id của entry là 8 ký tự hex lấy từ randomUUID(), được kiểm tra trùng với các id đã có, và chỉ rơi về UUID đầy đủ nếu 100 lần thử đều trùng (session-manager.ts:276). Id ngắn giữ tệp gọn và đọc được bằng mắt; chúng chỉ cần duy nhất trong một tệp. Mười một loại entry theo sau.

message, thinking_level_change, model_change, usage
pi/packages/coding-agent/src/core/session-manager.tsdòng 64–89TypeScript
export interface SessionMessageEntry extends SessionEntryBase {	type: "message";	message: AgentMessage;}export interface ThinkingLevelChangeEntry extends SessionEntryBase {	type: "thinking_level_change";	thinkingLevel: string;}export interface ModelChangeEntry extends SessionEntryBase {	type: "model_change";	provider: string;	modelId: string;}export interface UsageEntry extends SessionEntryBase {	type: "usage";	/** Arbitrary usage category, such as "cache_warm". */	kind: string;	provider: string;	model: string;	usage: Usage;	/** Optional human-readable qualifier for usage notices. */	note?: string;}
message bọc một AgentMessage bất kỳ: system, user, assistant, toolResult, hoặc bashExecution của lệnh ! (chương AgentSession). usage ghi chi phí của những lời gọi mô hình không sinh message, như làm ấm prompt cache (kind: "cache_warm").
compaction và branch_summary
pi/packages/coding-agent/src/core/session-manager.tsdòng 91–116TypeScript
export interface CompactionEntry<T = unknown> extends SessionEntryBase {	type: "compaction";	summary: string;	firstKeptEntryId: string;	tokensBefore: number;	/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */	details?: T;	/** Usage from the LLM call(s) that generated this summary, if available */	usage?: Usage;	/** True if generated by an extension, undefined/false if pi-generated (backward compatible) */	fromHook?: boolean;	/** Complete prompt and tool state at this compaction boundary. */	systemMessage?: SystemMessage;}export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {	type: "branch_summary";	fromId: string;	summary: string;	/** Extension-specific data (not sent to LLM) */	details?: T;	/** Usage from the LLM call that generated this summary, if available */	usage?: Usage;	/** True if generated by an extension, false if pi-generated */	fromHook?: boolean;}
firstKeptEntryId đánh dấu entry đầu tiên được giữ nguyên; mọi thứ trước nó trên nhánh được thay bằng summary. systemMessage là ảnh chụp toàn bộ system prompt và bộ tool tại điểm nén. branch_summary mang tóm tắt của nhánh vừa rời, với fromId là lá cũ. Trường fromHook là tên cũ, nghĩa là "do extension tạo".
custom, label, session_info
pi/packages/coding-agent/src/core/session-manager.tsdòng 118–145TypeScript
/** * Custom entry for extensions to store extension-specific data in the session. * Use customType to identify your extension's entries. * * Purpose: Persist extension state across session reloads. On reload, extensions can * scan entries for their customType and reconstruct internal state. * * Does NOT participate in LLM context (ignored by buildSessionContext). * For injecting content into context, see CustomMessageEntry. */export interface CustomEntry<T = unknown> extends SessionEntryBase {	type: "custom";	customType: string;	data?: T;}/** Label entry for user-defined bookmarks/markers on entries. */export interface LabelEntry extends SessionEntryBase {	type: "label";	targetId: string;	label: string | undefined;}/** Session metadata entry (e.g., user-defined display name). */export interface SessionInfoEntry extends SessionEntryBase {	type: "session_info";	name?: string;}
custom là chỗ extension lưu trạng thái của nó và không bao giờ vào ngữ cảnh. Pi cũng dùng nó cho trạng thái router của virtual model (customType: "pi.virtual-model-state"). label với label: undefined xoá nhãn.
custom_message và context_edit
pi/packages/coding-agent/src/core/session-manager.tsdòng 147–180TypeScript
/** * Custom message entry for extensions to inject messages into LLM context. * Use customType to identify your extension's entries. * * Unlike CustomEntry, this DOES participate in LLM context. * The content is converted to a user message in buildSessionContext(). * Use details for extension-specific metadata (not sent to LLM). * * display controls TUI rendering: * - false: hidden entirely * - true: rendered with distinct styling (different from user messages) */export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {	type: "custom_message";	customType: string;	content: string | (TextContent | ImageContent)[];	details?: T;	display: boolean;}/** Content that an append-only context edit may replace without changing message metadata. */export type ContextEditableContent =	| UserMessage["content"]	| AssistantMessage["content"]	| ToolResultMessage["content"]	| CustomMessage["content"];/** Append-only change to one earlier entry's contribution to model context. */export interface ContextEditEntry extends SessionEntryBase {	type: "context_edit";	targetId: string;	/** Null omits the target from model context. A value replaces only its content. */	replacement: { content: ContextEditableContent } | null;}
Khác với custom, custom_message có vào ngữ cảnh. Chú thích nói nội dung "được đổi thành message user trong buildSessionContext()"; thực tế ở v1.4.0 buildSessionProjection() sinh một message role custom, và việc hạ về user xảy ra trong convertToLlm() lúc gửi. context_edit thay nội dung của một entry trước đó, hoặc bỏ nó (replacement: null), chỉ trong ngữ cảnh.
Union của mọi entry và của mọi dòng trong tệp
pi/packages/coding-agent/src/core/session-manager.tsdòng 182–197TypeScript
/** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */export type SessionEntry =	| SessionMessageEntry	| ThinkingLevelChangeEntry	| ModelChangeEntry	| UsageEntry	| CompactionEntry	| BranchSummaryEntry	| CustomEntry	| CustomMessageEntry	| ContextEditEntry	| LabelEntry	| SessionInfoEntry;/** Raw file entry (includes header) */export type FileEntry = SessionHeader | SessionEntry;

Bảng dưới gom lại: loại nào vào ngữ cảnh, và ai ghi nó.

typeVào ngữ cảnh?Ghi bởi
messageCó, chính message đó (bashExecution với !! bị bỏ khi gửi)appendMessage()
thinking_level_changeKhông; chỉ đặt mức suy nghĩ khi resumeappendThinkingLevelChange()
model_changeKhông; chỉ chọn mô hình khi resumeappendModelChange()
usageKhông; cộng vào thống kêappendUsage()
compactionCó: message system ảnh chụp, rồi compactionSummaryappendCompaction()
branch_summaryCó: branchSummary (nếu tóm tắt khác rỗng)branchWithSummary()
customKhôngappendCustomEntry()
custom_messageCó: message customappendCustomMessageEntry()
context_editKhông tự sinh message; sửa hoặc bỏ entry đíchappendContextEdit()
labelKhôngappendLabelChange()
session_infoKhôngappendSessionInfo()

Ví dụ minh hoạ một tệp JSONL#

Ví dụ minh hoạ: tệp của kịch bản ở chương AgentSession (hỏi version trong package.json, mô hình gọi read một lần), sau đó người dùng đặt tên phiên bằng /name và gắn nhãn cho câu trả lời. Id, thời điểm và nội dung là bịa; tên trường đúng với các type ở trên. Dấu … đánh dấu chỗ rút gọn (nội dung system prompt, schema tool, đối tượng usage), không phải cú pháp JSON.

{"type":"session","version":3,"id":"019a0b2c-7d3e-7f41-9c2a-5b6e8d1f0a37","timestamp":"2026-10-22T05:04:11.123Z","cwd":"/home/phuc/projects/demo"}
{"type":"model_change","id":"a1b2c3d4","parentId":null,"timestamp":"2026-10-22T05:04:11.130Z","provider":"anthropic","modelId":"claude-sonnet-4-5"}
{"type":"thinking_level_change","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"2026-10-22T05:04:11.131Z","thinkingLevel":"medium"}
{"type":"message","id":"c3d4e5f6","parentId":"b2c3d4e5","timestamp":"2026-10-22T05:04:20.005Z","message":{"role":"system","content":"","sections":{"preamble":"You are an expert coding assistant operating inside pi, …","tools":"<tools>\n- read: …","rules":"<rules>…","docs":"<docs>…","cwd":"<cwd>\n/home/phuc/projects/demo\n</cwd>"},"toolsAdded":[{"name":"read","description":"…","parameters":{…}},{"name":"bash",…},{"name":"edit",…},{"name":"write",…}],"timestamp":1792645460003}}
{"type":"message","id":"d4e5f607","parentId":"c3d4e5f6","timestamp":"2026-10-22T05:04:20.006Z","message":{"role":"user","content":[{"type":"text","text":"package.json khai báo version nào?"}],"timestamp":1792645460001}}
{"type":"message","id":"e5f60718","parentId":"d4e5f607","timestamp":"2026-10-22T05:04:23.410Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01","name":"read","arguments":{"path":"package.json"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{…},"stopReason":"toolUse","thinkingLevel":"medium","timestamp":1792645460010}}
{"type":"message","id":"f6071829","parentId":"e5f60718","timestamp":"2026-10-22T05:04:23.452Z","message":{"role":"toolResult","toolCallId":"toolu_01","toolName":"read","content":[{"type":"text","text":"{\n  \"name\": \"demo\",\n  \"version\": \"1.2.3\"\n}"}],"isError":false,"timestamp":1792645463451}}
{"type":"message","id":"0718293a","parentId":"f6071829","timestamp":"2026-10-22T05:04:25.977Z","message":{"role":"assistant","content":[{"type":"text","text":"Version là 1.2.3."}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{…},"stopReason":"stop","thinkingLevel":"medium","timestamp":1792645463460}}
{"type":"session_info","id":"18293a4b","parentId":"0718293a","timestamp":"2026-10-22T05:05:02.000Z","name":"Kiểm tra version"}
{"type":"label","id":"293a4b5c","parentId":"18293a4b","timestamp":"2026-10-22T05:05:10.000Z","targetId":"0718293a","label":"đã trả lời"}
Ví dụ minh hoạ, không phải tệp thật. Lưu ý parentId nối mọi dòng thành một chuỗi, kể cả session_info và label: chúng cũng là nút của cây.

Vài điều đọc được từ ví dụ. Message system có timestamp lớn hơn message user dù đứng trước: _prepareRun() tạo message user trước, rồi mới tạo bản vá system và chèn nó lên đầu mảng (chương AgentSession, bước 2). Các phần prompt khác preamble được bọc trong thẻ cùng tên, như <cwd>…</cwd> (system-prompt.ts:188). Mỗi message assistant mang thinkingLevel được yêu cầu cho chính phản hồi đó, do vòng lặp agent gắn vào (agent-loop.ts:419).

Ghi thêm và nạp lại#

Mọi hàm append*() có cùng hình dạng: tạo entry với parentId là lá hiện tại, rồi giao cho _appendEntry():

Entry mới là con của lá; lá dời sang entry mới
pi/packages/coding-agent/src/core/session-manager.tsdòng 1191–1214TypeScript
private _appendEntry(entry: SessionEntry): void {	this.fileEntries.push(entry);	this.byId.set(entry.id, entry);	this.leafId = entry.id;	this._persist(entry);}/** Append a message as child of current leaf, then advance leaf. Returns entry id. * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly. * Reason: we want these to be top-level entries in the session, not message session entries, * so it is easier to find them. * These need to be appended via appendCompaction() and appendBranchSummary() methods. */appendMessage(message: Message | CustomMessage | BashExecutionMessage): string {	const entry: SessionMessageEntry = {		type: "message",		id: generateId(this.byId),		parentId: this.leafId,		timestamp: new Date().toISOString(),		message,	};	this._appendEntry(entry);	return entry.id;}
Kiểu tham số của appendMessage() cố ý loại hai role tóm tắt: chúng phải là entry cấp cao (compaction, branch_summary) để dễ tìm, và message tương ứng được sinh lại mỗi lần dựng ngữ cảnh.

Ghi xuống đĩa là một lệnh appendFileSync một dòng. Ngoại lệ là lần đầu:

Tạo tệp trễ: chỉ khi đã có message user hoặc assistant
pi/packages/coding-agent/src/core/session-manager.tsdòng 1160–1189TypeScript
/** * A new session file is created only once the session contains a user or assistant message. * Setup entries alone (model, thinking level, system prompt) stay in memory so opening and * closing pi without chatting leaves no file behind. Starting at the user message (not the * first assistant reply) keeps the prompt on disk if the first turn never completes (#10000). */private _hasConversation(): boolean {	return this.fileEntries.some(		(e) => e.type === "message" && (e.message.role === "user" || e.message.role === "assistant"),	);}_persist(entry: SessionEntry): void {	if (!this.persist || !this.sessionFile) return;	if (!this.flushed) {		if (!this._hasConversation()) return;		const fd = openSync(this.sessionFile, "wx");		try {			for (const e of this.fileEntries) {				writeFileSync(fd, `${JSON.stringify(e)}\n`);			}		} finally {			closeSync(fd);		}		this.flushed = true;	} else {		appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);	}}
Trước khi có cuộc trò chuyện, các entry thiết lập (mô hình, mức suy nghĩ, system prompt) chỉ nằm trong bộ nhớ. Khi message user đầu tiên đến, tệp được mở với cờ "wx" (thất bại nếu tệp đã tồn tại) và mọi entry đang có được viết một lượt. Chọn mốc là message user chứ không phải phản hồi đầu tiên, để prompt không bị mất nếu lượt đầu không bao giờ xong: đó là lỗi #10000, sửa ở 0.99.0 (CHANGELOG.md:482).

Nạp lại một tệp đi theo chiều ngược. loadEntriesFromFile() đọc theo khối 1 MB và tách dòng dần, thay vì đọc cả tệp thành một chuỗi lớn rồi mới tách:

loadEntriesFromFile(): đọc từng khối, bỏ dòng hỏng, kiểm tra header
pi/packages/coding-agent/src/core/session-manager.tsdòng 638–669TypeScript
	while (true) {		const bytesRead = readSync(fd, buffer, 0, buffer.length, null);		if (bytesRead === 0) break;		pending += decoder.write(buffer.subarray(0, bytesRead));		let lineStart = 0;		let newlineIndex = pending.indexOf("\n", lineStart);		while (newlineIndex !== -1) {			const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex));			if (entry) entries.push(entry);			lineStart = newlineIndex + 1;			newlineIndex = pending.indexOf("\n", lineStart);		}		pending = pending.slice(lineStart);	}	pending += decoder.end();	const finalEntry = parseSessionEntryLine(pending);	if (finalEntry) entries.push(finalEntry);} finally {	closeSync(fd);}// Validate session header before repairing the file.if (entries.length === 0) return entries;const header = entries[0];if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") {	return [];}if (pending) appendFileSync(resolvedFilePath, "\n");return entries;
StringDecoder giữ các byte UTF-8 bị cắt ngang giữa hai khối. Dòng không parse được bị bỏ qua lặng lẽ. Nếu dòng đầu không phải header hợp lệ, hàm trả mảng rỗng. Nếu tệp kết thúc giữa chừng một dòng (pi bị giết khi đang ghi), nó thêm một ký tự xuống dòng để lần ghi sau bắt đầu ở dòng mới.

Hàm gọi nó, _setSessionFile(), phân biệt ba trường hợp: tệp không tồn tại thì tạo phiên mới nhưng giữ đường dẫn được chỉ định (cho --session đường/dẫn.jsonl); tệp rỗng thì khởi tạo header; tệp có nội dung mà không phải session thì ném lỗi thay vì ghi đè (session-manager.ts:1029). Sau đó chỉ mục trong bộ nhớ được dựng lại:

_buildIndex(): bảng id, nhãn hiện hành, và lá = entry cuối tệp
pi/packages/coding-agent/src/core/session-manager.tsdòng 1103–1122TypeScript
private _buildIndex(): void {	this.byId.clear();	this.labelsById.clear();	this.labelTimestampsById.clear();	this.leafId = null;	for (const entry of this.fileEntries) {		if (entry.type === "session") continue;		this.byId.set(entry.id, entry);		this.leafId = entry.id;		if (entry.type === "label") {			if (entry.label) {				this.labelsById.set(entry.targetId, entry.label);				this.labelTimestampsById.set(entry.targetId, entry.timestamp);			} else {				this.labelsById.delete(entry.targetId);				this.labelTimestampsById.delete(entry.targetId);			}		}	}}
Lá sau khi nạp luôn là dòng cuối cùng của tệp. Nhãn được tính lại bằng cách phát lại mọi entry label theo thứ tự; nhãn rỗng xoá nhãn trước.

Phiên bản và migration#

Định dạng đã đổi hai lần (docs/session-format.md:22). Phiên bản 1 là một chuỗi tuyến tính không có id. Phiên bản 2 (pi 0.31.0, tháng 1 năm 2026) thêm id/parentId để rẽ nhánh tại chỗ (CHANGELOG.md:5150). Phiên bản 3 đổi role hookMessage thành custom khi hook được hợp nhất thành extension. Header ghi phiên bản; thiếu trường này nghĩa là v1.

v1 → v2: gán id, nối parentId theo thứ tự, đổi chỉ số thành id
pi/packages/coding-agent/src/core/session-manager.tsdòng 286–313TypeScript
/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */function migrateV1ToV2(entries: FileEntry[]): void {	const ids = new Set<string>();	let prevId: string | null = null;	for (const entry of entries) {		if (entry.type === "session") {			entry.version = 2;			continue;		}		entry.id = generateId(ids);		entry.parentId = prevId;		prevId = entry.id;		// Convert firstKeptEntryIndex to firstKeptEntryId for compaction		if (entry.type === "compaction") {			const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };			if (typeof comp.firstKeptEntryIndex === "number") {				const targetEntry = entries[comp.firstKeptEntryIndex];				if (targetEntry && targetEntry.type !== "session") {					comp.firstKeptEntryId = targetEntry.id;				}				delete comp.firstKeptEntryIndex;			}		}	}}
Một tệp v1 trở thành một chuỗi thẳng: mỗi entry nhận cha là entry đứng trước. Compaction cũ lưu vị trí bằng firstKeptEntryIndex (chỉ số dòng); migration đổi nó thành firstKeptEntryId trỏ vào entry ở vị trí đó.
Chạy các migration còn thiếu theo thứ tự
pi/packages/coding-agent/src/core/session-manager.tsdòng 333–347TypeScript
/** * Run all necessary migrations to bring entries to current version. * Mutates entries in place. Returns true if any migration was applied. */function migrateToCurrentVersion(entries: FileEntry[]): boolean {	const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;	const version = header?.version ?? 1;	if (version >= CURRENT_SESSION_VERSION) return false;	if (version < 2) migrateV1ToV2(entries);	if (version < 3) migrateV2ToV3(entries);	return true;}

Migration chạy trong _loadEntries() mỗi khi một tệp được nạp, sửa mảng trong bộ nhớ, rồi viết lại toàn bộ tệp (session-manager.ts:1085). Ngoài việc khởi tạo một tệp rỗng và viết một tệp mới khi fork, đây là chỗ duy nhất một tệp session đã có nội dung bị viết lại thay vì ghi thêm, và nó chỉ xảy ra một lần cho mỗi tệp cũ.

src/migrations.ts là chuyện khác: các migration một lần lúc khởi động cho cấu hình và vị trí tệp, chạy trước khi chọn session (main.ts:668). Phần liên quan tới session là migrateSessionsFromAgentRoot(): bản 0.30.0 lỡ lưu session thẳng vào ~/.pi/agent/, nên hàm này đọc cwd từ header của từng tệp và chuyển nó về đúng thư mục sessions/--cwd--/ (migrations.ts:75). Các migration khác chuyển credential sang auth.json, commands/ sang prompts/, tools/ sang bin/ (migrations.ts:300).

Dựng lại context từ nhánh đang hoạt động#

Mỗi yêu cầu tới mô hình bắt đầu bằng một câu hỏi: từ cây entry này, mô hình phải thấy danh sách message nào? buildSessionProjection() trả lời bằng bốn bước. Hàm được gọi trước mỗi yêu cầu (chương AgentSession), và chi phí của nó chỉ là vài lần duyệt tuyến tính: sao chép danh sách entry, đi ngược từ lá về gốc, rồi duyệt đường đi đó.

Bước 1: đi từ lá về gốc

buildSessionPath(): lần theo parentId từ lá về gốc rồi đảo lại
pi/packages/coding-agent/src/core/session-manager.tsdòng 390–416TypeScript
function buildSessionPath(	entries: SessionEntry[],	leafId?: string | null,	byId?: Map<string, SessionEntry>,): SessionEntry[] {	const index = buildEntryIndex(entries, byId);	let leaf: SessionEntry | undefined;	if (leafId === null) {		return [];	}	if (leafId) {		leaf = index.get(leafId);	}	leaf ??= entries[entries.length - 1];	if (!leaf) {		return [];	}	const path: SessionEntry[] = [];	let current: SessionEntry | undefined = leaf;	while (current) {		path.push(current);		current = current.parentId ? index.get(current.parentId) : undefined;	}	path.reverse();	return path;}
leafId === null (sau resetLeaf()) cho đường rỗng; không truyền leafId thì lấy entry cuối mảng. Nhánh khác của cây không bao giờ được chạm tới.

Trên đường đi đó, getSessionContextSettings() đọc mức suy nghĩ từ thinking_level_change cuối cùng và mô hình từ model_change hay message assistant cuối cùng (session-manager.ts:418). Hai giá trị này chỉ dùng khi resume; chúng không thành message.

Bước 2: áp dụng compaction mới nhất

buildContextEntries(): compaction đứng đầu, rồi phần được giữ, rồi phần sau
pi/packages/coding-agent/src/core/session-manager.tsdòng 476–512TypeScript
export function buildContextEntries(	entries: SessionEntry[],	leafId?: string | null,	byId?: Map<string, SessionEntry>,): SessionEntry[] {	const path = buildSessionPath(entries, leafId, byId);	let compaction: CompactionEntry | null = null;	for (const entry of path) {		if (entry.type === "compaction") {			compaction = entry;		}	}	if (!compaction) {		return path;	}	const compactionIdx = path.findIndex((entry) => entry.id === compaction.id);	if (compactionIdx < 0) {		return path;	}	const contextEntries: SessionEntry[] = [compaction];	let foundFirstKept = false;	for (let i = 0; i < compactionIdx; i++) {		const entry = path[i];		if (entry.id === compaction.firstKeptEntryId) {			foundFirstKept = true;		}		if (foundFirstKept && !(entry.type === "message" && entry.message.role === "system")) {			contextEntries.push(entry);		}	}	contextEntries.push(...path.slice(compactionIdx + 1));	return contextEntries;}
Thứ tự kết quả: chính entry compaction, các entry từ firstKeptEntryId tới trước compaction (bỏ message system), rồi mọi entry sau compaction. Message system trong phần giữ lại bị bỏ vì ảnh chụp systemMessage của compaction đã chứa trạng thái prompt đầy đủ tại điểm đó. Entry không phải message (nhãn, model_change...) vẫn được giữ để interactive mode vẽ được.

Bước 3: đổi entry thành message

sessionEntryToContextMessages(): mỗi loại entry cho ra gì
pi/packages/coding-agent/src/core/session-manager.tsdòng 435–466TypeScript
/** * Project one selected session entry into LLM/runtime messages. * Plain custom entries are display/state entries and do not participate in context. */export function sessionEntryToContextMessages(entry: SessionEntry): AgentMessage[] {	if (entry.type === "message") {		const message = entry.message;		// Session files are parsed without validation; old versions, forks, or		// hand-edited files can contain messages with null/missing content.		if (message.role === "system" && message.content == null) return [{ ...message, content: "" }];		if (			(message.role === "user" || message.role === "assistant" || message.role === "toolResult") &&			message.content == null		) {			return [{ ...message, content: [] }];		}		return [message];	}	if (entry.type === "custom_message") {		return [			createCustomMessage(entry.customType, entry.content ?? [], entry.display, entry.details, entry.timestamp),		];	}	if (entry.type === "branch_summary" && entry.summary) {		return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];	}	if (entry.type === "compaction") {		const summary = createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);		return entry.systemMessage ? [entry.systemMessage, summary] : [summary];	}	return [];}
Tệp session được parse không có kiểm tra schema, nên content null (từ bản cũ, từ fork, từ tệp sửa tay) được chuẩn hoá thành rỗng thay vì làm vỡ yêu cầu. Compaction cho ra hai message, ảnh chụp system rồi tóm tắt; entry compaction cũ không có ảnh chụp thì chỉ cho ra tóm tắt.

Bước 4: áp dụng context edit, giữ nguồn gốc

buildSessionProjection(): edit mới nhất cho mỗi đích, chỉ compaction đầu tiên được tính
pi/packages/coding-agent/src/core/session-manager.tsdòng 542–573TypeScript
/** Build provenance-preserving, compaction-aware model context. */export function buildSessionProjection(	entries: SessionEntry[],	leafId?: string | null,	byId?: Map<string, SessionEntry>,): SessionProjection {	const path = buildSessionPath(entries, leafId, byId);	const { thinkingLevel, model } = getSessionContextSettings(path);	const contextEntries = buildContextEntries(entries, leafId, byId);	const edits = new Map<string, ContextEditEntry>();	for (const entry of contextEntries) {		if (entry.type === "context_edit") edits.set(entry.targetId, entry);	}	const projectedEntries = contextEntries.map(		(sourceEntry, index): ProjectedSessionEntry => ({			sourceEntry,			// buildContextEntries() may retain an older compaction entry because its			// raw ID lies inside the newest retained range. Only the newest compaction			// at index zero contributes a checkpoint and summary.			messages:				sourceEntry.type === "compaction" && index > 0					? []					: projectContextEntry(sourceEntry, edits.get(sourceEntry.id)),		}),	);	return {		entries: projectedEntries,		messages: projectedEntries.flatMap((entry) => entry.messages),		thinkingLevel,		model,	};}
edits là một Map ghi đè theo thứ tự nhánh, nên với cùng một đích, edit sau cùng thắng. Edit chỉ có hiệu lực khi nó nằm trên nhánh đang hoạt động: điều hướng về trước nó thì đích hiện lại nguyên bản (docs/session-format.md:147). Một compaction cũ hơn có thể lọt vào phần được giữ; nó không đóng góp message nào.

Việc thay nội dung nằm trong projectContextEntry(): replacement: null cho mảng rỗng; nội dung mới thay content nhưng giữ role và metadata, và chuỗi được bọc thành một khối text cho assistant, toolResult vì hai role này đòi mảng (session-manager.ts:519). Mỗi ProjectedSessionEntry giữ sourceEntry: AgentSession dùng nó để biết message nào ứng với entry nào khi cần ghi một context edit hay báo id entry cho extension. buildSessionContext() chỉ bỏ phần nguồn gốc đó đi (session-manager.ts:575).

Ví dụ: từ cây entry đến danh sách message#

Ví dụ minh hoạ, đủ mọi loại entry có ảnh hưởng tới ngữ cảnh. Người dùng nhờ viết hàm parseDate, rồi nhờ thêm test bằng vitest; nhánh đó thất bại vì vitest chưa được cài. Họ dùng /tree quay về sau câu trả lời A2, chọn tóm tắt nhánh vừa rời, và yêu cầu viết test bằng node:test. Một extension todo gửi một custom message cuối lượt. Ngữ cảnh vượt ngưỡng nên compaction tự động chạy, giữ lại từ U3. Ở prompt sau, provider trả "overloaded" một lần, retry thành công.

M0  model_change            anthropic / claude-sonnet-4-5
└─ T0  thinking_level_change   medium
   └─ S0  message (system)        mọi phần prompt, toolsAdded: read, bash, edit, write
      └─ U1  message (user)          "Viết hàm parseDate trong src/date.ts"
         └─ A1  message (assistant)     toolCall write
            └─ R1  message (toolResult)
               └─ A2  message (assistant)     "Đã viết xong parseDate."
                  ├─ U2  message (user)          "Thêm test bằng vitest"            ┐
                  │  └─ A3 ─ R2 ─ A4   (gọi bash, vitest chưa cài, thất bại)          ┘ nhánh bị bỏ
                  └─ BS  branch_summary     fromId = A4
                     └─ U3  message (user)          "Viết test bằng node:test"
                        └─ A5  message (assistant)     "Đã thêm test/date.test.ts"
                           └─ CM  custom_message    customType "todo", display true
                              └─ C   compaction        firstKeptEntryId = U3, có systemMessage
                                 └─ U4  message (user)          "Chạy lại toàn bộ test"
                                    └─ AE  message (assistant)     stopReason "error", "overloaded"
                                       └─ CE  context_edit      targetId = AE, replacement null
                                          └─ A6  message (assistant)     "12 test đều qua."   ◄ lá
Cây của ví dụ. A3, R2, A4 viết gọn trên một dòng: assistant gọi bash, kết quả tool, assistant báo lỗi.

Lần theo bốn bước với lá là A6:

  1. Đường điA6 → CE → AE → U4 → C → CM → A5 → U3 → BS → A2 → R1 → A1 → U1 → S0 → T0 → M0, đảo lại. U2, A3, R2, A4 không nằm trên đường nên không bao giờ được xét.
  2. Thiết lậpMức suy nghĩ medium từ T0; mô hình từ message assistant cuối trên đường, A6.
  3. CompactionC là compaction duy nhất. Context entries = [C, U3, A5, CM] + [U4, AE, CE, A6]. M0 tới BS đứng trước firstKeptEntryId nên bị thay bằng tóm tắt của C, kể cả bản tóm tắt nhánh BS.
  4. Context editCE nhắm AE với replacement: null, nên AE không cho ra message nào; bản thân CE cũng không.
#Entry nguồnMessage trong projectionGửi provider (sau convertToLlm)
1Csystem: ảnh chụp prompt và tool tại lúc nénsystem
2CcompactionSummaryuser: "The conversation history before this point was compacted into the following summary:" + <summary>…</summary>
3U3useruser
4A5assistantassistant
5CMcustom (customType: "todo")user với nội dung của CM; details không được gửi
6U4useruser
7A6assistantassistant

Prompt kế tiếp của người dùng sẽ thành entry con của A6 và message thứ 8. Bây giờ đổi lá: nếu người dùng dùng /tree chọn A4, đường đi là M0 … A2 → U2 → A3 → R2 → A4, không có compaction và context edit nào trên đó, nên projection là S0, U1, A1, R1, A2, U2, A3, R2, A4 nguyên vẹn. Cùng một tệp, hai lá, hai ngữ cảnh hoàn toàn độc lập; compaction C và edit CE chỉ thuộc về nhánh của chúng.

Rẽ nhánh trong cùng tệp: /tree#

Pi có ba cách quay lại một điểm cũ (docs/sessions.md:20): /tree đi trong cùng tệp, /fork và /clone tạo tệp mới. Armin Ronacher mô tả cây session như chỗ cho các "side quest": rẽ sang một việc phụ rồi quay lại nhánh chính mà không làm bẩn ngữ cảnh của nó ("Pi: The Minimal Agent Within OpenClaw"). Ở tầng tệp, rẽ nhánh chỉ là dời con trỏ:

branch() và resetLeaf(): chỉ đổi leafId, không ghi gì
pi/packages/coding-agent/src/core/session-manager.tsdòng 1573–1593TypeScript
/** * Start a new branch from an earlier entry. * Moves the leaf pointer to the specified entry. The next appendXXX() call * will create a child of that entry, forming a new branch. Existing entries * are not modified or deleted. */branch(branchFromId: string): void {	if (!this.byId.has(branchFromId)) {		throw new Error(`Entry ${branchFromId} not found`);	}	this.leafId = branchFromId;}/** * Reset the leaf pointer to null (before any entries). * The next appendXXX() call will create a new root entry (parentId = null). * Use this when navigating to re-edit the first user message. */resetLeaf(): void {	this.leafId = null;}
Vì không ghi gì, một lần dời lá đơn thuần không tồn tại sau khi thoát (xem cạm bẫy). resetLeaf() dùng khi message được chọn để sửa lại chính là gốc của cây (chẳng hạn message user đầu tiên của một phiên cũ không có entry thiết lập): entry kế tiếp sẽ là một gốc mới.
branchWithSummary(): dời lá và ghi bản tóm tắt làm con của điểm đến
pi/packages/coding-agent/src/core/session-manager.tsdòng 1600–1625TypeScript
branchWithSummary(	branchFromId: string | null,	summary: string,	details?: unknown,	fromHook?: boolean,	usage?: Usage,): string {	if (branchFromId !== null && !this.byId.has(branchFromId)) {		throw new Error(`Entry ${branchFromId} not found`);	}	const fromId = this.leafId ?? "root";	this.leafId = branchFromId;	const entry: BranchSummaryEntry = {		type: "branch_summary",		id: generateId(this.byId),		parentId: branchFromId,		timestamp: new Date().toISOString(),		fromId,		summary,		details,		usage,		fromHook,	};	this._appendEntry(entry);	return entry.id;}
fromId lấy lá cũ trước khi dời (một lỗi trước đây ghi nhầm điểm đến, CHANGELOG.md:869). Entry tóm tắt trở thành lá mới, nên prompt kế tiếp là con của nó và mô hình đọc tóm tắt ngay trước prompt đó.

AgentSession.navigateTree() là phần điều phối mà lệnh /tree gọi. Nó từ chối khi agent đang chạy hoặc đang nén (agent-session.ts:4028), gom các entry của nhánh cũ từ lá cũ tới tổ tiên chung để tóm tắt (branch-summarization.ts:108), cho extension huỷ hoặc tự cung cấp tóm tắt qua session_before_tree, rồi gọi bộ tóm tắt mặc định nếu người dùng chọn tóm tắt. Cách viết tóm tắt thuộc chương Compaction. Phần quyết định lá mới:

Chọn một message user: lá về cha của nó, văn bản về trình soạn thảo
pi/packages/coding-agent/src/core/agent-session.tsdòng 4148–4163TypeScript
// Determine the new leaf position based on target typelet newLeafId: string | null;let editorText: string | undefined;if (targetEntry.type === "message" && targetEntry.message.role === "user") {	// User message: leaf = parent (null if root), text goes to editor	newLeafId = targetEntry.parentId;	editorText = contentText(targetEntry.message.content, "");} else if (targetEntry.type === "custom_message") {	// Custom message: leaf = parent (null if root), text goes to editor	newLeafId = targetEntry.parentId;	editorText = contentText(targetEntry.content, "");} else {	// Non-user message: leaf = selected node	newLeafId = targetId;}
Chọn message user (hay custom_message) nghĩa là "tôi muốn sửa câu này": lá dời về cha của nó và văn bản được đặt lại vào trình soạn thảo. Chọn một entry khác (một câu trả lời assistant chẳng hạn) nghĩa là "tiếp tục sau entry này": lá là chính nó.
Ghi tóm tắt, hoặc chỉ dời lá; gắn nhãn nếu có
pi/packages/coding-agent/src/core/agent-session.tsdòng 4165–4194TypeScript
// Switch leaf (with or without summary)// Summary is attached at the navigation target position (newLeafId), not the old branchlet summaryEntry: BranchSummaryEntry | undefined;if (summaryText) {	// Create summary at target position (can be null for root)	const summaryId = this.sessionManager.branchWithSummary(		newLeafId,		summaryText,		summaryDetails,		fromExtension,		summaryUsage,	);	summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;	// Attach label to the summary entry	if (label) {		this.sessionManager.appendLabelChange(summaryId, label);	}} else if (newLeafId === null) {	// No summary, navigating to root - reset leaf	this.sessionManager.resetLeaf();} else {	// No summary, navigating to non-root	this.sessionManager.branch(newLeafId);}// Attach label to target entry when not summarizing (no summary entry to label)if (label && !summaryText) {	this.sessionManager.appendLabelChange(targetId, label);}
Tóm tắt được gắn tại điểm đến, không tại nhánh cũ: nó là ngữ cảnh mà nhánh mới mang theo. Sau đó AgentSession làm mới ngữ cảnh từ projection, khôi phục bộ tool mà nhánh mới đã ghi, và phát session_tree cho extension (agent-session.ts:4196).

Bộ chọn của /tree vẽ từ getTree(): một bản sao nông của toàn bộ cây, con của mỗi nút sắp theo thời gian (cũ trên, mới dưới), entry mồ côi có cha không tồn tại được coi là gốc, và mỗi nút mang nhãn hiện hành (session-manager.ts:1524). Cách vẽ, lọc và phím tắt thuộc chương pi-tui.

Fork và clone sang tệp mới#

Khi một hướng thử đáng thành một công việc riêng, cây trong một tệp trở nên chật. /fork (tách nhánh) tạo một phiên mới kết thúc ngay trước một message user đã chọn, đặt văn bản của message đó vào trình soạn thảo; /clone sao chép nhánh đang hoạt động tới lá hiện tại. Cả hai đi qua cùng một hàm, khác nhau ở position:

AgentSessionRuntime.fork(): before (fork) hay at (clone)
pi/packages/coding-agent/src/core/agent-session-runtime.tsdòng 262–287TypeScript
async fork(	entryId: string,	options?: { position?: "before" | "at"; withSession?: (ctx: ReplacedSessionContext) => Promise<void> },): Promise<{ cancelled: boolean; selectedText?: string }> {	const position = options?.position ?? "before";	const beforeResult = await this.emitBeforeFork(entryId, { position });	if (beforeResult.cancelled) {		return { cancelled: true };	}	let targetLeafId: string | null;	let selectedText: string | undefined;	const selectedEntry = this.session.sessionManager.getEntry(entryId);	if (!selectedEntry) {		throw new Error("Invalid entry ID for forking");	}	if (position === "at") {		targetLeafId = selectedEntry.id;	} else {		if (selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {			throw new Error("Invalid entry ID for forking");		}		targetLeafId = selectedEntry.parentId;		selectedText = extractUserMessageText(selectedEntry.message.content);	}
Interactive mode gọi fork(entryId) từ bộ chọn message user cho /fork, và fork(leafId, { position: "at" }) cho /clone (interactive-mode.ts:5511). Bộ chọn của /fork liệt kê message user của mọi nhánh, không chỉ nhánh đang hoạt động (agent-session.ts:4221).

Với phiên được lưu, phần còn lại (agent-session-runtime.ts:289) có hai nhánh. Nếu message user được chọn là gốc của cây (parentId: null, chẳng hạn trong một phiên cũ), không có gì để chép: một phiên rỗng được tạo với parentSession trỏ về tệp cũ. Ở phiên tạo bằng v1.4.0, message user đầu tiên luôn có cha (các entry thiết lập và message system), nên fork tại đó cho ra một phiên chỉ chứa các entry thiết lập. Còn lại, một SessionManager mới mở lại tệp hiện tại và gọi createBranchedSession(). Phiên chưa từng được lưu (chưa có message nào) thì không fork được.

createBranchedSession(): chỉ một đường đi, bỏ nhãn, nối lại parentId
pi/packages/coding-agent/src/core/session-manager.tsdòng 1632–1668TypeScript
createBranchedSession(leafId: string): string | undefined {	const previousSessionFile = this.sessionFile;	const path = this.getBranch(leafId);	if (path.length === 0) {		throw new Error(`Entry ${leafId} not found`);	}	// Filter out LabelEntry from path - we'll recreate them from the resolved map.	// Because labels are real tree entries, later entries can be children of labels;	// removing labels requires re-chaining the retained path to avoid orphaned subtrees.	const pathWithoutLabels: SessionEntry[] = [];	const replacementByLabelId = new Map<string, string>();	const pendingLabelIds: string[] = [];	let pathParentId: string | null = null;	for (const entry of path) {		if (entry.type === "label") {			pendingLabelIds.push(entry.id);			continue;		}		for (const labelId of pendingLabelIds) {			replacementByLabelId.set(labelId, entry.id);		}		pendingLabelIds.length = 0;		pathWithoutLabels.push(			entry.type === "compaction"				? {						...entry,						parentId: pathParentId,						firstKeptEntryId:							entry.firstKeptEntryId === entry.id								? entry.id								: (replacementByLabelId.get(entry.firstKeptEntryId) ?? entry.firstKeptEntryId),					}				: { ...entry, parentId: pathParentId },		);		pathParentId = entry.id;	}
Tệp mới chỉ chứa đường đi từ gốc tới lá đã chọn. Entry label là nút thật của cây, nên bỏ chúng đi sẽ để lại entry mồ côi; vòng lặp nối lại parentId của mỗi entry giữ lại vào entry giữ lại liền trước, và sửa firstKeptEntryId của compaction nếu nó từng trỏ vào một nhãn.

Phần sau của hàm (session-manager.ts:1670) tạo header mới với id mới và parentSession là tệp cũ, tạo lại các nhãn còn hiệu lực dưới dạng một chuỗi entry label ở cuối, giữ nguyên timestamp của nhãn, và áp dụng cùng quy tắc tạo tệp trễ như _persist(). Các entry được giữ nguyên id, nên một id trong phiên con trỏ tới đúng nội dung như trong phiên mẹ.

--fork: chép cả tệp sang dự án hiện tại

Cờ --fork <đường dẫn|id> dùng một hàm khác, SessionManager.forkFrom(), và chép mọi entry, cả các nhánh không hoạt động:

forkFrom(): header mới với cwd đích, chép nguyên các entry
pi/packages/coding-agent/src/core/session-manager.tsdòng 1838–1865TypeScript
// Create new session file with new ID but forked contentif (options?.id !== undefined) {	assertValidSessionId(options.id);}const newSessionId = options?.id ?? createSessionId();const timestamp = new Date().toISOString();const fileTimestamp = timestamp.replace(/[:.]/g, "-");const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);// Write new header pointing to source as parent, with updated cwdconst newHeader: SessionHeader = {	type: "session",	version: CURRENT_SESSION_VERSION,	id: newSessionId,	timestamp,	cwd: resolvedTargetCwd,	parentSession: resolvedSourcePath,};writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" });// Copy all non-header entries from sourcefor (const entry of sourceEntries) {	if (entry.type !== "session") {		appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);	}}return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);
cwd trong header là thư mục đích, nên phiên mới thuộc về dự án hiện tại. --session trỏ tới một phiên của dự án khác cũng đi đường này, sau khi hỏi người dùng có muốn fork vào thư mục hiện tại không (main.ts:399).

Trong cả ba trường hợp, header của tệp con có parentSession (docs/session-format.md:72); bộ chọn session dùng nó để vẽ các phiên thành cây theo quan hệ cha con (session-selector.ts:206).

Nhãn và tên phiên#

Nhãn là dấu trang trên một entry ("checkpoint-1", "trước refactor"), để tìm lại điểm đó trong /tree. Vì entry không được sửa, đặt nhãn không ghi vào entry đích mà ghi thêm một entry label trỏ tới nó:

appendLabelChange(): một entry mới, và cập nhật bảng nhãn trong bộ nhớ
pi/packages/coding-agent/src/core/session-manager.tsdòng 1441–1462TypeScript
appendLabelChange(targetId: string, label: string | undefined): string {	if (!this.byId.has(targetId)) {		throw new Error(`Entry ${targetId} not found`);	}	const entry: LabelEntry = {		type: "label",		id: generateId(this.byId),		parentId: this.leafId,		timestamp: new Date().toISOString(),		targetId,		label,	};	this._appendEntry(entry);	if (label) {		this.labelsById.set(targetId, label);		this.labelTimestampsById.set(targetId, entry.timestamp);	} else {		this.labelsById.delete(targetId);		this.labelTimestampsById.delete(targetId);	}	return entry.id;}
Entry label là con của lá như mọi entry khác, nên nó dời lá. Bảng labelsById giữ nhãn hiện hành của mỗi đích; nạp lại tệp thì _buildIndex() dựng lại bảng bằng cách phát lại mọi entry label.

Nhãn có thể được đặt từ /tree, từ extension (pi.setLabel()), hoặc khi điều hướng cây kèm tuỳ chọn label: có tóm tắt thì nhãn gắn vào entry tóm tắt, không thì gắn vào entry đích (mục /tree). Nhãn không bao giờ vào ngữ cảnh.

Tên phiên đi theo cùng nguyên tắc ghi thêm, với entry session_info:

Tên phiên: entry session_info mới nhất thắng, chuỗi rỗng xoá tên
pi/packages/coding-agent/src/core/session-manager.tsdòng 1303–1329TypeScript
/** Append a session info entry (e.g., display name). Returns entry id. */appendSessionInfo(name: string): string {	const sanitizedName = name.replace(/[\r\n]+/g, " ").trim();	const entry: SessionInfoEntry = {		type: "session_info",		id: generateId(this.byId),		parentId: this.leafId,		timestamp: new Date().toISOString(),		name: sanitizedName,	};	this._appendEntry(entry);	return entry.id;}/** Get the current session name from the latest session_info entry, if any. */getSessionName(): string | undefined {	// Walk entries in reverse to find the latest session_info entry.	// Empty names explicitly clear the session title. Reads fileEntries directly: the footer	// calls this on every frame, and getEntries() copies the whole session.	for (let i = this.fileEntries.length - 1; i >= 0; i--) {		const entry = this.fileEntries[i];		if (entry.type === "session_info") {			return entry.name?.trim() || undefined;		}	}	return undefined;}
Ký tự xuống dòng bị thay bằng dấu cách để tên nằm gọn một dòng trong bộ chọn. getSessionName() duyệt ngược mảng thay vì gọi getEntries(), vì footer gọi nó ở mỗi khung hình và getEntries() sao chép cả phiên.

Tên được đặt bằng /name, cờ --name/-n lúc khởi động, hoặc pi.setSessionName() của extension (docs/session-format.md:199); AgentSession.setSessionName() phát thêm session_info_changed (agent-session.ts:4002). Bộ chọn /resume hiện tên thay cho message đầu tiên, và khi liệt kê, nó cũng lấy session_info cuối cùng, kể cả lần xoá tên (session-manager.ts:828). Cả tên lẫn nhãn đều tính trên toàn tệp chứ không theo nhánh, dù entry ghi chúng nằm trên một nhánh cụ thể: getSessionName() và _buildIndex() duyệt fileEntries, không duyệt getBranch(). Đổi tên ở một nhánh rồi /tree sang nhánh khác không làm tên cũ quay lại.

Liệt kê và mở lại phiên#

Lúc khởi động, createSessionManager() trong main.ts biến các cờ dòng lệnh thành đúng một SessionManager. Nó xét theo thứ tự cố định, cờ đầu tiên khớp thắng (main.ts:357):

CờHành viFactory
--no-sessionPhiên trong bộ nhớ, không ghi tệpSessionManager.inMemory()
--fork <đường dẫn|id>Chép phiên nguồn thành một phiên mới cho cwd hiện tại; có thể kèm --session-id cho id mớiSessionManager.forkFrom()
--session <đường dẫn|id>Mở phiên đó; nếu nó thuộc dự án khác thì hỏi có fork vào thư mục hiện tại khôngopen() hoặc forkFrom()
--resume, -rMở bộ chọn, phiên của dự án hiện tại trước, có thể xem mọi dự ánlist(), listAll(), rồi open()
--continue, -cMở phiên mới nhất của cwd, hoặc tạo phiên mới nếu chưa cóSessionManager.continueRecent()
--session-id <id>Mở phiên có đúng id đó trong dự án, hoặc tạo phiên mới mang id đó kèm cảnh báofindById(), rồi open() hoặc create()
(không cờ)Phiên mớiSessionManager.create()

Các tổ hợp mâu thuẫn bị chặn trước đó: --fork không đi cùng --session, --continue, --resume, --no-session; --session-id không đi cùng --session, --continue, --resume (main.ts:299, docs/cli.md:86). Đối số của --session và --fork có thể là đường dẫn hoặc id, kể cả tiền tố id:

resolveSessionPath(): đường dẫn, id chính xác, tiền tố id, rồi tìm toàn cục
pi/packages/coding-agent/src/main.tsdòng 252–283TypeScript
async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise<ResolvedSession> {	// If it looks like a file path, resolve it before handing it to the session manager.	if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) {		return { type: "path", path: resolvePath(sessionArg, cwd) };	}	// Exact IDs only require reading session headers. Fall back to the full	// metadata listing for prefix matches.	const exactLocalMatch = findLocalSessionByExactId(sessionArg, cwd, sessionDir);	if (exactLocalMatch) {		return exactLocalMatch;	}	const localSessions = await SessionManager.list(cwd, sessionDir);	const localMatch = localSessions.find((s) => s.id.startsWith(sessionArg));	if (localMatch) {		return { type: "local", path: localMatch.path };	}	// Try global search across all projects	const allSessions = await SessionManager.listAll(sessionDir);	const globalMatch =		allSessions.find((s) => s.id === sessionArg) ?? allSessions.find((s) => s.id.startsWith(sessionArg));	if (globalMatch) {		return { type: "global", path: globalMatch.path, cwd: globalMatch.cwd };	}	// Not found anywhere	return { type: "not_found", arg: sessionArg };}
Có /, \ hay đuôi .jsonl thì coi là đường dẫn. Id chính xác chỉ cần đọc header của từng tệp; tiền tố id mới cần liệt kê đầy đủ. Không tìm thấy trong dự án hiện tại thì tìm trong mọi dự án, và kết quả global dẫn tới câu hỏi fork ở trên.

--continue chọn theo thời điểm sửa tệp (mtime), không theo tên tệp:

findMostRecentSession(): tệp sửa gần nhất có header hợp lệ
pi/packages/coding-agent/src/core/session-manager.tsdòng 749–768TypeScript
export function findMostRecentSession(sessionDir: string, cwd?: string): string | null {	const resolvedSessionDir = normalizePath(sessionDir);	const resolvedCwd = cwd ? resolvePath(cwd) : undefined;	try {		const files = readdirSync(resolvedSessionDir)			.filter((file) => file.endsWith(".jsonl"))			.map((file) => join(resolvedSessionDir, file))			.map((path) => ({ path, mtime: statSync(path).mtimeMs }))			.sort((a, b) => b.mtime - a.mtime);		for (const { path } of files) {			const header = readSessionHeaderForDiscovery(path);			if (header && (!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(header), resolvedCwd))) return path;		}		return null;	} catch {		// Directory access and stat races make recent-session discovery unavailable.		return null;	}}
Tệp hỏng, không đọc được hoặc có header quá lớn bị bỏ qua (readSessionHeaderForDiscovery() nuốt lỗi), nên một tệp hỏng không chặn việc tìm các phiên khác.

Bộ chọn của --resume và /resume cần nhiều thông tin hơn: buildSessionInfo() đọc cả tệp theo dòng để lấy tên, số message, message đầu tiên và toàn văn để tìm kiếm (session-manager.ts:799). Thời điểm "sửa đổi" dùng để sắp xếp là timestamp của message user hay assistant mới nhất, không phải mtime của tệp, nên đổi tên hay gắn nhãn một phiên cũ không đưa nó lên đầu danh sách (session-manager.ts:856). list() chạy tối đa 10 lần đọc song song và báo tiến độ theo từng đợt để bộ chọn hiện dần; listAll() duyệt mọi thư mục con của ~/.pi/agent/sessions/ (session-manager.ts:1894). Xoá một phiên đơn giản là xoá tệp; bộ chọn có Ctrl+D và dùng lệnh trash khi có (docs/session-format.md:16).

Mở một tệp bằng SessionManager.open() chỉ đọc header trước, trong giới hạn 1 MB, để lấy cwd của phiên (session-manager.ts:1760). Cwd đó, chứ không phải thư mục bạn đang đứng, quyết định settings dự án, extension và trust của runtime. Nếu cwd ghi trong header không còn tồn tại (dự án đã bị xoá hay đổi tên), interactive mode hỏi có tiếp tục ở cwd hiện tại không; các chế độ khác thoát với lỗi (main.ts:697).

Xuất HTML và chia sẻ#

/export ghi phiên thành HTML hoặc JSONL; pi --export tệp.jsonl [đích.html] làm điều tương tự từ dòng lệnh mà không mở phiên (main.ts:640). Hai định dạng khác nhau ở một điểm quan trọng: HTML mang cả cây, JSONL chỉ mang nhánh đang hoạt động.

exportSessionToHtml(): mọi entry và lá hiện tại, nhúng vào một trang tự chứa
pi/packages/coding-agent/src/core/export-html/index.tsdòng 238–253, 265–274TypeScript
export async function exportSessionToHtml(	sm: SessionManager,	state?: AgentState,	options?: ExportOptions | string,): Promise<string> {	const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};	const sessionFile = sm.getSessionFile();	if (!sessionFile) {		throw new Error("Cannot export in-memory session to HTML");	}	if (!existsSync(sessionFile)) {		throw new Error("Nothing to export yet - start a conversation first");	}	const entries = sm.getEntries();	const sessionData: SessionData = {		header: sm.getHeader(),		entries,		leafId: sm.getLeafId(),		systemPrompt: state?.systemPrompt,		tools: state?.tools?.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters })),		renderedTools,	};	const html = generateHtml(sessionData, opts.themeName);
Dữ liệu được mã hoá base64 rồi chèn vào template cùng CSS, JS và các thư viện marked, highlight.js, nên tệp HTML mở được không cần mạng (index.ts:143). Tool tuỳ biến của extension được vẽ trước bằng chính renderer TUI của chúng, đổi ANSI sang HTML (index.ts:182).

Trang xuất ra có thanh bên vẽ cây và đọc tham số leafId, targetId trên URL để mở đúng một nhánh và cuộn tới một entry (template.js:21). Bản xuất JSONL thì dựng một header mới (không có parentSession) và nối lại parentId dọc theo nhánh (session-export.ts:8), nên nó là một phiên tuyến tính mở được bằng /import hay --session.

/share tải phiên lên để lấy một đường link:

/share: Radius nếu có đăng nhập, không thì gist riêng tư
pi/packages/coding-agent/src/modes/interactive/session-share.tsdòng 57–76, 89–95TypeScript
export async function shareSession(context: SessionShareContext): Promise<void> {	const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-share-"));	const jsonlFile = path.join(tempDir, "session.jsonl");	const htmlFile = path.join(tempDir, "session.html");	try {		try {			exportSessionForShare(jsonlFile, context.session);		} catch (error: unknown) {			context.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);			return;		}		const destination = context.session.settingsManager.getSessionShareDestination();		if (destination !== "gist") {			if (await tryShareViaRadius(jsonlFile, context)) return;			if (destination === "radius") {				context.showError("Radius authentication is not configured. Run '/login radius' first.");				return;			}		}		try {			await context.session.exportToHtml(htmlFile, { themeName: theme.name });		} catch (error: unknown) {			context.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);			return;		}		await shareViaGist(htmlFile, context);
Đường Radius tải lên bản JSONL của nhánh đang hoạt động, kèm một entry custom cuối với customType: "pi.share" chứa system prompt và schema tool cho trang xem (session-share.ts:24). Đường gist tạo một gist riêng tư từ bản xuất HTML bằng gh gist create --public=false, và link xem có dạng https://pi.dev/session/#<gistId> (config.ts:594).

Quyết định thiết kế#

Một tệp, chỉ ghi thêm, một dòng mỗi entry

Phương án khác là một cơ sở dữ liệu, hay một tệp cho mỗi nhánh. JSONL chỉ ghi thêm thắng ở ba điểm thực tế. An toàn khi sập: mỗi lần ghi là một dòng; pi bị giết giữa chừng thì tệp mất tối đa dòng cuối, và lần nạp sau sửa ký tự xuống dòng còn thiếu. Không mất lịch sử: rẽ nhánh, nén, sửa ngữ cảnh đều là dòng mới, nên mọi thứ từng xảy ra vẫn đọc được, thống kê chi phí cộng được trên toàn bộ. Dễ dùng bằng công cụ thường: grep, jq, diff đều chạy được trên tệp, và định dạng được tài liệu hoá để chương trình khác đọc. Cái giá: tệp chỉ lớn lên, mở một phiên phải đọc cả tệp (nên mới đọc theo khối), và mỗi yêu cầu phải đi lại đường từ lá về gốc.

Cây trong cùng tệp

Rẽ nhánh tại chỗ có từ 0.31.0 (CHANGELOG.md:5152). Giữ mọi nhánh trong một tệp nghĩa là quay lại một hướng đã bỏ chỉ là dời lá, không cần tìm tệp; và bản tóm tắt nhánh nối hai hướng thử bằng chính một entry của cây. Khi một nhánh đáng tách riêng, /fork và /clone đưa nó sang tệp mới, giữ nguyên id entry và ghi parentSession để vẫn lần ra được nguồn gốc.

Mọi thay đổi trạng thái đều là entry

Mô hình, mức suy nghĩ, tên, nhãn không phải trường của header mà là entry. Lý do: chúng đổi giữa phiên và có thể khác nhau giữa các nhánh. Resume chỉ cần đọc giá trị mới nhất trên đường đi. Cũng vì vậy mà compaction mang theo ảnh chụp systemMessage: phần ngữ cảnh giữ lại không cần các message system cũ, vì trạng thái prompt tại điểm nén đã được gộp vào ảnh chụp (docs/session-format.md:248).

Id ngắn, duy nhất trong một tệp

Tám ký tự hex đủ để không trùng trong một phiên (có kiểm tra), ngắn hơn nhiều so với UUID khi mỗi dòng mang hai id (id, parentId) và nhiều entry còn trỏ thêm (targetId, firstKeptEntryId, fromId). Id chỉ cần duy nhất trong tệp; id toàn cục là id phiên trong header.

Cạm bẫy và trường hợp biên#

  • Nhãn dời lá. Entry label là con của lá hiện tại như mọi entry khác, nên nó nằm trên nhánh đang hoạt động và entry kế tiếp sẽ là con của nó. Nó không sinh message, nhưng công cụ nào đi theo parentId phải tính tới nó, như createBranchedSession() phải nối lại cây khi bỏ nhãn.
  • Dòng hỏng cắt đứt lịch sử. Dòng không parse được bị bỏ lặng lẽ. Nếu đó là một entry giữa nhánh, entry con của nó có parentId trỏ vào hư không: buildSessionPath() dừng ở đó, nên mọi thứ trước điểm hỏng biến khỏi ngữ cảnh mà không báo lỗi, còn getTree() coi entry con là một gốc mồ côi (session-manager.ts:1544). Sửa tệp bằng tay cần cẩn thận.
  • Tiền tố id chọn phiên gần nhất. --session ab không báo mơ hồ khi nhiều phiên có id bắt đầu bằng ab; nó lấy phiên đầu tiên trong danh sách đã sắp theo hoạt động gần nhất (main.ts:266).
  • Compaction không giữ gì. firstKeptEntryId bằng chính id của compaction nghĩa là không giữ entry nào phía trước (session-manager.ts:1278, docs/session-format.md:131).
  • custom không vào ngữ cảnh. Extension muốn mô hình đọc thứ gì phải dùng custom_message; custom chỉ để lưu trạng thái.
  • Hai loại timestamp. Entry dùng chuỗi ISO, message bên trong dùng số mili giây. Thứ tự hai loại có thể ngược nhau, như message system và user ở ví dụ JSONL.
  • getEntries() sao chép. Nó lọc toàn bộ fileEntries mỗi lần gọi; mã gọi thường xuyên nên tránh, như getSessionName() đã làm (session-manager.ts:1320).
  • Phiên trong bộ nhớ không xuất HTML được (index.ts:247), và phiên mới chưa có message nào thì chưa có tệp để xuất hay fork.
  • Có thể có nhiều gốc. resetLeaf() và branchWithSummary(null, ...) làm entry kế tiếp thành một gốc mới (docs/session-format.md:211); công cụ đọc tệp không nên giả định chỉ có một parentId: null.

Đọc code ở đâu#

#Tệp và vùngĐọc để hiểu
1packages/coding-agent/docs/session-format.md, packages/coding-agent/docs/sessions.mdĐịnh dạng và cách dùng, trước khi đọc code.
2session-manager.ts:41Header, mười một loại entry, projection, SessionInfo.
3session-manager.ts:976Constructor, mở tệp, phiên mới, chỉ mục, tạo tệp trễ, appendMessage().
4session-manager.ts:1216Các hàm append*() còn lại, kể cả appendCompaction() và appendContextEdit().
5session-manager.ts:390Dựng ngữ cảnh: đường đi, compaction, entry thành message, context edit.
6session-manager.ts:1400Lá, con, nhãn, getBranch(), getTree().
7session-manager.ts:1569branch(), branchWithSummary(), createBranchedSession().
8session-manager.ts:1750Factory: create, open, continueRecent, inMemory, forkFrom, findById, list, listAll.
9session-manager.ts:585Thư mục theo cwd, đọc tệp theo khối, đọc header có giới hạn, thông tin cho bộ chọn.
10session-manager.ts:286, packages/coding-agent/src/migrations.tsMigration định dạng và migration lúc khởi động.
11agent-session.ts:4009navigateTree() cho /tree; danh sách message để fork.
12agent-session-runtime.ts:262/fork và /clone.
13main.ts:232Cờ dòng lệnh thành SessionManager.
14packages/coding-agent/src/core/session-export.ts, packages/coding-agent/src/core/export-html/index.ts, packages/coding-agent/src/modes/interactive/session-share.tsXuất JSONL, xuất HTML, /share.

Tóm tắt#

  • Một session là một tệp JSONL dưới ~/.pi/agent/sessions/--cwd--/: một dòng header, rồi các entry chỉ ghi thêm, nối thành cây bằng id/parentId.
  • Có mười một loại entry; bốn loại ảnh hưởng tới ngữ cảnh (message, compaction, branch_summary, custom_message), một loại sửa ngữ cảnh (context_edit), sáu loại còn lại chỉ mang trạng thái hay metadata.
  • Lá là vị trí hiện tại; entry mới luôn là con của lá. Ngữ cảnh là đường đi từ gốc tới lá, áp dụng compaction mới nhất và context edit mới nhất cho mỗi đích, được tính lại trước mỗi yêu cầu.
  • Tệp chỉ được tạo khi có message user đầu tiên; migration v1 → v2 → v3 là lần duy nhất tệp bị viết lại.
  • /tree dời lá trong cùng tệp, có thể kèm tóm tắt nhánh vừa rời; /fork và /clone chép một đường đi sang tệp mới; --fork chép cả tệp sang dự án hiện tại.
  • Nhãn và tên phiên cũng là entry, tính trên toàn tệp. Xuất HTML mang cả cây; xuất JSONL và chia sẻ qua Radius chỉ mang nhánh đang hoạt động.

9Công cụ và system prompt#

Vai trò#

Mô hình ngôn ngữ chỉ sinh ra văn bản. Nó đọc được file hay chạy được lệnh là nhờ harness: harness khai báo cho mô hình một danh sách tool (tên, mô tả, JSON Schema của tham số), mô hình trả lời bằng một lời gọi tool, harness chạy lời gọi đó rồi gửi lại kết quả tool. Chương này nói về hai thứ mô hình thấy đầu tiên trong mỗi request của Pi: bộ tool có sẵn và system prompt.

Các tool có sẵn nằm trong packages/coding-agent/src/core/tools/. Có tám tool: bốn tool bật mặc định là read, bash, edit, write; ba tool chỉ đọc là grep, find, ls; và powershell cho Windows (tools/index.ts:95). codemode và tool_search của Pi 1.0 không nằm ở đây mà là built-in extension, xem chương Codemode. Mỗi tool là một ToolDefinition: ngoài phần mô hình thấy, nó còn mang phần đóng góp cho system prompt và phần vẽ giao diện (renderer).

Ai dùng các tool này? AgentSession (chương AgentSession) tạo definition cho cả tám tool khi dựng runtime, bọc chúng thành AgentTool của pi-agent-core, rồi giao danh sách tool đang bật cho Agent. Vòng lặp agent (agent loop) kiểm tra đối số, gọi hook, chạy execute() và gói kết quả thành message (chương pi-agent-core). Chương này đi vào bên trong execute() của từng tool, rồi vào system-prompt.ts, nơi prompt được ghép từ tool đang bật, file AGENTS.md, skills và thư mục làm việc.

Ngoài phạm vi chương: chặn hay sửa lời gọi tool bằng extension (chương Extensions), cách session lưu message (chương Session), codemode, MCP và tool search (chương Codemode).

Mô hình tư duy#

Một tool có ba mặt hướng về ba người đọc khác nhau: mô hình, system prompt và giao diện. Kết quả của nó cũng tách làm nhiều phần, mỗi phần đi một nơi:

 coding-agent                        pi-agent-core           provider
 ToolDefinition                      AgentTool               (mô hình thấy)
 ┌──────────────────────────┐ wrap   ┌──────────────────┐    ┌──────────────┐
 │ name, label, description │ ─────▶ │ name, description│ ─▶ │ name         │
 │ parameters (TypeBox)     │        │ parameters       │    │ description  │
 │ execute(..., ctx)        │        │ execute(...)     │    │ JSON Schema  │
 │ promptSnippet ───────────┼──┐     └──────────────────┘    └──────────────┘
 │ promptGuidelines ────────┼──┴──▶ system prompt: <tools>, <rules>
 │ renderCall, renderResult ┼─────▶ giao diện (TUI)
 └──────────────────────────┘

 execute() trả về AgentToolResult:
   content            ──▶ mô hình (text, ảnh)
   details            ──▶ giao diện và file session (diff, thông tin cắt bớt)
   structuredContent  ──▶ code gọi tool theo lập trình (codemode), không tới mô hình
   isError            ──▶ mô hình thấy đây là kết quả lỗi
Một ToolDefinition, ba người đọc; một kết quả tool, bốn nơi nhận.

Các từ dùng trong phần còn lại của chương:

ToolDefinition
Kiểu của coding-agent (cũng là kiểu extension dùng với registerTool()). execute() nhận thêm ctx, ngữ cảnh extension của lời gọi: thư mục làm việc, mô hình, session.
AgentTool
Kiểu của pi-agent-core, thứ vòng lặp thực sự gọi. wrapToolDefinition() chuyển một ToolDefinition thành AgentTool.
Operations
Mỗi tool nhận một interface backend: ReadOperations, BashOperations, EditOperations... Mặc định là hệ thống file và tiến trình cục bộ; thay interface này để chạy tool qua SSH, trong container hay micro-VM.
promptSnippet, promptGuidelines
Một dòng mô tả tool trong mục <tools> của system prompt, và các gạch đầu dòng tool thêm vào mục <rules> khi nó đang bật.
Cắt bớt (truncation)
Giới hạn chung cho output tool: 2.000 dòng hoặc 50KB, cái nào chạm trước. Phần bị cắt được thay bằng một dòng hướng dẫn mô hình cách lấy tiếp.
Hàng đợi ghi theo file (file mutation queue)
Khoá theo đường dẫn thật của file, để hai lời gọi edit/write chạy song song không ghi đè lên nhau.

Data model: ToolDefinition và kết quả tool#

Định nghĩa đầy đủ của một tool nằm trong kiểu extension. Phần đầu là thứ mô hình và system prompt thấy, phần cuối là thực thi và vẽ giao diện:

ToolDefinition: phần mô hình thấy, phần prompt, phần thực thi
pi/packages/coding-agent/src/core/extensions/types.tsdòng 579–598, 649–668TypeScript
export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = unknown, TState = any> {	/** Tool name (used in LLM tool calls) */	name: string;	/** Human-readable label for UI */	label: string;	/** Description for LLM */	description: string;	/** Optional one-line snippet for the Available tools section in the default system prompt. Custom tools are omitted from that section when this is not provided. */	promptSnippet?: string;	/** Optional guideline bullets appended to the default system prompt Guidelines section when this tool is active. */	promptGuidelines?: string[];	/** Parameter schema (TypeBox) */	parameters: TParams;	/** Optional provider-side constrained sampling request for this tool. Set false to explicitly disable it, equivalent to leaving it undefined. */	constrainedSampling?: false | ConstrainedSamplingConfig;	/** Controls whether ToolExecutionComponent renders the standard colored shell or the tool renders its own framing. */	renderShell?: "default" | "self";	/** Optional compatibility shim to prepare raw tool call arguments before schema validation. Must return an object conforming to TParams. */	prepareArguments?: (args: unknown) => Static<TParams>;	/** Execute the tool. */	execute(		toolCallId: string,		params: Static<TParams>,		signal: AbortSignal | undefined,		onUpdate: AgentToolUpdateCallback<TDetails> | undefined,		ctx: ExtensionToolContext,	): Promise<AgentToolResult<TDetails>>;	/** Custom rendering for tool call display */	renderCall?: (args: Static<TParams>, theme: Theme, context: ToolRenderContext<TState, Static<TParams>>) => Component;	/** Custom rendering for tool result display */	renderResult?: (		result: AgentToolResult<TDetails>,		options: ToolRenderResultOptions,		theme: Theme,		context: ToolRenderContext<TState, Static<TParams>>,	) => Component;}
promptSnippet là tuỳ chọn, nhưng thiếu nó thì tool không xuất hiện trong mục <tools> của prompt (dòng 586). prepareArguments (dòng 598) là chỗ edit sửa đối số sai dạng trước khi kiểm tra schema.

Các trường lược bớt (dòng 599–648) là của Pi 1.0: outputSchema cho codemode, exposure và namespace cho MCP, executionMode để buộc chạy tuần tự. Tool có sẵn không dùng exposure hay executionMode; ba tool read, bash, powershell khai báo outputSchema.

Kết quả của execute() là một AgentToolResult, định nghĩa trong pi-agent-core:

AgentToolResult: content cho mô hình, details cho UI và log
pi/packages/agent/src/types.tsdòng 423–446TypeScript
/** Final or partial result produced by a tool. */export interface AgentToolResult<T = JsonValue | undefined> {	/** Text or image content returned to the model. */	content: (TextContent | ImageContent)[];	/** Arbitrary structured details for logs or UI rendering. */	details: T;	/**	 * Machine-readable result matching the tool's `outputSchema`, for programmatic callers. Not sent	 * to the model; `content` remains the model-facing result.	 */	structuredContent?: JsonValue;	/** Usage from the final tool execution itself, if available. Not used for main LLM context accounting. */	usage?: Usage;	/**	 * Report a failure without throwing. The model sees `content` as an error result, like a thrown	 * error, but `details` and `structuredContent` are kept for the UI and programmatic callers.	 */	isError?: boolean;	/**	 * Hint that the agent should stop after the current tool batch.	 * Early termination only happens when every finalized tool result in the batch sets this to true.	 */	terminate?: boolean;}
isError cho phép báo lỗi mà không throw: mô hình vẫn thấy một kết quả lỗi, còn details được giữ cho giao diện. bash dùng đường này khi lệnh trả mã khác 0.

Khi lời gọi xong, vòng lặp gói kết quả thành một ToolResultMessage của pi-ai. Đây là thứ được nối vào transcript và ghi vào file session:

ToolResultMessage: message nằm trong transcript
pi/packages/ai/src/types.tsdòng 633–650TypeScript
export type ToolResultMessage<TDetails = JsonValue> = IsJsonCompatible<TDetails> extends true	? {			role: "toolResult";			toolCallId: string;			toolName: string;			content: (TextContent | ImageContent)[]; // Supports text and images			details?: JsonRepresentation<TDetails>;			/** Usage from the tool execution itself, if available. Not part of main LLM context accounting. */			usage?: Usage;			/** Calls this tool made to other tools. Kept for the session record; not sent to the model. */			nestedCalls?: NestedToolCalls;			isError: boolean;			/** Unix timestamp in milliseconds when the result was created. */			timestamp: number;			/** Milliseconds the tool's execution took, measured with a monotonic clock. Absent for legacy results. */			durationMs?: number;		}	: never;

Để ý là structuredContent không có trong message: createToolResultMessage() chỉ chép content, details, usage, isError và durationMs (agent-loop.ts:940–954). Đến lượt mình, adapter của provider chỉ gửi đi content và cờ lỗi của message. Với Anthropic Messages API, khối tool_result được dựng từ đúng hai trường đó (anthropic-messages.ts:1339–1346). details vì thế không tốn token nào; nó chỉ phục vụ giao diện, file session và extension.

Ví dụ minh hoạ: một ToolResultMessage của edit như được ghi vào session (rút gọn)
{
  "role": "toolResult",
  "toolCallId": "toolu_01",
  "toolName": "edit",
  "content": [{ "type": "text", "text": "Successfully replaced 1 block(s) in src/server.ts." }],
  "details": {
    "diff": "-12 const port = 3000;\n+12 const port = Number(process.env.PORT ?? 3000);",
    "patch": "--- src/server.ts\n+++ src/server.ts\n@@ ...",
    "firstChangedLine": 12
  },
  "isError": false,
  "durationMs": 4,
  "timestamp": 1761100000000
}
Mô hình chỉ nhận dòng “Successfully replaced...”. Diff nằm trong details cho giao diện vẽ.

Bộ tool mặc định và cách chọn tool#

tools/index.ts liệt kê tám tên tool và cung cấp các bộ dựng sẵn cho SDK: bộ “coding” bốn tool và bộ chỉ đọc bốn tool.

Hai bộ dựng sẵn: coding và chỉ đọc
pi/packages/coding-agent/src/core/tools/index.tsdòng 164–180TypeScript
export function createCodingToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] {	return [		createReadToolDefinition(cwd, options?.read),		createBashToolDefinition(cwd, options?.bash),		createEditToolDefinition(cwd, options?.edit),		createWriteToolDefinition(cwd, options?.write),	];}export function createReadOnlyToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] {	return [		createReadToolDefinition(cwd, options?.read),		createGrepToolDefinition(cwd, options?.grep),		createFindToolDefinition(cwd, options?.find),		createLsToolDefinition(cwd, options?.ls),	];}

Bộ tool lúc khởi động do createAgentSession() quyết định. Mặc định là DEFAULT_TOOL_NAMES (settings-manager.ts:85), trừ khi setting defaultTools đổi nó. Tuỳ chọn tools của SDK (cờ --tools của CLI) hoặc thay hẳn danh sách, hoặc chỉ cộng trừ khi mọi phần tử đều có dạng +name/-name; excludeTools lọc cuối cùng:

createAgentSession(): chọn tên tool bật lúc khởi động
pi/packages/coding-agent/src/core/sdk.tsdòng 280–294TypeScript
const toolListError = options.tools ? getToolListError(options.tools) : undefined;if (toolListError) throw new Error(`Invalid tools option: ${toolListError}`);const defaultToolNames = options.noTools ? [] : (settingsManager.getDefaultTools() ?? DEFAULT_TOOL_NAMES);// A `tools` list of only `+name`/`-name` entries changes the default selection instead of// replacing it, like the `defaultTools` setting.const toolModifiers = options.tools?.some(isToolModifier) ? options.tools : undefined;const selectedToolNames = toolModifiers ? applyToolModifiers(defaultToolNames, toolModifiers) : options.tools;const allowedToolNames = toolModifiers	? options.noTools === "all"		? selectedToolNames		: undefined	: (options.tools ?? (options.noTools === "all" ? [] : undefined));const excludedToolNames = options.excludeTools;const isExcludedTool = excludedToolNames ? createToolNameMatcher(excludedToolNames) : undefined;const initialActiveToolNames = (selectedToolNames ?? defaultToolNames).filter((name) => !isExcludedTool?.(name));

Ở phía AgentSession, _buildRuntime() luôn tạo definition cho cả tám tool, truyền vào các setting liên quan (tự thu nhỏ ảnh, tiền tố lệnh shell, đường dẫn shell), nhưng chỉ bật những tên được chọn:

_buildRuntime(): tạo đủ tám definition, bật bốn
pi/packages/coding-agent/src/core/agent-session.tsdòng 3671–3684, 3710–3717TypeScript
const autoResizeImages = this.settingsManager.getImageAutoResize();const shellCommandPrefix = this.settingsManager.getShellCommandPrefix();const shellPath = this.settingsManager.getShellPath();const baseToolDefinitions = this._baseToolsOverride	? Object.fromEntries(			Object.entries(this._baseToolsOverride).map(([name, tool]) => [				name,				createToolDefinitionFromAgentTool(tool),			]),		)	: createAllToolDefinitions(this._cwd, {			read: { autoResizeImages },			bash: { commandPrefix: shellCommandPrefix, shellPath },		});const defaultActiveToolNames = this._baseToolsOverride	? Object.keys(this._baseToolsOverride)	: ["read", "bash", "edit", "write"];const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames;this._refreshToolRegistry({	activeToolNames: baseActiveToolNames,	includeAllExtensionTools: options.includeAllExtensionTools,});

Sau đó _refreshToolRegistry() gộp tool có sẵn với tool của extension và SDK (tool extension cùng tên sẽ thay tool có sẵn), gom promptSnippet và promptGuidelines của từng tool vào hai map cho system prompt, rồi bọc tất cả bằng wrapRegisteredTools() (agent-session.ts:3583–3618). Hàm bọc rất mỏng:

wrapToolDefinition(): ToolDefinition thành AgentTool
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),			),	};}
ctxFactory do ExtensionRunner.createToolContext() cung cấp (extensions/wrapper.ts:17–21), nên tool có sẵn và tool extension nhận cùng một loại ngữ cảnh.

Nhờ ctx này, mọi tool có sẵn tính đường dẫn theo ctx?.cwd || cwd tại thời điểm gọi chứ không chỉ theo cwd lúc tạo tool (ví dụ edit.ts:161), và read biết mô hình hiện tại có nhận ảnh hay không.

Từ dòng lệnh, --tools read,grep,find,ls cho một bộ tool không ghi được file (docs/cli.md:114); --tools +codemode,-write cộng trừ trên bộ mặc định; --exclude-tools tắt theo mẫu; --no-builtin-tools tắt tool có sẵn nhưng giữ tool extension; --no-tools tắt hết (docs/cli.md:119–128).

Cắt bớt output: truncate.ts#

Vấn đề: output tool là thứ ngốn cửa sổ ngữ cảnh (context window) nhiều nhất. Một lệnh cat trên file log 30MB, hay một npm test in mười nghìn dòng, đủ làm request kế tiếp vượt giới hạn của mô hình. Và đã vào transcript thì output đó được gửi lại ở mọi lượt (turn) sau. Pi đặt một bộ giới hạn chung cho mọi tool, trong một file:

Hai giới hạn độc lập: 2.000 dòng và 50KB
pi/packages/coding-agent/src/core/tools/truncate.tsdòng 1–13TypeScript
/** * Shared truncation utilities for tool outputs. * * Truncation is based on two independent limits - whichever is hit first wins: * - Line limit (default: 2000 lines) * - Byte limit (default: 50KB) * * Never returns partial lines (except bash tail truncation edge case). */export const DEFAULT_MAX_LINES = 2000;export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KBexport const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line

Có hai hướng cắt. truncateHead() giữ phần đầu, dùng cho read, grep, find, ls, vì phần đầu của file hay của danh sách kết quả là phần có ích nhất. truncateTail() giữ phần cuối, dùng cho bash, vì lỗi và kết quả cuối cùng của một lệnh nằm ở cuối output.

Không bao giờ trả về nửa dòng, trừ một ngoại lệ. Khi cắt đầu mà riêng dòng đầu tiên đã lớn hơn 50KB (một file JSON minified chẳng hạn), hàm trả chuỗi rỗng kèm firstLineExceedsLimit: true; read khi đó trả cho mô hình một gợi ý dùng sed -n ... | head -c qua bash. Khi cắt đuôi mà dòng cuối đã lớn hơn giới hạn, hàm lấy phần cuối của chính dòng đó, cắt đúng ranh giới ký tự UTF-8:

truncateTail(): đi ngược từ cuối, chỉ cắt nửa dòng khi buộc phải vậy
pi/packages/coding-agent/src/core/tools/truncate.tsdòng 168–170, 193–218TypeScript
export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult {	const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;	const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;	// Work backwards from the end	const outputLinesArr: string[] = [];	let outputBytesCount = 0;	let truncatedBy: "lines" | "bytes" = "lines";	let lastLinePartial = false;	for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {		const line = lines[i];		const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline		if (outputBytesCount + lineBytes > maxBytes) {			truncatedBy = "bytes";			// Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes,			// take the end of the line (partial)			if (outputLinesArr.length === 0) {				const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);				outputLinesArr.unshift(truncatedLine);				outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");				lastLinePartial = true;			}			break;		}		outputLinesArr.unshift(line);		outputBytesCount += lineBytes;	}

TruncationResult ghi đủ thông tin để dựng thông báo: tổng số dòng và byte, số dòng và byte giữ lại, giới hạn nào bị chạm (truncate.ts:15–38). Mỗi tool dùng nó để nối vào output một dòng hướng dẫn hành động, ví dụ [Showing lines 1-2000 of 8412. Use offset=2001 to continue.] cho read, hay [Showing lines 6413-8412 of 8412. Full output: /tmp/pi-bash-….log] cho bash. Thông báo không chỉ nói là đã cắt mà nói luôn bước tiếp theo, nên mô hình tự lấy được phần còn lại mà harness không phải can thiệp. File còn có truncateLine() cắt một dòng xuống 500 ký tự cho grep (truncate.ts:268), và truncateMiddle() giữ đầu và đuôi “như Codex”, hiện chỉ dùng cho output của tool MCP (mcp/tools.ts:185).

read: đọc text và ảnh#

Schema của read chỉ có ba tham số. Phần đóng góp cho system prompt nằm ngay cạnh, cùng file:

Schema TypeBox và đóng góp prompt của read
pi/packages/coding-agent/src/core/tools/read.tsdòng 14–23TypeScript
const readSchema = Type.Object({	path: Type.String({ description: "Path to the file to read (relative or absolute)" }),	offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),	limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),});export const readToolSystemPromptContribution = {	snippet: "Read file contents",	guidelines: ["Use read to examine files instead of cat or sed."],} as const;

Mô tả mà mô hình đọc được dựng từ chính các hằng số cắt bớt, nên con số trong mô tả không thể lệch khỏi hành vi:

read: phần mô hình thấy
pi/packages/coding-agent/src/core/tools/read.tsdòng 93–101TypeScript
return {	name: "read",	label: "read",	description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,	promptSnippet: readToolSystemPromptContribution.snippet,	promptGuidelines: [...readToolSystemPromptContribution.guidelines],	parameters: readSchema,	outputSchema: readOutputSchema,	constrainedSampling: { type: "json_schema", strict: "prefer" },

constrainedSampling xin provider dùng chế độ strict, tức là sinh đối số bị ràng buộc theo JSON Schema, khi API có hỗ trợ; "prefer" là muốn có nhưng không bắt buộc, khác "require" (pi-ai types.ts:745–756). Bốn tool mặc định đều bật nó; grep, find, ls thì không.

Phân giải đường dẫn

Mọi tool file đều đi qua path-utils.ts. resolveToCwd() gọi resolvePath() với hai tuỳ chọn: bỏ ký tự @ ở đầu (mô hình hay chép nguyên cú pháp @file của người dùng) và đổi các biến thể khoảng trắng Unicode thành dấu cách thường. normalizePath() còn mở rộng ~ và nhận URL file://:

normalizePath() và resolvePath(): đường dẫn tuyệt đối được giữ nguyên
pi/packages/coding-agent/src/utils/paths.tsdòng 76–107TypeScript
export function normalizePath(input: string, options: PathInputOptions = {}): string {	let normalized = options.trim ? input.trim() : input;	if (options.normalizeUnicodeSpaces) {		normalized = normalized.replace(UNICODE_SPACES, " ");	}	if (options.stripAtPrefix && normalized.startsWith("@")) {		normalized = normalized.slice(1);	}	if (process.platform === "win32") {		normalized = normalizeWindowsShellPath(normalized);	}	if (options.expandTilde ?? true) {		const home = options.homeDir ?? homedir();		if (normalized === "~") return home;		if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) {			return join(home, normalized.slice(2));		}	}	if (/^file:\/\//.test(normalized)) {		return fileURLToPath(normalized);	}	return normalized;}export function resolvePath(input: string, baseDir: string = process.cwd(), options: PathInputOptions = {}): string {	const normalized = normalizePath(input, options);	const normalizedBaseDir = normalizePath(baseDir);	return isAbsolute(normalized) ? nodeResolvePath(normalized) : nodeResolvePath(normalizedBaseDir, normalized);}

Dòng 106 đáng chú ý: đường dẫn tuyệt đối được dùng nguyên, đường dẫn tương đối được nối vào thư mục làm việc, và .. không bị chặn. Không có sandbox nào ở tầng tool. Riêng read thử thêm vài biến thể khi file không tồn tại, vì tên ảnh chụp màn hình trên macOS chứa ký tự mà người dùng không gõ ra được:

resolveReadPathAsync(): khoảng trắng hẹp trước AM/PM, dạng NFD, dấu nháy cong
pi/packages/coding-agent/src/core/tools/path-utils.tsdòng 86–118TypeScript
export async function resolveReadPathAsync(filePath: string, cwd: string): Promise<string> {	const resolved = resolveToCwd(filePath, cwd);	if (await pathExists(resolved)) {		return resolved;	}	// Try macOS AM/PM variant (narrow no-break space before AM/PM)	const amPmVariant = tryMacOSScreenshotPath(resolved);	if (amPmVariant !== resolved && (await pathExists(amPmVariant))) {		return amPmVariant;	}	// Try NFD variant (macOS stores filenames in NFD form)	const nfdVariant = tryNFDVariant(resolved);	if (nfdVariant !== resolved && (await pathExists(nfdVariant))) {		return nfdVariant;	}	// Try curly quote variant (macOS uses U+2019 in screenshot names)	const curlyVariant = tryCurlyQuoteVariant(resolved);	if (curlyVariant !== resolved && (await pathExists(curlyVariant))) {		return curlyVariant;	}	// Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran")	const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant);	if (nfdCurlyVariant !== resolved && (await pathExists(nfdCurlyVariant))) {		return nfdCurlyVariant;	}	return resolved;}

File text

read với file text: offset, limit, rồi truncateHead
pi/packages/coding-agent/src/core/tools/read.tsdòng 157–165, 169–194TypeScript
const allLines = textContent.split("\n");const totalFileLines = allLines.length;// Apply offset if specified. Convert from 1-indexed input to 0-indexed array access.const startLine = offset ? Math.max(0, offset - 1) : 0;const startLineDisplay = startLine + 1;// Check if offset is out of bounds.if (startLine >= allLines.length) {	throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);}if (limit !== undefined) {	const endLine = Math.min(startLine + limit, allLines.length);	selectedContent = allLines.slice(startLine, endLine).join("\n");	userLimitedLines = endLine - startLine;} else {	selectedContent = allLines.slice(startLine).join("\n");}// Apply truncation, respecting both line and byte limits.const truncation = truncateHead(selectedContent);let outputText: string;if (truncation.firstLineExceedsLimit) {	// First line alone exceeds the byte limit. Point the model at a bash fallback.	const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));	outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;	details = { truncation };} else if (truncation.truncated) {	// Truncation occurred. Build an actionable continuation notice.	const endLineDisplay = startLineDisplay + truncation.outputLines - 1;	const nextOffset = endLineDisplay + 1;	outputText = truncation.content;	if (truncation.truncatedBy === "lines") {		outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;	} else {		outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;	}	details = { truncation };
offset đánh số từ 1. limit của mô hình được áp trước; giới hạn 2.000 dòng/50KB áp sau, nên limit: 5000 vẫn chỉ trả về tối đa 2.000 dòng kèm thông báo đọc tiếp.

Output không kèm số dòng. Thứ mô hình nhận đúng là nội dung file, nên đoạn nó chép sang oldText của edit cũng là văn bản thật của file, không phải gỡ tiền tố số dòng ra trước.

Ảnh

read không đoán loại file theo đuôi. detectSupportedImageMimeTypeFromFile() đọc 4.100 byte đầu và nhận diện JPEG, PNG (bỏ qua PNG động), GIF, WebP, BMP theo chữ ký nhị phân (utils/mime.ts:6–23). Có MIME thì đi nhánh ảnh:

read với ảnh: chuẩn hoá, thu nhỏ, kèm một dòng text
pi/packages/coding-agent/src/core/tools/read.tsdòng 129–152TypeScript
const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(absolutePath) : undefined;let content: (TextContent | ImageContent)[];let details: ReadToolDetails | undefined;const nonVisionImageNote = getNonVisionImageNote(ctx?.model);if (mimeType) {	// Read image as binary.	const buffer = await ops.readFile(absolutePath);	const processed = await processImage(buffer, mimeType, {		autoResizeImages,		resizeOptions: ctx?.model?.inputLimits?.images?.resize ?? fallbackResizeOptions,	});	if (!processed.ok) {		let textNote = `Read image file [${mimeType}]\n${processed.message}`;		if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`;		content = [{ type: "text", text: textNote }];	} else {		let textNote = `Read image file [${processed.mimeType}]`;		if (processed.hints.length > 0) textNote += `\n${processed.hints.join("\n")}`;		if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`;		content = [			{ type: "text", text: textNote },			{ type: "image", data: processed.data, mimeType: processed.mimeType },		];	}
Giới hạn thu nhỏ lấy từ metadata của mô hình nếu có (inputLimits.images.resize), nếu không thì dùng mặc định.

processImage() đổi BMP sang PNG, rồi thu nhỏ về tối đa 2000×2000 điểm ảnh và 4,5MB base64, “dưới giới hạn 5MB của Anthropic” (image-resize-core.ts:31–39). Khi ảnh bị thu nhỏ, dòng text đi kèm báo kích thước gốc và hệ số nhân toạ độ (image-resize.ts:115), để mô hình nói “nút ở toạ độ (840, 220)” thì toạ độ đó vẫn đúng trên ảnh gốc. Nếu mô hình hiện tại không nhận ảnh, text kèm thêm một câu báo ảnh sẽ bị bỏ khỏi request (read.ts:79–84). Cuối cùng, structuredContent của read là chuỗi text hoặc một đối tượng ảnh cho script codemode (read.ts:216).

bash: chạy lệnh, timeout, luồng output#

Schema của bash: một lệnh và một timeout tuỳ chọn
pi/packages/coding-agent/src/core/tools/bash.tsdòng 40–48TypeScript
const bashSchema = Type.Object({	command: Type.String({ description: "Shell command to execute" }),	timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),});export const bashToolSystemPromptContribution = {	snippet: "Execute bash commands (ls, grep, find, etc.)",	guidelines: ["You can inspect PI_* environment variables for current model and session details."],} as const;
Không có timeout mặc định. timeout tính bằng giây, phải là số dương hữu hạn, tối đa khoảng 2.147.483 giây, giới hạn của setTimeout (bash.ts:27–38).

Mô tả mô hình thấy báo trước rằng output bị cắt còn 2.000 dòng cuối hoặc 50KB và output đầy đủ được lưu ra file tạm (bash.ts:255). bash và powershell dùng chung một hàm dựng, createShellToolDefinition(), chỉ khác cấu hình.

Sinh tiến trình

Backend mặc định là createLocalShellOperations(). Shell được chọn bởi getShellConfig(): shellPath trong settings nếu có, trên Unix thì /bin/bash, rồi bash trên PATH, cuối cùng là sh; trên Windows thì tìm Git Bash (utils/shell.ts:86–139). Lệnh chạy dưới dạng bash -c "<lệnh>":

spawn(): tiến trình tách riêng, stdin bị bỏ qua
pi/packages/coding-agent/src/core/tools/bash.tsdòng 104–128TypeScript
const shellConfig = resolveShellConfig();try {	await fsAccess(cwd, constants.F_OK);} catch {	throw new Error(`Working directory does not exist: ${cwd}\nCannot execute ${shellName} commands.`);}const commandFromStdin = shellConfig.commandTransport === "stdin";const child = spawn(shellConfig.shell, commandFromStdin ? shellConfig.args : [...shellConfig.args, command], {	cwd,	detached: process.platform !== "win32",	env: env ?? getShellEnv(),	stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],	windowsHide: true,});if (commandFromStdin) {	child.stdin?.on("error", () => {});	child.stdin?.end(command);}if (child.pid) trackDetachedChildPid(child.pid);let timedOut = false;let timeoutHandle: NodeJS.Timeout | undefined;const onAbort = () => {	if (child.pid) killProcessTree(child.pid);};
detached: true trên Unix làm shell thành trưởng một nhóm tiến trình (process group) mới, nhờ vậy về sau giết được cả cây. stdin là "ignore": lệnh nào chờ nhập từ bàn phím sẽ nhận EOF ngay chứ không treo.
Timeout, luồng stdout/stderr và mã thoát
pi/packages/coding-agent/src/core/tools/bash.tsdòng 131–158TypeScript
// Set timeout if provided.if (timeoutMs !== undefined) {	timeoutHandle = setTimeout(() => {		timedOut = true;		if (child.pid) killProcessTree(child.pid);	}, timeoutMs);}// Stream stdout and stderr.child.stdout?.on("data", onData);child.stderr?.on("data", onData);// Handle abort signal by killing the entire process tree.if (signal) {	if (signal.aborted) onAbort();	else signal.addEventListener("abort", onAbort, { once: true });}// Handle shell spawn errors and wait for the process to terminate without hanging// on inherited stdio handles held by detached descendants.const exitCode = await waitForChildProcess(child);if (signal?.aborted) {	throw new Error("aborted");}if (timedOut) {	throw new Error(`timeout:${timeout}`);}// A signal-killed shell has no exit code. Use the standard shell convention so// callers do not mistake the termination for a successful command.const signalCode = child.signalCode;return { exitCode: exitCode ?? (signalCode ? 128 + (osConstants.signals[signalCode] ?? 0) : 1) };

stdout và stderr đổ chung vào một onData, nên mô hình thấy chúng xen kẽ theo thứ tự Pi nhận được, như trong một terminal. Shell bị giết bằng tín hiệu thì không có mã thoát; dòng 158 đổi thành 128 cộng số hiệu tín hiệu theo quy ước của shell, để không bị hiểu nhầm là thành công. waitForChildProcess() giải quyết một ca khó: shell đã thoát nhưng một tiến trình con chạy nền vẫn giữ pipe output. Hàm không đóng pipe theo một hạn cố định mà chờ pipe im lặng 100ms, và mỗi chunk mới lại đặt lại đồng hồ (child-process.ts:39–48).

Khi hết giờ hoặc bị huỷ, Pi giết cả nhóm tiến trình chứ không chỉ shell, vì npm test có thể đã sinh ra cả chục tiến trình con:

killProcessTree(): SIGKILL cho cả nhóm tiến trình
pi/packages/coding-agent/src/utils/shell.tsdòng 217–221, 238–250TypeScript
/** * Kill a process and all its children (cross-platform) */export function killProcessTree(pid: number): void {	if (process.platform === "win32") {	} else {		// Use SIGKILL on Unix/Linux/Mac		try {			process.kill(-pid, "SIGKILL");		} catch {			// Fallback to killing just the child if process group kill fails			try {				process.kill(pid, "SIGKILL");			} catch {				// Process already dead			}		}	}
PID âm trong process.kill(-pid) nghĩa là cả nhóm. Trên Windows, Pi gọi taskkill /F /T từ System32. Các PID tách riêng còn được ghi nhớ để giết nốt khi chính Pi thoát (shell.ts:196–215).

Môi trường của lệnh

getShellEnv() sao chép môi trường của Pi và thêm ~/.pi/agent/bin (nơi Pi tải rg và fd về) vào đầu PATH (shell.ts:171–183). Mỗi lời gọi còn nhận vài biến PI_* mô tả session hiện tại, khớp với guideline “You can inspect PI_* environment variables” trong prompt:

resolveSpawnContext(): biến PI_* và spawnHook
pi/packages/coding-agent/src/core/tools/bash.tsdòng 193–211TypeScript
const env = { ...getShellEnv() };delete env.PI_SESSION_ID;delete env.PI_SESSION_FILE;delete env.PI_PROVIDER;delete env.PI_MODEL;delete env.PI_REASONING_LEVEL;if (exposeSessionEnvironment && ctx) {	const model = ctx.model;	env.PI_SESSION_ID = ctx.sessionManager.getSessionId();	const sessionFile = ctx.sessionManager.getSessionFile();	if (sessionFile) env.PI_SESSION_FILE = sessionFile;	if (model) {		env.PI_PROVIDER = model.provider;		env.PI_MODEL = model.id;	}	if (ctx.thinkingLevel) env.PI_REASONING_LEVEL = ctx.thinkingLevel;}const baseContext: BashSpawnContext = { command, cwd, env };return spawnHook ? spawnHook(baseContext) : baseContext;
Các biến cũ bị xoá trước (dòng 194–198) để lệnh không nhận nhầm giá trị của một Pi khác đã khởi động tiến trình này. spawnHook cho extension sửa lệnh, thư mục hay môi trường trước khi chạy; setting shellCommandPrefix nối thêm một dòng vào trước mọi lệnh (bash.ts:268).

Luồng output và file tạm

Lệnh chạy lâu thì giao diện cần thấy output ngay, còn bộ nhớ của Pi không được phình theo output. OutputAccumulator giải quyết cả hai: giải mã UTF-8 theo luồng, chỉ giữ trong bộ nhớ một đoạn đuôi cỡ hai lần giới hạn, và chuyển sang ghi file tạm ngay khi output vượt giới hạn:

OutputAccumulator: đuôi trong bộ nhớ, toàn bộ ra file
pi/packages/coding-agent/src/core/tools/output-accumulator.tsdòng 64–78, 239–256TypeScript
append(data: Buffer): void {	if (this.finished) {		throw new Error("Cannot append to a finished output accumulator");	}	this.totalRawBytes += data.length;	this.appendDecodedText(this.decoder.decode(data, { stream: true }));	if (this.tempFileStream || this.shouldUseTempFile()) {		this.ensureTempFile();		this.tempFileStream?.write(data);	} else if (data.length > 0) {		this.rawChunks.push(data);	}}private shouldUseTempFile(): boolean {	return (		this.totalRawBytes > this.maxBytes || this.totalDecodedBytes > this.maxBytes || this.totalLines > this.maxLines	);}private ensureTempFile(): void {	if (this.tempFilePath) {		return;	}	const { path, stream } = createOutputFileStream(this.tempFilePrefix, ".log");	this.tempFilePath = path;	this.tempFileStream = stream;	for (const chunk of this.rawChunks) {		this.tempFileStream.write(chunk);	}	this.rawChunks = [];}
Chừng nào output còn nhỏ, các chunk thô nằm trong bộ nhớ. Lần đầu vượt giới hạn, mọi chunk đã có được ghi ra file và từ đó output đi thẳng vào file. File nằm trong thư mục tạm của hệ điều hành, tên dạng pi-bash-<16 ký tự hex>.log, quyền 0600 vì output có thể chứa dữ liệu riêng (output-files.ts:14–20).

Mỗi chunk đến thì tool đánh dấu “có dữ liệu mới” và hẹn một lần cập nhật, tối đa mười lần mỗi giây (BASH_UPDATE_THROTTLE_MS = 100, renderers/bash.ts:19). Mỗi lần cập nhật gửi qua onUpdate một ảnh chụp đã cắt đuôi; vòng lặp biến nó thành sự kiện tool_execution_update cho giao diện:

Gộp các chunk thành tối đa mười cập nhật mỗi giây
pi/packages/coding-agent/src/core/tools/bash.tsdòng 303–316, 322–326TypeScript
const scheduleOutputUpdate = () => {	if (!onUpdate) return;	updateDirty = true;	const delay = BASH_UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt);	if (delay <= 0) {		clearUpdateTimer();		emitOutputUpdate();		return;	}	updateTimer ??= setTimeout(() => {		updateTimer = undefined;		emitOutputUpdate();	}, delay);};const handleData = (data: Buffer) => {	if (!acceptingOutput) return;	output.append(data);	scheduleOutputUpdate();};

Kết quả

Ba loại kết thúc: thành công, mã thoát khác 0, huỷ hoặc hết giờ
pi/packages/coding-agent/src/core/tools/bash.tsdòng 371–382, 386–408TypeScript
} catch (err) {	const snapshot = await finishOutput();	const { text } = formatOutput(snapshot, "");	if (err instanceof Error && err.message === "aborted") {		throw new Error(appendStatus(text, "Command aborted"));	}	if (err instanceof Error && err.message.startsWith("timeout:")) {		const timeoutSecs = err.message.split(":")[1];		throw new Error(appendStatus(text, `Command timed out after ${timeoutSecs} seconds`));	}	throw err;}if (exitCode === null) {	throw new Error(appendStatus(outputText, "Command terminated without an exit code"));}const wallTimeSeconds = Math.round((performance.now() - startedAt) / 100) / 10;const fullOutput = await output.readFullOutput(STRUCTURED_OUTPUT_MAX_BYTES);const structuredContent: BashToolOutput = {	output: fullOutput.content,	truncated: fullOutput.truncated,	...(fullOutput.truncated && snapshot.fullOutputPath		? { full_output_path: snapshot.fullOutputPath }		: {}),	exit_code: exitCode,	wall_time_seconds: wallTimeSeconds,};if (exitCode !== 0) {	return {		content: [{ type: "text", text: appendStatus(outputText, `Command exited with code ${exitCode}`) }],		details,		structuredContent,		isError: true,	};}return { content: [{ type: "text", text: outputText }], details, structuredContent };

Ba loại kết thúc được báo theo ba cách khác nhau. Mã thoát khác 0 là kết quả bình thường có isError: true: mô hình thấy output kèm dòng Command exited with code N, còn details (thông tin cắt bớt, đường dẫn file đầy đủ) vẫn tới giao diện. Huỷ và hết giờ thì throw một Error mà message đã chứa phần output thu được; vòng lặp agent bắt lỗi đó và biến thành kết quả lỗi (agent-loop.ts:855–863). structuredContent cho codemode có output tới 1MB (giữ đầu và đuôi), mã thoát và thời gian chạy.

powershell dùng đúng bộ máy này với pwsh.exe hoặc powershell.exe, chèn trước mỗi lệnh một câu đặt mã hoá output là UTF-8 (powershell.ts:16–37), và chỉ chạy trên Windows (shell.ts:144–147).

edit: thay thế đúng văn bản#

edit không nhận số dòng, cũng không nhận patch. Mô hình đưa đoạn văn bản cũ, chép nguyên văn, cùng đoạn thay thế; một lời gọi có thể chứa nhiều cặp như vậy cho cùng một file:

Schema của edit và bốn guideline cho system prompt
pi/packages/coding-agent/src/core/tools/edit.tsdòng 21–51TypeScript
const replaceEditSchema = Type.Object(	{		oldText: Type.String({			description:				"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.",		}),		newText: Type.String({ description: "Replacement text for this targeted edit." }),	},	{},);const editSchema = Type.Object(	{		path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),		edits: Type.Array(replaceEditSchema, {			description:				"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.",		}),	},	{},);export const editToolSystemPromptContribution = {	snippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",	guidelines: [		"Use edit for precise changes (edits[].oldText must match exactly)",		"When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",		"Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.",		"Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",	],} as const;

Quy tắc xuất hiện ở ba chỗ: mô tả từng trường (dòng 25, 37), mô tả tool (edit.ts:152), và guidelines trong system prompt. Changelog cho thấy schema này được chỉnh dần để giảm lời gọi sai: bản 0.63.0 cho phép nhiều edit trong một lời gọi (CHANGELOG.md:3048), bản 0.63.2 bỏ hẳn dạng một edit lẻ vì việc trộn hai dạng khiến mô hình gọi sai rồi thử lại liên tục (CHANGELOG.md:2987).

Sửa đối số trước khi kiểm tra schema

prepareEditArguments(): nhận cả những dạng đối số sai đã gặp thật
pi/packages/coding-agent/src/core/tools/edit.tsdòng 103–134TypeScript
function prepareEditArguments(input: unknown): EditToolInput {	if (!input || typeof input !== "object") {		return input as EditToolInput;	}	const args = input as Record<string, unknown>;	// Some models (Opus 4.6, GLM-5.1) send edits as a JSON string instead of an array.	// Others send a single edit object instead of a one-element edits array.	if (typeof args.edits === "string") {		try {			const parsed = JSON.parse(args.edits);			if (Array.isArray(parsed)) {				args.edits = parsed;			} else if (isSingleEditInput(parsed)) {				args.edits = [parsed];			}		} catch {}	} else if (isSingleEditInput(args.edits)) {		args.edits = [args.edits];	}	const legacy = args as LegacyEditToolInput;	if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") {		return args as EditToolInput;	}	const edits = Array.isArray(legacy.edits) ? [...legacy.edits] : [];	edits.push({ oldText: legacy.oldText, newText: legacy.newText });	const { oldText: _oldText, newText: _newText, ...rest } = legacy;	return { ...rest, edits } as EditToolInput;}

Vòng lặp gọi prepareArguments ngay trước validateToolArguments() (agent-loop.ts:736–737). Hàm này đỡ ba dạng sai: edits là chuỗi JSON thay vì mảng (comment ghi tên Opus 4.6 và GLM-5.1), edits là một đối tượng đơn, và dạng cũ oldText/newText ở cấp cao nhất, vẫn còn trong các session cũ được mở lại (CHANGELOG.md:2972). Sửa ở đây rẻ hơn nhiều so với trả lỗi validation để mô hình gọi lại.

Thực thi

execute(): đọc, chuẩn hoá, áp edit, khôi phục, ghi, tạo diff
pi/packages/coding-agent/src/core/tools/edit.tsdòng 160–170, 185–211TypeScript
const { path, edits } = validateEditInput(input);const absolutePath = resolveToCwd(path, ctx?.cwd || cwd);return withFileMutationQueue(absolutePath, async () => {	// Do not reject from an abort event listener here: that would release the	// mutation queue while an in-flight filesystem operation may still finish.	// Checking signal.aborted after each await observes the same aborts while	// keeping the queue locked until the current operation has settled.	const throwIfAborted = (): void => {		if (signal?.aborted) throw new Error("Operation aborted");	};	// Read the file.	const buffer = await ops.readFile(absolutePath);	const rawContent = buffer.toString("utf-8");	throwIfAborted();	// Strip BOM before matching. The model will not include an invisible BOM in oldText.	const { bom, text: content } = splitBom(rawContent);	const originalEnding = detectLineEnding(content);	const normalizedContent = normalizeToLF(content);	const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);	throwIfAborted();	const finalContent = bom + restoreLineEndings(newContent, originalEnding);	await ops.writeFile(absolutePath, finalContent);	throwIfAborted();	const diffResult = generateDiffString(baseContent, newContent);	const patch = generateUnifiedPatch(path, baseContent, newContent);	return {		content: [			{				type: "text",				text: `Successfully replaced ${edits.length} block(s) in ${path}.`,			},		],		details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },	};

Mọi việc diễn ra trong hàng đợi của file (dòng 163): kiểm tra quyền đọc và ghi, đọc file, tách BOM (mô hình không bao giờ chép được một BOM vô hình vào oldText), ghi nhớ kiểu xuống dòng rồi chuẩn hoá về LF, áp các edit, khôi phục CRLF và BOM, ghi file. Nhờ vậy một file CRLF vẫn là CRLF sau khi sửa dù mô hình chỉ viết LF; kiểu xuống dòng được xác định theo lần xuống dòng đầu tiên trong file (edit-diff.ts:11–17). Comment ở dòng 164–167 giải thích vì sao huỷ được kiểm tra sau mỗi await thay vì reject từ listener: reject sớm sẽ nhả khoá hàng đợi trong khi một thao tác ghi file có thể vẫn đang chạy.

Khớp chính xác, rồi khớp mờ

Phần lõi là applyEditsToNormalizedContent(). Mọi edit được tìm trên cùng một nội dung gốc, không phải trên kết quả của edit trước, rồi được áp từ cuối file lên đầu để vị trí của các edit chưa áp không bị xê dịch:

applyEditsToNormalizedContent(): tìm, kiểm tra trùng và chồng lấn, rồi áp
pi/packages/coding-agent/src/core/tools/edit-diff.tsdòng 316–331, 341–359TypeScript
const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch);const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;const matchedEdits: MatchedEdit[] = [];for (let i = 0; i < normalizedEdits.length; i++) {	const edit = normalizedEdits[i];	const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText);	if (!matchResult.found) {		throw getNotFoundError(path, i, normalizedEdits.length);	}	const occurrences = countOccurrences(replacementBaseContent, edit.oldText);	if (occurrences > 1) {		throw getDuplicateError(path, i, normalizedEdits.length, occurrences);	}matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);for (let i = 1; i < matchedEdits.length; i++) {	const previous = matchedEdits[i - 1];	const current = matchedEdits[i];	if (previous.matchIndex + previous.matchLength > current.matchIndex) {		throw new Error(			`edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`,		);	}}const baseContent = normalizedContent;const newContent = usedFuzzyMatch	? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits)	: applyReplacements(replacementBaseContent, matchedEdits);if (baseContent === newContent) {	throw getNoChangeError(path, normalizedEdits.length);}

Quy tắc cụ thể:

  • Chính xác trước. fuzzyFindText() thử indexOf nguyên văn; chỉ khi không thấy mới chuẩn hoá cả hai phía rồi tìm lại (edit-diff.ts:207–245).
  • Một edit mờ thì cả lời gọi mờ. Nếu bất kỳ edit nào cần khớp mờ, mọi edit được tìm lại trên nội dung đã chuẩn hoá (dòng 318), để tất cả vị trí nằm trong cùng một hệ toạ độ.
  • Phải duy nhất. countOccurrences() luôn đếm trên nội dung đã chuẩn hoá (edit-diff.ts:247–251), kể cả khi khớp chính xác.
  • Không chồng lấn (dòng 345–348), và phải thay đổi được gì đó (dòng 357–358).

Chuẩn hoá mờ làm năm việc: NFKC, bỏ khoảng trắng cuối mỗi dòng, đổi dấu nháy cong thành nháy thẳng, đổi các loại gạch ngang Unicode thành -, đổi các khoảng trắng đặc biệt thành dấu cách (edit-diff.ts:34–55). Tính năng có từ 0.46.0 (CHANGELOG.md:4392). Khi khớp mờ, Pi không ghi cả file ở dạng đã chuẩn hoá: applyReplacementsPreservingUnchangedLines() chỉ viết lại những dòng bị edit chạm vào, mọi dòng khác được chép lại nguyên byte từ bản gốc (edit-diff.ts:122–136).

Các thông báo lỗi được viết để mô hình đọc và tự sửa: “Could not find the exact text in … The old text must match exactly including all whitespace and newlines.”, “Found 2 occurrences … Please provide more context to make it unique.”, hay “edits[0] and edits[1] overlap … Merge them into one edit” (edit-diff.ts:253–289).

Diff cho giao diện

Sau khi ghi, edit tạo hai biểu diễn của thay đổi, cả hai nằm trong details. generateDiffString() cho diff hiển thị: số dòng, bốn dòng ngữ cảnh mỗi phía, dấu ... thay cho đoạn dài không đổi, cùng firstChangedLine để editor nhảy tới (edit-diff.ts:372). generateUnifiedPatch() cho một patch unified chuẩn, dựng bằng thư viện diff (edit-diff.ts:365–370). Mô hình chỉ nhận một câu: Successfully replaced N block(s) in <path>.

write và hàng đợi ghi theo file#

write là tool đơn giản nhất: tạo thư mục cha nếu cần rồi ghi đè cả file.

write.execute(): mkdir -p rồi ghi, cũng trong hàng đợi
pi/packages/coding-agent/src/core/tools/write.tsdòng 65–90TypeScript
	const absolutePath = resolveToCwd(path, ctx?.cwd || cwd);	const dir = dirname(absolutePath);	return withFileMutationQueue(absolutePath, async () => {		// Do not reject from an abort event listener here: that would release the		// mutation queue while an in-flight filesystem operation may still finish.		// Checking signal.aborted after each await observes the same aborts while		// keeping the queue locked until the current operation has settled.		const throwIfAborted = (): void => {			if (signal?.aborted) throw new Error("Operation aborted");		};		throwIfAborted();		// Create parent directories if needed.		await ops.mkdir(dir);		throwIfAborted();		// Write the file contents.		await ops.writeFile(absolutePath, content);		throwIfAborted();		return {			content: [{ type: "text", text: `Successfully wrote to ${path}` }],			details: undefined,		};	});},

Guideline của nó, “Use write only for new files or complete rewrites.”, đẩy mô hình sang edit cho mọi thay đổi cục bộ: với write, mô hình phải sinh lại toàn bộ nội dung file trong đối số content.

Vì sao cần hàng đợi

Agent chạy batch tool song song theo mặc định (agent.ts:261), và không tool có sẵn nào đặt executionMode: "sequential". Mô hình hoàn toàn có thể phát hai lời gọi edit vào cùng một file trong một câu trả lời. Không có khoá, cả hai cùng đọc bản gốc, mỗi bên áp edit của mình, và bên ghi sau xoá mất thay đổi của bên ghi trước. withFileMutationQueue() tuần tự hoá thao tác trên cùng một file và vẫn để các file khác nhau chạy song song:

Ba chi tiết làm hàng đợi đúng:

  • Khoá là đường dẫn thật (realpath, dòng 19): ./a.ts, src/../a.ts và một symlink trỏ tới a.ts rơi vào cùng một hàng. File chưa tồn tại thì dùng đường dẫn đã resolve.
  • Đăng ký cũng xếp hàng. realpath là bất đồng bộ, nên nếu mỗi lời gọi tự tính khoá, lời gọi đến sau có thể đăng ký trước. registrationQueue buộc các lần đăng ký chạy lần lượt, nên thứ tự trong hàng đúng bằng thứ tự gọi. Vòng lặp khởi động các tool song song theo đúng thứ tự trong câu trả lời của mô hình (agent-loop.ts:656–658), nên edit đứng trước trong câu trả lời được áp trước.
  • Dọn dẹp. Xong việc thì nhả khoá cho người kế tiếp, và xoá mục trong map nếu không còn ai xếp sau (dòng 56–59), để map không lớn dần.

Ba tool này tắt theo mặc định. Chúng dành cho những cấu hình không có bash, như một agent chỉ được đọc, hoặc khi cần output có giới hạn và định dạng ổn định. Khi bash bật mà không tool nào trong ba tool này bật, system prompt thêm luật “Use bash for file operations like ls, rg, find” (system-prompt.ts:108–116).

grep chạy ripgrep. Nếu máy chưa có rg, ensureTool() tải bản phù hợp về ~/.pi/agent/bin, trừ khi đang ở chế độ offline (tools-manager.ts:350–380). Các cờ được cố định:

grep: cờ ripgrep cố định, không đọc cấu hình của người dùng
pi/packages/coding-agent/src/core/tools/grep.tsdòng 162–168TypeScript
// --no-config: the tool contract (literal, glob, limit, ...) must not depend on// ambient user config like RIPGREP_CONFIG_PATH. Users can customize via bash or extensions.const args: string[] = ["--no-config", "--json", "--line-number", "--color=never", "--hidden"];if (ignoreCase) args.push("--ignore-case");if (literal) args.push("--fixed-strings");if (glob) args.push("--glob", glob);args.push("--", pattern, searchPath);

--no-config để hợp đồng của tool (literal, glob, limit...) không phụ thuộc RIPGREP_CONFIG_PATH của người dùng; --hidden tìm cả dotfile nhưng vẫn tôn trọng .gitignore. Pi đọc các sự kiện match từ output --json, giết rg khi đủ 100 kết quả (mặc định), tự đọc file để lấy dòng ngữ cảnh khi có context, cắt mỗi dòng còn 500 ký tự, rồi cắt cả output theo 50KB. Mỗi giới hạn bị chạm sinh ra một thông báo hành động, ví dụ “100 matches limit reached. Use limit=200 for more, or refine pattern” (grep.ts:282–305).

find chạy fd theo cùng cách:

find: fd với glob, .gitignore và mẫu có dấu gạch chéo
pi/packages/coding-agent/src/core/tools/find.tsdòng 182–214TypeScript
const args: string[] = ["--glob", "--color=never", "--hidden"];// fd normally ignores .gitignore outside git repos, so keep --no-require-git// there. Inside repos, use fd's default git-aware behavior so parent// .gitignore rules stop at nested repo boundaries:// https://github.com/earendil-works/pi/issues/5960let insideGitRepo = false;for (let current = searchPath; ; ) {	if (await pathExists(path.join(current, ".git"))) {		insideGitRepo = true;		break;	}	const parent = path.dirname(current);	if (parent === current) break;	current = parent;}if (!insideGitRepo) args.push("--no-require-git");args.push("--max-results", String(effectiveLimit));// fd --glob matches against the basename unless --full-path is set; in --full-path// mode it matches against the absolute candidate path, so a path-containing// pattern like 'src/**/*.spec.ts' needs a leading '**/' to match anything.let effectivePattern = pattern;if (pattern.includes("/")) {	args.push("--full-path");	if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") {		effectivePattern = `**/${pattern}`;	}	// fd matches full paths using native separators on Windows.	if (process.platform === "win32")		effectivePattern = effectivePattern.replaceAll("/", String.raw`[/\\]`);}args.push("--", effectivePattern, searchPath);

Hai chỗ đáng để ý. Ngoài một git repo, fd mặc định bỏ qua .gitignore, nên Pi thêm --no-require-git; trong repo thì dùng hành vi mặc định của fd để luật .gitignore của repo cha dừng ở ranh giới repo con. Và fd --glob chỉ so với tên file, nên một mẫu chứa / như src/**/*.spec.ts được chạy với --full-path và tiền tố **/. Mặc định tối đa 1.000 kết quả.

ls không gọi chương trình ngoài: đọc thư mục, sắp xếp không phân biệt hoa thường, thêm / sau thư mục, gồm cả dotfile, tối đa 500 mục (ls.ts:108–130). Như mọi tool có sẵn, cả ba nhận một interface *Operations để chạy trên máy khác; find còn cho thay hẳn fd bằng hàm glob() riêng.

Renderer: phần giao diện thấy#

Mỗi tool có sẵn trải renderCall và renderResult từ tools/renderers/ vào definition của mình (ví dụ edit.ts:214). Phần vẽ được tách khỏi phần chạy để một tiến trình chỉ hiển thị không phải nạp code thực thi và schema TypeBox, khoảng 17MB module (renderers/index.ts:1–7); withBuiltInRenderers() lắp renderer có sẵn vào definition nào chưa tự có (renderers/index.ts:51–63).

Một lời gọi tool có mặt trên màn hình từ trước khi chạy. Trong lúc assistant message còn stream, mỗi message_update có khối toolCall mới sẽ tạo một ToolExecutionComponent và cập nhật đối số của nó khi JSON dài dần (interactive-mode.ts:3486–3510). Sau đó tool_execution_start đánh dấu bắt đầu chạy, mỗi tool_execution_update vẽ lại với kết quả tạm, và tool_execution_end vẽ kết quả cuối cùng, gồm cả details:

Chế độ interactive: kết quả tạm và kết quả cuối vào cùng một component
pi/packages/coding-agent/src/modes/interactive/interactive-mode.tsdòng 3590–3608TypeScript
case "tool_execution_update": {	const component = this.pendingTools.get(event.toolCallId);	if (component) {		component.updateResult({ ...event.partialResult, isError: false }, true);		this.ui.requestRender();	}	break;}case "tool_execution_end": {	if (event.isError) this.maybeShowInstallChangeWarning();	const component = this.pendingTools.get(event.toolCallId);	if (component) {		component.updateResult({ ...event.result, isError: event.isError, durationMs: event.durationMs });		this.pendingTools.delete(event.toolCallId);		this.ui.requestRender();	}	break;}

Hai renderer đáng xem. Renderer của bash chỉ hiện năm dòng cuối, có phím mở rộng, và thay dòng thông báo “Full output: …” trong text bằng một dòng cảnh báo dựng từ details.truncation (renderers/bash.ts:39–87). Renderer của edit không chờ tool chạy xong: ngay khi đối số stream xong, nó tự tính diff xem trước trên file hiện tại, rồi thay bằng diff thật trong details khi kết quả về:

Renderer của edit: diff xem trước trước cả khi tool chạy
pi/packages/coding-agent/src/core/tools/renderers/edit.tsdòng 186–195TypeScript
if (context.argsComplete && previewInput && !component.preview && !component.previewPending) {	component.previewPending = true;	const requestKey = argsKey;	void computeEditsDiff(previewInput.path, previewInput.edits, context.cwd).then((preview) => {		if (component.previewArgsKey === requestKey) {			setEditPreview(component, preview, requestKey);			context.invalidate();		}	});}
computeEditsDiff() chạy cùng thuật toán khớp với tool nhưng không ghi gì (edit-diff.ts:510–543), nên người dùng thấy diff, hay lỗi “không tìm thấy”, ngay khi mô hình vừa viết xong lời gọi.

Lệnh ! của người dùng#

Người dùng gõ !git status trong editor thì lệnh không đi qua mô hình. AgentSession.executeBash() chạy nó bằng chính BashOperations mà tool bash dùng (nên extension nào thay backend cho tool cũng thay được cho lệnh !), qua một bộ thực thi riêng, bash-executor.ts (agent-session.ts:3901–3933). Khác biệt chính so với tool là output được làm sạch cho người đọc:

executeBashWithOperations(): bỏ mã ANSI và ký tự nhị phân, giữ bộ đệm cuốn
pi/packages/coding-agent/src/core/bash-executor.tsdòng 76–104TypeScript
const appendText = (rawText: string) => {	// Sanitize: strip ANSI, replace binary garbage, normalize newlines	const text = sanitizeBinaryOutput(stripAnsi(rawText)).replace(/\r/g, "");	if (!text) {		return;	}	// Start writing to temp file if exceeds threshold	if (totalBytes > DEFAULT_MAX_BYTES) {		ensureTempFile();	}	if (tempFileStream) {		tempFileStream.write(text);	}	// Keep rolling buffer	outputChunks.push(text);	outputBytes += text.length;	while (outputBytes > maxOutputBytes && outputChunks.length > 1) {		const removed = outputChunks.shift()!;		outputBytes -= removed.length;	}	// Stream to callback	if (options?.onChunk) {		options.onChunk(text);	}};

Kết quả thành một BashExecutionMessage ghi vào session (agent-session.ts:3939–3960). Ở request kế tiếp, convertToLlm() đổi nó thành một tin nhắn user dạng “Ran `git status`” kèm output trong khối code (messages.ts:82–98). Lệnh !! chạy y hệt nhưng bị loại khỏi context (messages.ts:152–156). Thời điểm ghi, và vì sao message bị hoãn khi agent đang chạy, thuộc về chương AgentSession.

System prompt được dựng thế nào#

Toàn bộ việc dựng prompt nằm trong system-prompt.ts, 229 dòng, gần như toàn hàm thuần (chỉ đường dẫn tài liệu là lấy từ nơi cài đặt): đầu vào là một đối tượng tuỳ chọn, đầu ra là các section văn bản. Đầu vào trông như sau:

BuildSystemPromptOptions: mọi thứ prompt cần biết
pi/packages/coding-agent/src/core/system-prompt.tsdòng 9–37TypeScript
export interface BuildSystemPromptOptions {	/** Custom system prompt (replaces the default prefix). */	customPrompt?: string;	/** Exact full prompt replacement set by a before_agent_start handler. */	forceSystemPrompt?: string;	/** Tools to include in prompt. Default: [read, bash, edit, write]. */	selectedTools?: string[];	/**	 * Selected tools whose declarations requests leave out (`prepareLoadout` hidden declarations).	 * They are reachable only through another tool, so the tool list and rules leave them out too.	 */	hiddenTools?: string[];	/** Optional one-line tool snippets keyed by tool name. */	toolSnippets?: Record<string, string>;	/** Guideline bullets contributed by each tool, keyed by tool name. */	toolGuidelines?: Record<string, string[]>;	/** Additional guideline bullets appended to the default system prompt rules. */	promptGuidelines?: string[];	/** Text appended from user configuration before project context, skills, and cwd. */	appendSystemPrompt?: string;	/** Additional XML-wrapped prompt sections keyed by tag name. */	sections?: Record<string, string>;	/** Working directory. */	cwd: string;	/** Pre-loaded context files. */	contextFiles?: Array<{ path: string; content: string }>;	/** Pre-loaded skills. */	skills?: Skill[];}
AgentSession dựng đối tượng này trong _rebuildSystemPrompt(), xem bên dưới. Extension đọc và sửa được nó trong sự kiện before_agent_start (chương Extensions).

Prompt không còn là một chuỗi dài mà là các section có tên. preamble là câu mở đầu không bọc thẻ; mọi section khác được bọc trong một thẻ cùng tên:

buildSystemPromptSections(): thứ tự và nội dung các section
pi/packages/coding-agent/src/core/system-prompt.tsdòng 150–161, 172–192TypeScript
const declaredTools = selectedTools.filter((name) => !hiddenTools.includes(name));const promptSections: Record<string, string> = {};if (customPrompt) {	promptSections.preamble = customPrompt;} else {	promptSections.preamble =		"You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.";	const visibleTools = declaredTools.filter((name) => !!toolSnippets[name]);	const tools =		visibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets[name]}`).join("\n") : "(none)";	promptSections.tools = `${tools}\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.`;	promptSections.rules = buildRules(declaredTools, toolGuidelines, promptGuidelines);if (appendSystemPrompt) promptSections.addendum = appendSystemPrompt;if (contextFiles.length > 0) promptSections.project_context = renderProjectContext(contextFiles);// 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;}promptSections.cwd = cwd.replace(/\\/g, "/");for (const [name, content] of Object.entries(customSections)) {	if (content) promptSections[name] = content;}const sections: SystemPromptSections = { preamble: promptSections.preamble };for (const [name, content] of Object.entries(promptSections)) {	if (name !== "preamble") sections[name] = `<${name}>\n${content}\n</${name}>`;}return sections;

Thứ tự cố định: preamble, tools, rules, docs, addendum, project_context, skills, cwd, rồi các section tuỳ biến. Vài điểm đáng chú ý:

  • Mục tools chỉ liệt kê tool có promptSnippet (dòng 157), kèm một câu nhắc rằng có thể còn tool khác. Mô hình vẫn thấy đủ tool qua phần khai báo tool của request; mục này chỉ là bản tóm tắt.
  • customPrompt thay cả bốn section đầu (dòng 152–153). Với --system-prompt hay SYSTEM.md, mất luôn danh sách tool, luật và mục tài liệu, nhưng vẫn giữ addendum, AGENTS.md, skills và thư mục làm việc.
  • Mục docs chỉ đường tới README, docs/ và examples/ của gói Pi đã cài, và dặn chỉ đọc khi người dùng hỏi về chính Pi (system-prompt.ts:162–169). Nhờ mục này, khi được nhờ viết extension cho chính Pi, mô hình biết tài liệu nằm đâu để đọc; Armin Ronacher coi đây là một trong những điểm mạnh của Pi (Pi: The Minimal Agent Within OpenClaw).
  • Không có ngày giờ. Mục cuối cùng là thư mục làm việc, đổi dấu \ thành / (dòng 183). Ngày hiện tại đã bị bỏ khỏi prompt từ 0.80.7 vì nó làm prompt cache trượt mỗi khi qua ngày mới (CHANGELOG.md:1444).

Mục rules được ghép từ guidelines của các tool đang khai báo, theo thứ tự tool, bỏ trùng, rồi thêm hai luật chung:

buildRules(): luật phụ thuộc tool nào đang bật
pi/packages/coding-agent/src/core/system-prompt.tsdòng 88–125TypeScript
function buildRules(	selectedTools: string[],	toolGuidelines: Record<string, string[]>,	promptGuidelines: string[],): string {	const rules: string[] = [];	const seen = new Set<string>();	const addRule = (rule: string): void => {		const normalized = rule.trim();		if (!normalized || seen.has(normalized)) return;		seen.add(normalized);		rules.push(normalized);	};	const hasBash = selectedTools.includes("bash");	const hasPowerShell = selectedTools.includes("powershell");	const hasGrep = selectedTools.includes("grep");	const hasFind = selectedTools.includes("find");	const hasLs = selectedTools.includes("ls");	if ((hasBash || hasPowerShell) && !hasGrep && !hasFind && !hasLs) {		if (hasBash && hasPowerShell) {			addRule("Use bash or PowerShell for file operations like listing, searching, and finding files");		} else if (hasPowerShell) {			addRule("Use PowerShell for file operations like listing, searching, and finding files");		} else {			addRule("Use bash for file operations like ls, rg, find");		}	}	for (const name of selectedTools) {		for (const rule of toolGuidelines[name] ?? []) addRule(rule);	}	for (const rule of promptGuidelines) addRule(rule);	addRule("Be concise in your responses");	addRule("Show file paths clearly when working with files");	return rules.map((rule) => `- ${rule}`).join("\n");}
Luật ở dòng 108–116 là luật duy nhất nhìn vào tổ hợp tool: chỉ khi có shell mà không có grep/find/ls mới bảo mô hình dùng shell để tìm file. Changelog 0.77.0 ghi lại lý do: prompt cũ khuyên dùng các tool tìm file kể cả khi chúng không bật (CHANGELOG.md:1998).

Prompt nằm trong transcript

Các section không chỉ được nối thành chuỗi. Chúng trở thành SystemMessage.sections, một message nằm ngay trong transcript:

SystemMessage của pi-ai: nội dung, section có tên, tool thêm và bớt
pi/packages/ai/src/types.tsdòng 554–570TypeScript
export interface SystemMessage {	role: "system";	/** Instruction text. On the leading message this is the base prompt; later, additional instructions. */	content: string | TextContent[];	/**	 * Named, ordered prompt sections rendered verbatim after `content`. The leading message	 * declares them; later messages replace sections by name, and `null` removes one. Keep	 * each section self-delimiting (a tag, a heading) so the model can relate an update to	 * the original. Avoid integer-like names; JSON objects reorder those.	 */	sections?: Record<string, string | null>;	/** Complete definitions of tools that become available at this point. */	toolsAdded?: Tool[];	/** Tools that stop being available at this point. */	toolsRemoved?: ToolReference[];	timestamp: number; // Unix timestamp in milliseconds}

Message system đầu tiên khai báo các section; các message system về sau chỉ thay section theo tên, null là xoá. getSystemMessageText() ghép content với các section bằng một dòng trống (pi-ai utils/text.ts:15–21). Khi prompt cần đổi giữa chừng (một tool vừa bật, một skill mới được nạp, AGENTS.md đổi sau /reload), AgentSession không viết lại message đầu tiên mà tính một bản vá:

diffSystemPromptSections(): chỉ gửi những section đã đổi
pi/packages/coding-agent/src/core/system-prompt.tsdòng 217–229TypeScript
export function diffSystemPromptSections(	previous: Record<string, string | null>,	current: SystemPromptSections,): Record<string, string | null> | undefined {	const patch: Record<string, string | null> = {};	for (const [name, text] of Object.entries(current)) {		if (previous[name] !== text) patch[name] = text;	}	for (const name of Object.keys(previous)) {		if (current[name] === undefined) patch[name] = null;	}	return Object.keys(patch).length > 0 ? patch : undefined;}

Bản vá được nối vào cuối transcript dưới dạng một message system mới (agent-session.ts:1758–1770). Lý do là prompt cache (chương Compaction có phần nói kỹ): cache khớp theo tiền tố chính xác, nên sửa message đầu tiên làm trượt cache của toàn bộ hội thoại, còn nối thêm ở cuối thì giữ nguyên tiền tố. Với mô hình hỗ trợ message system giữa hội thoại, provider nhận bản vá có tên section (utils/text.ts:28–40). Tính năng có từ 0.86.0 (CHANGELOG.md:609); cách nó đi qua session và resume thuộc chương AgentSession và chương Pi 1.0.

Còn một đường thứ ba: extension trả về systemPrompt trong before_agent_start sẽ đặt forceSystemPrompt, một chuỗi đóng kín thay toàn bộ prompt, không có section (system-prompt.ts:199–205).

Ai gom đầu vào

_rebuildSystemPrompt(): tool, SYSTEM.md, APPEND_SYSTEM.md, skills và AGENTS.md
pi/packages/coding-agent/src/core/agent-session.tsdòng 1720–1746TypeScript
private _rebuildSystemPrompt(toolNames: string[]): void {	const validToolNames = toolNames.filter((name) => this._toolRegistry.has(name));	const toolSnippets: Record<string, string> = {};	for (const name of this._toolRegistry.keys()) {		const snippet = this._toolPromptSnippets.get(name);		// Tools without a snippet are not listed.		if (snippet) toolSnippets[name] = snippet;	}	const loaderSystemPrompt = this._resourceLoader.getSystemPrompt();	const loaderAppendSystemPrompt = this._resourceLoader.getAppendSystemPrompt();	const appendSystemPrompt = loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : "";	const loadedSkills = this._resourceLoader.getSkills().skills;	const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;	this._baseSystemPromptOptions = normalizeBuildSystemPromptOptions({		cwd: this._cwd,		skills: loadedSkills,		contextFiles: loadedContextFiles,		customPrompt: loaderSystemPrompt,		appendSystemPrompt,		selectedTools: validToolNames,		hiddenTools: [...this._hiddenDeclarations],		toolSnippets,		toolGuidelines: Object.fromEntries(this._toolPromptGuidelines),	});}

Snippet và guideline lấy từ registry tool (đã gom ở _refreshToolRegistry()); mọi thứ khác lấy từ ResourceLoader, chủ đề của mục kế tiếp. Hàm chạy lại mỗi khi bộ tool đổi hay tài nguyên được nạp lại.

AGENTS.md, SYSTEM.md và skills#

Context file

Context file là file hướng dẫn của dự án mà Pi chèn vào prompt. Mỗi thư mục đóng góp tối đa một file, theo thứ tự ưu tiên cố định:

Mỗi thư mục một context file: AGENTS.override.md thắng, CLAUDE.md đứng cuối
pi/packages/coding-agent/src/core/resource-loader.tsdòng 184–203TypeScript
function loadContextFileFromDir(dir: string): { path: string; content: string } | null {	const candidates = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];	for (const filename of candidates) {		const filePath = join(dir, filename);		if (existsSync(filePath)) {			try {				if (!statSync(filePath).isFile()) {					continue;				}				return {					path: filePath,					content: stripBom(readFileSync(filePath, "utf-8")),				};			} catch (error) {				console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));			}		}	}	return null;}

Thư mục nào có cả AGENTS.md lẫn CLAUDE.md thì chỉ AGENTS.md được nạp. Các thư mục được duyệt như sau:

loadProjectContextFiles(): file toàn cục trước, rồi từ gốc ổ đĩa xuống cwd
pi/packages/coding-agent/src/core/resource-loader.tsdòng 239–246, 251–273TypeScript
const contextFiles: Array<{ path: string; content: string }> = [];const seenPaths = new Set<string>();const globalContext = loadContextFileFromDir(resolvedAgentDir);if (globalContext) {	contextFiles.push(globalContext);	seenPaths.add(globalContext.path);}let currentDir = resolvedCwd;while (true) {	const contextFile = loadContextFileFromDir(currentDir);	// Compare the directory's real path, not the file's: a worktree AGENTS.md that	// symlinks to the main repo's file resolves to the shadowed path itself.	const isShadowed =		shadowedContextFile !== undefined &&		contextFile !== null &&		join(canonicalizePath(currentDir), basename(contextFile.path)) === shadowedContextFile;	if (contextFile && !isShadowed && !seenPaths.has(contextFile.path)) {		ancestorContextFiles.unshift(contextFile);		seenPaths.add(contextFile.path);	}	const parentDir = dirname(currentDir);	if (parentDir === currentDir) break;	currentDir = parentDir;}contextFiles.push(...ancestorContextFiles);return contextFiles;

File toàn cục trong ~/.pi/agent/ đứng đầu. Sau đó vòng lặp đi từ thư mục làm việc lên tới gốc hệ thống file; unshift (dòng 262) đảo thứ tự, nên trong prompt file của thư mục xa nhất đứng trước, file gần nhất đứng sau cùng. Đường dẫn đã thấy thì bỏ qua. Riêng trường hợp một git worktree nằm lồng bên trong repo chính, bản AGENTS.md của repo chính bị bỏ để không áp cùng một hướng dẫn hai lần (resource-loader.ts:205–230). Mỗi file được bọc trong <project_instructions path="..."> (system-prompt.ts:79–86). --no-context-files (-nc) tắt toàn bộ việc này (resource-loader.ts:646–653).

SYSTEM.md, APPEND_SYSTEM.md và cờ dòng lệnh

--system-prompt <văn bản> trở thành customPrompt, thay bốn section đầu như đã nói ở trên; --append-system-prompt thêm vào section addendum và dùng được nhiều lần (cli/args.ts:121–125). Giá trị của cả hai có thể là đường dẫn: nếu file tồn tại thì Pi đọc nội dung file, không thì dùng nguyên chuỗi (resource-loader.ts:167–182). Không có cờ thì Pi tìm file:

SYSTEM.md và APPEND_SYSTEM.md: bản của dự án cần trust, bản toàn cục thì không
pi/packages/coding-agent/src/core/resource-loader.tsdòng 1213–1239TypeScript
private discoverSystemPromptFile(): string | undefined {	const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md");	if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) {		return projectPath;	}	const globalPath = join(this.agentDir, "SYSTEM.md");	if (existsSync(globalPath)) {		return globalPath;	}	return undefined;}private discoverAppendSystemPromptFile(): string | undefined {	const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md");	if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) {		return projectPath;	}	const globalPath = join(this.agentDir, "APPEND_SYSTEM.md");	if (existsSync(globalPath)) {		return globalPath;	}	return undefined;}

Khác với AGENTS.md, .pi/SYSTEM.md và .pi/APPEND_SYSTEM.md của dự án chỉ được dùng khi dự án đã được trust; bản trong ~/.pi/agent/ luôn được dùng. Cờ dòng lệnh thắng cả hai (resource-loader.ts:657–676).

Skills: chỉ tên và mô tả

Skill là một file SKILL.md chứa hướng dẫn cho một loại việc (cách tìm và nạp skill thuộc chương Extensions). Prompt không chứa nội dung skill, chỉ chứa danh mục:

formatSkillsForPrompt(): tên, mô tả, vị trí; nội dung nạp khi cần
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");}
Skill đặt disableModelInvocation không vào danh mục (dòng 359); người dùng vẫn gọi được nó bằng lệnh.

Mô hình thấy tên, mô tả và đường dẫn của từng skill; khi việc đang làm khớp với mô tả, nó tự đọc SKILL.md bằng read (hoặc bash). Danh mục chỉ xuất hiện khi một trong hai tool đọc đó tồn tại (system-prompt.ts:174–182). Đây là cùng một kiểu nạp lười như bên tool search: chi phí prompt của một skill là vài chục token cho mô tả, còn hướng dẫn dài vài nghìn token chỉ tốn khi thật sự dùng.

Ví dụ minh hoạ: khung của system prompt mặc định, với một AGENTS.md và một skill
[preamble: một câu nói mô hình là trợ lý lập trình trong harness pi]

<tools>
- read: [promptSnippet của read]
- bash: [promptSnippet của bash]
- edit: [promptSnippet của edit]
- write: [promptSnippet của write]

[câu nhắc: có thể còn tool tuỳ biến khác]
</tools>

<rules>
- [luật dùng bash để tìm file, vì grep/find/ls đang tắt]
- [guidelines của read, bash, edit, write]
- [hai luật chung: ngắn gọn, ghi rõ đường dẫn]
</rules>

<docs>
[đường dẫn README, docs/, examples/ của gói pi đã cài]
</docs>

<project_context>
[một câu dẫn]

<project_instructions path="/home/me/work/app/AGENTS.md">
[nội dung AGENTS.md]
</project_instructions>
</project_context>

<skills>
[cách nạp skill]
<available_skills>
  <skill>
    <name>release</name>
    <description>Cut a release of this repo</description>
    <location>/home/me/work/app/.pi/skills/release/SKILL.md</location>
  </skill>
</available_skills>
</skills>

<cwd>
/home/me/work/app
</cwd>
Văn bản thật của từng phần nằm trong các đoạn code ở trên; khung này chỉ cho thấy thứ tự và thẻ.

“Dưới 1.000 token”: điều gì còn đúng ở v1.4.0#

Bài blog tháng 11/2025 của Mario Zechner viết: “pi's system prompt and tool definitions together come in below 1000 tokens” (What I learned building an opinionated and minimal coding agent). Có thể đo lại ở v1.4.0 bằng chính các chuỗi trong source. Cách đo: ghép prompt mặc định (bốn tool, không AGENTS.md, không skill, cwd là /home/user/project, gói Pi cài ở một đường dẫn toàn cục dài 59 ký tự), tuần tự hoá khai báo bốn tool thành JSON kiểu Anthropic (name, description, input_schema), rồi đổi ký tự ra token bằng heuristic của chính Pi, số ký tự chia 4 (compaction.ts:294–298).

PhầnKý tự≈ token (ký tự/4)
preamble16943
<tools>33985
<rules>839210
<docs>1.319330
<cwd>318
System prompt2.705677
Khai báo read655164
Khai báo bash514129
Khai báo edit1.150288
Khai báo write401101
Khai báo tool2.725682
Tổng5.430≈ 1.360

Con số là ước lượng: tokenizer thật của mỗi provider đếm khác, và Pi tự gọi heuristic này là thận trọng, tức có xu hướng đếm dư. Nhưng kết luận đủ rõ: ở v1.4.0 prompt cùng tool mặc định không còn dưới 1.000 token, mà vào khoảng 1.200 đến 1.400. Điều vẫn đúng là bậc độ lớn: khoảng một nghìn token, không phải hàng chục nghìn. Phần tăng đến từ những thay đổi có lý do, đều có trong changelog:

  • edit nhận nhiều edit (0.63.0, 0.63.2). Khai báo của nó giờ lớn nhất trong bốn tool, cộng thêm bốn guideline trong <rules>.
  • Mục <docs> thành bản đồ tài liệu: liệt kê tài liệu theo chủ đề (extension, theme, skill, SDK, MCP, codemode...) để mô hình tự đọc khi được hỏi về Pi. Riêng mục này chiếm khoảng một phần tư tổng số.
  • Thẻ XML thay cho tiêu đề Markdown (CHANGELOG.md:2156, bản 0.75.0), để mô hình phân biệt ranh giới các phần rõ hơn.
  • Một số thứ bị bỏ để giữ cache: giờ hiện tại bị bỏ ở 0.58.0 (CHANGELOG.md:3316), ngày hiện tại bị bỏ ở 0.80.7. Prompt nhỏ đi một chút, và quan trọng hơn là không còn đổi theo thời gian.

Những gì giữ nguyên: bốn tool mặc định, grep/find/ls vẫn tắt, phần mở đầu vẫn là một câu, và AGENTS.md vẫn được nối vào sau prompt gốc. Trong một dự án thật, AGENTS.md và danh mục skill thường lớn hơn cả prompt gốc lẫn bốn khai báo tool cộng lại.

Trace: mô hình gọi edit#

Người dùng nhờ: “cho server đọc cổng từ biến PORT”. Mô hình đã read file src/server.ts ở lượt trước, giờ trả lời bằng một lời gọi tool:

Ví dụ minh hoạ: khối toolCall trong assistant message
{
  "type": "toolCall",
  "id": "toolu_01",
  "name": "edit",
  "arguments": {
    "path": "src/server.ts",
    "edits": [
      { "oldText": "const port = 3000;",
        "newText": "const port = Number(process.env.PORT ?? 3000);" }
    ]
  }
}
  1. Stream đối sốpi-ai ghép JSON của arguments từ từng mảnh khi stream (chương pi-ai). Giao diện tạo component cho lời gọi ngay từ mảnh đầu; khi đối số đủ, renderer của edit tính diff xem trước trên file thật. Chưa có gì được ghi.
  2. Vào batchAssistant message kết thúc. Vòng lặp lấy mọi khối toolCall, chọn chế độ song song (không tool nào đòi tuần tự) và phát tool_execution_start (agent-loop.ts:606–614).
  3. Chuẩn bịTìm tool theo tên, chạy prepareArguments, kiểm tra schema, gọi hook beforeToolCall. Mỗi bước hỏng đều thành một kết quả lỗi “ngay lập tức” mà tool không hề chạy (đoạn code bên dưới).
  4. Thực thiexecutePreparedToolCall() gọi AgentTool.execute(); lớp bọc thêm ctx rồi gọi ToolDefinition.execute(). Thời gian chạy đo bằng đồng hồ đơn điệu; exception bị bắt và thành kết quả lỗi (agent-loop.ts:831–867).
  5. Bên trong editĐăng ký vào hàng đợi của src/server.ts, kiểm tra quyền, đọc, tách BOM, chuẩn hoá LF. indexOf("const port = 3000;") thấy đúng một chỗ, đếm trùng ra 1, không chồng lấn. Áp edit, khôi phục kiểu xuống dòng, ghi file, tạo diff và patch.
  6. Sau khi chạyafterToolCall phát sự kiện tool_result cho extension, bên đó có thể thay content, details hay isError (agent-loop.ts:880–907; agent-session.ts:684).
  7. Báo kết quảtool_execution_end mang toàn bộ AgentToolResult; giao diện thay diff xem trước bằng details.diff thật. Rồi createToolResultMessage() dựng ToolResultMessage, phát message_start/message_end.
  8. Ghi sessionAgentSession nhận message_end và ghi message thành một SessionMessageEntry, giữ nguyên details (agent-session.ts:1158–1166).
  9. Request kế tiếpContext được dựng lại từ session; adapter của provider biến message thành khối tool_result chỉ có content và is_error. Mô hình đọc đúng một câu: “Successfully replaced 1 block(s) in src/server.ts.”

Bước 3 là nơi phần lớn lỗi dừng lại:

prepareToolCall(): không thấy tool, sai schema, bị chặn đều thành kết quả lỗi
pi/packages/agent/src/agent-loop.tsdòng 726–737, 755–765, 780–786TypeScript
const tool = tools.find((t) => t.name === toolCall.name);if (!tool) {	return {		kind: "immediate",		result: createErrorToolResult(`Tool ${toolCall.name} not found`),		isError: true,	};}try {	const preparedToolCall = prepareToolCallArguments(tool, toolCall);	const validatedArgs = validateToolArguments(tool, preparedToolCall);		if (beforeResult?.block) {			const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked");			if (beforeResult.terminate === true) {				result.terminate = true;			}			return {				kind: "immediate",				result,				isError: true,			};		}} catch (error) {	return {		kind: "immediate",		result: createErrorToolResult(error instanceof Error ? error.message : String(error)),		isError: true,	};}
Dòng 755 là chỗ một lời gọi bị extension chặn biến thành kết quả lỗi mang lý do chặn; cách extension chặn thuộc chương Extensions.

Lỗi schema do validateToolArguments() của pi-ai tạo. Hàm sao chép đối số, ép kiểu theo schema (chuỗi "5" thành số 5 cho timeout), kiểm tra, và nếu sai thì liệt kê từng lỗi kèm nguyên văn đối số đã nhận, để mô hình thấy chính xác mình gửi gì (pi-ai validation.ts:317–353).

Giả sử mô hình viết oldText là port thay vì cả dòng. Bước 5 khi đó throw: “Found 3 occurrences of the text in src/server.ts. The text must be unique. Please provide more context to make it unique.” Bước 4 bắt exception và trả một kết quả lỗi với đúng câu đó; còn giao diện thì đã hiện lỗi này trong khung lời gọi ngay từ bước 1, nhờ diff xem trước. Mô hình đọc lỗi và gọi lại với oldText dài hơn. Không có code “thử lại” nào trong harness: vòng lặp chỉ cần trả lỗi về đúng dạng.

AiNhận gì từ lời gọi này
Mô hìnhMột dòng text trong content và cờ lỗi
Giao diệnĐối số đang stream, diff xem trước, details.diff, isError, durationMs
File sessionToolResultMessage đầy đủ: content, details (diff, patch, firstChangedLine), isError, durationMs
Extensiontool_call với đối số đã kiểm tra; tool_result với content, details, isError

Quyết định thiết kế#

Bốn tool là đủ

Bài blog năm 2025 nói thẳng: “these four tools are all you need for an effective coding agent”, vì “models know how to use bash and have been trained on the read, write, and edit tools with similar input schemas” (Mario Zechner). Mọi việc khác (tìm kiếm, chạy test, git, xem process) đi qua bash với những chương trình mô hình đã quen. Mỗi tool thêm vào có hai cái giá: vài trăm token khai báo trong mọi request, và một chỗ có thể làm trượt prompt cache, vì khai báo tool nằm gần đầu prompt nên đổi bộ tool là đổi tiền tố (Prompt Caching In Agents). grep, find, ls vẫn có cho cấu hình không có shell, nhưng tắt theo mặc định.

Cũng bài đó có câu “Bash is all you need”, viết khi giải thích vì sao Pi không có bash chạy nền: muốn một tiến trình sống lâu thì dùng tmux. Ở v1.4.0 vẫn vậy: tool bash không có chế độ nền, không có timeout mặc định, và mỗi lời gọi chờ tiến trình kết thúc.

Không có hệ thống quyền

Pi không hỏi xác nhận trước mỗi lời gọi tool. Tài liệu bảo mật mở đầu bằng câu: Pi “can read, change, and execute files with the permissions of the account that started it, and it does not ask for approval before every tool call” (docs/security.md:3). Lý do là hộp thoại xác nhận không tạo ra ranh giới an toàn: an toàn đến từ việc giới hạn file, credential, tiến trình và mạng mà Pi chạm được, còn việc xem transcript hay review thay đổi thì không phải ranh giới (docs/security.md:7). Bài blog nói gắt hơn: các biện pháp bảo mật của agent khác “mostly security theater”, và “as soon as your agent can write code and run code, it's pretty much game over”.

Thay cho hộp thoại, tài liệu đưa ra các mức cô lập: chạy cả Pi trong container, Docker Sandboxes hay OpenShell, hoặc giữ Pi trên máy và chỉ đẩy tool vào một micro-VM Gondolin (docs/containerization.md:9–14). Phương án cuối dùng đúng các interface *Operations ở trên: extension Gondolin thay read, write, edit, bash, grep, find, ls bằng bản chạy trong VM (docs/containerization.md:181). Ai vẫn muốn hỏi xác nhận thì viết extension bắt sự kiện tool_call; repo có sẵn ví dụ permission-gate.ts (docs/extensions.md:390). Bộ --tools read,grep,find,ls thu hẹp khả năng, không phải sandbox: read vẫn đọc được mọi file mà tài khoản của bạn đọc được.

Kết quả hai phần, thông báo hành động

Tách content khỏi details là quyết định đã có từ thời bài blog 2025 (“splitting tool results into a portion handed to the LLM and a portion for UI display”). Nó cho phép edit trả mô hình một câu trong khi người dùng xem diff đầy đủ, và cho bash báo thông tin cắt bớt cho giao diện mà không tốn token. Cùng tinh thần đó, mọi giới hạn đều đi kèm một câu nói bước tiếp theo (offset=…, limit=…, “Full output: …”): harness không tóm tắt hay đoán hộ, nó để mô hình tự phân trang bằng chính các tool sẵn có.

Backend thay được

Mỗi tool nhận một interface *Operations với comment “Override these to delegate … to remote systems (for example SSH)” (ví dụ read.ts:44–55). Logic của tool (cắt bớt, khớp văn bản, thông báo lỗi) giữ nguyên; chỉ chỗ chạm vào hệ thống file và tiến trình được thay. Đây là cách SSH, container hay micro-VM được cắm vào mà không phải viết lại tool.

Cạm bẫy và trường hợp biên#

  • Lệnh không bao giờ tự dừng. bash không có timeout mặc định, nên npm run dev giữ lượt hiện tại cho tới khi người dùng huỷ. Chạy nền bằng & cũng chưa chắc thoát được: theo comment của waitForChildProcess(), một tiến trình con vẫn đang ghi vào pipe kế thừa sẽ giữ Pi tiếp tục đọc. Hãy chuyển output của tiến trình nền ra file (cmd > log 2>&1 &) hoặc dùng tmux.
  • Huỷ là SIGKILL. Hết giờ hay bị huỷ thì cả nhóm tiến trình nhận SIGKILL, không có cơ hội dọn dẹp. Tiến trình nào tự tách khỏi nhóm (daemon dùng setsid) thì thoát khỏi lệnh giết này.
  • Đường dẫn không bị giới hạn. Đường dẫn tuyệt đối, ~ và .. đều dùng được; thư mục làm việc chỉ là gốc cho đường dẫn tương đối (docs/security.md:19). Bộ tool chỉ đọc vẫn đọc được ~/.ssh.
  • bash không vào hàng đợi. Hàng đợi ghi chỉ bọc edit và write. Một lời gọi bash chạy sed -i song song với một edit trên cùng file vẫn có thể giẫm lên nhau.
  • Khớp mờ đổi cả dòng. Khi phải khớp mờ, các dòng bị edit chạm vào được viết lại ở dạng đã chuẩn hoá: mất khoảng trắng cuối dòng, nháy cong thành nháy thẳng. Ngược lại, đếm trùng luôn chạy trên dạng chuẩn hoá, nên một đoạn duy nhất khi so nguyên văn vẫn có thể bị báo trùng nếu sau chuẩn hoá nó giống một đoạn khác.
  • Mô hình chỉ thấy đuôi. Với một build log dài, lỗi đầu tiên thường nằm ở đầu, mà bash chỉ giữ 2.000 dòng cuối. Mô hình phải tự read file đầy đủ theo đường dẫn trong thông báo; file nằm trong thư mục tạm của hệ điều hành.
  • Thứ bạn thấy khác thứ mô hình thấy. Renderer bỏ mã màu ANSI và ký tự nhị phân trước khi vẽ (render-utils.ts:67), còn content gửi cho mô hình là output thô. Lệnh ép màu (FORCE_COLOR=1, --color=always) sẽ đổ escape code vào context dù màn hình trông sạch. Lệnh ! của người dùng thì được làm sạch trước khi vào context.
  • --system-prompt bỏ nhiều hơn bạn nghĩ. Prompt tuỳ biến thay cả mục tool, luật và tài liệu. Khai báo tool vẫn được gửi, nhưng các guideline như “edits[].oldText must match exactly” thì mất, trừ khi bạn tự viết lại.
  • Ảnh và mô hình không đọc ảnh. Với mô hình không nhận ảnh, read vẫn trả khối ảnh nhưng ghi chú rằng ảnh sẽ bị bỏ khỏi request; ảnh bị thu nhỏ thì toạ độ mô hình nói ra là toạ độ trên ảnh nhỏ, phải nhân với hệ số trong ghi chú.
  • Context file của repo lạ. AGENTS.md và CLAUDE.md nạp không cần trust, như đã nói ở trên. Đó là một đường prompt injection có sẵn.

Đọc code ở đâu#

Đường dẫn tính từ packages/coding-agent/, trừ khi ghi khác. Thứ tự gợi ý: từ định nghĩa, qua các tool, tới system prompt.

FileĐọc để hiểu
src/core/extensions/types.tsToolDefinition, ToolRenderContext: hợp đồng của mọi tool
packages/agent/src/types.tsAgentToolResult, AgentTool: thứ vòng lặp gọi và nhận về
packages/coding-agent/src/core/tools/index.ts, packages/coding-agent/src/core/tools/tool-definition-wrapper.tsTám tên tool, các bộ dựng sẵn, cách bọc definition
packages/coding-agent/src/core/tools/truncate.tsGiới hạn chung, cắt đầu và cắt đuôi
packages/coding-agent/src/core/tools/read.ts, packages/coding-agent/src/core/tools/path-utils.tsĐọc text và ảnh, phân giải đường dẫn
packages/coding-agent/src/core/tools/bash.ts, packages/coding-agent/src/core/tools/output-accumulator.ts, packages/coding-agent/src/utils/shell.tsSinh tiến trình, timeout, giết cây tiến trình, luồng output, file tạm
packages/coding-agent/src/core/tools/edit.ts, packages/coding-agent/src/core/tools/edit-diff.tsThay thế đúng văn bản, khớp mờ, diff
packages/coding-agent/src/core/tools/file-mutation-queue.ts, packages/coding-agent/src/core/tools/write.tsKhoá ghi theo file thật
packages/coding-agent/src/core/tools/grep.ts, packages/coding-agent/src/core/tools/find.ts, packages/coding-agent/src/core/tools/ls.tsBộ chỉ đọc qua ripgrep và fd
packages/coding-agent/src/core/tools/renderers/index.tsRenderer tách khỏi phần thực thi
packages/agent/src/agent-loop.ts (dòng 518–959)Một lời gọi tool đi từ chuẩn bị tới ToolResultMessage
packages/coding-agent/src/core/bash-executor.tsLệnh ! của người dùng
packages/coding-agent/src/core/system-prompt.tsCác section của prompt, luật, bản vá giữa hội thoại
src/core/resource-loader.ts (dòng 167–274, 646–676, 1213–1239)AGENTS.md, SYSTEM.md, APPEND_SYSTEM.md
src/core/agent-session.ts (dòng 1720–1770, 3555–3718)Registry tool và nơi gom đầu vào cho prompt
packages/coding-agent/docs/security.md, packages/coding-agent/docs/containerization.mdVì sao không có hệ thống quyền, và nên cô lập thế nào

Tóm tắt#

  • Tám tool có sẵn, bốn tool bật mặc định: read, bash, edit, write. Mọi việc khác đi qua bash.
  • Mỗi tool là một ToolDefinition có ba mặt: khai báo cho mô hình, đóng góp cho system prompt, renderer cho giao diện. Kết quả tách content (cho mô hình) khỏi details (cho giao diện và session).
  • Output bị cắt ở 2.000 dòng hoặc 50KB, kèm câu hướng dẫn lấy tiếp; bash giữ đuôi và lưu toàn bộ ra file tạm.
  • edit thay đúng văn bản: khớp nguyên văn trước, khớp mờ sau, bắt buộc duy nhất và không chồng lấn, giữ BOM và CRLF. Hàng đợi theo đường dẫn thật ngăn các lần ghi song song đè nhau.
  • System prompt là các section có tên, nằm trong transcript; đổi giữa chừng thì nối bản vá chứ không viết lại, để giữ prompt cache. Không còn ngày giờ trong prompt.
  • AGENTS.md/CLAUDE.md nạp từ thư mục toàn cục và mọi thư mục cha, không cần trust; skills chỉ góp tên, mô tả và vị trí.
  • Prompt và bốn khai báo tool mặc định giờ khoảng 1.300 token (ước lượng ký tự/4), không còn dưới 1.000 như năm 2025.
  • Không có hệ thống quyền: an toàn đến từ cô lập ở tầng hệ điều hành; muốn hỏi xác nhận thì viết extension.

10Compaction và tóm tắt nhánh#

Vai trò#

Mỗi lượt (turn) làm transcript dài thêm: tin nhắn người dùng, câu trả lời của mô hình, và nhất là kết quả tool. Cửa sổ ngữ cảnh (context window) của mô hình thì cố định, ví dụ 200.000 token. Một buổi làm việc thật, đọc vài chục file và chạy test nhiều lần, chạm giới hạn đó sau vài giờ; khi đó provider từ chối request với lỗi tràn ngữ cảnh. Compaction (nén ngữ cảnh) thay phần cũ của hội thoại bằng một bản tóm tắt có cấu trúc, giữ nguyên văn phần gần nhất, rồi để session chạy tiếp.

Ba lớp tham gia:

AgentSession
Quyết định khi nào nén: trước prompt, giữa hai lượt, sau lần chạy, khi tràn, khi người dùng gõ /compact. Gọi hook extension, ghi kết quả, phát sự kiện.
src/core/compaction/
Hàm thuần: ước lượng token, chọn điểm cắt, dựng prompt, gọi mô hình tóm tắt. “Pure functions for compaction logic. The session manager handles I/O” (compaction.ts:4–5).
SessionManager
Lưu CompactionEntry vào file JSONL và áp nó khi dựng lại context (chương Session).

Cùng thư mục còn có tóm tắt nhánh (branch summarization): khi rời một nhánh bằng /tree, Pi có thể tóm tắt nhánh bị bỏ lại và mang bản tóm tắt sang nhánh mới. Hai cơ chế dùng chung cách tuần tự hoá hội thoại, chung system prompt tóm tắt và chung cách theo dõi file.

Một chi tiết lịch sử: bài blog ngày 30/11/2025 của Mario Zechner viết “Missing compaction hasn't been a problem for me personally”; compaction xuất hiện bốn ngày sau, ở bản 0.12.7 (CHANGELOG.md:6131), với hai con số mặc định vẫn dùng tới hôm nay: giữ 20K token gần nhất, chừa 16K token (nay là 16.384). Pi Durable có cơ chế nén riêng (chương Durable: task); chương này chỉ nói về coding agent.

Mô hình tư duy#

 Nhánh đang hoạt động trước khi nén (mỗi ô là một entry, từ gốc tới lá)

  ┌────┬────┬────┬─ ··· ─┬─────┬─────┬─────┬─────┬─ ··· ─┬─────┐
  │ u1 │ a2 │ t3 │       │ u30 │ a31 │ t32 │ a33 │       │ t42 │
  └────┴────┴────┴─ ··· ─┴─────┴─────┴─────┴─────┴─ ··· ─┴─────┘
  └─── messagesToSummarize ───┘└ turn prefix ┘└── giữ lại, ~20K ──┘
                                                ↑ firstKeptEntryId

 Sau khi nén: thêm một entry compaction ở cuối, không xoá gì khỏi file

  ┌────┬─ ··· ─┬─────┬─ ··· ─┬─────┬─────┐
  │ u1 │       │ a33 │       │ t42 │ cmp │
  └────┴─ ··· ─┴─────┴─ ··· ─┴─────┴─────┘

 Context của request kế tiếp

  ┌──────────────────┬──────────────────┬─────┬─ ··· ─┬─────┐
  │ system (bản chụp) │ tóm tắt (vai user)│ a33 │       │ t42 │ + entry mới
  └──────────────────┴──────────────────┴─────┴─ ··· ─┴─────┘
  u: user, a: assistant, t: kết quả tool, cmp: CompactionEntry
Compaction là một entry ghi thêm. Phần bị tóm tắt vẫn nằm trong file, chỉ không còn được gửi cho mô hình.
Ngưỡng (threshold)
contextWindow - reserveTokens. Context vượt ngưỡng thì nén.
reserveTokens
Khoảng trống chừa lại, mặc định 16.384. Từ 1.4.0 nó chỉ quyết định khi nào bắt đầu nén.
keepRecentTokens
Ngân sách cho phần giữ nguyên văn, mặc định 20.000 token, “roughly 5 to 20 turns” theo bài How Compaction Works in Pi.
Điểm cắt (cut point), firstKeptEntryId
Entry đầu tiên được giữ. Mọi thứ trước nó (tính từ ranh giới của lần nén trước) được tóm tắt.
Lượt bị tách (split turn), turn prefix
Khi điểm cắt rơi vào giữa một lượt, phần đầu của lượt đó (từ tin nhắn user tới trước điểm cắt) được tóm tắt riêng.
Bản tóm tắt trước (previousSummary)
Lần nén sau không tóm tắt lại từ đầu mà cập nhật bản tóm tắt của lần trước.
Phép chiếu (projection)
Danh sách message mà buildSessionProjection() dựng từ nhánh đang hoạt động, đã áp compaction và context edit (chương Session).

Data model#

Ba con số cấu hình, kèm giá trị mặc định:

CompactionSettings và giá trị mặc định
pi/packages/coding-agent/src/core/compaction/compaction.tsdòng 120–130TypeScript
export interface CompactionSettings {	enabled: boolean;	reserveTokens: number;	keepRecentTokens: number;}export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {	enabled: true,	reserveTokens: 16384,	keepRecentTokens: 20000,};

Trong settings.json chúng nằm dưới khoá compaction; hai giá trị token còn có thể đặt riêng cho từng mô hình qua compaction.modelOverrides với khoá "provider/modelId". Mỗi giá trị được tra theo thứ tự: override của mô hình, setting thường, mặc định (settings-manager.ts:833–880; docs/compaction.md:439–463). enabled là setting toàn cục; tắt nó chỉ tắt nén tự động, /compact vẫn chạy.

Kết quả của một lần nén được ghi thành một entry trong session:

CompactionEntry: bản tóm tắt, ranh giới giữ lại, và bản chụp prompt
pi/packages/coding-agent/src/core/session-manager.tsdòng 91–104TypeScript
export interface CompactionEntry<T = unknown> extends SessionEntryBase {	type: "compaction";	summary: string;	firstKeptEntryId: string;	tokensBefore: number;	/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */	details?: T;	/** Usage from the LLM call(s) that generated this summary, if available */	usage?: Usage;	/** True if generated by an extension, undefined/false if pi-generated (backward compatible) */	fromHook?: boolean;	/** Complete prompt and tool state at this compaction boundary. */	systemMessage?: SystemMessage;}
  • summary là văn bản thuần, đọc được, chuyển được sang provider khác.
  • firstKeptEntryId trỏ vào entry đầu tiên được giữ nguyên văn. Nén kiểu “không giữ gì” (retain-none) ghi chính id của entry compaction (CHANGELOG.md:551).
  • tokensBefore là ước lượng kích thước context ngay trước khi nén.
  • details của bản nén mặc định là danh sách file đã đọc và đã sửa (CompactionDetails, compaction.ts:51–55); extension tự nén thì để gì vào đây cũng được.
  • fromHook: bản tóm tắt do extension tạo. Tên cũ, giữ để tương thích file session.
  • systemMessage: bản chụp prompt và bộ tool hiện hành tại ranh giới nén. Trường này không có trong khối CompactionEntry của docs/compaction.md (docs/compaction.md:144–156); code ở v1.4.0 có nó.
Ví dụ minh hoạ: một dòng compaction trong file JSONL (rút gọn, xuống dòng cho dễ đọc)
{"type":"compaction","id":"7f3a9c21","parentId":"c41e0b77",
 "timestamp":"2026-10-22T05:12:40.114Z",
 "summary":"## Goal\n- Thêm cache cho API /users\n...\n\n<read-files>\nsrc/api.ts\n</read-files>\n\n<modified-files>\nsrc/cache.ts\n</modified-files>",
 "firstKeptEntryId":"a33d10e5","tokensBefore":190350,
 "details":{"readFiles":["src/api.ts"],"modifiedFiles":["src/cache.ts"]},
 "usage":{"input":39120,"output":1650, ...},
 "systemMessage":{"role":"system","content":"","sections":{...},"toolsAdded":[...],"timestamp":1761109960114}}

Hai kiểu trung gian chảy giữa prepareCompaction() và compact(). CompactionPreparation là thứ extension nhận được trong hook session_before_compact:

CompactionPreparation: những gì sẽ bị tóm tắt, và vì sao
pi/packages/coding-agent/src/core/compaction/compaction.tsdòng 759–775TypeScript
export interface CompactionPreparation {	/** UUID of first entry to keep */	firstKeptEntryId: string;	/** Messages that will be summarized and discarded */	messagesToSummarize: AgentMessage[];	/** Messages that will be turned into turn prefix summary (if splitting) */	turnPrefixMessages: AgentMessage[];	/** Whether this is a split turn (cut point in middle of turn) */	isSplitTurn: boolean;	tokensBefore: number;	/** Summary from previous compaction, for iterative update */	previousSummary?: string;	/** File operations extracted from messagesToSummarize */	fileOps: FileOperations;	/** Compaction settions from settings.jsonl	*/	settings: CompactionSettings;}
Comment ở dòng 773 ghi “settings.jsonl” (và “settions”); setting thật nằm trong settings.json.
CompactionResult: thứ compact() hay một extension trả về
pi/packages/coding-agent/src/core/compaction/compaction.tsdòng 104–114TypeScript
/** Result from compact() - SessionManager adds uuid/parentUuid when saving */export interface CompactionResult<T = unknown> {	summary: string;	firstKeptEntryId: string;	tokensBefore: number;	estimatedTokensAfter?: number;	/** Usage from the LLM call(s) that generated this summary, if available */	usage?: Usage;	/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */	details?: T;}

Khi dựng context, entry compaction trở thành một CompactionSummaryMessage (vai compactionSummary, mang summary và tokensBefore, messages.ts:62–67), rồi convertToLlm() đổi nó thành một tin nhắn user trước khi gửi đi; mục ghi entry và dựng lại context đi chi tiết.

Khi nào compaction chạy#

Điều kiện tự động chỉ có một dòng:

shouldCompact(): vượt cửa sổ trừ phần chừa lại
pi/packages/coding-agent/src/core/compaction/compaction.tsdòng 264–270TypeScript
/** * Check if compaction should trigger based on context usage. */export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {	if (!settings.enabled) return false;	return contextTokens > contextWindow - settings.reserveTokens;}

Với mô hình 200.000 token và reserveTokens mặc định, Pi nén khi context vượt 183.616 token. Từ 1.4.0, reserveTokens chỉ quyết định thời điểm bắt đầu; request tóm tắt dùng giới hạn output của chính mô hình (CHANGELOG.md:33), và tham số reserveTokens của generateSummary() chỉ còn để giữ tương thích (compaction.ts:647).

Câu hỏi khó hơn là kiểm tra ở đâu. AgentSession kiểm tra ở bốn chỗ, cộng thêm lệnh thủ công:

  1. Trước một prompt mới_prepareRun() gọi _checkCompaction(lastAssistant, false) trước khi gửi tin nhắn của người dùng. Tham số false cho phép xét cả phản hồi bị huỷ: người dùng bấm Esc khi context đã gần đầy thì prompt kế tiếp vẫn được nén trước (agent-session.ts:1926–1932).
  2. Giữa hai lượtBatch tool xong, kết quả đã nối vào transcript, mô hình chưa được gọi lại: hook prepareNextTurnWithContext gọi _compactBeforeNextAssistantResponse() (agent-session.ts:904–905). Điểm kiểm tra này có từ 0.84.4, để một kết quả tool khổng lồ không bị gửi đi trước khi kịp nén (CHANGELOG.md:779).
  3. Sau khi lần chạy kết thúc_handlePostAgentRun() gọi _checkCompaction() với assistant message cuối; kết quả true (cần thử lại, hoặc còn tin nhắn chờ) khiến vòng ngoài gọi agent.continue() (agent-session.ts:2008–2010; vòng ngoài ở :1899–1905).
  4. Khi tràn ngữ cảnhCũng trong _checkCompaction(): lỗi tràn từ provider, tràn “im lặng” (provider trả thành công nhưng input vượt cửa sổ), hay phản hồi dừng vì length sớm hơn giới hạn output. Pi bỏ lần thử hỏng khỏi context, nén, rồi thử lại đúng một lần.
  5. Thủ công/compact [hướng dẫn] gọi AgentSession.compact() (interactive-mode.ts:7053–7057); hướng dẫn được nối vào prompt tóm tắt dưới dạng “Additional focus”.

Giữa hai lượt

_compactBeforeNextAssistantResponse(): nén trước khi gọi mô hình lần nữa
pi/packages/coding-agent/src/core/agent-session.tsdòng 776–785TypeScript
private async _compactBeforeNextAssistantResponse(context: AgentContext): Promise<AgentContext> {	const projection = this.sessionManager.buildSessionProjection();	// A virtual selection is checked in prepareRequest, against the model the request is routed to.	const model = this.model;	if (!model || isVirtualModel(model) || !this._exceedsCompactionThreshold(model, projection)) {		return { ...context, messages: projection.messages };	}	await this._runAutoCompaction("threshold", false);	return { ...context, messages: this.sessionManager.buildSessionProjection().messages };}
Kiểm tra dùng estimateProjectedContextTokens() trên phép chiếu hiện tại (agent-session.ts:767–774): usage của assistant message cuối cộng ước lượng các kết quả tool vừa thêm. Mô hình ảo (virtual model) được kiểm tra muộn hơn, trong prepareRequest, với mô hình thật mà request được định tuyến tới (:838–841).

Không có điểm kiểm tra nào cắt ngang một câu trả lời đang stream. Bản 0.17.0 từng có “proactive compaction”, huỷ lượt giữa chừng khi sắp chạm ngưỡng, và đã bỏ nó (CHANGELOG.md:5969). Các điểm kiểm tra hiện nay đều nằm ở ranh giới tự nhiên: trước prompt, giữa hai lượt, sau lần chạy.

Sau lần chạy: _checkCompaction()

_checkCompaction(): các điều kiện loại trừ, rồi nhánh tràn ngữ cảnh
pi/packages/coding-agent/src/core/agent-session.tsdòng 3011–3016, 3027–3035, 3063–3076, 3101–3106TypeScript
private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<boolean> {	const settings = this.settingsManager.getCompactionSettings(this.model);	if (!settings.enabled) return false;	// Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false	if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false;	// Skip compaction checks if this assistant message is older than the latest	// compaction boundary. This prevents a stale pre-compaction usage/error	// from retriggering compaction on the first prompt after compaction.	const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());	const assistantIsFromBeforeCompaction =		compactionEntry !== null && assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime();	if (assistantIsFromBeforeCompaction) {		return false;	}	const explicitOverflow = assistantMessage.stopReason === "error" && isContextOverflow(assistantMessage);	const contextOverflow =		sameModel &&		((explicitOverflow && assistantRetainedForExplicitRecovery) ||			(assistantUsageMatchesProjection && isContextOverflow(assistantMessage, contextWindow)));	const recoverableLength =		sameModel && assistantIsProjected && isRecoverableLength(assistantMessage, messageModel.maxTokens);	if (contextOverflow || recoverableLength) {		const willRetry = assistantMessage.stopReason !== "stop";		// Case 2: the response completed successfully. Compact, but do not retry because		// agent.continue() cannot continue from a completed assistant response.		if (!willRetry) {			return await this._runAutoCompaction("overflow", false);		// Persistently omit the selected final attempt before post-run recovery compaction.		this._overflowRecoveryAttempted = true;		this._omitRecoveryAttempt(assistantMessage);		const retry = await this._runAutoCompaction("overflow", willRetry);		if (retry) this._failedResponse = assistantMessage;		return retry;

Đọc theo thứ tự:

  • Các điều kiện loại trừ. Tắt nén tự động thì thôi. Phản hồi bị huỷ thì bỏ qua, trừ lần kiểm tra trước prompt. Phản hồi đến từ mô hình khác mô hình hiện tại thì không xét tràn (agent-session.ts:3018–3025): lỗi tràn của một mô hình 200K không có nghĩa gì sau khi đã đổi sang mô hình 1M. Và assistant message cũ hơn entry compaction mới nhất thì bỏ qua, vì usage của nó phản ánh context trước lần nén, dùng lại sẽ nén ngay lần nữa (dòng 3031–3034).
  • Nhận diện tràn. isContextOverflow() của pi-ai khớp thông báo lỗi với một bộ mẫu theo provider, cộng hai kiểu tràn không báo lỗi: phản hồi thành công mà input + cacheRead vượt cửa sổ, và phản hồi length với output bằng 0 khi input đã lấp đầy cửa sổ (pi-ai overflow.ts:142–176). isRecoverableLength() nhận phản hồi length dừng dưới giới hạn output gốc của mô hình (:184–186).
  • Tràn mà phản hồi đã xong (stopReason là "stop"): nén nhưng không thử lại, vì không thể “tiếp tục” một câu trả lời đã hoàn chỉnh (dòng 3075–3076).
  • Tràn thật: chỉ một lần thử. Pi đánh dấu đã thử, ghi context_edit loại lần thử hỏng cùng các kết quả tool của nó khỏi context (agent-session.ts:1239–1272), nén với lý do "overflow", rồi trả true để vòng ngoài gọi lại mô hình. Lần thứ hai vẫn tràn thì dừng với thông báo “Context overflow recovery failed after one compact-and-retry attempt…” (:3079–3082). Cờ “đã thử” được xoá khi có tin nhắn user mới hoặc một phản hồi thành công (:1123, :1173–1174).

Không tràn thì đến nhánh ngưỡng. Phần khó là lấy được một con số đáng tin:

Nhánh ngưỡng: con số nào được dùng để so
pi/packages/coding-agent/src/core/agent-session.tsdòng 3113–3121, 3137–3144TypeScript
let contextTokens: number;const projection = currentProjection;const hasContextEdits = projection.entries.some((entry) => entry.sourceEntry.type === "context_edit");const directContextTokens = assistantMessage.usage ? calculateContextTokens(assistantMessage.usage) : 0;if (hasContextEdits) {	contextTokens = estimateProjectedContextTokens(projection, branch).tokens;} else if (assistantMessage.stopReason === "error" || directContextTokens === 0) {	const messages = this.agent.state.messages;	const estimate = estimateContextTokens(messages);	contextTokens = estimate.tokens;} else {	contextTokens = directContextTokens;}if (shouldCompact(contextTokens, contextWindow, settings)) {	return await this._runAutoCompaction("threshold", false);}return false;

Có context edit trên nhánh thì usage cũ không còn khớp với thứ sẽ gửi đi, nên Pi ước lượng lại từ phép chiếu. Phản hồi lỗi (ví dụ 529 quá tải) hay usage toàn số 0 thì lấy usage hợp lệ gần nhất cộng ước lượng phần sau nó, và bỏ qua nếu usage đó có trước lần nén cuối (dòng 3122–3136). Còn lại thì dùng thẳng usage provider báo cho phản hồi vừa xong. Mục ước lượng token đi vào các hàm này.

Nén thủ công

AgentSession.compact(): huỷ việc đang chạy, rồi mới chuẩn bị
pi/packages/coding-agent/src/core/agent-session.tsdòng 2828–2852TypeScript
async compact(customInstructions?: string): Promise<CompactionResult> {	await this.abort();	this._compactionAbortController = new AbortController();	this._emit({ type: "compaction_start", reason: "manual" });	let fromExtension = false;	let cancelledByExtension = false;	try {		const model = this.model;		if (!model) {			throw new Error(formatNoModelSelectedMessage());		}		const settings = this.settingsManager.getCompactionSettings(model);		const pathEntries = this.sessionManager.getBranch();		const preparation = prepareCompaction(pathEntries, settings);		if (!preparation) {			// Check why we can't compact			const lastEntry = pathEntries[pathEntries.length - 1];			if (lastEntry?.type === "compaction") {				throw new Error("Already compacted");			}			throw new Error("Nothing to compact (session too small)");		}
Nén thủ công huỷ lần chạy hiện tại trước (dòng 2829) và không bao giờ tự chạy tiếp lượt bị huỷ. Hai lỗi thường gặp: “Already compacted” khi entry cuối đã là compaction, và “Nothing to compact (session too small)”.

Sau bước chuẩn bị, nén thủ công và nén tự động (_runAutoCompaction()) đi chung một đường: phát compaction_start, hỏi hook session_before_compact (có thể huỷ hoặc trả bản tóm tắt riêng), nếu không thì chạy bộ tóm tắt mặc định, ghi entry, dựng lại context, phát session_compact cho extension và compaction_end cho giao diện (agent-session.ts:3157–3271). Nén tự động xong mà còn tin nhắn steer hay follow-up đang chờ thì trả true để lần chạy tiếp tục giao chúng (dòng 3269–3271).

Ước lượng token#

Pi không chạy tokenizer. Mọi ước lượng dùng một heuristic: số ký tự chia 4.

estimateTokens(): ký tự chia 4, mỗi ảnh tính như 4.800 ký tự
pi/packages/coding-agent/src/core/compaction/compaction.tsdòng 276, 298–336TypeScript
const ESTIMATED_IMAGE_CHARS = 4800;export function estimateTokens(message: AgentMessage): number {	let chars = 0;	switch (message.role) {		case "system": {			const system = message as SystemMessage;			chars = estimateTextAndImageContentChars(system.content);			if (system.sections) {				for (const section of Object.values(system.sections)) {					if (section) chars += section.length;				}			}			if (system.toolsAdded) chars += JSON.stringify(system.toolsAdded).length;			return Math.ceil(chars / 4);		}		case "user": {			chars = estimateTextAndImageContentChars(				(message as { content: string | Array<{ type: string; text?: string }> }).content,			);			return Math.ceil(chars / 4);		}		case "assistant": {			const assistant = message as AssistantMessage;			for (const block of assistant.content) {				if (block.type === "text") {					chars += block.text.length;				} else if (block.type === "thinking") {					chars += block.thinking.length;				} else if (block.type === "toolCall") {					chars += block.name.length + JSON.stringify(block.arguments).length;				}			}			return Math.ceil(chars / 4);		}		case "custom":		case "toolResult": {			chars = estimateTextAndImageContentChars(message.content);			return Math.ceil(chars / 4);		}
Comment của hàm gọi heuristic này là “conservative (overestimates tokens)” (dòng 296). Lời gọi tool tính tên cộng JSON đối số; message system tính cả các section lẫn JSON của tool khai báo; một ảnh luôn tính 1.200 token dù kích thước thật thế nào.

Ước lượng thô chỉ dùng cho phần chưa có số liệu thật. Mỗi assistant message mang usage do provider báo, và usage đó đo chính xác cái request vừa gửi: input, output, cache đọc, cache ghi (compaction.ts:140–142). Phản hồi bị huỷ, bị lỗi hay có usage toàn số 0 bị bỏ qua (:148–161). Kích thước context vì thế được tính bằng usage hợp lệ cuối cùng cộng ước lượng của mọi message đến sau nó:

estimateContextTokens(): usage thật cho phần đầu, ký tự/4 cho phần đuôi
pi/packages/coding-agent/src/core/compaction/compaction.tsdòng 196–224TypeScript
export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate {	const usageInfo = getLastAssistantUsageInfo(messages);	if (!usageInfo) {		let estimated = 0;		for (const message of messages) {			estimated += estimateTokens(message);		}		return {			tokens: estimated,			usageTokens: 0,			trailingTokens: estimated,			lastUsageIndex: null,		};	}	const usageTokens = calculateContextTokens(usageInfo.usage);	let trailingTokens = 0;	for (let i = usageInfo.index + 1; i < messages.length; i++) {		trailingTokens += estimateTokens(messages[i]);	}	return {		tokens: usageTokens + trailingTokens,		usageTokens,		trailingTokens,		lastUsageIndex: usageInfo.index,	};}

Sau một kết quả tool 24.000 ký tự, con số là usage của lượt trước cộng 6.000. Phần “đuôi” thường nhỏ, nên sai số của heuristic ít ảnh hưởng. estimateProjectedContextTokens() thêm một lớp an toàn: nếu usage cuối cùng đến từ một entry nằm trước context edit hay compaction mới nhất, usage đó đo một context đã không còn, nên hàm bỏ nó và ước lượng toàn bộ phép chiếu bằng ký tự/4 (compaction.ts:226–262). Hàm này cho ra tokensBefore của mỗi lần nén.

Chọn điểm cắt#

Điểm cắt không được rơi vào bất kỳ đâu. Một kết quả tool phải đi ngay sau lời gọi tool sinh ra nó; provider từ chối một tool_result không có tool_use đi trước. Vì vậy chỉ một số loại message được làm điểm cắt:

isCutPointMessage(): mọi thứ trừ kết quả tool
pi/packages/coding-agent/src/core/compaction/compaction.tsdòng 351–364TypeScript
function isCutPointMessage(message: AgentMessage): boolean {	switch (message.role) {		case "user":		case "assistant":		case "bashExecution":		case "custom":		case "branchSummary":		case "compactionSummary":			return true;		case "toolResult":			return false;	}	return false;}
Cắt tại một assistant message có lời gọi tool thì các kết quả của nó nằm ngay sau và được giữ cùng. Một hàm anh em, isTurnStartMessage(), nhận diện message mở đầu một lượt: user, lệnh bash của người dùng, custom message và hai loại tóm tắt (:366–379).

Thuật toán đi ngược từ entry mới nhất, cộng dồn ước lượng token, dừng khi chạm keepRecentTokens:

findProjectedCutPoint(): đi ngược tới khi đủ ngân sách, rồi tìm điểm cắt hợp lệ gần nhất
pi/packages/coding-agent/src/core/compaction/compaction.tsdòng 795–816, 845–856TypeScript
const cutPoints: number[] = [];for (let i = startIndex; i < endIndex; i++) {	const entry = entries[i];	if (entry.sourceEntry.type !== "compaction" && entry.messages.some(isCutPointMessage)) cutPoints.push(i);}if (cutPoints.length === 0) {	return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };}let accumulatedTokens = 0;let exceededBudget = false;let cutIndex = cutPoints[0];for (let i = endIndex - 1; i >= startIndex; i--) {	const messageTokens = entries[i].messages.reduce((sum, message) => sum + estimateTokens(message), 0);	if (messageTokens === 0) continue;	accumulatedTokens += messageTokens;	if (accumulatedTokens >= keepRecentTokens) {		exceededBudget = true;		cutIndex = cutPoints.find((candidate) => candidate >= i) ?? cutPoints[cutPoints.length - 1];		break;	}}while (cutIndex > startIndex) {	const previous = entries[cutIndex - 1];	if (previous.sourceEntry.type === "compaction" || previous.messages.length > 0) break;	cutIndex--;}const startsTurn = isProjectedTurnStart(entries[cutIndex]);const turnStartIndex = startsTurn ? -1 : findProjectedTurnStartIndex(entries, cutIndex, startIndex);return {	firstKeptEntryIndex: cutIndex,	turnStartIndex,	isSplitTurn: !startsTurn && turnStartIndex !== -1,};

Ba chi tiết:

  • Tiến về phía mới, không lùi về phía cũ. Entry làm tổng vượt ngân sách thường là một kết quả tool, không cắt được. Pi chọn điểm cắt hợp lệ đầu tiên tại hoặc sau entry đó (dòng 813), nên phần giữ lại xoay quanh 20.000 token, và thường hụt một chút khi entry làm vượt ngân sách là một kết quả tool. Nếu chỉ riêng mấy kết quả tool cuối đã vượt ngân sách, điểm cắt là điểm hợp lệ cuối cùng, tức assistant message gọi các tool đó.
  • Kéo theo metadata. Các entry không sinh message (đổi mô hình, nhãn...) đứng ngay trước điểm cắt được giữ cùng (dòng 845–849), dừng ở ranh giới compaction.
  • Lượt bị tách. Nếu entry ở điểm cắt không mở đầu một lượt, tức là một assistant message giữa lượt, Pi tìm ngược về tin nhắn user mở đầu lượt đó (dòng 850–851). Một lượt dài (một yêu cầu dẫn tới vài chục lời gọi tool) một mình có thể lớn hơn 20.000 token, nên trường hợp này không hiếm.

Đoạn bị lược (dòng 818–843) xử lý một trường hợp hẹp của phục hồi sau tràn: nếu phía sau điểm cắt chỉ còn những lần thử đã bị loại khỏi context, điểm cắt được đẩy qua chúng để phần input chưa gửi vẫn được giữ nguyên văn. Hàm cũ findCutPoint(), làm việc trên entry thô thay vì phép chiếu, vẫn được export công khai (src/index.ts:41), nhưng Pi không còn gọi nó.

Chuẩn bị: prepareCompaction()

prepareCompaction(): ranh giới lần trước, điểm cắt, hai danh sách message
pi/packages/coding-agent/src/core/compaction/compaction.tsdòng 863–865, 872–901TypeScript
if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {	return undefined;}const prevCompactionIndex = projectedEntries.findIndex(	(entry) => entry.sourceEntry.type === "compaction" && entry.messages.length > 0,);let previousSummary: string | undefined;let boundaryStart = 0;if (prevCompactionIndex >= 0) {	previousSummary = (projectedEntries[prevCompactionIndex].sourceEntry as CompactionEntry).summary;	// The canonical projection has already selected the previous compaction's retained tail.	boundaryStart = prevCompactionIndex + 1;}const boundaryEnd = projectedEntries.length;const tokensBefore = estimateProjectedContextTokens(projection, pathEntries).tokens;const cutPoint = findProjectedCutPoint(projectedEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);const firstKeptEntry = projectedEntries[cutPoint.firstKeptEntryIndex]?.sourceEntry;if (!firstKeptEntry?.id) return undefined;const firstKeptEntryId = firstKeptEntry.id;const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;const messagesToSummarize = projectedEntries	.slice(boundaryStart, historyEnd)	.flatMap(getMessagesFromProjectedEntryForCompaction);const turnPrefixMessages = cutPoint.isSplitTurn	? projectedEntries			.slice(cutPoint.turnStartIndex, cutPoint.firstKeptEntryIndex)			.flatMap(getMessagesFromProjectedEntryForCompaction)	: [];if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) return undefined;
  • Entry cuối đã là compaction thì không có gì để làm (dòng 863).
  • Ranh giới của lần nén trước. Trong phép chiếu, compaction mới nhất đứng ở vị trí 0, theo sau là phần nó đã giữ lại. Việc tóm tắt lần này bắt đầu ngay sau nó (dòng 878–882), nên những message từng được giữ nguyên văn ở lần trước sẽ được tóm tắt ở lần này, cùng với bản tóm tắt cũ trong vai previousSummary (docs/compaction.md:83).
  • Hai danh sách. Không tách lượt: messagesToSummarize là mọi thứ từ ranh giới tới điểm cắt. Tách lượt: danh sách đó dừng ở đầu lượt, phần đầu lượt vào turnPrefixMessages. Message system và entry compaction không vào danh sách nào (:98–102), vì bản chụp prompt đi theo entry compaction.
  • Không có gì để tóm tắt thì trả undefined (dòng 901), thay vì sinh ra một bản tóm tắt rỗng.

Prompt tóm tắt và định dạng bản tóm tắt#

Bản tóm tắt được tạo bằng một request riêng, không phải bằng cách nhờ chính cuộc hội thoại tự tóm tắt mình. Request đó có system prompt riêng:

System prompt của mọi request tóm tắt
pi/packages/coding-agent/src/core/compaction/utils.tsdòng 161–163TypeScript
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;

và chỉ một tin nhắn user. Hội thoại cần tóm tắt được tuần tự hoá thành văn bản thay vì gửi dưới dạng message, để mô hình không coi đó là một cuộc trò chuyện cần đáp tiếp:

serializeConversation(): nhãn [User], [Assistant], [Assistant tool calls], [Tool result]
pi/packages/coding-agent/src/core/compaction/utils.tsdòng 120–155TypeScript
export function serializeConversation(messages: Message[]): string {	const parts: string[] = [];	for (const msg of messages) {		if (msg.role === "user") {			const content = contentText(msg.content, "");			if (content) parts.push(`[User]: ${content}`);		} else if (msg.role === "assistant") {			const toolCalls: string[] = [];			for (const block of msg.content) {				if (block.type === "toolCall") {					const args = block.arguments as Record<string, unknown>;					const argsStr = Object.entries(args)						.map(([k, v]) => `${k}=${JSON.stringify(v)}`)						.join(", ");					toolCalls.push(`${block.name}(${argsStr})`);				}			}			if (msg.content.some((block) => block.type === "text")) {				parts.push(`[Assistant]: ${contentText(msg.content)}`);			}			if (toolCalls.length > 0) {				parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);			}		} else if (msg.role === "toolResult") {			const content = contentText(msg.content, "");			if (content) {				parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`);			}		}	}	return parts.join("\n\n");}
Mỗi kết quả tool bị cắt còn 2.000 ký tự (TOOL_RESULT_MAX_CHARS, dòng 94), vì kết quả tool là phần lớn nhất của context mà bản tóm tắt không cần nguyên văn. Thinking bị bỏ hẳn từ 1.2.0 (CHANGELOG.md:135): thinking được phát lại thường không còn nằm trong context thật của mô hình, và có thể làm request tóm tắt lớn hơn chính context nó tóm tắt.

Prompt cho lần nén đầu tiên yêu cầu một định dạng cố định:

SUMMARIZATION_PROMPT: sáu mục, giữ nguyên đường dẫn, tên hàm, thông báo lỗi
pi/packages/coding-agent/src/core/compaction/compaction.tsdòng 507–538TypeScript
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.Use this EXACT format:## Goal[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]## Constraints & Preferences- [Any constraints, preferences, or requirements mentioned by user]- [Or "(none)" if none were mentioned]## Progress### Done- [x] [Completed tasks/changes]### In Progress- [ ] [Current work]### Blocked- [Issues preventing progress, if any]## Key Decisions- **[Decision]**: [Brief rationale]## Next Steps1. [Ordered list of what should happen next]## Critical Context- [Any data, examples, or references needed to continue]- [Or "(none)" if not applicable]Keep each section concise. Preserve exact file paths, function names, and error messages.`;

Định dạng này là một checkpoint cho một mô hình khác đọc (“another LLM will use to continue the work”): mục tiêu, ràng buộc của người dùng, tiến độ chia ba trạng thái, các quyết định kèm lý do, bước tiếp theo, và dữ liệu cần thiết. Câu cuối, giữ nguyên đường dẫn, tên hàm và thông báo lỗi, nhắm vào đúng những thứ mà một bản tóm tắt tự do hay làm mất. Từ lần nén thứ hai, Pi gửi kèm bản tóm tắt cũ và đổi sang prompt cập nhật:

Prompt cập nhật: giữ cái cũ, thêm cái mới, chuyển việc đã xong sang Done
pi/packages/coding-agent/src/core/compaction/compaction.tsdòng 540–546, 577–579TypeScript
const UPDATE_SUMMARIZATION_INSTRUCTIONS = `Update the existing structured summary with new information. RULES:- PRESERVE all existing information from the previous summary- ADD new progress, decisions, and context from the new messages- UPDATE the Progress section: move items from "In Progress" to "Done" when completed- UPDATE "Next Steps" based on what was accomplished- PRESERVE exact file paths, function names, and error messages- If something is no longer relevant, you may remove itconst UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.${UPDATE_SUMMARIZATION_INSTRUCTIONS}`;
Phần lược đi (dòng 547–576) lặp lại sáu mục ở trên, mỗi mục kèm một chỉ dẫn “preserve” hay “update”.

Hàm ghép tất cả lại:

generateSummaryWithUsage(): <conversation>, <previous-summary>, rồi chỉ dẫn
pi/packages/coding-agent/src/core/compaction/compaction.tsdòng 713–729, 742–752TypeScript
// Use update prompt if we have a previous summary, otherwise initial promptlet basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;if (customInstructions) {	basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;}// Serialize conversation to text so model doesn't try to continue it// Convert to LLM messages first (handles custom types like bashExecution, custom, etc.)const llmMessages = convertToLlm(currentMessages);const conversationText = serializeConversation(llmMessages);// Build the prompt with conversation wrapped in tagslet promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;if (previousSummary) {	promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;}promptText += basePrompt;const failure = getSummarizationFailure(response, "Summarization");if (failure) {	throw new Error(failure);}if (response.content.some((block) => block.type === "toolCall")) {	throw new Error("Summarization attempted to call a tool");}const textContent = contentText(response.content);return { text: textContent, usage: response.usage };

Thứ tự trong tin nhắn là hội thoại trong thẻ <conversation>, bản tóm tắt cũ trong <previous-summary>, rồi mới đến chỉ dẫn; hướng dẫn của /compact được nối cuối với tiền tố “Additional focus:”. Kết quả bị từ chối trong ba trường hợp: lỗi, dừng vì chạm giới hạn output (một bản tóm tắt dở dang không được thành checkpoint, compaction.ts:585–593), và mô hình cố gọi tool.

Mọi request tóm tắt, của compaction lẫn của tóm tắt nhánh, đi qua một chỗ:

completeSummarization(): không ghi cache, thử lại theo chính sách retry
pi/packages/coding-agent/src/core/compaction/compaction.tsdòng 626–637TypeScript
// Avoid cache writes for one-off summaries. Reuse caller-supplied routing when available;// callers without a session ID, including branch summaries, receive a fresh routing ID.const requestOptions: SimpleStreamOptions = {	...options,	cacheRetention: "none",	sessionId: options.sessionId ?? uuidv7(),};const produce = async (): Promise<AssistantMessage> =>	streamFn		? (await streamFn(model, context, requestOptions)).result()		: completeSimple(model, context, requestOptions);return retryAssistantCall(produce, retry, requestOptions.signal, callbacks);
cacheRetention: "none" vì một prompt tóm tắt dùng một lần sẽ không bao giờ được tái sử dụng; ghi cache cho nó chỉ tốn thêm tiền. sessionId là một id định tuyến mới cho mỗi request, trừ khi caller truyền vào; cả hai thay đổi này có từ bản sửa ở 0.82.0 (CHANGELOG.md:1260).

Mô hình tóm tắt là mô hình hiện tại của session, cùng mức suy nghĩ (thinking level) và cùng hàm stream (agent-session.ts:2783–2806; :572–609). Bài How Compaction Works in Pi nhấn mạnh rằng vì tóm tắt là một request riêng nên nó có thể dùng một mô hình khác, rẻ hơn; ở v1.4.0, muốn vậy thì viết extension, như ví dụ ở mục extension.

Lượt bị tách: hai bản tóm tắt

Khi điểm cắt rơi giữa một lượt, phần đầu lượt được tóm tắt bằng một prompt khác, ngắn hơn và nói rõ phần sau đã được lưu riêng:

TURN_PREFIX_SUMMARIZATION_PROMPT
pi/packages/coding-agent/src/core/compaction/compaction.tsdòng 929–942TypeScript
const TURN_PREFIX_SUMMARIZATION_PROMPT = `The messages above are earlier context from an ongoing conversation. Later messages are stored separately and do not need to be reconstructed.Create a concise checkpoint of the user's request and the progress shown above. This checkpoint will be placed before the later messages so the conversation can continue with the necessary context.## Original Request[What did the user ask for?]## Progress So Far- [Key decisions and work completed in these messages]## Context Needed to Continue- [Information from these messages needed to understand the later work]Only summarize information explicitly present above. Do not infer or recreate later messages.`;
Changelog 0.87.1 ghi rằng Claude Fable 5.1 từng từ chối tóm tắt phần đầu lượt, và bản sửa là tách rõ hội thoại khỏi chỉ dẫn, cùng chỉ dẫn hướng về việc tiếp tục (CHANGELOG.md:526). Hàm gọi prompt này đặt hai phần dưới hai tiêu đề # Conversation và # Instructions (compaction.ts:1078).

compact() gọi hai request lần lượt, không song song, để provider cục bộ chỉ phục vụ một request một lúc không trả lỗi 429 (CHANGELOG.md:1517), rồi ghép hai kết quả và nối danh sách file:

compact(): ghép bản tóm tắt lịch sử với bản tóm tắt đầu lượt, thêm danh sách file
pi/packages/coding-agent/src/core/compaction/compaction.tsdòng 1017–1019, 1042–1056TypeScript
	// Merge into single summary	summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`;	summaryUsage = historyUsage ? combineUsage(historyUsage, turnPrefixResult.usage) : turnPrefixResult.usage;// Compute file lists and append to summaryconst { readFiles, modifiedFiles } = computeFileLists(fileOps);summary += formatFileOperations(readFiles, modifiedFiles);if (!firstKeptEntryId) {	throw new Error("First kept entry has no UUID - session may need migration");}return {	summary,	firstKeptEntryId,	tokensBefore,	usage: summaryUsage,	details: { readFiles, modifiedFiles } as CompactionDetails,};

Không có lịch sử nào trước lượt bị tách (ví dụ cả session chỉ có một yêu cầu rất dài) thì phần lịch sử là bản tóm tắt cũ, hoặc chuỗi “No prior history.” (compaction.ts:980–983).

Theo dõi file đã đọc và đã sửa#

Bản tóm tắt do mô hình viết có thể bỏ sót một đường dẫn. Danh sách file thì không cần mô hình: Pi tự rút nó ra từ các lời gọi tool và nối vào cuối bản tóm tắt.

extractFileOpsFromMessage(): đọc đối số path của read, write, edit
pi/packages/coding-agent/src/core/compaction/utils.tsdòng 30–61TypeScript
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {	if (message.role === "toolResult") {		// Calls made from codemode scripts are recorded on the script's result.		for (const call of message.nestedCalls?.calls ?? []) addFileOp(call.name, call.arguments, fileOps);		return;	}	if (message.role !== "assistant") return;	if (!("content" in message) || !Array.isArray(message.content)) return;	for (const block of message.content) {		if (typeof block !== "object" || block === null) continue;		if (!("type" in block) || block.type !== "toolCall") continue;		if (!("arguments" in block) || !("name" in block)) continue;		addFileOp(block.name, block.arguments as Record<string, unknown> | undefined, fileOps);	}}function addFileOp(toolName: string, args: Record<string, unknown> | undefined, fileOps: FileOperations): void {	const path = typeof args?.path === "string" ? args.path : undefined;	if (!path) return;	switch (toolName) {		case "read":			fileOps.read.add(path);			break;		case "write":			fileOps.written.add(path);			break;		case "edit":			fileOps.edited.add(path);			break;	}}
Lời gọi tool từ script codemode không nằm trong assistant message mà được ghi trên kết quả của script (nestedCalls), nên hàm đọc cả chỗ đó (dòng 31–34).
Hai danh sách: chỉ đọc, và đã sửa
pi/packages/coding-agent/src/core/compaction/utils.tsdòng 67–87TypeScript
export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {	const modified = new Set([...fileOps.edited, ...fileOps.written]);	const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort();	const modifiedFiles = [...modified].sort();	return { readFiles: readOnly, modifiedFiles };}/** * Format file operations as XML tags for summary. */export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {	const sections: string[] = [];	if (readFiles.length > 0) {		sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);	}	if (modifiedFiles.length > 0) {		sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);	}	if (sections.length === 0) return "";	return `\n\n${sections.join("\n\n")}`;}

File vừa đọc vừa sửa chỉ nằm trong danh sách “đã sửa”. Cả hai danh sách được sắp xếp, đặt trong thẻ <read-files> và <modified-files>, rồi ghi thêm vào details của entry. Lần nén sau gộp danh sách của lần trước vào (compaction.ts:60–88), nên sau mười lần nén, danh sách vẫn phủ cả session. Ngoại lệ là bản nén do extension tạo (fromHook): Pi không biết details của nó có dạng gì nên không đọc (dòng 70).

Giới hạn rõ ràng của cách này: chỉ ba tool read, write, edit được tính, theo đối số path. File sửa bằng sed -i hay git checkout qua bash, hoặc file chỉ được tìm thấy bằng grep, không xuất hiện trong danh sách.

Ghi entry và dựng lại context#

Có bản tóm tắt rồi, AgentSession gọi appendCompaction():

appendCompaction(): chụp prompt hiện hành, ghi entry như mọi entry khác
pi/packages/coding-agent/src/core/session-manager.tsdòng 1260–1287TypeScript
/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */appendCompaction<T = unknown>(	summary: string,	firstKeptEntryId: string | null,	tokensBefore: number,	details?: T,	fromHook?: boolean,	usage?: Usage,): string {	const timestamp = new Date().toISOString();	const systemMessage = getCurrentSystemMessage(this.buildSessionProjection().messages);	const id = generateId(this.byId);	const entry: CompactionEntry<T> = {		type: "compaction",		id,		parentId: this.leafId,		timestamp,		summary,		firstKeptEntryId: firstKeptEntryId ?? id,		tokensBefore,		details,		usage,		fromHook,		...(systemMessage ? { systemMessage: { ...systemMessage, timestamp: new Date(timestamp).getTime() } } : {}),	};	this._appendEntry(entry);	return entry.id;}

Entry được ghi thêm như mọi entry khác: con của lá hiện tại, rồi trở thành lá mới. Không entry nào bị xoá hay sửa. Dòng 1270 chụp lại prompt và bộ tool hiện hành: getCurrentSystemMessage() của pi-ai phát lại mọi message system của phép chiếu (message đầu cộng các bản vá) thành một message duy nhất chứa đủ section và tool (pi-ai transcript.ts:68–95). Bản chụp cần thiết vì sau khi nén, message system đầu tiên của session nằm trong phần bị bỏ.

Lần dựng context kế tiếp áp entry này. buildContextEntries() chọn entry nào được chiếu thành message:

buildContextEntries(): compaction mới nhất, phần nó giữ lại, rồi mọi thứ sau nó
pi/packages/coding-agent/src/core/session-manager.tsdòng 476–512TypeScript
export function buildContextEntries(	entries: SessionEntry[],	leafId?: string | null,	byId?: Map<string, SessionEntry>,): SessionEntry[] {	const path = buildSessionPath(entries, leafId, byId);	let compaction: CompactionEntry | null = null;	for (const entry of path) {		if (entry.type === "compaction") {			compaction = entry;		}	}	if (!compaction) {		return path;	}	const compactionIdx = path.findIndex((entry) => entry.id === compaction.id);	if (compactionIdx < 0) {		return path;	}	const contextEntries: SessionEntry[] = [compaction];	let foundFirstKept = false;	for (let i = 0; i < compactionIdx; i++) {		const entry = path[i];		if (entry.id === compaction.firstKeptEntryId) {			foundFirstKept = true;		}		if (foundFirstKept && !(entry.type === "message" && entry.message.role === "system")) {			contextEntries.push(entry);		}	}	contextEntries.push(...path.slice(compactionIdx + 1));	return contextEntries;}

Thứ tự kết quả: entry compaction mới nhất đứng đầu; rồi các entry từ firstKeptEntryId tới trước entry compaction, trừ message system (bản chụp đã thay chúng); rồi mọi entry sau compaction. Bản thân entry compaction chiếu thành hai message, bản chụp prompt và bản tóm tắt (session-manager.ts:461–464); một compaction cũ hơn lỡ nằm trong phần được giữ thì không đóng góp gì (:558–564). Trước khi gửi, convertToLlm() bọc bản tóm tắt thành một tin nhắn user với lời dẫn cố định (messages.ts:176–183):

Lời dẫn mô hình đọc trước bản tóm tắt compaction và bản tóm tắt nhánh
pi/packages/coding-agent/src/core/messages.tsdòng 11–24TypeScript
export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:<summary>`;export const COMPACTION_SUMMARY_SUFFIX = `</summary>`;export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from:<summary>`;export const BRANCH_SUMMARY_SUFFIX = `</summary>`;

Ngay sau khi ghi, _refreshFinalizedContext() đặt agent.state.messages bằng phép chiếu mới (agent-session.ts:938–944), và estimatedTokensAfter được tính trên phép chiếu đó cho sự kiện compaction_end. Phần lịch sử bị tóm tắt vẫn nằm trong file, và /tree vẫn quay về được một entry trước lần nén. Cách phép chiếu xử lý nhánh, context edit và các loại entry khác thuộc chương Session.

Extension tuỳ biến hoặc thay compaction#

Trước mỗi lần nén, thủ công hay tự động, extension nhận sự kiện sau:

SessionBeforeCompactEvent: bản chuẩn bị, toàn bộ nhánh, lý do, có thử lại không
pi/packages/coding-agent/src/core/extensions/types.tsdòng 784–795TypeScript
/** Fired before context compaction (can be cancelled or customized) */export interface SessionBeforeCompactEvent {	type: "session_before_compact";	preparation: CompactionPreparation;	branchEntries: SessionEntry[];	customInstructions?: string;	/** What triggered the compaction: manual /compact, the context threshold, or context overflow recovery */	reason: "manual" | "threshold" | "overflow";	/** True when the aborted turn is retried after this compaction (overflow recovery) */	willRetry: boolean;	signal: AbortSignal;}
Handler trả { cancel: true } để huỷ, hoặc { compaction: CompactionResult } để thay bộ tóm tắt mặc định (types.ts:1511–1514; xử lý ở agent-session.ts:3184–3204).

Extension nhận đúng CompactionPreparation mà bộ mặc định sẽ dùng, nên có thể giữ nguyên điểm cắt và chỉ thay cách tóm tắt. Ví dụ đi kèm repo tóm tắt bằng Gemini Flash:

custom-compaction.ts: mô hình khác, cùng điểm cắt
pi/packages/coding-agent/examples/extensions/custom-compaction.tsdòng 21–35, 100–109TypeScript
pi.on("session_before_compact", async (event, ctx) => {	ctx.ui.notify("Custom compaction extension triggered", "info");	const { preparation, branchEntries: _, signal } = event;	const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, previousSummary } = preparation;	// Use Gemini Flash for summarization (cheaper/faster than most conversation models)	const model = ctx.modelRegistry.find("google", "gemini-2.5-flash");	if (!model) {		ctx.ui.notify(`Could not find Gemini Flash model, using default compaction`, "warning");		return;	}	// Combine all messages for full summary	const allMessages = [...messagesToSummarize, ...turnPrefixMessages];		// Return compaction content - SessionManager adds id/parentId		// Use firstKeptEntryId from preparation to keep recent messages		return {			compaction: {				summary,				firstKeptEntryId,				tokensBefore,				usage: response.usage,			},		};
Không tìm thấy mô hình, hay tóm tắt rỗng, hay lỗi, thì handler trả undefined và Pi dùng bộ mặc định.

Comment đầu file nói extension này “discards all old turns completely, keeping only the summary”, nhưng code trả về firstKeptEntryId của bản chuẩn bị (dòng 105), nên phần ~20.000 token gần nhất vẫn được giữ như bình thường. Khác biệt thật nằm ở chỗ khác: nó tóm tắt cả turnPrefixMessages trong cùng một request (dòng 35) và dùng prompt riêng. Muốn bỏ hết phần giữ lại thì phải trả một firstKeptEntryId khác, chẳng hạn kiểu retain-none.

Sau khi nén, extension nhận session_compact với entry đã lưu; nén thất bại hay bị huỷ thì nhận session_compact_failed (types.ts:797–821). Hướng dẫn đầy đủ cho người viết extension ở docs/compaction.md:293–383; API extension nói chung ở chương Extensions.

Tóm tắt nhánh khi rời nhánh qua /tree#

Session của Pi là một cây (chương Session). /tree cho người dùng nhảy về một entry cũ và đi tiếp theo hướng khác, ví dụ bỏ một hướng sửa lỗi không ra gì. Nhưng những gì học được trên nhánh bị bỏ (file nào liên quan, cách nào đã thử và hỏng) sẽ mất nếu nhánh mới không biết gì về nó. Khi chọn một entry khác, giao diện hỏi “Summarize branch?” với ba lựa chọn: không tóm tắt, tóm tắt, tóm tắt theo hướng dẫn riêng, trừ khi setting branchSummary.skipPrompt bật (interactive-mode.ts:5563–5569). Lựa chọn đi vào AgentSession.navigateTree().

Ví dụ minh hoạ: rời nhánh B–C–D để quay về nhánh E–F

  Trước:                         Sau khi điều hướng tới F, có tóm tắt:

        ┌─ B ─ C ─ D  ← lá cũ          ┌─ B ─ C ─ D
   A ───┤                         A ───┤
        └─ E ─ F                       └─ E ─ F ─ [S: tóm tắt B, C, D]  ← lá mới

  Tổ tiên chung: A.  Entry được tóm tắt: B, C, D.  S.fromId = D, S.parentId = F.
Bản tóm tắt được gắn ở vị trí đích, như con của F; nhánh cũ vẫn còn nguyên trong file.

Gom entry

collectEntriesForBranchSummary(): từ lá cũ ngược về tổ tiên chung
pi/packages/coding-agent/src/core/compaction/branch-summarization.tsdòng 118–145TypeScript
// Find common ancestor (deepest node that's on both paths)const oldPath = new Set(session.getBranch(oldLeafId).map((e) => e.id));const targetPath = session.getBranch(targetId);// targetPath is root-first, so iterate backwards to find deepest common ancestorlet commonAncestorId: string | null = null;for (let i = targetPath.length - 1; i >= 0; i--) {	if (oldPath.has(targetPath[i].id)) {		commonAncestorId = targetPath[i].id;		break;	}}// Collect entries from old leaf back to common ancestorconst entries: SessionEntry[] = [];let current: string | null = oldLeafId;while (current && current !== commonAncestorId) {	const entry = session.getEntry(current);	if (!entry) break;	entries.push(entry);	current = entry.parentId;}// Reverse to get chronological orderentries.reverse();return { entries, commonAncestorId };

Tổ tiên chung là entry sâu nhất nằm trên cả đường của lá cũ lẫn đường của đích. Khác với compaction, hàm này không dừng ở ranh giới compaction: một compaction trên nhánh bị bỏ cũng được gom, và bản tóm tắt của nó trở thành một phần nội dung cần tóm tắt.

prepareBranchEntries(): từ mới tới cũ, dừng ở ngân sách
pi/packages/coding-agent/src/core/compaction/branch-summarization.tsdòng 218–244TypeScript
// Second pass: walk from newest to oldest, adding messages until token budgetfor (let i = entries.length - 1; i >= 0; i--) {	const entry = entries[i];	const message = getMessageFromEntry(entry);	if (!message) continue;	// Extract file ops from assistant messages (tool calls)	extractFileOpsFromMessage(message, fileOps);	const tokens = estimateTokens(message);	// Check budget before adding	if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {		// If this is a summary entry, try to fit it anyway as it's important context		if (entry.type === "compaction" || entry.type === "branch_summary") {			if (totalTokens < tokenBudget * 0.9) {				messages.unshift(message);				totalTokens += tokens;			}		}		// Stop - we've hit the budget		break;	}	messages.unshift(message);	totalTokens += tokens;}
Ngân sách là contextWindow - reserveTokens của branchSummary (mặc định 16.384; contextWindow không rõ thì lấy 128.000, branch-summarization.ts:311–313). Nhánh quá dài thì phần cũ nhất bị bỏ, trừ các entry tóm tắt, được ưu tiên nhét vào nếu tổng còn dưới 90% ngân sách.

getMessageFromEntry() bỏ qua kết quả tool (“context is in assistant's tool call”) và các entry không mang nội dung hội thoại (:156–180). Danh sách file được gom từ mọi entry, kể cả phần vượt ngân sách, và từ các bản tóm tắt nhánh lồng nhau do chính Pi tạo (:200–216).

Prompt và kết quả

Lời dẫn và prompt của tóm tắt nhánh: năm mục, không có Critical Context
pi/packages/coding-agent/src/core/compaction/branch-summarization.tsdòng 253–285TypeScript
const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.Summary of that exploration:`;const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later.Use this EXACT format:## Goal[What was the user trying to accomplish in this branch?]## Constraints & Preferences- [Any constraints, preferences, or requirements mentioned]- [Or "(none)" if none were mentioned]## Progress### Done- [x] [Completed tasks/changes]### In Progress- [ ] [Work that was started but not finished]### Blocked- [Issues preventing progress, if any]## Key Decisions- **[Decision]**: [Brief rationale]## Next Steps1. [What should happen next to continue this work]Keep each section concise. Preserve exact file paths, function names, and error messages.`;

generateBranchSummary() dùng cùng system prompt tóm tắt, cùng serializeConversation() và cùng completeSummarization() như compaction. Hướng dẫn riêng của người dùng được nối thêm, hoặc thay hẳn prompt nếu replaceInstructions bật (:327–335). Kết quả được thêm lời dẫn “The user explored a different conversation branch before returning here.” ở đầu và danh sách file ở cuối (:365–372). Khác compaction ở hai điểm: request không truyền mức suy nghĩ, và kết quả lỗi được trả về dưới dạng giá trị ({ error }, { aborted }) thay vì throw.

Bản tóm tắt nằm ở đâu

navigateTree(): chọn lá mới, rồi gắn bản tóm tắt vào đó
pi/packages/coding-agent/src/core/agent-session.tsdòng 4152–4163, 4168–4177TypeScript
if (targetEntry.type === "message" && targetEntry.message.role === "user") {	// User message: leaf = parent (null if root), text goes to editor	newLeafId = targetEntry.parentId;	editorText = contentText(targetEntry.message.content, "");} else if (targetEntry.type === "custom_message") {	// Custom message: leaf = parent (null if root), text goes to editor	newLeafId = targetEntry.parentId;	editorText = contentText(targetEntry.content, "");} else {	// Non-user message: leaf = selected node	newLeafId = targetId;}if (summaryText) {	// Create summary at target position (can be null for root)	const summaryId = this.sessionManager.branchWithSummary(		newLeafId,		summaryText,		summaryDetails,		fromExtension,		summaryUsage,	);	summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;

Đích là một tin nhắn user thì lá mới là cha của nó, và nội dung tin nhắn được đưa vào editor để người dùng sửa rồi gửi lại; đích là entry khác thì lá mới chính là đích. Bản tóm tắt được gắn vào vị trí lá mới:

branchWithSummary(): con của đích, ghi nhớ lá cũ
pi/packages/coding-agent/src/core/session-manager.tsdòng 1595–1625TypeScript
/** * Start a new branch with a summary of the abandoned path. * Same as branch(), but also appends a branch_summary entry that captures * context from the abandoned conversation path. */branchWithSummary(	branchFromId: string | null,	summary: string,	details?: unknown,	fromHook?: boolean,	usage?: Usage,): string {	if (branchFromId !== null && !this.byId.has(branchFromId)) {		throw new Error(`Entry ${branchFromId} not found`);	}	const fromId = this.leafId ?? "root";	this.leafId = branchFromId;	const entry: BranchSummaryEntry = {		type: "branch_summary",		id: generateId(this.byId),		parentId: branchFromId,		timestamp: new Date().toISOString(),		fromId,		summary,		details,		usage,		fromHook,	};	this._appendEntry(entry);	return entry.id;}

Entry branch_summary có parentId là đích, fromId là lá cũ (hoặc "root"), rồi trở thành lá. Từ đó mọi request trên nhánh mới đều mang bản tóm tắt, dưới dạng tin nhắn user bắt đầu bằng “The following is a summary of a branch that this conversation came back from:” (messages.ts:170–175). Extension có thể huỷ việc điều hướng, thay bản tóm tắt hay đổi hướng dẫn qua session_before_tree (agent-session.ts:4084–4111).

Đánh đổi với prompt cache#

Provider tính rẻ hơn nhiều cho phần input trùng tiền tố với một request gần đây: đó là prompt cache. Cache khớp theo tiền tố token chính xác: “If a token changes in the middle, everything after that token is a different continuation” (Prompt Caching In Agents). Trong một session bình thường, mỗi request chỉ nối thêm vào cuối request trước, nên gần như toàn bộ input trúng cache; chỉ phần mới phải trả giá đầy đủ.

Compaction phá điều đó, một cách có chủ ý. Sau khi nén, request bắt đầu bằng bản chụp prompt, rồi bản tóm tắt, rồi phần giữ lại. Bản tóm tắt là văn bản mới, nên mọi thứ từ đó trở đi trượt cache, kể cả 20.000 token giữ nguyên văn: chúng giống hệt trước, nhưng đứng sau một tiền tố khác. Bài How Compaction Works in Pi nói đúng điều này: phần giữ lại phải được tính lại, và cache chỉ trở lại từ các request sau. Phần đầu của request (bản chụp prompt và tool) có thể vẫn trùng với request trước nếu prompt và bộ tool không đổi trong session.

Chi phí của một lần nén vì thế có hai phần: request tóm tắt, với input là hội thoại đã tuần tự hoá và không ghi cache; và request kế tiếp, phải xử lý lại toàn bộ phần sau bản chụp. Đổi lại, mọi request sau đó nhỏ đi nhiều lần.

Pi chọn trả chi phí đó một lần, ở những thời điểm rõ ràng, thay vì cắt tỉa context dần dần. Bài về prompt caching giải thích: xoá nội dung ở giữa làm đổi tiền tố ngay tại chỗ xoá, phần phía sau phải xử lý lại, và chi phí viết lại có thể vượt phần tiết kiệm được; kết quả tool cũ còn có thể chứa bằng chứng mà các quyết định sau dựa vào. Vì vậy Pi “prefers a stable, append-oriented transcript”, và trong thống kê session coi compaction là “a cache reset rather than a cache failure”. Hai lựa chọn đã gặp ở trên đi cùng hướng: request tóm tắt không ghi cache (docs/compaction.md:23), và ngưỡng mặc định nằm sát giới hạn cửa sổ, nên số lần nén, cũng là số lần mất cache, ở mức thấp.

Ví dụ có số liệu: một session vượt ngưỡng#

Một session dài với mô hình có cửa sổ 200.000 token và cấu hình mặc định. Mọi số liệu dưới đây là minh hoạ; số token của từng entry là ước lượng ký tự/4 như Pi tính, trừ usage do provider báo.

Ví dụ minh hoạ, bước 1: vượt ngưỡng giữa hai lượt

 Ngưỡng      = contextWindow - reserveTokens = 200.000 - 16.384 = 183.616

 e41 (assistant) vừa gọi bash("npm test"). Usage provider báo cho e41:
   input 3.120 + output 1.830 + cacheRead 176.900 + cacheWrite 2.450 = 184.300
 e42 (kết quả tool): output của npm test, 24.200 ký tự             ≈   6.050
                                                                  ─────────
 Trước khi gọi mô hình lần nữa (prepareNextTurnWithContext)       ≈ 190.350 > 183.616
 → _compactBeforeNextAssistantResponse() → _runAutoCompaction("threshold")

Usage của e41 đã được tính từ provider; chỉ e42 là ước lượng. Đây đúng là trường hợp mà điểm kiểm tra giữa hai lượt (từ 0.84.4) tồn tại để bắt: không có nó, 6.050 token output của npm test sẽ được gửi đi cùng request kế tiếp trước khi kịp nén. Tiếp theo, prepareCompaction() tìm điểm cắt, đi ngược từ entry mới nhất:

Ví dụ minh hoạ, bước 2: điểm cắt (keepRecentTokens = 20.000)

 entry  nội dung                                 ≈ token   cộng dồn
 e42    kết quả tool: npm test                     6.050      6.050
 e41    assistant: bash("npm test")                  110      6.160
 e40    kết quả tool: edit                            15      6.175
 e39    assistant: edit("src/cache.ts")              380      6.555
 e38    kết quả tool: read src/cache.ts            7.400     13.955
 e37    assistant: read("src/cache.ts")               90     14.045
 e36    user: "Giờ thêm cache cho /users"             40     14.085
 e35    assistant: "Đã sửa xong lỗi 500..."          450     14.535
 e34    kết quả tool: git diff                     3.900     18.435
 e33    assistant: bash("git diff")                   80     18.515
 e32    kết quả tool: read src/api.ts              5.200     23.715  ← vượt 20.000
 e31    assistant: read("src/api.ts")
 e30    user: "Sửa lỗi 500 ở /api/users"
 ...

 e32 là kết quả tool, không cắt được → điểm hợp lệ gần nhất phía sau: e33
 firstKeptEntryId = e33, phần giữ lại ≈ 18.515 token
 e33 là assistant giữa lượt → lượt bắt đầu ở e30 → isSplitTurn = true
 messagesToSummarize = e1..e29      turnPrefixMessages = e30, e31, e32
 tokensBefore = 190.350

Không có compaction nào trước đó, nên previousSummary rỗng và phần lịch sử dùng SUMMARIZATION_PROMPT. Vì lượt bị tách, compact() gửi hai request, lần lượt:

Ví dụ minh hoạ, bước 3: hai request tóm tắt (cacheRetention: "none")

 Request 1, lịch sử:   e1..e29 tuần tự hoá, mỗi kết quả tool cắt còn 2.000 ký tự
                       input ≈ 38.000, output ≈ 1.400
 Request 2, đầu lượt:  e30..e32 với TURN_PREFIX_SUMMARIZATION_PROMPT
                       input ≈ 1.000, output ≈ 250

 summary = [tóm tắt lịch sử]
           ---
           **Turn Context (split turn):**
           [tóm tắt đầu lượt]
           <read-files> ... </read-files>
           <modified-files> ... </modified-files>         ≈ 7.000 ký tự ≈ 1.750 token

Entry compaction được ghi, context được dựng lại, và lượt kế tiếp gửi đi một request nhỏ hơn tám lần:

Ví dụ minh hoạ, bước 4: context trước và sau

 Trước (request lẽ ra được gửi)         Sau (request thật sự được gửi)
 ┌──────────────────────────────┐       ┌──────────────────────────────┐
 │ system + tool        ≈  3.000│       │ system (bản chụp)    ≈  3.000│
 │ e1..e29              ≈163.500│       │ tóm tắt (vai user)   ≈  1.800│
 │ e30..e32             ≈  5.330│       │ e33..e42             ≈ 18.515│
 │ e33..e42             ≈ 18.515│       └──────────────────────────────┘
 └──────────────────────────────┘        estimatedTokensAfter ≈ 23.300
  tokensBefore = 190.350

 Prompt cache ở request kế tiếp: bản chụp có thể trúng (≈ 3.000 nếu prompt không đổi);
 ≈ 20.300 token còn lại là trượt và được ghi cache mới.

Cái giá của lần nén này: khoảng 39.000 token input và 1.650 token output cho hai request tóm tắt, cộng chừng 20.000 token phải xử lý lại ở request kế tiếp. Cái được: request kế tiếp và các request sau đó bắt đầu từ khoảng 23.000 token thay vì 190.000, và session còn khoảng 160.000 token để đi tiếp trước lần nén sau. Lần nén sau sẽ tóm tắt từ e33 trở đi, gửi kèm bản tóm tắt này trong <previous-summary>, và gộp danh sách file của lần này vào.

Quyết định thiết kế#

  • Bản tóm tắt là văn bản thuần, nằm trong session. Nó đọc được, sửa được, và mang sang provider khác được. Một số provider có compaction riêng phía server; với OpenAI đó là một khối mã hoá mà provider khác không đọc được, và “Pi never passes this sort of information to another provider” (The Session You Cannot Take With You). Compaction của Pi luôn chạy phía client, nên đổi mô hình giữa session vẫn mang theo được bản tóm tắt.
  • Giữ nguyên văn phần gần nhất. Tóm tắt thì mất chi tiết, mà chi tiết quan trọng nhất (file đang sửa, lỗi vừa gặp) thường nằm ở vài lượt cuối. 20.000 token là đủ cho “roughly 5 to 20 turns”.
  • Cập nhật thay vì tóm tắt lại từ đầu. Mỗi lần nén chỉ gửi phần mới cộng bản tóm tắt cũ, nên chi phí không tăng theo độ dài session, và prompt cập nhật dặn giữ mọi thứ đã có.
  • Request riêng, hội thoại tuần tự hoá thành văn bản. Mô hình tóm tắt có system prompt riêng, không thấy tool nào (CHANGELOG.md:838), và đọc hội thoại như một tài liệu chứ không như một cuộc trò chuyện để trả lời tiếp. Tách riêng cũng là thứ cho phép extension dùng một mô hình khác.
  • Thứ cần chính xác thì không giao cho mô hình. Danh sách file do code rút ra và nối vào; điểm cắt do code chọn, không bao giờ tách lời gọi tool khỏi kết quả của nó.
  • Nén ở ranh giới tự nhiên. Trước prompt, giữa hai lượt, sau lần chạy; không cắt ngang một câu trả lời đang stream (cách làm này đã bị bỏ từ 0.17.0). Tràn ngữ cảnh chỉ được thử phục hồi một lần, để không rơi vào vòng lặp nén rồi lại tràn.
  • Ghi thêm, không ghi đè. Compaction là một entry mới. Lịch sử đầy đủ còn nguyên trong file cho /tree, cho thống kê và cho extension đọc lịch sử; chỉ phép chiếu gửi cho mô hình là thay đổi.
  • Thay được. Mọi thứ từ điểm cắt tới prompt đều có thể thay qua session_before_compact, mà vẫn giữ cách ghi entry và dựng context của Pi.

Cạm bẫy và trường hợp biên#

  • Tắt compaction tự động là tắt luôn đường phục hồi khi tràn ngữ cảnh. _checkCompaction() thoát ngay khi enabled là false, trước cả nhánh xử lý lỗi tràn (agent-session.ts:3013). Khi đó lỗi “context too long” của provider kết thúc lần chạy; bạn phải tự gõ /compact, lệnh này vẫn chạy được.
  • Ước lượng token là thô và cố ý ước lượng dư. Khi không có usage thật của provider, Pi đếm khoảng bốn ký tự một token và tính mỗi ảnh như 4.800 ký tự (compaction.ts:276, compaction.ts:294–298). Với code nhiều ký hiệu hay văn bản không phải tiếng Anh, số thật có thể lệch đáng kể, nên compaction có thể chạy sớm hoặc muộn hơn bạn tính.
  • Mô hình tóm tắt không đọc hết output của tool. Khi tuần tự hoá hội thoại, mỗi kết quả tool bị cắt còn 2.000 ký tự đầu, còn phần suy nghĩ (thinking) bị bỏ hẳn (utils.ts:94, utils.ts:107–120). Một chi tiết nằm sâu trong một log dài có thể không lọt vào bản tóm tắt. Thứ thật sự cần nhớ nên được ghi ra file (một TODO.md, một PLAN.md) đúng như triết lý ban đầu của Pi.
  • Đổi sang mô hình có cửa sổ lớn hơn không kích hoạt compaction vì lỗi cũ. Lỗi tràn của mô hình trước bị bỏ qua khi tin nhắn đến từ mô hình khác, và mọi tin nhắn cũ hơn compaction mới nhất cũng không được xét lại (agent-session.ts:3018–3035).
  • Virtual model được xét theo mô hình thật mà request được định tuyến tới. Nếu router chọn một mô hình cửa sổ nhỏ hơn, compaction có thể chạy ngay trước request đó dù mô hình bạn chọn còn dư chỗ (agent-session.ts:835–838). Xem chương Pi 1.0.
  • Request ngay sau compaction đắt hơn bình thường. Prefix mới không có trong cache, nên phần giữ nguyên văn bị xử lý lại (mục Prompt cache). Đừng ngạc nhiên khi thấy footer báo trượt cache sau /compact.
  • Compaction không làm file session nhỏ lại. Nó chỉ nối thêm một entry; file vẫn lớn dần, và /tree vẫn cho quay về trước điểm cắt. Muốn một file gọn, dùng fork hoặc clone sang session mới (chương Session).

Đọc code ở đâu#

ĐọcĐể hiểu
docs/compaction.mdMô tả chính thức: khi nào nén, giữ lại gì, cấu hình ra sao.
DEFAULT_COMPACTION_SETTINGS, shouldCompact()Ngưỡng: cửa sổ ngữ cảnh trừ reserveTokens.
_checkCompaction(), _runAutoCompaction(), _compactBeforeNextAssistantResponse()Ba điểm kích hoạt trong AgentSession và đường phục hồi khi tràn.
findCutPoint(), prepareCompaction()Chọn điểm cắt theo keepRecentTokens, không tách tool call khỏi kết quả, xử lý lượt bị cắt đôi.
SUMMARIZATION_PROMPT, UPDATE_…, SUMMARIZATION_SYSTEM_PROMPTNguyên văn các prompt tóm tắt.
compact()Chạy tóm tắt (một hoặc hai request) và lắp kết quả.
extractFileOpsFromMessage(), serializeConversation()Danh sách file đọc/sửa và cách hội thoại được biến thành văn bản để tóm tắt.
CompactionEntryEntry được ghi vào session.
collectEntriesForBranchSummary(), generateBranchSummary()Tóm tắt nhánh khi rời đi bằng /tree.
examples/extensions/custom-compaction.tsThay compaction bằng cách của bạn qua session_before_compact.

Tóm tắt#

  • Compaction thay phần lịch sử cũ bằng một bản tóm tắt có cấu trúc, giữ nguyên văn khoảng keepRecentTokens (mặc định 20.000) token gần nhất, và kích hoạt khi ngữ cảnh vượt cửa sổ trừ reserveTokens (mặc định 16.384).
  • Nó chạy ở ranh giới tự nhiên: sau một lượt, trước request kế tiếp, khi provider báo tràn (thử một lần), hoặc khi bạn gõ /compact.
  • Bản tóm tắt là văn bản thuần trong một entry compaction; lần nén sau cập nhật nó thay vì viết lại, và danh sách file đọc/sửa do code tính chứ không do mô hình nhớ.
  • Không có gì bị xoá: phép chiếu ra ngữ cảnh mô hình bắt đầu từ bản tóm tắt và firstKeptEntryId, còn toàn bộ lịch sử vẫn ở trong file.
  • Tóm tắt nhánh dùng cùng bộ máy khi bạn rời một nhánh qua /tree; extension có thể thay cả hai.

11Extensions, skills, templates, themes, packages#

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á.

12Các chế độ chạy: interactive, print, JSON, RPC, SDK#

Vai trò: năm giao diện, một cơ chế#

Cùng một Pi được dùng theo năm cách. Một người gõ trong terminal (interactive). Một script chạy pi -p rồi đọc câu trả lời cuối (print). Một tiến trình đọc luồng sự kiện (event stream) có cấu trúc của một lần chạy (JSON). Một ứng dụng khác, viết bằng ngôn ngữ bất kỳ, điều khiển Pi lâu dài qua stdin/stdout (RPC). Hoặc một chương trình Node.js/Bun nhúng thẳng Pi vào tiến trình của nó (SDK). Tài liệu “How Pi Works” tóm lại quan hệ giữa chúng bằng một câu ngắn ở cuối mục Interfaces:

docs/how-pi-works.md, mục Interfaces
pi/packages/coding-agent/docs/how-pi-works.mddòng 33–39Markdown
## InterfacesInteractive mode renders session and agent events in the terminal. Print mode runs a prompt and writes the final response. JSON mode writes agent events as JSONL.RPC mode accepts JSONL commands on stdin and writes responses and events to stdout. The TypeScript SDK creates and controls agent sessions in process.All interfaces use the same agent and session mechanisms.

Câu đó đúng theo nghĩa đen trong code. Bốn chế độ CLI đều nhận cùng một đối tượng AgentSessionRuntime do main() dựng, và đều nói chuyện với cùng một AgentSession (chương AgentSession), vốn bọc cùng một Agent (chương pi-agent-core). SDK bỏ qua main() nhưng gọi đúng các hàm mà main() gọi. Khác biệt giữa các chế độ chỉ nằm ở bốn chỗ: đầu vào đi vào thế nào, sự kiện được vẽ hay tuần tự hoá ra sao, extension nhận giao diện người dùng nào, và tiến trình sống bao lâu. Chương này đi qua đúng bốn chỗ đó. Phần bên trong AgentSession thuộc về chương AgentSession, cách TUI vẽ thuộc về chương pi-tui, API của extension thuộc về chương Extensions.

Chế độKích hoạtHàm chạyVòng đờiExtension thấy
Interactivestdin và stdout đều là TTY, không có -p/--mode json|rpcnew InteractiveMode(runtime).run()tới khi người dùng thoátmode: "tui", hasUI: true
Print-p, hoặc stdin/stdout bị chuyển hướngrunPrintMode(runtime, { mode: "text" })một lần"print", hasUI: false
JSON--mode jsonrunPrintMode(runtime, { mode: "json" })một lần"json", hasUI: false
RPC--mode rpcrunRpcMode(runtime)tới khi stdin đóng"rpc", hasUI: true
SDKimport { createAgentSession }không qua main()do host quyếtmặc định "print", không UI, trừ khi host truyền vào

Mô hình tư duy: mode là lớp vỏ quanh AgentSessionRuntime#

Mọi lần chạy lệnh pi đi qua cùng một đường khởi động, và chỉ rẽ nhánh ở bước cuối:

pi [options] [@files] [messages]               cli.ts → main(args)
│
├─ lệnh con auth / install / remove / update / list / config / mcp  → chạy rồi thoát
├─ parseArgs(args) → Args                                     cli/args.ts
├─ resolveAppMode(parsed, stdin.isTTY, stdout.isTTY)
│     → "interactive" | "print" | "json" | "rpc"
├─ takeOverStdout()  nếu không phải interactive               core/output-guard.ts
├─ createSessionManager(parsed, …)   session mới, -c, -r, --session, --fork
├─ createRuntime: factory dựng services theo cwd + AgentSession
├─ createAgentSessionRuntime(createRuntime, { cwd, agentDir, sessionManager })
│
└─ rẽ nhánh theo appMode
     rpc          → runRpcMode(runtime)
     interactive  → new InteractiveMode(runtime, …).run()
     print, json  → runPrintMode(runtime, { mode: "text" | "json", … })
Đường khởi động trong main(). Ba hàm chạy ở cuối là “lớp vỏ”; mọi thứ phía trên là chung.
Mode
Đọc đầu vào, xuất sự kiện, cung cấp ExtensionUIContext. InteractiveMode, runPrintMode, runRpcMode, hoặc host SDK.
AgentSessionRuntime
Giữ session hiện tại và services theo cwd; thay session khi /new, /resume, /fork, import.
AgentSession
Một hội thoại: prompt, tin nhắn steer và follow-up, lưu session, extension, compaction (nén ngữ cảnh), retry (chương AgentSession).
Agent
Vòng lặp agent của pi-agent-core (chương pi-agent-core).
Runtime host
Tên tham số mà các mode dùng cho AgentSessionRuntime. Mode đọc lại runtimeHost.session mỗi khi session bị thay (qua callback rebind), vì đối tượng AgentSession cũ khi đó đã bị dispose.
Services
AgentSessionServices: cwd, agentDir, ModelRuntime, SettingsManager, ResourceLoader, cùng các chẩn đoán (diagnostics) thu được khi dựng.
Binding
Lời gọi session.bindExtensions(...) mà mỗi mode thực hiện để đưa cho extension một giao diện người dùng, một tên mode và các hành động như “tạo session mới”.
AgentSessionEvent
Sự kiện mà session.subscribe() phát: mọi AgentEvent của vòng lặp cộng với sự kiện cấp session như agent_settled, queue_update, compaction_start/end, auto_retry_start/end (agent-session.ts:196–237).
agent_end và agent_settled
agent_end đóng một lần chạy cấp thấp của Agent. agent_settled nghĩa là Pi không còn việc tự động nào cho lần chạy cấp session đó: không thử lại, không compaction rồi chạy tiếp, không tin nhắn chờ (json.md:48).

Từ lệnh pi tới một runtime#

File thực thi chỉ có sáu dòng:

cli.ts: điểm vào của lệnh pi
pi/packages/coding-agent/src/cli.tsdòng 1–6TypeScript
#!/usr/bin/env nodeimport { setupCli } from "./cli/setup.ts";import { main } from "./main.ts";setupCli();main(process.argv.slice(2));
setupCli() đặt tên tiến trình, biến môi trường PI_CODING_AGENT và AI_AGENT, tắt cảnh báo của Node và cấu hình HTTP dispatcher (setup.ts:4–13). Một điểm vào thứ hai, rpc-entry.ts, làm y hệt nhưng chèn sẵn --mode rpc (rpc-entry.ts:13).

main() xử lý các lệnh con trước khi đọc tuỳ chọn: pi auth ..., các lệnh gói (install, remove, update, list), pi config, pi mcp ... (main.ts:585–621). Chỉ khi không phải lệnh con, nó mới gọi parseArgs(). Bộ phân tích là một vòng for viết tay; hai tuỳ chọn quyết định chế độ trông như sau:

--mode và --print trong parseArgs()
pi/packages/coding-agent/src/cli/args.tsdòng 96–110, 177–183TypeScript
} else if (arg === "--mode") {	const mode = args[i + 1];	if (mode === undefined || mode.startsWith("-")) {		result.diagnostics.push({ type: "error", message: "--mode requires text, json, or rpc" });		continue;	}	i++;	if (mode !== "text" && mode !== "json" && mode !== "rpc") {		result.diagnostics.push({			type: "error",			message: `Invalid mode "${mode}". Valid values: text, json, rpc`,		});		continue;	}	result.mode = mode;} else if (arg === "--print" || arg === "-p") {	result.print = true;	const next = args[i + 1];	if (next !== undefined && !next.startsWith("@") && (!next.startsWith("-") || next.startsWith("---"))) {		result.messages.push(next);		i++;	}
Tuỳ chọn dài không biết tên không bị từ chối: chúng vào unknownFlags để extension đăng ký cờ riêng (args.ts:249–262); tuỳ chọn ngắn không biết tên thì báo lỗi.

Chế độ cuối cùng do một hàm nhỏ quyết định, theo thứ tự ưu tiên:

resolveAppMode(): rpc, json, rồi print nếu có -p hoặc không có TTY
pi/packages/coding-agent/src/main.tsdòng 112–123TypeScript
function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode {	if (parsed.mode === "rpc") {		return "rpc";	}	if (parsed.mode === "json") {		return "json";	}	if (parsed.print || !stdinIsTTY || !stdoutIsTTY) {		return "print";	}	return "interactive";}

Hai hệ quả đáng nhớ. Chỉ cần stdin hoặc stdout không phải terminal là Pi chọn print, nên git diff | pi "Review" và pi "..." > out.txt chạy được mà không cần -p. Và --mode text không ép chạy một lần: trong terminal, nó vẫn mở TUI (cli.md:41). Ngay sau khi chọn xong, mọi chế độ không tương tác chiếm lấy stdout:

Chiếm stdout cho các chế độ không tương tác; RPC từ chối @file
pi/packages/coding-agent/src/main.tsdòng 654–663TypeScript
let appMode = resolveAppMode(parsed, process.stdin.isTTY, process.stdout.isTTY);const shouldTakeOverStdout = appMode !== "interactive" && !isPlainRuntimeMetadataCommand(parsed);if (shouldTakeOverStdout) {	takeOverStdout();}if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) {	console.error(chalk.red("Error: @file arguments are not supported in RPC mode"));	process.exit(1);}

Tiếp theo là session. createSessionManager() chọn một session trong bộ nhớ (--no-session), fork (tách nhánh) một session có sẵn (--fork), mở theo đường dẫn hoặc id (--session, có hỏi fork nếu session thuộc dự án khác), hiện bộ chọn (-r), tiếp tục session gần nhất (-c), hoặc tạo mới (main.ts:358–449). Bước này đi trước mọi service, vì chú thích ở main.ts:686–690 giải thích: session được chọn có thể thuộc một thư mục làm việc khác, và settings, resource, provider (nhà cung cấp) của dự án phải được tính theo thư mục đó.

Rồi main() định nghĩa một factory, tức một hàm dựng runtime cho một cwd và một session manager bất kỳ:

createRuntime: services cho cwd, rồi AgentSession từ services
pi/packages/coding-agent/src/main.tsdòng 733–740, 751–757, 842–864TypeScript
const createRuntime: CreateAgentSessionRuntimeFactory = async ({	cwd,	agentDir,	sessionManager,	sessionStartEvent,	projectTrustContext,}) => {	const isInitialRuntime = sessionStartEvent === undefined;	const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });	const services = await createAgentSessionServices({		cwd,		agentDir,		settingsManager: runtimeSettingsManager,		modelRuntimeSignal: AbortSignal.timeout(15_000),		extensionFlagValues: parsed.unknownFlags,	const created = await createAgentSessionFromServices({		services,		sessionManager,		sessionStartEvent,		model: sessionOptions.model,		thinkingLevel: sessionOptions.thinkingLevel,		scopedModels: sessionOptions.scopedModels,		tools: sessionOptions.tools,		excludeTools: sessionOptions.excludeTools,		noTools: sessionOptions.noTools,		customTools: sessionOptions.customTools,	});	const cliThinkingOverride = parsed.thinking !== undefined || cliThinkingFromModel;	if (created.session.model && cliThinkingOverride) {		created.session.setThinkingLevel(created.session.thinkingLevel);	}	return {		...created,		services,		diagnostics,	};};

Factory đóng gói (closure) mọi đầu vào cố định của tiến trình (các cờ CLI, đường dẫn resource đã chuyển thành tuyệt đối, kho trust dự án) và mỗi lần được gọi thì: quyết định trust cho cwd đó, dựng SettingsManager, gọi createAgentSessionServices(), giải mô hình (model) và bộ tool từ cờ CLI (buildSessionOptions(), main.ts:451–554), đặt --api-key thành khoá tạm không lưu, rồi gọi createAgentSessionFromServices(), một lớp mỏng quanh createAgentSession() của SDK (agent-session-services.ts:214–233). Phần cuối của main() dùng factory đó và rẽ nhánh:

Dựng runtime, đọc stdin bị pipe, rẽ vào một trong ba mode
pi/packages/coding-agent/src/main.tsdòng 866–872, 894–901, 949–964, 985–992TypeScript
const runtime = await createAgentSessionRuntime(createRuntime, {	cwd: sessionManager.getCwd(),	agentDir,	sessionManager,});time("createAgentSessionRuntime");const { services, session, modelFallbackMessage } = runtime;// Read piped stdin content (if any) - skip for RPC mode which uses stdin for JSON-RPClet stdinContent: string | undefined;if (appMode !== "rpc") {	stdinContent = await readPipedStdin();	if (stdinContent !== undefined && appMode === "interactive") {		appMode = "print";	}}if (appMode === "rpc") {	printTimings();	await runRpcMode(runtime);} else if (appMode === "interactive") {	const interactiveMode = new InteractiveMode(runtime, {		migratedProviders,		startupDiagnostics,		modelFallbackMessage,		autoTrustOnReloadCwd,		initialMessage,		initialImages,		initialMessages: parsed.messages,		verbose: parsed.verbose,		tuiMode: parsed.tuiMode,		initialThemeSetting: parsed.useTheme,	});} else {	printTimings();	const exitCode = await runPrintMode(runtime, {		mode: toPrintOutputMode(appMode),		messages: parsed.messages,		initialMessage,		initialImages,	});

Nếu stdin bị pipe, toàn bộ nội dung được đọc trước (readPipedStdin) và nối vào đầu prompt đầu tiên cùng nội dung các file @path (initial-message.ts:20–43); một phiên đang định mở TUI sẽ chuyển sang print (dòng 898–900). RPC không đọc stdin ở bước này, vì stdin của nó chở lệnh. Trước khi rẽ nhánh, các chế độ không tương tác in chẩn đoán ra stderr, thoát với mã 1 nếu có lỗi, và đòi phải có mô hình (main.ts:915–931).

AgentSessionRuntime và services#

Vấn đề. Một session gắn với một thư mục làm việc: settings của dự án, các file AGENTS.md, extension của dự án, provider mà extension đó đăng ký, quyết định trust. Khi người dùng /resume một session của dự án khác, hoặc fork, mọi thứ đó phải được dựng lại cho thư mục mới, và các extension context cũ phải bị vô hiệu hoá. Nếu từng mode tự làm việc này, bốn chế độ sẽ có bốn cách thay session hơi khác nhau.

Giải pháp. Gom mọi thứ phụ thuộc cwd vào một gói services, và giao việc thay session cho một đối tượng duy nhất giữ factory:

AgentSessionServices: mọi thứ phụ thuộc cwd
pi/packages/coding-agent/src/core/agent-session-services.tsdòng 67–80TypeScript
/** * Coherent cwd-bound runtime services for one effective session cwd. * * This is infrastructure only. The AgentSession itself is created separately so * session options can be resolved against these services first. */export interface AgentSessionServices {	cwd: string;	agentDir: string;	modelRuntime: ModelRuntime;	settingsManager: SettingsManager;	resourceLoader: ResourceLoader;	diagnostics: AgentSessionRuntimeDiagnostic[];}

Kiểu của factory nhận cwd, agentDir, session manager, và tuỳ chọn một sessionStartEvent cho extension biết vì sao session bắt đầu (agent-session-runtime.ts:35–41). AgentSessionRuntime giữ session hiện tại, services và chính factory đó; mọi thao tác thay session đi theo cùng một khuôn:

switchSession(): hỏi extension, tháo session cũ, dựng runtime mới, rebind
pi/packages/coding-agent/src/core/agent-session-runtime.tsdòng 196–224TypeScript
async switchSession(	sessionPath: string,	options?: {		cwdOverride?: string;		withSession?: (ctx: ReplacedSessionContext) => Promise<void>;		projectTrustContextFactory?: (cwd: string) => ProjectTrustContext;	},): Promise<{ cancelled: boolean }> {	const beforeResult = await this.emitBeforeSwitch("resume", sessionPath);	if (beforeResult.cancelled) {		return beforeResult;	}	const previousSessionFile = this.session.sessionFile;	const sessionManager = SessionManager.open(sessionPath, undefined, options?.cwdOverride);	assertSessionCwdExists(sessionManager, this.cwd);	await this.teardownCurrent("resume", sessionManager.getSessionFile());	this.apply(		await this.createRuntime({			cwd: sessionManager.getCwd(),			agentDir: this.services.agentDir,			sessionManager,			sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },			projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()),		}),	);	await this.finishSessionReplacement(options?.withSession);	return { cancelled: false };}
  1. Hỏi extensionsession_before_switch có thể huỷ thao tác.
  2. Tháo session cũteardownCurrent(): abort() để lượt (turn) đang chạy được lưu, phát session_shutdown, rồi dispose().
  3. Dựng lạiGọi factory với cwd của session đích và session_start lý do "resume".
  4. RebindGọi callback mà mode đã đăng ký bằng setRebindSession().

Bước cuối là chỗ mode và runtime gặp nhau. Mỗi mode đăng ký một hàm để đăng ký lại listener và gắn lại extension vào session mới: print mode ở print-mode.ts:70–72, RPC ở rpc-mode.ts:313–315, interactive ở interactive-mode.ts:619–622. teardownCurrent() gọi abort() trước tiên, và chú thích giải thích lý do: lượt đang dở, kể cả kết quả tool, phải được lưu vào session cũ trước khi nó bị thay (agent-session-runtime.ts:167–178).

Mỗi chế độ gắn extension theo cách riêng#

Extension không biết mình đang chạy trong chế độ nào cho tới khi mode gọi bindExtensions(). Lời gọi này nhận một uiContext, một tên mode và các hành động lệnh, rồi mới phát session_start cho extension:

bindExtensions(): mode cung cấp UI, rồi session_start mới được phát
pi/packages/coding-agent/src/core/agent-session.tsdòng 3318–3342TypeScript
async bindExtensions(bindings: ExtensionBindings): Promise<void> {	if (bindings.uiContext !== undefined) {		this._extensionUIContext = bindings.uiContext;	}	if (bindings.mode !== undefined) {		this._extensionMode = bindings.mode;	}	if (bindings.commandContextActions !== undefined) {		this._extensionCommandContextActions = bindings.commandContextActions;	}	if (bindings.abortHandler !== undefined) {		this._extensionAbortHandler = bindings.abortHandler;	}	if (bindings.shutdownHandler !== undefined) {		this._extensionShutdownHandler = bindings.shutdownHandler;	}	if (bindings.onError !== undefined) {		this._extensionErrorListener = bindings.onError;	}	this._applyExtensionBindings(this._extensionRunner);	await this._extensionRunner.emit(this._sessionStartEvent);	this._extensionRunner.reportUnhandledMcpServers();	await this.extendResourcesFromExtensions(this._sessionStartEvent.reason === "reload" ? "reload" : "startup");}

Tên mode là một trong bốn giá trị "tui" | "rpc" | "json" | "print", và extension context có thêm cờ hasUI (extensions/types.ts:323–331). Khi mode không đưa uiContext, extension runner dùng một context rỗng:

noOpUIContext: hộp thoại trả về ngay giá trị mặc định; hasUI là false
pi/packages/coding-agent/src/core/extensions/runner.tsdòng 331–345, 572–575, 628–630TypeScript
const noOpUIContext: ExtensionUIContext = {	select: async () => undefined,	confirm: async () => false,	input: async () => undefined,	notify: () => {},	onTerminalInput: () => () => {},	setStatus: () => {},	setWorkingMessage: () => {},	setWorkingVisible: () => {},	setWorkingIndicator: () => {},	setHiddenThinkingLabel: () => {},	setWidget: () => {},	setFooter: () => {},	setHeader: () => {},	setTitle: () => {},	setUIContext(uiContext?: ExtensionUIContext, mode: ExtensionMode = "print"): void {		this.uiContext = uiContext ? this.wrapUIPromptContext(uiContext) : noOpUIContext;		this.mode = mode;	}	hasUI(): boolean {		return this.uiContext !== noOpUIContext;	}
Chế độmodeuiContexthasUIctx.ui.confirm(...) trả về
Interactive"tui"hộp thoại thật trong TUI (interactive-mode.ts:2633)truelựa chọn của người dùng
RPC"rpc"giao thức con extension_ui_*truecâu trả lời của client
JSON"json"không có, dùng noOpUIContextfalsefalse ngay lập tức
Print"print"không có, dùng noOpUIContextfalsefalse ngay lập tức
SDKmặc định "print" (agent-session.ts:461)do host truyềntuỳ hosttuỳ host

Vì vậy extension viết cẩn thận luôn xét ctx.hasUI. Ví dụ permission-gate.ts hỏi người dùng trước khi chạy rm -rf hay sudo, nhưng khi không có UI thì chặn luôn thay vì im lặng cho qua (permission-gate.ts:19–29).

Print mode#

Print mode là người dùng đơn giản nhất của runtime, và cũng là nơi dễ thấy nhất rằng một mode chỉ là lớp vỏ. runPrintMode() phục vụ cả -p (text) lẫn --mode json; hai chế độ chỉ khác nhau ở chỗ sự kiện có được ghi ra hay không. Mỗi lần gắn session, nó gắn extension với tên mode tương ứng và đăng ký listener:

rebindSession() của print mode: gắn extension, ghi sự kiện nếu là JSON
pi/packages/coding-agent/src/modes/print-mode.tsdòng 74–78, 101–119TypeScript
const rebindSession = async (): Promise<void> => {	session = runtimeHost.session;	await session.bindExtensions({		mode: mode === "json" ? "json" : "print",		commandContextActions: {		onError: (err) => {			console.error(`Extension error (${err.extensionPath}): ${err.error}`);		},	});	unsubscribe?.();	unsubscribeBackpressure?.();	unsubscribe = session.subscribe((event) => {		if (mode === "json") {			writeRawStdout(`${JSON.stringify(toJsonEvent(event))}\n`);		}	});	unsubscribeBackpressure =		mode === "json"			? session.agent.subscribe(async () => {					await waitForRawStdoutBackpressure();				})			: undefined;};

Phần thân chỉ là gửi các prompt lần lượt rồi, với text, in khối văn bản của assistant message cuối cùng:

Gửi prompt, in câu trả lời cuối, chọn mã thoát
pi/packages/coding-agent/src/modes/print-mode.tsdòng 121–160TypeScript
try {	if (mode === "json") {		const header = session.sessionManager.getHeader();		if (header) {			writeRawStdout(`${JSON.stringify(header)}\n`);		}	}	await rebindSession();	if (initialMessage) {		await session.prompt(initialMessage, { images: initialImages });	}	for (const message of messages) {		await session.prompt(message);	}	if (mode === "text") {		const state = session.state;		const lastMessage = state.messages[state.messages.length - 1];		if (lastMessage?.role === "assistant") {			const assistantMsg = lastMessage as AssistantMessage;			if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {				console.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`);				exitCode = 1;			} else {				for (const content of assistantMsg.content) {					if (content.type === "text") {						writeRawStdout(`${content.text}\n`);					}				}				if (assistantMsg.stopReason === "length") {					console.error("Response was truncated: the model reached its output token limit.");					exitCode = 1;				}			}		}	}

Mỗi await session.prompt() chỉ resolve khi lần chạy đã xong, kể cả các lần thử lại tự động (sdk.md:64), nên các prompt trong pi -p "a" "b" chạy nối tiếp nhau trên cùng một session. Với text, chỉ các khối text của message cuối được in, mỗi khối một dòng; khối thinking và lời gọi tool không bao giờ ra stdout. Mã thoát phản ánh stopReason: "error" hoặc "aborted" in lỗi ra stderr và trả 1; "length" vẫn in phần văn bản bị cụt nhưng cảnh báo và trả 1. JSON mode không đặt mã thoát theo kết quả của mô hình; người đọc phải tự xem sự kiện (cli-integration.md:46).

Phần còn lại của hàm là vệ sinh tiến trình: SIGTERM và SIGHUP giết các tiến trình con chạy tách rời mà Pi theo dõi, dispose runtime rồi thoát với mã 143 hoặc 129 (print-mode.ts:50–66); khối finally luôn dispose runtime (phát session_shutdown lý do "quit") và xả hết stdout trước khi trả về.

JSON event mode và stdout dành riêng#

pi --mode json "..." ghi một dòng header của session, rồi mỗi sự kiện session một dòng JSON, rồi thoát khi các prompt xong (json.md:9). Hình dạng sự kiện trên dây gần như y hệt AgentSessionEvent trong tiến trình, trừ một loại:

toJsonEvent(): message_update mất ảnh chụp tích luỹ
pi/packages/coding-agent/src/modes/json-event.tsdòng 4–18, 40–61TypeScript
type WithoutPartial<T> = T extends { partial: unknown } ? Omit<T, "partial"> : T;type ToJsonAssistantMessageEvent<T> = T extends { type: "toolcall_start"; partial: unknown }	? WithoutPartial<T> & { id: string; toolName: string }	: WithoutPartial<T>;type MessageUpdateEvent = Extract<AgentSessionEvent, { type: "message_update" }>;type JsonMessageUpdateEvent = {	type: "message_update";	usage: Usage;	assistantMessageEvent: ToJsonAssistantMessageEvent<MessageUpdateEvent["assistantMessageEvent"]>;};/** Session event shape emitted by the JSON and RPC stdout protocols. */export type JsonAgentSessionEvent = Exclude<AgentSessionEvent, { type: "message_update" }> | JsonMessageUpdateEvent;/** * Remove cumulative assistant snapshots from streaming wire events. * `message_start` provides the initial message, deltas build it, and * `message_end` provides the final authoritative message. Cumulative usage, * tool-call ids, and tool names remain available because their size is constant. */export function toJsonEvent(event: MessageUpdateEvent): JsonMessageUpdateEvent;export function toJsonEvent(event: AgentSessionEvent): JsonAgentSessionEvent;export function toJsonEvent(event: AgentSessionEvent): JsonAgentSessionEvent {	if (event.type !== "message_update") {		return event;	}	if (event.message.role !== "assistant") {		throw new Error("message_update message is not an assistant message");	}	return {		type: "message_update",		usage: event.message.usage,		assistantMessageEvent: toJsonAssistantMessageEvent(event.assistantMessageEvent),	};}

Vì sao. Trong tiến trình, mỗi message_update mang theo toàn bộ assistant message đang lớn dần, và mỗi sự kiện con của pi-ai còn mang partial là chính message đó. Với một câu trả lời N mảnh, tuần tự hoá nguyên si nghĩa là ghi ra tổng cộng cỡ N² ký tự. Từ 0.84.0, JSON và RPC chỉ ghi phần delta: message_start cho message ban đầu, các delta dựng dần, message_end là bản cuối có thẩm quyền (CHANGELOG.md:976). Hai thứ có kích thước cố định được giữ lại: usage tích luỹ, và id/toolName gắn vào toolcall_start vì bỏ partial thì chúng không còn chỗ nào khác.

Ví dụ minh hoạ: pi --mode json "Liệt kê file" (rút gọn)
{"type":"session","version":3,"id":"0199…","timestamp":"2026-10-22T05:00:00.000Z","cwd":"/work/app"}
{"type":"agent_start"}
{"type":"turn_start"}
{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"Liệt kê file"}],…}}
{"type":"message_end","message":{"role":"user",…}}
{"type":"message_start","message":{"role":"assistant","content":[],"stopReason":"pending",…}}
{"type":"message_update","usage":{…},"assistantMessageEvent":{"type":"toolcall_start","contentIndex":0,"id":"call_1","toolName":"bash"}}
{"type":"message_update","usage":{…},"assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"{\"command\":\"ls\"}"}}
…
{"type":"agent_end","messages":[…],"willRetry":false}
{"type":"agent_settled","aborted":false}
Dòng đầu là header của session (version 3 là CURRENT_SESSION_VERSION, session-manager.ts:41); RPC không có dòng này.

Stdout chỉ dành cho giao thức

Một dòng console.log lạc từ extension hay thư viện nào đó sẽ làm hỏng luồng JSONL. Vì vậy, ngay khi chọn chế độ không tương tác, main() gọi takeOverStdout():

takeOverStdout(): mọi lần ghi stdout thông thường bị chuyển sang stderr
pi/packages/coding-agent/src/core/output-guard.tsdòng 45–70TypeScript
export function takeOverStdout(): void {	if (stdoutTakeoverState) {		return;	}	const rawStdoutWrite = process.stdout.write.bind(process.stdout) as StdoutTakeoverState["rawStdoutWrite"];	const rawStderrWrite = process.stderr.write.bind(process.stderr) as StdoutTakeoverState["rawStderrWrite"];	const originalStdoutWrite = process.stdout.write;	process.stdout.write = ((		chunk: string | Uint8Array,		encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),		callback?: (error?: Error | null) => void,	): boolean => {		if (typeof encodingOrCallback === "function") {			return rawStderrWrite(String(chunk), encodingOrCallback);		}		return rawStderrWrite(String(chunk), callback);	}) as typeof process.stdout.write;	stdoutTakeoverState = {		rawStdoutWrite,		rawStderrWrite,		originalStdoutWrite,	};}

Sau lời gọi này, process.stdout.write thực chất ghi vào stderr. Chỉ còn một đường tới stdout thật, và đường đó tuần tự hoá mọi lần ghi:

writeRawStdout() xếp hàng các lần ghi; waitForRawStdoutBackpressure() chờ hàng đợi rỗng
pi/packages/coding-agent/src/core/output-guard.tsdòng 85–103TypeScript
export function writeRawStdout(text: string): void {	if (text.length === 0) {		return;	}	rawStdoutWriteTail = rawStdoutWriteTail.then(() => writeRawStdoutChunk(text));	void rawStdoutWriteTail.catch(() => {		process.exit(1);	});}export async function waitForRawStdoutBackpressure(): Promise<void> {	while (true) {		const tail = rawStdoutWriteTail;		await tail;		if (tail === rawStdoutWriteTail) {			return;		}	}}

Mỗi bản ghi được nối vào một chuỗi promise (dòng 89), nên hai bản ghi không bao giờ xen vào nhau; lỗi tạm thời như EAGAIN hay ENOBUFS được thử lại sau 10 ms, còn lỗi thật làm tiến trình thoát (output-guard.ts:20–43). Đó là một sửa lỗi ở 0.76.0 (CHANGELOG.md:2023).

RPC mode: giao thức JSONL hai chiều#

RPC mode biến Pi thành một tiến trình con sống lâu: client ghi lệnh vào stdin, Pi ghi phản hồi và sự kiện ra stdout. Giao thức có bốn họ bản ghi (rpc.md:24–31):

HướngBản ghiKiểu trong rpc-types.ts
stdinLệnhRpcCommand
stdoutPhản hồi { type: "response" }RpcResponse
stdoutSự kiện sessionJsonAgentSessionEvent (cùng hình dạng với JSON mode), cộng extension_error
cả haiExtension UIRpcExtensionUIRequest (stdout), RpcExtensionUIResponse (stdin)

Data model của giao thức

Phản hồi là một hợp lớn, mỗi lệnh một nhánh thành công có kiểu data riêng, cộng một nhánh lỗi chung cho mọi lệnh:

RpcResponse: đầu hợp và nhánh lỗi chung
pi/packages/coding-agent/src/modes/rpc/rpc-types.tsdòng 116–135, 236–245TypeScript
export type RpcResponse =	// Prompting	| { id?: string; type: "response"; command: "prompt"; success: true; data: { disposition: PromptDisposition } }	| { id?: string; type: "response"; command: "steer"; success: true; data: { disposition: QueuedInputDisposition } }	| {			id?: string;			type: "response";			command: "follow_up";			success: true;			data: { disposition: QueuedInputDisposition };	  }	| { id?: string; type: "response"; command: "abort"; success: true }	| {			id?: string;			type: "response";			command: "clear_queue";			success: true;			data: { steering: string[]; followUp: string[] };	  }	| { id?: string; type: "response"; command: "new_session"; success: true; data: { cancelled: boolean } }	| {			id?: string;			type: "response";			command: "get_commands";			success: true;			data: { commands: RpcSlashCommand[] };	  }	// Error response (any command can fail)	| { id?: string; type: "response"; command: string; success: false; error: string };

disposition trong phản hồi của prompt cho biết prompt đã đi đâu: "started" (bắt đầu một lần chạy), "queued" (đang chạy nên được xếp hàng làm steer hoặc follow-up), hay "handled" (một lệnh extension hoặc input handler đã tiêu thụ nó, không có lần chạy nào) (agent-session.ts:311–312, rpc-commands.md:40). Trạng thái mà get_state trả về là một ảnh chụp phẳng của session:

RpcSessionState
pi/packages/coding-agent/src/modes/rpc/rpc-types.tsdòng 96–109TypeScript
export interface RpcSessionState {	model?: Model<any>;	thinkingLevel: ThinkingLevel;	isStreaming: boolean;	isCompacting: boolean;	steeringMode: "all" | "one-at-a-time";	followUpMode: "all" | "one-at-a-time";	sessionFile?: string;	sessionId: string;	sessionName?: string;	autoCompactionEnabled: boolean;	messageCount: number;	pendingMessageCount: number;}

Khung bản ghi: JSONL nghiêm ngặt

jsonl.ts: chỉ tách bản ghi ở LF
pi/packages/coding-agent/src/modes/rpc/jsonl.tsdòng 4–41TypeScript
/** * Serialize a single strict JSONL record. * * Framing is LF-only. Payload strings may contain other Unicode separators such as * U+2028 and U+2029. Clients must split records on `\n` only. */export function serializeJsonLine(value: unknown): string {	return `${JSON.stringify(value)}\n`;}/** * Attach an LF-only JSONL reader to a stream. * * This intentionally does not use Node readline. Readline splits on additional * Unicode separators that are valid inside JSON strings and therefore does not * implement strict JSONL framing. */export function attachJsonlLineReader(stream: Readable, onLine: (line: string) => void): () => void {	const decoder = new StringDecoder("utf8");	let buffer = "";	const emitLine = (line: string) => {		onLine(line.endsWith("\r") ? line.slice(0, -1) : line);	};	const onData = (chunk: string | Buffer) => {		buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);		while (true) {			const newlineIndex = buffer.indexOf("\n");			if (newlineIndex === -1) {				return;			}			emitLine(buffer.slice(0, newlineIndex));			buffer = buffer.slice(newlineIndex + 1);		}	};

Chi tiết này nghe vụn vặt nhưng từng làm hỏng giao thức. JSON.stringify không thoát hai ký tự U+2028 (line separator) và U+2029 (paragraph separator), nên chúng có thể nằm nguyên trong một chuỗi JSON hợp lệ. readline của Node lại coi chúng là ký tự xuống dòng và cắt bản ghi làm đôi. Từ 0.57.0, cả hai phía dùng bộ đọc tự viết chỉ tách ở \n (CHANGELOG.md:3373), và tài liệu cấm client dùng readline (rpc.md:52–54).

Phía server: runRpcMode()

runRpcMode() chiếm stdout, định nghĩa hàm output() ghi một bản ghi JSONL qua writeRawStdout, cùng hai hàm dựng phản hồi success() và error() (rpc-mode.ts:54–77). Việc gắn session giống print mode, nhưng có UI thật và một kênh báo lỗi extension:

rebindSession() của RPC: UI qua giao thức, mọi sự kiện ra stdout, áp lực ngược
pi/packages/coding-agent/src/modes/rpc/rpc-mode.tsdòng 317–322, 345–364TypeScript
const rebindSession = async (): Promise<void> => {	session = runtimeHost.session;	await session.bindExtensions({		uiContext: createExtensionUIContext(),		mode: "rpc",		commandContextActions: {		shutdownHandler: () => {			shutdownRequested = true;		},		onError: (err) => {			output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });		},	});	unsubscribe?.();	unsubscribeBackpressure?.();	unsubscribe = session.subscribe((event) => {		output(toJsonEvent(event));		if (event.type === "agent_settled") {			void checkShutdownRequested();		}	});	unsubscribeBackpressure = session.agent.subscribe(async () => {		await waitForRawStdoutBackpressure();	});};

Mọi sự kiện session đều được ghi ra (dòng 355–356), và agent_settled còn là điểm kiểm tra yêu cầu tắt từ extension. Bộ xử lý lệnh là một switch lớn; phần thú vị nhất là bốn lệnh đầu:

prompt trả phản hồi sau preflight; steer, follow_up, abort chờ kết quả
pi/packages/coding-agent/src/modes/rpc/rpc-mode.tsdòng 394–429TypeScript
case "prompt": {	// Start prompt handling immediately, but emit the authoritative response only after	// prompt preflight succeeds. Queued and immediately handled prompts also count as success.	let preflightSucceeded = false;	void session		.prompt(command.message, {			images: command.images,			streamingBehavior: command.streamingBehavior,			source: "rpc",			preflightResult: (disposition) => {				preflightSucceeded = true;				output(success(id, "prompt", { disposition }));			},		})		.catch((e) => {			if (!preflightSucceeded) {				output(error(id, "prompt", e.message));			}		});	return undefined;}case "steer": {	const disposition = await session.steer(command.message, command.images, { source: "rpc" });	return success(id, "steer", { disposition });}case "follow_up": {	const disposition = await session.followUp(command.message, command.images, { source: "rpc" });	return success(id, "follow_up", { disposition });}case "abort": {	await session.abort();	return success(id, "abort");}

prompt là lệnh duy nhất không chờ. Một prompt được chấp nhận có thể chạy nhiều phút, và client cần biết sớm rằng nó đã được nhận. Vì vậy session.prompt() được khởi động mà không await, và phản hồi được ghi đúng một lần qua callback preflightResult: khi lệnh extension đã xử lý, khi prompt được xếp hàng, hoặc ngay trước khi lần chạy bắt đầu. Nếu prompt bị từ chối trước bước đó (không có mô hình, thiếu API key, đang compaction), .catch ghi một phản hồi lỗi; lỗi xảy ra sau khi đã chấp nhận thì chỉ xuất hiện trong luồng sự kiện. Hành vi “một phản hồi có thẩm quyền sau preflight” có từ 0.67.3 (CHANGELOG.md:2740), còn disposition có từ 0.99.0 (CHANGELOG.md:450). abort thì ngược lại: nó chờ session rảnh hẳn rồi mới trả lời.

Mỗi dòng stdin đi qua handleInputLine():

Ba nhánh. JSON hỏng nhận một phản hồi lỗi với command: "parse" và không có id. Một extension_ui_response được chuyển cho hộp thoại đang chờ có cùng id và không sinh phản hồi nào. Mọi thứ còn lại là lệnh. Điểm quan trọng nằm ở chỗ gắn bộ đọc: mỗi dòng được xử lý bằng void handleInputLine(line), không chờ dòng trước xong (rpc-mode.ts:807–815). Nhờ vậy abort hay steer được xử lý ngay cả khi một lệnh chậm như compact đang chạy, và cũng vì vậy client phải ghép phản hồi theo id, không theo thứ tự (rpc.md:44). Khi stdin đóng, shutdown() dispose runtime, xả stdout và thoát; hàm runRpcMode() trả về một promise không bao giờ resolve để giữ tiến trình sống (rpc-mode.ts:726–743, :817–818).

Extension UI đi qua RPC#

Vấn đề. Một extension như permission-gate gọi await ctx.ui.select("Allow?", ["Yes", "No"]) ngay giữa hook tool_call, tức giữa vòng lặp agent. Trong TUI, đó là một hộp thoại. Trong RPC không có màn hình nào; người duy nhất trả lời được là client ở đầu kia của pipe. Giải pháp là một giao thức con: Pi ghi một extension_ui_request có id ngẫu nhiên, giữ promise của hộp thoại trong một bảng chờ, và resolve nó khi client gửi extension_ui_response cùng id.

Yêu cầu UI (stdout) và phản hồi UI (stdin)
pi/packages/coding-agent/src/modes/rpc/rpc-types.tsdòng 252–263, 286–297TypeScript
export type RpcExtensionUIRequest =	| { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number }	| { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number }	| {			type: "extension_ui_request";			id: string;			method: "input";			title: string;			placeholder?: string;			timeout?: number;	  }	| { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string }	| { type: "extension_ui_request"; id: string; method: "setTitle"; title: string }	| { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string };// ============================================================================// Extension UI Commands (stdin)// ============================================================================/** Response to an extension UI request */export type RpcExtensionUIResponse =	| { type: "extension_ui_response"; id: string; value: string }	| { type: "extension_ui_response"; id: string; confirmed: boolean }	| { type: "extension_ui_response"; id: string; cancelled: true };

Hộp thoại có ba cách kết thúc: client trả lời, timeout hết hạn, hoặc AbortSignal của extension bị huỷ. Hai cách sau resolve bằng giá trị mặc định, nên client không cần tự đếm giờ (rpc-extension-ui.md:10). Các phương thức của ExtensionUIContext chia làm hai loại:

select, confirm, input là hộp thoại; notify chỉ ghi ra rồi thôi
pi/packages/coding-agent/src/modes/rpc/rpc-mode.tsdòng 136–161TypeScript
const createExtensionUIContext = (): ExtensionUIContext => ({	select: (title, options, opts) =>		createDialogPromise(opts, undefined, { method: "select", title, options, timeout: opts?.timeout }, (r) =>			"cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined,		),	confirm: (title, message, opts) =>		createDialogPromise(opts, false, { method: "confirm", title, message, timeout: opts?.timeout }, (r) =>			"cancelled" in r && r.cancelled ? false : "confirmed" in r ? r.confirmed : false,		),	input: (title, placeholder, opts) =>		createDialogPromise(opts, undefined, { method: "input", title, placeholder, timeout: opts?.timeout }, (r) =>			"cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined,		),	notify(message: string, type?: "info" | "warning" | "error"): void {		// Fire and forget - no response needed		output({			type: "extension_ui_request",			id: crypto.randomUUID(),			method: "notify",			message,			notifyType: type,		} as RpcExtensionUIRequest);	},
  • Hộp thoại (select, confirm, input, editor): chờ phản hồi. Riêng editor() tự dựng promise mà không qua createDialogPromise, nên không có timeout hay abort (rpc-mode.ts:254–271); kiểu yêu cầu của nó cũng không có trường timeout.
  • Ghi rồi thôi (notify, setStatus, setWidget, setTitle, setEditorText): ghi một bản ghi và không chờ gì. setWidget chỉ nhận mảng chuỗi; widget dạng component bị bỏ qua (rpc-mode.ts:195–208).
  • Không hỗ trợ: custom() trả undefined, getEditorText() trả "" vì một hàm đồng bộ không thể chờ RPC, footer, header, chỉ báo “working”, đổi theme đều là no-op (rpc-extension-ui.md:12–23). Extension cần UI thật của terminal nên kiểm tra ctx.mode === "tui".

RpcClient: phía client#

Với client TypeScript, gói xuất sẵn RpcClient: nó khởi động node <cliPath> --mode rpc với các cờ tuỳ chọn (rpc-client.ts:81–98), rồi bọc mỗi lệnh thành một phương thức có kiểu. Mọi phương thức đi qua send():

send(): gắn id req_N, chờ phản hồi cùng id, hết hạn sau 30 giây
pi/packages/coding-agent/src/modes/rpc/rpc-client.tsdòng 576–604TypeScript
const id = `req_${++this.requestId}`;const fullCommand = { ...command, id } as RpcCommand;return new Promise((resolve, reject) => {	const timeout = setTimeout(() => {		this.pendingRequests.delete(id);		reject(new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.stderr}`));	}, 30000);	this.pendingRequests.set(id, {		resolve: (response) => {			clearTimeout(timeout);			resolve(response);		},		reject: (error) => {			clearTimeout(timeout);			reject(error);		},	});	try {		stdin.write(serializeJsonLine(fullCommand));	} catch (error: unknown) {		const writeError = error instanceof Error ? error : new Error(String(error));		const pending = this.pendingRequests.get(id);		this.pendingRequests.delete(id);		pending?.reject(writeError);	}});
handleLine(): phản hồi có id thì resolve, còn lại là sự kiện
pi/packages/coding-agent/src/modes/rpc/rpc-client.tsdòng 523–543TypeScript
private handleLine(line: string): void {	try {		const data = JSON.parse(line);		// Check if it's a response to a pending request		if (data.type === "response" && data.id && this.pendingRequests.has(data.id)) {			const pending = this.pendingRequests.get(data.id)!;			this.pendingRequests.delete(data.id);			pending.resolve(data as RpcResponse);			return;		}		// Otherwise it's an event. Iterate a snapshot so listeners that unsubscribe during dispatch		// do not cause later listeners to miss this event.		for (const listener of [...this.eventListeners]) {			listener(data as JsonAgentSessionEvent);		}	} catch {		// Ignore non-JSON lines	}}

Bản ghi nào không phải phản hồi của một yêu cầu đang chờ đều được coi là sự kiện, kể cả extension_ui_request. Nhưng RpcClient ở v1.4.0 không có phương thức nào để ghi extension_ui_response: send() là private và luôn gắn id rồi chờ một phản hồi. Vì vậy ví dụ chính thức về extension UI không dùng RpcClient mà tự spawn tiến trình, tự đọc stdout và tự ghi phản hồi hộp thoại vào stdin (examples/rpc-extension-ui.ts:259–263, :540). Hai tiện ích cuối của RpcClient giải quyết một cuộc đua (race) dễ mắc:

waitForIdle() chờ agent_settled; promptAndWait() đăng ký listener trước khi gửi
pi/packages/coding-agent/src/modes/rpc/rpc-client.tsdòng 471–486, 513–517TypeScript
waitForIdle(timeout = 60000): Promise<void> {	return new Promise((resolve, reject) => {		const timer = setTimeout(() => {			unsubscribe();			reject(new Error(`Timeout waiting for agent to become idle. Stderr: ${this.stderr}`));		}, timeout);		const unsubscribe = this.onEvent((event) => {			if (event.type === "agent_settled") {				clearTimeout(timer);				unsubscribe();				resolve();			}		});	});}async promptAndWait(message: string, images?: ImageContent[], timeout = 60000): Promise<JsonAgentSessionEvent[]> {	const eventsPromise = this.collectEvents(timeout);	await this.prompt(message, images);	return eventsPromise;}

Một prompt ngắn có thể chạy xong, và agent_settled có thể tới, trước khi lời gọi prompt() kịp trả về. Nếu client chỉ đăng ký listener sau đó, nó chờ mãi. promptAndWait() tạo promise thu sự kiện trước rồi mới gửi lệnh (rpc.md:71). Ví dụ chính thức dùng đúng mẫu đó:

examples/rpc-client.ts: một prompt qua tiến trình con RPC
pi/packages/coding-agent/examples/rpc-client.tsdòng 15–35TypeScript
const client = new RpcClient({	cliPath: join(exampleDirectory, "../dist/cli.js"),	args: ["--no-session"],});const unsubscribe = client.onEvent((event) => {	if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {		process.stdout.write(event.assistantMessageEvent.delta);	} else if (event.type === "tool_execution_start") {		process.stderr.write(`\n[tool: ${event.toolName}]\n`);	}});try {	await client.start();	await client.promptAndWait(prompt);	process.stdout.write("\n");} finally {	unsubscribe();	await client.stop();}

SDK: nhúng Pi trong tiến trình#

SDK không phải một chế độ CLI. Nó là chính gói @earendil-works/pi-coding-agent được import như thư viện, cho chương trình Node.js hay Bun truy cập thẳng agent, session, tool và mô hình (cli-integration.md:7). Hàm trung tâm là createAgentSession(), đúng hàm mà factory của CLI gọi qua createAgentSessionFromServices(). Ví dụ tối giản trong repo:

examples/sdk/01-minimal.ts
pi/packages/coding-agent/examples/sdk/01-minimal.tsdòng 8–26TypeScript
import { createAgentSession } from "@earendil-works/pi-coding-agent";const { session } = await createAgentSession();try {	session.subscribe((event) => {		if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {			process.stdout.write(event.assistantMessageEvent.delta);		}	});	await session.prompt("What files are in the current directory?");	session.state.messages.forEach((msg) => {		console.log(msg);	});	console.log();} finally {	session.dispose();}

Không truyền gì, hàm dùng thư mục hiện tại, settings đã lưu, credential đã cấu hình và resource được phát hiện. Mỗi ranh giới đều thay được qua options:

CreateAgentSessionOptions (đầu và cuối)
pi/packages/coding-agent/src/core/sdk.tsdòng 48–62, 93–106TypeScript
export interface CreateAgentSessionOptions {	/** Working directory for project-local discovery. Default: process.cwd() */	cwd?: string;	/** Global config directory. Default: ~/.pi/agent */	agentDir?: string;	/** Canonical model/auth runtime. Defaults to a runtime using agentDir/auth.json and models.json. */	modelRuntime?: ModelRuntime;	/** Model to use. Default: from settings, else first available */	model?: Model<any>;	/** Thinking level. Default: from settings, else 'medium' (clamped to model capabilities) */	thinkingLevel?: ThinkingLevel;	/** Models available for cycling (Ctrl+P in interactive mode) */	scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;	/** Custom tools to register (in addition to built-in tools). */	customTools?: ToolDefinition[];	/** Resource loader. When omitted, DefaultResourceLoader is used. */	resourceLoader?: ResourceLoader;	/** Session manager. Default: SessionManager.create(cwd) */	sessionManager?: SessionManager;	/** Settings manager. Default: SettingsManager.create(cwd, agentDir) */	settingsManager?: SettingsManager;	/** Session start event metadata for extension runtime startup. */	sessionStartEvent?: SessionStartEvent;}

Bên trong, hàm dựng ModelRuntime, SettingsManager, SessionManager, DefaultResourceLoader, rồi một Agent và một AgentSession bọc nó (sdk.ts:191–207, :453–470); cách Agent được cấu hình có ở chương pi-agent-core, còn AgentSession ở chương AgentSession. Điều quan trọng với người nhúng là những gì SDK không làm so với main():

  • Không chiếm stdout và không chọn mode: host tự quyết cách hiển thị.
  • Không nạp các extension dựng sẵn của CLI (codemode, tool_search, MCP); host phải tự thêm vào extensionFactories của DefaultResourceLoader (sdk.md:116).
  • Không gọi bindExtensions(), nên extension chưa nhận session_start và chạy với mode "print", không UI, cho tới khi host tự gắn. MCP chỉ kết nối server ở session_start, nên quên bước này nghĩa là không có tool MCP.

Host nào cần /new, resume hay fork thì dựng AgentSessionRuntime với một factory, đúng như main(), và gắn lại listener sau mỗi lần thay session:

examples/sdk/13-session-runtime.ts: factory, runtime, rebind sau mỗi lần thay
pi/packages/coding-agent/examples/sdk/13-session-runtime.tsdòng 20–50TypeScript
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {	const services = await createAgentSessionServices({ cwd });	return {		...(await createAgentSessionFromServices({			services,			sessionManager,			sessionStartEvent,		})),		services,		diagnostics: services.diagnostics,	};};const runtime = await createAgentSessionRuntime(createRuntime, {	cwd: process.cwd(),	agentDir: getAgentDir(),	sessionManager: SessionManager.create(process.cwd()),});let unsubscribe: (() => void) | undefined;async function bindSession() {	unsubscribe?.();	const session = runtime.session;	await session.bindExtensions({});	unsubscribe = session.subscribe((event) => {		if (event.type === "queue_update") {			console.log("Queued:", event.steering.length + event.followUp.length);		}	});	return session;}

Sau khi runtime thay session, mọi subscription cũ thuộc về một AgentSession đã dispose (sdk.md:60), đúng vấn đề mà setRebindSession() giải quyết cho các mode CLI. Gói còn xuất chính các mode (InteractiveMode, runPrintMode, runRpcMode, index.ts:417–434) và cả main() với MainOptions.extensionFactories. pi nạp extension dựng sẵn của chính nó qua đúng đường đó (main.ts:570–578), nên một host có thể phát hành CLI riêng kèm extension inline mà không phải sửa Pi.

Chọn SDK hay RPC? Tài liệu tóm tắt: SDK chạy trong cùng tiến trình và có toàn bộ API TypeScript; RPC chạy trong tiến trình con, điều khiển bằng JSONL, hợp với ngôn ngữ khác, IDE, hay khi muốn cô lập tiến trình (rpc.md:7–10).

Interactive mode ở mức cấu trúc#

interactive-mode.ts dài hơn 7.000 dòng, nhưng phần lớn là giao diện: bộ chọn, hộp thoại, phím tắt, cách vẽ từng loại message (chương pi-tui). Ở mức cấu trúc, nó là một mode như mọi mode khác: nhận runtime, gắn extension, đăng ký sự kiện, và đẩy đầu vào của người dùng vào session.prompt().

  1. constructorLưu runtime, đăng ký setRebindSession và setBeforeSessionInvalidate, dựng TUI và cây component (:610).
  2. init()Gắn component vào TUI, ui.start() trước extension để session_start dùng được hộp thoại, cài phím, rồi rebindCurrentSession() (:999–1105).
  3. bindbindExtensions({ uiContext, mode: "tui", ... }) và subscribeToAgent() (:2092–2118).
  4. run()Kiểm tra phiên bản, cập nhật gói, danh mục mô hình chạy nền; gửi prompt khởi đầu; rồi vòng lặp chính.
run(): prompt khởi đầu, rồi vòng lặp đọc đầu vào mãi mãi
pi/packages/coding-agent/src/modes/interactive/interactive-mode.tsdòng 1232–1262TypeScript
// Process initial messagesif (initialMessage) {	try {		await this.session.prompt(initialMessage, { images: initialImages });	} catch (error: unknown) {		const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";		this.showError(errorMessage);	}}if (initialMessages) {	for (const message of initialMessages) {		try {			await this.session.prompt(message);		} catch (error: unknown) {			const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";			this.showError(errorMessage);		}	}}// Main interactive loopwhile (true) {	const userInput = await this.getUserInput();	try {		await this.session.prompt(userInput);	} catch (error: unknown) {		const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";		this.showError(errorMessage);	}}

Trong lúc một lần chạy diễn ra, vòng lặp chính đang đứng ở await this.session.prompt(...). Nếu người dùng nhấn Enter lúc đó, bộ xử lý submit của editor không đi qua vòng lặp này mà gửi thẳng một prompt có streamingBehavior: "steer"; tổ hợp phím follow-up (mặc định Alt+Enter) gửi "followUp" (:4460–4468):

Enter khi đang chạy là steer; khi rảnh thì trả cho vòng lặp chính
pi/packages/coding-agent/src/modes/interactive/interactive-mode.tsdòng 3333–3354, 3357–3361TypeScript
		// If streaming, use prompt() with steer behavior		// This handles extension commands (execute immediately), prompt template expansion, and queueing		if (this.session.isStreaming) {			this.editor.addToHistory?.(text);			this.editor.setText("");			await this.session.prompt(text, { streamingBehavior: "steer" });			this.updatePendingMessagesDisplay();			this.ui.requestRender();			return;		}		// Normal message submission		// First, move any pending bash components to chat		this.flushPendingBashComponents();		if (this.onInputCallback) {			this.onInputCallback(text);		} else {			this.pendingUserInputs.push(text);		}		this.editor.addToHistory?.(text);	};private subscribeToAgent(): void {	this.unsubscribe = this.session.subscribe(async (event) => {		await this.handleEvent(event);	});}

Trước nhánh đó, bộ xử lý submit đã tự xử lý các lệnh dựng sẵn như /settings, /model, /resume, /quit và lệnh bash !/!! (:3156–3319). Mọi thứ còn lại, kể cả lệnh của extension, đi vào session.prompt(), cùng hàm mà RPC và print gọi. Chiều ngược lại, handleEvent() biến mỗi AgentSessionEvent thành một thay đổi trên cây component rồi xin vẽ lại:

message_start của assistant tạo một AssistantMessageComponent đang stream
pi/packages/coding-agent/src/modes/interactive/interactive-mode.tsdòng 3457–3479TypeScript
case "message_start":	if (event.message.role === "custom") {		this.addMessageToChat(event.message);		this.ui.requestRender();	} else if (event.message.role === "user") {		this.addMessageToChat(event.message);		this.updatePendingMessagesDisplay();		this.ui.requestRender();	} else if (event.message.role === "assistant") {		this.streamingComponent = new AssistantMessageComponent(			undefined,			this.hideThinkingBlock,			this.getMarkdownThemeWithSettings(),			this.hiddenThinkingLabel,			this.outputPad,			this.getMarkdownTransformers(),		);		this.streamingMessage = event.message;		this.chatContainer.addChild(this.streamingComponent);		this.streamingComponent.updateContent(this.streamingMessage, true);		this.ui.requestRender();	}	break;
Sự kiệnInteractive mode làm gì
message_start (user, custom, assistant)Thêm message vào khung chat; với assistant, tạo component đang stream
message_updateCập nhật component; mỗi khối toolCall mới sinh một ToolExecutionComponent ngay khi đối số còn đang stream (:3486–3511)
message_endChốt message; nếu lỗi hoặc bị huỷ, đánh dấu lỗi cho các tool đang chờ
tool_execution_*Cập nhật đúng component theo toolCallId; lời gọi lồng nhau (parentToolCallId) hiện bên trong dòng của tool cha (:3563–3608)
queue_updateVẽ lại danh sách tin steer/follow-up đang chờ
compaction_*, auto_retry_*Chỉ báo trạng thái
agent_settledKiểm tra yêu cầu tắt từ extension

Trace: một phiên RPC có hộp thoại của extension#

Kịch bản: một client khởi động pi --mode rpc --no-session -e ./permission-gate.ts, gửi một prompt khiến mô hình gọi bash với rm -rf build. Extension permission-gate chặn lại hỏi ý kiến; client đồng ý. Mũi tên → là dòng client ghi vào stdin, ← là dòng Pi ghi ra stdout.

Ví dụ minh hoạ: trao đổi JSONL (các trường dài được rút gọn bằng …)
→ {"id":"req-1","type":"prompt","message":"Xoá thư mục build rồi báo lại"}
← {"id":"req-1","type":"response","command":"prompt","success":true,"data":{"disposition":"started"}}
← {"type":"agent_start"}
← {"type":"turn_start"}
← {"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"Xoá thư mục build rồi báo lại"}],…}}
← {"type":"message_end","message":{"role":"user",…}}
← {"type":"message_start","message":{"role":"assistant","content":[],"stopReason":"pending",…}}
← {"type":"message_update","usage":{…},"assistantMessageEvent":{"type":"toolcall_start","contentIndex":0,"id":"call_1","toolName":"bash"}}
← {"type":"message_update","usage":{…},"assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"{\"command\":\"rm -rf build\"}"}}
← {"type":"message_update","usage":{…},"assistantMessageEvent":{"type":"toolcall_end","contentIndex":0,"toolCall":{…}}}
← {"type":"message_end","message":{"role":"assistant","stopReason":"toolUse",…}}
← {"type":"tool_execution_start","toolCallId":"call_1","toolName":"bash","args":{"command":"rm -rf build"}}
← {"type":"extension_ui_request","id":"5f0c…","method":"select","title":"… rm -rf build … Allow?","options":["Yes","No"]}
     vòng lặp agent đứng chờ trong beforeToolCall; các lệnh khác vẫn được xử lý
→ {"type":"extension_ui_response","id":"5f0c…","value":"Yes"}
← {"type":"tool_execution_end","toolCallId":"call_1","toolName":"bash","result":{"content":[{"type":"text","text":"(no output)"}],…},"isError":false,"durationMs":14}
← {"type":"message_start","message":{"role":"toolResult","toolCallId":"call_1",…}}
← {"type":"message_end","message":{"role":"toolResult",…}}
← {"type":"turn_end","message":{…},"toolResults":[{…}]}
← {"type":"turn_start"}
← {"type":"message_start","message":{"role":"assistant",…}}
← {"type":"message_update","usage":{…},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Đã xoá thư mục build."}}
← {"type":"message_end","message":{"role":"assistant","stopReason":"stop",…}}
← {"type":"turn_end","message":{…},"toolResults":[]}
← {"type":"agent_end","messages":[…],"willRetry":false}
← {"type":"agent_settled","aborted":false}
→ {"id":"req-2","type":"get_state"}
← {"id":"req-2","type":"response","command":"get_state","success":true,"data":{"isStreaming":false,…}}
Một lần chạy, hai lượt, một hộp thoại. Phản hồi của req-1 tới trước agent_start; agent_settled mới là tín hiệu xong.

Đi lại từng chặng, kèm chỗ code chịu trách nhiệm:

  1. handleInputLine() parse dòng đầu và gọi handleCommand(). Nhánh prompt khởi động session.prompt() mà không await. AgentSession kiểm tra đã có mô hình và API key, chuẩn bị lần chạy, rồi gọi preflightResult("started") ngay trước agent.prompt() (agent-session.ts:1873–1899). Vì mọi bản ghi đi qua cùng một hàng đợi writeRawStdout, phản hồi luôn đứng trước agent_start.
  2. Các sự kiện từ agent_start tới message_end của assistant là chuỗi sự kiện của vòng lặp agent (trace ở chương pi-agent-core), đi qua toJsonEvent() nên message_update chỉ còn delta, và toolcall_start mang id, toolName.
  3. tool_execution_start được phát trước khi chuẩn bị lời gọi; ngay sau đó vòng lặp gọi beforeToolCall, tức hook tool_call của extension. permission-gate thấy hasUI là true và gọi ctx.ui.select(), mà trong RPC là createDialogPromise(): một extension_ui_request với UUID mới được ghi ra, và promise nằm trong bảng chờ. Không có timeout vì extension không truyền.
  4. Client ghi extension_ui_response. Vì mỗi dòng stdin được xử lý độc lập, dòng này không phải chờ req-1. handleInputLine() tìm thấy id trong bảng chờ và resolve promise với "Yes". Hook trả về undefined, bash chạy, kết quả đi qua afterToolCall rồi ra thành tool_execution_end và message toolResult.
  5. Lượt 2 trả lời bằng văn bản, không gọi tool; vòng lặp kết thúc với agent_end. AgentSession kiểm tra xem có cần thử lại, compaction hay tin chờ không; không có gì nên nó phát agent_settled (agent-session.ts:1899–1917).

Hai biến thể đáng thử trong đầu. Nếu client trả "No", permission-gate trả { block: true, reason: "Blocked by user" }; vòng lặp tạo một kết quả lỗi mang đúng lý do đó, nên tool_execution_end có isError: true và mô hình đọc được lý do ở lượt 2. Nếu chạy cùng kịch bản bằng pi -p, hasUI là false và extension chặn ngay với lý do “Dangerous command blocked (no UI for confirmation)”, không có bản ghi UI nào. Còn nếu trong lúc bash chạy client gửi {"id":"req-3","type":"steer","message":"..."}, Pi trả {"disposition":"queued"} và phát queue_update; khi tin đó được chèn sau batch tool, AgentSession gỡ nó khỏi danh sách chờ và phát queue_update mới trước khi chuyển tiếp message_start của tin nhắn user đó (agent-session.ts:1120–1135).

Quyết định thiết kế#

Một runtime, nhiều lớp vỏ

“All four modes use the same agent, sessions, resources, and tools. The mode determines how input enters Pi, how output is exposed, and whether the process remains available for more commands” (cli-integration.md:5). Lợi ích thấy ngay trong code: compaction, retry, extension, session tree được viết một lần trong AgentSession và chạy ở mọi chế độ; một client RPC có cùng hành vi steer/follow-up như người gõ trong TUI. Cái giá là mọi tính năng UI phải có đường suy biến khi không có màn hình, do đó có noOpUIContext, hasUI và ctx.mode.

Chọn chế độ theo TTY

Chọn print khi stdin hoặc stdout không phải terminal làm Pi khớp với thói quen Unix: pipe vào, pipe ra, không cần cờ. Đánh đổi là một chút bất ngờ cho người chuyển hướng output mà vẫn tưởng sẽ thấy TUI, và nhu cầu phân biệt rõ --print (chạy một lần) với --mode (định dạng đầu ra) (cli.md:41).

Bảo vệ stdout thay vì tin vào code khác

Extension là module TypeScript chạy ngay trong tiến trình Pi (how-pi-works.md:43), nên không thể giả định không ai gọi console.log. Thay vì đòi mọi extension tuân thủ, Pi chuyển hướng process.stdout.write sang stderr và giữ cho mình một lối ghi riêng. Thiết kế này đổi một lớp lỗi khó gỡ (luồng JSONL hỏng giữa chừng) lấy một quy ước đơn giản: stdout chỉ có bản ghi giao thức, stderr chứa mọi thứ khác.

Giao thức tuyến tính, có áp lực ngược

Ba lựa chọn cùng hướng: JSONL chỉ tách ở LF (0.57.0), message_update chỉ mang delta để kích thước tuyến tính (0.84.0), và áp lực ngược từ stdout tới tận vòng lặp agent nhờ listener được await. Kết quả là một client chậm làm Pi chậm lại thay vì làm Pi phình bộ nhớ, và một câu trả lời dài không làm luồng sự kiện phình theo bình phương.

Tách “được nhận”, “xong một lần chạy” và “hết việc”

Một client cần ba tín hiệu khác nhau, và giao thức tách chúng ra. Phản hồi của prompt nói prompt đã được nhận và đi đâu (disposition). agent_end nói một lần chạy cấp thấp đã xong. agent_settled nói Pi không còn tự làm gì nữa, vì một lần chạy cấp session có thể gồm nhiều lần chạy cấp thấp: thử lại sau lỗi provider, compaction khi tràn rồi chạy lại, follow-up (agent-session.ts:1899–1909). agent_settled xuất hiện ở 0.80.4 (CHANGELOG.md:1484) và có thêm cờ aborted ở 1.1.0 (CHANGELOG.md:186).

UI của extension là một giao thức con, suy biến có kiểm soát

Thay vì cấm extension dùng hộp thoại ngoài TUI, RPC chuyển hộp thoại thành cặp yêu cầu và phản hồi có id, có timeout phía Pi, và biến những gì không thể làm qua dây (component tuỳ biến, footer, đọc editor đồng bộ) thành no-op thay vì lỗi. Nhờ vậy cùng một extension chạy được trong TUI, trong IDE dùng RPC và trong script, miễn nó xét hasUI và mode.

Factory theo cwd thay vì một session cố định

Vì services phụ thuộc cwd, Pi không dựng chúng một lần lúc khởi động mà giữ một factory để dựng lại khi session đổi thư mục. Chú thích của AgentSessionRuntime nói rõ thứ tự: tháo runtime cũ trước, rồi dựng và áp dụng runtime mới; nếu dựng thất bại, lỗi được ném lên cho caller (agent-session-runtime.ts:67–73).

Cạm bẫy và trường hợp biên#

  • --mode text trong terminal vẫn mở TUI. Muốn chạy một lần, dùng -p.
  • Pipe vào stdin âm thầm chuyển sang print. echo "..." | pi không mở TUI (main.ts:898–900).
  • RPC từ chối @file. Gửi ảnh qua trường images của lệnh prompt.
  • JSON mode không đặt mã thoát theo kết quả mô hình. Một câu trả lời lỗi vẫn cho mã 0; phải xem stopReason trong message_end của assistant.
  • Đăng ký listener trước khi gửi prompt, nếu không một lần chạy ngắn có thể xong trước khi bạn kịp nghe.
  • Ghép phản hồi theo id. Lệnh được xử lý đồng thời, nên thứ tự phản hồi không theo thứ tự lệnh; lỗi parse không có id.
  • Ngừng đọc stdout là làm Pi đứng. Áp lực ngược đi tới tận vòng lặp agent.
  • Đừng dùng readline cho luồng của Pi; nó tách sai ở U+2028/U+2029.
  • Hộp thoại không có UI trả về ngay giá trị mặc định. Trong print, JSON và SDK chưa gắn UI, confirm() trả false và select() trả undefined ngay lập tức. Extension không xét hasUI có thể đi nhầm nhánh mà không ai thấy.
  • Listener của session.subscribe() không phải rào chắn. Chúng được gọi mà không await; muốn chặn vòng lặp cho tới khi việc async xong (như áp lực ngược của các mode), phải đăng ký trên session.agent, và khi đó chỉ nhận AgentEvent.
  • SDK sau khi thay session: listener cũ thuộc về session đã dispose; phải đăng ký lại và gọi lại bindExtensions().
  • Chú thích JSDoc của createAgentSession() còn ví dụ cũ createAgentSession({ continueSession: true }) (sdk.ts:171–174), nhưng CreateAgentSessionOptions ở v1.4.0 không có trường đó. Cách đúng là truyền sessionManager: SessionManager.continueRecent(cwd), như main() làm cho -c (main.ts:432–434).
  • pi -c -p không kèm prompt không gửi gì cho mô hình; theo code, print mode vẫn đọc message cuối của transcript đã khôi phục, nên nếu message cuối là của assistant, nó in lại câu trả lời cuối của session gần nhất (print-mode.ts:139–143).
  • RpcClient không trả lời được hộp thoại của extension. Nếu extension của bạn gọi ctx.ui.select() hay confirm() không kèm timeout hay signal, một client chỉ dùng RpcClient sẽ để vòng lặp agent chờ mãi; hãy tự quản lý tiến trình như examples/rpc-extension-ui.ts, hoặc đặt timeout cho hộp thoại.
  • RpcClient mặc định tìm dist/cli.js tương đối với thư mục hiện tại của client (rpc-client.ts:81); truyền cliPath rõ ràng như ví dụ chính thức.

Đọc code ở đâu#

Thứ tựFileĐọc để hiểu
1how-pi-works.md, cli-integration.mdCác chế độ là gì và chọn chế độ nào.
2src/cli.ts, src/main.ts:574Đường khởi động: lệnh con, resolveAppMode(), session, factory, rẽ nhánh.
3src/cli/args.ts, docs/cli.mdMọi tuỳ chọn dòng lệnh và cách chúng được phân tích.
4agent-session-runtime.ts, agent-session-services.tsServices theo cwd, factory, thay session và rebind.
5src/core/sdk.ts:191, docs/sdk.mdcreateAgentSession(); sau đó sang chương AgentSession.
6print-mode.ts, json-event.ts, output-guard.tsChế độ một lần, định dạng trên dây, bảo vệ stdout; tham chiếu sự kiện ở docs/json.md.
7rpc-types.ts, jsonl.tsData model và khung bản ghi của giao thức.
8rpc-mode.ts, docs/rpc.md, rpc-commands.md, rpc-extension-ui.mdServer RPC, từng lệnh, giao thức con của extension UI.
9rpc-client.ts, examples/rpc-client.ts, examples/rpc-extension-ui.tsPhía client, kể cả cách trả lời hộp thoại.
10interactive-mode.ts:945init(), run() (1148), submit (3156), handleEvent() (3363); phần vẽ ở chương pi-tui.
11examples/sdk/, permission-gate.tsMười bốn ví dụ SDK được typecheck cùng repo; một extension mẫu xử lý cả có và không có UI.

Tóm tắt#

  • main() dựng một AgentSessionRuntime duy nhất qua một factory theo cwd, rồi giao nó cho một trong ba lớp vỏ: InteractiveMode, runPrintMode() (text và JSON), runRpcMode(). SDK gọi cùng các hàm đó trong tiến trình của host.
  • Chế độ được chọn theo thứ tự: --mode rpc, --mode json, rồi print nếu có -p hoặc stdin/stdout không phải TTY, cuối cùng là interactive.
  • Mỗi mode khác nhau ở bốn điểm: đầu vào, cách xuất sự kiện, ExtensionUIContext đưa cho extension, và vòng đời tiến trình.
  • Các chế độ không tương tác chuyển console.log sang stderr và ghi giao thức qua một hàng đợi riêng, có áp lực ngược tới vòng lặp agent.
  • JSON và RPC dùng JSONL chỉ tách ở LF, và message_update trên dây chỉ mang delta.
  • RPC: lệnh có id tuỳ chọn và được xử lý đồng thời; prompt trả lời một lần sau preflight kèm disposition; agent_settled là tín hiệu hết việc; hộp thoại của extension là cặp extension_ui_request/extension_ui_response.
  • Khi session bị thay (/new, resume, fork, import), runtime tháo session cũ, dựng lại services và gọi callback rebind của mode.

13pi-tui: giao diện terminal vẽ theo vi sai#

Vai trò của pi-tui#

Một coding agent chạy trong terminal phải vẽ cùng lúc nhiều thứ thay đổi liên tục: câu trả lời đang stream từng token, các hàng tool với trạng thái đang chạy, ô soạn thảo nơi người dùng vẫn gõ được, footer với số token và phần trăm cửa sổ ngữ cảnh (context window). Terminal không có DOM. Thứ duy nhất chương trình có là một luồng byte ghi ra stdout, gồm chữ xen với các chuỗi điều khiển (escape sequence) để di chuyển con trỏ, xoá dòng, đổi màu. Nếu mỗi token mới lại xoá màn hình rồi vẽ lại tất cả, màn hình nhấp nháy, CPU bận vô ích, và scrollback (phần lịch sử mà terminal giữ phía trên màn hình) bị phá. pi-tui giải quyết đúng việc này: ứng dụng mô tả giao diện bằng một cây component, mỗi component chỉ biết trả về các dòng văn bản cho một độ rộng; pi-tui so khung hình mới với khung trước rồi chỉ ghi phần khác.

Gói là @earendil-works/pi-tui, thư mục packages/tui, khoảng 19.600 dòng TypeScript trong src/. README mô tả nó là “Minimal terminal UI framework with differential rendering and synchronized output for flicker-free interactive CLI applications” (README.md:3). Lúc chạy, gói chỉ phụ thuộc ba thư viện: get-east-asian-width để đo độ rộng ký tự Đông Á, marked và marked-cjk-friendly để phân tích Markdown (package.json:53–57). Phần còn lại, từ phân tích phím, độ rộng grapheme, bố cục, cuộn, chọn văn bản tới ảnh inline, đều tự viết.

Ai dùng gói này:

  • Chế độ interactive của coding agent (packages/coding-agent/src/modes/interactive/) là người dùng chính: nó dựng toàn bộ giao diện chat trên pi-tui (mục Interactive mode). Các chế độ print, JSON, RPC và SDK không vẽ TUI; xem chương Các chế độ chạy.
  • Lệnh pi config dựng riêng một TuiMainScreen nhỏ cho bộ chọn cấu hình (config-selector.ts:25).
  • Extension vẽ hộp thoại, widget, overlay qua ctx.ui bằng chính các component của pi-tui. Bề mặt API đó thuộc chương Extensions; tài liệu dặn rõ “Do not create a second terminal renderer inside an extension” (docs/tui.md:18).
InteractiveMode (coding agent)
Một cây component duy nhất, theme, keybinding app.*; nối sự kiện của session vào component.
TUI: TuiMainScreen | TuiAltScreen
Lịch render, so khung hình, focus, overlay, định tuyến phím và chuột. Hai cài đặt cùng một interface.
Component
Container, Text, Markdown, Editor, Image, SelectList, VStack, ScrollView... Mỗi cái trả về string[] cho một độ rộng.
Terminal: ProcessTerminal
Raw mode, bracketed paste, thương lượng giao thức bàn phím Kitty, tách luồng stdin thành từng chuỗi, ghi stdout.
Native helper
Clipboard, trạng thái phím bổ trợ, chế độ nhập VT của console Windows. Không bắt buộc; thiếu thì có đường lui.

Mô hình tư duy: component, frame và vi sai#

Mọi thứ trong chương xoay quanh một vòng: có sự kiện, component đổi state và xin vẽ lại; TUI gộp các lời xin đó thành một khung hình (frame), dựng khung bằng cách gọi render(width) trên cây, rồi so với khung trước để quyết định ghi byte nào.

sự kiện: token mới, phím bấm, resize, nhịp spinner, màu terminal trả về
   │  component đổi state, gọi tui.requestRender()
   ▼
requestRender()   gộp lại: tối đa một khung mỗi 16 ms; phím bấm thì vẽ ngay ở nextTick
   ▼
doRender()
   1. lines = root.render(width)        mỗi phần tử một dòng, rộng không quá width
   2. resolveFakeCursors(lines)         con trỏ giả thành video đảo (SGR 7 / 27)
   3. compositeOverlays(lines)          chồng overlay lên các dòng nền
   4. extractCursorPosition(lines)      tìm và gỡ CURSOR_MARKER, nhớ hàng/cột
   5. applyLineResets(lines)            nối \x1b[0m\x1b]8;;\x07 vào cuối mỗi dòng
   6. so với khung trước
        ├─ TuiMainScreen (regular):    từ dòng đầu tiên khác, ghi đè tới hết;
        │                               dòng dư ở đáy đẩy dòng cũ lên scrollback
        └─ TuiAltScreen  (fullscreen): so từng hàng của viewport, hàng khác thì ghi
   7. bọc trong \x1b[?2026h … \x1b[?2026l, ghi ra stdout, lưu làm khung trước
Đường đi của một khung hình (giản lược; thứ tự bước 2 tới 5 trong TuiAltScreen có thêm tô kết quả tìm kiếm, vùng chọn và thông báo nổi).

Từ vựng dùng trong phần còn lại:

Component
Đối tượng có render(width): string[]. Mỗi chuỗi là một dòng terminal, có thể chứa mã ANSI, và độ rộng hiển thị không được vượt width.
Retained mode (giữ cây)
Ứng dụng giữ một cây đối tượng component sống lâu, đổi state của chúng, còn TUI gọi lại render() trên cả cây mỗi khung. Ngược với immediate mode, nơi ứng dụng tự vẽ lại từ đầu mỗi khung. Component tự cache kết quả nên phần lớn lời gọi render() chỉ trả về mảng cũ.
Khung hình (frame)
Mảng dòng TUI có được sau bước 5. Khung trước được giữ trong previousLines (regular) hoặc previousScreen (fullscreen).
Viewport, scrollback
Viewport là phần đang hiện trên màn hình, cao bằng số hàng của terminal. Scrollback là lịch sử phía trên viewport mà terminal tự giữ; chương trình không sửa lại được những gì đã trôi vào đó.
Main screen, alternate screen
Terminal có hai bộ đệm. Main screen có scrollback. Alternate screen (bật bằng \x1b[?1049h) là một tấm cố định đúng bằng màn hình, không có scrollback, và khi tắt thì main screen hiện lại nguyên vẹn; vim và less dùng nó.
Synchronized output
Cặp CSI ?2026h / CSI ?2026l (\x1b[?2026h … \x1b[?2026l): terminal hỗ trợ sẽ gom mọi thay đổi ở giữa rồi hiện một lần, nên người dùng không thấy trạng thái vẽ dở. Terminal không hỗ trợ thì bỏ qua cặp mã này.
Vi sai (differential rendering)
Chỉ ghi những dòng (hoặc hàng) khác với khung trước, thay vì vẽ lại toàn bộ.

Các chuỗi điều khiển xuất hiện nhiều nhất trong code:

ChuỗiNghĩaAi dùng
\x1b[?2026h, \x1b[?2026lBắt đầu, kết thúc synchronized outputCả hai renderer, mỗi khung
\x1b[nA, \x1b[nB, \r, \r\nLên, xuống n dòng; về cột 0; xuống dòng (ở hàng cuối thì cuộn màn hình)TuiMainScreen: di chuyển tương đối
\x1b[r;cH, \x1b[nGĐặt con trỏ tuyệt đối; về cột nTuiAltScreen đặt từng hàng; regular đặt cột con trỏ phần cứng
\x1b[2K, \x1b[2J\x1b[H\x1b[3JXoá cả dòng; xoá màn hình, về góc, xoá scrollbackGhi đè một dòng; vẽ lại toàn bộ ở regular
\x1b[?1049h, \x1b[?7l, \x1b[?1006hVào alternate screen; tắt tự xuống dòng; báo chuột dạng SGRTuiAltScreen
\x1b[?2004h, \x1b[>7u, \x1b[>4;2mBracketed paste; đẩy cờ bàn phím Kitty; modifyOtherKeys mức 2ProcessTerminal lúc khởi động
\x1b]10;?\x07, \x1b]11;?\x07, \x1b]4;i;?\x07, \x1b[cHỏi màu chữ, màu nền, màu bảng i; DA1 làm mốc kết thúcSystem theme
\x1b]8;;url\x1b\\, \x1b]133;A\x07, \x1b]52;c;…\x07Hyperlink OSC 8; dấu đầu lời nhắc OSC 133; ghi clipboard OSC 52Markdown, tin nhắn, chọn văn bản ở fullscreen

Data model: Component, Container và TUI#

Component: hợp đồng duy nhất

Toàn bộ thư viện dựa trên một interface rất nhỏ:

Component: render, handleInput, handleMouse, invalidate
pi/packages/tui/src/tui.tsdòng 117–142TypeScript
export interface Component {	/**	 * Render the component to lines for the given viewport width	 * @param width - Current viewport width	 * @returns Array of strings, each representing a line	 */	render(width: number): string[];	/** Optional handler for keyboard input when component has focus. */	handleInput?(data: string): void;	/** Optional normalized mouse handler. */	handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;	/**	 * If true, component receives key release events (Kitty protocol).	 * Default is false - release events are filtered out.	 */	wantsKeyRelease?: boolean;	/**	 * Invalidate any cached rendering state.	 * Called when theme changes or when component needs to re-render from scratch.	 */	invalidate(): void;}
  • render(width) là bắt buộc và thuần: nhận độ rộng, trả về các dòng. Component không biết mình nằm ở hàng nào trên màn hình, cũng không tự ghi ra terminal. README nhấn mạnh mỗi dòng “must not exceed width or the TUI will error” và khuyên dùng truncateToWidth() (README.md:260). “Độ rộng” ở đây là số cột hiển thị, không phải string.length: visibleWidth() bỏ qua mã ANSI, OSC, APC và tính ký tự CJK, emoji là hai cột (utils.ts:250).
  • handleInput(data) nhận dữ liệu thô từ terminal (có thể là một chuỗi escape), chỉ khi component đang giữ focus.
  • handleMouse(event) nhận sự kiện chuột đã chuẩn hoá, chỉ ở chế độ fullscreen (mục Overlay, focus và định tuyến input).
  • wantsKeyRelease: với giao thức Kitty, terminal báo cả lúc nhả phím; mặc định TUI lọc bỏ các sự kiện này.
  • invalidate() bắt buộc: xoá cache để lần render() sau dựng lại từ đầu. Ứng dụng gọi tui.invalidate() (lan xuống cả cây) khi đổi theme; chính TUI gọi khi terminal báo kích thước ô ảnh mới.

Vì sao invalidate() bắt buộc mà không tuỳ chọn? Vì cache là mặc định. Mẫu chuẩn trong README là giữ cachedWidth và cachedLines, trả mảng cũ nếu độ rộng không đổi (README.md:888–915). Một component đã cache chuỗi có mã màu thì khi theme đổi sẽ vẽ màu cũ mãi mãi nếu không có đường xoá cache; tài liệu của coding agent cảnh báo đúng điều này (docs/tui.md:170).

Một hợp đồng ngầm nữa: style không được kéo sang dòng sau. TUI nối \x1b[0m (reset SGR) và \x1b]8;;\x07 (đóng hyperlink OSC 8) vào cuối mọi dòng (tui.ts:1450–1459, README.md:265). Nhờ vậy mỗi dòng độc lập: thuật toán vi sai ghi lại dòng 23 mà không cần biết dòng 22 đã để lại màu gì.

Con trỏ: CURSOR_MARKER và con trỏ giả

Component trả về chuỗi, không trả về toạ độ. Vậy làm sao TUI biết đặt con trỏ phần cứng ở đâu? Vị trí đó cần cho bộ gõ IME (tiếng Trung, Nhật, Hàn...), vì cửa sổ gợi ý của bộ gõ hiện cạnh con trỏ của terminal. Component chèn một dấu vô hình vào đúng vị trí:

Focusable, CURSOR_MARKER và renderFakeCursor()
pi/packages/tui/src/tui.tsdòng 180–207TypeScript
export interface Focusable {	/** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */	focused: boolean;}/** Type guard to check if a component implements Focusable */export function isFocusable(component: Component | null): component is Component & Focusable {	return component !== null && "focused" in component;}/** * Cursor position marker - APC (Application Program Command) sequence. * This is a zero-width escape sequence that terminals ignore. * Components emit this at the cursor position when focused. * TUI finds and strips this marker, then positions the hardware cursor there. */export const CURSOR_MARKER = "\x1b_pi:c\x07";/** Fake cursor markers - zero-width APC sequences around the text drawn as the cursor. See resolveFakeCursors(). */export const FAKE_CURSOR_START = "\x1b_pi:fc\x07";export const FAKE_CURSOR_END = "\x1b_pi:/fc\x07";// CURSOR_MARKER + FAKE_CURSOR_START + text + FAKE_CURSOR_END. Truncating the line right after the cursor cell drops the end.const FOCUSED_FAKE_CURSOR = /(\x1b_pi:c\x07)\x1b_pi:fc\x07(.*?)(?:\x1b_pi:\/fc\x07|$)/s;/** Wrap text in fake cursor markers. */export function renderFakeCursor(text: string): string {	return `${FAKE_CURSOR_START}${text}${FAKE_CURSOR_END}`;}

CURSOR_MARKER là một chuỗi APC (ESC _ … BEL) mà terminal bỏ qua. Mỗi khung, TUI quét dấu này trong các dòng thuộc viewport, tính cột bằng visibleWidth của phần trước dấu, gỡ dấu đi, rồi đặt con trỏ phần cứng ở đó (tui.ts:1479–1497). Con trỏ phần cứng mặc định vẫn ẩn; thứ người dùng thấy là con trỏ giả: ô dưới con trỏ được bọc bởi hai dấu APC khác và TUI đổi chúng thành \x1b[7m/\x1b[27m (video đảo). Khi bật showHardwareCursor, con trỏ giả ngay sau CURSOR_MARKER bị bỏ để chỉ còn con trỏ thật (tui.ts:1504–1510). Cơ chế dấu APC thay cho việc component tự ghi \x1b[7m là thay đổi phá vỡ tương thích của 1.2.0 (CHANGELOG.md:22).

Container: ghép cây

Container: render() là phép nối các dòng của con
pi/packages/tui/src/tui.tsdòng 358–381, 405–418TypeScript
export class Container implements Component {	children: Component[] = [];	private mouseLayout?: { width: number; children: Array<{ component: Component; height: number }> };	addChild(component: Component): void {		this.children.push(component);	}	removeChild(component: Component): void {		const index = this.children.indexOf(component);		if (index !== -1) {			this.children.splice(index, 1);		}	}	clear(): void {		this.children = [];	}	invalidate(): void {		for (const child of this.children) {			child.invalidate?.();		}	}	render(width: number): string[] {		const lines: string[] = [];		const mouseChildren: Array<{ component: Component; height: number }> = [];		for (const child of this.children) {			const childLines = child.render(width);			mouseChildren.push({ component: child, height: childLines.length });			for (const line of childLines) {				lines.push(line);			}		}		this.mouseLayout = { width, children: mouseChildren };		return lines;	}}
handleMouse() ở giữa (bị lược) dùng mouseLayout, tức chiều cao của từng con ghi lại ở lần render trước, để biết con trỏ chuột rơi vào con nào mà không phải render lại.

Đây là toàn bộ “bố cục” ở chế độ regular: xếp dọc, nối mảng. Không có toạ độ, không có lề, không có z-index. Một Container chứa Text, Spacer(1) và Markdown trả về các dòng của Text, rồi một dòng rỗng, rồi các dòng của Markdown. Vòng for thay cho push(...childLines) là có chủ ý: spread một mảng quá dài vượt giới hạn đối số của V8 và gây tràn ngăn xếp ở session dài (CHANGELOG.md:616). Bố cục có chiều cao ràng buộc (VStack, HStack, ScrollView) chỉ có nghĩa ở fullscreen; mục TuiAltScreen giải thích.

TUI: một interface, hai renderer

TUI mở rộng Component: bản thân TUI là container gốc (children, addChild). Ngoài ra nó gom các trách nhiệm toàn cục: focus (setFocus), overlay, lắng nghe input thô (addInputListener trả về hàm huỷ), vòng đời (start/stop), lịch render (requestRender/renderNow), và hỏi màu terminal. fullRedraws là bộ đếm số lần vẽ lại toàn bộ, tiện cho test và cho việc đo đạc. ViewportTUI chỉ thêm setLayoutRoot(); hàm isViewportTUI() nhận ra nó qua một khoá Symbol.for(...), tức một symbol trong bảng đăng ký toàn cục, nên phép kiểm tra vẫn đúng với đối tượng tạo từ một bản sao khác của gói.

Hai cài đặt cùng kế thừa một lớp trừu tượng nội bộ, TuiBase (không export). Nó giữ focus, overlay, danh sách input listener và lịch render, còn việc ghi ra terminal được để lại cho lớp con theo kiểu template method:

TuiBase: phần chung, doRender() và các hook để lớp con cài
pi/packages/tui/src/tui.tsdòng 506–522, 552–562TypeScript
export abstract class TuiBase extends Container implements TUI {	abstract readonly mode: TuiMode;	public terminal: Terminal;	private focusedComponent: Component | null = null;	private inputListeners = new Set<TuiInputListener>();	/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */	public onDebug?: () => void;	private renderRequested = false;	private immediateRenderScheduled = false;	private renderTimer: NodeJS.Timeout | undefined;	private lastRenderAt = 0;	private static readonly MIN_RENDER_INTERVAL_MS = 16;	private showHardwareCursor = false;	private clearOnShrink = false;	protected fullRedrawCount = 0;	protected stopped = false;	protected abstract doRender(): void;	protected resetRenderState(): void {}	protected beforeTerminalStart(): void {}	protected afterTerminalStart(): void {}	protected beforeTerminalStop(_options: TuiStopOptions): void {}	protected afterTerminalStop(_options: TuiStopOptions): void {}
TuiMainScreenTuiAltScreen
mode"regular""fullscreen", là ViewportTUI
Bộ đệm terminalMain screen, giữ scrollbackAlternate screen, không scrollback
Khung trướcpreviousLines: cả tài liệupreviousScreen: đúng height hàng
Ai cuộnTerminal (scrollback gốc)Ứng dụng (ScrollView, chuột, phím)
ChuộtKhông bắt; terminal tự chọn chữ, cuộnBắt chuột SGR, tự chọn chữ, tự copy, tự mở link
Khi thoátĐể nguyên nội dung, đưa con trỏ xuống dướiRời alternate screen, in lại toàn bộ tài liệu vào main screen

Terminal: thứ duy nhất TUI nói chuyện

TUI không đụng trực tiếp process.stdout; nó nhận một Terminal:

Interface thật lớn hơn bản trong README: README chỉ liệt kê start, stop, write, kích thước và vài lệnh con trỏ (README.md:757–773), còn code ở v1.4.0 đòi thêm drainInput, kittyProtocolActive, setTitle, setProgress (thanh tiến trình OSC 9;4 trên tab terminal), setProgramStatus (OSC 7501, bắt buộc từ 1.1.0, CHANGELOG.md:44) và restoreOnExit tuỳ chọn. Cài đặt thật là ProcessTerminal; các test dùng một VirtualTerminal dựng trên @xterm/headless (packages/tui/test/virtual-terminal.ts), nhờ đó kiểm tra được chính xác màn hình sau mỗi khung mà không cần terminal thật.

Vòng render: requestRender, nhịp 16 ms và renderNow#

Vấn đề. Khi mô hình stream, các sự kiện message_update tới dồn dập, mỗi sự kiện chỉ mang vài token. Interactive mode gọi ui.requestRender() sau mỗi sự kiện. Nếu mỗi lời gọi là một lần vẽ, phần lớn công sức là vẽ những khung không ai kịp thấy. Ngược lại, khi người dùng gõ phím, độ trễ dù 16 ms cũng cảm nhận được.

Giải pháp. TuiBase có ba lối vào và một bộ hẹn giờ:

renderNow(), requestRender() và requestImmediateRender()
pi/packages/tui/src/tui.tsdòng 1019–1053TypeScript
renderNow(force = false): void {	if (force) this.resetRenderState();	this.renderRequested = false;	this.cancelRenderTimer();	this.lastRenderAt = performance.now();	this.doRender();}requestRender(force = false): void {	if (force) {		this.resetRenderState();		this.requestImmediateRender();		return;	}	if (this.renderRequested) return;	this.renderRequested = true;	process.nextTick(() => this.scheduleRender());}private requestImmediateRender(): void {	this.cancelRenderTimer();	this.renderRequested = true;	if (this.immediateRenderScheduled) return;	this.immediateRenderScheduled = true;	process.nextTick(() => {		this.immediateRenderScheduled = false;		if (this.stopped || !this.renderRequested) return;		// A previously queued scheduleRender() can create a timer before this		// callback runs. User input must preempt that throttled frame.		this.cancelRenderTimer();		this.renderRequested = false;		this.lastRenderAt = performance.now();		this.doRender();	});}
scheduleRender(): giữ khoảng cách tối thiểu MIN_RENDER_INTERVAL_MS = 16 ms
pi/packages/tui/src/tui.tsdòng 1061–1079TypeScript
private scheduleRender(): void {	if (this.stopped || this.renderTimer || !this.renderRequested) {		return;	}	const elapsed = performance.now() - this.lastRenderAt;	const delay = Math.max(0, TuiBase.MIN_RENDER_INTERVAL_MS - elapsed);	this.renderTimer = setTimeout(() => {		this.renderTimer = undefined;		if (this.stopped || !this.renderRequested) {			return;		}		this.renderRequested = false;		this.lastRenderAt = performance.now();		this.doRender();		if (this.renderRequested) {			this.scheduleRender();		}	}, delay);}
  1. requestRender()Đặt cờ renderRequested; nếu cờ đã bật thì không làm gì. Lời xin đầu tiên hẹn scheduleRender() ở process.nextTick, nên mọi lời xin trong cùng một lượt xử lý đồng bộ gộp thành một.
  2. scheduleRender()Tính thời gian từ khung trước; nếu chưa đủ 16 ms thì setTimeout cho phần còn thiếu, đủ rồi thì hẹn 0 ms. Trong lúc chờ, mọi requestRender() đều rơi vào cờ đã bật. Sau khi vẽ, nếu trong lúc vẽ có ai xin thêm, nó tự hẹn khung tiếp theo.
  3. requestImmediateRender()Đường nhanh cho input: huỷ bộ hẹn giờ đang chờ và vẽ ở nextTick. handleTerminalInput() gọi nó ngay sau khi component đang focus xử lý phím (tui.ts:1145–1156). Chú thích ghi lý do: trên Windows ngay cả setTimeout(0) có thể mất trọn một nhịp 16 ms (CHANGELOG 0.84.0, CHANGELOG.md:268).
  4. renderNow() và forcerenderNow() vẽ đồng bộ ngay. force = true gọi resetRenderState() trước, xoá khung trước nên khung kế tiếp là vẽ lại toàn bộ. Interactive mode dùng requestRender(true) sau khi quay lại từ Ctrl+Z (SIGCONT) và từ trình soạn thảo ngoài, vì lúc đó không còn biết màn hình đang hiện gì (interactive-mode.ts:4424–4428).

Cách gộp theo nhịp 16 ms (khoảng 60 khung mỗi giây) có từ 0.65.2, để sửa việc vẽ dồn dập khi stream nhiều (CHANGELOG.md:628). Hệ quả cho người viết component: gọi requestRender() thoải mái, nó rẻ; đừng tự gọi renderNow() trong vòng lặp nóng.

start() và stop()

start() (tui.ts:950–966) đăng ký một handler cho sự kiện exit của tiến trình, gọi hook beforeTerminalStart() (fullscreen vào alternate screen ở đây), bật terminal với hai callback: input đi vào handleTerminalInput(), resize chỉ là một requestRender(). Rồi nó ẩn con trỏ, hỏi kích thước ô bằng pixel nếu terminal vẽ được ảnh (CSI 16 t), và xin khung đầu tiên. Handler exit là lưới an toàn của 1.4.0: nếu tiến trình gọi thẳng process.exit() mà không qua stop(), nó vẫn ghi đồng bộ các mã tắt chế độ chuột, bàn phím Kitty, bracketed paste và alternate screen, để shell của người dùng không bị kẹt (tui.ts:574–585, CHANGELOG.md:13). stop() làm ngược lại theo cùng khung: gỡ handler, huỷ bộ hẹn giờ render, gọi beforeTerminalStop(), hiện con trỏ, dừng terminal, rồi afterTerminalStop() (tui.ts:1006–1017).

Phía terminal, ProcessTerminal.start() làm phần việc với hệ điều hành:

ProcessTerminal.start(): raw mode, bracketed paste, resize, thương lượng bàn phím
pi/packages/tui/src/terminal.tsdòng 199–230TypeScript
start(onInput: (data: string) => void, onResize: () => void): void {	this.inputHandler = onInput;	this.resizeHandler = onResize;	// Save previous state and enable raw mode	this.wasRaw = process.stdin.isRaw || false;	if (process.stdin.setRawMode) {		process.stdin.setRawMode(true);	}	process.stdin.setEncoding("utf8");	process.stdin.resume();	// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~	process.stdout.write("\x1b[?2004h");	// Set up resize handler immediately	process.stdout.on("resize", this.resizeHandler);	// Refresh terminal dimensions - they may be stale after suspend/resume	// (SIGWINCH is lost while process is stopped). Unix only, best-effort.	refreshTerminalDimensions();	// On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends	// VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console	// events that lose modifier information. Must run AFTER setRawMode(true)	// since that resets console mode flags.	this.enableWindowsVTInput();	// Query Kitty keyboard protocol and fall back to modifyOtherKeys when DA confirms no Kitty response.	// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/	this.queryAndEnableKittyProtocol();}

Raw mode tắt việc terminal tự xử lý dòng: mỗi phím tới ngay, và Ctrl+C không còn phát SIGINT mà thành byte \x03 cho ứng dụng tự quyết (README nhắc điều này trong ví dụ đầu tiên, README.md:43). Bracketed paste (\x1b[?2004h) bảo terminal bọc nội dung dán trong \x1b[200~ … \x1b[201~, nhờ đó editor phân biệt được dán với gõ. Lệnh gửi SIGWINCH cho chính mình làm mới kích thước sau khi tiến trình bị tạm dừng rồi chạy lại, vì tín hiệu resize bị mất trong lúc dừng. Thương lượng bàn phím được trình bày ở mục Phân tích phím.

Thuật toán vi sai của TuiMainScreen#

Vấn đề. Ở chế độ regular, pi viết vào main screen như mọi chương trình dòng lệnh: nội dung cũ trôi lên scrollback, người dùng cuộn bằng con lăn của terminal và chọn chữ bằng chuột của terminal. TUI chỉ thật sự kiểm soát phần đuôi tài liệu đang nằm trong viewport. Nó phải tự nhớ con trỏ đang đứng ở dòng nào của tài liệu và viewport bắt đầu từ dòng nào, rồi di chuyển con trỏ một cách tương đối (lên, xuống n dòng), vì trên main screen hàng màn hình của một dòng tài liệu thay đổi mỗi khi nội dung cuộn.

State của TuiMainScreen: khung trước và các con trỏ dòng
pi/packages/tui/src/tui-main-screen.tsdòng 113–133TypeScript
export interface TuiMainScreenRenderState {	previousLines: string[];	previousWidth: number;	previousHeight: number;	cursorRow: number;	hardwareCursorRow: number;	maxLinesRendered: number;	previousViewportTop: number;}/** TUI implementation that renders into the terminal's main screen and scrollback. */export class TuiMainScreen extends TuiBase implements TUI {	readonly mode = "regular" as const;	private previousLines: string[] = [];	private previousKittyImageIds = new Set<number>();	private previousWidth = 0;	private previousHeight = 0;	private cursorRow = 0;	private hardwareCursorRow = 0;	private maxLinesRendered = 0;	private previousViewportTop = 0;
  • previousLines: khung trước, gồm cả tài liệu chứ không chỉ phần đang hiện, ở dạng đã nối reset.
  • previousViewportTop: chỉ số của dòng tài liệu đang nằm ở hàng trên cùng màn hình.
  • hardwareCursorRow: dòng tài liệu mà con trỏ terminal đang thật sự đứng; mọi lệnh di chuyển tính từ đây. cursorRow là dòng cuối của nội dung.
  • maxLinesRendered: mức cao nhất của “vùng làm việc”, chỉ dùng cho tuỳ chọn clearOnShrink.
  • previousKittyImageIds: id các ảnh Kitty đang hiện, để xoá khi dòng chứa chúng đổi.

Cặp captureRenderState()/restoreRenderState() chuyển state này sang một renderer mới khi interactive mode đổi qua lại giữa regular và fullscreen lúc đang chạy (tui-main-screen.ts:135–156; mục Interactive mode).

Chuẩn bị khung

doRender(): kích thước, viewport, rồi bốn bước chuẩn bị chung
pi/packages/tui/src/tui-main-screen.tsdòng 247–275TypeScript
protected doRender(): void {	if (this.stopped) return;	const width = this.terminal.columns;	const height = this.terminal.rows;	const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;	const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;	const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;	let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop;	let viewportTop = prevViewportTop;	let hardwareCursorRow = this.hardwareCursorRow;	const computeLineDiff = (targetRow: number): number => {		const currentScreenRow = hardwareCursorRow - prevViewportTop;		const targetScreenRow = targetRow - viewportTop;		return targetScreenRow - currentScreenRow;	};	// Render all components to get new lines	// Resolve fake cursors before compositing so overlays and line slicing see plain SGR codes	let newLines = this.resolveFakeCursors(this.render(width));	// Composite overlays into the rendered lines (before differential compare)	if (this.hasOverlayEntries) {		newLines = this.compositeOverlays(newLines, width, height);	}	// Extract cursor position before applying line resets (marker must be found first)	const cursorPos = this.extractCursorPosition(newLines, height);	newLines = this.applyLineResets(newLines);

computeLineDiff(targetRow) đổi cả hai đầu sang toạ độ màn hình (dòng tài liệu trừ dòng đầu viewport) rồi lấy hiệu: con trỏ đang ở hàng màn hình 8, cần tới hàng 5, thì kết quả là −3 và TUI ghi \x1b[3A. Thứ tự chuẩn bị có ý nghĩa. Overlay được ghép vào trước khi so sánh, nên với thuật toán, một hộp thoại nổi chỉ là vài dòng đổi nội dung. Dấu con trỏ phải được tìm trước khi nối reset, vì sau bước đó vị trí của nó trong chuỗi không còn ý nghĩa.

Chuỗi quyết định

Bốn trường hợp vẽ lại toàn bộ được xét trước tiên
pi/packages/tui/src/tui-main-screen.tsdòng 331–361TypeScript
// First render - just output everything without clearing (assumes clean screen)if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {	logRedraw("first render");	fullRender(false);	return;}// Width changes always need a full re-render because wrapping changes.if (widthChanged) {	logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);	fullRender(true);	return;}// Height changes normally need a full re-render to keep the visible viewport aligned,// but Termux changes height when the software keyboard shows or hides.// In that environment, a full redraw causes the entire history to replay on every toggle.if (heightChanged && !isTermuxSession()) {	logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);	fullRender(true);	return;}// Content shrunk below the working area and no overlays - re-render to clear empty rows// (overlays need the padding, so only do this when no overlays are active)// Configurable via setClearOnShrink()if (this.getClearOnShrink() && newLines.length < this.maxLinesRendered && !this.hasOverlayEntries) {	logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);	fullRender(true);	return;}
Điều kiện (theo thứ tự xét)Hành độngVì sao
Khung đầu tiên: chưa có previousLines, kích thước chưa đổifullRender(false): in mọi dòng, không xoá gìGiả định màn hình sạch; giữ nguyên những gì shell in trước đó
Độ rộng đổifullRender(true)Mọi đoạn văn phải ngắt dòng lại, không dòng cũ nào còn đúng
Chiều cao đổi, trừ TermuxfullRender(true)Giữ viewport thẳng hàng; Termux đổi chiều cao mỗi lần bàn phím ảo hiện hay ẩn, vẽ lại sẽ phát lại cả lịch sử
clearOnShrink bật, nội dung ngắn hơn maxLinesRendered, không có overlayfullRender(true)Xoá các hàng trống còn sót. Mặc định tắt để đỡ vẽ lại trên terminal chậm (tui.ts:608–615)
Không dòng nào đổiChỉ đặt lại con trỏ phần cứngKhông ghi khung nào
Chỉ còn đổi ở các dòng đã bị xoá khỏi cuốiXoá dòng thừa tại chỗ; nếu phải lùi lên trên viewport, hoặc thừa hơn một màn hình, thì fullRender(true)Không vẽ được những gì không nhìn thấy
Dòng đầu tiên đổi nằm trên viewportfullRender(true)Phần đã trôi vào scrollback không sửa được bằng di chuyển tương đối
Còn lạiGhi từ firstChanged tới lastChangedĐường nhanh, xảy ra ở hầu hết các khung

Lịch sử của dòng “chiều cao đổi” cho thấy đây là thoả hiệp chứ không phải chân lý. Ở 0.51.1, đổi chiều cao thôi không vẽ lại toàn bộ để bớt nhấp nháy (CHANGELOG.md:904); ở 0.62.0 lại bật lại vì editor và footer bị lệch khi resize (CHANGELOG.md:778); ngoại lệ Termux có sau đó.

Vẽ lại toàn bộ

fullRender(): xoá màn hình và scrollback, in lại cả tài liệu
pi/packages/tui/src/tui-main-screen.tsdòng 278–288, 301–320TypeScript
const fullRender = (clear: boolean): void => {	this.fullRedrawCount += 1;	const output = new BoundedTerminalWriter((data) => this.terminal.write(data));	output.append("\x1b[?2026h"); // Begin synchronized output	if (clear) {		output.append(this.deleteKittyImages(this.previousKittyImageIds));		output.append("\x1b[2J\x1b[H\x1b[3J"); // Clear screen, home, then clear scrollback	}	for (let i = 0; i < newLines.length; i++) {		if (i > 0) output.append("\r\n");		const line = newLines[i];		output.append(line);	}	output.append("\x1b[?2026l"); // End synchronized output	output.flush();	this.cursorRow = Math.max(0, newLines.length - 1);	this.hardwareCursorRow = this.cursorRow;	// Reset max lines when clearing, otherwise track growth	if (clear) {		this.maxLinesRendered = newLines.length;	} else {		this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);	}	const bufferLength = Math.max(height, newLines.length);	this.previousViewportTop = Math.max(0, bufferLength - height);	this.positionHardwareCursor(cursorPos, newLines.length);	this.previousLines = newLines;	this.previousKittyImageIds = this.collectKittyImageIds(newLines);	this.previousWidth = width;	this.previousHeight = height;};
Nhánh bị lược (289–300) xử lý ảnh Kitty cao nhiều hàng: chừa trước các hàng trống rồi mới đặt ảnh, để ảnh không làm cuộn lệch.

\x1b[2J\x1b[H\x1b[3J xoá màn hình, đưa con trỏ về góc trên trái, rồi xoá scrollback; sau đó toàn bộ tài liệu được in lại. Ở một session dài, đó là hàng nghìn dòng. BoundedTerminalWriter chia đầu ra thành từng khúc 1 MiB và không cắt đôi cặp surrogate UTF-16, để một lần vẽ lớn không bao giờ dựng ra một chuỗi vượt giới hạn độ dài chuỗi của V8 (tui-main-screen.ts:9–17).

Một chi tiết nhỏ quyết định nhánh nào được chọn: resetRenderState() đặt previousWidth = -1 chứ không phải 0 (tui-main-screen.ts:158–166). TUI mới tạo có previousWidth = 0, được hiểu là “chưa vẽ lần nào” và đi vào nhánh khung đầu tiên không xoá màn hình. Còn sau requestRender(true), giá trị −1 khác mọi độ rộng thật nên được hiểu là “độ rộng đổi”, đi vào nhánh có xoá.

Tìm vùng thay đổi

So từng cặp dòng cùng chỉ số; dòng nối thêm kéo lastChanged tới cuối
pi/packages/tui/src/tui-main-screen.tsdòng 363–390TypeScript
// Find first and last changed lineslet firstChanged = -1;let lastChanged = -1;const maxLines = Math.max(newLines.length, this.previousLines.length);for (let i = 0; i < maxLines; i++) {	const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";	const newLine = i < newLines.length ? newLines[i] : "";	if (oldLine !== newLine) {		if (firstChanged === -1) {			firstChanged = i;		}		lastChanged = i;	}}const appendedLines = newLines.length > this.previousLines.length;if (appendedLines) {	if (firstChanged === -1) {		firstChanged = this.previousLines.length;	}	lastChanged = newLines.length - 1;}if (firstChanged !== -1) {	const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines);	firstChanged = expandedRange.firstChanged;	lastChanged = expandedRange.lastChanged;}const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;

Phép so sánh là so chuỗi tại cùng chỉ số, không có thuật toán dãy con chung dài nhất kiểu diff. Nếu một dòng được chèn vào giữa, mọi dòng bên dưới lệch đi một chỉ số và đều bị coi là đổi. Cách đơn giản này chấp nhận được nhờ quy tắc ở mục sau: firstChanged không được nằm trên viewport, nên đoạn phải ghi lại không bao giờ dài hơn một màn hình cộng số dòng mới. appendStart đánh dấu trường hợp chỉ có dòng nối thêm ở cuối, không dòng cũ nào đổi.

Hai lối tắt và một lối ra

Không có gì đổi; hoặc thay đổi nằm trên viewport
pi/packages/tui/src/tui-main-screen.tsdòng 392–398, 450–456TypeScript
// No changes - but still need to update hardware cursor position if it movedif (firstChanged === -1) {	this.positionHardwareCursor(cursorPos, newLines.length);	this.previousViewportTop = prevViewportTop;	this.previousHeight = height;	return;}// Differential rendering can only touch what was actually visible.// If the first changed line is above the previous viewport, we need a full redraw.if (firstChanged < prevViewportTop) {	logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);	fullRender(true);	return;}

Kiểm tra ở dòng 452 là giới hạn căn bản của chế độ regular. Những dòng đã trôi vào scrollback thuộc về terminal. Không có lệnh di chuyển tương đối nào với tới chúng, và nếu có cũng không an toàn vì người dùng có thể đang cuộn xem. Cách duy nhất để sửa là xoá sạch và in lại.

Ghi phần thay đổi

Đường nhanh: tới dòng đầu tiên đổi, ghi đè tới lastChanged
pi/packages/tui/src/tui-main-screen.tsdòng 458–494, 517, 546–547TypeScript
// Render from first changed line to end// Keep updates wrapped in synchronized output while writing bounded chunks.const output = new BoundedTerminalWriter((data) => this.terminal.write(data));output.append("\x1b[?2026h"); // Begin synchronized outputoutput.append(this.deleteChangedKittyImages(firstChanged, lastChanged));const prevViewportBottom = prevViewportTop + height - 1;const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;if (moveTargetRow > prevViewportBottom) {	const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));	const moveToBottom = height - 1 - currentScreenRow;	if (moveToBottom > 0) {		output.append(`\x1b[${moveToBottom}B`);	}	const scroll = moveTargetRow - prevViewportBottom;	output.append("\r\n".repeat(scroll));	prevViewportTop += scroll;	viewportTop += scroll;	hardwareCursorRow = moveTargetRow;}// Move cursor to first changed line (use hardwareCursorRow for actual position)const lineDiff = computeLineDiff(moveTargetRow);if (lineDiff > 0) {	output.append(`\x1b[${lineDiff}B`); // Move down} else if (lineDiff < 0) {	output.append(`\x1b[${-lineDiff}A`); // Move up}output.append(appendStart ? "\r\n" : "\r"); // Move to column 0// Only render changed lines (firstChanged to lastChanged), not all lines to end// This reduces flicker when only a single line changes (e.g., spinner animation)const renderEnd = Math.min(lastChanged, newLines.length - 1);for (let i = firstChanged; i <= renderEnd; i++) {	if (i > firstChanged) output.append("\r\n");	const line = newLines[i];	const isImage = isImageLine(line);	output.append("\x1b[2K"); // Clear current line	output.append(line);}
Đoạn bị lược ở giữa vòng lặp xử lý ảnh Kitty nhiều hàng và kiểm tra độ rộng dòng (mục Cạm bẫy).
  • Cuộn trước nếu cần (465–476). Nếu dòng đích nằm dưới đáy viewport cũ, TUI xuống hàng cuối rồi ghi đủ số \r\n để terminal cuộn. Lệnh CSI n B không làm màn hình cuộn khi con trỏ đã ở hàng cuối; chỉ ký tự xuống dòng mới làm được.
  • Dòng nối thêm (464, 486). Cùng lý do: khi chỉ có dòng mới ở cuối, TUI đứng ở dòng ngay trước rồi ghi \r\n, chứ không “di chuyển xuống” một dòng chưa tồn tại.
  • Ghi đè (517, 546). Mỗi dòng: \x1b[2K xoá cả hàng, rồi in nội dung mới. Nhờ xoá trước, TUI không cần biết dòng cũ dài bao nhiêu.
  • Dừng ở lastChanged (488–490). Chú thích trong code ghi rõ: chỉ ghi các dòng đổi để giảm nhấp nháy khi chỉ một dòng giữa màn hình đổi, ví dụ spinner.

Dọn dòng thừa và ghi sổ

Xoá các dòng cũ dư ra, đóng synchronized output, cập nhật state
pi/packages/tui/src/tui-main-screen.tsdòng 549–568, 601–616TypeScript
// Track where cursor ended up after renderinglet finalCursorRow = renderEnd;// If we had more lines before, clear them and move cursor backif (this.previousLines.length > newLines.length) {	// Move to end of new content first if we stopped before it	if (renderEnd < newLines.length - 1) {		const moveDown = newLines.length - 1 - renderEnd;		output.append(`\x1b[${moveDown}B`);		finalCursorRow = newLines.length - 1;	}	const extraLines = this.previousLines.length - newLines.length;	for (let i = newLines.length; i < this.previousLines.length; i++) {		output.append("\r\n\x1b[2K");	}	// Move cursor back to end of new content	output.append(`\x1b[${extraLines}A`);}output.append("\x1b[?2026l"); // End synchronized output// Track cursor position for next render// cursorRow tracks end of content (for viewport calculation)// hardwareCursorRow tracks actual terminal cursor position (for movement)this.cursorRow = Math.max(0, newLines.length - 1);this.hardwareCursorRow = finalCursorRow;// Track terminal's working area (grows but doesn't shrink unless cleared)this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);// Position hardware cursor for IMEthis.positionHardwareCursor(cursorPos, newLines.length);this.previousLines = newLines;this.previousKittyImageIds = this.collectKittyImageIds(newLines);this.previousWidth = width;this.previousHeight = height;

Dòng 608 là chỗ TUI “theo dõi” cú cuộn mà chính nó gây ra: nếu con trỏ đã xuống quá đáy viewport cũ thì terminal đã cuộn, nên dòng đầu viewport mới là finalCursorRow - height + 1. Cuối cùng positionHardwareCursor() đưa con trỏ terminal tới chỗ CURSOR_MARKER, bằng CSI A/B cho hàng và CSI G (cột tuyệt đối) cho cột, rồi ghi lại hardwareCursorRow (tui-main-screen.ts:624–655). Lệnh này được ghi sau khi đã đóng synchronized output. Vì vậy sau mỗi khung, con trỏ terminal đứng ở ô soạn thảo chứ không ở cuối nội dung, và khung tiếp theo bắt đầu tính đường đi từ đó.

Trace: tin nhắn đang stream dài thêm một dòng#

Giờ ghép các mảnh lại trong một kịch bản cụ thể ở chế độ regular. Terminal rộng 40 cột, cao 12 hàng. Session đã có hơn một màn hình nội dung; mô hình đang stream một đoạn văn, và token vừa tới làm đoạn văn xuống thêm một dòng. Các con số bên dưới là ví dụ minh hoạ, nhưng mỗi bước đi đúng nhánh code đã trích ở mục trước.

1. Từ token tới requestRender()

  1. message_updateAgentSession phát sự kiện mang assistant message từng phần (xem chương AgentSession). InteractiveMode.handleEvent() lưu message và gọi streamingComponent.updateContent(message, true) (interactive-mode.ts:3481–3484).
  2. updateContent()AssistantMessageComponent xoá container nội dung rồi dựng lại: một Spacer(1), rồi một Markdown mới cho mỗi khối text (assistant-message.ts:95–117). Chưa có byte nào ra terminal; chỉ có cây đổi.
  3. requestRender()handleEvent() gọi ui.requestRender() (:3513). Giả sử khung trước mới vẽ cách đây 5 ms: scheduleRender() hẹn 11 ms. Những message_update tới trong 11 ms đó chỉ thay cây, không thêm khung nào.
  4. doRender()Bộ hẹn giờ chạy; TuiMainScreen.doRender() gọi render(40) trên cả cây.

2. render() trả về gì

Gốc là chính TUI, một container với bảy con: tài liệu (header, danh sách tài nguyên, chat), tin nhắn chờ, trạng thái, widget phía trên, editor, widget phía dưới, footer (mục Interactive mode). Khi đang stream, tin nhắn chờ và trạng thái rỗng (chỉ báo “working” được nhúng vào viền trên của editor), widget phía trên là một Spacer(1), editor ba dòng, footer hai dòng. AssistantMessageComponent.render() còn gắn dấu OSC 133 vào dòng đầu (A) và dòng cuối (B, C) của tin nhắn không có tool call (assistant-message.ts:80–89). Đuôi của hai khung:

Ví dụ minh hoạ: đuôi khung trước (30 dòng) và khung mới (31 dòng), 40 × 12

dòng   khung trước                         khung mới
 18    nội dung cũ   (hàng 0 màn hình)     nội dung cũ
 ..    ...                                 ...
 21    [A] dòng trống đầu tin nhắn         [A] dòng trống đầu tin nhắn
 22    " Hàm render() trả về một mảng"     " Hàm render() trả về một mảng"
 23    [B][C] " chuỗi, mỗi chuỗi là"       " chuỗi, mỗi chuỗi là một dòng"    đổi
 24    Spacer của widget                   [B][C] " đã cắt vừa độ rộng."      đổi
 25    viền trên editor ── ⠋ Working       Spacer của widget                  đổi
 26    ‸ dòng nhập (CURSOR_MARKER)         viền trên editor ── ⠙ Working      đổi
 27    viền dưới editor                    ‸ dòng nhập (CURSOR_MARKER)        đổi
 28    footer: thư mục, nhánh git          viền dưới editor                   đổi
 29    footer: token, ngữ cảnh (hàng 11)  footer: thư mục, nhánh git         đổi
 30                                        footer: token, ngữ cảnh            mới

[A] = \x1b]133;A\x07      [B][C] = \x1b]133;B\x07\x1b]133;C\x07
mỗi dòng Markdown được đệm dấu cách đủ 40 cột; mỗi dòng kết thúc bằng \x1b[0m\x1b]8;;\x07
Dòng 23 đổi vì hai lẽ: nó không còn là dòng cuối nên mất dấu [B][C], và đoạn văn ngắt dòng lại khi có thêm chữ. Mọi dòng phía dưới lệch xuống một chỉ số.

3. Thuật toán quyết định

BướcGiá trịNhánh
Kích thước40 × 12, như khung trướcKhông vẽ lại toàn bộ vì resize
Viewport và con trỏprevViewportTop = 18, hardwareCursorRow = 26 (dòng nhập, do khung trước đặt)
Vùng đổifirstChanged = 23; có dòng nối thêm nên lastChanged = 30appendStart = false vì 23 ≠ 30
Trên viewport?23 < 18 là saiĐi đường nhanh
Cần cuộn trước?prevViewportBottom = 18 + 12 − 1 = 29; đích 23 không vượt đáyKhông
Di chuyển(23 − 18) − (26 − 18) = −3\x1b[3A, rồi \r
Phạm vi ghirenderEnd = min(30, 30) = 30Tám dòng, 23 tới 30

4. Byte ghi ra

Ví dụ minh hoạ: ba lần ghi ra stdout (ESC viết là \x1b, R = \x1b[0m\x1b]8;;\x07)

lần 1: BoundedTerminalWriter, một khúc
\x1b[?2026h                                bắt đầu synchronized output
\x1b[3A\r                                  từ dòng 26 lên dòng 23, cột 0
\x1b[2K" chuỗi, mỗi chuỗi là một dòng"…R    dòng 23 ở hàng màn hình 5
\r\n\x1b[2K[B][C]" đã cắt vừa độ rộng."…R   dòng 24, hàng 6
\r\n\x1b[2KR                               dòng 25, Spacer
\r\n\x1b[2K── ⠙ Working ───…R                dòng 26, hàng 8
\r\n\x1b[2K\x1b[7m \x1b[27m + 39 dấu cách R   dòng 27, con trỏ giả
\r\n\x1b[2K────…R                           dòng 28
\r\n\x1b[2K(footer: thư mục)R               dòng 29, hàng 11, hàng cuối
\r\n\x1b[2K(footer: token)R                 dòng 30: \r\n ở hàng cuối làm terminal
                                           cuộn lên một hàng; dòng 18 vào scrollback
\x1b[?2026l                                kết thúc synchronized output

lần 2: positionHardwareCursor()
\x1b[3A\x1b[1G                              từ dòng 30 lên dòng 27, cột 1
lần 3
\x1b[?25l                                  con trỏ phần cứng vẫn ẩn
Không có lệnh “cuộn” nào. Cú cuộn là tác dụng phụ của ký tự xuống dòng ở hàng cuối, và đó đúng là cách một dòng đi vào scrollback gốc của terminal.

5. Sau khung

TrườngTrướcSau
previousLines.length3031
previousViewportTop1819 = max(18, 30 − 12 + 1)
hardwareCursorRow2627, do positionHardwareCursor()
cursorRow2930
maxLinesRendered3031

Giả sử trong cùng khung đó còn có một thay đổi ở dòng 10, ví dụ người dùng vừa bấm phím mở rộng output của tool, hay một hàng tool đã trôi lên trên viewport đổi nội dung. Khi đó firstChanged = 10 < 18 và TUI đi nhánh vẽ lại toàn bộ: \x1b[?2026h, \x1b[2J\x1b[H\x1b[3J, cả 31 dòng nối bằng \r\n, \x1b[?2026l. Với 31 dòng thì rẻ; với một session năm nghìn dòng thì terminal phải nhận lại cả năm nghìn dòng, và trên terminal không hỗ trợ synchronized output người dùng thấy lịch sử chạy lại.

TuiAltScreen: chế độ toàn màn hình#

Từ 1.0.0, interactive mode chạy fullscreen mặc định; muốn giữ scrollback như cũ thì đặt tuiMode là "regular" hoặc chạy --tui-mode regular (coding-agent CHANGELOG.md:328, :345). Chế độ này dùng TuiAltScreen, và khác TuiMainScreen ở ba điểm căn bản. Khung hình không còn là cả tài liệu mà đúng height hàng. Màn hình được vẽ bằng toạ độ tuyệt đối. Và vì alternate screen không có scrollback, không có gì bị “đóng băng”: dòng nào của tài liệu cũng vẽ lại được bất cứ lúc nào.

Vào và ra alternate screen

beforeTerminalStart(): vào alternate screen, tắt autowrap, bật chuột
pi/packages/tui/src/tui-alt-screen.tsdòng 372–385TypeScript
const term = process.env.TERM?.toLowerCase() ?? "";// Multiplexers can lag when every pointer movement is forwarded. Button-motion// tracking preserves clicks, wheel events, selections, and scrollbar dragging.const mouseSequence =	process.env.TMUX !== undefined ||	process.env.ZELLIJ !== undefined ||	process.env.STY !== undefined ||	term.startsWith("tmux") ||	term.startsWith("screen")		? ENABLE_BUTTON_MOTION_MOUSE		: ENABLE_ALL_MOTION_MOUSE;this.terminal.write(	`${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? mouseSequence : ""}\x1b[2J\x1b[H\x1b[?25l`,);

Giá trị của các hằng nằm ở đầu file (tui-alt-screen.ts:63–77). Mã chuột gồm 1000 (nhấn và nhả), 1002 (kéo khi giữ nút), 1003 (mọi chuyển động), 1004 (terminal báo được hay mất focus bằng \x1b[I/\x1b[O) và 1006 (toạ độ dạng SGR, không giới hạn 223 cột như dạng cũ). Dưới tmux, Zellij hay GNU Screen, TUI bỏ 1003 vì chuyển tiếp mọi chuyển động chuột làm các multiplexer này chậm (CHANGELOG.md:233). Tắt autowrap (\x1b[?7l) để việc ghi kín cột cuối không làm terminal tự xuống dòng và cuộn. Cũng trong hook này, nếu terminal là iTerm2 thì khả năng vẽ ảnh bị tạm tắt: giao thức ảnh của iTerm2 không xoá hay cắt được một ảnh đã đặt, nên khi viewport cuộn ảnh cũ sẽ đè lên chữ mới (tui-alt-screen.ts:356–360, README.md:688).

afterTerminalStop(): rời alternate screen và in lại cả tài liệu
pi/packages/tui/src/tui-alt-screen.tsdòng 403–423TypeScript
protected override afterTerminalStop(options: TuiStopOptions): void {	if (!this.altScreenActive) return;	this.altScreenActive = false;	if (options.preserveScreen) {		this.terminal.write(`${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`);	} else {		const width = Math.max(1, this.terminal.columns);		const documentLines = this.resolveFakeCursors(this.render(width)).map((line) =>			line.replace(OSC133_ZONE_PREFIX, ""),		);		this.lastDocument = this.applyLineResets(documentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map(			(line) => (isImageLine(line) || visibleWidth(line) <= width ? line : sliceByColumn(line, 0, width, true)),		);		let buffer = `${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}${DISABLE_AUTOWRAP}`;		for (let row = 0; row < this.lastDocument.length; row++) {			if (row > 0) buffer += "\r\n";			buffer += `\r\x1b[2K${this.lastDocument[row] ?? ""}`;		}		buffer += `\x1b[0m${ENABLE_AUTOWRAP}\r\n\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`;		this.terminal.write(buffer);	}

Khi thoát, alternate screen biến mất cùng nội dung của nó. Để nội dung không mất khỏi terminal, mặc định TuiAltScreen render cả cây một lần nữa ở dạng tài liệu không giới hạn chiều cao (gỡ dấu OSC 133 và dấu con trỏ), rồi in vào main screen (README.md:62); gọi stop({ preserveScreen: true }) thì bỏ bước này. Coding agent dùng đúng lựa chọn đó: nó dừng TuiAltScreen mà giữ màn hình, chuyển hẳn sang một TuiMainScreen, và để renderer này in transcript; nếu phiên từng chạy ở regular rồi mới chuyển sang fullscreen, state vi sai đã chụp giúp phần in trước đó ở main screen không bị in lặp (mục Interactive mode).

Bố cục: VStack, HStack, ScrollView

Ở chế độ regular, bố cục là phép nối dọc vô hạn và terminal lo việc cuộn. Ở fullscreen, ứng dụng phải nói vùng nào được bao nhiêu hàng, vùng nào cuộn được. pi-tui có ba component bố cục, chỉ có nghĩa đầy đủ trên ViewportTUI (README.md:121). Chúng tự khai báo bằng một phương thức đặt tên bằng symbol LAYOUT_NODE, trả về một nút bố cục: vstack/hstack với danh sách mục, hoặc scroll với một con duy nhất (layout-node.ts:10–42). Mỗi mục của stack có basis, grow, shrink, minSize, maxSize và visible, như flexbox thu gọn; allocateStackSizes() chia chỗ theo trọng số (stack.ts:135–154). Bố cục của coding agent:

createChatViewport(): transcript cuộn được, phía dưới là dock cố định
pi/packages/coding-agent/src/modes/interactive/chat-viewport.tsdòng 22–46TypeScript
export function createChatViewport(options: ChatViewportOptions): ChatViewport {	const transcript = new ScrollView(options.document, {		follow: "end",		primary: true,		overscroll: "chain",		scrollbar: options.scrollbar ?? "auto",		...(options.scrollbarTrackStyle === undefined ? {} : { scrollbarTrackStyle: options.scrollbarTrackStyle }),		...(options.scrollbarThumbStyle === undefined ? {} : { scrollbarThumbStyle: options.scrollbarThumbStyle }),	});	const dock = new VStack([		{ component: options.pendingMessages, shrink: 1, minSize: 0 },		{ component: options.status, shrink: 1, minSize: 0 },		...(options.widgetsAbove === undefined ? [] : [{ component: options.widgetsAbove, shrink: 1, minSize: 0 }]),		{ component: options.editor, shrink: 1, minSize: 3 },		...(options.widgetsBelow === undefined ? [] : [{ component: options.widgetsBelow, shrink: 1, minSize: 0 }]),		{ component: options.footer, shrink: 1, minSize: 0 },	]);	return {		transcript,		root: new VStack([			{ component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },			{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },		]),	};}

Transcript có basis: 0, grow: 1: nó lấy mọi hàng còn lại. Dock (tin nhắn chờ, trạng thái, widget, editor, footer) có basis: "auto": nó cao đúng bằng nội dung, và mỗi phần có thể co lại, riêng editor không dưới ba hàng. ScrollView của transcript là primary, tức là nơi nhận phím cuộn và con lăn khi con trỏ chuột không nằm trên vùng cuộn nào khác, với follow: "end": dính đáy khi đang ở đáy.

layoutComponent(): nhánh scroll, nơi scrollTop được quyết định mỗi khung
pi/packages/tui/src/layout.tsdòng 136–167TypeScript
if (node.type === "scroll") {	const previousScrollTop = node.state.scrollTop;	const contentWidth = node.state.getContentWidth(safeWidth);	const childBox = layoutComponent(		context,		node.component,		x,		y - previousScrollTop,		contentWidth,		undefined,		clip,	);	const contentHeight = childBox.rect.height;	const viewportHeight = height === undefined ? contentHeight : Math.max(0, Math.floor(height));	node.state.updateLayout(contentHeight, viewportHeight, context.requestRender);	translateBox(childBox, previousScrollTop - node.state.scrollTop);	const scrollView = node.state as ScrollView;	if (node.state.primary || !context.primaryScrollView) context.primaryScrollView = scrollView;	const rect = { x, y, width: safeWidth, height: viewportHeight };	const childClip = intersect(clip, rect);	const box: LayoutBox = {		component,		rect,		clip: childClip,		children: [childBox],		scrollView,		scrollContentLines: renderCached(context, node.component, contentWidth),		layer: 0,	};	childBox.parent = box;	updateClips(childBox, childClip);	return box;

Con của ScrollView được bố trí với chiều cao không giới hạn tại toạ độ y - scrollTop. Biết chiều cao nội dung rồi, updateLayout() quyết định scrollTop mới, và cây con được dịch theo độ chênh:

ScrollView.updateLayout(): đang theo đáy thì nhảy tới đáy, không thì giữ chỗ
pi/packages/tui/src/components/scroll-view.tsdòng 189–201TypeScript
updateLayout(contentHeight: number, viewportHeight: number, requestRender: () => void): void {	this.contentHeight = Math.max(0, Math.floor(contentHeight));	this.currentViewportHeight = Math.max(0, Math.floor(viewportHeight));	this.requestRenderCallback = requestRender;	const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight);	if (this.followingEnd) this.currentScrollTop = maxScrollTop;	else this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop));	if (this.currentScrollTop < maxScrollTop) this.followSuppressedAtEnd = false;	if (this.followEnd && this.currentScrollTop === maxScrollTop && !this.followSuppressedAtEnd) {		this.followingEnd = true;	}	if (this.contentHeight <= this.currentViewportHeight) this.hideTransientScrollbar();}

Đây là toàn bộ cơ chế “tự cuộn theo output khi đang ở đáy, giữ nguyên chỗ khi người dùng đã cuộn lên” mà README mô tả (README.md:751). Cuộn lên bằng con lăn gọi scrollBy(), hàm này tắt followingEnd nếu vị trí mới không phải đáy, và trả về phần delta chưa dùng để chuyển tiếp cho ScrollView bao ngoài (scroll-view.ts:148–162).

renderLayoutFrame() dựng một cây LayoutBox (toạ độ, vùng cắt, các dòng của từng component) rồi sơn nó vào đúng height chuỗi (layout.ts:379–408). Mỗi khung có một cache render riêng, nên mỗi component được render tối đa một lần cho mỗi độ rộng (layout.ts:68–81); cache dài hạn vẫn là cache bên trong component. Khi sơn, một hộp rộng kín màn hình lên một hàng chưa có gì thì gán thẳng tham chiếu chuỗi thay vì ghép lại qua phân tích ANSI và grapheme (layout.ts:345–354), một thay đổi giảm lượng cấp phát mỗi khung khoảng 9 tới 18 lần (CHANGELOG.md:209). Không có layout root thì TUI tự bọc các con trong một ScrollView ngầm, theo đáy và primary (tui-alt-screen.ts:262–269).

doRender(): so từng hàng

  1. Dựng màn hìnhrenderLayoutFrame(root, width, height) trả về đúng height dòng. Nếu đang tìm kiếm và kết quả cần cuộn để lộ ra, khung được dựng lại lần hai.
  2. Trang tríGỡ dấu OSC 133 ở đầu dòng, đổi con trỏ giả, tô kết quả tìm kiếm, ghép nhãn “nhảy tới cuối”, ghép overlay, cắt về height hàng, tô vùng chọn, ghép thông báo nổi (flash).
  3. Chuẩn hoáLấy vị trí con trỏ, nối reset, và cắt dòng quá rộng. Chế độ regular ném lỗi ở tình huống này; fullscreen thì im lặng cắt bớt.
  4. So và ghiKhung đầu hoặc đổi kích thước thì \x1b[2J và vẽ mọi hàng. Nếu không, chỉ những hàng có chuỗi khác previousScreen[row], mỗi hàng bằng \x1b[r;1H\x1b[2K rồi nội dung. Cuối cùng đặt con trỏ tuyệt đối và đóng synchronized output, tất cả trong một lần ghi.

Cùng sự kiện ở fullscreen

Chạy lại kịch bản của mục Trace trên TuiAltScreen, vẫn 40 × 12. Dock cao 6 hàng (Spacer, ba hàng editor, hai hàng footer), nên allocateStackSizes() cho transcript basis 0 nâng lên minSize 1, rồi chia phần dư 5 hàng cho mục duy nhất có grow: transcript được 6 hàng, dock 6 hàng. Tài liệu bây giờ chỉ gồm header, danh sách tài nguyên và chat, tức 24 dòng cũ (0–23) và 25 dòng mới. Đang theo đáy, nên scrollTop đi từ 24 − 6 = 18 lên 25 − 6 = 19.

Ví dụ minh hoạ: cùng khung đó ở TuiAltScreen, 40 × 12

hàng   trước (scrollTop 18)        sau (scrollTop 19)
  0    tài liệu 18                 tài liệu 19                ghi
  1    tài liệu 19                 tài liệu 20                ghi
  2    tài liệu 20                 tài liệu 21 (dòng trống)   ghi
  3    tài liệu 21 (dòng trống)    tài liệu 22                ghi
  4    tài liệu 22                 tài liệu 23                ghi
  5    tài liệu 23                 tài liệu 24                ghi
  6    Spacer                      Spacer                     giữ
  7    ── ⠋ Working ──             ── ⠙ Working ──            ghi, vì spinner đổi khung
  8    ‸ dòng nhập                 ‸ dòng nhập                giữ
  9    viền dưới editor            viền dưới editor           giữ
 10    footer: thư mục             footer: thư mục            giữ
 11    footer: token               footer: token              giữ

\x1b[?2026h
\x1b[1;1H\x1b[2K(tài liệu 19)R  \x1b[2;1H\x1b[2K(tài liệu 20)R  …  \x1b[6;1H\x1b[2K(tài liệu 24)R
\x1b[8;1H\x1b[2K── ⠙ Working ───…R
\x1b[9;1H\x1b[?25l                         con trỏ về dòng nhập (hàng 8), vẫn ẩn
\x1b[?2026l
Dock không dịch chuyển nên các hàng của nó giữ nguyên chuỗi và không được ghi lại. Dòng 18 không trôi vào đâu cả: nó vẫn ở trong tài liệu, cuộn lên là thấy, và TUI vẽ lại nó từ cây component.

Ở kịch bản này hai chế độ tốn gần như nhau: tám dòng so với bảy hàng. Khác biệt lộ ra ở các trường hợp khác. Khi người dùng đã cuộn lên đọc lại, scrollTop đứng yên, không hàng nào của transcript đổi, và thứ được ghi ra chỉ còn lệnh đặt con trỏ, cộng hàng spinner nếu nó vừa đổi khung. Một thay đổi ở bất kỳ đâu trong tài liệu tốn tối đa height hàng, không bao giờ là phát lại cả lịch sử. Đổi kích thước cửa sổ cũng chỉ vẽ lại một màn hình.

Bàn phím của viewport

Constructor đăng ký handleViewportInput() làm input listener đầu tiên (tui-alt-screen.ts:281), nên nó thấy mọi byte trước component đang focus. Báo focus và sự kiện chuột bị nuốt ở đây; phần còn lại là các phím của viewport (tìm kiếm, cuộn trang, nửa trang, từng dòng, lời nhắc trước và sau, đầu và cuối), mỗi phím khớp thì trả về { consume: true } để editor không nhận nữa (tui-alt-screen.ts:739–805).

Các phím này cố ý che phím cùng tên của editor ở fullscreen (keybindings.ts:159): PageUp cuộn transcript chứ không cuộn ô soạn thảo; editor vẫn còn Ctrl+PageUp. Khi một overlay khác ô tìm kiếm đang giữ focus, các phím cuộn được nhường cho overlay (:680–682). Hai phím “lời nhắc trước/sau” dò dấu \x1b]133;A mà tin nhắn của người dùng và của assistant gắn vào dòng đầu, và nhảy tới đó (:517–529). Ở chế độ regular, chính các dấu đó được ghi thẳng ra terminal, nên terminal nào hỗ trợ OSC 133 tự có phím nhảy giữa các lời nhắc. Ctrl+Shift+F mở ô tìm kiếm, bản thân nó là một overlay neo góc trên phải, rộng 40% (:531–556); chỉ mục tìm kiếm giữ kết quả cũ chừng nào các dòng của transcript chưa đổi (alt-screen-search.ts:155).

Chuột: cuộn, chọn, copy, mở link

Khi bắt chuột, ứng dụng nhận lại mọi việc terminal vốn tự làm. handleMouseEvent() xét theo thứ tự: component đang “bắt” chuỗi kéo thả; nút của ô tìm kiếm; overlay trên cùng dưới con trỏ; nhãn “nhảy tới cuối”; thanh cuộn (kéo, bấm vào rãnh, phóng to khi rê qua); component trong bố cục (handleMouse); dán bằng chuột phải trên Windows; cuối cùng là chọn văn bản (:912–973). Con lăn đi đường riêng: phần delta chưa dùng chuyển từ ScrollView trong ra ScrollView ngoài, trừ khi vùng trong đặt overscroll: "contain" (:1004–1016); Alt cộng con lăn cuộn gấp năm.

Vùng chọn được neo bằng toạ độ trong nội dung của ScrollView chứ không phải toạ độ màn hình, nên nó đúng cả khi transcript cuộn. Kéo tới mép trên hay dưới thì tự cuộn mỗi 50 ms. Bấm đúp chọn một từ và giữ nguyên đường dẫn hay từ nối bằng gạch ngang, vì / và - được coi là phần của từ (:83–85); bấm ba lần chọn cả dòng. Nhả chuột sau một cú bấm (không kéo) lên một hyperlink OSC 8 thì mở link; còn lại thì copy nếu copyOnSelect bật. copyTextToClipboard() ưu tiên hàm clipboard do ứng dụng truyền vào và chỉ báo “Copied!” khi hàm đó thành công; không có hàm nào thì ghi OSC 52 (tui-alt-screen.ts:1480–1497).

Chú thích giải thích vì sao không tin OSC 52: một lần ghi \x1b]52;c;… có thể hiện “Copied!” trong khi clipboard hệ thống không đổi, ví dụ trên Terminal.app của macOS hay tmux không chuyển tiếp OSC 52. Coding agent vì thế truyền vào một copySelection dùng clipboard native và các công cụ của hệ điều hành (tui-renderer.ts:39–46).

Overlay, focus và định tuyến input#

Overlay: vẽ đè lên dòng, không có lớp

Vấn đề. Hộp thoại, menu, ô tìm kiếm cần nổi lên trên nội dung mà không đẩy nội dung đi chỗ khác. Trong một mô hình chỉ có dòng, không có lớp (layer) hay z-index. Overlay vì thế phải được ghép vào chính các dòng của khung: cắt dòng nền tại cột bắt đầu, chèn dòng của overlay, nối phần còn lại của dòng nền.

Một mục của ngăn xếp overlay và showOverlay()
pi/packages/tui/src/tui.tsdòng 326–333, 752–766TypeScript
type OverlayStackEntry = {	component: Component;	options?: OverlayOptions;	preFocus: Component | null;	hidden: boolean;	focusOrder: number;	bounds?: OverlayBounds;};	showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {		const entry: OverlayStackEntry = {			component,			...(options === undefined ? {} : { options }),			preFocus: this.focusedComponent,			hidden: false,			focusOrder: ++this.focusOrderCounter,		};		this.overlayStack.push(entry);		// Only focus if overlay is actually visible		if (!options?.nonCapturing && this.isOverlayVisible(entry)) {			this.setFocus(component);		}		this.hideTerminalCursor();		this.requestRender();

Mỗi overlay nhớ preFocus, component đang giữ focus lúc nó hiện ra, để trả focus khi nó đóng; và focusOrder, một bộ đếm tăng dần quyết định overlay nào nằm trên. Tuỳ chọn vị trí và kích thước (width, maxHeight, anchor, row/col tuyệt đối hoặc phần trăm, margin, visible(w, h), nonCapturing) nằm trong OverlayOptions (tui.ts:254–290); thứ tự ưu tiên khi chúng xung đột được README ghi rõ (README.md:239–243). Ghép vào khung diễn ra trong compositeOverlays():

  • Overlay được sắp theo focusOrder, overlay sau đè overlay trước. Độ rộng được giải trước (không phụ thuộc chiều cao), component render ở đúng độ rộng đó, bị cắt theo maxHeight, rồi mới giải toạ độ với chiều cao thật. Hình chữ nhật cuối cùng được lưu lại cho getBounds() và cho việc dò chuột.
  • Khung được đệm dòng trống cho đủ ít nhất một màn hình, và toạ độ của overlay tính từ viewportStart, tức tương đối với màn hình chứ không với đầu tài liệu. Chú thích ở dòng 1421–1423 ghi lại một lỗi cũ: từng dùng mức cao nhất maxLinesRendered để đệm, và nó tự phình ra, đẩy nội dung vào scrollback mỗi khi terminal rộng ra.
  • Mỗi dòng được ghép bằng compositeTuiLine(): tách dòng nền thành phần trước và phần sau theo cột, có giữ trạng thái ANSI, rồi chèn \x1b[0m\x1b]8;;\x07 trước và sau đoạn overlay để màu của nền không loang vào hộp thoại và ngược lại. Dòng chứa ảnh thì không ghép (tui.ts:426–455).

Ở chế độ regular, overlay được ghép trước khi so khung, nên với thuật toán vi sai nó chỉ là vài dòng đổi nội dung: hiện ra thì ghi các dòng đó, đóng lại thì các dòng nền khác với khung trước và được ghi lại. Ở fullscreen, overlay được ghép vào màn hình sau bước tô kết quả tìm kiếm và trước bước cắt về height hàng.

Focus

Mỗi lúc có đúng một component giữ focus. setFocus() bật cờ focused của component mới nếu nó là Focusable và tắt cờ của component cũ; cờ này quyết định component có phát CURSOR_MARKER hay không. Overlay giành focus khi hiện ra, trừ khi nonCapturing hoặc đang bị visible() ẩn; khi bị đóng, focus về overlay hiển thị cao nhất còn lại hoặc về preFocus (tui.ts:770–784). Phần phức tạp nhất của TuiBase là một máy trạng thái nhỏ, overlayFocusRestore, với các trạng thái inactive, eligible và blocked (tui.ts:343–353, :625–688). Nó cài đặt một hứa hẹn của README: một overlay đang hiện và đang giữ focus sẽ lấy lại bàn phím sau khi một giao diện thay thế tạm thời trả focus (README.md:226–228). Muốn chủ động nhường bàn phím cho component khác trong lúc overlay vẫn hiện thì gọi handle.unfocus({ target }).

Một container chứa Input hay Editor bên trong phải tự cài Focusable và chuyển cờ focused xuống con. Nếu không, TUI bật cờ trên container, ô nhập bên trong không phát CURSOR_MARKER, và cửa sổ gợi ý của bộ gõ IME hiện sai chỗ (README.md:336–360).

Đường đi của một chuỗi input

Một phím bấm đi qua nhiều tầng trước khi tới component:

  1. StdinBufferGom các mảnh data của stdin thành từng chuỗi hoàn chỉnh; nội dung dán được gom thành một sự kiện và bọc lại bằng \x1b[200~ … \x1b[201~ (terminal.ts:265–270). Mục Phân tích phím giải thích vì sao cần.
  2. ProcessTerminalNuốt các câu trả lời thương lượng bàn phím Kitty và trạng thái chương trình; còn lại chuyển cho TuiBase.handleTerminalInput() (terminal.ts:244–263).
  3. Câu trả lời của terminalCâu trả lời màu (OSC 10, 11, 4 và DA1) khi có truy vấn màu đang chờ, và báo đổi sáng/tối (\x1b[?997;1n hoặc 2n), bị nuốt ở TuiBase trước mọi thứ khác, không tới component nào.
  4. Input listenerChạy theo thứ tự đăng ký; mỗi listener có thể nuốt chuỗi (consume) hoặc thay nó (data). Viewport của fullscreen là listener đầu tiên; listener của extension cũng ở đây (xem chương Extensions).
  5. FocusCâu trả lời kích thước ô ảnh (\x1b[6;h;wt) bị nuốt. Shift+Ctrl+D gọi onDebug. Nếu overlay đang giữ focus vừa bị ẩn (resize, visible()) thì chuyển focus; nếu có overlay chờ lấy lại focus thì trả cho nó.
  6. ComponentBỏ sự kiện nhả phím trừ khi component đặt wantsKeyRelease, gọi handleInput(data), rồi vẽ khung ngay.

Ctrl+C cũng chỉ là một chuỗi input như mọi phím khác. TUI không xử lý nó; component đang focus quyết định. Trong coding agent, CustomEditor chuyển nó cho hành động app.clear (xoá editor, bấm lần hai thì thoát).

Chuột, chỉ ở fullscreen

TuiMainScreen không bắt chuột, vì terminal sở hữu scrollback và cần chuột để cuộn và chọn chữ (README.md:269). TuiAltScreen chuẩn hoá chuỗi SGR thành TuiMouseEvent có toạ độ cục bộ của component, toạ độ màn hình, kích thước, nút, phím bổ trợ, số lần bấm và delta con lăn (tui.ts:25–44), và component trả lời bằng:

TuiMouseEventResult: handled, capture, focus, render
pi/packages/tui/src/tui.tsdòng 46–58TypeScript
export interface TuiMouseEventResult {	/** Stop propagation and suppress renderer-level fallback behavior. */	handled?: boolean;	/** Route subsequent drag/release events to this component. Implies handled. */	capture?: boolean;	/** Give keyboard focus to this component. Implies handled. */	focus?: boolean;	/**	 * Explicitly request or suppress a render. Move and release default to false;	 * press, click, drag, and wheel default to true.	 */	render?: boolean;}

handled chặn hành vi mặc định của renderer (cuộn, chọn chữ); capture giữ các sự kiện kéo và nhả tiếp theo cho cùng component; focus xin focus bàn phím; render ghi đè quy tắc mặc định là nhấn, bấm, kéo, con lăn thì vẽ lại còn di chuột và nhả thì không (README.md:290). Để dò trúng con nào, Container.handleMouse() dùng chiều cao từng con ghi lại ở lần render trước, nên di chuột không làm cả cây render lại chỉ để dò (tui.ts:383–403). MouseRegion bọc một component bất kỳ để thêm hành vi chuột mà không đổi cách nó vẽ; coding agent dùng nó cho việc bấm vào khối thinking để ẩn hay hiện (assistant-message.ts:163–170). Từ 1.2.0, con lăn không được component nào xử lý thì cuộn transcript, kể cả khi một overlay đang giữ focus; overlay muốn cuộn bằng con lăn phải cài handleMouse (CHANGELOG.md:35).

Phân tích phím và keybinding#

Vấn đề: terminal mã hoá phím rất mơ hồ

Terminal truyền thống gửi phím dưới dạng byte. Chữ a là 0x61, Ctrl+A là 0x01, Enter là \r, Tab là 0x09 và trùng với Ctrl+I, Esc là 0x1b và trùng với Ctrl+[, Alt+x là ESC rồi x, mũi tên lên là ESC [ A. Chú thích đầu keys.ts nhắc đúng những chỗ trùng này (keys.ts:8–11). Với một coding agent, ba hệ quả gây khó chịu nhất là: Shift+Enter thường gửi y hệt Enter, nên không phân biệt được “xuống dòng” với “gửi prompt”; một byte ESC đơn lẻ có thể là phím Esc hoặc là đầu của một chuỗi chưa tới hết; và một lần đọc stdin có thể chứa vài phím, hoặc chỉ nửa chuỗi.

Thương lượng giao thức: Kitty trước, modifyOtherKeys sau

queryAndEnableKittyProtocol(): xin cờ 7, hỏi lại, dùng DA1 làm mốc
pi/packages/tui/src/terminal.tsdòng 278–306TypeScript
/** * Query terminal for Kitty keyboard protocol support and enable it if available. * * Kitty's progressive enhancement detection requires requesting the desired * flags before querying them. The trailing DA query is a sentinel supported by * terminals that do not know Kitty keyboard protocol; receiving DA before a * Kitty response enables modifyOtherKeys fallback without a startup timeout. * * The requested flags are: * - 1 = disambiguate escape codes * - 2 = report event types (press/repeat/release) * - 4 = report alternate keys (shifted key, base layout key) * * The OSC 7501 program status query shares the DA sentinel: a terminal that supports it replies * before DA. `PI_PROGRAM_STATUS=1` or `0` skips the query. */private queryAndEnableKittyProtocol(): void {	this.setupStdinBuffer();	process.stdin.on("data", this.stdinDataHandler!);	this.keyboardProtocolPushed = true;	this.pendingKeyboardProtocolDeviceAttributes += 1;	this.clearKeyboardProtocolNegotiationBuffer();	const programStatusOverride = process.env.PI_PROGRAM_STATUS;	this.programStatusSupported = programStatusOverride === "1";	this.programStatusQueryPending = programStatusOverride !== "1" && programStatusOverride !== "0";	const programStatusQuery = this.programStatusQueryPending ? PROGRAM_STATUS_QUERY : "";	process.stdout.write(`${KITTY_KEYBOARD_PROTOCOL_QUERY}${programStatusQuery}${DEVICE_ATTRIBUTES_QUERY}`);	this.writeProgramStatus();}

Giao thức bàn phím Kitty cho phép chương trình xin terminal mã hoá phím rõ ràng. \x1b[>7u đẩy cờ 7 = 1 | 2 | 4: phân biệt mã escape, báo loại sự kiện (nhấn, giữ lặp, nhả), báo phím thay thế. \x1b[?u hỏi cờ đang bật. \x1b[c (DA1) là câu hỏi mà mọi terminal đều trả lời, và terminal trả lời theo thứ tự; nên nếu câu trả lời DA1 tới mà chưa thấy câu trả lời Kitty, terminal không biết giao thức Kitty, và không cần một bộ hẹn giờ chờ đoán. Khi nhận được cờ khác 0, ProcessTerminal bật cờ toàn cục setKittyProtocolActive(true) mà bộ khớp phím tra cứu; nếu không, nó bật modifyOtherKeys của xterm bằng \x1b[>4;2m (terminal.ts:308–336, :408–412). Lúc dừng, \x1b[<u gỡ cờ Kitty và \x1b[>4;0m tắt modifyOtherKeys.

Dạng CSI u: mã phím, phím shift, phím theo bố cục gốc, phím bổ trợ, loại sự kiện
pi/packages/tui/src/keys.tsdòng 602–622TypeScript
function parseKittySequence(data: string): ParsedKittySequence | null {	// CSI u format with alternate keys (flag 4):	// \x1b[<codepoint>u	// \x1b[<codepoint>;<mod>u	// \x1b[<codepoint>;<mod>:<event>u	// \x1b[<codepoint>:<shifted>;<mod>u	// \x1b[<codepoint>:<shifted>:<base>;<mod>u	// \x1b[<codepoint>::<base>;<mod>u (no shifted key, only base)	//	// With flag 2, event type is appended after modifier colon: 1=press, 2=repeat, 3=release	// With flag 4, alternate keys are appended after codepoint with colons	const csiUMatch = data.match(/^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/);	if (csiUMatch) {		const codepoint = parseInt(csiUMatch[1]!, 10);		const shiftedKey = csiUMatch[2] && csiUMatch[2].length > 0 ? parseInt(csiUMatch[2], 10) : undefined;		const baseLayoutKey = csiUMatch[3] ? parseInt(csiUMatch[3], 10) : undefined;		const modValue = csiUMatch[4] ? parseInt(csiUMatch[4], 10) : 1;		const eventType = parseEventType(csiUMatch[5]);		_lastEventType = eventType;		return { codepoint, shiftedKey, baseLayoutKey, modifier: modValue - 1, eventType };	}

Giá trị phím bổ trợ trên dây lớn hơn bitmask một đơn vị; bitmask là Shift 1, Alt 2, Ctrl 4, Super 8, còn Caps Lock 64 và Num Lock 128 bị bỏ qua khi so (keys.ts:296–303). Tài liệu cấu hình terminal của Pi dùng đúng các chuỗi này khi hướng dẫn người dùng tự map phím:

PhímCSI uGiải mãNguồn
Shift+Enter\x1b[13;2u13 = Enter, 2 − 1 = Shiftterminal-setup.md:177
Alt+Enter\x1b[13;3u3 − 1 = Alt:88
Ctrl+-\x1b[45;5u45 = -, 5 − 1 = Ctrl:161
Ctrl+Alt+]\x1b[93;7u93 = ], 7 − 1 = Ctrl + Alt:162

modifyOtherKeys mã hoá cùng thông tin theo dạng CSI 27 ; mod ; code ~, ví dụ \x1b[27;2;13~ cho Shift+Enter (keys.ts:722–728). Đây là dạng mà tmux 3.2 tới 3.4 chuyển tiếp; tmux từ 3.5 chuyển tiếp được CSI u nếu bật extended-keys-format csi-u (tmux.md:13–22).

StdinBuffer: cắt luồng byte thành từng chuỗi

Một sự kiện data của stdin không phải một phím. Chú thích của StdinBuffer đưa ví dụ chuỗi chuột \x1b[<35;20;5m tới thành ba mảnh \x1b, [<35, ;20;5m (stdin-buffer.ts:1–18). Bộ đệm nhận biết điểm kết thúc của từng loại chuỗi (CSI kết thúc bằng một byte trong khoảng 0x40–0x7e, OSC bằng BEL hoặc ESC \, DCS và APC bằng ESC \), phát từng chuỗi hoàn chỉnh, và giữ phần dở dang lại, xả nó ra như phím sau một thời gian chờ có hạn (stdin-buffer.ts:411–432).

Một ESC đơn lẻ chờ escapeTimeout, mặc định 10 ms; qua SSH (có SSH_CONNECTION hoặc SSH_TTY) là 100 ms, vì Alt+phím kiểu cũ là ESC cộng một byte và trên đường truyền chậm hai byte đó có thể tới cách xa nhau; biến PI_TUI_ESC_TIMEOUT ghi đè cả hai (terminal.ts:142–151). Chuỗi dở khác chờ 50 ms. Riêng câu trả lời của terminal có phần đầu nhận ra được (màu, trạng thái chương trình, XTVersion, ảnh Kitty) thì chờ tới khi có ký tự kết thúc, không xả theo thời gian (dòng 416). Lý do được ghi ngay trong chú thích: xả ra thì phần đuôi bị gõ vào prompt như chữ thường, và ký tự BEL kết thúc của OSC thành Ctrl+G, phím mở trình soạn thảo ngoài. Đó là lỗi đã sửa ở 1.2.0 (CHANGELOG.md:36). Bộ đệm cũng gom nội dung dán giữa \x1b[200~ và \x1b[201~ thành một sự kiện paste, nên một lần dán dài không bị phân tích thành hàng nghìn phím.

matchesKey(): một tên phím, nhiều cách mã hoá

Tên phím có kiểu KeyId, một hợp của các template literal như "ctrl+shift+p", nên viết sai tên là lỗi biên dịch (keys.ts:141–152). matchesKey(data, keyId) chấp nhận mọi cách mà một terminal có thể đã gửi phím đó. Trường hợp Shift+Enter cho thấy rõ nhất:

Shift+Enter: CSI u (cả phím Enter của bàn phím số), modifyOtherKeys, và các map tự đặt
pi/packages/tui/src/keys.tsdòng 917–938TypeScript
case "enter":case "return":	if (modifier === MODIFIERS.shift) {		// CSI u sequences (standard Kitty protocol)		if (			matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) ||			matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.shift)		) {			return true;		}		// xterm modifyOtherKeys format (fallback when Kitty protocol not enabled)		if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.shift)) {			return true;		}		// When Kitty protocol is active, legacy sequences are custom terminal mappings		// \x1b\r = Kitty's "map shift+enter send_text all \e\r"		// \n = Ghostty's "keybind = shift+enter=text:\n"		if (_kittyProtocolActive) {			return data === "\x1b\r" || data === "\n";		}		return false;	}

Hai chuỗi ở dòng 935 là map người dùng hay tự đặt trong terminal (chú thích ghi map shift+enter send_text all \e\r của Kitty và keybind = shift+enter=text:\n của Ghostty). Chúng chỉ được hiểu là Shift+Enter khi giao thức Kitty đang bật; nếu không, \x1b\r là Alt+Enter kiểu cũ và \n là Ctrl+J. Tài liệu khuyên gỡ map Ghostty kiểu này vì Pi không phân biệt nó với Ctrl+J (terminal-setup.md:57–63). Vài chi tiết khác của bộ khớp:

  • Bố cục bàn phím không phải Latin. Với tiếng Nga, Ctrl+С (chữ Cyrillic) vẫn khớp ctrl+c nhờ “phím theo bố cục gốc” mà Kitty báo kèm. Nhưng bộ khớp chỉ dùng phím gốc khi mã phím không phải chữ Latin hay ký hiệu đã biết, để bố cục Dvorak hay Colemak không khớp nhầm Ctrl+K thành Ctrl+V (keys.ts:700–717).
  • Nhả phím. isKeyRelease() nhận ra sự kiện nhả bằng hậu tố :3u, :3~, :3A... và bỏ qua nội dung dán, vì một địa chỉ MAC như 90:62:3F chứa đúng chuỗi đó (keys.ts:542–566).
  • Gõ chữ. Khi bật cờ phân biệt, terminal có thể gửi cả chữ thường dưới dạng CSI u. Ô nhập dùng decodePrintableKey() để lấy lại ký tự, và chỉ nhận chữ không kèm Ctrl hay Alt (keys.ts:1404–1437).
  • Backspace mơ hồ. Byte 0x08 là Ctrl+Backspace trên Windows Terminal nhưng là Backspace thường ở vài terminal và cấu hình tmux; bộ khớp đoán theo WT_SESSION (keys.ts:747–760).

Keybinding: gọi phím bằng tên hành động

Component không nên viết cứng matchesKey(data, "ctrl+k"); nó hỏi theo tên hành động, và người dùng đổi được phím cho hành động đó. Danh sách hành động là một interface mở rộng được bằng declaration merging:

Keybindings: sổ đăng ký hành động, mở rộng bằng declaration merging
pi/packages/tui/src/keybindings.tsdòng 3–12, 57–69TypeScript
/** * Global keybinding registry. * Downstream packages can add keybindings via declaration merging. */export interface Keybindings {	// Editor navigation and editing	"tui.editor.cursorUp": true;	"tui.editor.cursorDown": true;	"tui.editor.historyPrevious": true;	"tui.editor.historyNext": true;	"tui.altScreen.top": true;	"tui.altScreen.bottom": true;}export type Keybinding = keyof Keybindings;export interface KeybindingDefinition {	defaultKeys: KeyId | KeyId[];	description?: string;}export type KeybindingDefinitions = Record<string, KeybindingDefinition>;export type KeybindingsConfig = Record<string, KeyId | KeyId[] | undefined>;

pi-tui định nghĩa các hành động tui.editor.*, tui.input.*, tui.select.*, tui.altScreen.* cùng phím mặc định trong TUI_KEYBINDINGS (keybindings.ts:71–210). KeybindingsManager.rebuild() dựng một bảng tra từ tên hành động tới danh sách phím, và matches(data, action) chỉ là thử matchesKey() với từng phím trong danh sách (keybindings.ts:243–276).

Cấu hình người dùng thay thế danh sách phím của một hành động chứ không cộng thêm, và một mảng rỗng tắt hành động đó (keybindings.md:21–27). Xung đột chỉ được ghi nhận giữa các phím do người dùng gán, không giữa phím người dùng với phím mặc định. Bảng tra là một biến toàn cục (setKeybindings/getKeybindings, keybindings.ts:309–320), nên editor và viewport của fullscreen đọc chung một bản mà không phải truyền qua constructor. Coding agent mở rộng sổ đăng ký với các hành động app.* và đổi vài mặc định theo nền tảng:

Coding agent: thêm app.* bằng declaration merging, đổi mặc định cho Windows và WSL
pi/packages/coding-agent/src/core/keybindings.tsdòng 176–199TypeScript
declare module "@earendil-works/pi-tui" {	interface Keybindings extends AppKeybindings {}}export const KEYBINDINGS = {	...TUI_KEYBINDINGS,	"tui.editor.undo": {		...TUI_KEYBINDINGS["tui.editor.undo"],		defaultKeys: process.platform === "win32" ? "ctrl+z" : windowsKeybindings ? "alt+z" : "ctrl+-",	},	"tui.altScreen.previousPrompt": {		...TUI_KEYBINDINGS["tui.altScreen.previousPrompt"],		defaultKeys: windowsKeybindings ? "ctrl+up" : ["ctrl+shift+up", "ctrl+up"],	},	"tui.altScreen.nextPrompt": {		...TUI_KEYBINDINGS["tui.altScreen.nextPrompt"],		defaultKeys: windowsKeybindings ? "ctrl+down" : ["ctrl+shift+down", "ctrl+down"],	},	"tui.altScreen.search": {		...TUI_KEYBINDINGS["tui.altScreen.search"],		defaultKeys: windowsKeybindings ? "ctrl+f" : "ctrl+shift+f",	},	...APP_KEYBINDINGS,} as const satisfies KeybindingDefinitions;

Trên Windows và WSL, tìm kiếm trong transcript mặc định là Ctrl+F. Undo thì tuỳ: Windows gốc không có job control nên Ctrl+Z rảnh và được dùng cho undo (keybindings.md:131); WSL vẫn dùng Ctrl+Z để tạm dừng tiến trình nên undo chuyển sang Alt+Z; các nền tảng khác dùng Ctrl+-. KeybindingsManager.create() của coding agent đọc <agent-dir>/keybindings.json, mặc định ~/.pi/agent/keybindings.json (keybindings.ts:337–341), và interactive mode cài nó làm bảng toàn cục ngay trong constructor (interactive-mode.ts:646–647).

Editor: soạn nhiều dòng, autocomplete, paste, undo#

Editor (components/editor.ts, 2.473 dòng) là component lớn nhất của gói, và là nơi người dùng ở lâu nhất. State cốt lõi rất nhỏ:

EditorState và EditorSnapshot
pi/packages/tui/src/components/editor.tsdòng 219–230TypeScript
interface EditorState {	lines: string[];	cursorLine: number;	cursorCol: number;}/** Undo snapshot: editor text state plus the paste registry. */interface EditorSnapshot {	state: EditorState;	pastes: Map<number, string>;	pasteCounter: number;}

Văn bản là một mảng dòng logic, con trỏ là cặp (dòng, cột) với cột là chỉ số trong chuỗi của dòng đó. Ngắt dòng không nằm trong state: mỗi lần render, layoutText() ngắt các dòng logic thành dòng hiển thị theo độ rộng hiện tại (editor.ts:998). Di chuyển lên xuống đi theo dòng hiển thị và nhớ một “cột ưa thích” (preferredVisualCol) để con trỏ không trôi dần sang trái khi đi qua các dòng ngắn. Ngoài ra editor giữ bảng nội dung dán, lịch sử prompt (tối đa 100 mục), kill ring kiểu Emacs, ngăn xếp undo và trạng thái autocomplete (editor.ts:337–372).

render(): chiều cao có trần, cuộn bên trong

Editor cao tối đa 30% màn hình (ít nhất 5 dòng) và tự cuộn để con trỏ luôn hiện
pi/packages/tui/src/components/editor.tsdòng 536–557TypeScript
// Calculate max visible lines: 30% of terminal height, minimum 5 linesconst terminalRows = this.tui.terminal.rows;const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3));// Find the cursor line index in layoutLineslet cursorLineIndex = layoutLines.findIndex((line) => line.hasCursor);if (cursorLineIndex === -1) cursorLineIndex = 0;// Adjust scroll offset to keep cursor visibleif (cursorLineIndex < this.scrollOffset) {	this.scrollOffset = cursorLineIndex;} else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) {	this.scrollOffset = cursorLineIndex - maxVisibleLines + 1;}// Clamp scroll offset to valid rangeconst maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines);this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset));// Get visible lines sliceconst visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);this.renderedVisibleLineCount = visibleLines.length;

Editor đọc tui.terminal.rows, đó là lý do constructor bắt buộc nhận tui. Trần chiều cao có từ khi một prompt dài hơn màn hình làm hỏng hiển thị của terminal (CHANGELOG.md:1068). Phần còn lại của render() vẽ viền trên và viền dưới (kèm “↑ N more” khi có phần bị che), chèn CURSOR_MARKER và con trỏ giả vào đúng grapheme dưới con trỏ, đệm mỗi dòng đủ độ rộng, và nối danh sách autocomplete bên dưới viền dưới (editor.ts:559–630). Khi không có padding ngang, độ rộng bố trí chừa một cột cho con trỏ đứng cuối dòng (dòng 526–528).

handleInput(): thứ tự ưu tiên

Một chuỗi input được thử lần lượt (editor.ts:696–996), và cách đầu tiên khớp thắng:

  1. Chế độ nhảySau Ctrl+], phím in được tiếp theo là ký tự đích để nhảy tới.
  2. Bracketed pasteTừ \x1b[200~, mọi thứ được gom cho tới \x1b[201~ rồi chuyển cho handlePaste(); phần sau dấu đóng được xử lý tiếp như input thường.
  3. Ctrl+C và undotui.input.copy trả về ngay để component cha quyết định; tui.editor.undo hoàn tác.
  4. Autocomplete đang mởEsc huỷ, lên xuống chọn, Tab áp dụng, Enter áp dụng; với lệnh slash, Enter áp dụng rồi đi tiếp xuống bước gửi.
  5. Soạn thảoTab gọi completion; các phím xoá; yank và yank-pop của kill ring; lịch sử; di chuyển con trỏ.
  6. Xuống dòng hay gửitui.input.newLine (Shift+Enter, Ctrl+J) chèn dòng. Enter gửi, trừ khi ký tự ngay trước con trỏ là \: khi đó dấu \ bị xoá và một dòng mới được chèn, một cách lách cho terminal không gửi được Shift+Enter (dòng 912–919).
  7. Lên, xuốngỞ dòng hiển thị đầu tiên, phím lên duyệt lịch sử nếu editor rỗng, đang duyệt dở, hoặc con trỏ ở cột 0; nếu không thì nhảy về đầu dòng (dòng 926–939).
  8. Ký tựThử decodePrintableKey() cho chữ đến dưới dạng CSI u, rồi tới byte thường từ 0x20 trở lên.

Dán: marker thay cho cả khối

Dán lớn (hơn 10 dòng hoặc hơn 1.000 ký tự) thành một marker
pi/packages/tui/src/components/editor.tsdòng 1298–1316TypeScript
// Split into lines to check for large pasteconst pastedLines = filteredText.split("\n");// Check if this is a large paste (> 10 lines or > 1000 characters)const totalChars = filteredText.length;if (pastedLines.length > 10 || totalChars > 1000) {	// Store the paste and insert a marker	this.pasteCounter++;	const pasteId = this.pasteCounter;	this.pastes.set(pasteId, filteredText);	// Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"	const marker =		pastedLines.length > 10			? `[paste #${pasteId} +${pastedLines.length} lines]`			: `[paste #${pasteId} ${totalChars} chars]`;	this.insertTextAtCursorInternal(marker);	return;}

Trước đó, handlePaste() chuẩn hoá xuống dòng, đổi tab thành bốn dấu cách, lọc ký tự điều khiển, và giải mã các Ctrl+chữ mà vài terminal (ví dụ popup của tmux với extended-keys-format=csi-u) mã hoá lại thành CSI u bên trong nội dung dán (editor.ts:1267–1277). Một đường dẫn dán dính vào sau một chữ được thêm dấu cách phía trước. Nội dung lớn được cất vào bảng pastes, còn editor chỉ chứa marker như [paste #1 +123 lines]. segmentWithMarkers() gộp mọi grapheme của một marker hợp lệ thành một đơn vị, nên di chuyển, xoá và ngắt dòng coi marker như một ký tự (editor.ts:41–105). Marker chỉ được thay lại bằng nội dung thật ở hai chỗ. Khi gửi, submitValue() mở mọi marker, xoá state, bảng nội dung dán và ngăn xếp undo, rồi gọi onSubmit (editor.ts:1364–1378). Khi mở trình soạn thảo ngoài, getExpandedText() đưa ra văn bản đầy đủ (editor.ts:1099–1105).

README chỉ nói về ngưỡng “hơn 10 dòng” (README.md:467); code còn tạo marker cho một lần dán một dòng dài hơn 1.000 ký tự, với dạng [paste #2 1234 chars].

Undo kiểu fish

Gộp undo theo từ; snapshot gồm cả bảng nội dung dán
pi/packages/tui/src/components/editor.tsdòng 1201–1214, 2116–2132TypeScript
private insertCharacter(char: string, skipUndoCoalescing?: boolean): void {	this.exitHistoryBrowsing();	// Undo coalescing (fish-style):	// - Consecutive word chars coalesce into one undo unit	// - Space captures state before itself (so undo removes space+following word together)	// - Each space is separately undoable	// Skip coalescing when called from atomic operations (e.g., handlePaste)	if (!skipUndoCoalescing) {		if (isWhitespaceChar(char) || this.lastAction !== "type-word") {			this.pushUndoSnapshot();		}		this.lastAction = "type-word";	}private pushUndoSnapshot(): void {	this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter });}private undo(): void {	this.exitHistoryBrowsing();	const snapshot = this.undoStack.pop();	if (!snapshot) return;	Object.assign(this.state, snapshot.state);	this.pastes = snapshot.pastes;	this.pasteCounter = snapshot.pasteCounter;	this.lastAction = null;	this.preferredVisualCol = null;	if (this.onChange) {		this.onChange(this.getText());	}}

Gõ liên tiếp các ký tự trong một từ chỉ tạo một snapshot ở đầu từ; mỗi khoảng trắng tạo snapshot ngay trước nó. Undo vì vậy xoá từng từ chứ không từng ký tự, giống shell fish như chú thích ghi. UndoStack.push() lưu một bản sao sâu bằng structuredClone, nên snapshot không bị sửa ngầm khi state đổi sau đó (undo-stack.ts:1–13). Snapshot gồm cả bảng pastes và bộ đếm, nên undo một lần dán xoá luôn nội dung đã cất. Kill ring là một vòng chuỗi riêng: các lần xoá liên tiếp được nối vào cùng một mục, Ctrl+Y dán mục mới nhất, Alt+Y xoay vòng (kill-ring.ts:1–28).

Autocomplete bất đồng bộ

AutocompleteProvider: lấy gợi ý (có AbortSignal) và áp dụng gợi ý
pi/packages/tui/src/autocomplete.tsdòng 268–297TypeScript
export interface AutocompleteProvider {	/** Characters that should naturally trigger this provider at token boundaries. */	triggerCharacters?: string[];	// Get autocomplete suggestions for current text/cursor position	// Returns null if no suggestions available	getSuggestions(		lines: string[],		cursorLine: number,		cursorCol: number,		options: { signal: AbortSignal; force?: boolean },	): Promise<AutocompleteSuggestions | null>;	// Apply the selected item	// Returns the new text and cursor position	applyCompletion(		lines: string[],		cursorLine: number,		cursorCol: number,		item: AutocompleteItem,		prefix: string,	): {		lines: string[];		cursorLine: number;		cursorCol: number;	};	// Check if file completion should trigger for explicit Tab completion	shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;}

Editor tự gọi gợi ý khi gõ / ở đầu tin nhắn (lệnh slash), khi gõ @, # hoặc ký tự kích hoạt riêng của provider ở đầu một token, và khi gõ tiếp chữ trong một ngữ cảnh như vậy; ngoài ngữ cảnh lệnh slash, Tab ép gợi ý đường dẫn file (editor.ts:1228–1257). Vì getSuggestions() có thể chậm (tìm file qua fd, gọi completion của extension), editor xếp các yêu cầu nối tiếp nhau, huỷ yêu cầu cũ bằng AbortController, hoãn 20 ms với các token bắt đầu bằng ký tự kích hoạt như @ hay #, và bỏ kết quả tới muộn:

Kết quả chỉ được dùng nếu văn bản và con trỏ vẫn y như lúc gửi yêu cầu
pi/packages/tui/src/components/editor.tsdòng 2416–2430TypeScript
private isAutocompleteRequestCurrent(	requestId: number,	controller: AbortController,	snapshotText: string,	snapshotLine: number,	snapshotCol: number,): boolean {	return (		!controller.signal.aborted &&		requestId === this.autocompleteRequestId &&		this.getText() === snapshotText &&		this.state.cursorLine === snapshotLine &&		this.state.cursorCol === snapshotCol	);}

Khi Tab ép gợi ý và chỉ có đúng một kết quả, nó được áp dụng luôn mà không mở danh sách (editor.ts:2393–2410). CombinedAutocompleteProvider của pi-tui ghép lệnh slash với đường dẫn file; với tiền tố @, nó tìm mờ qua fd, có theo .gitignore, gồm file ẩn, bỏ thư mục .git (autocomplete.ts:145–175). Coding agent dựng provider từ lệnh có sẵn, prompt template, lệnh của extension và skill, rồi cho extension bọc thêm (interactive-mode.ts:827–843).

CustomEditor: phím của ứng dụng đứng trước

Coding agent không sửa Editor; nó kế thừa. CustomEditor.handleInput() (custom-editor.ts:88–147) thử phím tắt do extension đăng ký, phím dán ảnh, Esc (chỉ ngắt agent khi danh sách autocomplete không mở, nếu không thì để Esc đóng danh sách), Ctrl+D (chỉ thoát khi editor rỗng), rồi mọi hành động app.* đã đăng ký, cuối cùng mới tới super.handleInput(). Nó cũng vẽ chỉ báo “working” ngay trong viền trên (custom-editor.ts:36–79), lý do status container trống trong trace.

Markdown và hình ảnh#

Markdown

Câu trả lời của mô hình là Markdown, nên Markdown là component được render nhiều nhất. Nó dùng lexer của marked với ba phần tuỳ biến: gạch ngang chỉ nhận ~~ chặt chẽ, nhấn mạnh thân thiện với chữ CJK (marked-cjk-friendly), và hai extension LaTeX cho công thức inline và dạng khối, được vẽ thành ký tự Unicode bởi latex.ts (markdown.ts:190–197). Mỗi token thành các dòng có mã ANSI theo MarkdownTheme, một tập hàm tô màu do ứng dụng cung cấp, kể cả highlightCode cho khối code (markdown.ts:222–240). Hai lớp cache:

Markdown.render(): cache dòng theo (text, width), cache token qua WeakRef
pi/packages/tui/src/components/markdown.tsdòng 303–333TypeScript
render(width: number): string[] {	// Check cache	if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {		return this.cachedLines;	}	// Calculate available width for content (subtract horizontal padding)	const contentWidth = Math.max(1, width - this.paddingX * 2);	const text = this.options.transform?.(this.text, contentWidth) ?? this.text;	// Don't render anything if there's no actual text	if (!text || text.trim() === "") {		const result: string[] = [];		// Update cache		this.cachedText = this.text;		this.cachedWidth = width;		this.cachedLines = result;		return result;	}	// Replace tabs with 3 spaces for consistent rendering	const normalizedText = text.replace(/\t/g, "   ");	// Parse markdown to HTML-like tokens	const cached = this.cachedTokens?.deref();	let tokens = cached?.source === normalizedText ? cached.tokens : undefined;	if (!tokens) {		tokens = markdownParser.lexer(normalizedText);		trimPartialClosingFences(tokens);		this.cachedTokens = new WeakRef({ source: normalizedText, tokens });	}

Cache dòng trúng khi văn bản và độ rộng không đổi, tức là với mọi tin nhắn đã xong. Cache token chỉ phụ thuộc mã nguồn Markdown, nên sống qua được việc đổi theme hay đổi độ rộng; nó được giữ bằng WeakRef vì theo chú thích, cây token lớn khoảng mười lần mã nguồn và mỗi tin nhắn của một transcript dài đều giữ một Markdown (markdown.ts:271–274). Cuối render(), flattenLines() ép V8 làm phẳng các chuỗi được ghép từ nhiều mảnh, vốn giữ cây các mảnh con và tốn gấp mấy lần kích thước thật (utils.ts:891–898). Hai thay đổi này của 1.0.0 làm một tin nhắn dài chỉ còn giữ khoảng một phần năm bộ nhớ so với trước (CHANGELOG.md:86).

Tin nhắn đang stream thì khác. AssistantMessageComponent.updateContent() tạo một Markdown mới mỗi lần cập nhật, nên cả hai cache của nó không giúp gì: mỗi khung, đoạn văn đang stream được lex và ngắt dòng lại từ đầu. Chi phí đó tỷ lệ với độ dài của một tin nhắn, không phải của cả transcript, và bị chặn bởi nhịp 16 ms. Vài chỗ trong Markdown có mặt chỉ vì streaming: trimPartialClosingFences() bỏ dấu đóng khối code đang tới dở (dòng cuối chỉ gồm vài dấu backtick hay dấu ngã, ngắn hơn dấu mở), để khối code không co lại rồi giãn ra khi ký tự cuối tới (markdown.ts:165–188); công thức $$ chưa đóng được giữ ở dạng thô cho tới khi đóng, và từ 1.4.0 không còn nuốt bảng phía sau (CHANGELOG.md:11).

Ảnh inline: Kitty và iTerm2

Terminal có hai giao thức ảnh phổ biến. pi-tui đoán terminal hỗ trợ cái nào từ biến môi trường: Kitty, Ghostty, WezTerm, Warp, Herdr, Rex dùng giao thức Kitty; iTerm2 dùng giao thức riêng; dưới tmux hay screen thì tắt ảnh vì không đáng tin (terminal-image.ts:70–146). Người dùng ghi đè được bằng PI_IMAGE_PROTOCOL, PI_TRUE_COLOR, PI_HYPERLINKS hoặc setting terminal.* (terminal-setup.md:207–219).

Giao thức Kitty truyền ảnh PNG đã mã hoá base64 trong các chuỗi APC \x1b_G…;…\x1b\\, chia khúc 4.096 ký tự với cờ m=1 cho khúc còn tiếp; tham số a=T,f=100,q=2 nghĩa là truyền và hiện ngay, định dạng PNG, không trả lời; c/r là số cột và hàng ô chữ ảnh chiếm, i là id ảnh, C=1 bảo terminal không dời con trỏ (terminal-image.ts:232–276). Ảnh JPEG, GIF, WebP cần một hàm chuyển sang PNG do ứng dụng đăng ký; thiếu nó thì hiện chữ thay thế (image.ts:24–31). iTerm2 dùng OSC 1337 với inline=1 và kích thước byte (terminal-image.ts:299–325). Điều quan trọng với TUI là ảnh phải khớp vào mô hình dòng:

Image.render(): một ảnh cao N hàng trả về đúng N dòng
pi/packages/tui/src/components/image.tsdòng 114–152TypeScript
if (caps.images && data) {	if (caps.images === "kitty" && this.imageId === undefined) {		this.imageId = allocateImageId();	}	const result = renderImage(data, dimensions, {		maxWidthCells: maxWidth,		maxHeightCells: maxHeight,		imageId: this.imageId,		moveCursor: false,	});	if (result) {		// Store the image ID for later cleanup		if (result.imageId) {			this.imageId = result.imageId;		}		if (caps.images === "kitty") {			// For Kitty: C=1 prevents cursor movement.			// Don't need the cursor movement.			lines = [result.sequence];			// Return `rows` lines so TUI accounts for image height.			for (let i = 0; i < result.rows - 1; i++) {				lines.push("");			}		} else {			// Return `rows` lines so TUI accounts for image height.			// First (rows-1) lines are empty and cleared before the image is drawn.			// Last line: move cursor back up, draw the image, then move back down			// so TUI cursor accounting stays inside the scroll area.			lines = [];			for (let i = 0; i < result.rows - 1; i++) {				lines.push("");			}			const rowOffset = result.rows - 1;			const moveUp = rowOffset > 0 ? `\x1b[${rowOffset}A` : "";			lines.push(moveUp + result.sequence);		}

Ảnh cao N hàng ô chữ trả về đúng N dòng, nên phép đếm dòng của cả hai renderer vẫn đúng. Với Kitty, chuỗi ảnh nằm ở dòng đầu và N − 1 dòng rỗng giữ chỗ. Với iTerm2, N − 1 dòng đầu rỗng, còn dòng cuối là lệnh lên N − 1 dòng rồi tới chuỗi ảnh; theo chú thích trong code, việc vẽ ảnh đưa con trỏ trở xuống, nên phép đếm dòng của TUI vẫn nằm trong vùng cuộn. Kích thước ô theo pixel, cần để tính N, được hỏi bằng CSI 16 t lúc khởi động; khi có câu trả lời, TUI gọi invalidate() cho cả cây để ảnh tính lại (tui.ts:1217–1235).

Dòng ảnh không bị nối reset và không bị ghép overlay. TuiMainScreen mở rộng vùng thay đổi để bao trọn các hàng một ảnh chiếm, xoá ảnh cũ theo id bằng a=d,d=I trước khi vẽ, và vẽ lại toàn bộ nếu việc chừa hàng cho ảnh sẽ làm màn hình cuộn (tui-main-screen.ts:210–245). TuiAltScreen còn làm thêm hai việc. Ảnh đã tải lên terminal được dùng lại bằng lệnh đặt chỗ a=p thay vì truyền lại cả dữ liệu, với giới hạn 16 ảnh, 32 MiB dữ liệu truyền và 64 MiB ước tính sau giải mã cho các ảnh đang khuất (tui-alt-screen.ts:445–493). Và ảnh bị cuộn khuất một phần được cắt bằng tham số y, h, r để chỉ hiện phần nằm trong viewport (terminal-image.ts:464–476).

Phần native#

Phần lớn những gì TUI cần đều đi qua terminal bằng chuỗi điều khiển. Ba thứ thì không: đọc clipboard của hệ điều hành (văn bản, ảnh, danh sách file, cho phím dán ảnh), biết phím bổ trợ nào đang được giữ ngay lúc này, và bật chế độ nhập VT của console Windows. packages/tui/native/ chứa ba addon nhỏ viết bằng C và Objective-C cho đúng ba việc đó, kèm bản dựng sẵn cho x64 và arm64:

Thư mụcHàm xuất raDựa trên
native/darwin/isModifierPressed, getText, setText, getImage, getFilePaths (darwin-platform.m:126–130)AppKit; trạng thái phím qua CGEventSourceFlagsState
native/win32/enableVirtualTerminalInput, isModifierPressed, getText, setText, getImage (win32-platform.c:235–239)kernel32, user32; GetAsyncKeyState, SetConsoleMode
native/linux/getText, getImage (linux-platform-x11.c:271–272)X11 qua libxcb.so.1; Wayland dùng wl-paste ở phía coding agent
NativeClipboard và cách chọn helper theo nền tảng
pi/packages/tui/src/native-platform.tsdòng 9–23, 55–65TypeScript
export interface NativeClipboard {	/** Undefined means unavailable, null means no text; transfer failures reject. */	getText(): Promise<string | null | undefined>;	/** Undefined means unavailable, null means no image; transfer failures reject. */	getImage(): Promise<Uint8Array | null | undefined>;	/** POSIX paths of file URLs on the clipboard. Undefined means unsupported, null means no files. */	getFilePaths?(): Promise<string[] | null | undefined>;	/** Linux uses command-line tools to retain clipboard ownership instead. */	setText?(text: string): Promise<void>;}type NativePlatformHelper = NativeClipboard & {	enableVirtualTerminalInput?: () => boolean;	isModifierPressed?: (name: ModifierKey) => boolean;};export function getNativePlatformHelper(): NativePlatformHelper | undefined {	if (process.platform !== "darwin" && process.platform !== "win32") return undefined;	return loadNativePlatformHelper(process.platform);}/** Load a clipboard helper without opening the display until a read is requested. */export function getNativeClipboard(): NativeClipboard | undefined {	if (process.platform !== "linux") return getNativePlatformHelper();	if (!process.env.DISPLAY) return undefined;	return loadNativePlatformHelper("linux", "-x11");}

Mỗi hàm clipboard trả về một Promise và phân biệt ba trường hợp: undefined nghĩa là không dùng được, null nghĩa là clipboard không có loại dữ liệu đó, còn lỗi truyền dữ liệu thì reject. Trên Linux, helper chỉ được nạp khi có DISPLAY, và theo chú thích ở dòng 25 chỉ việc nạp module được cache, không phải tình trạng của màn hình, vì một display bị ngắt có thể kết nối lại. Addon không cần header của Node: napi.h tự tìm một tập nhỏ hàm N-API lúc chạy, “from Node or Bun, without Node headers or a link-time dependency on a particular runtime” (napi.h:4–5). File .node được tìm ở vài chỗ: cạnh gói đã cài, cạnh module đang chạy, và cạnh file thực thi, để bản binary đóng gói một file cũng tìm thấy (native-module-path.ts:14–31). Không tìm thấy thì hàm trả về undefined và người gọi đi đường lui; coding agent khi đó đọc clipboard bằng các lệnh như wl-paste hay xclip (clipboard-image.ts:173).

Trạng thái phím bổ trợ giải quyết một vấn đề cụ thể của bàn phím: Terminal.app của macOS và console Windows có thể gửi Enter trần (\r) cho cả Shift+Enter. Khi nhận đúng một \r trên hai nền tảng đó, ProcessTerminal hỏi helper xem Shift có đang được giữ không, và nếu có thì đổi chuỗi thành Shift+Enter dạng CSI u:

forwardInputSequence(): \r cộng Shift đang giữ thành \x1b[13;2u
pi/packages/tui/src/terminal.tsdòng 396–406TypeScript
private forwardInputSequence(sequence: string): void {	if (!this.inputHandler) return;	const shouldDetectNativeShiftEnter =		sequence === "\r" && (isAppleTerminalSession() || process.platform === "win32");	const input = normalizeNativeShiftEnterInput(		sequence,		shouldDetectNativeShiftEnter,		shouldDetectNativeShiftEnter && isNativeModifierPressed("shift"),	);	this.inputHandler(input);}

Cách này chỉ đúng khi Pi chạy trên cùng máy với terminal; qua SSH, máy chạy Pi không nhìn thấy bàn phím của người dùng (terminal-setup.md:43–45). Việc thứ ba, enableVirtualTerminalInput, thêm cờ ENABLE_VIRTUAL_TERMINAL_INPUT cho stdin của console (win32-platform.c:33–48). Nếu thiếu cờ đó, theo chú thích trong terminal.ts, libuv đọc sự kiện console thô và bỏ mất phím bổ trợ, nên Shift+Tab tới như Tab thường; cờ phải được đặt sau setRawMode(true) vì raw mode đặt lại các cờ console (terminal.ts:221–225).

Interactive mode dựng giao diện trên pi-tui#

Phần này nhìn từ phía ứng dụng: coding agent chọn renderer, dựng cây component và nối sự kiện của session vào cây ra sao. Vòng đời của bản thân InteractiveMode (khởi động, lệnh slash, chuyển session) thuộc chương Các chế độ chạy.

Chọn renderer

createInteractiveTui(): composition root cho cả hai chế độ
pi/packages/coding-agent/src/modes/interactive/tui-renderer.tsdòng 22–50TypeScript
export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScreen | TuiAltScreen {	const terminal = options.terminal ?? new ProcessTerminal();	if (options.tuiMode === "fullscreen") {		const styleSearchMatch = (text: string) => theme.bg("searchMatchBg", theme.fg("searchMatchText", text));		return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, {			searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)),			searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),			searchNavigationButtonStyle: (text, hovered) => (hovered ? theme.underline(text) : text),			scrollToEndIndicator: () => {				const shortcut = keyDisplayText("tui.altScreen.bottom");				const label = ` ↓ Jump to latest message${shortcut ? ` · ${shortcut}` : ""} `;				return theme.bg("selectedBg", theme.fg("text", label));			},			openUrl: openBrowser,			onRightClickPaste: options.onRightClickPaste,			copyOnSelect: options.fullscreenCopyOnSelect,			wheelScrollLines: options.fullscreenWheelScrollLines ?? "auto",			copySelection: async (text) => {				try {					await copyToClipboard(text);					return true;				} catch (error) {					return error instanceof Error ? error.message : String(error);				}			},		});	}	return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory);}

Đây là chỗ duy nhất biết đến cả hai lớp renderer. Ở fullscreen, nó nối các điểm mở của TuiAltScreen vào thế giới của coding agent: kiểu tô kết quả tìm kiếm lấy từ theme, nhãn “↓ Jump to latest message” ghi kèm phím tắt hiện hành, mở URL bằng trình duyệt, copy bằng clipboard thật thay cho OSC 52, và con lăn mặc định ở chế độ "auto" (tăng tốc khi quay nhanh trên các terminal không tự tăng tốc). Phần còn lại của interactive mode chỉ thấy interface TUI.

Vì người dùng có thể đổi chế độ trong /settings lúc đang chạy, renderer có thể bị thay. Component nào đã giữ tham chiếu tới renderer cũ (editor, loader, tool row) sẽ gọi vào một đối tượng đã dừng. createInteractiveTuiReference() giải quyết việc đó bằng một Proxy: mỗi lần đọc thuộc tính hay gọi phương thức, nó hỏi renderer hiện tại qua getTui() (tui-renderer.ts:52–81). InteractiveMode giữ renderer thật và đưa cho mọi người ui là proxy này (interactive-mode.ts:624–633).

Một cây, hai cách gắn

init(): dựng layout fullscreen một lần và gắn cùng một cây vào renderer
pi/packages/coding-agent/src/modes/interactive/interactive-mode.tsdòng 968–992TypeScript
// Keep one component tree and remount it when changing renderers.this.renderWidgets(); // Initialize with default spacerconst viewport = createChatViewport({	document: this.documentContainer,	pendingMessages: this.pendingMessagesContainer,	status: this.statusContainer,	widgetsAbove: this.widgetContainerAbove,	editor: this.editorContainer,	widgetsBelow: this.widgetContainerBelow,	footer: this.footerContainer,	scrollbar: this.settingsManager.getFullscreenScrollbar(),	scrollbarTrackStyle: (text) => theme.fg("scrollbarTrack", text),	scrollbarThumbStyle: (text) => theme.fg("scrollbarThumb", text),});this.transcriptScrollView = viewport.transcript;this.fullscreenLayoutRoot = viewport.root;this.mountInteractiveTui(this.renderer, [	this.documentContainer,	this.pendingMessagesContainer,	this.statusContainer,	this.widgetContainerAbove,	this.editorContainer,	this.widgetContainerBelow,	this.footerContainer,]);
TUI.children (regular: nối dọc)              layoutRoot (fullscreen: createChatViewport)
├─ documentContainer                         VStack
│   ├─ headerContainer                       ├─ ScrollView(documentContainer)  grow 1, primary, follow end
│   ├─ loadedResourcesContainer              └─ VStack dock                    basis auto
│   └─ chatContainer                             ├─ pendingMessagesContainer
│       ├─ UserMessageComponent                  ├─ statusContainer
│       ├─ AssistantMessageComponent             ├─ widgetContainerAbove
│       ├─ ToolExecutionComponent ...            ├─ editorContainer ─ CustomEditor
├─ pendingMessagesContainer                      ├─ widgetContainerBelow
├─ statusContainer                               └─ footerContainer ─ FooterComponent
├─ widgetContainerAbove
├─ editorContainer ─ CustomEditor
├─ widgetContainerBelow
└─ footerContainer ─ FooterComponent
Cùng các đối tượng container, hai cách gắn. Ở regular, thứ tự của children là bố cục. Ở fullscreen, setLayoutRoot() bọc chúng trong layout có chiều cao ràng buộc, và render() của TUI đi qua layout root (tui-alt-screen.ts:334–336). children vẫn được gắn ở cả hai chế độ: đó là danh sách mà switchTuiMode() mang sang renderer mới.

Container là đơn vị thay thế: bộ chọn model, session hay settings không phải overlay mà được đặt thay editor trong editorContainer, nhận focus, và trả editor về chỗ khi xong (interactive-mode.ts:4853–4876). Widget của extension vào widgetContainerAbove/Below; khi không có widget nào, container phía trên chứa một Spacer(1) (:2498–2511).

Đổi chế độ khi đang chạy

switchTuiMode(): dừng renderer cũ mà giữ màn hình, gắn lại cây vào renderer mới
pi/packages/coding-agent/src/modes/interactive/interactive-mode.tsdòng 892–910, 923–932TypeScript
private switchTuiMode(mode: TuiMode, restoreProgress = true, startRenderer = true): boolean {	const previousUi = this.renderer;	if (mode === previousUi.mode) return true;	if (previousUi.hasOverlayEntries) return false;	const components = [...previousUi.children];	const focus = previousUi.getFocusedComponent();	const terminal = previousUi.terminal;	const showHardwareCursor = previousUi.getShowHardwareCursor();	const clearOnShrink = previousUi.getClearOnShrink();	const onDebug = previousUi.onDebug;	if (previousUi instanceof TuiMainScreen) {		this.mainScreenRenderState = previousUi.captureRenderState();	}	previousUi.stop({ preserveScreen: true });	previousUi.setFocus(null);	previousUi.clear();	if (TuiLayouts.isViewportTUI(previousUi)) previousUi.setLayoutRoot(undefined);	if (nextUi instanceof TuiMainScreen && this.mainScreenRenderState) {		nextUi.restoreRenderState(this.mainScreenRenderState);	}	this.renderer = nextUi;	this.options.tuiMode = mode;	this.mountInteractiveTui(nextUi, components);	nextUi.invalidate();	nextUi.setFocus(focus);	if (!startRenderer) return true;	nextUi.start();

Không đổi được khi đang có overlay. Renderer cũ dừng với preserveScreen: true để không in lại hay đẩy con trỏ đi; cây được tháo ra và gắn y nguyên vào renderer mới, focus được trả về đúng component cũ. Nếu đi từ regular sang fullscreen, state vi sai của main screen được chụp lại, để khi quay về regular, TuiMainScreen mới tiếp tục từ chỗ cũ thay vì in lại cả lịch sử; đó là “render-state handoff” mà CHANGELOG 0.84.0 nhắc tới (CHANGELOG.md:241). Lúc thoát fullscreen, nếu fullscreenExitOutput là "transcript" (mặc định), interactive mode chuyển hẳn về regular rồi gọi renderNow() để in transcript vào main screen (:883–890); giá trị kia, "resume-hint", chỉ in gợi ý cách mở lại session (settings-selector.ts:728).

Từ sự kiện của session tới component

InteractiveMode đăng ký một listener với AgentSession và xử lý từng sự kiện trong handleEvent() (interactive-mode.ts:3357–3369). Mỗi nhánh chỉ làm hai việc: đổi cây component, rồi ui.requestRender(); nhịp 16 ms lo phần còn lại.

message_start: tin nhắn user và custom vào chat ngay; assistant thì tạo component stream
pi/packages/coding-agent/src/modes/interactive/interactive-mode.tsdòng 3457–3479TypeScript
case "message_start":	if (event.message.role === "custom") {		this.addMessageToChat(event.message);		this.ui.requestRender();	} else if (event.message.role === "user") {		this.addMessageToChat(event.message);		this.updatePendingMessagesDisplay();		this.ui.requestRender();	} else if (event.message.role === "assistant") {		this.streamingComponent = new AssistantMessageComponent(			undefined,			this.hideThinkingBlock,			this.getMarkdownThemeWithSettings(),			this.hiddenThinkingLabel,			this.outputPad,			this.getMarkdownTransformers(),		);		this.streamingMessage = event.message;		this.chatContainer.addChild(this.streamingComponent);		this.streamingComponent.updateContent(this.streamingMessage, true);		this.ui.requestRender();	}	break;
Sự kiệnThay đổi trên cây
message_startUser, custom: thêm component vào chatContainer. Assistant: tạo AssistantMessageComponent đang stream và thêm vào cuối chat.
message_updateupdateContent(message, true); với mỗi tool call mới xuất hiện trong luồng, tạo một ToolExecutionComponent ngay cả khi đối số chưa stream xong, hoặc cập nhật đối số của hàng đã có (:3486–3512).
message_endCập nhật lần cuối với isStreaming = false; báo lỗi cho các tool đang chờ nếu bị huỷ hay lỗi, hoặc báo đối số đã đủ để tool sửa file tính diff.
tool_execution_start, _update, _endĐánh dấu bắt đầu, cập nhật kết quả từng phần, kết quả cuối kèm thời gian chạy. Lời gọi lồng (từ script codemode) được vẽ trong hàng của tool cha.
agent_endTắt chỉ báo working và thanh tiến trình của terminal; gỡ component stream còn sót.
entry_appended loại compactionXoá chatContainer và dựng lại từ các entry còn trong context (xem chương Compaction).

Mọi sự kiện đều bắt đầu bằng footer.invalidate(), vì footer hiện số token, chi phí, phần trăm context và có thể đổi sau bất kỳ sự kiện nào. Một ToolExecutionComponent là một Box nền toolPendingBg chuyển sang toolSuccessBg hay toolErrorBg khi có kết quả, và vẽ phần gọi và phần kết quả bằng renderer riêng của tool nếu tool có (tool-execution.ts:104–110; cách tool khai báo renderer thuộc chương Công cụ). Tin nhắn user là một Markdown tự tô nền userMessageBg và tự đệm, không bọc trong Box, vì Box sẽ giữ thêm một bản sao rộng kín màn hình của mỗi dòng (user-message.ts:38–58). Cả tin nhắn user lẫn assistant đều gắn dấu OSC 133, nhờ đó phím nhảy giữa các lời nhắc hoạt động ở cả hai chế độ (mục TuiAltScreen).

Theme và system theme#

Theme: token ngữ nghĩa thành mã ANSI

Component của coding agent không viết màu cứng. Chúng gọi theme.fg("accent", text) hay theme.bg("toolSuccessBg", text) theo tên vai trò. Danh sách vai trò là THEME_TOKENS, 56 token, mỗi token thuộc ô chữ (foreground) hoặc ô nền (background), có mô tả và có thể có token dự phòng (theme-tokens.ts:24–101). Một file theme JSON gán giá trị cho các token, với sáu dạng giá trị: hex, oklch(...), okhsl(...), chỉ số 0–255, tên biến trong vars, và chuỗi rỗng nghĩa là màu mặc định của terminal (themes.md:83–92).

Ở tầng dưới, pi-tui có kiểu Color là chỉ số bảng màu, sRGB hoặc OKLCH, và mọi màu đều đổi được sang sRGB nên phép trộn màu luôn chạy (colors.ts:4–24). OKLab là không gian màu theo cảm nhận: L là độ sáng từ 0 tới 1, hai trục a, b là cặp màu đối nghịch; OKLCH là dạng toạ độ cực của nó (độ sáng, độ đậm chroma, góc màu hue); OKHSL đo saturation tương đối với mức tối đa mà gam sRGB cho phép ở hue và độ sáng đó, nên mọi giá trị đều hiển thị được và cùng saturation trông đậm như nhau ở mọi hue (README.md:107–109). Khi ghi ra, màu thành 38;2;r;g;b trên terminal truecolor hoặc chỉ số gần nhất của bảng 256 màu (colors.ts:299–307).

Theme: tính sẵn mã ANSI cho từng token; fg() chỉ còn tra bảng và nối chuỗi
pi/packages/coding-agent/src/modes/interactive/theme/theme.tsdòng 226–235, 297–306TypeScript
	const addToken = (token: ThemeToken, value: string | number, isBackground: boolean): string => {		if (value === "") {			(isBackground ? this.defaultBackgroundTokens : this.defaultForegroundTokens).push(token);			return isBackground ? "\x1b[49m" : "\x1b[39m";		}		const color = parseColor(value);		this.concreteColors[token] = color;		(isBackground ? concreteBackgrounds : concreteForegrounds).push(color);		return isBackground ? backgroundAnsi(color, mode) : foregroundAnsi(color, mode);	};fg(color: ThemeColor, text: string): string {	const ansi = this.tokenAnsi(this.fgAnsi, color);	if (this.dimTokens.has(color)) return `${ansi}\x1b[2m${text}\x1b[22;39m`;	return `${ansi}${text}\x1b[39m`;}bg(color: ThemeBg, text: string): string {	const ansi = this.tokenAnsi(this.bgAnsi, color);	return `${ansi}${text}\x1b[49m`;}

fg() nằm trên đường nóng của render, nên mọi chuyển đổi màu được làm một lần lúc tạo theme. Token rỗng thành \x1b[39m hoặc \x1b[49m, tức màu mặc định của terminal; token mờ được thêm SGR 2. Biến toàn cục theme là một Proxy đọc theme thật từ một khoá Symbol.for(...) trên globalThis, để mọi bản sao của module (Node và jiti khi chạy dev) thấy cùng một theme (theme.ts:647–664). Đổi theme thì controller gọi ui.invalidate() rồi requestRender(); mọi cache dòng bị xoá và component dựng lại chuỗi màu (theme-controller.ts:249–252).

System theme: màu lấy từ chính terminal

Vấn đề. Một bảng màu cố định, dù là dark hay light, hiếm khi hợp với theme mà người dùng đã chọn cho terminal (Solarized, Catppuccin, Gruvbox...), và có thể khó đọc trên nền xám trung tính. Từ 0.99.0, ngay trước bản 1.0, theme mặc định là system (coding-agent CHANGELOG.md:445): Pi hỏi terminal màu chữ, màu nền và 16 màu ANSI, lấy hue của từng vai trò từ bảng màu đó, và tự giải độ sáng sao cho mỗi vai trò đạt một mức tương phản (themes.md:5–12). Bài “There are many themes, but this one is yours” của Earendil tóm tắt đúng các ý này: đọc màu của terminal, giải độ sáng từ quy tắc tương phản bằng đa thức đã khớp, làm việc trong OKLCH và OKHSL, và giới hạn chroma theo chroma của bảng màu (earendil.com/posts/system-theme).

Bước 1: hỏi terminal.

Một lần hỏi: OSC 10, OSC 11, OSC 4 cho 16 màu, và DA1 làm mốc kết thúc
pi/packages/tui/src/tui.tsdòng 160–172TypeScript
const TERMINAL_PALETTE_SIZE = 16;/** OSC 10 and 11 plus OSC 4 for every palette color. */const TERMINAL_COLOR_REPLY_COUNT = 2 + TERMINAL_PALETTE_SIZE;/** * Default colors, palette colors 0-15, and a trailing primary device attributes (DA1) request. * Every terminal answers DA1 and terminals answer in order, so the DA1 reply marks the end of * the color replies, including for terminals that ignore the color queries. */const TERMINAL_COLOR_QUERY = `\x1b]10;?\x07\x1b]11;?\x07${Array.from(	{ length: TERMINAL_PALETTE_SIZE },	(_, index) => `\x1b]4;${index};?\x07`,).join("")}\x1b[c`;const DEVICE_ATTRIBUTES_RESPONSE_PATTERN = /^\x1b\[\?[\d;]*c$/;

Mười tám câu hỏi màu và một DA1 đi trong một lần ghi. Câu trả lời tới qua stdin và bị TuiBase nuốt trước mọi xử lý khác (tui.ts:1159–1191). Truy vấn xong khi DA1 tới hoặc khi đủ mười tám câu trả lời; bảng màu chỉ được đặt khi có đủ 16 màu. Interactive mode chờ tối đa 100 ms (theme-controller.ts:20–40); câu trả lời tới muộn hơn, ví dụ qua SSH chậm, vẫn được áp dụng qua onLateReply. Trong lúc chờ, system theme render ở thang xám (saturation 0) thay vì đoán màu (theme.ts:536–549), và header lúc khởi động, vốn “nướng” sẵn màu vào chuỗi, chỉ được dựng sau khi màu đã về (interactive-mode.ts:1005–1008). Tuỳ những gì terminal trả lời, theme được sinh theo một trong ba bậc (system-theme.ts:16–19): có cả nền và bảng màu thì hue lấy từ bảng màu; chỉ có nền thì dùng hue riêng của từng họ màu; không có gì thì dùng chỉ số ANSI 0–15 và màu mặc định để terminal tự vẽ, chữ phụ thì làm mờ bằng SGR 2 thay vì dùng màu ANSI 8 (bright black), vốn gần như vô hình trong vài theme (system-theme.ts:637–655).

Bước 2: họ màu. Mỗi token thuộc một họ, và mỗi họ có hue, một khoảng saturation, và một ô trong bảng ANSI để lấy hue thật của terminal:

Họ màu: hue, khoảng saturation, ô ANSI nguồn
pi/packages/coding-agent/src/modes/interactive/theme/system-theme.tsdòng 41–64TypeScript
/** A family's OKHSL hue and saturation: `max` at mid lightness, falling toward `min` at black and white. */interface Family {	hue: number;	saturation: { min: number; max: number };	/** ANSI palette slot the family takes its hue and saturation from. */	slot: number;}const FAMILIES = {	neutral: { hue: 231.49, saturation: { min: 0.02, max: 0.08 }, slot: 8 },	blue: { hue: 231.49, saturation: { min: 0.1, max: 0.68 }, slot: 4 },	green: { hue: 158.68, saturation: { min: 0.1, max: 0.76 }, slot: 2 },	red: { hue: 20, saturation: { min: 0.1, max: 0.92 }, slot: 1 },	yellow: { hue: 82.36, saturation: { min: 0.5, max: 1 }, slot: 3 },	orange: { hue: 52, saturation: { min: 0.12, max: 0.85 }, slot: 3 },	violet: { hue: 295, saturation: { min: 0.2, max: 0.6 }, slot: 5 },	calamine: { hue: 202.43, saturation: { min: 0.1, max: 0.74 }, slot: 6 },	thinkingSlate: { hue: 231.49, saturation: { min: 0.08, max: 0.2 }, slot: 4 },	thinkingBlue: { hue: 231.49, saturation: { min: 0.2, max: 0.45 }, slot: 4 },	thinkingPeriwinkle: { hue: 263.25, saturation: { min: 0.3, max: 0.6 }, slot: 6 },	thinkingViolet: { hue: 295, saturation: { min: 0.4, max: 0.75 }, slot: 5 },	thinkingMagenta: { hue: 337.5, saturation: { min: 0.5, max: 0.85 }, slot: 13 },	thinkingRed: { hue: 20, saturation: { min: 0.95, max: 1 }, slot: 1 },} satisfies Record<string, Family>;

Ví dụ error thuộc họ red (ô 1), mdLink thuộc blue (ô 4), accent thuộc violet (ô 5); vài token dùng ô khác để khỏi trùng màu với token tương tự, như syntaxString lấy ô 2 (system-theme.ts:68–131). Saturation của họ theo một đường cong chuông: lớn nhất ở độ sáng trung bình, giảm dần về min ở gần đen và gần trắng (:443–453).

Bước 3: mức tương phản là một đa thức.

Curve và levelTarget(): độ sáng cần có là đa thức bậc năm theo độ sáng của bề mặt
pi/packages/coding-agent/src/modes/interactive/theme/system-theme.tsdòng 137–152, 455–460TypeScript
/** * Target-lightness curves: a polynomial in the surface's OKLab lightness giving the OKLab lightness a token * needs on it. `reachable` is the range of surface lightness where the level can be reached; beyond it the * level is relaxed. */interface Curve {	coefficients: number[];	reachable: [number, number];}const LEVELS = {	panel: {		dark: { coefficients: [0.29131, -0.39746, 2.33185, -0.85524, -1.2076, 0.86276], reachable: [0, 0.979] },		light: { coefficients: [-3.74073, 27.94549, -78.44258, 112.6798, -79.60015, 22.11277], reachable: [0.348, 1] },	},	track: {/** The target lightness for a level on a surface, or undefined where the level cannot be reached. */function levelTarget(level: Level, appearance: ThemeAppearance, surfaceL: number): number | undefined {	const curve: Curve = LEVELS[level][appearance];	if (surfaceL < curve.reachable[0] || surfaceL > curve.reachable[1]) return undefined;	return curve.coefficients.reduce((sum, coefficient, power) => sum + coefficient * surfaceL ** power, 0);}

Một “mức” (panel, subtle, readable, emphasis, text...) là hàm cho biết token cần độ sáng OKLab bao nhiêu khi nằm trên một bề mặt có độ sáng cho trước. Mỗi mức có hai đa thức, cho nền tối và nền sáng, với hệ số được khớp theo bản thiết kế theme tham chiếu, và một khoảng reachable ngoài đó mức bị nới (system-theme.ts:11–14). Thay số vào để thấy hình dạng của chúng:

Ví dụ minh hoạ: nền (OKLab L)panelsubtlereadabletext
Tối, 0,100,2740,5640,6690,893
Tối, 0,200,2970,5720,6750,897
Tối, 0,300,3510,6000,6990,915
Sáng, 1,000,6510,456
Sáng, 0,900,5050,209

Các giá trị được tính tay từ hệ số trong LEVELS. Trên nền tối, độ sáng đích gần như cố định (chữ thân bài khoảng 0,9 dù nền là 0,1 hay 0,2); trên nền sáng, khoảng cách cần thiết lớn dần khi nền tối đi (chữ cách nền 0,54 khi nền trắng, 0,69 khi nền 0,9). Đúng như chú thích đầu file mô tả.

Bước 4: quy tắc và thứ tự giải.

RULES: token nào phải đạt mức nào trên những bề mặt nào
pi/packages/coding-agent/src/modes/interactive/theme/system-theme.tsdòng 289–300TypeScript
const each = (tokens: ThemeToken[], on: Surface[], level: Level): Rule[] =>	tokens.map((token) => ({ token, on, level }));const RULES: Rule[] = [	...each(PANELS, ["background"], "panel"),	{ token: "text", on: ["background"], level: "text" },	{ token: "text", on: ["selectedBg"], level: "textOnPanel" },	{ token: "userMessageText", on: ["userMessageBg"], level: "textOnPanel" },	{ token: "toolTitle", on: TOOL_PANELS, level: "textOnPanel" },	...each(["accent", "success", "error", "warning"], ["background", "selectedBg", ...TOOL_PANELS], "readable"),	{ token: "muted", on: ["background", "selectedBg", "customMessageBg", ...TOOL_PANELS], level: "readable" },	{ token: "dim", on: ["background", "selectedBg", "customMessageBg", ...TOOL_PANELS], level: "subtle" },

Mỗi quy tắc nói một token phải đạt một mức trên một danh sách bề mặt: các panel (nền tin nhắn user, nền tool...) phải đạt panel trên nền; text đạt text trên nền và textOnPanel trên selectedBg; accent, error đạt readable trên nền và trên mọi panel tool. Vì token có thể nằm trên một panel mà bản thân panel cũng là token cần giải, SOLVE_ORDER sắp xếp theo phụ thuộc để mọi bề mặt được giải trước token vẽ trên nó (system-theme.ts:352–365).

Bước 5: giải, và nới khi không giải được.

solve(t) và tìm nhị phân mức nới nhỏ nhất
pi/packages/coding-agent/src/modes/interactive/theme/system-theme.tsdòng 522–553TypeScript
const solve = (t: number): Map<Surface | ThemeToken, RgbColor> | undefined => {	const colors = new Map<Surface | ThemeToken, RgbColor>([["background", background]]);	for (const token of SOLVE_ORDER) {		const targets: number[] = [];		for (const rule of RULES) {			if (rule.token !== token) continue;			for (const surface of rule.on) {				const value = target(rule.level, oklabLightness(colors.get(surface) ?? background), t);				if (value === undefined || value < 0 || value > 1) return undefined;				targets.push(value);			}		}		const l = lighter ? Math.max(...targets) : Math.min(...targets);		colors.set(token, PANELS.includes(token as ThemeBg) ? limitPanel(token, l) : paint(token, l));	}	return colors;};let relaxation = 0;let colors = solve(0);if (!colors) {	// Mid-gray backgrounds cannot fit every level: relax as little as possible. Full relaxation always fits.	let [low, high] = [0, 2];	colors = solve(high);	for (let index = 0; index < 20; index++) {		const middle = (low + high) / 2;		const attempt = solve(middle);		if (attempt) [high, colors] = [middle, attempt];		else low = middle;	}	relaxation = high;}

Với mỗi token, solve() tính độ sáng đích cho từng cặp (quy tắc, bề mặt), lấy giá trị khắt khe nhất (lớn nhất trên nền tối, nhỏ nhất trên nền sáng), rồi tô màu ở độ sáng đó với hue và saturation của họ. Panel còn bị giới hạn để chữ trắng (hoặc đen) vẫn đạt tỷ lệ tương phản WCAG 4,5:1 trên nó (system-theme.ts:504–520). Nền xám trung tính không chứa nổi mọi mức: đích có thể vượt khỏi 0–1, và solve(0) trả về undefined. Khi đó tham số nới t được tìm nhị phân trong 20 bước: từ 0 tới 1, các mức mạnh hơn mức sàn (readable trên nền tối, subtle trên nền sáng, system-theme.ts:341) bị nén dần về mức sàn; từ 1 tới 2, mọi mức bị kéo về phía chính bề mặt (system-theme.ts:490–502). t = 2 luôn giải được, nên luôn có kết quả.

Chữ thân bài được xử lý riêng sau cùng. Nếu màu chữ mặc định của terminal đủ đạt mức emphasis trên mọi bề mặt nó nằm, token được để rỗng, tức dùng đúng màu chữ của người dùng; nếu không, nó lấy hue của màu chữ đó ở độ sáng vừa đủ. Trong mọi trường hợp, withTextContrast() đẩy màu về phía trắng hoặc đen cho tới khi đạt 4,5:1 trên mọi bề mặt (system-theme.ts:566–585, :619–635).

Bước 6: giữ chroma của bảng màu.

anchored(): đổi độ sáng của một màu bảng mà không làm nó đậm hơn
pi/packages/coding-agent/src/modes/interactive/theme/system-theme.tsdòng 602–617TypeScript
/** * A source color's hue at another OKHSL lightness. Its saturation applies at its own lightness and falls off * toward black and white along the family's saturation curve, never rising above it. * * OKHSL saturation is relative to the most chroma sRGB allows at a lightness, so the same saturation can mean * more chroma elsewhere: Catppuccin Frappe's pink #f4b8e4 (chroma 0.089) would become #eb76d1 (0.180) at the * lightness the accent needs. Chroma is therefore also capped at the source's, with the same falloff. */function anchored(source: SourceColor, family: Family, lightness: number, saturation: number): RgbColor {	const anchor = saturationCurve(family, source.l);	const falloff = anchor > 0 ? Math.min(1, saturationCurve(family, lightness) / anchor) : 1;	const color = okhslColor(source.h, source.s * falloff * saturation, lightness);	const cap = source.chroma * falloff * saturation;	const { l, c } = colorToOklch(color);	return c <= cap ? color : colorToRgb(oklchColor(l, cap, source.h));}

Khi một màu của bảng được dời sang độ sáng mà token cần, giữ nguyên saturation OKHSL nghe có vẻ đúng, nhưng saturation đó là tương đối với gam màu, và ở độ sáng khác gam cho phép nhiều chroma hơn. Ví dụ trong chú thích: màu hồng #f4b8e4 của Catppuccin Frappe có chroma 0,089, sẽ thành #eb76d1 với chroma 0,180 ở độ sáng mà accent cần, tức gấp đôi độ đậm, và bảng màu pastel không còn pastel. Vì vậy chroma bị chặn trên bằng chroma gốc, giảm dần theo cùng đường cong của họ. Đây là sửa lỗi của 1.0.0 (coding-agent CHANGELOG.md:365).

Bước 7: theo terminal khi nó đổi sáng tối. Với system theme và các cặp theme dạng "light/dark", controller bật thông báo đổi bảng màu (chế độ 2031, \x1b[?2031h; tui.ts:986–994). Terminal hỗ trợ sẽ gửi \x1b[?997;1n khi chuyển sang tối và \x1b[?997;2n khi sang sáng (terminal-colors.ts:85–91); controller hỏi lại màu và sinh lại theme (theme-controller.ts:241–247). Câu hỏi “terminal đang sáng hay tối” được quyết định bằng màu nền và màu chữ terminal báo; nếu không có màu nền thì theo báo cáo 2031, rồi biến COLORFGBG, cuối cùng mặc định là tối (theme.ts:623–636).

Quyết định thiết kế: từ scrollback tới fullscreen#

Thiết kế gốc: ghi tiếp vào scrollback của terminal

Bài blog tháng 11/2025 của Mario, viết khi pi còn là dự án cá nhân, mô tả pi-tui theo đúng những gì TuiMainScreen làm hôm nay: giữ nguyên scrollback của terminal, component theo kiểu retained mode với render(width), vẽ vi sai bắt đầu từ dòng đầu tiên thay đổi, và bọc mỗi lần cập nhật trong synchronized output CSI ?2026h/CSI ?2026l để không nhấp nháy (What I learned building an opinionated and minimal coding agent). Lựa chọn này cho transcript hành xử như output của mọi chương trình dòng lệnh: nó nằm trong lịch sử của terminal, nên cuộn, tìm kiếm, chọn chữ và copy đều dùng công cụ quen thuộc của chính terminal, và ứng dụng không phải tự cài chúng (tài liệu nói ngắn gọn: “the terminal owns scrollback”, docs/tui.md:62). README vẫn mô tả TuiMainScreen theo cách đó: “renders into the main terminal buffer and preserves terminal scrollback” (README.md:61). Thiết kế này sống tiếp nguyên vẹn thành chế độ regular: cùng thuật toán, chọn bằng tuiMode: "regular" hay --tui-mode regular, và vẫn là renderer của pi config.

Giá của scrollback

Mục Trace cho thấy mặt trái. Thứ gì đã trôi vào scrollback thì đóng băng: muốn đổi nó, TUI phải xoá cả màn hình lẫn scrollback rồi in lại toàn bộ tài liệu. Với một coding agent, những thay đổi “ở quá khứ” không hiếm: mở rộng output của mọi tool, ẩn hiện khối thinking, một tool cũ trong batch song song xong muộn. Đổi độ rộng cửa sổ cũng buộc in lại tất cả vì mọi đoạn phải ngắt dòng lại. Ở session vài nghìn dòng, đó là vài nghìn dòng mỗi lần, và chỉ synchronized output che được cảnh lịch sử chạy lại trước mắt. Chiều cao còn là một chỗ giằng co: 0.51.1 bỏ vẽ lại khi đổi chiều cao, 0.62.0 bật lại, rồi thêm ngoại lệ Termux. Bố cục cũng bị ràng buộc: editor và footer chỉ là các dòng cuối của tài liệu, không có vùng cố định; overlay phải đệm khung đủ một màn hình. Và chuột thuộc về terminal, nên không có bấm để mở rộng hay kéo thanh cuộn.

Fullscreen: ứng dụng sở hữu viewport

0.84.0 thêm hai renderer cùng interface, có cuộn do ứng dụng sở hữu, cùng VStack, HStack, ScrollView lồng nhau (CHANGELOG.md:244–245); 1.0.0 đổi mặc định sang fullscreen (coding-agent CHANGELOG.md:328), và bài “Pi 1.0” của Earendil liệt kê “fullscreen by default” trong các thay đổi lớn (Pi 1.0). Đổi lại được:

  • Lượng ghi ra mỗi khung có trần là height hàng, bất kể thay đổi nằm đâu trong tài liệu và bất kể tài liệu dài bao nhiêu. Resize chỉ vẽ lại một màn hình.
  • Dock cố định: editor và trạng thái đứng yên ở đáy cửa sổ trong khi transcript cuộn bên trên (usage.md:86).
  • Cuộn không bị giật về đáy: follow: "end" chỉ theo output khi đang ở đáy; ai cuộn lên đọc thì được để yên, kèm nhãn “nhảy tới tin mới nhất”.
  • Tương tác của ứng dụng: tìm trong transcript, nhảy giữa các lời nhắc, bấm để ẩn hiện thinking, link bấm được, thanh cuộn, thông báo nổi, ảnh Kitty cắt theo viewport.
  • Không mất transcript khi thoát, vì nó được in lại vào main screen.

Cái giá là ứng dụng phải tự làm những việc terminal vốn làm, và làm khác terminal một chút: chọn chữ, copy (OSC 52 không đáng tin nên phải dùng clipboard native), mở link, tìm kiếm, cuộn bằng con lăn. Tính năng riêng của terminal mất tác dụng khi chuột bị bắt: Ghostty không còn gạch chân và xem trước URL khi rê chuột (terminal-setup.md:65–67), cử chỉ trackpad nhanh trên iTerm2 chỉ cuộn khoảng một dòng mỗi lần (:29–39), ảnh iTerm2 thành chữ thay thế, multiplexer chậm đi vì lưu lượng chuột. Phần code cũng lớn: tui-alt-screen.ts gần 1.800 dòng, chưa kể bố cục và tìm kiếm, so với khoảng 650 dòng của tui-main-screen.ts. Vì cả hai lựa chọn đều có người cần, Pi giữ cả hai.

Một cây component, hai renderer

Điều làm việc giữ cả hai rẻ là component không biết mình đang ở renderer nào. TuiBase chứa mọi thứ chung (focus, overlay, định tuyến input, lịch render, hỏi màu), mỗi lớp con chỉ cài doRender() và vài hook vòng đời. Interactive mode dựng đúng một cây và gắn nó theo hai cách, đổi renderer lúc đang chạy bằng cách tháo cây ra và gắn sang renderer mới, chuyển cả state vi sai của main screen để không in lại lịch sử. Proxy createInteractiveTuiReference() giữ cho các tham chiếu tới TUI trong component luôn trỏ vào renderer hiện hành. Những gì chỉ có nghĩa ở một chế độ được tách bằng kiểu: handleMouse chỉ được gọi ở fullscreen; setLayoutRoot() chỉ có trên ViewportTUI; các layout vẫn có render(width) trả về tài liệu không giới hạn, nên đặt chúng trong cây của main screen cũng chỉ cho ra các dòng xếp chồng bình thường (README.md:164).

Dòng chuỗi thay vì lưới ô

Nhiều thư viện TUI, curses chẳng hạn, giữ một lưới ô (mỗi ô một ký tự và một style) và so lưới. pi-tui giữ mảng chuỗi có mã ANSI. Lợi ích: một component có thể chỉ dài mười dòng; test so chuỗi; cache là giữ lại một mảng; và phép so khung chỉ là so chuỗi từng dòng. Chi phí: kỷ luật độ rộng thuộc về component; ghép overlay, tô vùng chọn, vẽ thanh cuộn đều phải cắt chuỗi theo cột mà vẫn giữ đúng trạng thái ANSI (extractSegments, sliceByColumn, getGraphemeCellRange trong utils.ts); style phải reset ở cuối mỗi dòng; và ảnh, thứ không phải chữ, phải được nhận diện riêng ở mọi bước bằng isImageLine().

System theme thay vì một bảng màu riêng

Với theme, Pi chọn mặc định là hoà vào terminal của người dùng thay vì mang theo một bộ màu của mình. Độ dễ đọc không dựa vào việc ai đó chỉnh tay từng bảng màu, mà được bảo đảm bằng quy tắc: mỗi vai trò có mức tương phản trên mỗi bề mặt nó có thể nằm, chữ thân bài luôn đạt 4,5:1 theo WCAG. Đổi lại, cả một bộ giải nhỏ (đa thức đã khớp, tìm nhị phân mức nới, giới hạn chroma) phải chạy mỗi khi terminal báo màu, và ba bậc dự phòng cho terminal không trả lời. Theme dark và light cố định vẫn có cho ai muốn.

Cạm bẫy và trường hợp biên#

Kiểm tra độ rộng trong nhánh vi sai: log, dừng TUI, ném lỗi
pi/packages/tui/src/tui-main-screen.tsdòng 518–545TypeScript
if (!isImage && visibleWidth(line) > width) {	// Log all lines to crash file for debugging	const crashLogPath = path.join(this.logDirectory ?? os.tmpdir(), "pi-tui-crash.log");	const crashData = [		`Crash at ${new Date().toISOString()}`,		`Terminal width: ${width}`,		`Line ${i} visible width: ${visibleWidth(line)}`,		"",		"=== All rendered lines ===",		...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),		"",	].join("\n");	fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });	fs.writeFileSync(crashLogPath, crashData);	// Clean up terminal state before throwing	this.stop();	const errorMsg = [		`Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,		"",		"This is likely caused by a custom TUI component not truncating its output.",		"Use visibleWidth() to measure and truncateToWidth() to truncate lines.",		"",		`Debug log written to: ${crashLogPath}`,	].join("\n");	throw new Error(errorMsg);}
  • Kiểm tra chỉ có ở nhánh vi sai. fullRender() không kiểm tra, nên một dòng quá rộng có thể lọt qua khung đầu tiên và chỉ gây lỗi ở khung sau, khi nó nằm trong vùng thay đổi. Ở fullscreen, dòng quá rộng bị cắt im lặng (tui-alt-screen.ts:1709–1712), nên một component sai có thể chạy êm ở fullscreen mà sập ở regular. Kiểm thử ở cả hai chế độ, như tài liệu khuyên (docs/tui.md:182).
  • Ghi thẳng ra stdout khi TUI đang chạy. TuiMainScreen di chuyển con trỏ tương đối từ hardwareCursorRow. Một console.log chen vào giữa làm con trỏ thật lệch khỏi con số đó, và khung sau ghi đè sai chỗ. Interactive mode chỉ dùng console.log trước ui.start() (interactive-mode.ts:965, so với :1000).
  • Chuỗi màu nằm trong state. Theme đổi thì cache dòng bị xoá, nhưng chuỗi đã có mã màu cũ mà ứng dụng tự giữ thì không. Component phải dựng lại chúng trong invalidate(); AssistantMessageComponent làm vậy bằng cách gọi lại updateContent() (assistant-message.ts:51–56).
  • Sửa quá khứ ở regular. Mọi thay đổi trên viewport là một lần in lại cả tài liệu và xoá scrollback bằng \x1b[3J. Ở session dài, mở rộng tất cả tool output hay bật hiện thinking là những thao tác đắt ở regular, rẻ ở fullscreen.
  • Hàng trống còn sót. clearOnShrink tắt mặc định để đỡ vẽ lại; bật bằng setting terminal.clearOnShrink nếu thấy hàng trống còn lại sau khi nội dung co lại (settings.md:108).
  • Esc chậm qua SSH. Một ESC đơn lẻ phải chờ 100 ms qua SSH trước khi được coi là phím Esc. Hạ xuống bằng PI_TUI_ESC_TIMEOUT nếu đường truyền nhanh; đặt quá thấp thì Alt+phím kiểu cũ có thể bị tách thành Esc rồi một chữ.
  • Rò chuỗi nhả phím khi thoát. Với giao thức Kitty, terminal gửi cả sự kiện nhả phím. Nếu tiến trình thoát ngay sau phím Ctrl+D, sự kiện nhả có thể tới sau khi Pi đã trả terminal cho shell và hiện lên như rác, nhất là qua SSH chậm. Interactive mode vì vậy tắt giao thức Kitty rồi “xả” stdin tối đa một giây trước khi dừng (interactive-mode.ts:4282–4288, terminal.ts:435–471).
  • tmux. Phím có phím bổ trợ cần extended-keys; ảnh bị tắt; hyperlink OSC 8 chỉ bật nếu tmux display-message -p '#{client_termfeatures}' có hyperlinks (terminal-image.ts:49–68); chuột ở fullscreen chỉ báo khi giữ nút.
  • Phím nhả và component. Sự kiện nhả phím bị lọc trừ khi component đặt wantsKeyRelease. Component chỉ cần phím nhấn thì không phải làm gì; component nào cần biết lúc phím được thả ra thì phải bật cờ, và chỉ nhận được khi terminal dùng giao thức Kitty.
  • Gỡ lỗi render. PI_TUI_WRITE_LOG ghi lại luồng ANSI thô ra file hoặc thư mục (terminal.ts:176–189); PI_TUI_DEBUG_REDRAW=1 ghi lý do của mỗi lần vẽ lại toàn bộ vào pi-tui-debug.log trong thư mục log, ở coding agent là thư mục agent (tui-main-screen.ts:322–329); PI_TUI_DEBUG=1 chép mỗi khung vẽ theo đường vi sai, kèm các biến trạng thái, vào /tmp/tui (:570–597); lệnh /debug ghi các dòng đã render vào pi-debug.log (usage.md:92).

Tài liệu và code lệch nhau ở v1.4.0

  • README liệt kê interface Terminal thiếu drainInput, kittyProtocolActive, setTitle, setProgress, setProgramStatus và restoreOnExit so với terminal.ts:78–132.
  • README nói TuiMainScreen có “three rendering strategies” (README.md:743–749); code còn vẽ lại toàn bộ khi đổi chiều cao, khi clearOnShrink kích hoạt, và khi xoá dòng làm viewport lùi lên.
  • README chỉ nhắc ngưỡng dán hơn 10 dòng; code còn tạo marker cho lần dán hơn 1.000 ký tự.
  • AGENTS.md dặn thêm phím mặc định vào DEFAULT_EDITOR_KEYBINDINGS hay DEFAULT_APP_KEYBINDINGS (AGENTS.md:27); ở v1.4.0 hai bảng đó tên là TUI_KEYBINDINGS (pi-tui) và APP_KEYBINDINGS (coding agent). Quy tắc “không viết cứng phím” cũng có vài ngoại lệ trong chính pi-tui: shift+backspace, shift+delete, shift+space trong editor (editor.ts:841) và Shift+Ctrl+D gỡ lỗi trong TUI (tui.ts:1112).

Đọc code ở đâu#

Thứ tựFileĐọc để hiểu
1tui/README.mdHợp đồng component, overlay, chuột, hai renderer, bố cục fullscreen. Đọc trước để có bức tranh.
2tui.ts:117Component, Container, interface TUI, rồi TuiBase: lịch render (1019–1079), định tuyến input (1081–1157), overlay (752–865, 1376–1448), hỏi màu (1520–1542).
3tui-main-screen.ts:247Thuật toán vi sai của chế độ regular, đọc liền một mạch tới hết hàm.
4test/tui-render.test.tsĐặc tả chạy được: spinner giữa màn hình, nội dung co lại, resize, Termux, ảnh Kitty, ghi theo khúc.
5tui-alt-screen.ts:1690Khung fullscreen; sau đó handleViewportInput() (691) và handleMouseEvent() (912) cho phím và chuột.
6layout.ts:379, scroll-view.ts, stack.tsBố cục có chiều cao ràng buộc, theo đáy, chia chỗ theo trọng số.
7terminal.ts, stdin-buffer.tsRaw mode, thương lượng giao thức bàn phím, cắt luồng stdin, bracketed paste, khôi phục khi thoát.
8keys.ts:859, keybindings.tsKhớp một tên phím với mọi cách mã hoá; sổ đăng ký hành động.
9editor.ts:696, autocomplete.tsThứ tự ưu tiên phím, dán, undo, autocomplete bất đồng bộ.
10markdown.ts:303, terminal-image.ts, image.tsHai lớp cache của Markdown; giao thức ảnh và cách ảnh khớp vào mô hình dòng.
11native-platform.ts, native/*/README.mdAddon nào làm gì, nạp ở đâu, đi đường lui thế nào.
12tui-renderer.ts, chat-viewport.tsComposition root, proxy tham chiếu, bố cục fullscreen của coding agent.
13interactive-mode.ts:945init(), switchTuiMode() (892), handleEvent() (3363). File dài 7.000 dòng; dùng grep theo tên sự kiện.
14assistant-message.ts, custom-editor.ts, tool-execution.tsComponent tiêu biểu của ứng dụng: stream, phím của app, hàng tool.
15system-theme.ts, theme-controller.ts, theme.tsBộ giải màu, việc hỏi terminal, lớp Theme; đọc cùng docs/themes.md.
16terminal-setup.md, keybindings.md, tmux.md, tui.mdHành vi theo từng terminal, bảng phím đầy đủ, cấu hình tmux, hướng dẫn viết component cho extension.
17tui CHANGELOG.md:235, coding-agent CHANGELOG.md:324Lúc hai renderer ra đời và lúc fullscreen thành mặc định.

Tóm tắt#

  • pi-tui (khoảng 19.600 dòng, ba dependency) là một thư viện retained mode: component trả về các dòng chuỗi cho một độ rộng, TUI so khung mới với khung trước và chỉ ghi phần khác, bọc trong synchronized output \x1b[?2026h … \x1b[?2026l.
  • requestRender() gộp mọi lời xin thành tối đa một khung mỗi 16 ms; phím bấm được vẽ ngay ở nextTick; requestRender(true) buộc vẽ lại toàn bộ.
  • TuiMainScreen (regular) giữ cả tài liệu làm khung trước, di chuyển con trỏ tương đối tới dòng đầu tiên đổi và ghi đè tới dòng cuối đổi; dòng mới ở đáy đẩy dòng cũ vào scrollback gốc. Thay đổi trên viewport, đổi độ rộng hay chiều cao thì xoá scrollback và in lại toàn bộ.
  • TuiAltScreen (fullscreen, mặc định từ 1.0.0) vẽ đúng một màn hình trong alternate screen, so từng hàng và đặt con trỏ tuyệt đối. Ứng dụng sở hữu viewport: VStack, HStack, ScrollView theo đáy, chuột, chọn chữ, copy, tìm kiếm, nhảy giữa lời nhắc OSC 133; transcript được in lại vào main screen khi thoát.
  • Input đi qua StdinBuffer (cắt chuỗi, gom nội dung dán), thương lượng giao thức bàn phím Kitty với DA1 làm mốc và modifyOtherKeys làm dự phòng, rồi tới listener, focus và component. Phím được gọi theo tên hành động, người dùng cấu hình được, mở rộng bằng declaration merging.
  • Editor có trần chiều cao, marker cho lần dán lớn, undo gộp theo từ, kill ring và autocomplete bất đồng bộ có kiểm tra kết quả cũ. Markdown cache dòng theo (văn bản, độ rộng) và cache token qua WeakRef; ảnh Kitty và iTerm2 chiếm đúng số dòng bằng số hàng ảnh.
  • Interactive mode dựng một cây component, gắn theo hai cách cho hai renderer, đổi renderer lúc đang chạy kèm chuyển state vi sai; mỗi sự kiện của session chỉ đổi cây rồi gọi requestRender().
  • System theme hỏi terminal màu chữ, màu nền và 16 màu ANSI, gán mỗi token vào một họ màu, giải độ sáng từ các mức tương phản là đa thức đã khớp, nới mức khi nền xám không chứa nổi, giữ chữ thân bài ở 4,5:1 và không cho màu bảng đậm hơn chroma gốc.

14Pi 1.0 đã thay đổi những gì#

Vai trò: “Pi 1.0” nghĩa là gì#

Ngày 2026-10-01 Earendil phát hành Pi 1.0 và gọi nó là “a hardened, minimal, extensible agent harness that you can make your own”, kèm mục tiêu biến Pi thành “a stable piece of software that people and businesses can depend on” (Pi 1.0). Bối cảnh lịch sử và triết lý dẫn tới mốc này nằm ở chương Lịch sử và triết lý; chương này chỉ hỏi một câu kỹ thuật: trong code, những gì đã đổi và chúng hoạt động thế nào.

Bài công bố liệt kê bảy điểm mới: codemode (hỗ trợ MCP gốc, mô hình không phải LLM như Jev, mô hình sinh ảnh), virtual model cho extension, nạp tool hoãn lại (deferred tool loading), làm ấm cache cho mô hình Anthropic, system message giữa hội thoại, theme TUI mới, và chế độ toàn màn hình mặc định. Đọc changelog thì thấy phần lớn các điểm đó không nằm trong bản 1.0.0. Changelog 1.0.0 của coding agent khá ngắn: toàn màn hình mặc định, codemode gọn hơn khoảng 40% token, sinh ảnh trong codemode, đăng nhập Radius và Anthropic kiểu copy code, gia cố OAuth cho MCP (CHANGELOG.md:324–334). Các thay đổi lớn đến từ hai bản trước đó:

Điểm trong bài công bốVào từ bảnCơ chế chínhĐọc ở
System message giữa hội thoại0.86.0 (CHANGELOG.md:609)SystemMessage trong transcript, declareToolChanges(), diff các mục promptThông điệp hệ thống, lên dây
Làm ấm cache cho Anthropic0.86.0 (:619)CacheWarmer, Model.promptCacheLàm ấm cache
Codemode và MCP0.99.0 (:440)Gói pi-codemode, pi-mcp, ba built-in extensionchương Codemode, MCP
Nạp tool hoãn lại0.99.0 (:441)exposure, prepareLoadout(), tool_searchData model, loadout
Virtual model0.99.0, thử nghiệm (:443)registerVirtualModel(), router mỗi requestVirtual model
Mô hình Jev và mô hình ảnh0.99.0 (:451–452), codemode sinh ảnh ở 1.0.0ModelType, classify(), generateImages()ModelRuntime
Theme mới, toàn màn hình0.99.0 và 1.0.0 (:445, :328)Theme system, tuiMode: "fullscreen"Toàn màn hình, theme

Vì vậy “1.0” nên hiểu là một mốc ổn định đặt sau một loạt bản dồn dập trong hai tuần, chứ không phải một bản chứa mọi thứ mới. Mốc này cũng là lúc Pi tách bạch hai sản phẩm: pi-agent-core bỏ hẳn harness thử nghiệm cũ, chỉ giữ Agent, vòng lặp, proxy stream và các kiểu của chúng (agent/CHANGELOG.md:35), còn harness bền ra mắt cùng ngày dưới dạng gói riêng, gắn nhãn thử nghiệm (mục Pi Durable).

Chương này đi qua cơ chế của từng điểm, theo thứ tự mà một request đi qua chúng: mô hình dữ liệu của tool, cách AgentSession chọn những gì được khai báo, cách thay đổi được ghi vào transcript, và cách pi-ai đưa nó lên dây cho từng provider. Codemode, MCP và tool_search là những người dùng các cơ chế này; phần ruột của chúng thuộc chương Codemode, MCP và tool search. Vòng lặp và AgentSession được giả định là đã quen (chương pi-agent-core, chương AgentSession).

Dòng thời gian phát hành#

Ngày lấy theo tiêu đề changelog (## [x.y.z] - date). Commit phát hành thường được tạo ngay sau nửa đêm giờ châu Âu (+02:00), nên ngày của tag trong git có thể muộn hơn một ngày, ví dụ tag v1.4.0 mang ngày 2026-10-22 00:29 +0200. Số phiên bản nhảy thẳng từ 0.87.1 lên 0.99.0 (CHANGELOG.md:428). Bảng chỉ giữ thay đổi kiến trúc, không liệt kê sửa lỗi hay mô hình mới.

BảnNgàyThay đổi kiến trúc
0.86.02026-09-19System prompt và tool sống trong transcript: provider nhận TranscriptContext thay cho Context (thay đổi phá vỡ cho provider tự viết, ai/CHANGELOG.md:279). Làm ấm cache, Model.promptCache, /bug.
0.87.02026-09-21SessionManager là nguồn chuẩn của context gửi provider, ContextEditEntry, boundary turn_end và agent_before_settle, sự kiện context_with_system; finishTurn thay shouldStopAfterTurn.
0.99.02026-09-29Codemode, tool_search, MCP thành built-in extension; hai gói mới pi-codemode, pi-mcp (commit 8562bcf66). API exposure, namespace, prepareLoadout(), ctx.executeTool(). Virtual model, classifier và mô hình ảnh trong ModelRuntime, theme system, Sign in with ChatGPT. Build chuyển sang TypeScript 7 và chế độ type stripping của Node.
0.99.1–0.99.209-29, 09-30Server MCP rời khỏi mô tả codemode, chuyển sang mục mcp_servers của system prompt; prompt đầu không còn chờ server; tên tool MCP đổi - thành _.
1.0.02026-10-01Toàn màn hình mặc định; codemode gọn hơn và có models.generateImages(); OAuth MCP kiểm tra iss (RFC 9207), credential theo từng server; pi-agent-core gỡ harness cũ; Pi Durable 1.0.0 ra mắt.
1.0.12026-10-03Nix flake; override server MCP theo dự án; CIMD; registerToolRenderer(); Anthropic định nghĩa tool mới ngay trong hội thoại (beta inline-tools-2026-09-15, ai/CHANGELOG.md:150); bỏ npm-shrinkwrap.json, ghim bắc cầu chuyển sang bộ cài pi.dev.
1.0.2–1.0.410-04, 10-05samplingParamsByThinkingLevel; đổi tên provider azure; mẫu * trong --tools, --no-mcp; built-in của codemode bị đóng băng; ToolLoadout.getPromptGuidelines().
1.1.02026-10-07Báo trạng thái chương trình qua OSC 7501; durationMs trên kết quả tool và assistant message; hàm stream bắt buộc trả về AssistantMessageEventStream; classifier GPT-6 Luna qua Decisions API, classifier llama.cpp gốc.
1.2.02026-10-12reachesTools, alias cho namespace; schema cấu hình công bố trên pi.dev; before_agent_start cho cả lần chạy do extension kích hoạt; pi-codemode có RemoteCodemodeSandbox, InlineCodemodeSandbox và lần chạy tất định start(); gỡ bộ server/client/plugin thử nghiệm và phụ thuộc Chord.
1.3.02026-10-15Đổi mức suy nghĩ trên GPT-6 mà giữ cache; Kimi Code qua Chat Completions, tool thêm giữa hội thoại không phá cache; bỏ khối thinking của mô hình khác khi đổi mô hình; session khôi phục giữ bộ tool đang bật.
1.4.02026-10-21Client identity (User-Agent, attribution) trong pi-ai; span telemetry quanh request; TranscriptToolSupport thay cho cờ boolean, OpenAI Responses khai báo mọi tool trong additional_tools; classifier nhận ảnh; codemode xếp hàng chờ worker; tóm tắt không còn trần output cố định.

Mô hình tư duy: prefix ổn định và mô hình ngoài chat#

Hai mạch ý chạy xuyên qua các bản 0.86 tới 1.0. Mạch thứ nhất là nối thêm thay vì viết lại. Prompt cache của provider là cache của một tiền tố (prefix) token chính xác (chương pi-ai); mọi thay đổi ở phần đầu request, dù chỉ là thêm một tool, làm trượt cache cho toàn bộ hội thoại phía sau. Bài “Prompt Caching In Agents” tóm lại: thêm vài token schema có thể buộc provider xử lý lại hàng chục nghìn token hội thoại (Prompt Caching In Agents). Vì thế mục prompt và bộ tool, khi đổi giữa hội thoại, được biểu diễn thành một thứ nối vào cuối transcript, và adapter của pi-ai cố gửi nó tại chỗ. Mức suy nghĩ đi cùng hướng: với mô hình hỗ trợ, adapter dựa vào mức đã ghi trên từng câu trả lời cũ để gửi phần thay đổi mà không sửa phần đầu (mục 1.3.0). Làm ấm cache là cùng ý ấy theo chiều thời gian: giữ cho prefix không hết hạn khi tool chạy lâu.

Mạch thứ hai là mô hình không chỉ để chat. Catalog có ba loại mô hình (chat, ảnh, classifier), mỗi loại một thao tác riêng, và có thêm virtual model: một mục chọn được trong /model nhưng không bao giờ tới provider, vì trước mỗi request một router chọn mô hình thật thay cho nó.

extension, MCP đăng ký tool                 các mục prompt: tools, rules,
(exposure, namespace, prepareLoadout)        skills, mcp_servers, ...
           │                                           │
           ▼                                           ▼
AgentSession._applyToolLoadout()             buildSystemPromptSections()
  bộ đang bật → tool được khai báo            diffSystemPromptSections()
  prepareLoadout → mô tả, hiddenDeclarations  → SystemMessage { sections }
           │ agent.state.tools                         │
           └─────────────────────┬─────────────────────┘
                                 ▼
agent-loop: declareToolChanges()   nối vào transcript (và session):
  SystemMessage { toolsAdded, toolsRemoved, sections }
                                 │
                                 ▼  transformContext: lọc hiddenDeclarations
pi-ai: resolveTranscript() / resolveTranscriptTools()
  ├─ Anthropic (native)       tool_addition / tool_removal ngay trong hội thoại
  ├─ OpenAI Responses         additional_tools, hoặc tool_search_call/output
  ├─ OpenAI Completions (Kimi) system message mang trường tools
  └─ các API khác             gộp mọi system message vào message đầu
                              (prefix đổi, cache trượt)
Đường đi của một thay đổi tool hoặc prompt, từ lúc đăng ký tới request gửi provider.

Từ vựng dùng trong phần còn lại:

Bộ tool đang bật (active set)
Các tool được khai báo cho mô hình, tức agent.state.tools; getActiveToolNames() trả về tên của chúng (agent-session.ts:1498).
Tool gọi được (callable)
Các tool mà một tool khác chạy được qua ctx.executeTool(), như script codemode làm. Gọi được không đồng nghĩa với được khai báo.
Exposure
Thuộc tính của từng tool, quyết định nó được khai báo, gọi được, hay cả hai.
Loadout
Thứ mô hình nhìn thấy về tool trong một request: danh sách khai báo cùng mô tả của chúng, sau khi các hook prepareLoadout đã sửa.
Hoãn lại (deferred)
Tool đã đăng ký và gọi được nhưng chưa khai báo; nó chỉ được khai báo khi có thứ gì đó (thường là tool_search) bật nó lên.
System message giữa hội thoại
Một SystemMessage nằm sau message đầu tiên, mang phần chênh lệch của prompt hoặc bộ tool kể từ điểm đó.

Data model: exposure, namespace và loadout#

Vấn đề. Trước 0.99, một tool của extension chỉ có hai trạng thái: đang bật thì được khai báo và gọi được, tắt thì không tồn tại với mô hình. Server MCP lớn có hàng chục tool; khai báo hết thì tốn token và phá cache mỗi khi server kết nối, mà tắt thì script codemode cũng không gọi được. Cần tách “mô hình thấy gì” khỏi “runtime chạy được gì”. Đó là việc của ToolExposure:

ToolExposure: năm cách mô hình tiếp cận 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";
Câu cuối của chú thích là định nghĩa quan trọng nhất: bộ tool đang bật (getActiveTools/setActiveTools) chính là bộ được khai báo cho mô hình.
exposureKhai báo cho mô hìnhGọi được qua ctx.executeToolBật khi đăng kýMô tả codemode liệt kêtool_search tìm được
direct (mặc định)khi đang bậtkhi đang bậtcóchỉ với codemode.mode: "only"không
model-onlykhi đang bậtkhông bao giờcókhôngkhông
codemodechỉ khi bật tường minhluôn, khi đã đăng kýkhôngcócó
deferredchỉ khi bật tường minhluôn, khi đã đăng kýkhôngkhôngcó
hiddenkhôngkhôngkhông (bật cũng vô hiệu)khôngkhông

Cột “gọi được” đến từ _getCallableTools() (agent-session.ts:1578–1587); cột codemode từ prepareCodemodeLoadout() (codemode/tool.ts:331); cột tool search từ isSearchable() (tool-search/tool.ts:186). Hai tool điều phối, codemode và tool_search, tự đăng ký với model-only: mô hình gọi được chúng, script thì không, nên một script không thể khởi chạy script khác (codemode/tool.ts:382). Server MCP có bộ exposure riêng ánh xạ sang bộ này; đáng chú ý là exposure mặc định codemode của MCP thành deferred của pi, nên tool MCP không làm phình mô tả codemode (mcp/tools.ts:40–46, xem chương Codemode, MCP).

Tool điều phối các tool khác cần nhìn toàn cảnh để tự mô tả mình. Pi đưa cho nó một ảnh chụp và nhận lại các thay đổi:

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[];}
hiddenDeclarations là cách chế độ codemode.mode: "only" giấu read, bash... khỏi request mà không tắt chúng: chúng vẫn đang bật, vẫn gọi được, và transcript vẫn khai báo chúng.

Các trường mới của ToolDefinition nằm cạnh nhau:

ToolDefinition: các trường thêm từ 0.99
pi/packages/coding-agent/src/core/extensions/types.tsdòng 600–638TypeScript
/** * JSON Schema of `structuredContent` in successful results. Tools that declare it should always * set `structuredContent`; codemode scripts then receive it instead of the text content. */outputSchema?: TSchema;/** * How the model reaches the tool. Default: `"direct"`. See {@link ToolExposure}. */exposure?: ToolExposure;/** Group the tool belongs to, for example its MCP server. */namespace?: ToolNamespace;/** * For tools that reach other tools (such as `codemode`, which calls them, and `tool_search`, which * loads them): which tools a call with `input` may reach. `"all"`, or the words of the input that * may name them, where a word refers to a tool or namespace whose name it contains, so words may * over-match. Extensions that register tools late, such as MCP servers that are still connecting, * use it from their `tool_call` handler to finish registering those tools before the call runs. */reachesTools?: (input: Record<string, unknown>) => "all" | readonly string[];/** Hints about what the tool does, for example from an MCP server. */annotations?: ToolAnnotations;/** * Whether registering the tool activates it. Default: `true` for `direct` and `model-only` tools; * other exposures are never activated on registration. A tool with `defaultActive: false` is * activated by naming it in `--tools` or the `defaultTools` setting, or with `setActiveTools()`. */defaultActive?: boolean;/** * Adjust how the loadout is presented to the model while this tool is active. Called whenever * the active tools change. Tools that orchestrate other tools use it, for example to list the * callable tools in their own description. */prepareLoadout?: (loadout: ToolLoadout) => ToolLoadoutChanges | undefined;
outputSchema cho script nhận structuredContent thay vì text; namespace gom tool của một server MCP; reachesTools (1.2.0) cho extension biết một lời gọi có thể chạm tới tool nào, để chờ server MCP còn đang kết nối.

ToolNamespace (types.ts:533–549) có name như mcp__docs, aliases, một description ngắn hiện trong danh sách tool, và instructions dài chỉ trả về khi script gọi describeNamespace(). Sự tách hai trường này có chủ đích: phần hiện thường trực phải ngắn và ổn định, phần dài chỉ tốn token khi thật sự cần.

AgentSession áp dụng loadout#

Mọi đường đổi bộ tool, gồm setActiveTools() của extension, tool_search, /reload, khôi phục session, đều dồn về một hàm. Nó lọc tên được yêu cầu, gọi các hook prepareLoadout của những tool đang bật và gán kết quả vào agent.state.tools, chính là thứ vòng lặp sẽ so với transcript:

Hook chỉ đổi mô tả hướng tới mô hình (dòng 1632–1635), không đổi tool. Codemode dùng nó để chèn vào mô tả của chính nó danh sách các tool script gọi được, và ở chế độ on để thêm vào mô tả của read, bash... một dòng “Codemode: tools.read(args) resolves to ...” (chương Codemode). Các khai báo bị giấu không bị gỡ khỏi transcript mà bị lọc khỏi request, bằng một phép chiếu trên transformContext:

Phép chiếu lọc hiddenDeclarations khỏi mọi system message
pi/packages/coding-agent/src/core/agent-session.tsdòng 1782–1805TypeScript
/** * Remove the declarations that `prepareLoadout` hooks hide from every request. The whole * transcript is filtered with the current set, so the projected declarations stay consistent * across requests and only change when the loadout does. */private _installHiddenDeclarationsProjection(): void {	const previousTransformContext = this.agent.transformContext;	this.agent.transformContext = async (messages, signal) => {		const transformed = previousTransformContext ? await previousTransformContext(messages, signal) : messages;		const hidden = this._hiddenDeclarations;		if (hidden.size === 0) return transformed;		return transformed.map((message) => {			if (message.role !== "system" || (!message.toolsAdded && !message.toolsRemoved)) return message;			const { toolsAdded, toolsRemoved, ...rest } = message;			const added = toolsAdded?.filter((tool) => !hidden.has(tool.name)) ?? [];			const removed = toolsRemoved?.filter((tool) => !hidden.has(tool.name)) ?? [];			return {				...rest,				...(added.length > 0 ? { toolsAdded: added } : {}),				...(removed.length > 0 ? { toolsRemoved: removed } : {}),			};		});	};}
Lọc cả transcript bằng tập hiện tại, nên phần khai báo mà provider thấy chỉ đổi khi loadout đổi, chứ không đổi ngẫu nhiên giữa các request.

Lý do giữ khai báo trong transcript nằm ở chiều ngược lại: khi mở lại session hoặc đi qua /tree, bộ tool đang bật được dựng lại từ transcript chứ không từ cấu hình:

_restoreToolsFromTranscript(): transcript là nguồn sự thật của loadout
pi/packages/coding-agent/src/core/agent-session.tsdòng 1830–1861TypeScript
private _restoreToolsFromTranscript(modifiers: readonly string[] = []): void {	this._pendingToolNames.clear();	// All system messages on the branch, including those before a compaction. Compaction snapshots	// are derived from these, so replaying them gives the current loadout.	const systemMessages = this.sessionManager		.getBranch()		.flatMap((entry) => (entry.type === "message" && entry.message.role === "system" ? [entry.message] : []));	// No recorded loadout: keep the current tools.	if (systemMessages.length === 0) return;	// Tools the branch ever added or removed. Removed tools stay inactive.	const mentioned = new Set(		systemMessages			.flatMap((message) => [...(message.toolsAdded ?? []), ...(message.toolsRemoved ?? [])])			.map((tool) => tool.name),	);	// Extension tools the branch never mentioned were registered after it recorded its loadout, so	// they are activated like newly registered tools. Built-in tools follow `defaultTools` instead.	const newTools = [...this._toolRegistry.keys()].filter(		(name) =>			!mentioned.has(name) && !this._baseToolDefinitions.has(name) && this._isActivatedOnRegistration(name),	);	// The recorded loadout plus the new tools, with `--tools +name`/`-name` applied.	const names = applyToolModifiers(		[...getCurrentTools(systemMessages).map((tool) => tool.name), ...newTools],		modifiers,	);	// Recorded tools that are not registered yet, such as MCP tools of servers still connecting, are	// activated when they register. Excluded tools never register, so they are not kept pending.	this._pendingToolNames = new Set(names.filter((name) => this._isAllowedTool(name)));	// Ignores names that are not registered.	this._setActiveTools(names);}
Tool có tên trong transcript nhưng chưa đăng ký, điển hình là tool MCP của server đang kết nối, được giữ trong _pendingToolNames và tự bật khi đăng ký (:3649). Khi một lần chạy bắt đầu, những tên vẫn chưa đăng ký bị bỏ (:1895–1897).

Changelog 1.0.0 ghi lại đúng lỗi mà cơ chế pending xử lý: tool MCP mà tool_search đã nạp bị rơi khi resume hay /reload, vì session khôi phục bộ tool trước khi server MCP kịp kết nối lại (CHANGELOG.md:364). Bản 1.3.0 mở rộng nguyên tắc: session khôi phục giữ bộ tool đang bật, và --tools +name/-name chỉ cộng trừ trên nó (CHANGELOG.md:82).

Thông điệp hệ thống giữa hội thoại#

Vấn đề. Trước 0.86, system prompt và danh sách tool là hai trường riêng của request. Đổi một chữ trong prompt, như cập nhật mục liệt kê server MCP khi một server vừa kết nối, là viết lại phần đầu request. Đổi bộ tool cũng vậy. Bài viết tháng 7 mô tả cơ chế lúc đó: với thay đổi thuần cộng, ghi tên tool mới lên kết quả tool để provider có hỗ trợ nạp tool tại chỗ, còn provider khác nhận lại cả danh sách (Prompt Caching In Agents). Từ 0.86.0 cả prompt lẫn tool sống trong transcript dưới dạng SystemMessage (ai/types.ts:554, đọc từng trường ở chương pi-ai): message đầu là system prompt, message sau mang content bổ sung, sections thay mục theo tên, toolsAdded/toolsRemoved đổi bộ tool.

Phía coding agent, system prompt mặc định được dựng thành các mục có tên (preamble, tools, rules, project_context, skills, cwd..., xem chương Công cụ và system prompt). Trước mỗi lần chạy và trước mỗi lượt kế tiếp, pi so các mục mong muốn với các mục mà mô hình đang có, phát lại từ transcript, và chỉ tạo system message khi có chênh lệch:

_preparePromptAndToolLoadout(): áp loadout và trả về bản vá mục prompt
pi/packages/coding-agent/src/core/agent-session.tsdòng 1748–1770TypeScript
/** * Apply a prompt and tool loadout for the next request. Sets the executable tools and * returns a system message patching the prompt sections the model currently has (replayed * from `messages`), or undefined when the prompt is unchanged. Tool changes are declared by * the agent loop before the request. * * A forced prompt does not affect the transcript: the structured sections are still diffed * and persisted, and the forced text is projected onto the request by * {@link _installAgentForcedPromptProjection}. */private _preparePromptAndToolLoadout(	options: NormalizedBuildSystemPromptOptions,	messages: AgentMessage[] = this.agent.state.messages,): SystemMessage | undefined {	options.selectedTools = this._applyToolLoadout(options.selectedTools).map((tool) => tool.name);	// The tool list and rules must match the declarations the request carries.	options.hiddenTools = [...this._hiddenDeclarations];	const sections = diffSystemPromptSections(		getCurrentSystemMessage(messages)?.sections ?? {},		buildSystemPromptSections(options),	);	return sections ? { role: "system", content: "", sections, timestamp: Date.now() } : undefined;}
Bản vá được đặt lên đầu các message của lần chạy (:1973–1975) hoặc vào danh sách message chuẩn bị cho lượt sau (:915–927).
diffSystemPromptSections(): chỉ các mục đổi, null cho mục bị xoá
pi/packages/coding-agent/src/core/system-prompt.tsdòng 212–229TypeScript
/** * Diff the sections the model currently has (replayed from the transcript, so never null) * against the desired ones. Returns a `SystemMessage.sections` patch, or undefined when * nothing changed. */export function diffSystemPromptSections(	previous: Record<string, string | null>,	current: SystemPromptSections,): Record<string, string | null> | undefined {	const patch: Record<string, string | null> = {};	for (const [name, text] of Object.entries(current)) {		if (previous[name] !== text) patch[name] = text;	}	for (const name of Object.keys(previous)) {		if (current[name] === undefined) patch[name] = null;	}	return Object.keys(patch).length > 0 ? patch : undefined;}

Bản vá ra đời với content: "" và không có tool. Phần tool do vòng lặp điền: declareToolChanges() (agent-loop.ts:342) tìm system message cuối trong các message chờ và thay trường tool của nó bằng chênh lệch giữa transcript đã commit và context.tools; không có thì nó chèn một system message mới. Việc thay trường dùng một hàm nhỏ:

withToolChanges(): danh sách rỗng thì bỏ hẳn trường
pi/packages/agent/src/agent-loop.tsdòng 374–384TypeScript
const NO_CHANGES: ToolStateChanges = { toolsAdded: [], toolsRemoved: [] };/** Copy a system message with its tool fields replaced by `changes`; empty lists omit the field. */function withToolChanges(message: SystemMessage, { toolsAdded, toolsRemoved }: ToolStateChanges): SystemMessage {	const { toolsAdded: _added, toolsRemoved: _removed, ...rest } = message;	return {		...rest,		...(toolsAdded.length > 0 ? { toolsAdded } : {}),		...(toolsRemoved.length > 0 ? { toolsRemoved } : {}),	};}
Bỏ trường thay vì để mảng rỗng giữ system message gọn. Khi không có gì đổi và message của caller cũng không khai báo tool, vòng lặp trả lại nguyên object đó (agent-loop.ts:363–364). Phân tích từng dòng của declareToolChanges() nằm ở chương pi-agent-core.

Kết quả là một transcript chỉ nối thêm. Ví dụ minh hoạ dưới đây theo dõi một session có server MCP linear chưa khai báo description; khi server kết nối, mô tả một dòng của nó lấy từ server instructions, nên mục mcp_servers đổi ở prompt kế tiếp (mcp/index.ts:1176–1184):

Ví dụ minh hoạ (rút gọn, không phải output thật)
[0] system  content ""  sections { preamble, tools, rules, docs, cwd,
                                   mcp_servers: "...\n- mcp__linear (codemode)" }
            toolsAdded [read, bash, edit, write, codemode]
[1] user    "Liệt kê issue OAuth đang mở"
[2] assistant  tool_use codemode { code: "..." }
[3] toolResult "Script completed ..."
[4] assistant  "Có 3 issue ..."
    ── prompt thứ hai, server đã kết nối, mục mcp_servers đổi ──
[5] system  content ""  sections { mcp_servers: "...\n- mcp__linear (codemode): Linear issues" }
[6] user    "Issue nào gấp nhất?"
Message [0]–[4] không bị sửa. Thay đổi của prompt thứ hai là một message [5] chèn trước câu hỏi mới.

Provider nào nhận system message giữa hội thoại thì [5] được gửi tại chỗ, với các mục được đóng khung theo tên để mô hình liên hệ được với prompt gốc:

renderSystemMessageUpdate(): cách một bản vá mục prompt được viết ra
pi/packages/ai/src/utils/text.tsdòng 23–40TypeScript
/** * Render a later system message for APIs that accept system messages mid-conversation. * Section changes are framed by name so the model can relate them to the leading prompt. * This framing is request-time only and may change between versions. */export function renderSystemMessageUpdate(message: SystemMessage): string {	const parts: string[] = [];	const text = contentText(message.content);	if (text.length > 0) parts.push(text);	for (const [name, value] of Object.entries(message.sections ?? {})) {		parts.push(			value === null				? `Removed system prompt section "${name}".`				: `Updated system prompt section "${name}":\n\n${value}`,		);	}	return parts.join("\n\n");}
Chú thích nói rõ khung này chỉ tồn tại lúc gửi request và có thể đổi giữa các phiên bản; transcript lưu dạng có cấu trúc.

Provider nào không nhận thì pi-ai dựng lại một system message đầu từ trạng thái đã phát lại và bỏ mọi system message phía sau. Kết quả đúng về nội dung, nhưng prefix đổi và cache trượt:

collapseSystemMessages() và resolveTranscript()
pi/packages/ai/src/utils/transcript.tsdòng 104–120TypeScript
/** * Rebuild the transcript for APIs without mid-conversation system messages: the replayed * system message leads, and every later system message is dropped. */export function collapseSystemMessages(context: TranscriptContext): TranscriptContext {	const head = getCurrentSystemMessage(context.messages);	const messages = context.messages.filter((message) => message.role !== "system");	return { messages: head ? [head, ...messages] : messages } as TranscriptContext;}/** Keep later system messages in place when the model accepts them; otherwise collapse them. */export function resolveTranscript(	context: TranscriptContext,	supportsMidConvoSystemMessages: boolean | undefined,): TranscriptContext {	return supportsMidConvoSystemMessages ? context : collapseSystemMessages(context);}

Việc chọn giữa hai đường dựa trên cờ compat.supportsMidConvoSystemMessages của từng mô hình; Anthropic, các API OpenAI và Mistral gọi resolveTranscript(), còn Google, Vertex và Bedrock luôn gộp (google-shared.ts:195, bedrock-converse-stream.ts:135). Riêng Anthropic giữ system message lại và chỉ chèn ngay trước assistant message kế tiếp, vì tool_result phải đứng sát tool_use (anthropic-messages.ts:1364).

pi-ai đưa thay đổi tool lên dây#

Transcript nói “từ đây có thêm tool X”. Mỗi API của nhà cung cấp diễn đạt câu đó khác nhau, hoặc không diễn đạt được. Từ 1.4.0 pi-ai đặt tên cho ba mức khả năng (ai/CHANGELOG.md:9):

TranscriptToolSupport và resolveTranscriptTools()
pi/packages/ai/src/utils/transcript.tsdòng 221–230, 242–263TypeScript
/** * How a transport can express tool changes after the first request. * * - `"none"`: only the top-level tool list exists; it always holds the current tools. * - `"additions"`: tools can be added at a later message, but not removed or redefined. * - `"changes"`: every tool, including the initial ones, is declared in place, and a later *   declaration under the same name replaces the earlier one. Removals are not expressible on *   the wire, so the transport announces them as text and the removed tools stay declared. */export type TranscriptToolSupport = "none" | "additions" | "changes";/** * Split tool declarations between the top-level request field and in-place changes. * * With `"changes"`, the top-level list stays empty and every system message, including the * leading one, declares its tools in place: an in-place declaration can replace an earlier * in-place one, but not a top-level one. With `"additions"`, a removal or redefinition cannot be * expressed in place, so the top-level list becomes the tool state after the last such change; * later additions stay anchored, and the cached prefix is invalidated once per removal instead * of on every later addition. With `"none"`, the top-level list is the current tool set. */export function resolveTranscriptTools(messages: TranscriptMessages, support: TranscriptToolSupport): TranscriptTools {	const systemMessages = messages.filter(isSystemMessage);	if (support === "none") {		return { requestTools: getCurrentTools(systemMessages), foldedSystemMessages: systemMessages.length };	}	if (support === "changes") return { requestTools: [], foldedSystemMessages: 0 };	const folded = Math.max(		getInitialSystemMessage(messages) ? 1 : 0,		findLastNonAdditiveToolChange(systemMessages) + 1,	);	return { requestTools: getCurrentTools(systemMessages.slice(0, folded)), foldedSystemMessages: folded };}
foldedSystemMessages cho adapter biết bao nhiêu system message đầu đã được gộp vào trường tools của request; mọi system message sau đó tự mang thay đổi tool tại chỗ.
  • "none": chỉ có danh sách tool ở cấp request, luôn là bộ hiện tại. Mọi thay đổi viết lại phần đầu.
  • "additions": thêm được tool ở giữa, nhưng không gỡ hay định nghĩa lại được. Danh sách cấp request trở thành trạng thái sau lần gỡ (hoặc định nghĩa lại) cuối cùng, và các lần thêm sau đó neo tại chỗ. Cache chỉ trượt một lần cho mỗi lần gỡ, thay vì ở mọi lần thêm sau đó.
  • "changes": mọi tool, kể cả bộ ban đầu, đều được khai báo tại chỗ; khai báo sau cùng tên thay khai báo trước. Danh sách cấp request để trống.

Anthropic: danh sách cố định và khối tool_addition

Với mô hình có compat.supportsMidConvoToolChanges (compat-schema.ts:485), adapter Anthropic không dùng ba mức trên mà có cách riêng, diễn đạt được cả việc gỡ tool: danh sách tools của request đóng băng ở bộ ban đầu, cộng một tool giữ chỗ hoãn lại không bao giờ được bật:

Anthropic: bộ tool ban đầu cố định cộng một placeholder defer_loading
pi/packages/ai/src/api/anthropic-messages.tsdòng 201–213, 1202–1204, 1264–1276TypeScript
/** * Stable deferred tool declared whenever native tool changes are in use. Anthropic adds * hidden prompt scaffolding for mid-conversation tool changes; declaring this placeholder * from the first request keeps that scaffolding in the cached prefix, so the first tool * change does not invalidate the cache (measured: full miss without it). It is never * activated and the model cannot see it. */const DEFERRED_TOOL_PLACEHOLDER: BetaTool = {	name: "__pi_deferred_placeholder__",	description: "Reserved placeholder. Never available. Never call this.",	input_schema: { type: "object", properties: {}, required: [] },	defer_loading: true,};	const initialTools = initialSystemMessage?.toolsAdded ?? [];	const nativeToolChanges =		compat.supportsMidConvoSystemMessages && compat.supportsMidConvoToolChanges && initialTools.length > 0;	const toolCacheControl = compat.supportsCacheControlOnTools ? cacheControl : undefined;	if (nativeToolChanges) {		// Initial tools stay active with the cache breakpoint on the last one, followed by the		// placeholder. The list never changes afterwards: later tools are defined by value in		// `tool_addition` blocks and withdrawn by `tool_removal`, so the cached prefix survives		// every tool change.		params.tools = [...convertToolDefinitions(initialTools, toolCacheControl), DEFERRED_TOOL_PLACEHOLDER];	} else {		const tools = getCurrentTools(context.messages);		if (tools.length > 0) {			params.tools = convertToolDefinitions(tools, toolCacheControl);		}	}
Chú thích ghi lại một phép đo: không có placeholder thì lần đổi tool đầu tiên trượt cache hoàn toàn, vì Anthropic thêm phần khung prompt ẩn cho thay đổi tool giữa hội thoại; khai báo placeholder từ request đầu đưa khung đó vào prefix đã cache.

Các tool sau đó đi trong chính system message của hội thoại, theo giá trị, cùng beta header inline-tools-2026-09-15 (anthropic-messages.ts:1156):

Một system message thành khối tool_removal và tool_addition
pi/packages/ai/src/api/anthropic-messages.tsdòng 1378–1399TypeScript
if (msg.role === "system") {	// Later system messages only reach this point when the model accepts them natively;	// otherwise the transcript was collapsed into the leading message before conversion.	const text = renderSystemMessageUpdate(msg);	const blocks: ContentBlockParam[] = [];	if (text.length > 0) blocks.push({ type: "text", text: sanitizeSurrogates(text) });	if (convertToolDefinitions) {		const added = msg.toolsAdded ?? [];		const redefined = new Set(added.map((tool) => tool.name));		for (const tool of msg.toolsRemoved ?? []) {			// A new definition under the same name replaces the old one, so no removal is needed.			if (redefined.has(tool.name)) continue;			blocks.push({				type: "tool_removal",				tool: { type: "tool_reference", name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name },			});		}		for (const definition of convertToolDefinitions(added)) {			blocks.push({ type: "tool_addition", tool: { type: "tool_definition", definition } });		}	}	if (blocks.length > 0) pendingSystemMessages.push({ role: "system", content: blocks });
Định nghĩa lại một tool cùng tên chỉ cần một tool_addition mới, không cần gỡ trước. Từ 1.0.1 điều này giữ được cache; trước đó, định nghĩa lại buộc gửi lại cả danh sách (CHANGELOG.md:302).

OpenAI Responses: additional_tools hoặc tool search giả lập

Chọn mức hỗ trợ cho các transport Responses
pi/packages/ai/src/api/openai-responses-shared.tsdòng 156–173TypeScript
/** * How a Responses transport expresses tool changes. * * `additional_tools` items accumulate: a later item adds tools and replaces same-name * definitions from earlier items, but it cannot replace a top-level tool and cannot remove any * tool (verified with strict schemas on the OpenAI, Codex, and Copilot backends, October 2026). * Like Codex, every tool is therefore declared in `additional_tools` items, starting with one * at the head of the input, and removals are announced as text. Synthetic tool search can * only add tools. Without mid-conversation system messages there is nothing to anchor. */export function getResponsesToolSupport(options: {	supportsMidConvoSystemMessages?: boolean;	supportsAdditionalTools?: boolean;	supportsToolSearch?: boolean;}): TranscriptToolSupport {	if (options.supportsMidConvoSystemMessages && options.supportsAdditionalTools) return "changes";	return options.supportsToolSearch ? "additions" : "none";}
Ghi chú “verified ... October 2026” là kết quả thử trên backend OpenAI, Codex và Copilot: một item additional_tools sau không thay được tool cấp request và không gỡ được tool nào.

Hai cờ supportsAdditionalTools và supportsToolSearch (compat-schema.ts:381–392) chọn mức. Mỗi system message nằm sau phần đã gộp được chuyển thành item đầu vào:

Một lần thêm tool: additional_tools, hoặc cặp tool_search_call/output
pi/packages/ai/src/api/openai-responses-shared.tsdòng 231–264TypeScript
const appendSystemToolAdditions = (message: SystemMessage, seed: string): void => {	const tools = message.toolsAdded ?? [];	for (const tool of tools) unavailableTools.delete(tool.name);	if (tools.length === 0) return;	if (toolSupport === "changes") {		if (declaredAdditionalTools) {			messages.push({ role: instructionRole, content: renderIncrementalToolsNotice(unavailableTools) });		}		declaredAdditionalTools = true;		messages.push({			type: "additional_tools",			role: "developer",			tools: convertResponsesTools(tools, options?.toolOptions),		} satisfies ResponseInputItem);		return;	}	if (toolSupport !== "additions") return;	const names = tools.map((tool) => tool.name);	const callId = `pi_tool_load_${shortHash(`${seed}:${names.join(",")}`)}`;	messages.push({		type: "tool_search_call",		call_id: callId,		execution: "client",		status: "completed",		arguments: { query: names.join(" "), limit: names.length },	} satisfies ResponseInputItem);	messages.push({		type: "tool_search_output",		call_id: callId,		execution: "client",		status: "completed",		tools: convertResponsesTools(tools, { ...options?.toolOptions, toolSearchResult: true }),	} satisfies ResponseToolSearchOutputItemParam);};
Ở mức "additions", pi giả lập một lời gọi tool search do client thực thi đã hoàn tất, với kết quả là các định nghĩa mang defer_loading: true (openai-responses-shared.ts:468). call_id là hash của vị trí và tên tool, nên request sau sinh lại đúng item ấy.

Gỡ tool không diễn đạt được trên dây Responses, nên ở mức "changes" pi viết một câu thông báo “The following tools are no longer available. Do not call them” và nhắc lại danh sách tool vẫn bị gỡ trước mỗi item additional_tools sau đó (openai-responses-shared.ts:175–189). Ghi chú ở đó cho biết vì sao: thiếu lời nhắc, các mô hình nhỏ đọc item mới nhất như bộ tool đầy đủ hoặc gọi lại tool đã gỡ. Đây là bản sửa của 1.4.0 cho lỗi cache bị mất ở mọi lần nạp tool sau một lần gỡ (ai/CHANGELOG.md:36).

APISystem message giữa hội thoạiThêm toolGỡ, định nghĩa lại
anthropic-messagesTheo supportsMidConvoSystemMessages, chèn trước assistant message kế tiếptool_addition theo giá trịtool_removal; định nghĩa lại bằng tool_addition mới
openai-responses, Codex, AzureTheo cờ, gửi với vai developer hoặc systemadditional_tools ("changes") hoặc tool_search_call/output ("additions")Thông báo bằng văn bản ("changes") hoặc viết lại danh sách đầu một lần ("additions")
openai-completions (Kimi)Theo cờSystem message có trường tools khi supportsMidConvoToolAdditions (openai-completions.ts:1235)Viết lại danh sách đầu ("additions")
Google, Vertex, BedrockLuôn gộp vào message đầuDanh sách cấp request là bộ hiện tại ("none")

Trace: nạp một tool hoãn lại giữa hội thoại#

Kịch bản: server MCP github được cấu hình "exposure": "deferred", nên extension MCP bật tool_search và tool của server có exposure deferred của pi (mcp/index.ts:501–525). Bộ đang bật là read, bash, edit, write, tool_search. Người dùng hỏi về code trên GitHub.

  1. Mô hình gọi tool_searchĐối số { query: "github search code" }. Tool xếp hạng BM25 các tool codemode/deferred chưa bật và gọi setActiveTools([...active, "mcp__github__search_code"]) (tool-search/tool.ts:195–209). Kết quả tool là văn bản “Loaded 1 tool. They are available from your next call: ...”.
  2. _applyToolLoadout()Tool mới được thêm vào agent.state.tools. Chưa có gì được ghi vào transcript; transcript vẫn khai báo bộ cũ.
  3. Lượt kế tiếp, declareToolChanges()Không có message chờ, mục prompt không đổi (tool MCP không có promptSnippet), nên vòng lặp chèn một system message mới { content: "", toolsAdded: [mcp__github__search_code] } ngay sau kết quả tool. Nó phát message_end và được lưu vào session như mọi message khác (agent-loop.ts:220–225).
  4. pi-ai dựng requestTuỳ provider, xem bảng dưới. Transcript như nhau cho mọi provider; chỉ hình dạng trên dây khác.
  5. Mô hình gọi thẳng tool mớimcp__github__search_code giờ là tool được khai báo, nên lời gọi đi qua pipeline tool bình thường; hook tool_call của extension MCP chờ đúng server của nó kết nối xong (mcp/index.ts:1204–1208).
Ví dụ minh hoạ: phần đuôi của request ở bước 4 (rút gọn)

Anthropic, supportsMidConvoToolChanges
  tools:    [read, bash, edit, write, tool_search(cache_control), __pi_deferred_placeholder__]
  messages: ..., assistant [tool_use tool_search], user [tool_result "Loaded 1 tool..."],
            system [{ type: "tool_addition", tool: { type: "tool_definition", definition: search_code } }]
  → phần đầu y hệt request trước: trúng cache tới tool_result

OpenAI Responses, mức "changes"
  tools:    (trống)
  input:    additional_tools [read, ..., tool_search], developer "<system prompt>", ...,
            function_call tool_search, function_call_output,
            developer "This is an incremental tools update. ...",
            additional_tools [mcp__github__search_code]

OpenAI Responses, mức "additions"
  input:    ..., function_call_output,
            tool_search_call    { call_id: "pi_tool_load_…", execution: "client", status: "completed" },
            tool_search_output  { tools: [search_code (defer_loading: true)] }

Google (gộp)
  systemInstruction và danh sách tool dựng lại từ trạng thái hiện tại
  → danh sách tool ở đầu request đổi: trượt cache cho cả hội thoại
Cùng một transcript, bốn hình dạng trên dây. Ba hình dạng đầu chỉ nối thêm vào cuối.

Hai chi tiết giữ cho kịch bản này rẻ. Mô tả của tool_search không liệt kê tool hay server nào, nên nó không đổi khi server kết nối (tool-search/tool.ts:211–215); và lần nạp được ghi vào transcript, nên sau resume hay /tree tool vẫn được khai báo trên nhánh đó mà không phải tìm lại.

Làm ấm cache cho Anthropic#

Vấn đề. Cache của Anthropic có tuổi thọ cứng: năm phút với cache_control thường, một giờ với ttl: "1h". Một agent hay chạy tool lâu hơn năm phút: một bộ test chạy lâu, một bản build, một lệnh chờ mạng. Khi tool xong, request kế tiếp gửi lại cả hội thoại, nhưng mục cache đã hết hạn, nên toàn bộ prefix bị tính giá ghi cache thay vì đọc. Với hội thoại vài trăm nghìn token, một lần trượt như vậy đắt hơn nhiều lần so với một request nhỏ giữ cho cache còn sống.

Giải pháp (0.86.0): ngay trước khi mục cache hết hạn, gửi lại đúng request vừa gửi với trần output một token, để giữ mục cache còn sống (cache-warmer.ts:157–161). Thời điểm và tuổi thọ:

Hằng số, thời điểm làm mới và tuổi thọ cache
pi/packages/coding-agent/src/core/cache-warmer.tsdòng 15–46TypeScript
/** Streaming warming never continues past this long after the real request that started it. */const MAX_WARMING_AGE_MS = 60 * 60_000;/** Idle warming uses a shorter horizon because continuation estimates become less reliable with age. */const MAX_IDLE_WARMING_AGE_MS = 30 * 60_000;/** A refresh is sent only when it is expected to save at least this many dollars. */const CACHE_WARMING_MINIMUM_EXPECTED_SAVINGS = 0.05;/** * Chance that a real request arrives before the cache entry expires while the * agent sits idle. Measured from our own usage; per-session estimates were not * better than this constant. */const IDLE_CONTINUATION_PROBABILITY = 0.15;/** Refresh at 90% of the TTL while preserving at least ten seconds of margin. */export function getCacheWarmingDelayMs(ttlMs: number): number | undefined {	if (ttlMs <= 10_000) return undefined;	return Math.max(1, Math.floor(Math.min(ttlMs * 0.9, ttlMs - 10_000)));}/** * Lifetime of the prompt cache entry a request writes, from the model's * `promptCache` tier for the retention the request used. Undefined when the * model has no lifetime for that tier or caching is off. */export function getPromptCacheTtlMs(model: Model<Api>, options: SimpleStreamOptions | undefined): number | undefined {	const retention =		options?.cacheRetention ??		(getProviderEnvValue("PI_CACHE_RETENTION", options?.env) === "long" ? "long" : "short");	if (retention === "none") return undefined;	const seconds = model.promptCache?.[retention];	return seconds === undefined ? undefined : seconds * 1000;}
Với TTL 300 giây, lần làm mới rơi vào giây thứ 270 (90%, còn ít nhất 10 giây dự phòng). PI_CACHE_RETENTION=long chọn tầng long.

Tuổi thọ lấy từ Model.promptCache, trường mới của pi-ai ở 0.86.0 (ai/CHANGELOG.md:288). Chữ “cho Anthropic” trong bài công bố đến từ chỗ này: script sinh catalog chỉ gắn trường đó cho provider Anthropic gọi trực tiếp.

Chỉ Anthropic trực tiếp có tuổi thọ cache, OpenAI cố ý chưa có
pi/packages/ai/scripts/generate-models.tsdòng 1000–1013TypeScript
// Anthropic ephemeral entries have a hard five-minute lifetime; `ttl: "1h"`// extends it to one hour. Only direct Anthropic is annotated so cache warming// does not assume equivalent behavior through proxies.// https://docs.anthropic.com/en/docs/build-with-claude/prompt-cachingconst ANTHROPIC_PROMPT_CACHE: ModelPromptCache = { short: 300, long: 3600 };function applyPromptCacheMetadata(model: Model<Api>): void {	if (model.provider === "anthropic" && model.api === "anthropic-messages") {		model.promptCache = ANTHROPIC_PROMPT_CACHE;	}	// Do not add OpenAI lifetimes yet. Before enabling warming for explicit	// OpenAI caches, re-evaluate it using observed expiry, replay, and billing	// behavior; a documented TTL alone does not establish full cache loss.}
Qua proxy, pi không giả định hành vi giống Anthropic gọi trực tiếp. Với OpenAI, chú thích ghi rằng một TTL trong tài liệu chưa đủ để kết luận cache mất hẳn khi hết hạn; phải đo hành vi hết hạn, phát lại và tính tiền trước. Mô hình không có promptCache thì không bao giờ được làm ấm.

Bộ làm ấm được khởi động lại với mỗi request của session, ngay trong streamFn của Agent:

Mỗi request của session khởi động lại CacheWarmer
pi/packages/coding-agent/src/core/sdk.tsdòng 412–423TypeScript
streamFn: async (model, context, options) => {	const requestOptions = buildRequestOptions(options);	// Compaction and summaries use their own routing ids; only session requests	// replace the cache entry, so warming restarts from them. Keep warming while	// the current transcript still extends the request's prefix. Agent state may	// shallow-copy the messages array or refresh the model object without changing	// the provider request, so top-level object identity is not a valid cache key.	if (options?.sessionId === sessionManager.getSessionId()) {		cacheWarmer.start({ model, context, options: requestOptions }, cacheContextIsCurrent(model));	}	return modelRuntime.streamSimple(model, context, requestOptions);},
Chỉ request mang sessionId của session mới khởi động lại bộ làm ấm; theo chú thích ở dòng 414–418, compaction và tóm tắt dùng id định tuyến riêng. cacheContextIsCurrent() (sdk.ts:359–373) dừng việc làm ấm khi người dùng đổi mô hình hoặc transcript không còn nối tiếp request đó, chẳng hạn sau /tree.

Mỗi lần hẹn giờ tới, refresh() kiểm tra lại, tính kinh tế, hỏi extension, rồi mới gửi:

evaluate(): làm ấm khi tiết kiệm kỳ vọng từ 0,05 USD
pi/packages/coding-agent/src/core/cache-warmer.tsdòng 378–400TypeScript
private evaluate(run: ActiveRun): CacheWarmingDecision {	const model = run.model;	const promptTokens = lastPromptTokens(this.sessionManager.getBranch());	const cacheHitCost = price(model, { cacheRead: promptTokens });	const cacheMissCost = price(		model,		model.cost.cacheWrite > 0 ? { cacheWrite: promptTokens } : { input: promptTokens },	);	const warmCost = price(model, { cacheRead: promptTokens, output: 1 });	const missCost = Math.max(0, cacheMissCost - cacheHitCost);	const continuationProbability = run.phase === "idle" ? IDLE_CONTINUATION_PROBABILITY : 1;	const economicsAvailable = promptTokens > 0 && (cacheHitCost > 0 || cacheMissCost > 0);	const expectedSavings = continuationProbability * missCost - warmCost;	return {		phase: run.phase,		warmCost,		missCost,		continuationProbability,		expectedSavings,		economicsAvailable,		action: expectedSavings >= CACHE_WARMING_MINIMUM_EXPECTED_SAVINGS ? "warm" : "stop",	};}
Đang chạy (streaming) thì xác suất có request thật tiếp theo coi là 1; lúc rảnh (idle) là 0,15, một hằng số đo từ dữ liệu sử dụng của chính nhóm phát triển (cache-warmer.ts:21–26).

Một phép tính với giá giả định cho thấy vì sao có hai pha. Giả sử prompt dài 100.000 token, đọc cache 0,30 USD/triệu token, ghi cache 3,75 USD/triệu token. Một lần làm ấm tốn khoảng 0,03 USD; một lần trượt tốn thêm 0,375 − 0,03 = 0,345 USD. Khi agent đang chạy, tiết kiệm kỳ vọng là 0,345 − 0,03 ≈ 0,315 USD, làm ấm. Khi rảnh, 0,15 × 0,345 − 0,03 ≈ 0,022 USD, dưới ngưỡng, dừng. Với prompt 300.000 token cùng giá, pha rảnh cho 0,15 × 1,035 − 0,09 ≈ 0,065 USD, lại đáng làm ấm. Nghĩa là làm ấm lúc rảnh chỉ đáng với hội thoại rất dài.

Các giới hạn an toàn: làm ấm trong lúc chạy không kéo quá 60 phút kể từ request thật, lúc rảnh không quá 30 phút; một bộ hẹn giờ chạy trễ quá nửa khoảng dự phòng (máy ngủ, event loop bị chặn) thì dừng, vì lần làm mới trễ nhiều khả năng là một lần ghi cache giá đầy đủ (cache-warmer.ts:284–298). Request không phát lại an toàn được thì không làm ấm: với thinking theo ngân sách (Claude không có adaptive thinking), budget_tokens suy ra từ max_tokens, nên một bản phát lại có max_tokens: 1 mang ngân sách khác, và Anthropic khoá cache theo nó. Vì vậy request Anthropic có bật suy nghĩ chỉ được làm ấm khi mô hình có compat.forceAdaptiveThinking (:48–58).

Chế độ nằm trong setting toàn cục cacheWarming: "off", "streaming" (mặc định, chỉ trong lúc chạy) hoặc "idle" (cả giữa các lần chạy) (docs/settings.md:21–23). Chi phí làm ấm vào tổng chi phí session nhưng không vào context của mô hình, vì nó là một entry riêng chứ không phải message:

UsageEntry: chi phí không thuộc về message nào
pi/packages/coding-agent/src/core/session-manager.tsdòng 80–89TypeScript
export interface UsageEntry extends SessionEntryBase {	type: "usage";	/** Arbitrary usage category, such as "cache_warm". */	kind: string;	provider: string;	model: string;	usage: Usage;	/** Optional human-readable qualifier for usage notices. */	note?: string;}

Virtual model#

Vấn đề. Người dùng muốn chọn một mô hình “auto” và để phần mềm chọn mô hình thật cho từng request: câu hỏi nhanh sang mô hình rẻ, việc khó sang mô hình mạnh. Nếu một extension làm điều đó bằng cách đổi mô hình của session, mỗi lần đổi sinh một entry model_change và lựa chọn của người dùng bị ghi đè. Virtual model (0.99.0, thử nghiệm) tách hai khái niệm: lựa chọn được ghi trong model_change, còn mô hình được gửi đi được ghi trên từng assistant message (docs/virtual-models.md:18–25).

Mục catalog của một virtual model có api: "pi-virtual" và giá bằng 0 (virtual-models.ts:169–187); provider bọc nó trả về một luồng lỗi nếu ai đó stream nó mà chưa định tuyến (:189–194). Việc định tuyến diễn ra trong prepareRequest của AgentSession, móc mà chương AgentSession mô tả ở mức tổng quát:

prepareRequest(): định tuyến virtual model cho đúng một request
pi/packages/coding-agent/src/core/agent-session.tsdòng 812–842TypeScript
const model = previous?.model ?? this.agent.state.model;const thinkingLevel = previous?.thinkingLevel ?? this.agent.state.thinkingLevel;if (!isVirtualModel(model)) return { ...previous, context, model, thinkingLevel };// The selection stays in agent state; only this request uses the routed model. A routing// failure rejects, which ends the run with an error response. Only messages the user wrote// start a turn; extension messages can follow them, e.g. from before_agent_start.const lastResponse = context.messages.findLastIndex((message) => message.role === "assistant");const userTurn = context.messages.slice(lastResponse + 1).some((message) => message.role === "user");const state = getVirtualModelState(this.sessionManager.getBranch(), model.provider, model.id);const route = await this._modelRuntime.resolveModel(model, convertToLlm(context.messages), {	reason: failed ? "retry" : userTurn ? "user" : "continuation",	thinkingLevel,	signal,	failed,	state,});if (route.state !== undefined && route.state !== state) {	const data: VirtualModelStateData = { provider: model.provider, modelId: model.id, state: route.state };	const entry = this.sessionManager.getEntry(		this.sessionManager.appendCustomEntry(VIRTUAL_MODEL_STATE_ENTRY, data),	);	if (entry) this._emit({ type: "entry_appended", entry });}// The route stands: the router already decided this request. The state entry does not change// the projection.if (this._exceedsCompactionThreshold(route.model, projection)) {	await this._runAutoCompaction("threshold", false);	({ previous, context } = await prepare());}return { ...previous, context, model: route.model, thinkingLevel: route.thinkingLevel };
Lựa chọn trong agent.state không đổi; chỉ request này dùng mô hình được chọn. Trạng thái router được lưu thành entry custom pi.virtual-model-state trên nhánh, nên nó đi theo cây session và sống qua compaction. Nếu cửa sổ ngữ cảnh của mô hình được chọn quá nhỏ, pi compaction trước khi gửi, nhưng giữ nguyên lựa chọn của router.

ModelRuntime.resolveModel() kiểm tra kết quả của router: phải là mô hình vật lý trong catalog, provider phải có credential, và mức suy nghĩ bị kẹp vào khả năng của mô hình đó (model-runtime.ts:1011–1042). Request ngoài vòng lặp, như tóm tắt compaction, đi qua streamSimple() với reason: "direct"; credential của caller chỉ được chuyển tiếp nếu mô hình được chọn cùng provider với virtual model, để không gửi nhầm khoá cho hãng khác (:733–751).

Ví dụ đầy đủ là jev-router.ts: lập kế hoạch trên mô hình mạnh, viết code trên mô hình rẻ.

jev-router: hai pha, một lần đổi mô hình
pi/packages/coding-agent/examples/extensions/jev-router.tsdòng 90–113TypeScript
export default function (pi: ExtensionAPI) {	pi.registerVirtualModel<JevState>({		provider: "jev",		id: "auto",		name: "Auto (Jev)",		thinkingLevels: ["low", "medium", "high", "xhigh"],		// Shared by all three models; shown before the first response.		contextWindow: 272_000,		maxTokens: 128_000,		async route(request, ctx) {			if (request.reason === "direct") return routeTo(request, ctx, LUNA);			const state = request.state;			if (!state) {				const model = await choosePlanningModel(request, ctx);				return routeTo(request, ctx, model, { phase: "planning", model });			}			// The planning model made the first edit: hand the rest of the work to Luna.			if (state.phase === "planning" && editedThisTurn(request.messages)) {				return routeTo(request, ctx, LUNA, { phase: "implementation", model: LUNA });			}			return routeTo(request, ctx, state.model);		},	});}
Ở request đầu, Jev phân loại tin nhắn của người dùng thành standard hay complex để chọn Sol hay Terra (jev-router.ts:62–88). Sau lần edit/write thành công đầu tiên, pha chuyển sang Luna và ở đó: đúng một lần trượt cache cho cả session.

Router cần tôn trọng cache. Tài liệu khuyên trả lại previous cho continuation và failed cho retry, vì đổi mô hình giữa các lượt làm mất prompt cache và chữ ký thinking (docs/virtual-models.md:84). Khi mở lại session, lựa chọn ảo được khôi phục từ model_change cuối; nếu virtual model không còn đăng ký, pi rơi về mô hình vật lý đã trả lời sau cùng (virtual-models.ts:120–145).

ModelRuntime: mô hình ảnh và classifier#

Ở 0.99.0, pi-ai gộp mô hình ảnh vào cùng bề mặt Provider/Models với mô hình chat (bộ ImagesModels riêng bị gỡ, ai/CHANGELOG.md:199) và thêm một loại thứ ba, classifier: mô hình trả lời các câu hỏi có kiểu về một trạng thái JSON, như Jev của TypeSafe, chứ không sinh văn bản (:209). Kiểu của mỗi mục catalog quyết định thao tác nào nhận nó:

ImageModel, ClassifierModel và ModelTypeMap
pi/packages/ai/src/types.tsdòng 882–908TypeScript
/** Image-generation model: usable with `generateImages()` only. */export interface ImageModel<TApi extends ImageApi> extends BaseModel<TApi> {	type: "image";	/** Output modalities. Always includes `"image"`; `"text"` means the model can also return text blocks. */	output: ("text" | "image")[];}/** Structured classifier model: usable with `classify()` only. */export interface ClassifierModel<TApi extends ClassifierApi> extends BaseModel<TApi> {	type: "classifier";	contextWindow: number;	/** Compatibility overrides for System One APIs. If not set, auto-detected from provider, baseUrl, and id. */	compat?: TApi extends "typesafe-system-one" | "cloudflare-workers-ai-system-one" ? SystemOneCompat : never;}/** Model shape for each model type. */export interface ModelTypeMap {	chat: Model<Api>;	image: ImageModel<ImageApi>;	classifier: ClassifierModel<ClassifierApi>;}/** What a catalog entry is for. Decides which `Models` operation accepts it. */export type ModelType = keyof ModelTypeMap;/** Anything a provider can list. Narrow with `isModelType()`. */export type AnyModel = ModelTypeMap[ModelType];
Mô hình chat được phép bỏ trống type, nên catalog và provider cũ chạy nguyên. Các accessor không có hậu tố (getModels(), getAvailable()) vẫn chỉ trả mô hình chat; muốn loại khác thì dùng getModelsOfType().

Provider khai báo thao tác nào nó hỗ trợ bằng hai phương thức tuỳ chọn, cả hai cam kết không bao giờ reject:

Provider.generateImages() và Provider.classify()
pi/packages/ai/src/models.tsdòng 234–246TypeScript
/** Present when the provider supports dedicated image models. Never rejects. */generateImages?(	model: ImageModel<ImageApi>,	context: ImagesContext,	options?: ImagesOptions,): Promise<AssistantImages>;/** Present when the provider supports structured classifier models. Never rejects. */classify?(	model: ClassifierModel<ClassifierApi>,	context: ClassifierContext,	options?: ClassifierOptions,): Promise<ClassifierResult>;

API classifier có sẵn ở v1.4.0 là typesafe-system-one, cloudflare-workers-ai-system-one, llama-cpp-classify (đọc xác suất token kế tiếp của llama-server) và openai-decisions (GPT-6 Luna, từ 1.1.0); API ảnh dựng sẵn là openrouter-images (ai/types.ts:78–88). Kiểu câu hỏi và câu trả lời (choice, score, bool) nằm ở ai/types.ts:675–736.

Phía coding agent, ModelRuntime là collection Models đã cấu hình: catalog dựng sẵn, models.json, provider và virtual model của extension, credential từ AuthStorage (chương AgentSession). Hai thao tác mới đi qua cùng bước giải credential với chat:

ModelRuntime.generateImages() và classify(): lỗi thành kết quả
pi/packages/coding-agent/src/core/model-runtime.tsdòng 799–832TypeScript
async generateImages(	model: ImageModel<ImageApi>,	context: ImagesContext,	options?: ModelsImagesOptions,): Promise<AssistantImages> {	try {		assertImageModel(model);		const prepared = await this.prepareRequest(model, options);		if (!prepared.provider.generateImages) {			throw new ModelsError("provider", `Provider ${model.provider} does not support image generation`);		}		return await prepared.provider.generateImages(prepared.model, context, prepared.options as ImagesOptions);	} catch (error) {		return imageErrorResult(model, error, options?.signal?.aborted);	}}async classify(	model: ClassifierModel<ClassifierApi>,	context: ClassifierContext,	options?: ModelsClassifierOptions,): Promise<ClassifierResult> {	try {		assertClassifierModel(model);		assertClassifierInputSupported(model, context);		const prepared = await this.prepareRequest(model, options);		if (!prepared.provider.classify) {			throw new ModelsError("provider", `Provider ${model.provider} does not support classification`);		}		return await prepared.provider.classify(prepared.model, context, prepared.options as ClassifierOptions);	} catch (error) {		return classifierErrorResult(model, error, options?.signal?.aborted);	}}
prepareRequest() (:659–705) giải credential đã lưu, OAuth, khoá runtime và header của models.json. Mọi lỗi, kể cả sai loại mô hình hay classifier không nhận ảnh, thành một kết quả có stopReason: "error", hoặc "aborted" khi tín hiệu huỷ đã bật (model-operations.ts:67).

Không có tool nào cho mô hình chat gọi thẳng classifier hay mô hình ảnh. Có hai đường: extension gọi ctx.modelRegistry.classify()/generateImages(), như router Jev ở trên, và script codemode gọi models.classify()/models.generateImages() (chương Codemode). Đường thứ hai là cái bài “You Said No MCP!” minh hoạ: cho Jev chấm giọng điệu của 167 issue đang mở với bốn lời gọi song song, kết quả 156 trung tính, 11 hơi bực bội, không issue nào rất bực bội (You Said No MCP!). Chi phí của các lời gọi đó được cộng vào kết quả tool codemode và tính vào chi phí session (CHANGELOG.md:459).

Toàn màn hình, theme hệ thống và đăng nhập#

Ba thay đổi dễ thấy nhất với người dùng lại ít động tới kiến trúc, nên ở đây chỉ nêu điểm chính; cơ chế vẽ của pi-tui thuộc chương pi-tui.

  • Toàn màn hình mặc định. Từ 1.0.0, tuiMode mặc định là "fullscreen" (settings-defaults.ts:62): Pi vẽ trên màn hình phụ (alternate screen) của terminal, tự quản lý cuộn, chọn chữ bằng chuột và tìm kiếm. Muốn giữ cách cũ, ghi nối vào scrollback gốc như năm 2025, đặt "tuiMode": "regular" hoặc chạy --tui-mode regular (CHANGELOG.md:345).
  • Theme system (0.99.0, mặc định, docs/settings.md:95). Pi hỏi terminal màu chữ, màu nền và 16 màu ANSI trong một lượt (OSC 10, 11, 4; API mới TUI.queryTerminalColors(), tui/CHANGELOG.md:97), rồi dựng từng token màu từ “họ màu” và các quy tắc tương phản, trong không gian OKHSL; màu của bảng màu không bao giờ tăng chroma khi đổi độ sáng, nên bảng pastel vẫn pastel (system-theme.ts:1–20, bài There are many themes, but this one is yours).
  • Đăng nhập. Sign in with ChatGPT cho provider openai: một luồng OAuth client công khai, gửi token người dùng thẳng tới api.openai.com (openai-chatgpt.ts:1–6); provider OpenAI Codex cũ được đổi tên thành “legacy” (CHANGELOG.md:468). Bản 1.0.0 thêm “Sign in with Radius” kèm cấu hình server MCP của Radius trong một bước (:346), và kiểu đăng nhập copy code cho Anthropic khi trình duyệt chạy trên máy khác (anthropic.ts:200). Anthropic workload identity federation qua biến môi trường của SDK có từ 0.99.2 (CHANGELOG.md:384). OAuth của MCP là chuyện riêng, ở chương Codemode, MCP.

Bộ cài managed, install lock và Nix flake#

Vấn đề. Cài pi-coding-agent bằng npm nghĩa là npm chọn phiên bản cho hàng trăm phụ thuộc bắc cầu vào lúc cài. Ghim chính xác phụ thuộc trực tiếp không đủ: một bản phát hành độc hại của một gói ở tầng sâu vẫn vào được máy người dùng ngay trong ngày nó xuất hiện. Pi chọn coi thay đổi phụ thuộc là code cần review, và ghi điều đó trong README:

README: Supply-chain hardening
pi/./README.mddòng 168–180Markdown
## Supply-chain hardeningWe treat npm dependency changes as reviewed code changes.- Direct external dependencies are pinned to exact versions. Internal workspace packages remain version-ranged.- `.npmrc` sets `save-exact=true` and `min-release-age=2` to avoid same-day dependency releases during npm resolution.- `package-lock.json` is the dependency ground truth. Pre-commit blocks accidental lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1` is set.- `npm run check` verifies pinned direct deps, native TypeScript import compatibility, and the generated coding-agent install lock.- The pi.dev installer installs from `packages/coding-agent/install-lock/`, generated from the root lockfile, to pin transitive deps. The npm package does not pin transitive deps.- Local release smoke tests and npm publication use the same tarball packer; npm publishes the validated tarballs rather than repacking workspace directories.- Local release installs, documented npm installs, and `pi update --self` use `--ignore-scripts` where supported.- CI installs with `npm ci --ignore-scripts`, and a scheduled GitHub workflow runs `npm audit --omit=dev` plus `npm audit signatures --omit=dev`.- Install lock generation has an explicit allowlist for dependency lifecycle scripts; new lifecycle-script deps fail checks until reviewed.

Gói npm cố ý không ghim bắc cầu: 1.0.1 gỡ npm-shrinkwrap.json khỏi gói để thư viện dùng lại pi có thể override phụ thuộc, và chuyển việc ghim sang bộ cài của pi.dev (CHANGELOG.md:322). Bộ cài đó dùng một lockfile riêng, sinh từ lockfile gốc của monorepo:

install-lock/package.json: gốc lockfile của bộ cài
pi/packages/coding-agent/install-lock/package.jsondòng 1–15JSON
{	"name": "@earendil-works/pi-coding-agent-install",	"version": "1.4.0",	"private": true,	"description": "Lockfile root used by the Pi installer and updater.",	"dependencies": {		"@earendil-works/pi-coding-agent": "1.4.0"	},	"overrides": {		"protobufjs": "7.6.6"	},	"engines": {		"node": ">=22.19.0"	}}
Kèm theo là một package-lock.json đầy đủ trong cùng thư mục, mỗi gói có resolved và integrity.

Script sinh lockfile đi theo đồ thị phụ thuộc từ @earendil-works/pi-coding-agent trong lockfile gốc, chép từng entry và thay workspace nội bộ bằng tarball trên registry (generate-coding-agent-install-lock.mjs:371–420). Phần đáng chú ý là kiểm tra cuối: gói nào có lifecycle script mà chưa nằm trong danh sách cho phép thì thất bại, và một mục cho phép không còn dùng cũng thất bại:

Danh sách cho phép lifecycle script và phép kiểm tra
pi/scripts/generate-coding-agent-install-lock.mjsdòng 17–20, 319–339JavaScript
const allowedInstallScriptPackages = new Map([	["@google/genai@2.21.0", "preinstall is a no-op in the published package"],	["protobufjs@7.6.6", "postinstall only warns about protobufjs version scheme mismatches"],]);		if (entry.hasInstallScript) {			if (!packageName || !entry.version) {				errors.push(`${lockPath || "root"} has install scripts but no package name/version`);			} else {				const packageId = `${packageName}@${entry.version}`;				if (allowedInstallScriptPackages.has(packageId)) {					seenAllowedInstallScriptPackages.add(packageId);				} else {					errors.push(						`${lockPath} has install scripts (${packageId}). Review it and add it to allowedInstallScriptPackages if intentional.`,					);				}			}		}	}	for (const packageId of allowedInstallScriptPackages.keys()) {		if (!seenAllowedInstallScriptPackages.has(packageId)) {			errors.push(`allowed install-script package ${packageId} is no longer present; remove it from the allowlist`);		}	}
npm run check chạy script này với --check, nên một thay đổi lockfile quên sinh lại install lock không qua được kiểm tra.

Phía người dùng, bộ cài managed đặt mỗi bản phát hành trong một thư mục releases/<version> dưới gốc PI_MANAGED_INSTALL_ROOT, đánh dấu bằng managed-install.json (package-manager-cli.ts:55–80). pi update trên bản cài này làm như sau:

runManagedSelfUpdate(): tải lockfile, npm ci, thử chạy, rồi mới chuyển
pi/packages/coding-agent/src/package-manager-cli.tsdòng 214–239TypeScript
const releaseUrl = `${installerApiBase}/${encodeURIComponent(version)}`;const stagingRoot = join(managedRoot, "staging");const releasesRoot = join(managedRoot, "releases");mkdirSync(releasesRoot, { recursive: true });const releaseDir = join(releasesRoot, version);if (existsSync(releaseDir)) {	verifyManagedRelease(releaseDir, version);	activateManagedRelease(managedRoot, version);	pruneManagedReleases(managedRoot, version);	return;}mkdirSync(stagingRoot, { recursive: true });stageDir = mkdtempSync(join(stagingRoot, "update-"));const [packageJsonContent, packageLockContent] = await Promise.all([	fetchInstallerArtifact(`${releaseUrl}/package.json`, "package.json"),	fetchInstallerArtifact(`${releaseUrl}/package-lock.json`, "package-lock.json"),]);writeFileSync(join(stageDir, "package.json"), packageJsonContent);writeFileSync(join(stageDir, "package-lock.json"), packageLockContent);await runManagedNpmCi(stageDir);verifyManagedRelease(stageDir, version);renameSync(stageDir, releaseDir);activateManagedRelease(managedRoot, version);pruneManagedReleases(managedRoot, version);
npm ci --ignore-scripts cài đúng cây đã khoá, không chạy lifecycle script (:90–104). Bản mới chỉ được kích hoạt sau khi pi --version của nó trả đúng số phiên bản, bằng cách ghi nguyên tử file current-version.

Cả quá trình nằm trong một khoá file, nên hai phiên chạy pi update cùng lúc không giẫm lên nhau. Từ 1.1.0, sau khi cập nhật chỉ giữ bản mới và bản vừa chạy lệnh cập nhật, để các phiên khác đang mở dùng tiếp và để quay lại được bằng cách sửa current-version (:138–156, CHANGELOG.md:198).

Nix flake (1.0.1): nix run github:earendil-works/pi/stable chạy bản mới nhất, nix profile add cài nó (CHANGELOG.md:284). Flake chỉ xuất package pi (cũng là default), app tương ứng và một overlay, cho bốn hệ macOS và Linux (flake.nix:20–53). Phần đáng học nằm trong nix/package.nix, dùng lại đúng install lock của bộ cài pi.dev và ghim catalog mô hình bằng hash nội dung:

nix/package.nix: install lock và catalog mô hình được ghim
pi/nix/package.nixdòng 17–30Nix
let  nodejs = nodejs_22;  packageJson = lib.importJSON (source + "/packages/coding-agent/package.json");  # Lockfile root used by the pi.dev installer. It pins the coding agent's  # runtime dependency tree and is kept in sync with package-lock.json by  # `npm run check`.  installLock = source + "/packages/coding-agent/install-lock";  modelCatalogPin = lib.importJSON ./model-catalog.json;  modelCatalog = fetchurl {    name = "pi-model-catalog.json";    # The typed catalog is the representation whose bytes the revision hashes.    url = "https://pi.dev/api/models/revisions/${modelCatalogPin.revision}?types=chat,image,classifier";    sha256 = lib.removePrefix "sha256-" modelCatalogPin.revision;  };

Cài qua Nix thì pi update không cập nhật được; dùng nix profile upgrade pi (docs/quickstart.md:23–29). Bản 1.3.0 sửa hai lỗi của gói Nix đáng nhớ: build hỏng khi một provider đổi API so với catalog đã ghim, và Node của Pi đứng đầu PATH khiến shell của tool bash dùng Node của Pi thay vì của người dùng (CHANGELOG.md:87–88).

Pi Durable phát hành cùng lúc#

Cùng ngày 1.0.0, @earendil-works/pi-durable có bản phát hành đầu tiên (durable/CHANGELOG.md:185), với dòng cảnh báo đầu README: “Experimental. The API changes without notice between releases.” (durable/README.md:3). Bài công bố gọi nó là “a new substrate for building long-running agentic applications” (Pi 1.0).

Quan hệ với coding agent được thể hiện rõ nhất qua phụ thuộc. Coding agent dựa trên pi-agent-core, pi-ai, pi-codemode, pi-mcp và pi-tui (coding-agent/package.json:44–48), không dùng Durable hay Chord. Durable dựa trên Chord, pi-ai, pi-codemode và pi-telemetry (durable/package.json:153–156), không dùng pi-agent-core. Con đường tới đó thấy được trong git: harness bền mang tên mã Pico được dời từ pi-agent-core sang gói riêng ngày 2026-09-18 (commit 080160162), harness thử nghiệm còn lại bị gỡ khỏi pi-agent-core ở 1.0.0 (commit 7fd478a2e), và 1.2.0 gỡ nốt bộ server, client, plugin thử nghiệm cùng phụ thuộc Chord của coding agent (CHANGELOG.md:165–166).

Hai sản phẩm chia sẻ một thứ đáng chú ý ngoài pi-ai: pi-codemode. Lần chạy tất định sandbox.start() mà 1.2.0 thêm vào gói (codemode/CHANGELOG.md:31) là thứ Pi Durable dùng để phát lại một script sau khi tiến trình chết (durable/src/codemode/codemode.ts:465); coding agent chỉ dùng execute(). Mô hình khái niệm của Durable ở chương Pi Durable, còn codemode bền ở chương Durable: task, scheduler, generation và tool.

Sau 1.0: các thay đổi kiến trúc của 1.1 tới 1.4#

Bốn bản sau 1.0 tiếp tục hai mạch ý ở đầu chương, cộng thêm việc đo đạc và nhận diện client.

Đo thời gian và trạng thái (1.1.0)

AssistantMessageEventStream đo mỗi phản hồi bằng đồng hồ đơn điệu từ lúc bắt đầu request tới message cuối, ghi vào AssistantMessage.durationMs. Phép đo nằm trong chính lớp luồng, và cùng bản đó mọi hàm stream bắt buộc trả về đúng lớp này (ai/CHANGELOG.md:88–93). Kết quả tool có durationMs của riêng execute(), không tính hook (agent/CHANGELOG.md:17). Terminal của pi-tui phải cài setProgramStatus() để báo trạng thái qua OSC 7501 cho terminal và bảng điều khiển agent (tui/CHANGELOG.md:44).

Tool chạm tới tool khác, sandbox ngoài Node (1.2.0)

reachesTools và alias của namespace (CHANGELOG.md:124–125) cho extension MCP biết một lời gọi codemode hay tool_search cần chờ server nào. before_agent_start chạy cả cho lần chạy do pi.sendMessage(..., { triggerTurn: true }) kích hoạt, nên các mục prompt thêm trong handler không còn bị gỡ giữa lần chạy, vốn làm trượt cache hai lần mỗi lần chạy (CHANGELOG.md:143). pi-codemode có RemoteCodemodeSandbox, InlineCodemodeSandbox cho Cloudflare và môi trường không có worker thread (codemode/CHANGELOG.md:28–29).

Đổi mức suy nghĩ và đổi mô hình mà giữ cache (1.3.0)

Mạch “nối thêm” lan sang mức suy nghĩ. Trên GPT-6 với API key, mức suy nghĩ không còn đổi trường reasoning.effort của request mà được gửi thành item configuration_update nằm trong input:

configuration_update: mức suy nghĩ thành item đầu vào, phần trước không đổi
pi/packages/ai/src/api/openai-responses-shared.tsdòng 133–142TypeScript
/** * Effort for the reply being generated. When set, the thinking level is sent as * configuration_update items instead of a changing reasoning.effort, which would invalidate the * prompt cache. Each item sets the effort from that point on: one goes before every earlier * reply with the level recorded on it (`providerThinkingLevel`), and one with this effort goes * at the end. Earlier input therefore never changes. OpenAI documents the item before user * messages only. After tool results it was verified on gpt-6-luna (October 2026) to be accepted * and to apply to the next reply. */configurationUpdateEffort?: string;
Cùng ý với các system message chỉ chứa output_config mà adapter Anthropic dùng cho mô hình hỗ trợ đổi effort giữa hội thoại (chương pi-ai).

Kimi Code chuyển sang Chat Completions để tool thêm giữa hội thoại không phá cache (ai/CHANGELOG.md:52). Khi đổi mô hình, khối thinking do mô hình khác sinh ra bị bỏ thay vì gửi thành văn bản, vì mô hình mới từng đọc chúng như một phần câu trả lời và bắt đầu viết suy nghĩ của nó ra phần hiển thị (:53). Codemode đếm worker tới khi chúng thực sự thoát, không chỉ tới khi kết quả trả về (codemode/CHANGELOG.md:13).

Danh tính client, telemetry và các mức hỗ trợ tool (1.4.0)

pi-ai nhận một identity quyết định User-Agent và các header attribution cho OpenRouter, NVIDIA NIM, OpenCode, đặt được cho cả createModels() (ai/CHANGELOG.md:13); Agent có identity tương ứng (agent/CHANGELOG.md:7). Request được bọc trong span telemetry không chứa nội dung (ai/CHANGELOG.md:16–17, xem chương pi-env, telemetry). Cờ boolean của resolveTranscriptTools() thành TranscriptToolSupport ba mức đã đọc ở trên. Classifier nhận ảnh, mỗi dịch vụ một dạng trên dây (ai/CHANGELOG.md:20). Script codemode xếp hàng chờ worker rảnh thay vì thất bại ngay (CHANGELOG.md:40), và tóm tắt compaction dùng giới hạn output của mô hình thay cho trần cố định (:33).

Quyết định thiết kế#

Transcript là nguồn sự thật cho prompt và bộ tool. Phương án đơn giản là mỗi request mang system prompt và danh sách tool hiện tại. Pi chọn ghi chúng vào transcript: tài liệu extension nói thẳng rằng prompt và bộ tool ban đầu nằm trong system message đầu, các thay đổi được nối thêm trước request kế tiếp, và provider nào không biểu diễn được bước chuyển thì nhận một “transcript checkpoint” đầy đủ, chấp nhận mất cache (docs/extensions.md:301). Được: resume, /tree và fork khôi phục đúng loadout của nhánh; provider nào có khả năng thì giữ được cache. Mất: adapter phức tạp hơn nhiều, và khả năng của từng mô hình phải được kiểm chứng bằng thực nghiệm. Các cờ compat ghi rõ catalog chỉ bật chúng “for verified models”, và mã nguồn có nhiều ghi chú “verified October 2026” (compat-schema.ts:303).

Khai báo và gọi được là hai trục riêng. Năm 2025 Mario từ chối MCP vì mô tả tool của nó chiếm một phần đáng kể cửa sổ ngữ cảnh ngay từ đầu (chương Lịch sử). Exposure giải quyết đúng chỗ đó: tool deferred gọi được mà không tốn token nào trong phần khai báo. Sự tồn tại của cả một server chỉ tốn một dòng trong mục mcp_servers. Cái giá là mô hình phải tìm tool trước khi dùng, bằng tool_search hoặc searchTools() trong script.

Mô tả ổn định quan trọng hơn mô tả đầy đủ. Mô tả codemode liệt kê tool theo exposure chứ không theo bộ đang bật, nên tool_search nạp tool không làm khai báo lại codemode (codemode/tool.ts:317–318); mô tả tool_search không liệt kê gì; danh sách server đi vào một mục prompt được nối thêm khi đổi. Ba lựa chọn cùng một mục đích: phần đầu request không đổi khi server MCP kết nối hay ngắt.

Làm ấm có tính toán, và chỉ ở nơi đã kiểm chứng. Làm ấm mọi lúc tốn tiền vô ích khi người dùng đã bỏ đi; dùng mặc định cache một giờ thì bài “Prompt Caching In Agents” cho rằng chi phí thêm thường không đáng ở giá API. Pi chọn một quyết định kinh tế cho từng lần làm mới, ngưỡng 0,05 USD, hằng số đo từ dữ liệu thật, và một sự kiện cache_warming_decision cho extension ghi đè. Nó cũng không làm ấm qua proxy, và hoãn làm ấm OpenAI cho tới khi đo được hành vi hết hạn, phát lại và tính tiền.

Lựa chọn tách khỏi việc gửi đi. Virtual model cho phép định tuyến mà không viết lại lịch sử lựa chọn của người dùng, và đặt trạng thái router trên cây session để nó đi theo nhánh. Phương án extension tự đổi mô hình bị loại vì nó trộn hai loại thông tin vào cùng entry model_change.

Ghim cho người dùng cuối, không ghim cho thư viện. Gói npm để phụ thuộc bắc cầu tự do để người nhúng pi còn override được; bộ cài pi.dev và gói Nix dùng install lock đã kiểm tra. Hai nhu cầu khác nhau, hai kênh khác nhau.

Hai sản phẩm thay vì một. Thay vì đưa tính bền vào coding agent, Earendil viết một harness riêng và để coding agent giữ vòng lặp đơn giản của nó. Việc gỡ harness cũ khỏi pi-agent-core ở 1.0.0 là hệ quả trực tiếp.

Cạm bẫy và trường hợp biên#

  • Provider tự viết phải đọc từ transcript. Từ 0.86.0, hàm stream nhận TranscriptContext; system prompt và tool lấy bằng getCurrentSystemPrompt() và getCurrentTools(), không còn trong context.systemPrompt (ai/CHANGELOG.md:279).
  • Handler context không thấy system message. Từ 0.87.0 pi khôi phục prompt và trạng thái tool sau khi handler chạy, vì handler lọc message từng làm mất cả khai báo tool. Cần biến đổi toàn bộ transcript thì dùng context_with_system (CHANGELOG.md:561).
  • Tool không gỡ đăng ký được. Muốn rút một tool, đăng ký lại nó với exposure: "hidden"; extension MCP làm đúng như vậy với tool mà server bỏ đi (docs/extensions.md:273, mcp/index.ts:447–452).
  • Vị trí trên dây khác vị trí trong transcript. Với Anthropic, một bản cập nhật đặt trước user message trong transcript sẽ nằm sau user message đó trên dây (anthropic-messages.ts:1367–1368).
  • Tool bị gỡ trên OpenAI Responses vẫn được khai báo. Ở mức "changes", việc gỡ chỉ được thông báo bằng văn bản. Nếu mô hình vẫn gọi, vòng lặp trả kết quả lỗi “Tool ... not found”, vì tool không còn trong bộ chạy được (agent-loop.ts:726–732).
  • Đổi sang provider phải gộp system message làm mất cache một lần cho cả hội thoại, cũng như mọi lần đổi mô hình. Router virtual model nên dính vào mô hình của lượt hiện tại.
  • Không có promptCache thì không có làm ấm. Mô hình Claude gọi qua OpenRouter hay Bedrock không được làm ấm; cacheWarming là setting toàn cục, không đặt theo dự án được. Bật showCacheMissNotices để thấy thông báo làm ấm và trượt cache (docs/settings.md:20).
  • Virtual model chưa có giới hạn ngữ cảnh trước phản hồi đầu, trừ khi định nghĩa khai báo; giới hạn chưa khai báo là 0, tức chưa biết (virtual-models.ts:92–97).
  • Cài qua Nix thì pi update không cập nhật được, và một gói Nix cũ có thể mang catalog mô hình cũ đã ghim.

Đọc code ở đâu#

#FileĐọc để hiểu
1coding-agent/CHANGELOG.mdTừ ## [0.86.0] đi lên: mục New Features của mỗi bản là bản đồ tính năng; mục Breaking Changes là nơi kiến trúc đổi.
2ai/src/utils/transcript.tsPhát lại system message, gộp, so bộ tool, TranscriptToolSupport. Ngắn, đọc hết.
3agent/src/agent-loop.ts:332–384Cầu nối giữa bộ chạy được và transcript.
4extensions/types.ts:501–668ToolExposure, ToolNamespace, ToolLoadout, ToolDefinition.
5agent-session.ts:1498–1640, :1748–1861Loadout, bản vá prompt, phép chiếu, khôi phục từ transcript.
6system-prompt.ts:127–229Các mục prompt có tên và phép diff.
7anthropic-messages.ts:1202–1276, :1353–1399Tool cố định cộng placeholder, tool_addition/tool_removal.
8openai-responses-shared.ts:156–300additional_tools, tool search giả lập, thông báo gỡ tool, configuration_update.
9core/cache-warmer.ts, sdk.ts:333–423Toàn bộ vòng đời làm ấm cache và chỗ nó được nối vào Agent.
10core/virtual-models.ts, docs/virtual-models.md, jev-router.tsKiểu dữ liệu, tài liệu, một router thật.
11model-runtime.ts:659–832, ai/src/models.ts:158–247Chat, ảnh, classifier đi qua cùng bước giải credential.
12README.md:168–180, generate-coding-agent-install-lock.mjs, package-manager-cli.ts:48–244, nix/package.nixChuỗi cung ứng: install lock, cập nhật managed, Nix.

Tóm tắt#

  • Pi 1.0 (2026-10-01) là mốc ổn định; phần lớn tính năng của bài công bố đã vào ở 0.86.0 (system message, làm ấm cache) và 0.99.0 (codemode, MCP, exposure, virtual model, classifier, theme system).
  • Mạch kiến trúc chính là “nối thêm thay vì viết lại”: prompt và bộ tool được ghi thành phần chênh lệch ở cuối transcript, và pi-ai gửi chúng tại chỗ với Anthropic, OpenAI Responses, Kimi, hoặc gộp lại với provider không hỗ trợ; đổi mức suy nghĩ trên mô hình hỗ trợ cũng không sửa phần đầu.
  • ToolExposure tách “được khai báo” khỏi “gọi được”; prepareLoadout() cho tool điều phối sửa mô tả và giấu khai báo; transcript là nguồn sự thật của loadout qua resume và /tree.
  • Làm ấm cache gửi lại request với trần một token trước khi cache Anthropic hết hạn, chỉ khi tiết kiệm kỳ vọng từ 0,05 USD, và ghi chi phí thành entry cache_warm.
  • Virtual model tách lựa chọn khỏi mô hình được gửi đi; catalog có thêm mô hình ảnh và classifier, dùng từ extension hoặc script codemode.
  • Bộ cài pi.dev và gói Nix ghim cả cây phụ thuộc bằng install lock; gói npm thì không. Pi Durable ra mắt cùng ngày như một gói riêng, thử nghiệm, không phụ thuộc pi-agent-core.

15Codemode, MCP và tool search#

Vì sao: từ “không MCP” tới codemode#

Năm 2025, Pi tuyên bố không hỗ trợ MCP (Model Context Protocol, giao thức để một client gọi tool do server bên ngoài cung cấp). Lý do, trình bày ở chương Lịch sử và triết lý, là chi phí ngữ cảnh: một server MCP thường khai báo sẵn hàng chục tool, và mô tả của chúng chiếm một phần đáng kể cửa sổ ngữ cảnh (context window) trước khi người dùng gõ chữ nào. Thay vào đó Pi khuyên dùng CLI kèm README, và “Bash is all you need”.

Ngày 2026-09-29, bài “You Said No MCP!” và bản 0.99.0 đưa MCP vào lõi (CHANGELOG.md:432). Bài viết giải thích hai điều. Một, MCP đã thay đổi kể từ đó, nhóm đã theo dõi các thay đổi ấy khoảng một năm, và muốn góp phần định hướng nó: gần với OpenAPI hơn, có kết quả có cấu trúc và khả năng tìm tool. Hai, vấn đề lớn nhất còn lại là ghép các lời gọi: “The biggest issue with MCP continues to be that it's hard to compose.” Pi trả lời bằng hai cơ chế. Chi phí ngữ cảnh được xử lý bằng exposure, nạp tool hoãn lại và system message giữa hội thoại (chương Pi 1.0). Việc ghép lời gọi được xử lý bằng codemode: mô hình không gọi từng tool một mà viết một đoạn JavaScript gọi nhiều tool, song song, lọc và gộp kết quả, rồi chỉ trả phần tóm tắt về cho mô hình.

Câu đó là chìa khoá của thiết kế. Tool bash chạy lệnh trên máy (hoặc trong sandbox) của người dùng; script codemode thì chạy bên cạnh vòng lặp agent, trong tiến trình của harness, nơi được coi là tin cậy. Một runtime JavaScript nhỏ biên dịch sang WebAssembly (QuickJS) là lớp bảo vệ quanh script, và trạng thái của script được giữ trong transcript của session chứ không trên hệ thống file. Gói pi-codemode mô tả mình đúng như vậy: chạy JavaScript do mô hình viết trong một VM QuickJS mà khả năng duy nhất là gọi các tool được tiêm vào; lời gọi lồng nhau không bao giờ vào ngữ cảnh của mô hình, chỉ output và giá trị trả về của script vào (codemode/README.md:3).

Trong repo, câu trả lời gồm hai gói mới và ba built-in extension của coding agent, thêm cùng commit 8562bcf66 (2026-09-29):

Ba extension được khai báo là “thay được”: một extension khác đăng ký codemode, tool_search hay /mcp sẽ thay chỗ bản có sẵn thay vì chạy song song (extensions/index.ts:7–14). Cơ chế nạp extension chung ở chương Extensions; chương này đi vào bên trong ba extension đó.

Mô hình tư duy: script ở phía harness#

mô hình
  │  lời gọi tool codemode { code: "<JavaScript>" }
  ▼
executeCodemode()  (extension codemode, luồng chính của pi)
  │  tạo CodemodeSandbox, mỗi tool gọi được thành một CodemodeTool
  ▼
Execution (host)  ◄── postMessage, mọi giá trị là chuỗi JSON ──►  worker thread
  │  deadline, abort, bản ghi lời gọi, output                     QuickJS VM (wasm)
  │                                                                prelude: tools, text(),
  │  ◄── { type: "call", id, name, args } ────────────────────    image(), store()...
  │  ─── { type: "result", id, ok, payload } ─────────────────►    script của mô hình
  ▼
ctx.executeTool(name, args)  → NestedToolCallRunner → runToolCall()
  │  kiểm tra đối số, hook tool_call / tool_result, parentToolCallId
  ▼
tool đích: read, bash, ... hoặc mcp__server__tool
  ▼  (MCP)
McpServerConnection → McpClient → StdioTransport | StreamableHttpTransport → server MCP
Một lời gọi codemode đi xuống tới server MCP. Chỉ output của script đi ngược lên tới mô hình.
Script
Đầu vào của tool codemode: JavaScript thô (không phải JSON, không có code fence), chạy như thân của một hàm async, nên dùng được await và return ở cấp cao nhất.
VM và host
VM là một instance QuickJS riêng, có bộ nhớ tuyến tính riêng, sống trong một worker thread. Host là phía Node của pi giữ bảng tool, deadline, tín hiệu huỷ và bản ghi lời gọi.
Prelude
JavaScript chạy trong VM trước script, dựng tools, ALL_TOOLS, text(), image(), exit(), store()/load(), console và các global, trên một cầu nối (bridge) duy nhất tới host.
Lời gọi lồng nhau (nested tool call)
Một lời gọi tool do script phát ra. Nó đi qua pipeline tool của agent như lời gọi của mô hình, nhưng không vào transcript; chỉ một bản ghi có giới hạn của nó được lưu trên kết quả của lời gọi codemode.
Namespace
Nhóm tool, thường là một server MCP, tên mcp__<server>.

Data model của pi-codemode#

Gói không biết gì về pi. Với nó, một tool chỉ là một tên, vài schema để vẽ khai báo, và một hàm:

CodemodeTool: thứ host tiêm vào script
pi/packages/codemode/src/types.tsdòng 14–41TypeScript
export interface CodemodeTool {	/**	 * The script calls tools as `tools.<id>(args)`, where `<id>` is the name with characters that	 * are not valid in identifiers replaced by `_` (see `toCodemodeIdentifier`), and also as	 * `tools["<name>"](args)`. Globals are called as `<name>(args)` and must be identifiers, or	 * `<namespace>.<member>`, which groups them into a frozen namespace object.	 */	name: string;	/** Shown as a doc comment in {@link renderDeclarations}, and listed in `ALL_TOOLS` for tools. */	description?: string;	/** Schema of the single argument. Rendered as the parameter type; `unknown` when omitted. */	inputSchema?: CodemodeJsonSchema;	/** Schema of the resolved value. Rendered as the promise type; `unknown` when omitted. */	outputSchema?: CodemodeJsonSchema;	/** Globals only: `execute` receives all call arguments as an array instead of the first one. */	spread?: boolean;	/**	 * Globals only: TypeScript parameter list and return type for {@link renderDeclarations}, for	 * example `(type: string, id?: string): Promise<Model[]>`. Replaces the rendering from the schemas.	 */	signature?: string;	/**	 * `args` is whatever the script passed, after a JSON round trip. The return	 * value must be JSON-serializable; a thrown error surfaces in the script as	 * an `Error` with the same message.	 */	execute(args: unknown, context: CodemodeToolContext): Promise<unknown> | unknown;}
Schema chỉ dùng để sinh khai báo cho mô hình; giá trị không được kiểm tra theo chúng (types.ts:11). Đối số đi qua một vòng JSON trước khi tới execute(), nên tool không bao giờ nhận object sống của VM.

Kết quả của một lần chạy không bao giờ là ngoại lệ. Lỗi của script, hết giờ, bị huỷ hay sandbox hỏng đều thành { ok: false, error }, và output tới lúc lỗi vẫn được giữ:

CodemodeOutputItem, CodemodeStoreWrites và CodemodeResult
pi/packages/codemode/src/types.tsdòng 43–49, 82–109TypeScript
/** * One item of the script's output, in the order the script produced it: `text()` and `console.*` * produce text items, with `console: true` for `console.*`, and `image()` image items. `data` is base64. */export type CodemodeOutputItem =	| { type: "text"; text: string; console?: true }	| { type: "image"; data: string; mimeType: string };/** Keys the script changed with `store()`. Only successful executions report writes. */export interface CodemodeStoreWrites {	set: Record<string, unknown>;	/** Keys stored as `undefined`. */	delete: string[];}/** * `output` is kept for failed executions too, up to the failure. `exit()` completes with `value: undefined`. * `during`: in a {@link CodemodeSandboxBase.start} run that the script itself ended (it returned, threw, or exited), * the result being processed when it did. */export type CodemodeResult =	| {			ok: true;			value: unknown;			output: CodemodeOutputItem[];			calls: CodemodeCall[];			storeWrites: CodemodeStoreWrites;			during?: CodemodeDuring;	  }	| {			ok: false;			error: CodemodeError;			output: CodemodeOutputItem[];			calls: CodemodeCall[];			during?: CodemodeDuring;	  };
error.kind là script, timeout, aborted hoặc sandbox (types.ts:59–80). storeWrites chỉ có ở lần chạy thành công: script lỗi thì những gì nó store() bị bỏ.
Ví dụ minh hoạ: một CodemodeResult thành công
{ ok: true,
  value: "3 issue mở, 1 PR liên quan",
  output: [ { type: "text", text: "đã quét 2 server", console: true } ],
  calls:  [ { name: "mcp__linear__list_issues",          status: "ok", durationMs: 412 },
            { name: "mcp__github__search_pull_requests", status: "ok", durationMs: 655 } ],
  storeWrites: { set: { lastScan: "2026-10-22" }, delete: [] } }
calls chỉ chứa lời gọi tool, không chứa global như searchTools() (types.ts:114–120).

Đầu vào có định dạng riêng: JavaScript, có thể mở đầu bằng một dòng // @options: {"max_output_tokens": ..., "timeout_ms": ...}. Với provider hỗ trợ ràng buộc đầu vào bằng ngữ pháp, tool khai báo một ngữ pháp Lark chỉ cố định hình dạng dòng options, để mô hình viết script như văn bản thô thay vì một chuỗi JSON đầy ký tự thoát:

Ngữ pháp Lark của đầu vào codemode
pi/packages/codemode/src/source.tsdòng 18–30TypeScript
/** * Lark grammar for providers with grammar-constrained tool input. It only fixes the shape of the * options line; the options JSON and the code are checked by {@link parseCodemodeSource}. */export const CODEMODE_SOURCE_GRAMMAR = String.raw`start: options_source | plain_sourceoptions_source: OPTIONS_LINE NEWLINE SOURCEplain_source: SOURCEOPTIONS_LINE: /[ \t]*\/\/ @options:[^\r\n]*/NEWLINE: /\r?\n/SOURCE: /[\s\S]+/`;
Coding agent gắn nó qua constrainedSampling: { type: "grammar", variants: { openai_lark: ... } } (codemode/tool.ts:386–387). Dòng options phải là dòng đầu; nằm ở chỗ khác thì lời gọi thất bại trước khi script chạy, thay vì bị lờ đi (source.ts:101–115).

Sandbox: worker, QuickJS và giao thức host#

Vấn đề. QuickJS chạy đồng bộ. Một script while (true) {} chạy trên luồng chính của pi sẽ chặn event loop: không bộ hẹn giờ nào chạy, không tín hiệu huỷ nào tới, giao diện đứng. Script còn có thể ăn hết bộ nhớ hay làm hỏng trạng thái dùng chung. Giải pháp của CodemodeSandbox: mỗi lần execute() có một worker thread và một VM QuickJS mới tinh, mất khoảng 20 ms kể cả tạo VM; VM chỉ có hai thứ được nhập vào là một lớp WASI (đồng hồ, số ngẫu nhiên, ghi stdout/stderr mà worker vứt đi) và một điểm gọi host duy nhất (codemode/README.md:358–368).

Phía host được viết một lần cho mọi nơi VM có thể chạy. Chỗ khác nhau duy nhất là một launcher:

VmEvents, VmChannel, VmLauncher: ranh giới giữa host và nơi chạy VM
pi/packages/codemode/src/runtime/execution.tsdòng 63–82TypeScript
/** Callbacks a launcher uses to report what its VM does. */export interface VmEvents {	/** A message from the VM. Validated here, so launchers can pass through anything they receive. */	message(message: unknown): void;	/** The VM failed or stopped outside the bridge, for example a worker exit or a remote error. */	failure(error: CodemodeError): void;}/** The host's handle on a running VM. */export interface VmChannel {	post(message: HostToWorkerMessage): void;	/** Requests VM shutdown. Called once; may resolve before a worker or remote VM actually exits. */	stop(): Promise<void>;}/** * Starts a VM for one execution. Rejections become `sandbox` errors with the rejection's message. * The VM may start reporting messages before the returned promise settles. */export type VmLauncher = (data: VmStartData, events: VmEvents) => VmChannel | Promise<VmChannel>;
Ba launcher có sẵn: worker thread (CodemodeSandbox), luồng gọi (InlineCodemodeSandbox), và một isolate khác qua RPC (RemoteCodemodeSandbox), xem mục runtime từ xa.

Hai phía chỉ trao đổi các message sau, và mọi đối số, kết quả, giá trị đều đi qua dưới dạng chuỗi JSON: worker chuyển chuỗi vào và ra VM, không tự dựng giá trị có cấu trúc (protocol.ts:4–8).

Giao thức host–VM
pi/packages/codemode/src/runtime/protocol.tsdòng 43–76TypeScript
export type WorkerToHostMessage =	| { type: "call"; id: number; target: "tool" | "global"; name: string; args: string | undefined }	| { type: "output"; item: CodemodeOutputItem }	/**	 * `writes` is a JSON array of `[key, json]` for `store()` and `[key]` for deletions. Deterministic mode: `during`	 * is the result the VM was processing when the script finished (0: its first run) and the calls issued by then.	 */	| { type: "done"; ok: true; value: string | undefined; writes: string; during?: CodemodeDuring }	| { type: "done"; ok: false; error: ScriptErrorJson; during?: CodemodeDuring }	/** The VM failed outside the script's control, for example a wasm trap. */	| { type: "crash"; message: string }	/** Deterministic mode, before the script runs: what the VM is, so a replay can refuse a different one. */	| { type: "started"; fingerprint: string }	/**	 * Deterministic mode, after the script waits again: having processed result `seq` (0: the script's first run), it	 * has issued `issued` calls in total.	 */	| { type: "idle"; seq: number; issued: number };export type HostToWorkerMessage =	/**	 * `payload` is the JSON result when `ok`, otherwise the error message. Deterministic mode: `seq` numbers the	 * results from 1 in the order the VM applies them, and the VM's clock reads `now` while it processes this one.	 */	{		type: "result";		id: number;		ok: boolean;		payload: string | undefined;		seq?: number;		now?: number;		/** Deterministic mode, `ok: false`: a JSON object whose properties the script's `Error` gets. */		properties?: string;	};
started, idle, seq, now và properties chỉ dùng ở chế độ tất định.

Launcher của worker thread:

workerLauncher(): một worker mỗi lần chạy, một cờ ngắt dùng chung
pi/packages/codemode/src/runtime/host.tsdòng 20–38, 59–66TypeScript
function workerLauncher(options: CodemodeSandboxOptions): VmLauncher {	return async (data, events) => {		let module: CodemodeWasmModule;		try {			module = await (options.wasm ?? loadQuickJSWasm());		} catch (error) {			throw new Error(`Failed to load QuickJS: ${errorMessage(error)}`);		}		const interrupt = new SharedArrayBuffer(4);		const workerData: WorkerData = { ...data, wasm: module, interrupt };		const releaseWorker = options.acquireWorker?.();		let worker: Worker;		try {			worker = new Worker(options.workerUrl ?? defaultWorkerUrl(), { workerData });		} catch (error) {			releaseWorker?.();			throw new Error(`Failed to start worker: ${errorMessage(error)}`);		}		if (releaseWorker) worker.once("exit", releaseWorker);		const stop = async (): Promise<void> => {			Atomics.store(new Int32Array(interrupt), 0, 1);			// A non-interruptible built-in can delay Bun's terminate() until it returns (#10748).			// Do not hold up the result while that worker exits.			void worker.terminate().catch(() => undefined);			// terminate() refs the worker internally; unref it after requesting termination.			worker.unref();		};
Module wasm được biên dịch một lần cho mỗi đường dẫn rồi dùng lại (wasm.ts:21); structured clone chia sẻ code đã biên dịch với worker. Cờ ngắt cần vì terminate() của Bun không dừng được một luồng đang quay trong wasm; VM thăm dò cờ đó.

Trong worker, startVm() tạo VM với giới hạn bộ nhớ, giới hạn stack và hàm ngắt, chạy prelude, rồi bọc script vào một hàm async. Cầu nối duy nhất từ prelude ra host chỉ nhận giá trị nguyên thuỷ:

startVm(): cầu nối và phần bọc script
pi/packages/codemode/src/runtime/vm.tsdòng 252–277, 323–336TypeScript
try {	// Called from the prelude with primitives only.	const bridge = vm.newFunction("bridge", (kind, a, b, c) => {		switch (kind.toString()) {			case "call":			case "global":				issued++;				post({					type: "call",					id: a.toNumber(),					target: kind.toString() === "call" ? "tool" : "global",					name: b.toString(),					args: c === undefined || c.isUndefined ? undefined : c.toString(),				});				break;			case "output":				post({					type: "output",					item:						a.toString() === "image"							? { type: "image", data: b.toString(), mimeType: c.toString() }							: a.toString() === "console"								? { type: "text", text: b.toString(), console: true }								: { type: "text", text: b.toString() },				});				break;	// The prefix shares the first line with the script so reported line numbers	// match the script as written.	let fn: JSValueHandle;	try {		fn = vm.evalCode(`(async (tools, console) => {${data.code}\n})`, "codemode.js");	} catch (error) {		if (!(error instanceof JSException)) throw error;		post({ type: "done", ok: false, error: describeException(error) });		return session;	}	vm.callFunction(run, api, fn).dispose();	fn.dispose();	drain();	if (clock) post({ type: "idle", seq: 0, issued });
Phần bọc đặt tiền tố chung dòng đầu với script, nên số dòng trong stack trace khớp script mô hình viết. drain() chạy các promise job từng cái một và thăm dò cờ ngắt sau mỗi 50 job, để cả while (true) await null cũng dừng được (vm.ts:238–250).

Phía host, mỗi message call chạy tool tương ứng và gửi kết quả lại:

Execution.handleCall(): một lời gọi tool từ script
pi/packages/codemode/src/runtime/execution.tsdòng 378–408TypeScript
private async handleCall(message: Extract<WorkerToHostMessage, { type: "call" }>): Promise<void> {	const { id, name } = message;	const isTool = message.target === "tool";	const record: CodemodeCall | undefined = isTool ? { name, status: "cancelled", durationMs: 0 } : undefined;	if (record) this.calls.push(record);	const pending: PendingCall = { record, startedAt: performance.now(), controller: new AbortController() };	this.pending.set(id, pending);	let status: CodemodeCallStatus;	let reply: HostToWorkerMessage;	try {		const tool = (isTool ? this.executors.tools : this.executors.globals).get(name);		if (!tool) throw new Error(`Unknown ${isTool ? "tool" : "global"} "${name}"`);		const args: unknown = message.args === undefined ? undefined : JSON.parse(message.args);		const value = await tool.execute(args, { signal: pending.controller.signal });		reply = { type: "result", id, ok: true, payload: value === undefined ? undefined : JSON.stringify(value) };		status = "ok";	} catch (error) {		reply = { type: "result", id, ok: false, payload: errorMessage(error) };		status = "error";	}	// Already cancelled by finish(): the record keeps "cancelled" and the	// VM is gone or going.	if (!this.pending.delete(id)) return;	if (record) {		record.status = status;		record.durationMs = performance.now() - pending.startedAt;	}	this.post(reply);}
Mỗi lời gọi có AbortController riêng, bị huỷ khi script kết thúc; bản ghi khởi đầu là cancelled và chỉ thành ok/error nếu tool trả về trước đó. Lỗi của tool thành một message lỗi; trong script nó là một Error cùng thông điệp.
  1. Khởi tạoConstructor của Execution đặt deadline (chỉ khi timeoutMs hữu hạn) và lắng nghe tín hiệu huỷ, rồi xin launcher một kênh. Câu trả lời phát sinh trước khi kênh có được xếp hàng rồi gửi sau (execution.ts:206–258).
  2. ChạyMessage output được nối vào output, call chạy tool, done và crash kết thúc. Message không giải mã được, ví dụ vì script đã sửa built-in khiến prelude gửi dữ liệu hỏng, làm lần chạy thất bại với lỗi sandbox “bridge broken” thay vì làm sập pi (:315–328).
  3. Kết thúcSự kiện kết thúc đầu tiên thắng: done, crash, launcher lỗi, deadline, hay huỷ. finish() huỷ mọi lời gọi tool đang chờ, yêu cầu kênh dừng, và resolve kết quả khi yêu cầu đó xong; worker có thể thoát muộn hơn (:410–448).

Prelude: những gì script nhìn thấy#

Script và prelude chạy chung một realm. Vấn đề đã xảy ra thật: một script gán Array.prototype.toJSON = ... làm hỏng JSON mà prelude gửi cho host, làm sập pi và để lời gọi tool treo mãi (CHANGELOG.md:241). Từ 1.0.4, việc đầu tiên prelude làm là đóng băng mọi built-in:

lockdown(): đóng băng built-in và biến global built-in thành chỉ đọc
pi/packages/codemode/src/runtime/prelude-source.tsdòng 51–58, 94–102, 117–138TypeScript
// Freeze every object reachable from the built-in globals, plus the intrinsics that are only// reachable from instances (iterator and generator prototypes, %TypedArray%).//// Freezing alone breaks ordinary code through the "override mistake": a data property that is// read-only on a prototype cannot be assigned on an instance either, so// this.name = "MyError" in an Error subclass would throw. Commonly overridden properties// become accessors whose setter defines an own property on the instance instead.(function lockdown() {	for (const key of Reflect.ownKeys(globalThis)) {		const descriptor = Object.getOwnPropertyDescriptor(globalThis, key);		add(descriptor.value);		add(descriptor.get);		add(descriptor.set);		if ("value" in descriptor && descriptor.configurable) {			Object.defineProperty(globalThis, key, { writable: false, configurable: false });		}	}	while (queue.length > 0) {		const object = queue.pop();		add(Object.getPrototypeOf(object));		const descriptors = Object.getOwnPropertyDescriptors(object);		for (const key of Reflect.ownKeys(descriptors)) {			const descriptor = descriptors[key];			if ("value" in descriptor) {				add(descriptor.value);				if (					descriptor.writable &&					descriptor.configurable &&					(object === Object.prototype || OVERRIDABLE.has(key))				) {					allowOverride(object, key, descriptor.value, descriptor.enumerable);				}			} else {				add(descriptor.get);				add(descriptor.set);			}		}		Object.freeze(object);	}
Đóng băng thuần tuý sẽ làm hỏng code bình thường vì “override mistake”: thuộc tính chỉ đọc trên prototype không gán được trên instance, nên this.name = "MyError" trong một lớp con của Error sẽ ném lỗi. Vì thế vài thuộc tính hay bị ghi đè được thay bằng accessor tự định nghĩa thuộc tính riêng trên instance (prelude-source.ts:71–92).

Sau đó prelude cất cầu nối vào closure, để script không gọi thẳng được, và dựng tools trên nó:

caller() và bảng tools: mỗi lời gọi là một promise chờ id
pi/packages/codemode/src/runtime/prelude-source.tsdòng 197–225TypeScript
function caller(kind, name, spread) {	return (...args) =>		new Promise((resolve, reject) => {			let json;			try {				json = serialize(spread ? args : args[0]);			} catch (error) {				reject(error);				return;			}			const id = nextId++;			pending.set(id, { resolve, reject });			bridge(kind, id, name, json);		});}const tools = Object.create(null);const allTools = [];for (const { name, jsName, description } of parse(toolsJson)) {	const fn = caller("call", name);	// The first tool wins when two names normalize to the same identifier.	if (!(jsName in tools)) {		tools[jsName] = fn;		allTools.push(Object.freeze({ name: jsName, description }));	}	if (!(name in tools)) tools[name] = fn;}Object.freeze(tools);Object.freeze(allTools);
Mỗi tool có hai tên: identifier cho script (my-tool thành my_tool, identifier.ts) và tên gốc, dùng được qua tools["my-tool"].

tools mà script nhận là một Proxy. Đọc một thành viên không tồn tại ném lỗi kèm gợi ý, thay vì trả undefined rồi hỏng ở chỗ khác với “not a function”:

guard(): lỗi nêu tên gần đúng
pi/packages/codemode/src/runtime/prelude-source.tsdòng 229–253TypeScript
const comparable = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");function guard(target, label, names, hint) {	return new Proxy(target, {		get(object, property, receiver) {			if (typeof property !== "string" || property in object || property in Object.prototype || property === "then" || property === "toJSON") {				return Reflect.get(object, property, receiver);			}			const wanted = comparable(property);			const exact = names.filter((name) => comparable(name) === wanted);			const close = exact.length > 0 ? exact : names.filter((name) => wanted && (comparable(name).includes(wanted) || wanted.includes(comparable(name))));			let message = label + "." + property + " does not exist.";			if (close.length > 0) message += " Did you mean " + close.slice(0, 5).map((name) => label + "." + name).join(", ") + "?";			else if (names.length <= 20) message += " Available: " + names.join(", ") + ".";			if (hint) message += " " + hint;			message += ' Check for a member with "' + property + '" in ' + label + ".";			throw new TypeErrorCtor(message);		},	});}const toolsProxy = guard(	tools,	"tools",	allTools.map((tool) => tool.name),	"ALL_TOOLS lists every tool; searchTools(query) finds tools by topic.",);

Ví dụ minh hoạ: script gọi tools.Bash(...) nhận TypeError: tools.Bash does not exist. Did you mean tools.bash? ALL_TOOLS lists every tool; searchTools(query) finds tools by topic. Check for a member with "Bash" in tools., vì comparable() bỏ qua hoa thường và mọi ký tự ngoài chữ cái và chữ số. Hệ quả là kiểm tra kiểu typeof tools.x cũng ném; phải dùng "x" in tools (CHANGELOG.md:350).

Output được giữ ở host tới khi script kết thúc, nên phải có trần. Từ 1.0.1, vượt trần làm script thất bại, kể cả khi script bắt lỗi đó:

output(): trần 16 Mi ký tự và 100.000 item
pi/packages/codemode/src/runtime/prelude-source.tsdòng 337–355TypeScript
let outputChars = 0;let outputItems = 0;// Past the output limits the script fails: done() reports the error, so catching it does not// resume output, and the host ends the script.function output(kind, data, mimeType) {	if (finished) return;	outputChars += data.length;	outputItems++;	if (outputChars > ${MAX_OUTPUT_CHARS} || outputItems > ${MAX_OUTPUT_ITEMS}) {		const error = new RangeErrorCtor(			"script output exceeded the limit of ${MAX_OUTPUT_CHARS} characters or ${MAX_OUTPUT_ITEMS} text(), image(), and console calls. " +				"Print a summary instead, or write large data to a file with a tool.",		);		done(false, describeError(error));		throw error;	}	bridge("output", kind, data, mimeType);}
Gọi done(false, ...) trước khi ném nghĩa là script có catch lỗi cũng không ghi thêm output được: kết cục đã được báo cho host.

Các global còn lại theo cùng tinh thần. store(key, value) và load(key) đồng bộ, làm việc trên một ảnh chụp JSON truyền vào lúc khởi động, tối đa 262.144 ký tự JSON mỗi giá trị và 1.048.576 ký tự cho tất cả (prelude-source.ts:286–326). image() chỉ nhận dữ liệu base64 hợp lệ có chữ ký PNG, JPEG, GIF hoặc WebP, và lấy kiểu MIME từ chữ ký thay vì từ kiểu khai báo, vì một ảnh hỏng được lưu vào session sẽ làm mọi request sau bị provider từ chối (:403–429). Cuối cùng, VM không có timer hay I/O, nên một script đang chờ mà không có lời gọi tool nào dở dang thì không bao giờ tiếp tục được; prelude phát hiện và kết thúc nó ngay:

stalled(): promise không bao giờ settle
pi/packages/codemode/src/runtime/prelude-source.tsdòng 509–520TypeScript
stalled() {	if (finished || pending.size > 0) return false;	done(		false,		stringify({			name: "Error",			message:				"The script is waiting on a promise that can never settle: no tool call is pending, and timers do not exist here.",		}),	);	return true;},

Sinh khai báo TypeScript cho tool#

Để viết đúng tools.x(args), mô hình cần biết args trông thế nào và lời gọi trả về gì. Tool khai báo bằng JSON Schema, nhưng mô hình đọc TypeScript tốt hơn nhiều so với một khối schema lồng nhau. declarations.ts dịch schema sang biểu thức kiểu TypeScript:

schemaToType(): JSON Schema thành kiểu TypeScript, có trần độ dài
pi/packages/codemode/src/declarations.tsdòng 131–145TypeScript
/** * Convert a JSON Schema to a TypeScript type expression: objects on one line (`{ a: string; b?: number; }`) with properties sorted by name, * or one property per line with `//` comments when a property has a description or constraints the * type cannot express (`// @integer @minimum 1 @default 20`); `Array<T>` for arrays. Local references * (`#/$defs/...`, `#/definitions/...`) resolve against `schema`; recursive and remote references * render as `unknown`. A result longer than `maxChars` is rendered again without the constraint tags, * and renders as `unknown` if it is still too long. */export function schemaToType(schema: CodemodeJsonSchema, options: { maxChars?: number } = {}): string {	const render = (tags: boolean) => toType(schema, { root: schema, resolving: new Set(), expansions: 0, tags });	const type = render(true);	if (options.maxChars === undefined || type.length <= options.maxChars) return type;	const untagged = render(false);	return untagged.length <= options.maxChars ? untagged : "unknown";}
Kiểu dài quá maxChars (mặc định 16.000 ký tự cho đầu vào, declarations.ts:10) được vẽ lại không có tag, rồi thành unknown nếu vẫn quá dài. Tham chiếu $ref cục bộ được mở tối đa 32 lần cho mỗi schema, tham chiếu đệ quy hay từ xa thành unknown.

Ràng buộc mà kiểu TypeScript không diễn đạt được, như integer, cận trên dưới, độ dài, pattern, default, được viết thành tag trên dòng chú thích của thuộc tính (1.2.0, declarations.ts:249–301). Thuộc tính được sắp theo tên; có mô tả hay tag thì mỗi thuộc tính một dòng, không thì cả object nằm trên một dòng (:303–340). Mẫu khai báo mà mô hình đọc cho từng tool ghép mô tả với một câu declare:

renderToolSample(): mô tả cộng khai báo
pi/packages/codemode/src/declarations.tsdòng 87–97TypeScript
/** * A tool's sample: the description followed by the tool's declaration. Used for tool * listings and `ALL_TOOLS` entries. */export function renderToolSample(	tool: Pick<CodemodeTool, "name" | "description" | "inputSchema" | "outputSchema">,	options: { inputMaxChars?: number } = {},): string {	const declaration = `declare const tools: { ${renderToolSignature(tool, options)} };`;	return `${tool.description?.trim() ?? ""}\n\ncodemode tool declaration:\n\`\`\`ts\n${declaration}\n\`\`\``;}
Ví dụ minh hoạ: lần theo code với một schema đầu vào
{ "type": "object", "required": ["query"],
  "properties": {
    "query": { "type": "string", "description": "Search text" },
    "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 } } }

        ↓ schemaToType()

{
  // @integer @minimum 1 @maximum 50 @default 10
  limit?: number;
  // Search text
  query: string;
}
limit đứng trước query vì thuộc tính được sắp theo tên; @integer đứng đầu, rồi các tag theo thứ tự của TAG_KEYWORDS, @default sau cùng.

Kiểu trả về đến từ outputSchema. Tool của coding agent không có output schema được gán một schema { type: "string" }, nên lời gọi trả về Promise<string> (codemode/tool.ts:141–156). Tool MCP luôn có output schema là CallToolResult mà script nhận, với output schema riêng của tool nằm ở structuredContent (mcp/tools.ts:155–174). Từ 1.2.0 kiểu đó được viết ra đầy đủ thay vì một kiểu generic CallToolResult<T> mà describeTool() và ALL_TOOLS hiện ra mà không định nghĩa (CHANGELOG.md:134).

Tất định, giới hạn và runtime từ xa#

Các giới hạn

Giới hạnGiá trịNơi đặt
Bộ nhớ VM256 MB trong coding agent; vượt thì InternalError: out of memory trong scriptexecute.ts:51–56
Output16 Mi ký tự (text và base64 ảnh), 100.000 itemprelude-source.ts:36–43
Output đưa cho mô hình10.000 token ước lượng (4 ký tự/token), giữ đầu và cuối, bản đầy đủ ghi ra file tạm; đổi bằng max_output_tokensexecute.ts:444–472
store()262.144 ký tự JSON mỗi giá trị, 1.048.576 ký tự cho tất cảprelude-source.ts:34–35
DeadlineSandbox mặc định 300 giây; coding agent không đặt deadline trừ khi script có timeout_msexecution.ts:49, execute.ts:555
StackGuard của QuickJS 512 KiB trong worker, 256 KiB trên luồng gọivm.ts:53–58, inline.ts:41–42
Worker đồng thời2 cho cả tiến trình pi (PI_CODEMODE_MAX_WORKERS), chờ tối đa 60 giâyexecute.ts:57–69
Lời gọi models.* đồng thời4 mỗi script; số còn lại xếp hàngexecute.ts:49–50

Lần chạy tất định

execute() tự chạy tool. start() (1.2.0) thì khác: mọi lời gọi đi tới host qua onCall, và host trả lời bằng run.deliver(seq, now, id, outcome). Mục đích là phát lại: cùng script, cùng thời điểm bắt đầu, cùng seed, được trả lời bằng cùng kết quả theo cùng thứ tự, thì gọi đúng những tool đó với đúng những đối số đó và cho cùng output, trên mọi VM có cùng fingerprint (codemode/README.md:314–343). VM vốn đã gần như tất định, vì nó chỉ chạy khi host đưa thứ gì đó vào và không có timer; phần còn lại được chốt khi tạo VM:

Chế độ tất định: đồng hồ ảo, UTC, không WeakRef
pi/packages/codemode/src/runtime/vm.tsdòng 160–180TypeScript
export async function startVm(options: StartVmOptions): Promise<VmSession> {	const { data, post } = options;	const maxStackSize = options.maxStackSize ?? MAX_STACK_SIZE;	const deterministic = data.deterministic;	const clock: VirtualClock | undefined = deterministic && {		creating: true,		startedAt: deterministic.startedAt,		now: deterministic.startedAt,		seed: deterministic.seed >>> 0,	};	const vm = await QuickJS.create({		wasm: options.wasm,		memoryLimit: data.memoryLimitBytes,		// Without a guard, deep recursion overflows the wasm stack and traps instead of throwing a		// catchable RangeError.		maxStackSize,		interruptHandler: options.shouldInterrupt,		wasi: clock ? deterministicWasi(clock) : discardOutput,		...(clock ? { timezoneOffset: 0, intrinsics: Intrinsics.ALL & ~Intrinsics.WEAK_REF } : {}),	});	if (clock) clock.creating = false;
Mọi đồng hồ WASI đọc thời gian ảo; random_get lấy từ một PRNG mulberry32 trên seed (vm.ts:102–125). Garbage collection không tất định, nên WeakRef và FinalizationRegistry bị bỏ khỏi môi trường.
Kết quả được áp dụng đúng thứ tự seq, dù transport giao lệch
pi/packages/codemode/src/runtime/vm.tsdòng 202–227TypeScript
const session: VmSession = {	deliver(message) {		if (disposed) return;		try {			if (!clock) {				apply(message);				return;			}			const { seq } = message;			if (seq === undefined || !Number.isInteger(seq) || seq < nextSeq || early.has(seq)) {				throw new Error(`Result ${message.id} has sequence number ${seq}, expected ${nextSeq} or later, once`);			}			early.set(seq, message);			for (let next = early.get(nextSeq); next !== undefined; next = early.get(nextSeq)) {				early.delete(nextSeq);				// Never backwards: a resumed host's clock may be behind the one that started the run.				if (next.now !== undefined && Number.isFinite(next.now)) clock.now = Math.max(clock.now, next.now);				current = nextSeq;				apply(next);				post({ type: "idle", seq: nextSeq, issued });				nextSeq++;			}		} catch (error) {			post(crashMessage(error));		}	},
Kết quả đến sớm được giữ lại; đồng hồ ảo chỉ tiến, không lùi, kể cả khi host được khởi động lại có đồng hồ chậm hơn. Sau mỗi kết quả, VM báo idle với số lời gọi đã phát, để host đối chiếu lần phát lại từng bước.

Trước khi script chạy, VM báo một fingerprint gồm phiên bản quickjs-wasi và QuickJS, hash FNV-1a của prelude, phiên bản chế độ tất định, stack, heap và ngân sách ngắt (vm.ts:314–321), để một host phát lại có thể từ chối một VM khác. Coding agent không dùng chế độ này; Pi Durable dùng nó cho codemode bền (chương Durable: task, scheduler, generation và tool).

Ngoài Node: luồng gọi, isolate khác, Cloudflare

Cloudflare Workers và Durable Object không có worker thread. InlineCodemodeSandbox chạy VM ngay trên luồng gọi, và vì trong lúc script tính toán thì không timer nào chạy được (trên Workers cả Date.now() cũng đứng yên), nó giới hạn bằng ngân sách ngắt: đếm số lần QuickJS thăm dò hàm ngắt, quá 100.000 lần thì dừng với lỗi timeout. Ghi chú trong code đo được khoảng 50.000 lần mỗi giây CPU trên laptop Apple M và khoảng 10.000 trên Durable Object, tức chừng 2 tới 10 giây tính toán liên tục (inline.ts:5–25).

Chạy trên luồng gọi vẫn chặn luồng đó. Trong một Durable Object phục vụ một session agent, điều này nghĩa là chặn mọi request khác của session, kể cả lệnh huỷ của người dùng. RemoteCodemodeSandbox giữ vai host ở bên gọi và chỉ đẩy VM sang nơi khác:

RemoteCodemodeSandbox: vấn đề, phép đo, giao thức
pi/packages/codemode/src/runtime/remote.tsdòng 5–23TypeScript
* Problem: a VM on the calling thread blocks that thread while the script computes. In a Durable* Object that serves an agent session, a CPU-heavy script would stall every other request to the* session (including the user's abort) for seconds. Measured on Cloudflare (October 2026), a* script that computed for 10 seconds delayed the session's other requests by 10 seconds when it* ran in the session object or in a Dynamic Worker, but by about 50 ms when it ran in a Durable* Object of a separately deployed Worker.** Solution: the caller keeps the host role (`RemoteCodemodeSandbox`: tools, call records,* deadline, abort) and the remote side only runs the VM (`serveCodemodeRemote`, behind* `CodemodeSandboxDurableObject` in `@earendil-works/pi-codemode/cloudflare`).** Protocol, one RPC call per execution:**     client                                     remote*     remote.execute(request, exchange) ───────▶ starts the VM (in-thread, with interrupt budget)*                                      ◀─────── exchange([...outputs, call])   one call per tool call*     runs the tool, replies with result ──────▶ VM continues*                                      ◀─────── exchange([...outputs, done])   terminal, reply ignored*     execute() resolves ◀────────────────────── VM disposed
Mỗi exchange mang tối đa một lời gọi, nên các lời gọi tool đồng thời là các RPC đồng thời; output được gom ở phía từ xa và gửi kèm lời gọi kế tiếp, để một script in trong vòng lặp không sinh một RPC mỗi dòng (remote.ts:25–29).
CodemodeSandboxDurableObject: đầu bên kia, triển khai như một Worker riêng
pi/packages/codemode/src/cloudflare.tsdòng 42–75TypeScript
/** * The remote end of `RemoteCodemodeSandbox`: a Durable Object whose one RPC method runs a script's * VM and calls back into the client for every tool call. * * Deploy it as its own Worker and bind it from the Worker that runs your agent with `script_name`. * A separate Worker is what keeps scripts off the agent's thread: a class in the same Worker can * share an isolate, and therefore a thread, with the objects that call it. * * The object keeps no state between executions and never touches storage. Executions that reach * the same instance share its thread: they interleave while they wait on tools, and a script that * computes delays the others. Name instances per session (`getByName(sessionId)`) to keep one * session's scripts together, or per execution for full independence. * * Limits apply to every request, whatever the client asks for. Override the fields in a subclass: * *     export class CodemodeSandbox extends CodemodeSandboxDurableObject { *       protected maxInterruptBudget = 200_000; *     } */export class CodemodeSandboxDurableObject<Env = unknown> extends DurableObject<Env> {	/** Interrupt polls per execution; the client may only ask for less. See `InlineCodemodeSandboxOptions`. */	protected maxInterruptBudget: number = DEFAULT_INTERRUPT_BUDGET;	/** VM memory per execution; the client may only ask for less. */	protected maxMemoryLimitBytes: number = DEFAULT_REMOTE_MEMORY_LIMIT_BYTES;	/** Called by `RemoteCodemodeSandbox` over RPC. Resolves when the script has settled or was stopped. */	execute(request: CodemodeRemoteRequest, exchange: CodemodeRemoteExchange): Promise<void> {		return serveCodemodeRemote(request, exchange, {			wasm: quickjsWasm,			maxInterruptBudget: this.maxInterruptBudget,			maxMemoryLimitBytes: this.maxMemoryLimitBytes,		});	}}
Phải là Worker riêng: một lớp trong cùng Worker có thể dùng chung isolate, tức chung luồng, với các object gọi nó. Giới hạn do phía từ xa quyết định; client chỉ được xin ít hơn. Bộ nhớ mặc định 32 MB vì một isolate chỉ có tổng 128 MB (cloudflare.ts:31–32).

Tool codemode của coding agent#

Built-in extension codemode đăng ký một tool tắt sẵn (defaultActive: false); người dùng bật nó bằng "defaultTools": ["+codemode"] hay --tools +codemode, hoặc extension MCP bật khi có server dùng exposure codemode (codemode/index.ts:32–46). Định nghĩa tool cho thấy nó chỉ dùng các API chung của extension:

createCodemodeToolDefinition()
pi/packages/coding-agent/src/extensions/codemode/tool.tsdòng 371–393TypeScript
export function createCodemodeToolDefinition(	options: CodemodeToolOptions = {},): ToolDefinition<typeof codemodeSchema, CodemodeToolDetails | undefined> {	return {		name: CODEMODE_TOOL_NAME,		label: CODEMODE_TOOL_NAME,		// Replaced with the declarations of the callable tools when the tool is activated.		description: createCodemodeDescription([], { models: options.models === true }),		promptSnippet: codemodeToolSystemPromptContribution.snippet,		promptGuidelines: [...codemodeToolSystemPromptContribution.guidelines],		parameters: codemodeSchema,		// Scripts must not start other scripts.		exposure: "model-only",		prepareLoadout: (loadout) => prepareCodemodeLoadout(loadout, options),		reachesTools: (input) => getScriptReach(input.code),		// Capable models write the script as raw text instead of a JSON-escaped string.		constrainedSampling: { type: "grammar", variants: { openai_lark: CODEMODE_SOURCE_GRAMMAR } },		// The sandbox (worker, QuickJS wasm) loads on the first call, not at startup.		execute: async (toolCallId, params, signal, onUpdate, ctx) =>			(await loadCodemodeExecutor()).executeCodemode(toolCallId, params, signal, onUpdate, ctx, options),		...codemodeRenderers,	};}
Sandbox, worker và wasm QuickJS chỉ được nạp ở lời gọi đầu tiên (execute.lazy.ts là một import() động), nên bật codemode không làm pi khởi động chậm hơn. Tài liệu extension nhấn mạnh: codemode chỉ dùng hook prepareLoadout, exposure, reachesTools và ctx.executeTool(), nên một extension khác có thể cài lại cùng hành vi dưới tên khác (docs/extensions.md:293).

Phần đầu mô tả là cố định: luật của script và các global, mỗi global một dòng. Chi tiết của API models không nằm ở đây mà ở docs/codemode.md, và mô tả bảo mô hình đọc file đó khi cần:

Phần cố định của mô tả codemode
pi/packages/coding-agent/src/extensions/codemode/tool.tsdòng 118–134TypeScript
const DESCRIPTION_INTRO = `Run JavaScript that calls other tools. The input is raw JavaScript (not JSON, no code fence), run as an async function body in a QuickJS sandbox: top-level \`await\` and \`return\` work. No Node, file system, network, or timers.- \`await tools.<name>({ ...args })\` resolves to a string, or an object if the tool's declaration says so, and rejects with an Error on failure. A tool whose declaration returns an object resolves on failure too when it returned that object; check its fields. Calls still running when the script ends are cancelled.- Optional first line: \`// @options: {"max_output_tokens": 10000, "timeout_ms": 60000}\``;/** One line per global. The details live in {@link CODEMODE_DOCS_PATH}. */function describeGlobals(models: boolean): string {	const lines = [		"Globals:",		"- `text(value)`, `image(dataUrlOrImageBlock)`, `console.log(...)`, and top-level `return` add output; `exit()` ends the script. With several text items, each starts with a `==> text N/M <==` line, and `console` lines follow the other output in one `<console_output>` block. `image()` also saves the image to a temp file and the result names its path.",		"- `store(key, value)` and `load(key)` keep JSON values across codemode calls.",		"- `ALL_TOOLS`, `await searchTools(query, { limit?, namespace? })`, `await describeTool(name)`, `await describeNamespace(name)`: find unlisted tools, such as MCP tools.",	];	if (models) {		lines.push(`- \`models\`: classifiers and image generation. Read ${CODEMODE_DOCS_PATH} first.`);	}	return lines.join("\n");}

Cách viết này là kết quả của đợt cắt giảm ở 1.0.0: với bộ tool mặc định và codemode bật, một request GPT-5.6 giảm từ khoảng 5.300 xuống 3.300 token, vì mô tả không còn nhắc lại toàn bộ khai báo và tài liệu models chuyển ra file (CHANGELOG.md:349).

Phần còn lại của mô tả, danh sách “Nested tools”, do prepareLoadout dựng mỗi khi bộ tool đổi. Setting codemode.mode chọn một trong hai cách trình bày:

Tool deferred không bao giờ được liệt kê, kể cả khi còn ngân sách; chúng tìm được bằng searchTools(). Vì tool MCP mặc định là deferred, ở cấu hình mặc định (mode: "on", chỉ có read, bash, edit, write và tool MCP) mô tả codemode không có mục “Nested tools” nào, và không đổi một byte khi server MCP kết nối. Mô hình biết cách gọi các tool chính từ dòng thêm vào mô tả của chúng:

Ví dụ minh hoạ: dòng describeScriptCall() thêm vào cuối mô tả của hai tool
bash:  ... Codemode: `tools.bash(args)` resolves to `{ output, truncated, full_output_path?, exit_code, wall_time_seconds }`.
edit:  ... Codemode: `tools.edit(args)` resolves to a string.
bash có output schema (bash.ts:56–62) nên lời gọi trả object; edit không có nên trả chuỗi (codemode/tool.ts:289–306).

Khi có tool được liệt kê (chế độ only, hoặc tool có exposure codemode của extension), chúng chia nhau một ngân sách khoảng 3.000 token ước lượng (codemode.inlineBudget, settings-defaults.ts:57–60). Cách chọn bảo đảm mọi namespace đều có mặt trước khi namespace nào được liệt kê đủ:

selectCatalog(): mỗi vòng, mỗi nhóm đặt tool rẻ nhất còn lại
pi/packages/coding-agent/src/extensions/codemode/tool.tsdòng 197–220TypeScript
/** * Pick the tool sections that fit the budget, like OpenCode's catalog: in each round every group * (tools without a namespace first, then namespaces by name) places its cheapest remaining tool; a * group whose next tool does not fit drops out while the others continue. Every namespace is * represented before any namespace is complete. */function selectCatalog(groups: readonly CatalogGroup[], budget: number | undefined): Set<string> {	if (budget === undefined) return new Set(groups.flatMap((group) => group.entries.map((entry) => entry.name)));	const queues = groups.map((group) => [...group.entries].sort((a, b) => a.cost - b.cost));	const shown = new Set<string>();	let remaining = budget;	let active = queues.filter((queue) => queue.length > 0);	while (active.length > 0) {		active = active.filter((queue) => {			const next = queue[0];			if (next.cost > remaining) return false;			remaining -= next.cost;			shown.add(next.name);			queue.shift();			return queue.length > 0;		});	}	return shown;}
Nhóm có tool tiếp theo không vừa thì rời cuộc chơi, các nhóm khác tiếp tục. Tiêu đề namespace ghi “(some tools not listed)” hay “(tools not listed)” để mô hình biết phải tìm thêm (codemode/tool.ts:252–267).

Hai chi tiết cuối của định nghĩa. reachesTools trả "all" nếu script dùng một global khám phá (searchTools, describeNamespace, describeTool, ALL_TOOLS), còn không thì trả các từ trong mã nguồn, nên tools.mcp__docs__search và tools["mcp__docs__" + name] đều “chạm” namespace mcp__docs (codemode/tool.ts:358–369). Và system prompt nhận một dòng hướng dẫn: dùng codemode để gộp các lời gọi độc lập bằng Promise.allSettled, nối chúng, hay lọc output lớn (codemode/tool.ts:108–113).

Chạy một script#

executeCodemode() là cầu nối giữa sandbox không biết gì về pi và session của pi. Việc đầu tiên là biến mỗi tool gọi được (ctx.tools, trừ chính codemode) thành một CodemodeTool mà execute đi qua ctx.executeTool():

Tool của pi thành tool của sandbox
pi/packages/coding-agent/src/extensions/codemode/execute.tsdòng 519–552TypeScript
const callable = ctx ? getCodemodeCallableTools(ctx.tools) : [];// ALL_TOOLS entries carry the declaration.const guidelines = options.getToolGuidelines?.();const samples = new Map(	callable.map((tool) => [tool.name, renderToolSample(toCodemodeDeclaration(tool, guidelines?.get(tool.name)))]),);const sandboxTools: CodemodeTool[] = callable.map((tool) => ({	name: tool.name,	description: samples.get(tool.name),	execute: async (args, { signal: callSignal }) => {		const record: CodemodeNestedCall = {			id: `${toolCallId}/?`,			name: tool.name,			args: previewArgs(args),			status: "running",		};		calls.push(record);		publish();		const callStartedAt = performance.now();		// Only tools from ctx.tools are callable, so ctx is set here.		if (!ctx) throw new Error("Tool calls need a session");		const outcome = await ctx.executeTool(tool.name, args, { signal: callSignal });		record.id = outcome.toolCall.id;		record.durationMs = performance.now() - callStartedAt;		if (outcome.isError) {			record.status = callSignal.aborted ? "cancelled" : "error";			record.error = truncateText(textOf(outcome.result) || `Tool "${tool.name}" failed`, ERROR_PREVIEW_CHARS);		} else {			record.status = "ok";		}		publish();		return toScriptValue(tool, outcome);	},}));
description của mỗi tool là mẫu khai báo đầy đủ; nó thành mục ALL_TOOLS và kết quả của describeTool(). Mỗi lời gọi được ghi một dòng CodemodeNestedCall để giao diện hiện trạng thái trong lúc chạy.

Giá trị mà script nhận theo một luật đơn giản, ghi ở đầu tool.ts: tool có outputSchema trả structuredContent, kể cả khi kết quả là lỗi nhưng vẫn mang nó; tool khác trả text; một lời gọi lỗi không có structuredContent để trả thì reject:

toScriptValue()
pi/packages/coding-agent/src/extensions/codemode/execute.tsdòng 474–486TypeScript
/** * The value a script receives for a nested call: a tool that declares * `outputSchema` resolves to its `structuredContent`, also for error results that carry one (such * as MCP results with `isError`); any other tool resolves to its text content. Other failures * reject with the tool's error text. */function toScriptValue(tool: AgentTool<any>, outcome: AgentToolCallOutcome): unknown {	const { result } = outcome;	if (tool.outputSchema && result.structuredContent !== undefined) return result.structuredContent;	const text = textOf(result);	if (outcome.isError) throw new Error(text || `Tool "${tool.name}" failed`);	return text;}

Rồi tới phần xin worker và tạo sandbox:

Xin worker, tạo sandbox, chạy
pi/packages/coding-agent/src/extensions/codemode/execute.tsdòng 554–558, 579–611TypeScript
// The wait for a worker counts toward the script's deadline.const deadline = sourceOptions.timeoutMs ?? Number.POSITIVE_INFINITY;const waitStartedAt = performance.now();const admission = await reserveCodemodeWorker(signal, Math.min(deadline, CODEMODE_WORKER_WAIT_MS));const waitedMs = performance.now() - waitStartedAt;let reserved = true;let sandbox: CodemodeSandbox | undefined;let result: CodemodeResult;try {	sandbox = new CodemodeSandbox({		tools: sandboxTools,		globals: [			...createDiscoveryGlobals(callable, samples, options),			...(options.models && ctx				? createModelGlobals(ctx.modelRegistry, toolCallId, calls, publish, addModelUsage, addGeneratedImages)				: []),		],		timeoutMs: Math.max(1, Math.round(deadline - waitedMs)),		memoryLimitBytes: CODEMODE_MEMORY_LIMIT_BYTES,		wasm: loadQuickJSWasm(getQuickJSWasmPath()),		workerUrl: getCodemodeWorkerSpecifier(),		acquireWorker: () => {			if (!reserved) throw new Error("The script already settled");			// From here the sandbox releases the slot when the worker actually exits.			reserved = false;			return releaseCodemodeWorker;		},	});	const store = ctx ? readCodemodeStore(ctx.sessionManager.getBranch()) : {};	result = await sandbox.execute(code, { signal, store });} finally {	await sandbox?.close();	if (reserved) {		reserved = false;		releaseCodemodeWorker();	}}
Thời gian chờ worker tính vào deadline của script. acquireWorker chuyển việc trả chỗ cho sandbox: chỗ chỉ được trả khi worker thật sự thoát, vì trên Bun một worker bị huỷ có thể còn chạy thêm (1.3.0). Hàng đợi chờ theo thứ tự đến, tối đa 60 giây (execute.ts:74–111).

store truyền vào là kết quả phát lại các entry codemode-store trên nhánh hiện tại của session:

readCodemodeStore(): store theo nhánh của cây session
pi/packages/coding-agent/src/extensions/codemode/execute.tsdòng 292–303TypeScript
/** Values of `load()`: the `codemode-store` entries on the branch, applied from the root. */export function readCodemodeStore(branch: readonly SessionEntry[]): Record<string, unknown> {	const store = new Map<string, unknown>();	for (const entry of branch) {		if (entry.type !== "custom" || entry.customType !== CODEMODE_STORE_ENTRY_TYPE || !isStoreEntryData(entry.data)) {			continue;		}		for (const key of entry.data.delete) store.delete(key);		for (const [key, value] of Object.entries(entry.data.set)) store.set(key, value);	}	return Object.fromEntries(store);}
Đây là nghĩa cụ thể của câu “trạng thái của codemode nằm trong transcript”: rẽ nhánh bằng /tree thì mỗi nhánh thấy các giá trị được ghi trên đường của nó.

Cuối cùng, kết quả được trình bày cho mô hình:

Từ CodemodeResult tới kết quả tool
pi/packages/coding-agent/src/extensions/codemode/execute.tsdòng 612–627, 635–651TypeScript
// Calls still marked running were cut off by the script ending, a timeout, or an abort.for (const call of calls) {	if (call.status === "running") call.status = "cancelled";}const scriptOutput = [...result.output];if (result.ok) {	const { set, delete: deleted } = result.storeWrites;	if (Object.keys(set).length > 0 || deleted.length > 0) {		options.appendEntry?.(CODEMODE_STORE_ENTRY_TYPE, { set, delete: deleted });	}	// pi extension: a returned value is appended like text().	if (result.value !== undefined) scriptOutput.push({ type: "text", text: valueText(result.value) });}const items = formatOutput(scriptOutput);if (!result.ok) items.push({ type: "text", text: `Script error:\n${formatError(result, calls)}` });const truncated = await truncateOutput(	joinAdjacentText(items),	sourceOptions.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,);// After truncation, which joins the text items and moves images after them, so each path stays// next to its image and is never cut.const output = joinAdjacentText(await saveImages(truncated.items));const wallTime = ((performance.now() - startedAt) / 1000).toFixed(1);const header = `${result.ok ? "Script completed" : "Script failed"}\nWall time ${wallTime} seconds\nOutput:\n`;const details = snapshot();if (truncated.fullOutputPath) details.fullOutputPath = truncated.fullOutputPath;return {	content: [{ type: "text", text: header }, ...output],	details,	...(modelUsage ? { usage: modelUsage } : {}),	...(result.ok ? {} : { isError: true }),};
Giá trị return được nối như một text(). Có nhiều item text thì mỗi item mở đầu bằng dòng ==> text N/M <==, và mọi dòng console gom vào một khối <console_output> ở cuối (execute.ts:316–341). Mỗi ảnh được lưu thêm vào một file tạm và tên file đứng trước ảnh, vì script không ghi file được và mô hình không có cách nào khác chạm tới bytes của ảnh (:409–442).

Script lỗi vẫn trả output đến lúc lỗi, theo sau là Script error:, lỗi, và danh sách lời gọi đã chạy kèm câu “they are not undone” (execute.ts:374–390).

Các global models.* được thêm khi extension tạo tool với models: true. Lời gọi classify() và generateImages() chỉ dùng provider và id của mô hình mà script đưa vào, rồi tra lại catalog: một baseUrl hay headers do script cung cấp không bao giờ được nhận credential (execute.ts:753–790), và mục catalog trả cho script bị bỏ headers vì header trong models.json có thể chứa credential (:145–150). Đối số sai hình dạng bị từ chối với hình dạng mong đợi, để mô hình tự sửa (:172–235).

Lời gọi tool lồng nhau#

Vấn đề. Script gọi tools.bash({...}). Lời gọi đó phải được đối xử như một lời gọi thật: kiểm tra đối số theo schema, chạy qua hook tool_call mà một extension phân quyền có thể dùng để chặn, có sự kiện cho giao diện, có chi phí được tính. Nhưng nó không được vào transcript, vì cả mục đích của codemode là để mô hình không thấy các kết quả trung gian. Tool lấy cả hai qua ctx.executeTool():

AgentSession: lời gọi lồng nhau đi qua pipeline tool với parentToolCallId
pi/packages/coding-agent/src/core/agent-session.tsdòng 726–764TypeScript
/** * Run a call that the tool call `parentToolCallId` made through `ctx.executeTool()`. It goes * through the agent's tool pipeline with the session's hooks, against the callable tools. */private async _executeNestedToolCall(	parentToolCallId: string,	name: string,	args: unknown,	options: ExecuteToolOptions,): Promise<AgentToolCallOutcome> {	this._nestedToolCalls ??= new NestedToolCallRunner({		getTools: () => this._getCallableTools(),		isSequential: () => this.agent.toolExecution === "sequential",		runToolCall: (toolCall, parentId, signal, onUpdate) => {			const assistantMessage = this._findLastAssistantMessage();			if (!assistantMessage) {				return Promise.resolve({					toolCall,					result: { content: [{ type: "text", text: "No assistant message issued this call" }], details: {} },					isError: true,				});			}			return runToolCall(toolCall, {				tools: this._getCallableTools(),				assistantMessage,				context: { messages: this.agent.state.messages, tools: this.agent.state.tools },				beforeToolCall: (context) => this._beforeToolCall(context, parentId),				afterToolCall: (context) => this._afterToolCall(context, parentId),				signal,				onUpdate,			});		},		emit: async (event) => {			await this._extensionRunner.emit(event);			this._emit(event);		},	});	return this._nestedToolCalls.execute(parentToolCallId, name, args, options);}
runToolCall() của pi-agent-core chạy đúng các bước của một lời gọi do mô hình phát ra, gồm chuẩn bị đối số, kiểm tra schema, beforeToolCall, chạy và afterToolCall, nhưng không phát sự kiện và không thêm message (agent-loop.ts:812–829). Tool được tra trong bộ gọi được, không phải bộ đang khai báo.

NestedToolCallRunner đặt id, giữ thứ tự khi cần, và ghi lại:

Bản ghi có giới hạn, vì một script có thể phát hàng nghìn lời gọi:

NESTED_CALL_LIMITS
pi/packages/coding-agent/src/core/nested-tool-calls.tsdòng 21–31TypeScript
/** * Limits of the nested-call record on a tool result: arguments * over the per-call or total size are omitted, calls beyond the count are dropped, and the record * is marked incomplete when any of that happens. */export const NESTED_CALL_LIMITS = {	maxCalls: 256,	maxArgumentBytesPerCall: 8 * 1024,	maxArgumentBytesTotal: 32 * 1024,	maxErrorChars: 500,} as const;

Khi kết quả của lời gọi codemode bắt đầu được phát, AgentSession lấy bản ghi ra và gắn vào chính message đó, cộng cả usage của các lời gọi lồng nhau (chúng không được lưu riêng, nên đây là nơi duy nhất chi phí của chúng được tính):

Gắn nestedCalls và usage lên kết quả tool
pi/packages/coding-agent/src/core/agent-session.tsdòng 1107–1119TypeScript
// Record the calls a tool made through ctx.executeTool() and their usage on its result message.if (this._nestedToolCalls) {	if (event.type === "message_start" && event.message.role === "toolResult") {		const message = event.message;		const summary = this._nestedToolCalls.takeRecord(message.toolCallId);		if (summary?.calls) message.nestedCalls = summary.calls;		if (summary?.usage) {			message.usage = message.usage ? combineUsage(message.usage, summary.usage) : summary.usage;		}	} else if (event.type === "agent_end") {		this._nestedToolCalls.clear();	}}
NestedToolCallRecord và NestedToolCalls
pi/packages/ai/src/types.tsdòng 611–631TypeScript
/** A tool call that another tool made while it ran, for example from a codemode script. */export interface NestedToolCallRecord {	id: string;	name: string;	/** Omitted when over the size limits; `argumentsBytes` then gives their size. */	arguments?: JsonObject;	/** UTF-8 size of the arguments as JSON, set when `arguments` is omitted. */	argumentsBytes?: number;	/** `unfinished`: the call was still running when the calling tool finished. */	status: "ok" | "error" | "unfinished";	durationMs?: number;	/** Error text, truncated. */	error?: string;}/** Bounded record of the nested calls a tool made. Results are not recorded. */export interface NestedToolCalls {	calls: NestedToolCallRecord[];	/** False when calls were dropped, arguments omitted, or calls had not finished. */	complete: boolean;}
Trên ToolResultMessage, nestedCalls được ghi chú là “Kept for the session record; not sent to the model” (ai/types.ts:642–643). Kết quả của lời gọi lồng nhau không được ghi lại, chỉ có tên, đối số, trạng thái và thời gian.

Sự kiện tool_call, tool_result và tool_execution_* của lời gọi lồng nhau mang parentToolCallId (nested-tool-calls.ts:110–129). Đó là cách một extension phân quyền phân biệt lời gọi của script với lời gọi của mô hình; tài liệu MCP nói rõ mọi lời gọi MCP, kể cả từ script, đều đi qua pipeline này (docs/mcp.md:250–252). Giao diện thì không vẽ chúng thành dòng tool riêng; renderer của codemode liệt kê chúng ngay trong dòng của nó, với biểu tượng trạng thái, thời gian và chi phí (renderer.ts:1–8).

pi-mcp: client, JSON-RPC và transport#

@earendil-works/pi-mcp là một client MCP nhỏ, độc lập, không phụ thuộc SDK MCP chính thức hay gói pi nào (mcp/README.md:3). Nó nói phiên bản giao thức 2025-11-25 và chấp nhận server đàm phán xuống 2025-06-18, 2025-03-26 hoặc 2024-11-05 (protocol/types.ts:4–10); batch JSON-RPC, HTTP+SSE kiểu cũ, sampling và tasks nằm ngoài phạm vi (README.md:132). Việc được chia đôi: transport lo đóng khung và I/O, client lo tương quan request, khởi tạo, timeout, huỷ và request từ server:

McpTransport: transport chỉ chuyển từng message JSON-RPC
pi/packages/mcp/src/transports/transport.tsdòng 9–26TypeScript
export interface McpTransportSendOptions {	/**	 * Aborted once the sender no longer waits for a reply (the request was cancelled, timed out, answered,	 * or the client closed). A transport must not dispatch the message after that, for example when	 * retrying it after an authorization refresh. It does not undo a message already dispatched.	 */	signal?: AbortSignal;}export interface McpTransport {	start(): Promise<void>;	send(message: JsonRpcMessage, options?: McpTransportSendOptions): Promise<void>;	close(): Promise<void>;	onMessage(listener: McpTransportMessageListener): () => void;	onError(listener: McpTransportErrorListener): () => void;	onClose(listener: McpTransportCloseListener): () => void;	setProtocolVersion?(version: string): void;}
signal trong send() (1.2.0) bị huỷ khi người gửi không còn chờ trả lời; transport không được gửi message sau đó, kể cả khi đang thử lại sau một lần làm mới uỷ quyền. Trước đó, một lời gọi tool đã bị huỷ hay hết giờ vẫn có thể tới server sau khi token được làm mới (mcp/CHANGELOG.md:16).
requestInternal(): id, progress token, huỷ và timeout
pi/packages/mcp/src/client.tsdòng 404–443TypeScript
const id = this.nextRequestId++;const progressToken = options.onProgress ? id : undefined;const requestParams =	progressToken === undefined		? params		: { ...params, _meta: { ...(isObject(params?._meta) ? params._meta : {}), progressToken } };const message: JsonRpcRequest = {	jsonrpc: "2.0",	id,	method,	...(requestParams === undefined ? {} : { params: requestParams }),};return new Promise<unknown>((resolve, reject) => {	const entry: PendingRequest = {		resolve,		reject,		timeoutMs: options.timeoutMs ?? this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,		timer: undefined,		signal: options.signal,		// The spec forbids cancelling `initialize`.		onAbort: () =>			this.cancelPending(				id,				new McpAbortError(),				method !== "initialize",				String(options.signal?.reason ?? "Aborted"),			),		sendController: new AbortController(),		cancellable: method !== "initialize",		onProgress: options.onProgress,		progressToken,	};	this.pending.set(id, entry);	if (progressToken !== undefined) this.progressRequests.set(progressToken, id);	options.signal?.addEventListener("abort", entry.onAbort, { once: true });	this.armTimeout(id, entry);	transport		.send(message, { signal: entry.sendController.signal })		.catch((error) => this.cancelPending(id, error, false));});
Huỷ một request gửi notifications/cancelled cho server, trừ initialize mà đặc tả cấm huỷ. Timeout mặc định của gói là 30 giây (coding agent đặt 60 giây, hoặc timeout của server trong mcp.json), và mỗi thông báo progress của server đặt lại nó (client.ts:536–559), nên một tool chạy lâu mà vẫn báo tiến độ không bị cắt.

Kết quả tools/call được kiểm tra nới tay: content theo đặc tả là bắt buộc nhưng có server chỉ trả structuredContent, và từ bản đặc tả 2026-07-28 structuredContent có thể là mọi giá trị JSON, kể cả mảng và null (client.ts:144–153). Lỗi của tool MCP không phải lỗi giao thức: chúng nằm trong kết quả với isError: true.

stdio

StdioTransport chạy server như một tiến trình con, gửi mỗi message một dòng JSON và đọc stdout theo dòng, mỗi message tối đa 16 MiB (stdio.ts:181–207). Server được đặt trong process group riêng, vì server hay được chạy qua npx hay uvx: giết tiến trình con trực tiếp sẽ để lại server thật chạy mồ côi. Đóng theo đúng đặc tả:

StdioTransport.close(): đóng stdin, rồi SIGTERM, rồi SIGKILL cho cả nhóm tiến trình
pi/packages/mcp/src/transports/stdio.tsdòng 152–179TypeScript
async close(): Promise<void> {	if (this.closed) return;	this.closed = true;	const child = this.child;	if (!child) {		this.emitClose();		return;	}	if (child.exitCode !== null || child.signalCode !== null) {		child.stdin?.end();		return;	}	// Shutdown per the spec: close stdin and let the server exit, then SIGTERM, then SIGKILL.	const closeTimeoutMs = this.options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;	await new Promise<void>((resolve) => {		const timers: ReturnType<typeof setTimeout>[] = [];		child.once("close", () => {			for (const timer of timers) clearTimeout(timer);			// Children of the server that ignored stdin closing would otherwise outlive it.			killProcessTree(child, "SIGTERM");			resolve();		});		const grace = Math.min(STDIN_CLOSE_GRACE_MS, closeTimeoutMs);		timers.push(setTimeout(() => killProcessTree(child, "SIGTERM"), grace));		timers.push(setTimeout(() => killProcessTree(child, "SIGKILL"), grace + closeTimeoutMs));		child.stdin?.end();	});}
Một hook exit của tiến trình còn gửi SIGTERM cho mọi nhóm tiến trình đang sống nếu pi thoát mà không kịp đóng (stdio.ts:43–53).

Streamable HTTP

Mỗi message JSON-RPC là một POST. Server trả lời bằng JSON hoặc bằng một luồng SSE:

StreamableHttpTransport.send()
pi/packages/mcp/src/transports/streamable-http.tsdòng 231–266TypeScript
async send(message: JsonRpcMessage, { signal }: McpTransportSendOptions = {}): Promise<void> {	if (!this.started || this.closed) throw new McpConnectionClosedError();	const response = await this.authorizedFetch(		"POST",		{			headers: { accept: "application/json, text/event-stream", "content-type": "application/json" },			body: JSON.stringify(message),		},		signal,	);	await this.checkResponse(response);	this.captureSession(response);	if (!isJsonRpcRequest(message)) {		// Notifications and responses are acknowledged with 202 and carry no reply; ignore any body.		await discard(response);		// The server-to-client stream may only open once the session is initialized.		if ("method" in message && message.method === "notifications/initialized") this.startGetStream();		return;	}	if (response.status === 202 || response.status === 204) {		throw new McpHttpError(response.status, `MCP server accepted request ${message.method} without a response`);	}	const type = contentType(response);	if (type === "application/json") {		const body: unknown = await response.json();		for (const item of Array.isArray(body) ? body : [body]) this.emitMessage(parseJsonRpcMessage(item));		return;	}	if (type === "text/event-stream" && response.body) {		void this.consumeResponseStream(response.body, message.id);		return;	}	await discard(response);	throw new McpHttpError(response.status, `Unsupported MCP response content type: ${type ?? "missing"}`);}
Luồng GET từ server về client chỉ mở sau notifications/initialized, và được mở lại với backoff khi rớt (streamable-http.ts:428–454). Luồng SSE trả lời một request mà đứt trước khi có câu trả lời thì được nối lại bằng GET với Last-Event-ID, nếu server có gán id cho sự kiện (:377–420).

Mọi fetch đi qua authorizedFetch(): gắn Mcp-Session-Id, MCP-Protocol-Version và token, và khi gặp 401 hay 403 kèm insufficient_scope thì giao cho auth provider đúng một lần rồi thử lại (streamable-http.ts:287–317). Một chi tiết nhỏ cho thấy gói được viết cho nhiều runtime: fetch được gọi không có receiver, vì Cloudflare Workers từ chối fetch của nền tảng khi this là một object khác (“Illegal invocation”, :211–214).

pi-mcp: OAuth#

Server MCP từ xa thường được bảo vệ bằng OAuth: client phải tìm ra authorization server, tự định danh với nó, đưa người dùng qua trình duyệt để lấy mã, rồi đổi mã lấy token. Phần OAuth của pi-mcp được chuyển thể từ SDK TypeScript chính thức bản 1.29.0 (giấy phép MIT, README.md:112), bỏ Zod và các shim CORS, và bắt buộc kiểm tra issuer của authorization server (oauth/discovery.ts:1–5).

  1. 401Một request bị từ chối. Auth provider thử refresh token; không được thì ném McpOAuthAuthorizationRequiredError và kết nối chuyển sang trạng thái “needs sign-in”. Kết nối không bao giờ tự mở trình duyệt.
  2. DiscoveryĐọc protected resource metadata (RFC 9728) của server, lấy authorization server đầu tiên, rồi đọc metadata của nó (RFC 8414 hoặc OpenID Connect) và đối chiếu issuer. Có oauth.authServerMetadataUrl thì dùng thẳng tài liệu đó (discovery.ts:148–194).
  3. Định danh clientThông tin client đã lưu; nếu không có thì một Client ID Metadata Document (CIMD); nếu không thì đăng ký động (dynamic client registration).
  4. Uỷ quyềnURL uỷ quyền có PKCE S256, state và resource (flow.ts:170–200). Người dùng duyệt trong trình duyệt; callback về một server loopback, hoặc người dùng dán URL chuyển hướng khi trình duyệt ở máy khác.
  5. Đổi mãKiểm tra iss theo RFC 9207, đổi mã lấy token, lưu token kèm scope đã cấp.
Metadata của authorization server: thử từng URL, đối chiếu issuer
pi/packages/mcp/src/oauth/discovery.tsdòng 119–146TypeScript
export async function discoverAuthorizationServerMetadata(	authorizationServerUrl: string | URL,	options: { fetch?: McpFetch; protocolVersion?: string; skipIssuerValidation?: boolean; signal?: AbortSignal } = {},): Promise<AuthorizationServerMetadata | undefined> {	const fetch = options.fetch ?? globalThis.fetch;	for (const { url } of buildAuthorizationServerDiscoveryUrls(authorizationServerUrl)) {		const response = await fetchMetadata(			url,			fetch,			options.protocolVersion ?? LATEST_PROTOCOL_VERSION,			options.signal,		);		if (!response.ok) {			discard(response);			if (isDiscoveryMiss(response.status)) continue;			throw new Error(`HTTP ${response.status} loading authorization server metadata from ${url}`);		}		const metadata = parseAuthorizationServerMetadata(await response.json());		if (!options.skipIssuerValidation) {			const expected = String(authorizationServerUrl);			// URL parsing adds a trailing slash to bare origins, so compare without one on either side.			const trim = (value: string) => (value.endsWith("/") ? value.slice(0, -1) : value);			if (trim(metadata.issuer) !== trim(expected)) throw new OAuthIssuerMismatchError(expected, metadata.issuer);		}		return metadata;	}	return undefined;}
4xx và 502 nghĩa là “không ở đây”, nên discovery thử URL kế tiếp (/.well-known/oauth-authorization-server, rồi openid-configuration, :106–117). Issuer khác URL mong đợi thì ném OAuthIssuerMismatchError.
Định danh client: đã lưu, CIMD, hoặc đăng ký động
pi/packages/mcp/src/oauth/flow.tsdòng 354–372TypeScript
const stored = await provider.clientInformation();const clientDocument = stored ? undefined : provider.clientMetadataDocument?.(metadata);if (clientDocument) {	const url = new URL(clientDocument.url);	if (url.protocol !== "https:" || url.pathname === "/") throw new Error("Invalid OAuth client metadata URL");}let client = stored ?? (clientDocument && { client_id: clientDocument.url });if (!client) {	if (options.authorizationCode) throw new Error("OAuth client information is missing during code exchange");	if (!provider.saveClientInformation) throw new Error("OAuth client information cannot be persisted");	client = await registerClient(discovered.authorizationServerUrl, {		metadata,		clientMetadata: provider.clientMetadata,		scope,		fetch: options.fetch,		signal: options.signal,	});	await provider.saveClientInformation(client);}
Đăng ký động gửi kèm application_type suy từ redirect URI: native cho loopback và scheme tuỳ biến, không thì web (MCP SEP-837). Thiếu trường này, authorization server OpenID Connect mặc định là web và từ chối redirect URI http loopback (flow.ts:109–120).
RFC 9207: không bao giờ gửi mã của authorization server này cho server khác
pi/packages/mcp/src/oauth/flow.tsdòng 383–399TypeScript
if (options.authorizationCode) {	// RFC 9207: never send a code from another authorization server to this one. Without metadata there	// is no issuer to compare with, so any `iss` is rejected.	const iss = options.iss;	if (iss !== undefined || metadata?.authorization_response_iss_parameter_supported) {		if (iss !== metadata?.issuer) throw new OAuthIssuerMismatchError(metadata?.issuer, iss);	}	const tokens = await exchangeAuthorizationCode(discovered.authorizationServerUrl, {		...tokenOptions,		code: options.authorizationCode,		codeVerifier: await provider.codeVerifier(),		redirectUrl,	});	// A response without `scope` grants the requested scope (RFC 6749 §5.1). Recorded so a step-up can	// keep it. Callers pass the options of the authorization request, so `scope` is what was requested.	await provider.saveTokens(withScope(tokens, scope));	return "AUTHORIZED";
Có iss, hoặc metadata hứa sẽ có, thì nó phải khớp issuer. Không có metadata thì không có gì để so, nên mọi iss đều bị từ chối; lỗ hổng này được vá ở 1.2.0 (mcp/CHANGELOG.md:15).

Khi server đòi thêm scope (insufficient_scope), lần uỷ quyền mới xin scope bị thiếu cộng scope đã có, vì challenge có thể chỉ liệt kê phần thiếu, và một token chỉ có phần đó sẽ mất quyền mà token cũ có (flow.ts:309–318). adaptOAuthProvider() cho nhiều 401 đồng thời dùng chung một lần làm mới, và chỉ thử lại request nếu token của nó đã được thay: với refresh token xoay vòng, lần làm mới thứ hai bằng refresh token cũ sẽ thất bại và huỷ luôn grant mới (flow.ts:446–482).

Phía coding agent: credential và CIMD

Extension MCP lưu trạng thái OAuth trong ~/.pi/agent/mcp-auth.json, theo khoá là tên namespace cộng URL, để hai server cùng URL dưới hai tên đăng nhập hai tài khoản khác nhau (1.0.0):

Khoá credential: namespace và URL
pi/packages/coding-agent/src/extensions/mcp/oauth.tsdòng 126–133TypeScript
/** * Keys of a server's state: by name and URL, so servers sharing a URL keep separate accounts, and the * legacy key by URL alone, written by older versions. */function storeKeys(name: string, serverUrl: string): { key: string; legacyKey: string } {	const legacyKey = String(new URL(serverUrl));	return { key: `${mcpNamespace(name)}|${legacyKey}`, legacyKey };}
Trạng thái cũ chỉ theo URL được server đầu tiên đọc nó tiếp quản (oauth.ts:151–169). Việc làm mới token được giữ bằng một khoá file theo từng server, để hai tiến trình pi không cùng làm mới bằng một refresh token xoay vòng (:300–350).

Với "clientRegistration": "cimd" (1.0.1), pi không đăng ký mà tự định danh bằng một tài liệu metadata trên pi.dev. Tài liệu và redirect URI có thể phải riêng cho từng server:

clientMetadataDocument(): tài liệu chung, hoặc riêng cho server khi thiếu iss
pi/packages/coding-agent/src/extensions/mcp/oauth.tsdòng 245–271TypeScript
/** * pi's Client ID Metadata Document, for `clientRegistration: "cimd"`, chosen like Codex chooses its own. * The configuration ensures the default callback path. Without the `iss` parameter in authorization * responses (RFC 9207), the redirect URI and the document are specific to the MCP server, so a response * cannot be mixed up with one from another authorization server (RFC 9700 section 4.4.2.2). */function clientMetadataDocument(	serverUrl: string,	redirectUrl: string,	metadata: AuthorizationServerMetadata | undefined,): OAuthClientMetadataDocument {	if (		!metadata?.client_id_metadata_document_supported ||		!metadata.token_endpoint_auth_methods_supported?.includes("none")	) {		throw new Error(			'The authorization server does not support Client ID Metadata Documents for public clients; remove oauth.clientRegistration "cimd"',		);	}	if (metadata.authorization_response_iss_parameter_supported) {		return { url: `${CLIENT_METADATA_BASE_URL}/client.json`, redirectUrl };	}	const id = callbackId(serverUrl);	const redirect = new URL(redirectUrl);	redirect.pathname = `${CALLBACK_PATH}/${id}`;	return { url: `${CLIENT_METADATA_BASE_URL}/${id}/client.json`, redirectUrl: redirect.href };}
Nếu authorization server không gửi iss trong phản hồi uỷ quyền, pi dùng tài liệu https://pi.dev/oauth/<id>/client.json và đường callback /callback/<id> riêng cho server MCP đó, để một phản hồi không thể bị nhầm với phản hồi của authorization server khác (RFC 9700 mục 4.4.2.2). Callback server từ chối phản hồi đến sai đường (callback.ts:89–102).

Toàn bộ quá trình đăng nhập, gồm chọn cổng callback giữ đúng redirect URI đã đăng ký, chờ callback hoặc URL dán vào, giới hạn 15 giây cho mỗi request tới authorization server, nằm trong signInMcpServer() (oauth.ts:468–558).

Extension MCP có sẵn#

Cấu hình

Server đến từ ~/.pi/agent/mcp.json và, khi dự án đã được tin cậy (project trust), từ .pi/mcp.json; định dạng mcpServers giống các client MCP khác nên cấu hình chép sang được (mcp/config.ts:1–30). Entry của dự án cùng tên thay entry toàn cục, trừ khi nó không có command, url hay type: khi đó nó chỉ ghi đè ba khoá (1.0.1):

Override của dự án: chỉ enabled, exposure, toolExposure
pi/packages/coding-agent/src/extensions/mcp/config.tsdòng 108–122TypeScript
for (const [name, value] of Object.entries(parsed.mcpServers ?? {})) {	if (scope === "project" && isRecord(value) && isOverride(value)) {		const base = servers.get(name);		const extra = Object.keys(value).filter((key) => !OVERRIDE_KEYS.includes(key));		if (!base) {			errors.push(`${path}: server "${name}" needs "command" or "url", or a global server to override`);		} else if (extra.length > 0) {			errors.push(`${path}: server "${name}": an override can only set ${OVERRIDE_KEYS.join(", ")}`);		} else {			const config = validateMcpServerConfig(name, { ...base.config, ...value });			if (typeof config === "string") errors.push(`${path}: ${config}`);			else servers.set(name, { ...base, config, override: path });		}		continue;	}
Phần còn lại của entry toàn cục được giữ, kể cả credential mà dự án không tự đặt được. "auth": { "provider": ... }, gửi token của một provider đã /login làm bearer token, bị cấm trong file của dự án, để một repository không chọn được nơi credential của bạn bị gửi tới (config.ts:134–137).

Exposure của server MCP có bốn giá trị, mặc định codemode:

McpExposure
pi/packages/coding-agent/src/core/mcp-servers.tsdòng 43–52TypeScript
/** * - `codemode`: tools are callable from codemode scripts but neither declared to the model nor *   listed in the codemode description, which lists only the server's namespace. Scripts find them *   with `searchTools()`. `codemode-deferred` is accepted as an alias. * - `deferred`: not declared to the model until the `tool_search` tool loads them; the model then *   calls them directly. Does not need codemode. * - `direct`: tools are declared to the model like any other tool (and callable from codemode). * - `hidden`: tools are registered but unreachable. */export type McpExposure = "codemode" | "deferred" | "direct" | "hidden";
Ánh xạ sang ToolExposure của pi: codemode và deferred đều thành deferred, chỉ khác ở chỗ extension bật codemode hay tool_search để với tới chúng (mcp/tools.ts:40–46). toolExposure ghi đè cho từng tool, theo tên chính xác hoặc mẫu * (mcp-servers.ts:233–242).

Tên tool và đăng ký

createMcpToolName(): mcp__server__tool, an toàn cho cả provider lẫn script
pi/packages/coding-agent/src/extensions/mcp/tools.tsdòng 78–93TypeScript
/** * `mcp__<server>__<tool>`, sanitized and shortened with a hash suffix when too long. Like Codex, * everything but `[A-Za-z0-9_]` becomes `_`, so the name is also the identifier codemode scripts * call it by. `isTaken` reports names used by a different MCP tool: sanitizing can map two tools to * one name (`a-b` and `a_b`), which then get the hash suffix. */export function createMcpToolName(	server: string,	tool: string,	isTaken: (name: string) => boolean = () => false,): string {	const name = `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_]/g, "_");	if (name.length <= MAX_TOOL_NAME_LENGTH && !isTaken(name)) return name;	const hash = createHash("sha256").update(`${server}\0${tool}`).digest("hex").slice(0, 8);	return `${name.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;}
Mọi ký tự ngoài [A-Za-z0-9_] thành _, như Codex, nên tên tool cũng chính là identifier mà script gọi. Tên dài quá 64 ký tự, hoặc trùng sau khi làm sạch (read-file và read_file), được thêm hậu tố hash. Server có tên chỉ khác nhau ở - và _ bị từ chối, vì chúng sẽ chung một namespace (config.ts:128–133).
registerTools(): mỗi tool của server thành một tool của pi
pi/packages/coding-agent/src/extensions/mcp/index.tsdòng 387–397, 418–424, 443–452TypeScript
const registerTools = (connection: McpServerConnection) => {	const server = connection.entry.name;	const entry = findServer(server)?.entry ?? connection.entry;	const description = entry.config.description?.trim();	const namespace = {		name: mcpNamespace(server),		// `describeNamespace("docs")` finds `mcp__docs`.		aliases: [server],		...(description ? { description } : {}),		...(connection.instructions ? { instructions: connection.instructions } : {}),	};	for (const tool of connection.tools) {		const definition = createMcpToolDefinition({			server,			tool,			name: assignName(tool.name, `${server}\0${tool.name}`),			exposure: getMcpToolExposure(entry.config, tool.name),			namespace,		definitions.set(definition.name, definition);		pi.registerTool(definition);	}	serverTools.set(server, current);	// Tools cannot be unregistered, so tools the server dropped are re-registered as hidden. When	// the server offers them again they are registered with their configured exposure above.	for (const name of previous) {		const definition = definitions.get(name);		if (!current.has(name) && definition) pi.registerTool({ ...definition, exposure: "hidden" });	}
Namespace có alias là tên server, nên describeNamespace("docs") tìm được mcp__docs, và mang instructions của server. Tool mà server bỏ đi (sau notifications/tools/list_changed) được đăng ký lại với exposure hidden.

Kết quả MCP được dịch cho hai người đọc khác nhau:

convertMcpResult(): nội dung cho mô hình, CallToolResult nguyên vẹn cho script
pi/packages/coding-agent/src/extensions/mcp/tools.tsdòng 265–286TypeScript
/** Convert an MCP result. `isError` results become error results that keep the structured result. */export async function convertMcpResult(	server: string,	tool: string,	result: CallToolResult,	options: ConvertMcpResultOptions = {},): Promise<AgentToolResult<McpToolDetails>> {	// Without content blocks, toLlmContent falls back to the structured content as JSON.	const converted: (TextContent | ImageContent)[] =		result.content.length > 0 ? await toModelContent(server, result.content, options) : toLlmContent(result);	if (result.isError && textOf(converted) === "") {		converted.push({ type: "text", text: `MCP tool ${server}/${tool} returned an error` });	}	const { content, fullOutputPath } = await limitMcpContent(converted, options.saveOutput);	const { _meta: _ignored, ...scriptResult } = result;	return {		content,		details: { server, tool, ...(fullOutputPath ? { fullOutputPath } : {}) },		structuredContent: scriptResult as unknown as JsonValue,		...(result.isError ? { isError: true } : {}),	};}
Mô hình nhận text và ảnh; text quá 20 KB bị cắt giữa theo định dạng của Codex và bản đầy đủ ghi ra file tạm (tools.ts:176–201). Script nhận cả CallToolResult trừ _meta, không bị cắt, làm structuredContent. isError là lỗi với mô hình, nhưng trong script lời gọi vẫn resolve.

Kết nối nền và chờ đúng server

Khi session bắt đầu, extension đọc cấu hình, bật codemode hoặc tool_search từ cấu hình (trước khi server nào kết nối), rồi chỉ nạp client MCP sau một vòng event loop, để lần vẽ đầu không phải chờ, và kết nối mọi server ở nền (mcp/index.ts:1114–1151). Prompt đầu chỉ chờ, tối đa 10 giây, những server có tool direct, vì tool của chúng phải được khai báo trong request đầu (:1153–1174). Các server khác được chờ đúng lúc cần, nhờ reachesTools:

Hook tool_call: chờ những server mà lời gọi có thể chạm tới
pi/packages/coding-agent/src/extensions/mcp/index.tsdòng 1186–1209TypeScript
// A tool that reaches other tools (`reachesTools`) waits for the servers it may reach, so their// tools are registered before it runs: a codemode script for the servers it names, or for every// server when it searches or enumerates tools, and tool_search for every server. The resource// tools reach every server, so they wait for all of them. Direct MCP calls wait only for their// own server, including a reconnect still closing its old transport.pi.on("tool_call", async (event, ctx) => {	const tool = pi.getAllTools().find((candidate) => candidate.name === event.toolName);	if (!tool) return;	// Readiness can be pending while the display state is still connected: reconnect first	// closes the old transport, then opens a new one. Settled promises return immediately.	const readyServers = servers.filter((server) => isEnabled(server) && server.ready);	if (readyServers.length === 0) return;	let waiting: McpServer[] = [];	const reach = tool.reachesTools?.(event.input);	if (reach !== undefined) {		waiting = readyServers.filter((server) => reachesServer(reach, server.entry.name));	} else if (RESOURCE_TOOL_NAMES.has(tool.name)) {		waiting = readyServers;	} else {		const owner = toolOwners.get(event.toolName)?.split("\0", 1)[0];		waiting = readyServers.filter((server) => server.entry.name === owner);	}	await waitForServers(waiting, ctx.signal);});
Script codemode chờ các server nó nêu tên, hoặc mọi server nếu nó tìm kiếm hay liệt kê tool; tool_search và tool resource chờ mọi server; lời gọi MCP trực tiếp chỉ chờ server của nó.

Mỗi server là một McpServerConnection, kết nối lại lười biếng khi một lời gọi thấy kết nối đã mất:

connectOnce(): client tên pi, roots là thư mục làm việc
pi/packages/coding-agent/src/extensions/mcp/runtime.tsdòng 359–365, 377–397TypeScript
private async connectOnce(): Promise<McpClient> {	const client = new McpClient({		name: "pi",		version: VERSION,		requestTimeoutMs: this.timeoutMs,		roots: [{ uri: pathToFileURL(this.cwd).href, name: basename(this.cwd) }],	});	try {		transport = this.createTransport(this.entry, this.cwd, this.authProvider);		await client.connect(transport);		client.onNotification("notifications/tools/list_changed", () => {			void this.refreshTools(client);		});		const stdio = transport instanceof StdioTransport ? transport : undefined;		client.onClose(() => this.handleClientClose(client, stdio));		// Servers without the tools capability (prompts or resources only) do not answer tools/list.		const tools = client.serverCapabilities?.tools ? await client.listTools() : [];		if (this.closed) throw new Error("shut down while connecting");		if (client.connectionState !== "connected") throw new Error("connection closed during setup");		this.client = client;		this.tools = tools;		this.hasResources = client.serverCapabilities?.resources !== undefined;		this.instructions = client.instructions?.trim() || undefined;		this.state = "connected";		this.error = undefined;		this.onTools(this);		this.changed();		return client;
Server không có capability tools không bị hỏi tools/list; resource không được liệt kê lúc kết nối, chỉ ghi nhận server có resource, vì liệt kê hết có thể rất lâu (1.2.0). Lời gọi tool không được thử lại sau lỗi HTTP tạm thời vì server có thể đã chạy nó; đọc resource thì được thử lại một lần, và session hết hạn (404) thì thử lại một lần trên session mới (runtime.ts:274–301).

Mục prompt, resource, dòng lệnh

Server có tool codemode hoặc deferred được liệt kê trong mục mcp_servers của system prompt, mỗi server một dòng gồm namespace, cách với tới và một câu tóm tắt, cả mục tối đa 4.096 ký tự (mcp/index.ts:155–221). Đây là nơi mô hình biết các server tồn tại mà không phải tìm, vì cả mô tả codemode lẫn tool_search đều không liệt kê chúng. Server có resource được với tới qua ba tool mang đúng tên mà Codex và opencode dùng, list_mcp_resources, list_mcp_resource_templates và read_mcp_resource, để mô hình đã được huấn luyện trên chúng dùng nguyên (resources.ts:1–11).

pi mcp add|remove|list|login|logout chạy ngoài session, không nạp extension. Đầu file ghi rõ đối tượng: agent chạy nó qua bash để cấu hình server, kiểm tra một mcp.json vừa viết và khởi động đăng nhập OAuth, còn người dùng chỉ việc duyệt trong trình duyệt (mcp/cli.ts:1–6). pi mcp list thoát với mã 1 khi có entry sai hoặc server không kết nối được. Session đang chạy nhận credential mới ở lượt kế tiếp, vì hook turn_start so token đã lưu với lúc server cần đăng nhập (mcp/index.ts:1211–1214). Extension cũng thêm server được bằng pi.registerMcpServer(); core chỉ kiểm tra và lưu đăng ký, việc kết nối thuộc về extension MCP (mcp-servers.ts:1–6).

Tool deferred tồn tại nhưng mô hình không biết tên chúng. Có hai cách tìm, dùng chung một bộ xếp hạng: searchTools() trong script codemode, và tool tool_search cho mô hình gọi tool trực tiếp. Văn bản được đánh chỉ mục cho mỗi tool gồm tên, tên với _ thay bằng dấu cách, mô tả, mô tả và tên thuộc tính trong schema, cùng tên, mô tả và instructions của namespace:

Một tool như bộ xếp hạng nhìn thấy
pi/packages/coding-agent/src/extensions/tool-search/tool.tsdòng 103–116TypeScript
/** * Search text of a tool: the name, the name with `_` * as spaces, the description, schema descriptions and property names, and the namespace with its * description and instructions. */export function createToolSearchDocument(	tool: Pick<ToolInfo, "name" | "description" | "parameters">,	namespace?: ToolNamespace,): ToolSearchDocument {	const parts = [tool.name, tool.name.replaceAll("_", " "), tool.description];	schemaText(tool.parameters, parts);	if (namespace) parts.push(namespace.name, namespace.description ?? "", namespace.instructions ?? "");	return { name: tool.name, text: parts.filter((part) => part.trim()).join(" ") };}
Vì vậy description của một server MCP trong mcp.json còn có tác dụng xếp hạng tool của nó. Từ được tách theo ranh giới camelCase và mọi ký tự ngoài chữ cái và chữ số, bỏ stop word, đưa về số ít một cách đơn giản (tool-search/tool.ts:63–80).
Bm25Ranker: Okapi BM25 với k1 = 1,2, b = 0,75
pi/packages/coding-agent/src/extensions/tool-search/tool.tsdòng 118–157TypeScript
/** Okapi BM25 with the usual parameters. Ties keep document order. */export class Bm25Ranker implements ToolRanker {	private readonly k1: number;	private readonly b: number;	constructor(options: { k1?: number; b?: number } = {}) {		this.k1 = options.k1 ?? 1.2;		this.b = options.b ?? 0.75;	}	rank(query: string, documents: readonly ToolSearchDocument[], limit: number): ToolSearchMatch[] {		const queryTerms = [...new Set(tokenize(query))];		if (queryTerms.length === 0 || documents.length === 0 || limit <= 0) return [];		const termCounts = documents.map((document) => {			const counts = new Map<string, number>();			for (const term of tokenize(document.text)) counts.set(term, (counts.get(term) ?? 0) + 1);			return counts;		});		const lengths = termCounts.map((counts) => [...counts.values()].reduce((sum, count) => sum + count, 0));		const averageLength = lengths.reduce((sum, length) => sum + length, 0) / documents.length || 1;		const idf = new Map(			queryTerms.map((term) => {				const frequency = termCounts.filter((counts) => counts.has(term)).length;				return [term, Math.log(1 + (documents.length - frequency + 0.5) / (frequency + 0.5))] as const;			}),		);		const matches: ToolSearchMatch[] = [];		documents.forEach((document, index) => {			let score = 0;			for (const term of queryTerms) {				const count = termCounts[index].get(term);				if (!count) continue;				const norm = this.k1 * (1 - this.b + (this.b * lengths[index]) / averageLength);				score += (idf.get(term) ?? 0) * ((count * (this.k1 + 1)) / (count + norm));			}			if (score > 0) matches.push({ name: document.name, score });		});		return matches.sort((a, b) => b.score - a.score).slice(0, limit);	}}
Interface ToolRanker ghi rõ đây là bản hiện tại: một bộ xếp hạng lai có embedding có thể thay nó (:34–37).

tool_search chỉ xếp hạng tool có thể tìm (exposure codemode hoặc deferred) mà chưa bật, rồi bật các kết quả:

searchAndLoad(): nạp là bật tool, không có gì khác
pi/packages/coding-agent/src/extensions/tool-search/tool.tsdòng 191–209TypeScript
/** * Rank the searchable tools that are not active yet and activate the matches, so the next model * call declares them. Activation is recorded in the transcript like any tool change. */function searchAndLoad(	tools: NonNullable<ToolSearchToolOptions["tools"]>,	query: string,	limit: number,): ToolSearchResultTool[] {	const active = tools.getActiveTools();	const candidates = tools.getAllTools().filter((tool) => isSearchable(tool.exposure) && !active.includes(tool.name));	const documents = candidates.map((tool) => createToolSearchDocument(tool, tool.namespace));	const matches = new Bm25Ranker().rank(query, documents, limit);	if (matches.length > 0) tools.setActiveTools([...active, ...matches.map((match) => match.name)]);	return matches.map((match) => ({		name: match.name,		description: candidates.find((tool) => tool.name === match.name)?.description ?? "",	}));}
Tool được đăng ký tắt sẵn với exposure model-only và reachesTools: () => "all" (:217–244, index.ts:12–16). Nó tắt theo mặc định; extension MCP bật nó khi có server dùng exposure deferred, hoặc người dùng thêm "+tool_search" vào defaultTools (docs/cli.md:180–182).

Vì sao cách nạp này giữ được prompt cache. Bài “Prompt Caching In Agents” chỉ ra rằng tên, mô tả và schema của tool là đầu vào của mô hình, nằm ở phần đầu prompt; thêm, bớt, sửa hay đổi thứ tự tool là sửa phần đầu, và trên hầu hết mô hình điều đó làm mất cache của cả hội thoại phía sau. Chỉ thay đổi thuần cộng, được neo vào một vị trí trong transcript, mới giữ được prefix (Prompt Caching In Agents). Thiết kế của tool_search làm đúng như vậy ở bốn điểm:

  1. Nạp là bậtKhông có cơ chế riêng: sau setActiveTools(), ở lượt kế tiếp vòng lặp nối một system message toolsAdded sau kết quả của tool_search, và pi-ai gửi nó tại chỗ cho provider hỗ trợ (trace ở chương Pi 1.0).
  2. Mô tả không đổiMô tả tool_search không liệt kê tool hay server nào, nên nó giống hệt nhau trước và sau khi server kết nối hay tool được nạp (tool-search/tool.ts:211–215).
  3. Codemode không bị khai báo lạiMô tả codemode liệt kê theo exposure chứ không theo bộ đang bật, nên một lần nạp không làm đổi khai báo của codemode (codemode/tool.ts:317–318).
  4. Nạp một lần cho cả nhánhLần nạp nằm trong transcript, nên sau resume, /tree hay fork, tool vẫn được khai báo trên nhánh đó mà không phải tìm lại (docs/mcp.md:226).

Với provider chỉ ở mức "none", như Google, một lần nạp vẫn viết lại danh sách tool và làm trượt cache. Đó là giới hạn của API, không phải của thiết kế; trên các provider ấy, script codemode gọi tool deferred là cách duy nhất không đổi khai báo, vì lời gọi trong script không cần tool đang bật.

Trace: một script gọi song song hai tool MCP#

Kịch bản: hai server MCP linear và github, exposure mặc định codemode, đã kết nối. Từ một lần chạy describeNamespace() trước, mô hình biết tên hai tool cần dùng. Người dùng hỏi: “Có issue auth nào đang mở, và có PR nào liên quan không?”. Tên tool, đối số và hình dạng structuredContent dưới đây là giả định: chúng do server MCP định nghĩa, không phải do pi.

Ví dụ minh hoạ: script mô hình gửi trong đối số code của tool codemode
// @options: {"timeout_ms": 60000}
const [issues, prs] = await Promise.allSettled([
  tools.mcp__linear__list_issues({ label: "auth", state: "open" }),
  tools.mcp__github__search_pull_requests({ query: "auth is:open" }),
]);
const lines = [];
if (issues.status === "fulfilled" && !issues.value.isError) {
  for (const issue of issues.value.structuredContent.issues) lines.push(`${issue.identifier}: ${issue.title}`);
} else {
  lines.push(`Linear lỗi: ${issues.status === "rejected" ? issues.reason.message : "isError"}`);
}
if (prs.status === "fulfilled" && !prs.value.isError) {
  lines.push(`${prs.value.structuredContent.total_count} PR liên quan`);
}
return lines.join("\n");
Hai lời gọi bắt đầu trước khi script await, nên chúng chạy song song. Kết quả MCP có isError vẫn resolve trong script, nên script tự kiểm tra.
  1. Lời gọi codemodeTrên mô hình OpenAI hỗ trợ tool ngữ pháp, script đến như văn bản thô theo ngữ pháp Lark; ở provider khác nó là chuỗi trong { code }. Vòng lặp kiểm tra đối số rồi gọi hook tool_call như mọi lời gọi của mô hình.
  2. Chờ serverreachesTools({ code }) không thấy global khám phá nên trả các từ của script; mcp__linear__list_issues chứa mcp__linear và mcp__github__search_pull_requests chứa mcp__github, nên hook của extension MCP chờ đúng hai server đó (mcp/index.ts:227–231). Chúng đã kết nối, nên promise đã settle trả về ngay.
  3. executeCodemode()Tách dòng options (timeout_ms 60.000), lấy các tool gọi được từ ctx.tools. Hai tool MCP có trong đó dù không được khai báo, vì exposure deferred luôn gọi được (agent-session.ts:1582). Xin một chỗ worker, tạo CodemodeSandbox với giới hạn 256 MB, đọc store từ nhánh.
  4. Worker và VMworkerLauncher() tạo cờ ngắt và worker; worker gọi startVm(), chạy prelude (đóng băng built-in, dựng tools), bọc script vào hàm async và chạy.
  5. Hai message callMỗi tools.*(...) chuyển đối số thành JSON, lấy id 1 và 2, gọi bridge("call", ...); worker chuyển chúng thành { type: "call", id, target: "tool", name, args }. Script chờ ở Promise.allSettled; stalled() thấy hai lời gọi đang chờ nên không kết thúc script.
  6. handleCall() chạy song songHost nhận hai message và chạy hai handleCall() mà không chờ nhau. Mỗi cái ghi một dòng CodemodeNestedCall trạng thái running (giao diện hiện ngay) rồi gọi ctx.executeTool(name, args, { signal }).
  7. Pipeline toolNestedToolCallRunner đặt id <id lời gọi codemode>/1 và /2, phát tool_execution_start có parentToolCallId. Không lời gọi nào phải xếp hàng độc quyền (agent chạy tool song song, tool MCP không khai báo sequential). runToolCall() kiểm tra đối số theo inputSchema của tool MCP và chạy hook; một extension phân quyền ở đây thấy cả hai lời gọi kèm parentToolCallId.
  8. Tới serverexecute() của tool MCP lấy McpServerConnection và gọi client.callTool() với timeout của server và callback progress. Hai request tools/call đi qua hai transport khác nhau, mỗi request có id JSON-RPC riêng.
  9. Kết quả vềconvertMcpResult() tạo nội dung cho mô hình (không ai dùng ở đây) và structuredContent là cả CallToolResult. afterToolCall chạy hook tool_result. Runner ghi bản ghi, phát tool_execution_end. toScriptValue() trả structuredContent vì tool MCP luôn có output schema.
  10. Trở lại VMHost gửi { type: "result", id, ok: true, payload: JSON }; worker gọi settle(), promise trong VM resolve, VM chạy các job tới khi script chờ lại hoặc xong. Khi cả hai đã về, script dựng lines và return.
  11. Kết thúcPrelude báo done với giá trị trả về dạng JSON và các lần ghi store (ở đây không có). finish() yêu cầu worker dừng: bật cờ ngắt, terminate(); chỗ worker được trả khi worker thoát.
  12. Kết quả toolexecuteCodemode() nối giá trị trả về như một item text, kiểm tra trần 10.000 token, thêm dòng đầu Script completed. AgentSession gắn nestedCalls vào message kết quả lúc nó bắt đầu được phát, rồi lưu vào session.
Ví dụ minh hoạ: thứ mô hình nhận ở request sau
toolResult codemode:
  Script completed
  Wall time 1.2 seconds
  Output:
  ENG-412: Refresh token bị mất khi huỷ request
  ENG-430: Đăng nhập treo khi cổng callback bận
  2 PR liên quan
Không có JSON nào của Linear hay GitHub; chỉ ba dòng script chọn trả về.
Ví dụ minh hoạ: bản ghi lưu trong session
"nestedCalls": {
  "calls": [
    { "id": "call_7/1", "name": "mcp__linear__list_issues",
      "arguments": { "label": "auth", "state": "open" },
      "status": "ok", "durationMs": 412 },
    { "id": "call_7/2", "name": "mcp__github__search_pull_requests",
      "arguments": { "query": "auth is:open" },
      "status": "ok", "durationMs": 655 } ],
  "complete": true }
Có tên, đối số, trạng thái, thời gian; không có kết quả. Không gửi cho mô hình.

Nếu kết nối tới server GitHub đứt giữa chừng, McpClient từ chối request đang chờ và execute() của tool MCP ném lỗi, runToolCall() biến nó thành kết quả lỗi không có structuredContent, toScriptValue() ném Error với văn bản lỗi, và trong script prs.status là "rejected". Script vẫn trả về các dòng của Linear; lời gọi codemode vẫn thành công; bản ghi của lời gọi thứ hai mang status: "error" và error đã cắt ngắn. Mô hình thấy đúng những gì script quyết định nói về lỗi đó.

Quyết định thiết kế#

Script chạy ở phía harness, trong một VM riêng. Phương án hiển nhiên là để mô hình viết script Node rồi chạy bằng bash. Nhưng khi đó tool của pi, server MCP đã đăng nhập, classifier với credential của session đều nằm ngoài tầm với, hoặc phải được phơi ra qua mạng. Codemode đặt script cạnh vòng lặp, nơi các thứ đó đã có, và bọc nó bằng một VM QuickJS mà khả năng duy nhất là gọi tool qua một cầu nối. Pi không có hệ thống phân quyền (chương Lịch sử); VM không thêm quyền gì, nó chỉ bảo đảm script không đi đường nào khác ngoài tool, và mọi lời gọi tool đi qua đúng pipeline có hook như lời gọi của mô hình.

Mỗi lần chạy một worker mới. Dựng worker và VM tốn khoảng 20 ms mỗi lần; dùng lại VM sẽ tiết kiệm khoản đó, nhưng một script quay vô hạn hay phá trạng thái sẽ đầu độc lần chạy sau. Worker mới cho phép kết thúc bằng terminate() và không để lại gì (host.ts:12–19). Giá phải trả là giới hạn số worker sống đồng thời và cờ ngắt cho Bun.

Cầu nối chỉ chở chuỗi JSON. Không có object nào được chia sẻ giữa VM và host; prelude tự JSON.parse phía VM, host tự kiểm tra phía host và coi payload hỏng là lỗi sandbox. Đóng băng built-in và Proxy trên tools là hai lớp bảo vệ thêm cho chính prelude, không phải cho host.

Chỉ output tới mô hình. Kết quả lồng nhau không vào transcript; transcript chỉ giữ một bản ghi có giới hạn. Đây là nguồn tiết kiệm token chính của codemode, và là lý do output có ngân sách mặc định 10.000 token cùng file tạm cho phần thừa. Trạng thái giữa các lần chạy, store(), cũng đi vào session dưới dạng entry, không phải file, để nó theo đúng nhánh của cây.

Khai báo TypeScript có ngân sách, cộng tìm kiếm. Liệt kê mọi tool trong mô tả thì quay lại đúng vấn đề MCP năm 2025. Pi liệt kê trong một ngân sách, giữ mô tả ổn định, và cho script bốn cách tìm phần còn lại (ALL_TOOLS, searchTools(), describeTool(), describeNamespace()).

Client MCP tự viết. pi-mcp không phụ thuộc SDK chính thức; phần OAuth được chuyển thể từ SDK, bỏ Zod, thêm kiểm tra issuer. Gói nhỏ, chạy được trên Cloudflare Workers, và các bản vá bảo mật như RFC 9207 nằm trong code của chính dự án. Tradeoff: tự theo kịp đặc tả, và một số phần của MCP (sampling, tasks, batch) chưa có.

Mặc định là gián tiếp. Tool MCP mặc định chỉ gọi được từ script; muốn mô hình gọi thẳng thì phải chọn deferred (qua tool_search) hoặc direct. Server kết nối ở nền, và chỉ server direct mới làm prompt đầu phải chờ.

Theo quy ước của Codex khi có lợi. Tên tool mcp__server__tool được làm sạch như Codex, kết quả dài bị cắt theo định dạng của Codex, tool resource mang đúng tên của Codex và opencode, CIMD chọn tài liệu như Codex chọn. Mô hình đã được huấn luyện trên các quy ước đó thì không phải học quy ước mới.

Built-in thay được. Một extension đăng ký /mcp, codemode hay tool_search thay chỗ bản có sẵn, và extension MCP chỉ bật một codemode hay tool_search thay thế nếu nó khai báo reachesTools (docs/mcp.md:264–268). Tính năng vào lõi nhưng không khoá người dùng vào cách cài của lõi.

Cạm bẫy và trường hợp biên#

  • Không có deadline mặc định. Trong coding agent, script không có timeout_ms chạy cho tới khi xong hoặc bị huỷ; một tool treo giữ script treo (docs/codemode.md:16).
  • Lời gọi là thật. Script lỗi giữa chừng không hoàn tác những gì đã chạy; lời gọi còn dở khi script kết thúc bị huỷ, promise không được await bị bỏ (docs/codemode.md:18).
  • Lỗi MCP không reject. Kết quả isError vẫn resolve trong script; cũng vậy với bash có mã thoát khác 0, vì tool có output schema trả object kể cả khi lỗi. Tool không có output schema thì reject khi lỗi.
  • typeof tools.x ném lỗi vì Proxy; dùng "x" in tools.
  • Store chỉ lưu khi script thành công, theo nhánh, tối đa 262.144 ký tự JSON mỗi giá trị; không dùng để giữ ảnh.
  • Hai worker cho cả tiến trình. Subagent chạy trong cùng tiến trình mà cũng dùng codemode có thể phải chờ tới 60 giây rồi thất bại mà chưa chạy (docs/codemode.md:228). Trên Bun, built-in không thăm dò cờ ngắt có thể chạy tiếp sau deadline.
  • Dòng // @options: phải là dòng đầu; ở chỗ khác lời gọi thất bại trước khi script chạy.
  • Tên MCP bị chuẩn hoá. - thành _; tên dài hay trùng có hậu tố hash, nên đừng đoán tên, hãy tìm. Hai server chỉ khác nhau ở -/_ không cùng tồn tại được.
  • Cấu hình dự án cần trust, và auth.provider chỉ được đặt trong file toàn cục. OAuth chỉ áp dụng cho server HTTP không có header Authorization (runtime.ts:82–87).
  • Lời gọi tool MCP không được thử lại sau lỗi mạng tạm thời, vì server có thể đã thực hiện nó.
  • SDK không nạp built-in extension. Session dựng bằng SDK phải tự thêm extension MCP, codemode và tool-search (docs/mcp.md:270–272).
  • Tài liệu và code lệch nhẹ. docs/mcp.md nói pi bật codemode “when a server with codemode exposure connects” (docs/mcp.md:204); ở v1.4.0, code bật nó từ cấu hình ngay khi session bắt đầu, trước khi server nào kết nối (mcp/index.ts:501–503).

Đọc code ở đâu#

#FileĐọc để hiểu
1coding-agent/docs/codemode.mdScript nhìn thấy gì: global, giá trị trả về, store, models, giới hạn. Đọc trước code.
2codemode/src/types.tsAPI của gói: tool, kết quả, lỗi, các tuỳ chọn, lần chạy tất định.
3runtime/execution.ts, protocol.ts, host.ts, worker.tsPhía host, giao thức, worker launcher.
4runtime/vm.ts, prelude-source.tsVM, cầu nối, chế độ tất định; mọi thứ script chạm tới.
5codemode/src/declarations.tsJSON Schema thành TypeScript, tag ràng buộc.
6inline.ts, remote.ts, cloudflare.tsChạy ngoài Node: ngân sách ngắt, RPC, Durable Object.
7extensions/codemode/tool.tsĐịnh nghĩa tool, mô tả và ngân sách, prepareLoadout, reachesTools.
8extensions/codemode/execute.tsMột lần chạy: worker, tool cho sandbox, output, store, models.*, global khám phá.
9core/nested-tool-calls.ts, agent-session.ts:726–764Lời gọi lồng nhau qua pipeline tool, bản ghi nestedCalls.
10extensions/tool-search/tool.tsBM25 và tool_search; ngắn, đọc hết.
11mcp/README.md, mcp/src/client.tsPhạm vi giao thức, vòng đời client, huỷ, progress.
12transports/stdio.ts, streamable-http.tsĐóng khung, nhóm tiến trình, SSE, nối lại, uỷ quyền.
13oauth/discovery.ts, oauth/flow.tsDiscovery, đăng ký, PKCE, RFC 9207, step-up, làm mới dùng chung.
14coding-agent/docs/mcp.md, core/mcp-servers.ts, extensions/mcp/config.tsCấu hình, exposure, kiểm tra hợp lệ, override của dự án.
15extensions/mcp/index.ts, tools.ts, runtime.tsĐăng ký tool, mục mcp_servers, chờ server, chuyển đổi kết quả, kết nối.
16extensions/mcp/oauth.ts, resources.ts, cli.tsCredential, CIMD, đăng nhập; tool resource; pi mcp.

Tóm tắt#

  • MCP vào lõi ở 0.99.0 vì hai vấn đề cũ đã có lời giải: chi phí ngữ cảnh được xử lý bằng exposure và nạp tool hoãn lại, còn việc ghép lời gọi được xử lý bằng codemode.
  • pi-codemode chạy script của mô hình trong một VM QuickJS/WASM mới cho mỗi lần chạy, trong một worker thread; host và VM chỉ trao đổi chuỗi JSON; prelude đóng băng built-in, dựng tools trên một Proxy và có trần cho output và store. Gói còn có lần chạy tất định cho Pi Durable và các sandbox cho Cloudflare.
  • Tool codemode của coding agent chỉ dùng API chung của extension: prepareLoadout dựng mô tả trong một ngân sách mà không bao giờ liệt kê tool deferred, ctx.executeTool() chạy lời gọi lồng nhau qua đúng pipeline tool với parentToolCallId, và chỉ output của script tới mô hình.
  • pi-mcp là client độc lập với stdio, Streamable HTTP và OAuth đã gia cố (issuer, RFC 9207, CIMD, step-up). Extension MCP kết nối ở nền, đặt tên mcp__server__tool, mặc định chỉ cho script gọi, liệt kê server trong mục mcp_servers, và chờ đúng server mà một lời gọi cần.
  • tool_search xếp hạng BM25 các tool chưa khai báo và nạp chúng bằng cách bật chúng; nhờ system message nối thêm, mô tả cố định và bản ghi trong transcript, việc nạp giữ được prompt cache trên provider hỗ trợ.

16Chord: state, delta, contract và RPC#

Chương này đọc @earendil-works/chord (gọi tắt Chord), package mà Pi Durable dựa vào để giữ state của document, truyền huỷ (cancellation) và span telemetry, và ghi SQLite. Không có bài blog nào viết riêng về Chord, nên nguồn chính là tài liệu trong repo (README, docs/guide.md, docs/spec.md, docs/tutorial.md, src/delta/README.md) và code ở v1.4.0. Ta đi từ phần nền dùng được ở mọi nơi (Context, phép toán delta, tracker, state nhân bản) lên tầng host runtime (contract, object, reference, hot reload, link), lần theo một thay đổi state đi từ server qua link tới trình duyệt, rồi xem Pi Durable dùng những mảnh nào. Phần dùng các mảnh đó bên trong Durable nằm ở chương Durable: session, commit, document và storage.

Vai trò: Chord là gì và ai dùng nó#

Mô tả trong package.json là "Typed host runtime for plugins, contracts, replicated state, and RPC over JSON links" (package.json:4). README nói rõ hơn: Chord dựng ứng dụng từ các object sống trong nhiều tiến trình (một server, các worker, các tab trình duyệt) và có thể nạp lại nóng (hot reload) trong lúc đang chạy (README.md:3). Năm ý của README là khung cho cả chương:

README: object, reference, principal, state, hot reload, contract và link
pi/packages/chord/README.mddòng 5–11Markdown
- An **object** is state plus code at an address. It is created on demand and lives while someone references it.- Everyone gets a **reference**, never the object. References work the same locally and across processes, and survive reloads.- Every object knows its **principal**: an owner and a guest get different objects, with different methods.- **State** is observable JSON. Remote viewers get a snapshot followed by deltas.- **Hot reload** swaps code while it runs. Work in flight finishes on the code it started with, state is kept, and incompatible changes migrate at a safe point.Contracts are runtime values described with [TypeBox](https://github.com/sinclairzx81/typebox) schemas. Hosts exchange JSON frames over links: in memory, a Node IPC channel, or a WebSocket.
Contract là giá trị lúc chạy mô tả bằng schema TypeBox; host trao đổi frame JSON qua link (bộ nhớ, kênh IPC của Node, hoặc WebSocket).

Package có chín điểm vào (entry point), khai báo ở package.json:8–55 và mô tả ở bảng của README (README.md:13–25):

ImportNội dungChạy ở
@earendil-works/chordContract (remote, local, method, opaque), host (createHost, defineFacet, defineRegistry), link, lỗi; cộng phần nền chung: Context, JsonValue, Draft, copyJson, replicatedStateNode và trình duyệt
/contextBACKGROUND_CONTEXT, withCancel, withAbortSignal, withTelemetry, traceIn, awaitWithContextNode và trình duyệt
/deltaTracker giao dịch, diff cấu trúc, apply bất biến, bộ mã hoá/giải mã lô phép toánNode và trình duyệt
/outputOutputBuffer, Progress, boundOutputNode và trình duyệt
/nodeTiến trình host con (createHostManager, forkDriver, childControl, processLock) và nodeLoaderNode
/sqlite, /sqlite/node, /sqlite/cloudflareFacade SQLite bất đồng bộ, migration nguyên tử, adapter node:sqlite và Durable ObjectMọi adapter SQLite
/telemetry-sqlitecreateSqliteTelemetry(): ghi span và metric vào SQLite, truy vấn lạiMọi adapter SQLite

Lịch sử ngắn

Commit đầu tiên, 28b49a6b3 "feat: add Chord runtime foundation" (Armin Ronacher, 2026-08-28), có phần code duy nhất là src/context.ts, tách ra từ packages/agent/src/harness/context.ts, kèm một PLANNING.md dài 859 dòng. Bản kế hoạch đó đặt mục tiêu một nền "application-neutral" cho plugin, service, RPC và state nhân bản, và cấm Chord phụ thuộc package Pi khác. State giao dịch được thêm ở 10d1ad621 (2026-09-21). Bản 1.2.0 thay toàn bộ runtime cũ (facet và service) bằng host runtime có kiểu như hiện nay, và gỡ ba package pi-client, pi-protocol, pi-server vốn chỉ phục vụ runtime cũ (CHANGELOG.md:78–81). Bản 1.3.0 đổi tên theo khái niệm (Handle thành Ref, epoch thành codeVersion) và nhận OutputBuffer từ Durable; bản 1.4.0 nhận các driver SQLite từ Durable và bắt buộc Context.telemetry (CHANGELOG.md:7–13).

README của Chord không gắn nhãn "experimental" như Pi Durable, nhưng cả ba bản minor 1.2, 1.3 và 1.4 đều có mục Breaking Changes. Hãy coi API là đang chuyển động.

Ai dùng Chord ở v1.4.0

Trong monorepo chỉ hai package khai báo phụ thuộc vào Chord: Pi Durable (durable/package.json:153) và pi-env (env/package.json:48). Coding agent, pi-agent-core và pi-tui không dùng nó. Điều quan trọng hơn: không package nào ngoài chính Chord tạo host. Tìm createHost, defineFacet, memoryLinkPair, hostNamed trong packages/ chỉ ra kết quả trong packages/chord. pi-env chỉ nhập kiểu Context (remote-env.ts:5); Pi Durable dùng Context, JSON, delta, replicatedState, output và sqlite (xem mục Pi Durable dùng Chord thế nào). Tầng host runtime (contract, object, link, reload) ở bản này được kiểm chứng bằng bộ test và tutorial của chính nó; mục "Durable" trong guide mô tả cách một ứng dụng có thể đặt Harness của Durable vào một object của Chord (guide.md:756–782), nhưng không có code nào trong repo làm việc đó.

Mô hình tư duy: hai tầng trong một package#

Chord gồm hai tầng. Tầng dưới là các mảnh độc lập, không cần host: Context, JSON nghiêm ngặt, delta, state nhân bản, output, SQLite. Tầng trên là host runtime, dựng trên các mảnh đó: contract mô tả object, host phục vụ object, reference là cách duy nhất chạm tới object, link nối các host thành cụm, và reload thay code trong lúc chạy.

 Host runtime   import "@earendil-works/chord" (+ "/node" cho tiến trình con)
   contract (remote · local, schema TypeBox) ── facet của plugin gọi host.serve(contract, factory)
   host ── object (state + code tại một địa chỉ) ◀── reference (Ref) ◀── người gọi
   root + Ctx + code version · hot reload: prepare → gate → commit → retire · registry
   link (memory · IPC · WebSocket) ── Connection ── frame JSON: hold · shape · call · state ...
 ───────────────────────────────────────────────────────────────────────────────
 Nền chung     dùng được không cần host; đây là phần Pi Durable dùng
   Context  ("/context")   huỷ + span telemetry + giá trị có kiểu, truyền tường minh
   delta    ("/delta")     tracker (draft → prepare → adopt) · 7 phép toán · diff · apply · codec
   replicatedState         giá trị JSON bất biến, quan sát được, đổi theo revision nguyên tử
   output   ("/output")    OutputBuffer (cửa sổ đầu/đuôi) · Progress (nhịp commit)
   sqlite   ("/sqlite…")   facade bất đồng bộ · migration · node:sqlite · Durable Object
   telemetry-sqlite        span và metric vào SQLite, truy vấn lại
Mũi tên phụ thuộc chỉ đi xuống: delta không phụ thuộc gì khác trong package (delta/index.ts:7–12); cell của object dùng tracker, link chở lô phép toán do encoder của delta mã hoá.

Từ vựng của phần còn lại (định nghĩa gốc ở spec.md:10–23):

Thuật ngữNghĩaTrong code
ContextGiá trị bất biến mang tín hiệu huỷ, span telemetry hiện tại và các giá trị có khoá; truyền tay qua mọi lời gọi bất đồng bộContext, context/index.ts:11
Revision, lô phép toán (op batch)Một giá trị JSON bất biến; danh sách phép toán biến revision trước thành revision nàyOp, Prepared
Tracker, draftChủ của một chuỗi revision; bản nháp đột biến được (mutable) của một thay đổi đang mởTracker, Change, Draft
State nhân bản (replicated state)Giá trị JSON quan sát được, mỗi thay đổi là một revision; bản sao ở xa nhận ảnh chụp rồi phép toánreplicatedState, Cell
ContractGiá trị lúc chạy mô tả một loại object: tên, version, input (địa chỉ), memberremote(), local()
FacetCode của một plugin cho một loại host (server, worker, trình duyệt)defineFacet()
Object, referenceMột thực thể của loại object tại một địa chỉ (type, input, principal); thứ get trả về, không bao giờ là objectObjectImpl, Ref
Principal, variantDanh tính người gọi do ứng dụng định nghĩa; tập member một object remote mở cho principal đócreateHost({ self, principalKey })
Root, Ctx, code versionMột cây lời gọi ngắn; ngữ cảnh lời gọi của nó; một bộ code đã commit trên hostCtx, types.ts:22
Unit, snap, ops, resetMột shape kèm ảnh chụp các member state; ảnh chụp; lô phép toán; ảnh chụp mới thay chuỗi cũframe shape, state

Context: huỷ, telemetry và giá trị tường minh#

Bài toán: một thao tác bất đồng bộ (một lần ghi storage, một lời gọi mô hình) cần biết ba thứ của người gọi: có bị huỷ chưa, span telemetry nào là cha của nó, và vài giá trị riêng của ứng dụng. Cách hay gặp trong Node là AsyncLocalStorage, một ngữ cảnh "vô hình" đi theo promise. Chord chọn ngược lại: một giá trị bất biến, truyền tay làm tham số cuối của mọi hàm. Không có span "hiện tại" ngầm nào (README.md:91), và spec của host runtime cũng ghi "Explicit; no AsyncLocalStorage dependency" (spec.md:196).

ContextKey và Context: ba thứ một thao tác cần biết về người gọi
pi/packages/chord/src/context/index.tsdòng 3–16TypeScript
/** Typed identity for one value carried by a {@link Context}. */export interface ContextKey<T> {	readonly token: symbol;	/** Type-only marker that prevents keys with different value types from being interchangeable. */	readonly valueType?: (value: T) => T;}/** Immutable invocation-scoped values passed explicitly through operations. */export interface Context {	readonly abortSignal: AbortSignal | undefined;	readonly telemetry: TelemetrySpan;	value<T>(key: ContextKey<T>): T | undefined;	toString(): string;}
Khoá là một symbol bọc trong object đông cứng; trường valueType chỉ tồn tại ở mức kiểu để hai khoá khác kiểu giá trị không đổi chỗ được cho nhau.

Hiện thực là một danh sách liên kết: mỗi lần "dẫn xuất" tạo một nút ContextValue trỏ về cha, và value(key) đi ngược chuỗi cho tới khi gặp khoá. Tín hiệu huỷ và span cũng chỉ là hai khoá riêng. Context gốc là EmptyContext, không mang gì:

BaseContext, ContextValue và BACKGROUND_CONTEXT
pi/packages/chord/src/context/index.tsdòng 26–37, 56–79TypeScript
abstract class BaseContext implements Context {	abstract value<T>(key: ContextKey<T>): T | undefined;	abstract toString(): string;	get abortSignal(): AbortSignal | undefined {		return this.value(ABORT_SIGNAL_CONTEXT_KEY);	}	get telemetry(): TelemetrySpan {		return this.value(TELEMETRY_CONTEXT_KEY) ?? NOOP_TELEMETRY_CONTEXT;	}}class ContextValue<T> extends BaseContext {	readonly #parent: Context;	readonly #key: ContextKey<T>;	readonly #value: T;	constructor(parent: Context, key: ContextKey<T>, value: T) {		super();		this.#parent = parent;		this.#key = key;		this.#value = value;	}	value<Value>(key: ContextKey<Value>): Value | undefined {		if (key.token === this.#key.token) return this.#value as unknown as Value;		return this.#parent.value(key);	}	toString(): string {		return `${this.#parent}.WithValue(${this.#key.token.description ?? "anonymous"})`;	}}export const BACKGROUND_CONTEXT: Context = new EmptyContext("[Context BACKGROUND_CONTEXT]");export const TODO_CONTEXT: Context = new EmptyContext("[Context TODO_CONTEXT]");
Không có span thì telemetry trả về span no-op dùng chung của pi-telemetry, nên mọi Context đều gọi được telemetry.addEvent() mà không cần kiểm tra. TODO_CONTEXT là một EmptyContext khác, giống hệt BACKGROUND_CONTEXT trừ tên mà toString() in ra; code không ghi chú khi nào dùng cái nào.

Các hàm dẫn xuất không bao giờ sửa cha:

awaitWithContext(promise, context) chờ một promise cho tới khi nó xong hoặc Context bị huỷ; huỷ chỉ làm người chờ bị từ chối, không huỷ công việc bên dưới (context/index.ts:134–156). Đây là đúng ngữ nghĩa Pi Durable hứa: "Cancelling a wait only cancels that wait, never the work" (durable/README.md:78).

Từ 1.4.0, telemetry là trường bắt buộc; một Context tự viết phải cung cấp nó, dùng span no-op khi không bật telemetry (CHANGELOG.md:7). Adapter telemetry (thứ mở span gốc) thuộc về host và không bao giờ nằm trong Context; Context chỉ mang span (README.md:75). README cũng nói rõ giới hạn hiện tại: các helper này chưa tự đo đạc host, method hay frame mạng của Chord (README.md:93). Kiểu span đến từ pi-telemetry, xem chương pi-env, telemetry và evals.

Để đối chiếu, đây là Ctx của host runtime: nó gắn với một root (một cây lời gọi), mang code version để mọi lời gọi lồng nhau chạy cùng một phiên bản code, và tự giải phóng các reference lấy qua get khi lời gọi xong.

Ctx: ngữ cảnh của một root trong host runtime
pi/packages/chord/src/types.tsdòng 18–35TypeScript
/** * The explicit call context of one root (a short Chord call tree). Calls made with it carry its root id and code * version; `get` references are released when the call that received this ctx settles. */export interface Ctx<P = unknown> {	readonly signal: AbortSignal;	readonly rootId: string;	readonly codeVersion: number;	/** Who the root acts as: the caller at ingress, the host's own principal inside objects. */	readonly principal: P;	/**	 * A reference for this call, released when the call settles. Resolves once it is ready: live, with every state	 * member of its variant (unless `state: false`). Rejects with the activation error, `aborted` when the ctx is	 * aborted or the call settles first, or `unavailable/ready_timeout` after the host's `readyTimeout`; the reference	 * is released then.	 */	get<C extends AnyContract>(type: C, input: InputOf<C>, options?: GetOptions<P>): Promise<Ref<C>>;}

Bảy phép toán delta và dạng dây của chúng#

Mọi thay đổi state trong Chord được mô tả bằng một lô phép toán (op batch) trên JSON. Cùng một dạng tuple dùng trong bộ nhớ, trên dây và trên đĩa: Pi Durable lưu nguyên các tuple này làm bản ghi delta của document (chương Durable storage).

Op: bảy phép toán, đường dẫn là mảng khoá và chỉ số
pi/packages/chord/src/delta/index.tsdòng 14–40TypeScript
export type Seg = string | number;export type Path = readonly Seg[];export type NonEmptyPath = readonly [Seg, ...Seg[]];/** A path inline, or an id assigned by the encoder on second use. */export type PathRef<P extends Path = Path> = P | number;/** * Tuples are the form — in memory, on the wire, on disk. * * `r` is the ONLY op that replaces a whole value. `s`/`d`/`a`/`t` cannot target * the root: the type forbids it. `p` and `m` may, because a tracked value can * itself be an array. Operation shape is not canonical: an array may be emptied by either * a replacement or a root splice. * * `Op` knows nothing about the path dictionary. Interning, id references and * omitted paths live in `WireOp` and exist only between `encode` and `decode`. */export type Op =	| readonly ["r", JsonValue]	| readonly ["s", NonEmptyPath, JsonValue]	| readonly ["d", NonEmptyPath]	| readonly ["a", NonEmptyPath, string]	| readonly ["t", NonEmptyPath, number]	| readonly ["p", Path, number, number, JsonValue[]]	/** Reorder an array in place: `new[i] = old[permutation[i]]`. */	| readonly ["m", Path, number[]];
Kiểu cấm s, d, a, t nhắm vào gốc (NonEmptyPath); chỉ r thay cả giá trị. p và m được nhắm vào gốc vì giá trị được theo dõi có thể là một mảng.
TupleNghĩaVí dụ minh hoạ
["r", value]Thay toàn bộ giá trị["r", {"count": 0}]
["s", path, value]Gán một thuộc tính hay một phần tử mảng["s", ["status"], "done"]
["d", path]Xoá thuộc tính, hoặc bỏ một phần tử mảng["d", ["draft"]]
["a", path, text]Nối thêm vào cuối một chuỗi["a", ["output"], "ok\n"]
["t", path, count]Bỏ count đơn vị UTF-16 ở đầu chuỗi["t", ["output"], 1024]
["p", path, index, remove, items]Splice một mảng["p", ["rows"], 3, 0, [{"id": 4}]]
["m", path, permutation]Sắp lại mảng: new[i] = old[permutation[i]]["m", ["rows"], [2, 0, 1]]

Bảng nghĩa gốc nằm trong delta README (delta/README.md:153–168), cùng một câu cần nhớ: "Batches are exact but not canonical". Một lô mô tả đúng thay đổi, nhưng cùng một thay đổi có thể ra các tuple khác nhau, và một tập sửa lớn có thể bị gộp thành một splice vùng, một s ở tổ tiên, hay r. Chỉ giá trị kết quả là hợp đồng; đừng suy ra nghĩa từ hình dạng phép toán (delta/README.md:94).

Dạng dây: encoder và decoder

Một luồng (stream) state đổi liên tục (văn bản mô hình đang được stream vào ["generation", "message", ...]) lặp lại cùng một đường dẫn rất nhiều lần. WireOp thêm hai cách nén và không gì khác: định nghĩa một mã số cho đường dẫn, và bỏ đường dẫn khi nó trùng phép toán ngay trước trong cùng lô (delta/index.ts:42–66).

Ví dụ minh hoạ (suy ra từ code ở trên, không chạy thật): một encoder, ba lô liên tiếp

 lô 1   Op:   ["s",["status"],"running"]  ["a",["output"],"x"]  ["a",["output"],"y"]
        dây:  ["s",["status"],"running"]  ["a",["output"],"x"]  ["a","y"]
              lần đầu: đường dẫn viết thẳng         trùng phép trước: bỏ đường dẫn
 lô 2   Op:   ["a",["output"],"z"]                  ["s",["status"],"done"]
        dây:  ["#",0,["output"]] ["a",0,"z"]        ["#",1,["status"]] ["s",1,"done"]
              lần dùng thứ hai: định nghĩa mã 0 rồi tham chiếu
 lô 3   Op:   ["a",["output"],"!"]
        dây:  ["a",0,"!"]
 lô 4   Op:   ["r",{"status":"idle","output":""}]  → encoder xoá từ điển; lô sau định nghĩa lại từ đầu
Định nghĩa một mã tốn hơn chính đường dẫn, nên mã hoá từ lần dùng đầu sẽ lỗ với những đường dẫn chỉ ghi một lần (delta/index.ts:562–565).

Decoder kiểm từng phép toán theo ngữ pháp dây (assertValidWireOp), dựng lại đường dẫn, và xoá từ điển khi gặp r (delta/index.ts:666–685). Quy tắc vận hành: một cặp encoder/decoder cho mỗi luồng state độc lập; dùng chung một kết nối không biến hai state thành một luồng (delta/index.ts:547–554). Sau một lỗi giải mã, bỏ decoder và bản sao, khôi phục từ một r sau đó (delta/README.md:179–182).

Đường dẫn là dữ liệu không tin cậy

Phép toán có thể đến từ một plugin, hay từ một tool mà details lặp lại output của mô hình. Phép áp (applier) làm đúng việc parent[key] = value, nên ["s", ["__proto__", "isAdmin"], true] sẽ làm bẩn Object.prototype của cả tiến trình. Vì vậy ba đoạn __proto__, constructor, prototype bị cấm, applier ghi bằng Object.defineProperty thay vì phép gán, và chỉ đi theo thuộc tính riêng (own property) (delta/index.ts:120–131). Chỉ số mảng chỉ được trỏ vào phần tử có sẵn hoặc đúng một ô sau cuối: mảng thưa không sống sót qua JSON, và ["s", ["xs", 4294967290], 1] sẽ cấp phát một mảng hơn bốn tỉ ô từ một phép toán (delta/index.ts:289–306). Phía tracker, một thay đổi tại hoặc dưới khoá cấm được gộp thành s của tổ tiên an toàn gần nhất, hoặc r ở gốc (delta/README.md:170–177).

Tracker: draft, prepare, adopt#

Bài toán: Pi Durable sửa document pi.live mỗi 100 ms trong lúc mô hình stream. Nó cần (1) một API sửa tự nhiên (draft.output += chunk), (2) đúng lô phép toán của lần sửa đó để lưu làm delta và gửi cho người xem, (3) revision cũ vẫn dùng được trong lúc revision mới đang được ghi xuống storage, và (4) nếu storage lỗi thì bỏ thay đổi mà không động vào state đang có. Tracker giải cả bốn bằng ba bước tách rời: mở draft, chuẩn bị (prepare), nhận (adopt).

Prepared, Change, Tracker
pi/packages/chord/src/delta/tracker.tsdòng 121–141TypeScript
export interface Prepared<T extends object> {	readonly base: T;	readonly value: T;	readonly ops: readonly Op[];	readonly baseRevision: number;	abort(): void;}export interface Change<T extends object> {	readonly state: Draft<T>;	prepare(): Prepared<T>;	abort(): void;}export interface Tracker<T extends object> {	readonly value: T;	readonly revision: number;	beginChange(): Change<T>;	prepareReplace(value: T): Prepared<T>;	adopt(prepared: Prepared<T>): void;}
Prepared mang base (revision gốc), value (revision mới) và ops biến cái này thành cái kia, cộng baseRevision để phát hiện tranh chấp.

Cách dùng ngắn nhất có trong README (README.md:127–138): track(initial) nhận quyền sở hữu gốc trong O(1), beginChange() mở một draft, sửa change.state, prepare() trả về một Prepared, rồi adopt(prepared).

Draft là một lớp phủ proxy

beginChange() không sao chép gì. Nó tạo một ngữ cảnh lớp phủ (overlay) mà gốc là một Proxy trên revision hiện tại; mỗi container được đọc qua draft có một nút phủ riêng, ghi lại phép ghi, phép xoá và các thao tác mảng của chính nó. Ghi một thuộc tính đi qua setProperty:

setProperty: sao chép giá trị được đặt vào, đánh dấu nút bẩn
pi/packages/chord/src/delta/tracker.tsdòng 563–591TypeScript
function setProperty(node: OverlayNode, property: PropertyKey, supplied: unknown): boolean {	assertWritable(node.context);	if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported");	if (Array.isArray(nodeBase(node))) {		if (property === "length") {			setArrayLength(node, toArrayLength(supplied));			return true;		}		const index = arrayIndex(property);		if (index === undefined) throw new TypeError("Only array indices and length can be written");		if (index > arrayLength(arrayOverlay(node))) throw new TypeError("Overlay arrays cannot contain holes");		setArrayIndex(node, index, clonePlacement(supplied));		return true;	}	const key = String(property);	if (supplied === undefined) return deleteProperty(node, key);	const stored = clonePlacement(supplied);	const current = objectValue(node, key);	const wasDeleted = isObjectDeleted(node, key);	if (!wasDeleted && !isContainer(stored) && current === stored) return true;	setObjectWrite(node, key, stored);	if (wasDeleted && Object.hasOwn(nodeBase(node), key)) {		if (node.readded === undefined) node.readded = new Set();		node.readded.add(key);	}	deleteObjectDeletion(node, key);	markDirty(node);	return true;}
Giá trị đặt vào được sao chép ngay (clonePlacement dùng copyJson, vốn từ chối JSON không nghiêm ngặt trước khi draft đổi). Gán undefined cho thuộc tính là xoá nó; ghi lại đúng giá trị nguyên thuỷ đang có thì không làm gì.

markDirty() đưa nút vào danh sách bẩn của ngữ cảnh và đánh dấu mọi tổ tiên là "cây con bẩn" (tracker.ts:761–766). Mảng có cấu trúc riêng: một cây các đoạn (piece) "lấy từ mảng gốc" và "chèn mới", tổ chức thành treap (cây nhị phân tìm kiếm giữ cân bằng bằng độ ưu tiên ngẫu nhiên), nên push, splice, reverse, sort chỉ sửa cây piece chứ không chép mảng (tracker.ts:1177–1243). Handle đọc từ draft đi theo phần tử qua các lần đổi chỉ số (delta/README.md:150–151).

prepare(): sinh phép toán từ các nút bẩn

ChangeImpl.prepare(): sinh ops, dựng revision mới, khoá draft
pi/packages/chord/src/delta/tracker.tsdòng 181–200TypeScript
prepare(): Prepared<T> {	if (this.#settled) throw new Error("Change has already been settled");	const context = this.#context!;	assertWritable(context);	context.status.value = "prepared";	try {		if (!context.replacement) context.ops = context.dirty.length === 0 ? [] : emitOperations(context);		const prepared = materializePrepared<T>(context);		this.#preparedStatus = context.status;		this.#settled = true;		return prepared;	} catch (error) {		context.status.value = "aborted";		clearContext(context);		this.#settled = true;		throw error;	} finally {		this.#context = undefined;	}}
Trong lúc sinh phép toán, ngữ cảnh ở trạng thái prepared (chỉ đọc); khi prepare() trả về, ngữ cảnh đã được dọn và mọi lần dùng draft hay handle đọc từ nó đều ném lỗi (delta/README.md:18). Lỗi trong lúc sinh phép toán chuyển ngữ cảnh sang aborted và dọn sạch nó.

emitOperations() đi qua các nút bẩn theo độ sâu, sinh phép toán cho object (ghi, xoá, xoá-rồi-thêm-lại để giữ thứ tự khoá) và cho mảng (một kế hoạch splice và hoán vị). Với giá trị từng thuộc tính, quyết định nằm ở một hàm nhỏ:

emitChangedValue(): chuỗi dài ra thành a, cửa sổ trượt thành t + a, còn lại là s
pi/packages/chord/src/delta/tracker.tsdòng 2039–2063TypeScript
function emitChangedValue(	operations: Op[],	path: NonEmptyPath,	before: JsonValue | undefined,	after: JsonValue,): boolean {	if (!isContainer(after) && before === after) return false;	if (isContainer(before) && isContainer(after) && equalTrustedJson(before, after)) return false;	if (typeof before === "string" && typeof after === "string") {		// Slice/equality stays on V8's rope fast path; startsWith scans the entire		// accumulated prefix on every append.		if (after.length > before.length && after.slice(0, before.length) === before) {			operations.push(["a", path, after.slice(before.length)]);			return true;		}		const shared = overlap(before, after, 65_536);		if (shared > 0) {			operations.push(["t", path, before.length - shared]);			if (after.length > shared) operations.push(["a", path, after.slice(shared)]);			return true;		}	}	operations.push(["s", path, after]);	return true;}
overlap(before, after, 65_536) tìm hậu tố dài nhất của chuỗi cũ là tiền tố của chuỗi mới (delta/index.ts:80–110). Đây là trường hợp một cửa sổ đuôi của output: cắt đầu rồi nối thêm.

Hai giới hạn giữ lô phép toán nhỏ hơn giá trị. Quá MAX_DELTA_OPERATIONS (4.096) phép toán thì cả lô bị gộp thành một ["r", root] (tracker.ts:1666–1669). Và với thao tác mảng phổ biến (chèn, xoá mà các phần tử còn lại giữ thứ tự), kế hoạch được tính chỉ từ các piece, không duyệt phần tử:

orderedArrayPlan(): append vào mảng dài không tốn một lượt duyệt mảng
pi/packages/chord/src/delta/tracker.tsdòng 1895–1917TypeScript
/** * The plan when the retained base elements keep their order, the common case of inserts and removals: computed from the * pieces alone, so appending to a long array costs no walk over its elements. `undefined` when base elements moved. */function orderedArrayPlan(baseLength: number, pieces: readonly Piece[]): ArrayPlan | undefined {	// Retained base ranges in piece order, `[first, last]` each; ordered means strictly ascending without overlap.	const ranges: number[] = [];	for (const piece of pieces) {		if (piece.kind !== "base" || piece.length === 0) continue;		if (piece.step !== 1 && piece.length > 1) return undefined;		if (ranges.length > 0 && piece.start <= ranges[ranges.length - 1]!) return undefined;		ranges.push(piece.start, piece.start + piece.length - 1);	}	// Unretained runs from the end backwards, as `start, length` pairs: after the last range, between ranges, before the first.	const removeRuns: number[] = [];	let end = baseLength;	for (let index = ranges.length - 2; index >= -2; index -= 2) {		const start = index >= 0 ? ranges[index + 1]! + 1 : 0;		if (end > start) removeRuns.push(start, end - start);		if (index >= 0) end = ranges[index]!;	}	return { removeRuns, permutation: undefined, insertRuns: insertRunsOf(pieces) };}
CHANGELOG 1.2.0 ghi lại lý do: trước bản sửa, 32.000 lần append một phần tử, mỗi lần một commit, mất 11,3 s; sau đó 1,4 s (CHANGELOG.md:90).

Phép toán xong thì materializePrepared() dựng revision mới: không có phép toán thì value === base; thay đổi chỉ ở object thì chép thẳng các nút bẩn; còn lại thì áp lô phép toán bất biến lên base, chia sẻ mọi cây con không đổi (tracker.ts:325–349).

Ví dụ minh hoạ (lần theo code, không chạy): ví dụ của README

 tracker = track({ output: "", count: 0 })          revision 0
 change  = tracker.beginChange()
 change.state.output += "done\n"     → nút gốc: writeKey "output"
 change.state.count  += 1            → nút gốc: writes {output, count}, gốc vào danh sách bẩn
 prepared = change.prepare()
   ops   = [ ["a", ["output"], "done\n"],     "done\n" bắt đầu bằng "" → append
             ["s", ["count"], 1] ]            số nguyên thuỷ đổi → set
   value = { output: "done\n", count: 1 }     object mới; base không đổi
 tracker.value === prepared.base             authority chưa đổi
 tracker.adopt(prepared)                      revision 1, tracker.value === prepared.value
Thứ tự phép toán theo thứ tự ghi vào nút gốc. Nếu hai thuộc tính nằm ở hai object con khác nhau, mỗi nút bẩn sinh phép toán của mình theo độ sâu tăng dần.

adopt(): một phép đổi con trỏ không thể thất bại

beginChange() và adopt(): kiểm tra tranh chấp, rồi đổi con trỏ
pi/packages/chord/src/delta/tracker.tsdòng 235–239, 254–277TypeScript
beginChange(): Change<T> {	const context = createContext(this.#selfRef, this.#owner, this.#revision, this.#value, false, this.#value);	this.#register(context);	return new ChangeImpl(context);}adopt(prepared: Prepared<T>): void {	const context = PREPARED.get(prepared as object);	if (context?.owner !== this.#owner) throw new Error("Prepared change belongs to a different tracker");	if (context.status.value === "consumed") throw new Error("Prepared change has already been used");	if (context.status.value === "aborted") throw new Error("Prepared change has been aborted");	if (context.status.value === "stale") throw new Error("Prepared change is stale");	if (context.status.value !== "prepared") throw new Error("Prepared change is not ready");	if (context.baseRevision !== this.#revision) {		context.status.value = "stale";		clearContext(context);		throw new Error("Prepared change is stale");	}	if (this.#value !== prepared.base) {		context.status.value = "stale";		throw new Error("Prepared change is stale");	}	// Materialization happened during prepare. Adoption is an infallible pointer	// swap so storage failure can discard the candidate without touching authority.	this.#value = prepared.value;	context.status.value = "consumed";	this.#revision += 1;	this.#invalidate(context);}
Mọi việc có thể lỗi (sinh phép toán, dựng giá trị) đã xảy ra ở prepare(). adopt() chỉ kiểm tra rồi đổi con trỏ, nên tầng storage có thể vứt bỏ ứng viên khi ghi lỗi mà không chạm vào authority. #invalidate đánh dấu mọi draft và preparation cạnh tranh là stale.

Nhiều change có thể mở hay đã prepare từ cùng một revision; adopt một cái làm mọi cái khác hết hạn: draft đang mở không dùng được nữa, preparation cạnh tranh bị adopt() từ chối (delta/README.md:124–136). Đó chính là cách Pi Durable dùng tracker trong một commit: prepare mọi document khi callback xong, ghi storage, chỉ adopt sau khi ghi thành công (durable/session/transaction.ts:709–712, transaction.ts:727–739).

Ai được đột biến cái gì

Tracker không đóng băng (freeze) và không sao chép phòng thủ. Bất biến là một hợp đồng sở hữu: đột biến trái phép không bị phát hiện, nó làm hỏng state trong im lặng (delta/README.md:6–7). Bảng quyền đột biến trong delta README (delta/README.md:28–40) tóm lại: gốc đưa cho track() hay replace() không được sửa nữa; change.state chỉ sửa được khi change còn mở; giá trị bên ngoài đặt vào draft vẫn của người gọi (draft giữ bản sao); mọi revision, mọi lô phép toán và mọi giá trị đọc từ state đều không được sửa.

Diff, apply và kiểm tra revision#

Tracker sinh phép toán từ các lần ghi qua draft. Có lúc không có draft: một derivation tính lại giá trị mới, hay một ứng dụng soi gương (mirror) một store bất biến bên ngoài bằng cell.replace(ctx, next). Khi đó Chord so hai revision bằng diffRevisions():

diffRevisions(): diff cấu trúc, rơi về r khi delta không rẻ hơn
pi/packages/chord/src/delta/diff.tsdòng 512–523TypeScript
/** Compute a compact operation batch from two immutable JSON revisions. */export function diffRevisions(before: JsonValue, after: JsonValue): Op[] {	const operations: Op[] = [];	diffValue(before, after, [], operations);	if (overflowedBatches.has(operations)) return [["r", after]];	if (operations.length === 0 || operations[0]?.[0] === "r") return operations;	let deltaCost = 2;	for (const operation of operations) deltaCost += operationCost(operation) + 1;	if (deltaCost < 65_536) return operations;	const snapshotCost = jsonCost(after) + 6;	return deltaCost >= snapshotCost ? [["r", after]] : operations;}
Chi phí là độ dài JSON ước tính của lô phép toán; chỉ khi nó vượt 65.536 ký tự mới so với chi phí một ảnh chụp, và chọn cái rẻ hơn.

Diff dựa vào danh tính container: một revision mới của cùng state ({ ...state.value, changed }) chia sẻ các cây con không đổi, nên phép so === bỏ qua chúng ngay. Với mảng, nó lần lượt thử tiền tố và hậu tố chung, so theo vị trí, chuỗi con theo danh tính, các mỏ neo danh tính (dãy con tăng dài nhất), rồi LCS "ngữ nghĩa" có trần 65.536 ô, và nhận ra một hoán vị thuần để phát ra m (diff.ts:284–418). Chuỗi dùng cùng luật a/t/s như tracker (diff.ts:63–76). Đây là lý do guide khuyên giữ chia sẻ cấu trúc giữa các revision (delta/README.md:88): Cell.replace() chỉ diff mặc định khi giá trị mới dùng lại ít nhất một container của giá trị cũ ở cùng khoá (cell.ts:275–283).

Áp phép toán: bất biến và đột biến

applyImmutable và applyImmutableBatches: sao chép đúng các container trên đường đi, mỗi cái một lần
pi/packages/chord/src/delta/index.tsdòng 408–441TypeScript
/** Apply one decoded operation batch without mutating the previous immutable value. */export function applyImmutable<T>(target: T | undefined, ops: readonly Op[]): T {	return applyImmutableBatches(target, [ops]);}/** * Apply decoded operation batches as one final-result-only replay. * * Containers copied for an earlier batch may be mutated while applying a later * batch, so this deliberately exposes no intermediate revisions. */export function applyImmutableBatches<T>(target: T | undefined, batches: Iterable<readonly Op[]>): T {	let root = target as unknown as JsonValue;	const owned = new WeakSet<object>();	for (const ops of batches) {		for (const op of ops) {			assertValidOp(op);			if (op[0] === "r") {				root = op[1];				continue;			}			if (op[0] === "p") {				const appended = appendCopy(root, op, owned);				if (appended !== undefined) {					root = appended;					continue;				}			}			root = copyContainers(root, op[0] === "p" || op[0] === "m" ? op[1] : op[1].slice(0, -1), owned);			root = applyOps(root, [op]);		}	}	return root as unknown as T;}
Tập owned nhớ container nào đã được chép trong lần áp này, nên nhiều phép toán vào cùng một nhánh chỉ chép nhánh đó một lần. Kết quả chia sẻ container với cả đầu vào lẫn payload của phép toán.

applyImmutableBatches() phát lại nhiều lô với một phạm vi copy-on-write chung, nên không lộ revision trung gian nào; Pi Durable dùng nó để đọc document từ base cộng chuỗi delta trong MemoryStorage (durable/storage/memory.ts:671–674). Bản đột biến apply() nhận một bản sao riêng và "nuốt" payload của lô vào nó: chép lô cho mỗi bản sao, đừng áp một lô trong bộ nhớ cho hai bản sao đột biến (delta/index.ts:335–345; delta/README.md:67–83). Backend SQLite và IndexedDB của Durable dùng apply() trên giá trị vừa đọc từ đĩa (với SQLite là kết quả của JSON.parse), nên bản sao là riêng của chúng (durable/storage/sqlite/storage.ts:649–655).

Khi tracker tự áp lô của chính nó, nó dùng applyImmutableTrusted(): bỏ kiểm tra hình dạng và an toàn đường dẫn vì lô do chính nó sinh, và tự viết splice bằng copyWithin (apply-immutable-trusted.ts:14–61); lô có p hay m thì vẫn đi đường applyImmutable() vì splice gốc nhanh hơn với lô lớn (tracker.ts:343–349).

Kiểm tra revision

JsonRevisionValidator kiểm một revision là JSON nghiêm ngặt (mảng dày, object thuần, không chu trình, số hữu hạn) và nhớ bằng WeakSet các container đã kiểm ở revision trước, nên một revision mới chỉ tốn công cho phần đã đổi (revision-validator.ts:7–42). Ở v1.4.0 không file nguồn nào nhập lớp này, và delta/index.ts không xuất nó; benchmark duyệt draft chỉ băm nội dung file để ghi lại phiên bản đo. Kiểm tra thật sự ở các ranh giới khác: placement qua draft được copyJson kiểm, gốc trao cho track() thì được tin (delta/README.md:96–113), và state đi qua link được kiểm theo schema của contract (mục link).

replicatedState: state quan sát được, không cần host#

Tracker cho revision và phép toán; một giao diện còn cần quan sát: đọc giá trị hiện tại, được báo khi nó đổi, theo đúng thứ tự, và một người nghe chậm không được chặn người khác. replicatedState là lớp đó, độc lập với host; README ghi Durable dùng nó cho state của document, view và task graph (README.md:154–156).

ReplicatedState và MutableReplicatedState
pi/packages/chord/src/state.tsdòng 9–42TypeScript
export interface ReplicatedStateDelivery {	readonly kind: "hydrate" | "update";	readonly sequence: number;}export interface ReplicatedState<T> {	/**	 * Contract-immutable value, or undefined until hydration. It is not frozen and may share containers with an	 * in-process provider; consumers must not mutate it. Later updates do not mutate previously returned values.	 */	readonly value: T | undefined;	/**	 * Each subscription serializes callbacks, awaiting hydration before updates. Values are immutable and may	 * share unchanged revision data. At most 100 deliveries wait behind the running callback; overflow keeps	 * only the newest pending value/context/delivery, so update sequences may skip. Failures are reported in	 * isolation and delivery continues. Unsubscribe discards pending work without aborting or joining a callback.	 */	subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => Promise<void>): () => void;	subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void;}export interface MutableReplicatedState<T extends object> extends ReplicatedState<T> {	readonly value: T;	/**	 * Atomically publish one synchronous overlay mutation. Draft handles are unusable after the callback returns.	 * Values placed through the draft are cloned by value; assigning `undefined` to an object property deletes it.	 */	change(context: Context, mutate: (draft: Draft<T>) => void): void;	/**	 * Atomically take immutable ownership of an alias-free strict-JSON replacement.	 * The caller must not mutate the transferred root after this call.	 */	replace(context: Context, value: T): void;}
Callback nhận giá trị, Context của người đã gây ra thay đổi, và delivery (hydrate hay update, kèm số thứ tự). Giá trị không bị đóng băng và có thể chia sẻ container với nơi cung cấp: không được sửa.

Một change() thành công công bố đúng một revision nguyên tử; callback ném lỗi thì giá trị và số thứ tự giữ nguyên (README.md:169–173). Hiện thực là tracker cộng một publisher:

MutableReplicatedStateImpl.change() và replace()
pi/packages/chord/src/state.tsdòng 255–285TypeScript
change(context: Context, mutate: Parameters<MutableReplicatedState<T>["change"]>[1]): void {	if (this.#changing) throw new Error("Replicated state cannot be changed reentrantly from a change callback");	this.#changing = true;	let prepared: Prepared<T>;	try {		const change = this.#tracker.beginChange();		try {			const outcome = (mutate as (draft: typeof change.state) => unknown)(change.state);			if (isPromiseLike(outcome)) {				void Promise.resolve(outcome).catch(() => undefined);				throw new TypeError("Replicated state change callbacks must be synchronous");			}			prepared = change.prepare();		} catch (error) {			change.abort();			throw error;		}	} finally {		this.#changing = false;	}	this.#tracker.adopt(prepared);	if (prepared.ops.length > 0) this.#publisher.publish(prepared.value, context);}replace(context: Context, value: T): void {	if (this.#changing) throw new Error("Replicated state cannot be replaced from a change callback");	const prepared = this.#tracker.prepareReplace(value);	this.#tracker.adopt(prepared);	if (prepared.ops.length > 0) this.#publisher.publish(prepared.value, context);}
Callback phải đồng bộ: trả về promise là bị từ chối, vì draft hết hạn ngay khi callback quay về. Lô rỗng (ghi lại đúng giá trị cũ) vẫn được adopt nhưng không được công bố.

Giao từng subscriber một, có trần

ReplicatedStatePublisher.subscribe() giao ngay giá trị hiện tại làm lần hydrate (lần nạp giá trị đầu tiên), với BACKGROUND_CONTEXT vì hydrate không có người gây ra (state.ts:201–212). publish() chống tái nhập (reentrancy): một subscriber đổi state ngay trong callback thì revision mới xếp hàng sau revision đang giao, không chen lên trước (state.ts:214–238). Mỗi subscriber có hàng đợi riêng:

StateSubscriber: tuần tự hoá callback bất đồng bộ, tối đa 100 frame chờ
pi/packages/chord/src/state.tsdòng 115–148TypeScript
push(frame: StateDelivery<T>): void {	if (this.#closed) return;	if (this.#pending.length === 100) {		// A cold replica can queue updates reentrantly before this subscriber's first hydration starts.		const hydration = this.#started ? undefined : this.#pending[0];		this.#pending.length = 0;		if (hydration !== undefined) this.#pending.push(hydration);	}	this.#pending.push(frame);}drain(): void {	if (this.#running || this.#closed) return;	this.#running = true;	for (let frame = this.#pending.shift(); frame !== undefined; frame = this.#pending.shift()) {		this.#started = true;		try {			const result: unknown = this.#listener(frame.value, frame.context, frame.delivery);			if (isPromiseLike(result)) {				void Promise.resolve(result).then(					() => this.#resume(),					(error: unknown) => {						this.#report(error);						this.#resume();					},				);				return;			}		} catch (error) {			this.#report(error);		}	}	this.#running = false;}
Callback bất đồng bộ của một subscriber không chồng nhau; subscriber khác vẫn chạy. Khi 100 frame đang chờ, hàng đợi bị xoá chỉ còn frame mới nhất (và lần hydrate nếu nó chưa bắt đầu), nên số thứ tự giao có thể nhảy cóc. Đây là trần theo số frame, không theo byte (README.md:187–191).

Gắn vào một nguồn revision bên ngoài

Dạng thứ hai, replicatedState(source, { onError }), không tự sở hữu tracker. Nó gắn vào một nguồn có thẩm quyền (authoritative) đã tự commit revision, như Session của Pi Durable:

ReplicatedStateSource: ảnh chụp nguyên tử cộng các frame có cursor liên tục
pi/packages/chord/src/state.tsdòng 44–78TypeScript
/** One immutable authoritative revision committed after an attachment snapshot. */export interface ReplicatedStateSourceFrame<T> {	/** Monotonic source cursor. The first frame after a snapshot must be `snapshot.cursor + 1`. */	readonly cursor: number;	/** The exact immutable value produced by this commit. */	readonly value: T;	/** The exact immutable operation batch that produced `value` from the preceding source revision. */	readonly ops: readonly Op[];	readonly context: Context;}export interface ReplicatedStateSourceAttachment<T> {	/** Fixed immutable snapshot captured at the atomic attachment boundary. */	readonly snapshot: { readonly value: T; readonly cursor: number };	/**	 * Install the sole listener and synchronously drain every buffered frame in source commit order.	 * This method is single-use. After it begins, every new committed frame must also be delivered in order	 * until disposal, including commits made reentrantly while a prior frame is being delivered.	 */	activate(listener: (frame: ReplicatedStateSourceFrame<T>) => void): void;	/** Stop delivery and release source resources. Disposal must be idempotent. */	dispose(): void;}/** * An authoritative immutable revision source. * * `attach()` must synchronously and atomically capture one snapshot and register the returned attachment to buffer * every later committed frame. The snapshot must include every commit before that boundary; buffered frames must * include every commit after it, with no overlap or gap. Snapshot values, frame values, and operation batches are * immutable and remain valid after delivery. Chord only publishes these references; it never applies or re-diffs them. */export interface ReplicatedStateSource<T> {	attach(): ReplicatedStateSourceAttachment<T>;}
attach() phải chụp ảnh và đăng ký nhận frame trong cùng một bước đồng bộ, để mọi commit trước ranh giới nằm trong ảnh chụp và mọi commit sau nằm trong frame, không trùng không hở. Chord chỉ công bố các tham chiếu này, không bao giờ áp hay diff lại chúng.

Phía Chord kiểm tra hợp đồng đó: cursor (số thứ tự tăng dần của frame) của frame phải là cursor trước cộng một, lệch là lỗi, state tự gỡ nguồn và báo qua onError (state.ts:326–339). Pi Durable hiện thực nguồn này bằng CommittedStateSource: attach() lấy giá trị và cursor hiện tại, advance(value, ops, context) tăng cursor và phát một frame cho mọi attachment sau mỗi commit chạm tới document hay view (durable/session/observation.ts:25–62; chi tiết ở chương Durable storage).

Cell, derivation và hàng đợi publication#

Bên trong host runtime, state của object nằm trong cell, khai báo bằng self.state(name, schema, initial). Cell cũng dùng tracker, nhưng có mô hình đăng ký riêng (README.md:201–202): đồng bộ, đi qua một hàng đợi chung của tiến trình, và giao sự kiện delta chứ không chỉ giá trị. Giao diện mọi nguồn state (cell, derivation, member state của một reference) cùng tuân theo:

StateEvent và Readable: subscribe cho giá trị, events cho luồng delta
pi/packages/chord/src/types.tsdòng 37–49TypeScript
export type StateEvent<T> =	| { readonly type: "snap"; readonly value: T }	| { readonly type: "ops"; readonly ops: readonly Op[]; readonly value: T }	| { readonly type: "reset"; readonly value: T };/** A readable state source: a cell, a derivation, or a reference's state member. Values are immutable by contract. */export interface Readable<T> {	readonly value: T;	/** Called with the current value immediately, then with every new value. */	subscribe(listener: (value: T) => void): () => void;	/** Delta stream: a snapshot immediately, then ops, or a reset after a migration or rebind. */	events(listener: (event: StateEvent<T>) => void): () => void;}

Tracker chỉ theo dõi container, mà một cell có thể chứa số hay chuỗi. Cell vì vậy bọc giá trị trong { v: value } và gỡ lớp bọc khỏi phép toán trước khi công bố:

Box và unbox(): bỏ đoạn đầu "v" của đường dẫn
pi/packages/chord/src/cell.tsdòng 122–147TypeScript
/** The tracker tracks containers, so a cell tracks `{ v: value }` and strips the box from published ops. */interface Box<T> {	v: T;}function unbox(ops: readonly Op[], value: unknown): Op[] {	const result: Op[] = [];	for (const op of ops) {		if (op[0] === "r" || op[1].length === 0) return [["r", value as JsonValue]];		const path = op[1].slice(1);		switch (op[0]) {			case "p":				result.push(["p", path, op[2], op[3], op[4]]);				break;			case "m":				result.push(["m", path, op[2]]);				break;			default:				// s/d/a/t on the value itself replace it.				if (path.length === 0) return [["r", value as JsonValue]];				if (op[0] === "d") result.push(["d", path as [string | number, ...(string | number)[]]]);				else result.push([op[0], path as [string | number, ...(string | number)[]], op[2]] as Op);		}	}	return result;}
Một phép toán nhắm thẳng vào v (gán số mới cho cell kiểu số) trở thành ["r", value] của cell.
Cell.change(): draft hoặc giá trị trả về, rồi #publish
pi/packages/chord/src/cell.tsdòng 194–219TypeScript
/** * Mutates a draft (only mutated paths are published), or returns a new value (`n => n + 1`). A returned value is * always validated against the schema. The draft is unusable after `fn` returns. */change(ctx: Ctx, fn: (draft: Draft<T>) => T): void;change(ctx: Ctx, fn: (draft: Draft<T>) => void): void;change(ctx: Ctx, fn: (draft: Draft<T>) => unknown): void {	this.#guard(ctx);	if (!this.#initialized) throw notReady(`state ${this.key} (its init has not run)`);	const change = this.#tracker.beginChange();	let prepared: ReturnType<typeof change.prepare>;	try {		const draft = change.state as Draft<Box<T>>;		const result: unknown = fn(draft.v as Draft<T>);		if (result instanceof Promise) throw new TypeError("cell.change callbacks must be synchronous");		if (result !== undefined) {			if (result !== draft.v) assertValid("state", `state ${this.key}`, this.schema, result);			draft.v = result as Draft<T>;		}		prepared = change.prepare();	} catch (error) {		change.abort();		throw error;	}	this.#publish(prepared, "ops");}
guard(ctx) là cổng ghi của object: không ghi trong factory, không ghi với ctx đã huỷ hay ctx của host khác, và chịu kiểm tra code version khi reload (object.ts:1546–1553). Callback trả về một giá trị (n => n + 1) thì giá trị đó được kiểm theo schema rồi gán vào v.

#publish() là chỗ cell khác replicatedState: trước khi adopt, nó tính lại mọi derivation phụ thuộc (bắc cầu) trên giá trị ứng viên, và ở chế độ dev kiểm chúng theo schema (cell.ts:110–115); một derivation ném lỗi, hay một giá trị sai schema ở chế độ dev, huỷ preparation và cell giữ nguyên. Chỉ khi mọi thứ hợp lệ, nó adopt cell, gửi sự kiện, rồi cài giá trị mới cho các derivation, tất cả bên trong holdPublications:

Cell.#publish(): kiểm derivation trước, rồi adopt và phát sự kiện trong một đợt
pi/packages/chord/src/cell.tsdòng 246–272TypeScript
/** Adopts this revision and every derived one, then queues all notifications (cell first, then derivations). */#publish(	prepared: ReturnType<Tracker<Box<T>>["prepareReplace"]>,	type: "ops" | "reset",	validate = true,	ops?: readonly Op[],): void {	if (prepared.ops.length === 0 && type === "ops") {		this.#tracker.adopt(prepared);		return;	}	const value = prepared.value.v;	let candidates: Map<Derived<unknown>, unknown>;	try {		if (validate) this.validate(value);		candidates = computeDependents(this as Source<unknown>, value, validate);	} catch (error) {		prepared.abort();		throw error;	}	holdPublications(() => {		this.#tracker.adopt(prepared);		const published = ops !== undefined && ops.length > 0 ? ops : unbox(prepared.ops, value);		this.fanout.emit(type === "reset" ? { type, value } : { type, ops: published, value });		for (const [derived, next] of candidates) derived.install(next);	});}

Lý do của holdPublications và enqueue được viết ngay đầu publish.ts, theo đúng lối "bài toán, ví dụ, lời giải":

publish.ts: một hàng FIFO cho mọi thông báo
pi/packages/chord/src/publish.tsdòng 1–29TypeScript
// One FIFO queue for every observer notification (state events, shape pushes, registry tables, listener// invocations).//// Problem: notifying observers synchronously from inside a write lets a re-entrant write overtake the write being// published. Example: subscriber A reacts to n = 1 by writing n = 2; subscriber B then sees 2 before 1.// Solution: a publication first adopts every revision it makes (cells and derivations), then enqueues its// notifications. Notifications run in enqueue order, so every observer sees revisions in adoption order.// `holdPublications` defers delivery until a multi-step change (a reload commit) is complete.const queue: (() => void)[] = [];let held = 0;let draining = false;/** Runs `task` after everything enqueued before it. Tasks must not throw (report instead). */export function enqueue(task: () => void): void {	queue.push(task);	drain();}/** Delivers nothing until `fn` returns, then everything it enqueued, in order. */export function holdPublications<T>(fn: () => T): T {	held++;	try {		return fn();	} finally {		held--;		drain();	}}
Không có hàng này, subscriber A phản ứng với n = 1 bằng cách ghi n = 2, và subscriber B thấy 2 trước 1. Với nó, mọi người quan sát thấy revision theo thứ tự adopt.

Derivation (self.derive(sources, fn)) là một phép chiếu đồng bộ. Nguồn là cell hay derivation cùng object thì được tính lại ngay trong lần ghi (như trên); nguồn là thứ khác (member state của một reference) thì được đăng ký và tính lại khi nguồn công bố. Mỗi lần cài giá trị mới, derivation phát lô phép toán bằng diffRevisions() giữa giá trị cũ và mới (cell.ts:388–396). Derivation không có tên và được tạo mới ở mỗi lần reload; cell có tên và giữ giá trị qua reload (mục hot reload).

Contract, schema và luật tương thích#

Bài toán: plugin được build riêng và chở đi dưới dạng bundle JavaScript, không còn TypeScript. Một bên cung cấp (provider) và một bên dùng (consumer) có thể được build từ hai phiên bản khác nhau, ở hai tiến trình khác nhau. Runtime phải tự kiểm hai bên có khớp không và kiểm dữ liệu đi qua link. Vì vậy contract là giá trị lúc chạy; kiểu TypeScript được suy ra từ nó (guide.md:76). Ví dụ nhanh của README:

README Quick start: contract Counter, factory, và một root gọi nó
pi/packages/chord/README.mddòng 33–63TypeScript
// contract.ts: shared by provider and consumersexport const Counter = remote("demo.counter", {	version: 1,	input: Type.Object({ name: Type.String() }),	host: hostNamed("server"), // where its objects live; callers never name a host	variants: {		default: {			state: { count: Type.Number() },			methods: { increment: method(Type.Object({ by: Type.Number() })) },		},	},});const host = createHost({ name: "server", self: { kind: "server" } });host.serve(Counter, ({ name }, _principal, self) => {	const count = self.state("count", Type.Number(), 0);	return {		variant: "default",		count,		increment: async ({ by }, ctx) => {			count.change(ctx, (n) => n + by);		},	};});await host.root(async (ctx) => {	const clicks = await ctx.get(Counter, { name: "clicks" }); // ready, with its state; released when this root ends	clicks.count.subscribe((n) => console.log("count", n));	await clicks.increment({ by: 2 }, ctx); // logs 0, then 2});
Input { name } là địa chỉ của object, không phải tham số của method. host: hostNamed("server") nói object sống ở đâu, nên người gọi không bao giờ nêu tên host.

Có hai loại contract. remote() cho loại object gọi được từ host khác: mọi thứ đi qua dây đều có schema (input, member state theo từng variant, tham số và kết quả của method), và bắt buộc có placement. local() cho object chỉ dùng trong host: member có thể là bất cứ gì (hàm, callback, object gốc của Node), nhưng phải được liệt kê (method, opaque("name@version"), hoặc một schema state) để một reference còn đang loading biết ref.x là method, state hay giá trị (spec.md:56).

remote(): kiểm placement, mô tả từng member, đóng dấu fingerprint
pi/packages/chord/src/contract.tsdòng 219–252TypeScript
export function remote<const D extends RemoteDef>(	name: string,	def: D & { readonly host: PlacementFor<Static<D["input"]>> },): RemoteContract<D> {	checkHeader(name, def.version);	const placement = def.host as Placement | undefined;	if (placement === undefined || !Value.Check(PlacementSchema, placement))		throw new TypeError(`remote contract ${name} needs \`host\`: hostNamed(…) or hostFamily(…)`);	// A JSON copy: the description travels, and a placement object may carry nothing else.	const host: Placement =		"name" in placement ? { name: placement.name } : { prefix: placement.prefix, key: placement.key };	const variants: Record<string, Record<string, MemberDescription>> = {};	if (Object.keys(def.variants).length === 0) throw new TypeError(`remote contract ${name} has no variants`);	for (const [kind, variant] of Object.entries(def.variants)) {		const members: Record<string, MemberDescription> = {};		for (const [key, schema] of Object.entries(variant.state ?? {}))			members[key] = describeMember(key, schema, "remote");		for (const [key, spec] of Object.entries(variant.methods ?? {})) {			if (key in members) throw new TypeError(`member ${key} is both state and method in ${name}.${kind}`);			members[key] = describeMember(key, spec, "remote");			if (members[key].kind !== "method") throw new TypeError(`methods.${key} is not a method()`);		}		variants[kind] = members;	}	const description: Description = { name, scope: "remote", version: def.version, input: def.input, host, variants };	return finish<RemoteContract<D>>({		scope: "remote",		name,		version: def.version,		input: def.input,		def,		description,	});}
finish() gắn fingerprint = hash64(canonicalJson(description)) (contract.ts:210–212): một hash 64 bit không mật mã, hai làn FNV-1a, đồng bộ và chạy được trong trình duyệt; contract là thứ được tin nên va chạm không phải bề mặt tấn công (schema.ts:21–31).

Mô tả (Description) là một object JSON thuần, có schema riêng (contract.ts:147–156), vì nó đi qua dây làm "token" của consumer trong frame hold. Ở phía nhận, contractFromDescription() dựng lại một contract chỉ dùng để bind và kiểm, không mang kiểu TypeScript (contract.ts:268–286).

Input là địa chỉ

{ session: "S1", conversation: 1 } luôn chỉ cùng một object. Input được chuẩn hoá (điền mặc định, bỏ trường lạ), kiểm, rồi tuần tự hoá chính tắc; trường bằng giá trị mặc định bị bỏ khỏi danh tính, nên thêm một trường tuỳ chọn có mặc định không đổi địa chỉ cũ (guide.md:133; schema.ts:45–55). Đổi một giá trị mặc định thì phải đổi tên loại object.

Placement của loại remote là dữ liệu: hostNamed("server"), hoặc hostFamily("session:", "session") nghĩa là host session: cộng trường session của input (contract.ts:63–92). Vì placement nằm trong mô tả, nó nằm trong fingerprint: chuyển một loại object sang host khác là thay đổi phá vỡ (guide.md:108–113).

Luật tương thích

bind(): cùng fingerprint, khác placement, cùng version, hay qua adapter
pi/packages/chord/src/contract.tsdòng 325–351TypeScript
/** How a consumer token binds to a provider: directly or through an `accepts` adapter. Throws contract_mismatch. */export function bind(provider: AnyContract, accepts: Accepts | undefined, consumer: AnyContract): Adapter | undefined {	if (provider.fingerprint === consumer.fingerprint) return undefined;	// A moved type is another type for its consumers: no adapter reaches across hosts.	if (!samePlacement(provider.description, consumer.description)) {		throw new ChordError(			"contract_mismatch",			`${provider.name} lives on ${placementOf(provider.description)}, the consumer expects ${placementOf(consumer.description)}`,		);	}	if (provider.version === consumer.version) {		const reason = coverage(provider.description, consumer.description);		if (reason === undefined) return undefined;		throw new ChordError(			"contract_mismatch",			`${provider.name}@${provider.version} does not cover the consumer's description (${reason}); forgotten version bump?`,		);	}	const adapter = accepts?.[consumer.version];	if (adapter === undefined) {		throw new ChordError(			"contract_mismatch",			`${provider.name}: provider version ${provider.version}, consumer version ${consumer.version}, no accepts adapter`,		);	}	return adapter;}
Cùng fingerprint thì bind thẳng. Khác placement thì không adapter nào bắc cầu được. Cùng version mà không phủ (cover) là "forgotten version bump?", bị từ chối.

"Phủ" (cover) được định nghĩa từng member trong coverage(): mỗi member của consumer phải có ở provider với cùng loại; method thì hai bên cùng nhận hoặc cùng không nhận tham số, provider nhận được mọi tham số consumer có thể gửi, và kết quả của provider vừa với kết quả consumer chờ; state thì giá trị của provider vừa schema của consumer; opaque thì cùng name@version (contract.ts:288–314). Câu hỏi "vừa" trả lời bằng fits(sub, sup), một phép kiểu con cấu trúc bảo thủ trên JSON Schema: chỉ trả về đúng khi mọi giá trị hợp lệ theo sub chắc chắn hợp lệ theo sup, và một từ khoá nó không mô hình (not, allOf, $ref...) buộc hai schema phải giống hệt (schema.ts:128–160).

Hệ quả thực hành, theo bảng của guide (guide.md:147–152): thêm method, thêm tham số tuỳ chọn, thêm trường kết quả hay trường state thì giữ version; bỏ hay đổi tên member, biến tham số thành bắt buộc, đổi kiểu thì tăng version và cung cấp accepts cho consumer cũ; đổi nghĩa mà giữ hình dạng (ví dụ query giờ là regex) cũng phải tăng version, và không gì phát hiện được việc đó thay bạn. Adapter là hàm biến implementation hiện tại thành view mà consumer bản cũ mong đợi (guide.md:154–161).

Kiểu của một reference được suy ra từ contract. Với loại remote nhiều variant, nó là một union phân biệt bằng variant: member của variant khác chỉ với tới được sau khi thu hẹp kiểu (contract.ts:431–440).

Host, object, reference và activation#

Host là runtime sở hữu các object của một tiến trình (hay một tab). createHost({ name, self, principalKey }) bắt buộc self: principal mà code của chính host hành động thay mặt. Chord không tự đặt ra principal nào; ứng dụng cung cấp mọi principal (host.ts:109–124). Một facet của plugin gọi host.serve(type, factory, options); factory chạy đồng bộ, khai báo những gì object cần qua self, và trả về implementation (guide.md:163–175).

Bảng khai báo của guide nói mỗi thứ sống sót qua reload thế nào (guide.md:203–212). Một quy tắc đi kèm hay bị quên: state cần sống qua reload phải nằm trong cell, không nằm trong biến closure, vì factory chạy lại ở mỗi lần reload (guide.md:222–241).

Lấy một reference: acquire

Mọi đường (self.get, ctx.get, host.get, host.hold, một frame hold từ link) đều đi qua HostImpl.acquire():

acquire(): ở host khác thì giữ từ xa; ở đây thì bind, định địa chỉ, tạo object nếu chưa có
pi/packages/chord/src/host.tsdòng 544–557, 566–590TypeScript
acquire(	type: AnyContract,	input: unknown,	options: GetOptions | undefined,	owner: RefOwner,	from?: ObjectImpl,	candidate = false,): RefCore {	const elsewhere = this.#elsewhere(type, input);	if (elsewhere !== undefined) return this.#acquireRemote(type, elsewhere.at, elsewhere.input, options, owner);	const service = this.#bindingService(type.name, candidate);	if (service === undefined) throw unknownType(type.name, this.name);	bind(service.contract, service.options.accepts, type);	const { address, normalized, principal } = this.#locate(service, input, options?.principal ?? this.principal);	let object = this.objects.get(address);	if (from !== undefined && object !== undefined && (object === from || reaches(object, from))) {		throw invalid("cycle", `${from.address} -> ${address} would create a ref cycle`);	}	if (object === undefined) {		if (this.gating?.has(type.name)) {			throw new ChordError("upgrade_conflict", `${type.name} is being upgraded; retry`, { reason: "gate" });		}		const dying = this.#dying.get(address);		if (dying?.status === "disposed") this.#dying.delete(address);		object = new ObjectImpl(this, {			address,			typeName: type.name,			input: normalized,			principal,			hostLifetime: hostLifetime(service),		});		this.objects.set(address, object);		// A new object for an address still being disposed waits for that cleanup (exclusive resources).		object.start(dying?.status === "disposing" ? dying.dispose(false) : undefined);	}	const ref = new RefImpl(object, type, owner);	object.addRef(ref);	return ref;}
Phần bị lược kiểm placement của chính provider: input nằm ở host khác thì là invalid/placement, bất kể request nói nó đi đâu. Một tham chiếu tạo chu trình trong cùng host bị từ chối ngay ở đây.

Địa chỉ của object là tên loại \0 danh tính chính tắc của input \0 phần principal, trong đó principal của chính host (so bằng principalKey) là host (host.ts:515–533). Nên có đúng một object cho mỗi (type, input, principal key): hai tab của cùng chủ sở hữu dùng chung một object, một khách (guest) có object riêng, và code bên trong host luôn hành động như self nên các object nội bộ được chia sẻ (guide.md:356–362).

Activation: factory đồng bộ, việc chậm làm sau

Factory không được await; mở database hay chờ một object khác là việc của activation, chạy sau khi factory đã khai báo xong:

ObjectImpl.#activate(): factory, mở resource và chờ reference, hydrate, init, rồi live
pi/packages/chord/src/object.tsdòng 1095–1113, 1131–1142TypeScript
async #activate(after: Promise<void> | undefined): Promise<void> {	await after?.catch(noop);	const signal = this.activationAbort.signal;	const abandoned = (): ChordError => new ChordError("aborted", `${this.address} abandoned`);	if (signal.aborted) throw abandoned();	const service = this.host.service(this.typeName);	if (service === undefined)		throw new ChordError("unavailable", `${this.typeName} is not served`, { reason: "unknown_type" });	this.service = service;	const run = this.run(service, undefined);	try {		await this.open(run.created.resources, run.created.refs, signal);		if (signal.aborted) throw abandoned();		await this.waitHydrated(run);		if (signal.aborted) throw abandoned();		await this.initCells(run, signal);		if (signal.aborted) throw abandoned();		this.host.checkRegistrations(this, run.decls);		this.decls = run.decls;		for (const derived of run.created.derives) derived.attach();		this.refreshSchemas();		// Served only by a running reload's pending code version: new code must not run (listeners, tasks,		// registrations) before that reload commits.		this.deferredRuntime = !this.implementations.has(this.host.currentCodeVersion());		if (!this.deferredRuntime) {			if (run.decls.registrations.size > 0) this.host.registriesChanged(this);			this.startRuntime();		}		// A listener starting up may have released the last reference synchronously: no resurrection.		if (signal.aborted) throw abandoned();		this.status = "live";
Phần bị lược cài implementation cho mọi code version đang giữ dùng cùng service, và ném superseded nếu code đổi trong lúc activation. Mọi lỗi ở bất kỳ bước nào gỡ lại toàn bộ: dừng listener và task, đóng resource đã mở theo thứ tự ngược, nhả reference (object.ts:1143–1157).

Các bước tương ứng với sơ đồ năm bước của guide (guide.md:249–256): chạy factory (inFactory tăng, nên dùng thứ vừa khai báo trong factory là not_ready), mở resource theo phụ thuộc và chờ reference sẵn sàng trong readyTimeout (mặc định 60 s), chờ mọi state được expose có giá trị, chạy init của các cell khai báo bằng { init }, kiểm registry, gắn derivation, rồi khởi động listener và task. Nhiều get cùng một địa chỉ dùng chung một activation; nếu mọi người nhả trước khi xong, kết quả bị huỷ (guide.md:276–281).

Lifetime, principal và variant

Object sống khi còn được tham chiếu. lifetime: "referenced" (mặc định) tạo object ở lần get đầu và huỷ sau khi tham chiếu cuối được nhả cộng linger; lifetime: "host" chỉ dành cho loại local có input rỗng, tạo một object duy nhất khi facet nạp và giữ tới khi host đóng, và chỉ object loại này được ghi vào registry (spec.md:135–152). Khi hết tham chiếu và hết lời gọi đang chạy, #maybeIdle() hẹn huỷ (object.ts:1646–1666). Huỷ dừng task, gỡ listener, chờ mọi lời gọi xong, đóng resource theo thứ tự mở ngược, rồi nhả các reference của chính object, có thể kéo theo huỷ object khác (object.ts:1681–1721).

Factory của loại remote chọn một variant theo principal: khách xem được trả về { variant: "viewer", view }, người tham gia nhận thêm send và stop (guide.md:366–381). Variant không phải toàn bộ phân quyền: method vẫn tự kiểm tham số, cờ thay đổi trong lúc giữ object là state, và thu hồi là host.invalidatePrincipal(match), ép huỷ các object khớp ở đây và qua link (guide.md:389–392).

Root, Ctx và một lời gọi method#

Một root là một cây lời gọi ngắn, mở ở điểm vào tin cậy: link nhận một request không mang root, host.root(fn) cho timer và script, mỗi sự kiện giao cho một listener, mỗi lời gọi Chord từ một task (guide.md:495–501). Root chạy trên code version hiện hành lúc nó bắt đầu, xuyên qua các host, cho tới khi kết thúc. Đó là cơ chế để hot reload không cắt ngang công việc đang chạy.

root() và joinRoot(): một rootId, một code version, một Ctx đóng lại khi xong
pi/packages/chord/src/host.tsdòng 612–617, 625–635, 644–666TypeScript
async root<T>(fn: (ctx: Ctx) => T | Promise<T>, options: RootOptions = {}): Promise<T> {	// The incarnation keeps root ids unique across restarts of a host with the same name.	const rootId = `${this.name}#${this.#incarnation}:${++this.#rootSeq}`;	this.#local.add(rootId);	return this.joinRoot(rootId, options.codeVersion ?? this.current.id, fn, options);}async joinRoot<T>(	rootId: string,	codeVersionId: number,	fn: (ctx: Ctx) => T | Promise<T>,	options: { readonly principal?: unknown; readonly signal?: AbortSignal; readonly from?: Connection } = {},): Promise<T> {	const codeVersion = this.codeVersions.get(codeVersionId);	if (codeVersion === undefined) {		this.#local.delete(rootId);		throw new ChordError("superseded", `code version ${codeVersionId} is not retained by ${this.name}`);	}	codeVersion.roots.set(rootId, (codeVersion.roots.get(rootId) ?? 0) + 1);	const ctx = new CtxImpl(this, {		// Forced retirement aborts what still runs on a code version before closing what only it used.		signals:			options.signal === undefined ? [codeVersion.abort.signal] : [options.signal, codeVersion.abort.signal],		rootId,		codeVersion: codeVersion.id,		principal: options.principal ?? this.principal,	});	try {		return await fn(ctx);	} finally {		ctx.close();		const count = (codeVersion.roots.get(rootId) ?? 1) - 1;		if (count > 0) codeVersion.roots.set(rootId, count);		else {			codeVersion.roots.delete(rootId);			if (!this.#foreign.has(rootId)) this.endRoot(rootId);			this.maybeRetire();			this.#retirementChanged();		}	}}
rootId mang một "incarnation" ngẫu nhiên để duy nhất qua các lần khởi động lại của host cùng tên. Code version không còn được giữ thì root bị từ chối superseded (thử lại được). Khi lời gọi cuối của root ở host này xong, host thử retire code version cũ.

CtxImpl gộp tín hiệu huỷ của người gọi với tín hiệu của code version, nên một lần retire cưỡng bức huỷ được mọi root còn chạy trên code cũ (object.ts:435–454). Nó cố tình không dùng AbortSignal.any: Node giữ các tín hiệu phụ thuộc còn listener sống theo tín hiệu nguồn sống lâu, nên mỗi ctx sẽ giữ closure (và code) của nó lâu như object (object.ts:485–510; CHANGELOG.md:67). ctx.get() lấy một reference thuộc về lời gọi; close() nhả nó khi lời gọi xong (object.ts:456–482).

Reference là một proxy gắn muộn

Người gọi không bao giờ cầm implementation. RefImpl lộ ra qua một Proxy: các trường cơ sở (status, ready, variant, release...) đọc thẳng, mọi tên khác đi qua member(name), tra theo contract của consumer và implementation hiện hành (object.ts:789–799). Method trả về một hàm ổn định luôn gọi tới code hiện hành; member state trả về một MemberReadable ổn định, tự đăng ký lại khi reload hay đổi variant đổi nguồn của nó và báo một reset (object.ts:613–618). Gọi không kèm ctx, hay từ một task (code holder), thì mở một root mới (object.ts:944–973).

admit(ctx) là cổng của mọi lời gọi và mọi lần ghi cell: host đã hỏng thì unavailable/host_failed; code version không còn thì superseded; object đã được nâng cấp ở code version mới hơn thì upgrade_conflict; object đang ở sau cổng (gate) của một reload mà root này chưa từng chạm vào nó thì cũng upgrade_conflict (object.ts:1521–1537). lease(ctx) ghi nhận root đã chạm vào object, để một gate sau đó cho phép nó đi tiếp.

Lỗi

Mười hai mã lỗi, lý do chi tiết, và hai mã thử lại được
pi/packages/chord/src/errors.tsdòng 1–16, 24–45TypeScript
/** Error codes callers see (guide table). A refused or aborted reload keeps the code of its cause. */export const CHORD_ERROR_CODES = [	"aborted",	"contract_mismatch",	"forbidden",	"gone",	"invalid",	"not_in_variant",	"not_ready",	"released",	"superseded",	"unavailable",	"upgrade_conflict",	"upgrade_required",] as const;export type ChordErrorCode = (typeof CHORD_ERROR_CODES)[number];const RETRYABLE: ReadonlySet<ChordErrorCode> = new Set(["superseded", "upgrade_conflict"]);export class ChordError extends Error {	readonly code: ChordErrorCode;	readonly reason: ChordErrorReason | undefined;	readonly retryable: boolean;	readonly details: unknown;	constructor(		code: ChordErrorCode,		message: string,		options?: { cause?: unknown; details?: unknown; reason?: ChordErrorReason },	) {		super(`${code}${options?.reason ? `/${options.reason}` : ""}: ${message}`, {			cause: options?.cause,		});		this.name = "ChordError";		this.code = code;		this.reason = options?.reason;		this.retryable = RETRYABLE.has(code);		this.details = options?.details;	}}
Một ChordError ném từ factory hay từ một phụ thuộc giữ nguyên mã qua mọi bên phụ thuộc; lỗi khác thành unavailable với lỗi gốc làm cause (errors.ts:56–62).

Qua link, lỗi đi dưới dạng { code, message, reason } và được dựng lại thành ChordError ở phía nhận (wire.ts:171–181). Bảng nghĩa từng mã nằm trong guide (guide.md:784–799); hai mã đáng nhớ: superseded (code version của root không còn trên host này) và upgrade_conflict (một root cũ chạm vào object vừa được migrate), cả hai thử lại được bằng một root mới.

Hot reload: prepare, gate, commit, retire#

Lời hứa của README: hot reload thay code trong lúc chạy; việc đang bay xong trên code nó đã bắt đầu, state được giữ, và thay đổi không tương thích được migrate ở một điểm an toàn (README.md:9). Spec coi đó là ràng buộc cứng: chỉ khởi động lại host khi chính Chord đổi, hoặc khi phục hồi sau crash (spec.md:8). Bài blog của Armin Ronacher về Pi nhấn mạnh hot reload để agent tự viết và thử extension của mình ("Pi: The Minimal Agent Within OpenClaw"); Chord đưa ý đó lên mức một runtime nhiều tiến trình.

Nạp module mới

nodeLoader: import lại với query chống cache, mọi thư mục facet được đăng ký trước
pi/packages/chord/src/loader.tsdòng 1–5, 44–67TypeScript
// Node facet loader: `createHost({ loader: nodeLoader })`. Kept out of index.ts so the core stays browser-safe.//// TODO(loader): replace with an isolating, evicting loader. Every reload imports fresh module copies under a new// query and the old copies are never evicted (they leak until the process exits). TUI plugins need isolation, which// this loader does not provide.export const nodeLoader: FacetLoader = async (modules) => {	hook();	const version = String(++generation);	const facets: Facet[] = [];	const urls = modules.map((module) => {		const url = toUrl(module);		url.search = "";		return url;	});	// Every root first: a facet importing another facet's directory gets this load's copy, whatever the order.	for (const url of urls) roots.add(new URL(".", url).href);	for (const url of urls) {		const name = url.href;		url.searchParams.set(PARAM, version);		// Owner-approved exception to the no-dynamic-import rule; see TODO(loader) above.		const loaded = (await import(url.href)) as { default?: FacetDefinition };		const definition = loaded.default;		if (definition === undefined || typeof definition.setup !== "function") {			throw new TypeError(`${name}: default export must be a facet (defineFacet)`);		}		facets.push({ name, setup: definition.setup, uses: definition.uses });	}	return facets;};
Query trên URL chỉ làm Node nạp lại module gốc; một hook registerHooks mang query đó xuống các import tương đối bên trong thư mục của facet, còn Chord và code dùng chung vẫn là một bản duy nhất (loader.ts:17–36). Bản cũ của module không bao giờ được giải phóng.

Năm bước

  1. LoadChạy setup của các facet mới vào một bảng service ứng viên; tính các loại đã đổi và đã bị gỡ; cấp phát code version kế tiếp ở trạng thái pending (reload.ts:278–303).
  2. PrepareChạy factory mới trên mọi object đang sống của loại đã đổi, ở chế độ ghi lại: đọc được cell, ghi cell thì ném lỗi, listener và task chưa chạy, resource đổi version được mở cạnh bản cũ. Kiểm contract với mọi consumer còn sống. Lỗi bất kỳ: mọi thứ giữ nguyên.
  3. GateChỉ khi một cell tăng version, hoặc một resource độc quyền (exclusive) bị thay: chặn root cũ mới vào, chờ root đã vào xong, migrate vào bản sao riêng.
  4. CommitTrong một bước đồng bộ: cài code mới, đổi bảng registry, khởi động lại task, gắn lại listener, gửi reset và shape mới cho người quan sát.
  5. RetireCode cũ tiếp tục chạy các root nó đã bắt đầu; khi chúng xong, nó bị bỏ cùng những gì chỉ nó khai báo.

Đây là tóm tắt của guide (guide.md:630–636); code của prepare() đi đúng thứ tự đó:

Reload.prepare(): retire bản trước, load, kiểm provider, prepare tới điểm cố định, gate
pi/packages/chord/src/reload.tsdòng 204–224TypeScript
/** Everything up to the commit. On failure the host is rolled back and the underlying error is thrown. */async prepare(): Promise<void> {	try {		await this.waitForRetirement();		this.load();		this.checkProviders();		await this.prepareAll();		// From here new objects of changed types are rejected (upgrade_conflict, retryable) until commit or abort,		// so the candidate set is final once the activations already in flight have been prepared.		this.host.gating = this.changed;		await this.withDeadline(this.prepareAll(), "activations in flight did not finish");		this.checkRefs();		this.tables = this.host.buildTables(this.entries());		await this.gate();		this.#alive();		this.#stage();	} catch (error) {		await this.rollback();		throw reloadError(error);	}}
prepareAll() chạy hai lần: lần hai sau khi bật gating, để bắt cả các object đang activation dở. Từ lúc đó, tạo object mới của một loại đã đổi bị từ chối upgrade_conflict (thử lại được) cho tới commit hoặc abort.

Mỗi cell mang version của riêng nó, và luật được thực thi ngay khi prepare một object:

prepareObject(): hạ version bị từ chối, tăng version thì qua gate, đổi schema không tăng version bị từ chối
pi/packages/chord/src/reload.tsdòng 388–398TypeScript
// Each cell carries its own version: a raised one gates and migrates, a schema change without one is refused.for (const [name, cell] of run.decls.cells) {	if (object.decls.cells.get(name) !== cell) continue;	const from = object.decls.cellVersions.get(name) ?? 1;	const to = run.decls.cellVersions.get(name) ?? 1;	if (to < from)		throw invalid("state", `${object.address}: version of state ${name} lowered from ${from} to ${to}`);	if (to > from) candidate.reasons.push(`state ${name} version ${from} -> ${to}`);	else if (!deepEqual(cell.schema, run.decls.cellSchemas.get(name)))		throw invalid("state", `${object.address}: schema of state ${name} changed without a version bump`);}

Gate và migrate

Bài toán: một root cũ đang giữa chừng đọc state theo hình dạng cũ; nếu migrate ngay dưới chân nó, code cũ sẽ chạy trên dữ liệu mới. Gate giải bằng một lát cắt nhận vào (admission cut): root đã chạm vào object (có lease) được "miễn trừ" (grandfathered) và được vào lại; root cũ chưa chạm thì nhận upgrade_conflict để thử lại như root mới. Task của object bị dừng ở lát cắt.

gate(): đặt cổng, dừng task, chờ root được miễn trừ, quá hạn thì huỷ reload
pi/packages/chord/src/reload.tsdòng 457–468, 478–488TypeScript
async gate(): Promise<void> {	this.gated = [...this.candidates.values()].filter(		(candidate) => candidate.reasons.length > 0 && candidate.object.status === "live",	);	const drained = this.gated.map(({ object }) => {		const grandfathered = new Set(object.leases.keys());		return new Promise<void>((resolve) => {			object.gate = { grandfathered, queue: [], drained: resolve };			object.stopTasks();			if (grandfathered.size === 0) resolve();		});	});	if (timedOut) {		const blocking = this.gated.flatMap(({ object }) => [...(object.gate?.grandfathered ?? [])]);		throw new ChordError(			"aborted",			`gate deadline (${deadline} ms) missed; blocking roots: ${blocking.join(", ")}`,			{				reason: "gate_deadline",				details: { blocking },			},		);	}
Quá gateDeadline (mặc định 5 s) thì cả reload bị huỷ và không có gì thay đổi; lỗi mang tên các root đang chặn trong details. Sau đó resource độc quyền được thay (đóng cũ, mở mới, mở lại cũ nếu mới lỗi), rồi migrate(structuredClone(cell.value), from) chạy trên một bản sao riêng và kết quả được kiểm theo schema mới (reload.ts:505–521).

Tutorial minh hoạ cả hai trường hợp: đổi nhãn (tương thích) không phải chờ, root cũ đang tạm dừng vẫn thấy "Original" hai lần trong khi lời gọi mới thấy "Updated" (tutorial.md:651–684); đổi nghĩa của rows (viết hoa) với version: 2 và migrate thì đi qua gate, và subscriber nhận snap rồi reset (tutorial.md:694–727).

Commit: mọi thứ có thể lỗi đã xảy ra trước đó

commit(): cài trong holdPublications, rồi mới khởi động việc có thể mở root mới
pi/packages/chord/src/reload.tsdòng 561–582TypeScript
/** * Publishes the new code version in one synchronous step. Everything fallible ran in prepare (factories, opens, * migration and derivation checks against the prospective schemas, listener subscriptions), so this only installs. * Observers (state, shapes, registries, listener roots) are notified after the whole step. If an install still * throws, the code version is half-published: the host fails terminally and admits no more work. */commit(beforeStart?: () => void): ReloadReport {	try {		return holdPublications(() => {			const report = this.#publish();			// Installed, not yet started: the coordinator queues its commit frames here, so nothing this code version			// starts (tasks, host object activations, then the held listener/registry/state notifications) can reach a child			// first.			beforeStart?.();			for (const start of this.#starts.splice(0)) start();			return report;		});	} catch (error) {		this.host.fail(error);		throw reloadError(error);	}}
Factory, mở resource, migrate, kiểm derivation theo schema tương lai và đăng ký listener đều đã chạy ở prepare (#stage() đăng ký listener và đệm sự kiện). Commit chỉ cài. Nếu một bước cài vẫn ném lỗi, code version bị công bố dở dang: host chuyển sang hỏng vĩnh viễn và từ chối mọi việc mới.

#publish() đổi previous thành code version đang chạy và current thành bản mới, cài implementation mới cho các object đã prepare, chép implementation cũ sang id mới cho object không đổi, cài bảng registry, ép huỷ object của loại bị gỡ, và đẩy việc khởi động task cùng tín hiệu facet ra sau (reload.ts:587–656). install() của một object được gate đặt minCodeVersion và bỏ ngay implementation cũ, vì code cũ không bao giờ được chạy trên state đã migrate; cell được migrate nhận reset; reference của trình duyệt mà contract không còn bind được nhận upgrade_required (reload.ts:658–722).

Luật hai phiên bản và retire

Một host chỉ giữ hai code version: current và previous. Lời gọi mang code version không còn được giữ bị từ chối superseded, không bao giờ được chạy bằng phiên bản khác (spec.md:198). previous được retire khi không còn root nào của nó (và, ở host con, khi cha đã retire nó) (host.ts:701–754). Reload kế tiếp chờ điều đó tối đa retireDeadline; quá hạn thì huỷ các root còn chạy (lý do superseded), cho chúng một giây, rồi retire bất kể (reload.ts:259–276).

Registry cũng theo code version: mỗi code version có một bảng hoàn chỉnh, registry(R).get(key, ctx) đọc bảng của ctx.codeVersion, nên một request bắt đầu trước reload vẫn thấy bảng cũ và không có lúc nào một mục bị thiếu (host.ts:787–809; guide.md:575–578). Spec ghi rõ Chord không kèm cây cầu sang RegistryReader của Durable; ứng dụng tự dựng nó từ bảng (spec.md:230).

Reload cả cụm

Trên server có host con, host.reload(serverFacets, { remote: { "session:": workerFacets } }) thành một commit hai pha (cluster.ts:7–14): server retire bản trước (frame retire tới con trước prepare nhờ link FIFO), prepare chính nó và gửi prepare cho mọi con đang đồng bộ, gom phiếu (vote) có hạn chót; một lỗi hay một phiếu trễ thì gửi abort mọi nơi; đủ phiếu thì commit ở server, gửi commit cho các con trước khi khởi động bất cứ thứ gì của code mới (cluster.ts:39–115). Một host con kết nối khi đang ở sau code version của cha bị cách ly (fenced) và được hoà giải (reconcile) bằng chính giao thức đó (cluster.ts:117–158). Spec liệt kê giới hạn: quyết định commit không được lưu, nên server crash giữa commit của nó và của con để con ở code cũ cho tới khi kết nối lại; chỉ một tầng con tham gia (spec.md:256–259).

Tiến trình host con chạy dưới createHostManager() của /node: fork với detached để con dẫn một process group và cả cây bị giết khi con thoát, một kênh điều khiển chung IPC với frame Chord (ready, fatal, status, shutdown), và một khoá tiến trình do kernel giữ (abstract socket trên Linux, named pipe trên Windows) mà con lấy trước khi mở storage (manager.ts:76–123; manager.ts:164–229).

Một link là kênh frame hai chiều, có thứ tự, giữa hai host (hoặc một host và một client trình duyệt). Nó không biết gì về object hay state; mọi ngữ nghĩa nằm ở Connection, một đầu của link trên mỗi host.

Link: FIFO, một bộ nhận, báo đóng, và kích thước frame đã gửi
pi/packages/chord/src/link.tsdòng 4–18TypeScript
/** An ordered (FIFO), bidirectional frame channel between two hosts. */export interface Link {	readonly closed: boolean;	/**	 * Returns the size of the encoded frame as sent (JSON text length, or bytes of a packed message) when the transport	 * knows it. The connection's flow control counts these sizes; frames without one count as zero, so a link that never	 * reports sizes is not windowed.	 */	send(frame: Frame): number | undefined;	/** Sets the single receiver. Frames arriving earlier are queued. */	receive(handler: (frame: Frame) => void): void;	onClose(handler: (reason: string) => void): void;	/** Closes both ends: the peer gets a `bye`. */	close(reason?: string): void;}
send() trả về kích thước frame đã mã hoá khi transport biết nó; flow control đếm các kích thước này, nên một link không báo kích thước (IPC) không bao giờ bị giới hạn cửa sổ.

Ba transport đi kèm. memoryLinkPair() cho test, nhưng vẫn dùng đúng giao thức: mỗi frame đi qua một vòng JSON.stringify/parse để tách khỏi bộ nhớ của bên gửi và để lộ những thứ JSON sẽ làm hỏng (như bigint), và giao không đồng bộ theo một chuỗi promise mỗi chiều (link.ts:242–269). ipcLink(process) bọc frame thành { chord2: frame } trên kênh IPC của child_process, để chung chỗ với tin điều khiển (link.ts:296–337). webSocketLink(socket) chạy trên bề mặt WebSocket chuẩn WHATWG, nên Chord không phụ thuộc thư viện ws; frame gửi trước khi socket mở được giữ trong outbox (link.ts:361–412). Phía server, acceptWebSockets() bọc socket ngay (để không mất frame trong lúc xác thực), gọi hàm xác thực của ứng dụng, rồi nhận link như một client với principal đã đóng dấu; không xác thực được thì đóng (websocket.ts:17–35).

Payload lớn (tham số, kết quả, ảnh chụp state có JSON dài hơn 64 KiB ký tự) được deflate. Host chỉ chuyển tiếp frame thì giữ nguyên bytes đã nén, chỉ host dùng payload mới giải nén (link.ts:105–113). Server nên từ chối frame nén từ client không tin cậy, vì một tin nhỏ có thể giải nén ra bất cứ kích thước nào (websocket.ts:24–26).

Frame

wire.ts: mỗi loại frame có một chiều, mọi frame nhận được đều được kiểm schema
pi/packages/chord/src/wire.tsdòng 6–24TypeScript
// JSON frames between two hosts (or a host and a client). Ids are chosen by the side that creates the hold, call or// subscription; every frame type has one direction, so ids never collide://   consumer → provider: hold, release, call, cancel, sub, unsub, resync//   provider → consumer: shape, gone, result, state// State is addressed by (hold id, member): a hold implies the state of its variant, sent as units (a shape followed by// a snapshot of each member the consumer lacks; see Connection).//   both: hello, ping, pong, bye, root_end, ack; parent → child: invalidate, prepare, commit, abort, retire;//   child → parent: vote//// Each frame is declared once, as the schema every received frame is checked against (`isFrame`); the types are derived// from it. Payloads (hold inputs, call args, results, snapshots) stay `unknown`: they are validated where they are// used, against the contract. State ops are checked op by op by the delta decoder./** * The link protocol version, announced in `hello`. Peers of different versions do not talk: the side that notices * closes the link with reason `protocol-version <its version>`, so the other side learns it too * (`LinkOptions.onProtocolMismatch`). */export const PROTOCOL_VERSION = 4;
Mỗi frame được khai báo một lần dưới dạng schema TypeBox mà mọi frame nhận được phải qua (isFrame); kiểu TypeScript suy ra từ schema. Payload (input, tham số, kết quả, ảnh chụp) để unknown và được kiểm ở nơi dùng theo contract.

Không có frame get riêng: một reference qua link là một hold, được trả lời bằng shape hoặc gone (spec.md:239). Frame hold mang host đích at, token contract của consumer, input đã chuẩn hoá, và có thể principal chuyển tiếp, origin, state: false, again (wire.ts:73–96). State đi trong frame state, đánh địa chỉ bằng id của hold và tên member:

Frame state: snap, reset, ops hoặc error cho một member của một hold
pi/packages/chord/src/wire.tsdòng 130–140TypeScript
Type.Object({	t: Type.Literal("state"),	hold: Id,	m: Name,	/** `snap`/`reset` without `value`: the value is `undefined`. */	as: Type.Union([Type.Literal("snap"), Type.Literal("reset"), Type.Literal("ops"), Type.Literal("error")]),	value: Type.Optional(Type.Unknown()),	/** Encoded with the stream's delta encoder; the decoder validates each op. */	ops: Type.Optional(List(List(Type.Unknown()))),	error: Type.Optional(WireErrorSchema),}),

Hai đầu công bố phiên bản giao thức trong hello; khác phiên bản thì không nói chuyện: bên phát hiện gọi onProtocolMismatch và đóng link với lý do protocol-version <n>, để bên kia cũng biết (connection.ts:832–859). Không thương lượng: bên cũ phải nạp code mới, trình duyệt thì tải lại trang (guide.md:747–753). Frame nhận được mà sai schema, trùng id, hay là frame điều khiển từ sai phía, thì đóng link (connection.ts:665–669).

Principal được đóng dấu, không được khai

#principalOf và #originOf: link phía dưới hành động như principal lúc accept, bất kể frame nói gì
pi/packages/chord/src/connection.tsdòng 861–876TypeScript
/** * Principal stamping: a downstream link acts as the principal it was accepted with, whatever the frame says. Only * the parent may forward a principal (relayed requests); its own requests act as the principal given to `connect`. */#principalOf(frame: { readonly principal?: unknown }): unknown {	if (this.role === "downstream") return this.principal;	return frame.principal ?? this.principal;}/** * The client connection a hold comes from: this link's own id for a client link (whatever the frame says), the id * the parent forwarded for a relayed hold, none for a host's own holds. */#originOf(frame: { readonly origin?: string }): string | undefined {	return this.role === "downstream" ? this.originId : frame.origin;}
Chỉ link tới cha mới được chuyển tiếp principal (request mà cha chuyển tiếp, tức relay, thay cho một client hay một host khác). Payload không bao giờ thiết lập danh tính: "Guest-provided names or message text must never establish identity" (tutorial.md:413).

Phía provider: unit, feed và encoder theo luồng

Khi nhận hold, connection kiểm placement của input với tên host mình, gọi acquire() với principal đã đóng dấu, và đăng ký nghe shape của reference (connection.ts:878–929). Khi reference sống, nó gửi một unit: frame shape liệt kê các member state mà consumer còn thiếu, tiếp theo là một frame state as: "snap" cho mỗi member đó (connection.ts:956–983). Mỗi member có một feed: một subscription events() trên member của reference, và một encoder riêng. Sự kiện đến trong lúc ảnh chụp đang được dựng được đệm lại và phát sau ảnh chụp (connection.ts:985–1012). Sau đó:

#feedEvent(): ops qua encoder của luồng; reset bắt đầu luồng mới với encoder mới
pi/packages/chord/src/connection.tsdòng 1024–1037TypeScript
/** A later event of a member's stream: ops are encoded, a reset starts a new stream. */#feedEvent(served: ServedLocal, name: string, feed: Feed, event: StateEvent<unknown>): void {	if (served.feeds.get(name) !== feed) return;	const overflow = { resend: () => this.#resend(served, name) };	if (event.type === "ops") {		this.sendState(			{ t: "state", hold: served.id, m: name, as: "ops", ops: feed.encoder.encode(event.ops) },			overflow,		);		return;	}	feed.encoder = encoder();	this.sendState(this.#snapshot(served, name, "reset", event.value), overflow);}
Ảnh chụp và reset được kiểm theo schema mà consumer được hứa (của provider khi bind thẳng, của consumer khi qua adapter); ops chỉ được kiểm ở chế độ dev (spec.md:280).

Phía consumer: một binding cho mỗi object ở xa

Mọi reference của một host tới cùng một object ở xa (cùng loại, cùng input, cùng principal key, cùng fingerprint contract) là các view của một RemoteBinding: một hold qua link, một luồng cho mỗi member state, lời gọi đi chung hold đó (host.ts:1084–1109). Binding áp một shape chỉ khi mọi ảnh chụp của nó đã tới, nên một binding live luôn có mọi member state của variant (remote.ts:364–383). Frame state đi vào đây:

RemoteBinding.state(): giải mã ops, áp bất biến, giao cho các view
pi/packages/chord/src/remote.tsdòng 257–288TypeScript
/** A state frame for member `name` of the current hold. */state(name: string, frame: StateIn): void {	if (this.ended) return;	if (frame.as === "error") {		const error = fromWireError(frame.error!);		// Before the first hydration nobody could read anything: the binding fails instead of waiting forever.		if (!this.hydrated && this.stateOn) {			this.gone(error);			this.close();		} else this.host.report(error);		return;	}	const pending = this.#pending;	if (frame.as === "ops") {		const target = pending?.values.get(name) ?? this.members.get(name);		if (target === undefined) return;		const ops = target.decoder.decode(frame.ops ?? []);		target.value = applyImmutable(target.value, ops);		if (target === this.members.get(name)) this.#deliver(name, { type: "ops", ops, value: target.value });		return;	}	const fresh: MemberState = { value: frame.value, decoder: decoder() };	if (pending !== undefined && (pending.missing.has(name) || pending.values.has(name))) {		pending.values.set(name, fresh);		pending.missing.delete(name);		if (pending.missing.size === 0) this.#apply();		return;	}	if (!this.inVariant(name)) return;	this.members.set(name, fresh);	this.#deliver(name, { type: "reset", value: fresh.value });}
Lỗi ảnh chụp trước lần hydrate đầu làm binding kết thúc thay vì chờ mãi. Mỗi luồng của mỗi member có decoder riêng, tạo mới ở mỗi ảnh chụp hay reset.

Lời gọi đi như frame call trên hold; client (trình duyệt) không bao giờ mang root hay code version, server mở root cho chúng (connection.ts:700–729). Phía nhận có thể giới hạn số lời gọi đang chạy của một peer (maxCalls); lời gọi vượt trần được trả lời ngay unavailable/too_many_calls và link vẫn mở (connection.ts:1100–1147).

Flow control: chỉ giữ lại state

Bài toán và lời giải của flow control, viết ngay trong class Connection
pi/packages/chord/src/connection.tsdòng 322–341TypeScript
* Flow control* ------------** Problem: state is pushed as fast as it changes, but a peer may read slowly (a phone behind a relay), and a relay may* hide that: Cloudflare forwards to browsers without backpressure. Everything sent then queues in front of the next* call result. Example: a browser opens three long transcripts (500 KiB of snapshots each) over a 5 Mbit/s path; a* result sent next waits 2.4 s, and the snapshot of a transcript the user already left is still sent in full.** Solution: the peer acknowledges what it processed (`ack`, a cumulative frame count), and the sender counts the sizes* of the frames not yet acknowledged. While they exceed the window, state waits here in slots, flushed in creation* order as acknowledgements come:* - a member's stream has one slot: ops are appended (encoded batches of one stream may be concatenated, see*   `encoder`), a snapshot, reset or error replaces it;* - a hold's unit has one slot, built lazily from the current values when it is sent, so a newer unit merges into it;*   while it waits, the members it introduces have no stream yet.** The first unit of a hold that is not a re-hold (`again`) is sent at once, past the window: a `get` waits for it, and* must not wait behind other holds' state. Results, shapes, gone, reload control and pings are sent at once too: they* are bounded by the requests the peer makes. Hence the one ordering exception of a link: while state waits, a result* can arrive before state changes made before it. Frames of one stream stay in order.
Ví dụ trong comment: trình duyệt mở ba transcript dài (mỗi cái 500 KiB ảnh chụp) qua đường 5 Mbit/s; một kết quả gửi ngay sau phải chờ 2,4 s, và ảnh chụp của transcript người dùng đã rời đi vẫn được gửi trọn.

Cụ thể: hai đầu công bố acks trong hello và gửi frame ack mang số frame đã xử lý cộng dồn, một lần cho mỗi đợt frame đến chứ không mỗi frame (connection.ts:611–619). Khi số byte chưa được xác nhận vượt cửa sổ (mặc định 256 KiB), frame state chờ ở bên gửi trong các ô (slot): mỗi luồng member một ô, ops mới được nối vào, ảnh chụp hay reset thay cả ô (connection.ts:468–506). Nối được là nhờ tính chất của encoder: các lô liên tiếp của cùng một encoder ghép thành một lô được, vì mỗi lô bắt đầu bằng một đường dẫn tường minh (delta/index.ts:547–549). Ops chờ có trần theo link (pendingCap, mặc định bốn cửa sổ); vượt trần thì các luồng cũ nhất bị thay bằng một reset dựng từ giá trị hiện tại lúc xả, nên bộ nhớ bị chặn dù peer dừng bao lâu (connection.ts:508–529). Unit đầu tiên của một hold mới, kết quả, shape, gone và ping thì luôn gửi ngay. Hệ quả duy nhất về thứ tự: trong lúc state chờ, một kết quả có thể tới trước thay đổi state đã xảy ra trước nó.

Relay, route và mất kết nối

Server chuyển tiếp hold từ trình duyệt tới worker, và giữa các worker. Relay cần một route, vì đó là nơi đặt phân quyền: authorize(principal, hostName) chạy trước khi resolve (có thể sinh tiến trình mới) và chạy lại sau đó; một lần thu hồi principal trong lúc resolve thắng (host.ts:1139–1185; connection.ts:124–129). Link ping mỗi 15 s và đóng sau 45 s im lặng; một link chết nhả mọi thứ giữ qua nó (connection.ts:12–16). Link không tiếp tục được (resume): provider nhả mọi thứ ngay khi link đóng, nên hold lại sau khi kết nối lại là object mới; lời gọi đang bay khi link rớt bị từ chối unavailable và không biết đã chạy hay chưa (spec.md:260). Phía consumer, binding giữ giá trị cuối và trạng thái loading trong lúc hold lại qua link mới (remote.ts:304–314).

Lần theo một thay đổi state qua link#

Kịch bản là ví dụ §6 của tutorial, chạy thật trong test/tutorial.test.ts: một host server phục vụ TodoStore (local, giữ cell rows) và view remote Todos; một host browser kiểu client nối lên qua memoryLinkPair(); trình duyệt lấy reference tới Todos, đăng ký rows, rồi gọi add.

tutorial §6: server, trình duyệt, một reference remote và một lời gọi
pi/packages/chord/docs/tutorial.mddòng 435–455TypeScript
const server = createHost<Principal>({ self: "server", name: "server" });server.serve(TodoStore, todoFactory);serveViews(server);const [down, up] = memoryLinkPair();server.accept(down, { principal: "owner", kind: "client" });const browser = createHost({	self: "browser",	name: "browser",	kind: "client",	upstream: { link: up, principal: "server" },});const todos = await browser.get(Todos, { list: "family" });assert.ok(todos.variant === "editor");assert.deepEqual(todos.rows.value, []);const updated = Promise.withResolvers<void>();const stop = todos.rows.subscribe((rows) => {	if (rows.length === 1) updated.resolve();});await browser.root((ctx) => todos.add({ text: "Buy milk" }, ctx));await updated.promise;assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]);
Factory của Todos trả về rows: store.rows, tức member state của reference tới TodoStore (tutorial.md:355–371); TodoStore.add là rows.change(ctx, (draft) => { draft.push({ text, done: false }); }) (tutorial.md:153–157).

Các bước dưới đây là đường đi trong code, đọc theo thứ tự; giá trị cụ thể của phép toán và frame là suy ra từ code, không in ra từ một lần chạy.

  1. 1. Kết nốiMỗi Connection gửi hello ngay khi được tạo, kèm protocol: 4 và acks: true (connection.ts:431–438). Server đã quyết định link này là client, hành động như "owner"; hello của trình duyệt phải khớp.
  2. 2. browser.get(Todos)acquire() thấy placement hostNamed("server") khác tên host mình, nên tạo một RemoteBinding và giữ nó qua link lên cha (host.ts:1111–1129). Frame hold không mang principal: principal của chính host bị lược, và server đằng nào cũng không tin nó.
  3. 3. Server nhận hold#onHold kiểm placement, rồi acquire(token, input, { principal: "owner" }, "client"). Object Todos chưa có, nên activation chạy factory, factory khai báo self.get(TodoStore), và Todos chỉ live khi TodoStore đã live. Factory thấy principal không phải "guest" nên chọn variant editor.
  4. 4. Unit đầu tiênReference live, #unit gửi ngay (unit đầu không chờ cửa sổ): frame shape với state: ["rows"], rồi frame state as: "snap", value: []. Ảnh chụp lấy từ sự kiện snap đầu tiên của events(), và feed được gắn trong cùng bước nên không lỡ thay đổi nào (connection.ts:989–1005).
  5. 5. Trình duyệt hydrateshaped() ghi nhận shape đang chờ rows; frame snap tới, #apply() cài shape, đặt hydrated = true, status live; get resolve. todos.rows.subscribe() gọi ngay listener với [].
  6. 6. Lời gọi addRemoteBinding.call() làm sạch tham số theo schema của consumer và gửi call; host kiểu client không mang root (connection.ts:724–727). Server mở một root cho nó, hành động như "owner", và ObjectImpl.call() kiểm { text: "Buy milk" } theo schema của provider.
  7. 7. Draft và phép toánTodos.add gọi store.add với ctx của nó. Cell.change() mở draft trên { v: [] }; draft.push(...) sao chép object vào một piece chèn và đánh dấu nút mảng bẩn. prepare(): nút bẩn là mảng nên không đi đường "chỉ object"; đường dẫn của nút là ["v"]; kế hoạch có thứ tự cho một piece chèn vào mảng rỗng là ["p", ["v"], 0, 0, [{ text: "Buy milk", done: false }]].
  8. 8. Công bố#publish() tính lại openCount (0 thành 1), rồi trong holdPublications adopt, gỡ hộp thành ["p", [], 0, 0, [...]] và xếp sự kiện ops vào hàng publication. Hàng được xả ngay khi holdPublications trả về, vẫn trong cùng lời gọi đồng bộ.
  9. 9. Hai lớp MemberReadableSự kiện đi qua MemberReadable("rows") của reference tới TodoStore, rồi qua MemberReadable("rows") của reference tới Todos mà feed đang nghe; mỗi lớp xếp việc giao vào cùng hàng FIFO.
  10. 10. Lên dây#feedEvent mã hoá bằng encoder của luồng; đường dẫn [] dùng lần đầu nên viết thẳng. Cửa sổ chưa đầy nên sendState gửi ngay; MemoryLink chuyển frame qua một vòng JSON.
  11. 11. Phía trình duyệtRemoteBinding.state(): decoder của luồng kiểm và dựng lại phép toán, applyImmutable([], ops) đi nhánh append một lần chép, rồi ViewReadable.deliver() gọi listener với [{ text: "Buy milk", done: false }]. Trình duyệt hẹn một ack.
  12. 12. Kết quảMethod của server xong sau đó (một promise), frame result không mang value vì add không khai báo kết quả; browser.root(...) resolve.
Ví dụ minh hoạ: các frame trên link (id, fingerprint và rootId là giả định; ack và ping bị lược)

 browser → server  {"t":"hello","protocol":4,"name":"browser","kind":"client","codeVersion":1,"acks":true}
 server → browser  {"t":"hello","protocol":4,"name":"server","kind":"host","codeVersion":1,"acks":true}
 browser → server  {"t":"hold","id":1,"at":"server","token":{"name":"tutorial.todo-view","scope":"remote",
                    "version":1,"input":{…},"host":{"name":"server"},"variants":{…}},"input":{"list":"family"}}
 server → browser  {"t":"shape","id":1,"variant":"editor","members":["rows","add"],"contract":"9f3c…","state":["rows"]}
 server → browser  {"t":"state","hold":1,"m":"rows","as":"snap","value":[]}
 browser → server  {"t":"call","id":2,"hold":1,"member":"add","args":{"text":"Buy milk"}}
                    server: root mới, principal "owner" · Cell.change · ["p",["v"],0,0,[…]] → unbox
 server → browser  {"t":"state","hold":1,"m":"rows","as":"ops","ops":[["p",[],0,0,[{"text":"Buy milk","done":false}]]]}
 server → browser  {"t":"result","id":2}
Ở kịch bản này frame state tới trước kết quả, vì sự kiện được xả đồng bộ trong rows.change() còn kết quả đi sau một promise. Khi cửa sổ đầy thì ngược lại có thể xảy ra, nên tutorial chờ updated.promise thay vì đọc state ngay sau khi lời gọi trả về (tutorial.md:827–828).

Ba điều kịch bản này cho thấy. Thứ nhất, không bên nào diff lại: phép toán sinh một lần từ draft trên server, được gỡ hộp, mã hoá, giải mã và áp nguyên trạng ở trình duyệt. Thứ hai, danh tính đến từ link: trình duyệt không nói nó là ai, server đóng dấu "owner" lúc accept, và chính principal đó chọn variant editor. Thứ ba, có hai lớp kiểm: tham số được kiểm ở ranh giới provider, còn ảnh chụp và reset được kiểm theo schema mà consumer được hứa trước khi gửi.

Pi Durable dùng Chord thế nào#

Gom mọi câu import từ @earendil-works/chord trong packages/durable/src (51 file) ra được một danh sách ngắn, và nó chỉ chạm tầng nền. Không có createHost, remote, local, link hay reload nào.

Từ ChordNơi Durable dùngĐể làm gì
Context, BACKGROUND_CONTEXT, withAbortSignal, withoutAbortSignal, awaitWithContext, withTelemetryTham số cuối của mọi API bất đồng bộ (README.md:78); span của mỗi phase (scheduler.ts:1762–1763)Huỷ một lần chờ, không huỷ công việc; truyền span xuống phase và tool
JsonValue, JsonRepresentation, copyJson, Drafttypes.ts, harness/tool.ts...; tx.doc() trả về Draft<T> (types.ts:970)Kiểu JSON nghiêm ngặt, sao chép giá trị vào bản ghi
track, Tracker, Change, Prepared, Opsession/transaction.ts, session/session.tsMột tracker cho mỗi document đã nạp; prepare khi callback của commit xong; prepared.ops thành bản ghi delta; adopt sau khi storage ghi xong
apply, applyImmutable, applyImmutableBatchesBackend SQLite, IndexedDB, Memory; harness/view.ts, harness/task-graph.tsĐọc base cộng chuỗi delta; áp ops của commit lên view đang mount
replicatedState, AttachedReplicatedState, ReplicatedStateSource…session/observation.ts; session.ts:372, view.ts:118, task-graph.ts:116documentState(), viewState(), taskGraph() trả về state Chord chỉ đọc
overlap, OutputBuffer, Progress, boundOutput, PROGRESS_BYTES_PER_SECONDharness/tool.ts:1–11Output có giới hạn của tool đang chạy, nhịp commit tiến độ
SqliteDatabase, applySqliteMigrations, validateSqliteSchemaPrefix, openNodeSqliteDatabase, DurableObjectSqliteDatabasestorage/sqlite/*Facade và driver SQLite; Durable vẫn tái xuất các tên cũ (storage/sqlite/node.ts:5–9)

Document: draft của Chord, commit của Durable

Mỗi document đã nạp là một Tracker<JsonObject> trong bộ nhớ đệm của Session (transaction.ts:87–98). tx.doc(token) mở tracker.beginChange() và trả về change.state làm draft. Khi callback của commit xong, transaction prepare đồng bộ mọi change đang mở (việc này thu hồi mọi draft), lập kế hoạch ghi: phiên bản định nghĩa đổi thì lưu một base, có phép toán thì lưu một delta { kind: "delta", ops: prepared.ops }, không có gì đổi thì không ghi (transaction.ts:1037–1053). Chỉ sau khi storage ghi thành công, adopt() mới đổi con trỏ; ghi lỗi thì discard() huỷ mọi preparation, tracker giữ revision cũ, và Session chuyển sang hỏng vì lỗi storage là lỗi chết của nó (durable/session/session.ts:747–758). Đây đúng là lý do prepare() và adopt() tách nhau trong tracker: bộ nhớ không bao giờ đi trước storage.

Quan sát: không diff lại ở đâu cả

Sau mỗi commit, Session đưa giá trị bất biến mới và đúng lô phép toán của commit đó cho các nguồn đang gắn. CommittedStateSource biến chúng thành frame có cursor cho replicatedState(source); CommittedWatch giao (value, ops, context) cho watch(). View của hội thoại ghép phép toán của nhiều document bằng cách dời đường dẫn xuống ["docs", kind] và thêm splice cho entry mới, rồi áp lên giá trị đang mount bằng applyImmutable:

Durable view: phép toán document được dời đường dẫn, rồi áp bất biến một lần
pi/packages/durable/src/harness/view.tsdòng 217–241TypeScript
	if (change.type !== "document" || change.conversationId !== id) continue;	const kind = change.record.kind;	if (!MOUNTED_KINDS.has(kind) || change.record.key !== undefined) continue;	const path: NonEmptyPath = ["docs", kind];	const mounted = mount.docs.get(kind);	if (change.value === null) {		if (mounted?.id !== change.record.id) continue;		mount.docs.delete(kind);		docOps.push(["d", path]);	} else if (mounted?.id === change.record.id && mounted.version === change.version) {		for (const op of change.ops) docOps.push(prefixed(op, path));	} else {		mount.docs.set(kind, { id: change.record.id, version: change.version! });		docOps.push(["s", path, change.value]);	}}const before = mount.value;const ops = [...docOps, ...entryOps];const frameContext = withoutAbortSignal(context);if (ops.length > 0) {	const value = docOps.length === 0 ? mount.value : applyImmutable(mount.value, docOps);	mount.value = entries === value.entries ? value : { ...value, entries };	for (const observer of [...mount.observers]) {		contained(() => observer.advance?.(mount.value, ops, frameContext), report);	}
Document mới tạo hay đổi version thì được đặt cả giá trị (s), document bị retire thì bị xoá (d). Đây là ví dụ mà chương mô hình Durable trích từ spec: ["s", ["generation", "message"], v] trở thành ["s", ["docs", "pi.live", "generation", "message"], v].

Một ứng dụng muốn đẩy view của Durable cho trình duyệt có hai đường: gói nó trong một object Chord và để link lo phần còn lại (cách guide gợi ý), hoặc tự gửi (value, ops) của watch() qua giao thức của mình và áp bằng applyImmutable ở đầu kia. Repo ở v1.4.0 không chứa ví dụ nào cho đường thứ nhất.

Những mảnh Durable từng sở hữu

Hai lần chuyển code từ Durable sang Chord cho thấy ranh giới mà nhóm muốn: OutputBuffer và Progress rời Durable sang Chord ở 1.3.0 (CHANGELOG.md:40); các driver SQLite, giao diện và bộ chạy migration chung sang Chord ở 1.4.0, còn helper storage và migration lược đồ của Durable ở lại (durable/CHANGELOG.md:35). Thứ gì không biết gì về hội thoại, entry hay task thì về Chord.

Output, SQLite và telemetry-sqlite#

Ba mảnh còn lại của tầng nền đều là thứ Durable từng tự giữ, hoặc cần chung với ứng dụng.

OutputBuffer và Progress

Output của một tool hay một tiến trình có thể dài vô hạn, trong khi thứ được lưu và gửi cho mô hình phải có giới hạn. OutputBuffer giữ cửa sổ đầu (head) hoặc cuối (tail) của một luồng với chi phí tỉ lệ với từng chunk: chế độ đầu ngừng lưu khi cửa sổ đã đầy, chế độ cuối bỏ những chunk đầu mà cửa sổ không còn cần, và tổng byte, dòng của cả luồng vẫn được đếm để báo chính xác phần đã bỏ (output.ts:122–217). snapshot() cắt theo dòng nguyên trên ranh giới ký tự UTF-8 và bỏ ký tự điều khiển làm hỏng hiển thị (output.ts:39–59). Phía Durable, harness/tool.ts gán output vào slot như một trường chuỗi duy nhất, để tracker biến một cửa sổ đuôi trượt thành một cặp t + a thay vì ghi lại cả cửa sổ; nó chỉ dùng overlap() để ước số byte một commit sẽ ghi, làm đầu vào cho Progress (durable/harness/tool.ts:493–509).

Progress: commit đầu tiên sau lúc rảnh đi ngay, mỗi commit đẩy lần kế ra theo kích thước đã ghi
pi/packages/chord/src/output.tsdòng 320–335, 384–405TypeScript
/** * Adaptive progress commits: the first change after an idle period commits at once; each commit then delays the next * by at least `minIntervalMs` and by its written size at 100 KiB/s. At most one commit is in flight; changes made * meanwhile coalesce into the next one. Paces on `clock`, by default `Date.now` and `setTimeout`. */export class Progress {	readonly #write: () => Promise<number>;	readonly #onError: (error: unknown) => void;	readonly #minIntervalMs: number;	readonly #clock: ProgressClock;	#waiters: PromiseWithResolvers<void>[] = [];	#timer: (() => void) | undefined;	#inFlight: Promise<void> | undefined;	#nextAt = 0;	#dirty = false;	#stopped = false;	#flush(): void {		if (this.#stopped || !this.#dirty) return;		this.#dirty = false;		const waiters = this.#waiters.splice(0);		const started = this.#clock.now();		this.#inFlight = this.#write()			.then(				(bytes) => {					this.#nextAt = started + Math.max(this.#minIntervalMs, (bytes * 1000) / PROGRESS_BYTES_PER_SECOND);					for (const waiter of waiters) waiter.resolve();				},				(error: unknown) => {					this.#nextAt = started + this.#minIntervalMs;					for (const waiter of waiters) waiter.reject(error);					this.#onError(error);				},			)			.finally(() => {				this.#inFlight = undefined;				if (this.#dirty) this.#schedule();			});	}
write() trả về số byte đã ghi; lần kế sớm nhất là max(minIntervalMs, bytes / 100 KiB/s) sau lúc bắt đầu commit trước. Chỉ một commit bay cùng lúc, thay đổi trong lúc chờ gộp vào commit kế. Chương task của Durable cho thấy nhịp này áp vào output của tool (mặc định 100 ms).

SQLite di động

Một facade bất đồng bộ đủ nhỏ để cài đặt trên node:sqlite (đồng bộ), trên ctx.storage.sql của Durable Object (đồng bộ, không có bước prepare), hay trên một adapter chạy ngoài tiến trình:

SqliteExecutor và SqliteDatabase: transaction nhận một handle riêng, mọi thao tác khác phải chờ
pi/packages/chord/src/sqlite/database.tsdòng 4–38TypeScript
/** * Asynchronous SQL operations shared by a database and its transaction handles. * * `exec` runs SQL text without bindings and may contain several statements. `run`, `get`, and `all` * execute one statement with positional bindings. Adapters may cache prepared statements by SQL text, * so callers pass values as bindings instead of interpolating them. */export interface SqliteExecutor {	exec(sql: string): Promise<void>;	run(sql: string, ...params: SqliteValue[]): Promise<void>;	get<T extends object>(sql: string, ...params: SqliteValue[]): Promise<T | undefined>;	all<T extends object>(sql: string, ...params: SqliteValue[]): Promise<T[]>;}/** * Minimal portable SQLite database facade. * * All operations are asynchronous so adapters may execute outside the caller's runtime. * * `transaction` passes the callback a transaction handle. All work in the transaction * must use that handle; the handle is invalid after the callback settles. Adapters must * queue unrelated operations and other transactions until the transaction finishes. The * returned promise settles after commit or rollback. Calling the database itself (including * `transaction` or `close`) from inside a callback therefore waits for that transaction and * never settles. * * When the callback rejects, the adapter must roll the transaction back before rejecting * with that same error. If rollback fails, it must reject with a different error (for * example an `AggregateError`) so callers cannot mistake the callback error for a * guaranteed rollback. */export interface SqliteDatabase extends SqliteExecutor {	transaction<T>(callback: (transaction: SqliteExecutor) => Promise<T>): Promise<T>;	close(): Promise<void>;}
Gọi chính database (kể cả transaction hay close) từ bên trong callback của transaction sẽ chờ transaction đó và không bao giờ xong. Rollback lỗi thì phải từ chối bằng một lỗi khác lỗi của callback, để người gọi không nhầm là đã rollback chắc chắn.

applySqliteMigrations() chạy mọi migration còn thiếu trong một transaction, ghi version vào một bảng metadata một dòng, và từ chối database đã được migrate bởi bản mới hơn (sqlite/migrations.ts:11–43). Adapter Node xếp mọi thao tác vào một hàng nối tiếp, mở transaction bằng BEGIN IMMEDIATE, cache câu lệnh đã prepare theo văn bản SQL, bật WAL với synchronous = NORMAL, chờ khoá tranh chấp 5 s, và wal_checkpoint(TRUNCATE) khi đóng (sqlite/node.ts:130–201). Adapter Durable Object dùng chung một hàng cho mọi adapter trên cùng storage object và từ chối bigint ngoài khoảng an toàn (sqlite/cloudflare.ts:56–75). Cách Durable dựng lược đồ và commit trên facade này nằm ở chương Durable storage.

telemetry-sqlite

createSqliteTelemetry(db) là một adapter telemetry của pi-telemetry ghi vào chính SQLite của ứng dụng, và cũng là một TelemetryStore đọc lại được. Ghi nhận không chạy SQL: span chỉ được đưa vào hàng đợi khi nó kết thúc, metric được gộp thành count, sum, min, max theo từng khoảng 10 giây cho mỗi chuỗi (tên, loại, đơn vị, thuộc tính) (README.md:117–121).

Truy vấn (findTraces, getTrace, findMetrics) chỉ đọc dòng đã commit, kể cả sau khi adapter đóng mà database còn mở; lọc theo thuộc tính dùng json_each và phân biệt true với số 1 (telemetry-sqlite/store.ts:48–64). Hợp đồng TelemetryStore và cách render một trace thuộc pi-telemetry (chương pi-env, telemetry và evals).

Quyết định thiết kế#

Bất biến bằng hợp đồng, không bằng đóng băng

Tracker nhận quyền sở hữu gốc mà không duyệt nó, không đóng băng giá trị đã công bố, và để người tiêu thụ trong cùng tiến trình dùng chung container với nơi cung cấp (README.md:145–152). Hai lựa chọn bị bỏ là Object.freeze (phải duyệt cả cây mỗi revision) và sao chép phòng thủ (phải chép mỗi lần đọc). Với state đổi mỗi 100 ms và view chứa cả transcript, cả hai đều tốn đúng phần tracker được thiết kế để tránh. Cái giá là một đột biến trái phép không bị bắt mà làm hỏng authority trong im lặng; vì vậy quy tắc "clone hoặc serialize ở mọi ranh giới tin cậy có thể đột biến" được nhắc ở cả README lẫn delta README. Ngược lại, giá trị đặt qua draft luôn được chép và kiểm, vì đó là chỗ dữ liệu bên ngoài đi vào.

Prepare tách khỏi adopt

Toàn bộ phần có thể thất bại (sinh phép toán, dựng giá trị) nằm ở prepare(); adopt() là một phép đổi con trỏ không thể lỗi, "so storage failure can discard the candidate without touching authority" (tracker.ts:271–272). Đây là điều kiện để Pi Durable giữ quy tắc "lưu trước, hiển thị sau": giữa prepare và adopt là lần ghi storage. Reload dùng cùng ý ở quy mô lớn hơn: mọi thứ có thể lỗi chạy ở prepare, commit chỉ cài (reload.ts:561–566).

Lô chính xác nhưng không chính tắc, và có trần

Không đòi một dạng chính tắc cho phép toán giúp tracker chọn đường rẻ: gộp nhiều thay đổi thành một splice vùng, một s ở tổ tiên, hay r. Trần 4.096 phép toán của tracker và phép so chi phí của diffRevisions() bảo đảm một lô không bao giờ đắt hơn nhiều so với ảnh chụp. Đổi lại, người tiêu thụ chỉ được dựa vào giá trị kết quả.

Context tường minh thay vì ngữ cảnh ngầm

Context là tham số, không phải AsyncLocalStorage. Nó chạy giống nhau trong Node, trình duyệt và Workers, các lời gọi đồng thời giữ cha độc lập, và không có span "hiện tại" ngầm (README.md:91). Cái giá là phải truyền context qua mọi hàm, đúng như mọi API của Durable làm.

Factory đồng bộ, activation bất đồng bộ

Reload phải chạy lại factory trên object đang sống mà không có hiệu ứng phụ, và cần danh sách khai báo đầy đủ để so với lần trước. Một factory bất đồng bộ không cho được cả hai. Vì vậy factory chỉ khai báo; mở resource, chờ reference và đọc giá trị đầu tiên là việc của activation (spec.md:122–133). Spec ghi rõ những gì đã bị bỏ so với bản guide trước: self.own, holdWhile, factory bất đồng bộ, suy diễn phụ thuộc (spec.md:120).

Root gắn với code version, tối đa hai version

Một root chạy hết trên code nó bắt đầu, và host không bao giờ thay một code version bằng cái khác (spec.md:198). Giữ hai version đủ để mọi root đang chạy xong mà không giữ code cũ vô hạn; root sống quá lâu thì bị huỷ với superseded ở reload kế tiếp. Đây là lý do guide nhắc "Keep roots short. Long-running work is a task, and long waits are state" (guide.md:501).

Cell có tên, không phải một state duy nhất cho mỗi object

Mỗi cell có version và migration riêng, nên đổi một cell không buộc migrate cả object. Spec ghi lại một quyết định đã chốt: "giving each object exactly one state value was rejected. Do not reopen this without a new, concrete problem" (spec.md:288).

Một hàng publication cho cả tiến trình

Mọi thông báo (sự kiện state, shape, bảng registry, listener) đi qua một hàng FIFO đồng bộ, nên mọi người quan sát thấy revision theo thứ tự adopt (mục cell). Spec liệt kê cái giá: một observer chậm làm chậm mọi thông báo sau nó trong tiến trình, kể cả của host khác cùng tiến trình (spec.md:262).

Flow control chỉ cho state, xác nhận đầu cuối

Một relay (Cloudflare tới trình duyệt) có thể che đi một người đọc chậm, nên không thể dựa vào backpressure của transport; xác nhận phải đi từ đầu tới cuối. Kết quả không được chờ sau state, nên link là FIFO trừ việc state đang chờ có thể bị vượt; state đang chờ được gộp thay vì xếp hàng, nên một peer đứng im chỉ tốn bộ nhớ có trần và frame kế tiếp nó nhận là giá trị hiện tại (spec.md:287).

Danh tính đến từ link

Phía chấp nhận đóng dấu principal từ bộ xác thực của nó; principal được chuyển tiếp chỉ được nhận từ link tới cha; trường principal trong payload bị bỏ qua (spec.md:236). Client không bao giờ mang root hay code version (spec.md:282).

Một ngoại lệ có chủ đích: import động

Quy tắc của repo cấm import nội tuyến và import động. nodeLoader là ngoại lệ được chủ dự án duyệt, đánh dấu ngay tại dòng gọi (loader.ts:58) và trong ràng buộc cứng của spec, cùng một TODO(loader): thay bằng một loader cô lập và giải phóng được (spec.md:7).

Cạm bẫy và trường hợp biên#

  • Sửa giá trị đọc được là làm hỏng state. cell.value, giá trị trong listener, tracker.value, payload của một lô phép toán đều có thể chia sẻ container với authority; không có gì bị đóng băng (delta/README.md:186–187).
  • Draft chết theo callback. Dùng draft hay một handle đọc từ nó sau prepare(), abort() hay sau khi change khác được adopt thì ném lỗi; callback của change() phải đồng bộ.
  • undefined có luật riêng. Gán undefined cho thuộc tính object là xoá; gán vào phần tử mảng, push(undefined), hay undefined lồng trong object được đặt vào thì ném lỗi, không bị bỏ qua như JSON.stringify. Mảng luôn dày: ghi quá chỉ số kế tiếp hay xoá một phần tử bằng delete thì ném lỗi (delta/README.md:115–122).
  • Callback của cell.change() trả về giá trị là thay giá trị. Đọc từ code: kết quả khác undefined được kiểm theo schema rồi gán vào cell (cell.ts:207–212). Vì vậy (d) => d.push(x) trả về độ dài mới và bị từ chối theo schema của mảng; tutorial luôn viết (draft) => { draft.push(x); }. MutableReplicatedState.change() thì bỏ qua giá trị trả về.
  • Subscriber của replicatedState có thể nhảy cóc. Quá 100 frame chờ thì chỉ giữ frame mới nhất; đọc giá trị được truyền vào callback, vì state.value có thể đã là revision sau (README.md:181–191).
  • Kết quả có thể tới trước state. Dưới flow control, một lời gọi trả về không có nghĩa thay đổi state nó gây ra đã tới; chờ đúng state bạn cần (guide.md:744).
  • Listener trên state phải hoà giải (reconcile), không đếm. Nó nhận giá trị hiện tại khi bắt đầu, và nhận lại khi reload thay nguồn của nó (mọi derivation đều mới ở mỗi reload) (guide.md:218).
  • Biến closure mất ở mỗi reload; state cần giữ phải là cell. Một select phụ thuộc giá trị bắt được trong closure sẽ không bị phát hiện là đã đổi; đặt tên listener mới khi đó (spec.md:266).
  • Link không resume. Hold lại sau khi kết nối lại là object mới; lời gọi đang bay khi link rớt không biết đã chạy hay chưa. Một close của resource chờ lời gọi qua một link đã chết sẽ chờ kết nối lại: hãy giới hạn bằng signal (spec.md:267).
  • Loader Node rò bộ nhớ. Mỗi reload import bản module mới, bản cũ không bao giờ được giải phóng, và plugin không bị cô lập (spec.md:264).
  • Hai kiểu tổ hợp tín hiệu huỷ. CtxImpl của host tránh AbortSignal.any vì lý do giữ bộ nhớ đã nói ở trên, còn withAbortSignal() của /context vẫn dùng nó (context/index.ts:111–115). Đây là quan sát từ đọc code; repo không có test hay ghi chú nào cho thấy nó gây rò rỉ trong Durable.

Tài liệu lệch code ở v1.4.0

  • Quick start của guide khai báo Counter bằng remote() mà không có host (guide.md:43–52); từ 1.3.0 host là bắt buộc và remote() ném TypeError khi thiếu (contract.ts:225–226). Bản trong README có host: hostNamed("server").
  • Bảng khai báo của guide và spec còn ghi self.get(Type, input, { at? }) (guide.md:206; spec.md:104), trong khi GetOptions.at đã bị gỡ ở 1.3.0 (CHANGELOG.md:28); GetOptions chỉ còn principal và state (types.ts:8–16).
  • Guide nói "self.get and self.resources only declare" (guide.md:247); self.resources đã bị gỡ ở 1.3.0, API hiện tại là self.resource(name, …) (CHANGELOG.md:24).
  • Ràng buộc cứng đầu spec nói runtime không phụ thuộc package Pi nào khác (spec.md:6), nhưng package.json phụ thuộc @earendil-works/pi-telemetry (package.json:87–90), được thêm khi Context có trường telemetry (commit 48909d987, 2026-10-18).

Đọc code ở đâu#

Mọi đường dẫn tính từ packages/chord/. Thứ tự dưới đây đi từ tầng nền lên host runtime; nếu chỉ cần phần Durable dùng, dừng sau mục 8.

#FileĐọc để hiểu
1README.mdNăm khái niệm, bảng entry point, telemetry trên Context, tracker và mô hình đăng ký của replicatedState
2src/context/index.tsContext, các hàm dẫn xuất, awaitWithContext
3src/delta/README.mdQuyền đột biến, vòng đời, bảng phép toán, giới hạn
4src/delta/index.tsOp, WireOp, an toàn đường dẫn, apply, applyImmutable, encoder/decoder
5src/delta/tracker.tsBắt đầu ở TrackerImpl và ChangeImpl, rồi setProperty, arrayMutators, emitOperations, emitChangedValue
6src/delta/diff.ts, apply-immutable-trusted.tsDiff cấu trúc, áp phép toán tin cậy
7src/state.tsreplicatedState: mutable, gắn nguồn, subscriber có trần
8src/output.ts, src/sqlite/OutputBuffer, Progress, facade và adapter SQLite
9docs/tutorial.mdDanh sách todo dựng dần; mỗi ví dụ chạy trong test/tutorial.test.ts
10src/cell.ts, src/publish.ts, src/types.tsCell, derivation, hàng publication, Readable, Ctx
11src/contract.ts, src/schema.tsremote/local, placement, coverage, bind, fits, danh tính input
12src/object.tsSelf, CtxImpl, RefImpl, ObjectImpl (activation, call, dispose)
13src/host.tsacquire, root, registry, accept/connect/route, relay, close
14src/reload.ts, src/cluster.ts, src/loader.tsPrepare, gate, commit, retire; reload cả cụm; loader Node
15src/wire.ts, src/link.ts, src/websocket.tsFrame, ba transport, nén payload
16src/connection.ts, src/remote.tsUnit, feed, flow control, relay; binding và view phía consumer
17src/telemetry-sqlite/, src/manager.tsGhi telemetry vào SQLite; quản lý tiến trình host con
18docs/guide.md, docs/spec.mdTham chiếu API; ngữ nghĩa và §12 giới hạn, quyết định (spec nói: lệch với code là bug)
19test/delta-tracker/, test/link.test.ts, test/reload.test.tsHành vi biên của tracker, link và reload, ở dạng có thể chạy

Tóm tắt#

  • Chord là host runtime có kiểu cho plugin nạp lại nóng, contract, state nhân bản và RPC qua link JSON; bên dưới là các mảnh dùng độc lập: Context, delta, replicatedState, output, SQLite.
  • Ở v1.4.0 chỉ Pi Durable và pi-env phụ thuộc Chord, và chỉ dùng tầng nền; không package nào khác trong repo tạo host.
  • Context mang huỷ, span telemetry và giá trị có khoá, truyền tường minh; huỷ một lần chờ không huỷ công việc. Ctx của host runtime là thứ khác.
  • Thay đổi là lô bảy phép toán (r s d a t p m), chính xác nhưng không chính tắc; trên dây, đường dẫn được đánh mã từ lần dùng thứ hai và r là điểm khôi phục.
  • Tracker: draft proxy, prepare() sinh phép toán và revision mới mà không đổi authority, adopt() là phép đổi con trỏ; đó là móc để Durable ghi storage trước khi hiển thị.
  • Host runtime: contract là giá trị lúc chạy với luật bind theo version và "phủ"; object tại địa chỉ (type, input, principal), chỉ chạm tới qua reference gắn muộn; factory đồng bộ, activation bất đồng bộ.
  • Hot reload: load, prepare không hiệu ứng phụ, gate khi state đổi nghĩa, commit đồng bộ, retire theo luật hai phiên bản; root chạy hết trên code nó bắt đầu.
  • Link chở frame JSON đã kiểm schema; principal do phía chấp nhận đóng dấu; state đi thành unit rồi ops theo encoder của từng luồng, có flow control chỉ cho state.

17Pi Durable: mô hình khái niệm#

Chương này dựng mô hình khái niệm của @earendil-works/pi-durable (gọi tắt Pi Durable): harness bền (durable harness) mà Earendil phát hành cùng Pi 1.0. Ta đi từ bài toán, qua các bản ghi và kiểu dữ liệu thật, tới một lần theo dấu (trace) đầy đủ từ lúc gửi một câu hỏi đến lúc nhận câu trả lời. Phần bên dưới mặt nước (Session, transaction, các backend lưu trữ) nằm ở chương Durable: session, commit, document và storage; task, scheduler, generation và tool nằm ở chương Durable: task, scheduler, generation và tool.

Vai trò: vì sao có Pi Durable#

Coding agent của Pi (CLI pi, xem chương AgentSession) chạy trong một terminal, cho một người. Nếu tiến trình chết giữa một lượt, người dùng xem lại chuyện đã xảy ra rồi bảo nó làm tiếp. Bài "Pi Durable" của Earendil (2026-10-01) đặt ra một bài toán khác: một harness chạy được ở bất cứ đâu có runtime JavaScript, được truy cập từ nhiều bề mặt (giao diện terminal, web, bot chat), chứa rất nhiều hội thoại dài, sống sót qua lỗi bên trong lẫn bên ngoài, và cho nhiều người cùng điều khiển một agent.

Bài viết đưa ra vài tình huống cụ thể. Một agent bị cắt ngang giữa lời gọi tool; một tiến trình mới mở lại cùng storage và chạy tiếp. Request tới mô hình bị đứt được gửi lại, phần trả lời dở được giữ và đánh dấu là bị huỷ (aborted). Một tool không an toàn khi chạy lại, như deploy, được báo cho mô hình là bị gián đoạn thay vì chạy lần thứ hai. Một kênh Slack là một hội thoại, một thread mở từ một câu trả lời trong kênh là một fork (tách nhánh), và cả hai chạy song song.

README của package tóm lời hứa đó trong hai câu: hội thoại, lượt mô hình, lời gọi tool và state riêng của ứng dụng đều được commit vào storage trước khi hiển thị bất cứ thứ gì; nếu tiến trình chết giữa lượt, mở lại storage là công việc tiếp tục từ chỗ dừng (README.md:5). Nó dựng trên pi-ai để gọi mô hình và trên Chord để giữ state của document (README.md:7).

Quan hệ với phần còn lại của Pi cần nói rõ, vì tên gọi dễ gây nhầm:

  • Không dùng AgentSession, không dùng pi-agent-core. Phụ thuộc trực tiếp của package là Chord, pi-ai, pi-codemode và pi-telemetry (package.json:152–156). Vòng lặp agent ở đây là các task bền: task pi.generation tự gọi models.streamSimple() của pi-ai (generation.ts:484; spec.md:3953–3960). Coding agent cũng không phụ thuộc Pi Durable; chương Bản đồ monorepo vẽ hai chồng package này cạnh nhau.
  • Không thay thế coding agent. Bài công bố viết "It does not replace the Pi coding agent": Pi Durable là framework để xây ứng dụng có agent, coding agent chỉ là một loại, và bài học rút ra sẽ chảy ngược về coding agent khi chứng minh được giá trị.
  • Ai gọi nó. Một ứng dụng host do bạn viết: tiến trình Node, server, Durable Object của Cloudflare, hay trang web. Host mở Harness.open(storage, options, context), cài extension vào một registry, rồi gửi input và quan sát state đã commit.

Mô hình tư duy#

Toàn bộ thiết kế xoay quanh một quy tắc, ghi ngay đầu đặc tả:

Nghĩa là: mọi thay đổi (một entry mới, một task đổi trạng thái, một document được sửa) đi qua cùng một dòng commit nguyên tử; giao diện, sự kiện và mọi thứ bên ngoài chỉ thấy những gì đã nằm trong storage. Không có kênh hiển thị nào "nhanh hơn storage". Danh sách bất biến ngay sau đó cấm hiệu ứng bên ngoài (gọi mô hình, chạy tiến trình) chạy bên trong transaction, và coi lỗi storage là lỗi chết của Session (spec.md:63–78).

 Ứng dụng host  (tiến trình Node, server, Durable Object, trình duyệt)
   │  submit · configure · reset · compact · abort · viewState · watch · watchEvents
   ▼
 Harness ──────────── Registry: extension (tool, section, hook, wrap, task)
   │                      chỉ sống trong bộ nhớ tiến trình, không được lưu
   ├─ Submissions        nhận input/write, inbox, wait()
   ├─ TaskScheduler      chạy task: pi.generation, pi.tool, pi.compaction, task riêng
   ├─ ConversationViews  mount view cho UI, dẫn xuất từ các commit
   ▼
 Session  một dòng commit nguyên tử; cache document dưới dạng tracker của Chord
   ▼
 Storage  Memory · SQLite · JSONL · IndexedDB · SQLite của Durable Object

 Trong storage:  conversation ── entry (bất biến)        task (bản ghi thay thế trọn)
                       └──── document (pi.agent, pi.live, pi.inbox, pi.usage, pi.provider, của bạn)
                 submission (input hoặc write đang chờ, đã đặt, đã xong)
Harness là một Session có thêm scheduler, hàng đợi submission và view. Code (extension) nằm trong registry của tiến trình; dữ liệu (hội thoại, entry, task, document) nằm trong storage.

Trong code, quan hệ "Harness là một Session" là quan hệ kế thừa thật: HarnessImpl mở rộng SessionImpl, và mọi thành phần bên trong đọc ghi qua this.storage, tức storage đã được bọc lớp chặn lỗi của Session.

HarnessImpl: một SessionImpl có thêm scheduler, submission và view
pi/packages/durable/src/harness/harness.tsdòng 206–223TypeScript
/** Session kernel extended with conversation handles and a registry. */class HarnessImpl<Tool extends ToolRegistration> extends SessionImpl implements HarnessType {	readonly #options: HarnessOptions<Tool>;	readonly #hostSettings: () => HarnessSettings | undefined;	readonly #report: (error: unknown, info: ReportInfo, context?: Context) => void;	readonly #host: ConversationHost<Tool>;	readonly #tasks: TaskScheduler;	readonly #submissions: Submissions;	readonly #taskGraph: TaskGraphView;	constructor(rawStorage: Storage, options: HarnessOptions<Tool>, context: Context) {		const report = safeReport(options.onReport, options.telemetry ?? NOOP_TELEMETRY_CONTEXT);		const now = safeNow(options.now, report);		const hostSettings = currentSettings(options.settings);		// Read at each use, as all settings are, but only this field: it is read on every commit.		super(rawStorage, now, () => hostSettings()?.documentRetentionMs, options.setTimer);		// Every component reads and writes through the Session's failure guard: `rawStorage` serves only `super()`.		const storage = this.storage;
Hàm khởi tạo tiếp tục tạo TaskScheduler, Submissions, TaskGraphView và ConversationViews trên cùng storage đó (harness.ts:228–263).

Từ vựng dùng trong phần còn lại của chương (định nghĩa gốc ở README.md:92–104 và spec.md:46–61):

Thuật ngữNghĩaKiểu trong code
HarnessMột storage đang mở cộng bộ máy chạy agent trên đóHarness, harness/types.ts:783
SessionChủ của dòng commit, các bản ghi và documentSession, types.ts:1070
Hội thoại (conversation)Một phạm vi transcript; có thể fork từ hội thoại khác. Handle không giữ state, so sánh bằng idConversationRecord, Conversation
EntryMột bản ghi bất biến của transcriptEntryRecord
DocumentState JSON có kiểu, đặt cạnh transcript, chỉ đổi qua commitdefineDoc(), DocToken
CommitMột lần ghi nguyên tử: entry, document, task cùng lưu hoặc cùng khôngTx, commit()
TaskMáy trạng thái bền, lưu checkpoint ở mỗi bước; luôn có chủ (hội thoại hoặc task khác)TaskRecord, defineTask()
SubmissionThứ host gửi vào hội thoại: input của người dùng hoặc một entry cần ghi; có thể chờSubmissionRecord, Submission
Lượt (turn) / lần chạy (run)Một câu trả lời của mô hình và các lời gọi tool của nó / chuỗi lượt từ một input tới câu trả lời cuối. Hội thoại bận (busy) khi có runpi.live.run
Extension / RegistryGói code có tên (tool, section, hook, wrap, task) / tập extension tiến trình này đã càiExtension, Registry
AgentThứ một hội thoại chạy cùng: mô hình, mức suy nghĩ (thinking level), extension, tool, instructions, thư mục làm việc. Lưu theo tên, giải lại mỗi lần dùngAgentState, Agent

Conversation, entry và các kind dựng sẵn#

ID: một không gian số duy nhất

Mọi bản ghi (hội thoại, entry, task, submission, document) lấy ID từ cùng một dãy số nguyên do storage cấp. Kiểu Id chỉ là number có gắn "nhãn" (brand) lúc biên dịch để TypeScript không cho truyền nhầm TaskId vào chỗ cần EntryId; trong JSON, JSONL và SQLite chúng là số thường (spec.md:242–246).

Id có nhãn, Seq của commit, và ID dành riêng cho hội thoại gốc
pi/packages/durable/src/types.tsdòng 18–40TypeScript
declare const idBrand: unique symbol;/** Erased nominal number identifying one durable record kind. */export type Id<Kind extends string, Type = unknown> = number & {	readonly [idBrand]: {		readonly kind: Kind;		readonly type: Type;	};};export type ConversationId = Id<"conversation">;export type EntryId = Id<"entry">;export type TaskId<Result = unknown> = Id<"task", Result>;export type SubmissionId = Id<"submission">;export type DocumentId = Id<"document">;declare const seqBrand: unique symbol;/** Strictly increasing sequence assigned to one atomic storage commit; gaps are permitted. */export type Seq = number & { readonly [seqBrand]: "sequence" };/** The root conversation always uses this reserved ID. */export const ROOT_CONVERSATION_ID = 1 as ConversationId;
Seq là số thứ tự của một commit trong storage, tăng nghiêm ngặt và được phép có khoảng trống. Nó mang nhãn riêng để một ID thực thể không bao giờ bị dùng làm điểm commit của document.

Vì dùng chung một dãy và storage hứa cấp số lớn hơn mọi số đã cấp trước đó (types.ts:1185–1189), thứ tự ID cũng là thứ tự tạo: quét entry theo ID là quét theo thời gian. Hội thoại gốc luôn có ID 1, nên mọi backend bắt đầu cấp từ 2; bộ kiểm thử tuân thủ có hẳn một ca cho việc này (storage-conformance.ts:98).

ConversationRecord: cha để thừa kế, chủ để quy trách nhiệm

ConversationRecord: danh tính, nguồn fork và cạnh sở hữu
pi/packages/durable/src/types.tsdòng 393–409TypeScript
/** Ownership selected explicitly whenever a conversation is created. */export type ConversationOwnership = { readonly kind: "ownerless" } | { readonly kind: "task"; readonly taskId: TaskId };/** Immutable identity, history ancestry, and task ownership of a transcript scope. */export type ConversationRecord = {	readonly id: ConversationId;	/** Fork source and inclusive parent entry through which history is inherited. */	readonly parent?: {		readonly conversationId: ConversationId;		readonly at: EntryId;	};	/** Creator edge used for attribution, subtree abort, and subtree idle waits. */	readonly owner?: {		readonly conversationId: ConversationId;		readonly taskId: TaskId;	};};

Hai trường tuỳ chọn có hai vai khác nhau (spec.md:247–255). parent nói hội thoại này là fork của hội thoại nào, tại entry nào: nó thấy các entry của cha tới parent.at. owner nói task nào đã tạo ra nó, ví dụ một subagent do tool subagent tạo; cạnh này dùng để huỷ cả cây và chờ cả cây rảnh, không phải để phân quyền. Khi tạo, người gọi luôn chọn quyền sở hữu tường minh (ownerless hoặc task); Session tự suy ra hội thoại của task chủ. Bản ghi bất biến: một hội thoại không bao giờ đổi cha hay đổi chủ.

EntryRecord: thứ mô hình thấy và thứ ứng dụng giữ

EntryRecord và EntryDraft
pi/packages/durable/src/types.tsdòng 427–449TypeScript
/** Immutable transcript event with separate model-facing and application-facing payloads. */export type EntryRecord = {	readonly id: EntryId;	readonly conversationId: ConversationId;	/** Application-defined entry discriminator. */	readonly kind: string;	/** Messages contributed to model context; absent for display or bookkeeping entries. */	readonly model?: readonly Message[];	/** JSON payload consumed by views, extensions, or bookkeeping logic. */	readonly data?: JsonValue;	/** First entry in the active context selected by this entry. */	readonly head?: EntryId;	/** Context-only overrides of earlier visible entries. */	readonly edits?: readonly ContextEdit[];	/** Task that appended this entry, when it was produced by durable work. */	readonly byTaskId?: TaskId;};/** Entry content supplied before the Session assigns identity and task attribution. */export type EntryDraft = Omit<EntryRecord, "id" | "conversationId" | "byTaskId" | "head"> & {	/** `"self"` starts active context at the newly assigned entry ID. */	readonly head?: EntryId | "self";};
ContextEdit ngay phía trên (types.ts:411–425) là một chỉnh sửa chỉ áp vào ngữ cảnh: omit bỏ phần đóng góp của entry đích, replace thay nó bằng các message khác.

Một entry tách hai luồng dữ liệu. model là các message (theo kiểu Message của pi-ai) mà entry đóng góp vào request kế tiếp gửi mô hình; data là JSON tuỳ ý cho view, extension hay sổ sách. Một entry không có model chỉ để hiển thị. head đánh dấu điểm bắt đầu của ngữ cảnh đang hoạt động: entry mang head là một "head marker", và khi viết nháp, head: "self" nghĩa là ngữ cảnh mới bắt đầu ngay tại entry này. edits sửa đóng góp của các entry cũ hơn mà không đụng vào chúng. byTaskId ghi task nào đã append entry, do Session đóng dấu từ phạm vi của commit.

Entry không bao giờ bị sửa hay xoá. Reset, compaction (nén ngữ cảnh) và chỉnh sửa đều là entry mới. Đây là khác biệt lớn so với cây JSONL của coding agent (chương Session): ở đó nhánh là cấu trúc của file; ở đây "nhánh" là một hội thoại khác có parent, và "quên bớt ngữ cảnh" là một head marker.

Các kind dựng sẵn

entries.ts: defineEntry và sáu token dựng sẵn
pi/packages/durable/src/entries.tsdòng 5–34TypeScript
/** Define a typed entry kind whose `is()` guard narrows by `EntryRecord.kind`. */export function defineEntry<D extends JsonValue = never>(kind: string): Entry<D> {	if (typeof kind !== "string" || kind.length === 0) throw new TypeError("Entry kind must be a non-empty string");	return {		kind,		is: (entry: EntryRecord | undefined): entry is TypedEntry<D> => entry !== undefined && entry.kind === kind,	};}/** User input: `model` is `[UserMessage]`. Written by submissions. */export const UserEntry = defineEntry("pi.user");/** Provider result with any stop reason: `model` is `[AssistantMessage]`. Written by generation. */export const AssistantEntry = defineEntry("pi.assistant");/** Positional prompt and tool change: `model` is `[SystemMessage]` with empty `content`. */export const SystemEntry = defineEntry("pi.system");/** * Tool result: `model` is `[ToolResultMessage]`, whose content ends with the rendered diagnostics block; `data` holds * the structured diagnostics, possibly none. Written by tool tasks, and by generation for calls it did not offer. */export const ToolResultEntry = defineEntry<{ diagnostics: ToolDiagnostic[] }>("pi.tool-result");/** * Start of a new context: always `head: "self"`, with `model` absent for a plain reset or `[UserMessage]` carrying the * handoff text. Written by `Conversation.reset()` and the `handoff` tool control. */export const ResetEntry = defineEntry("pi.reset");/** * Compaction summary: `model` is `[UserMessage]` with the wrapped summary, `head` the first kept entry. Written by * compaction tasks, directly or through a write submission. */export const CompactionEntry = defineEntry<{ reason: CompactionReason }>("pi.compaction");
Mỗi token là một cặp kind và hàm thu hẹp kiểu is(). Entry<D> mang kiểu của data: ToolResultEntry luôn có data.diagnostics, CompactionEntry có data.reason, các kind còn lại không có data (spec.md:3975–3979).
KindmodelAi ghi
pi.user[UserMessage], timestamp lấy từ đồng hồ Harness lúc đặt vào transcriptsubmission, continuation của hook onYield
pi.assistant[AssistantMessage] với mọi stop reason, kể cả lỗi và bị huỷgeneration
pi.system[SystemMessage] với content: ""; chỉ chứa section và thay đổi toolbước chuẩn bị của generation
pi.tool-result[ToolResultMessage]task tool; generation, cho lời gọi tới tool không được mời
pi.resetkhông có, hoặc [UserMessage] chứa ghi chú bàn giao; luôn head: "self"reset(), control handoff của tool
pi.compaction[UserMessage] chứa bản tóm tắt; head trỏ vào entry đầu tiên được giữtask compaction

Bảng trên tóm spec.md:3981–3988. Mọi câu trả lời của mô hình, kể cả lần thử lỗi, đều thành một entry pi.assistant; vì vậy không cần kind riêng cho "usage" hay "thông báo lỗi". Ứng dụng tự định nghĩa kind của mình bằng defineEntry<D>("app.note"); ví dụ 08-harness-conversations.ts:19 tạo kind message có data.from. Tiền tố pi. thuộc về các kind dựng sẵn theo quy ước; đặc tả nói rõ không có gì cưỡng chế điều đó (spec.md:5565–5568), trừ một chỗ: input gửi kèm entry.kind riêng bị từ chối nếu kind bắt đầu bằng pi. (submissions.ts:182–189).

Từ transcript tới ngữ cảnh của mô hình

"Mô hình thấy các entry từ lần reset mới nhất trở đi" (README.md:96). Chính xác hơn, việc dẫn xuất ngữ cảnh lấy head marker mới nhất H, quét các entry nhìn thấy được từ H.head tới cuối, áp chỉnh sửa (chỉnh sửa mới nhất của mỗi đích thắng), rồi chuẩn hoá cho giao thức của provider:

deriveRange(): chỉnh sửa, entry đang hoạt động, đóng góp, message
pi/packages/durable/src/harness/context.tsdòng 143–155TypeScript
/** Derive the context view from the entries scanned within `bounds`. */function deriveRange(bounds: ContextBounds, entries: readonly EntryRecord[]): ContextRange {	const edits = new Map<EntryId, ContextEdit>();	// Edits of every entry in the range count, including older head markers that `selectActive()` drops.	for (const entry of entries) for (const edit of entry.edits ?? []) edits.set(edit.target, edit);	const active = selectActive(bounds.head, entries);	const contributions = active.map((entry) => contribute(entry, edits.get(entry.id)));	const { settled, open } = settle([], contributions.flat());	const baseline = findBaseline(bounds.head, active);	const messages = leadWithSystem([...settled, ...orderToolResults(open)], baseline);	const view = { head: bounds.head, entries: active, contributions, messages };	return { bounds, entries, view, edited: new Set(edits.keys()), settled, open, baseline };}
contribute() loại các message assistant có stop reason aborted, error hoặc deferred (context.ts:10); orderToolResults() đặt kết quả tool ngay sau lời gọi của nó và tự tổng hợp kết quả lỗi cho lời gọi không có kết quả (context.ts:299–321); leadWithSystem() đưa system message nền (baseline) lên đầu (context.ts:212–220).

Mười quy tắc đầy đủ nằm ở spec.md:267–302. Hai hệ quả đáng nhớ: một fork cắt ngay sau một lời gọi tool vẫn gửi được request hợp lệ, vì kết quả thiếu được tổng hợp; và view cho UI giữ entry thô, không phải message đã chuẩn hoá (spec.md:304–306). Ví dụ 09-context.ts dựng đủ các trường hợp: message bị huỷ, kết quả về lệch thứ tự, chỉnh sửa, fork ở lời gọi tool, và một head "self".

Document: trạng thái JSON có kiểu#

Bài toán: ứng dụng có state riêng, như danh sách việc cần làm của agent, lựa chọn mô hình của một hội thoại, hay hàng đợi tin nhắn chưa xử lý. State này phải sống sót qua crash cùng lúc với transcript, và khi fork hội thoại thì nhánh mới phải nhận state "đúng lúc" với entry nó tách ra. Nếu state nằm ở một kho riêng, sẽ có lúc transcript đã ghi mà state chưa ghi. Pi Durable giải bằng document: một object JSON có kiểu, gắn vào session, một hội thoại hoặc một task, và chỉ thay đổi bên trong commit, cùng commit với entry.

Ngữ nghĩa của document và CommonDocDefinition
pi/packages/durable/src/types.tsdòng 42–79TypeScript
/** Conversation document that retains only its current state. */export type LatestConversationSemantics = {	readonly scope: "conversation";	readonly history: "latest";	readonly fork: "current" | "initial";};/** Conversation document whose history remains addressable for as-of reads. */export type RewindableConversationSemantics = {	readonly scope: "conversation";	readonly history: "rewindable";	readonly fork: "asOf" | "current" | "initial";};/** Ownership and lifetime of a document; only conversation documents declare history and fork behavior. */export type DocumentSemantics =	| { readonly scope: "session"; readonly history?: never; readonly fork?: never }	| LatestConversationSemantics	| RewindableConversationSemantics	| { readonly scope: "task"; readonly history?: never; readonly fork?: never };/** Stored replay state supplied to a document's checkpoint predicate. */export type CheckpointInfo = {	/** Deltas already stored after the newest base, excluding the change being evaluated. */	readonly deltasSinceBase: number;};/** Definition fields shared by singleton documents and document families. */export type CommonDocDefinition<T extends JsonObject> = {	/** Stable persisted kind; part of the public protocol. */	readonly kind: string;	/** Positive integer version of the stored value shape. */	readonly version: number;	initial(): T;	migrate?(value: JsonObject, fromVersion: number): T;	/** Required policy: return true to store this ordinary change as a complete base instead of a delta. */	checkpointWhen(value: Readonly<T>, ops: readonly Op[], info: CheckpointInfo): boolean;};

Các trường của một định nghĩa:

kind, version
kind là tên được lưu, thuộc giao thức công khai, không đổi được bằng migration; version là số nguyên dương của hình dạng giá trị. Giá trị lưu ở version cũ hơn được chuyển qua migrate(value, fromVersion) khi truy cập (spec.md:1596–1601).
scope
session (một bản cho cả session), conversation (một bản cho mỗi hội thoại) hoặc task (sống và tự nghỉ hưu cùng task).
history, fork (chỉ document hội thoại)
latest chỉ giữ giá trị hiện tại; rewindable giữ lịch sử để đọc "giá trị lúc entry X" bằng snapshotAsOf(). fork nói nhánh mới bắt đầu bằng gì: initial (giá trị khởi tạo), current (giá trị hiện tại của cha khi fork) hoặc asOf (giá trị của cha tại entry fork, nên đòi rewindable). Ràng buộc này nằm ở kiểu: LatestConversationSemantics không cho fork: "asOf".
initial(), checkpointWhen()
initial() chạy khi document chưa có và được tạo lần đầu. checkpointWhen(value, ops, info) quyết định một thay đổi được lưu như một base (giá trị đầy đủ) hay một delta (các phép toán Chord), để chặn độ dài chuỗi phát lại (replay). Nó bắt buộc, không có mặc định (spec.md:1222–1227).
defineDoc(): bốn overload trả về bốn loại token
pi/packages/durable/src/documents.tsdòng 38–57, 95–99TypeScript
/** Define a Session-scoped singleton document. */export function defineDoc<T extends JsonObject>(	definition: CommonDocDefinition<T> & { readonly scope: "session" },): SessionDocToken<T>;/** Define a latest-only conversation singleton document. */export function defineDoc<T extends JsonObject>(	definition: CommonDocDefinition<T> & LatestConversationSemantics,): ConversationDocToken<T>;/** Define a rewindable conversation singleton document. */export function defineDoc<T extends JsonObject>(	definition: CommonDocDefinition<T> & RewindableConversationSemantics,): RewindableConversationDocToken<T>;/** Define a task-scoped singleton document. */export function defineDoc<T extends JsonObject>(	definition: CommonDocDefinition<T> & { readonly scope: "task" },): TaskDocToken<T>;export function defineDoc<T extends JsonObject>(definition: DocDefinition<T>): DocToken<T, DocDefinition<T>> {	validateDefinition(definition);	return { definition };}function validateDefinition(definition: AnyDocDefinition): void {	if (!Number.isSafeInteger(definition.version) || definition.version < 1) {		throw new TypeError(`Document ${definition.kind} version must be a positive integer`);	}}
Lúc chạy, defineDoc() chỉ kiểm tra version rồi trả về { definition }. Token không được đăng ký ở đâu: nó được truyền tường minh vào mọi lần truy cập (tx.doc(token, id), snapshot(token, id)), và Session không quét document lúc mở.

defineDocFamily() làm tương tự cho một họ document cùng phạm vi, phân biệt bằng một khoá chuỗi, với initial(seed). Task tool dùng một họ như vậy, pi.tool.nested-call, để lưu kết quả của từng lời gọi lồng (tool.ts:106–114). Cách ghi, đọc lịch sử, base, delta và fork bên trong được mổ xẻ ở chương storage.

Năm document dựng sẵn

Mọi hội thoại do Harness tạo đều có năm document, tạo ngay trong commit tạo hội thoại bởi hook dựng sẵn:

Hook tạo hội thoại: năm document rồi tới hook của host
pi/packages/durable/src/harness/harness.tsdòng 420–432TypeScript
/** * The built-in creation hook, in every commit that creates or forks a conversation: empty `pi.live`, `pi.inbox`, and * `pi.usage`, a fresh `pi.provider`, the conversation's `pi.agent` (see `createAgent()`), then * `HarnessOptions.conversationCreated`. */protected override async conversationCreated(tx: Transaction, record: ConversationRecord): Promise<void> {	await tx.doc(LiveDoc, record.id);	await tx.doc(InboxDoc, record.id);	await tx.doc(UsageDoc, record.id);	await tx.doc(ProviderDoc, record.id);	await createAgent(tx, record);	await this.#options.conversationCreated?.(tx, record);}
Hook này chạy trong mọi commit tạo hoặc fork hội thoại, kể cả tx.createConversation() thô do một tool gọi (spec.md:768–783). Một Session trần (createSession()) không tạo document nào.
KindLịch sử / forkChứa gìLưu base khiĐịnh nghĩa
pi.agentrewindable / asOfLựa chọn của hội thoại, lưu theo tên: mô hình, mức suy nghĩ, extension, tool, instructions, cwdmọi thay đổiagent.ts:42–51
pi.providerlatest / initial{ sessionId }: UUIDv7 mà pi-ai nhận làm sessionId cho prompt cache và session affinitymọi thay đổiprovider.ts:11–20
pi.livelatest / initialQuyền điều khiển run, câu trả lời đang stream, các tool đang chạy, compaction đang chạykhông còn gì đang chạylive.ts:111–127
pi.inboxlatest / initialCác submission đang xếp hàng, theo thứ tự IDhàng đợi rỗnginbox.ts:21–29
pi.usagelatest / initialTổng token và chi phí theo provider/model và theo tên toolmọi thay đổiusage.ts:14–22

Chính sách fork của từng document là quyết định sản phẩm. pi.agent là asOf để một fork chạy với đúng agent mà cha có tại entry fork. Bốn document còn lại là initial: một fork bắt đầu với inbox rỗng (hàng đợi thuộc về hội thoại gốc), không có run, chi phí bằng không (để không đếm hai lần, spec.md:4433–4436) và một sessionId mới (spec.md:866–872). Riêng pi.agent của hội thoại do task sở hữu được sao chép từ hội thoại của task chủ, một lần, lúc tạo:

createAgent(): fork giữ bản asOf, hội thoại của task chép agent của chủ, hội thoại tự do bắt đầu rỗng
pi/packages/durable/src/harness/agent.tsdòng 169–180TypeScript
/** * Built-in part of every Harness commit that creates or forks a conversation, for `pi.agent`: a fork keeps its `asOf` * copy; a new task-owned conversation copies the stored agent of its owner task's conversation; a new ownerless one * starts empty. */export async function createAgent(tx: Tx, conversation: ConversationRecord): Promise<void> {	if (conversation.parent !== undefined) return;	const agent = await tx.doc(AgentDoc, conversation.id);	if (conversation.owner === undefined) return;	const owner = await tx.doc(AgentDoc, conversation.owner.conversationId);	Object.assign(agent, copyJson(owner) as AgentState);}

pi.live là document bận rộn nhất và cũng là định nghĩa của "đang bận":

LiveState: run, generation, tools, nestedTools, compactions
pi/packages/durable/src/harness/live.tsdòng 81–109TypeScript
/** Built-in live conversation state: run control and presentation of the current generation and tool round. */export type LiveState = {	/** Run control: the task that settles the run's inputs, and those inputs; present exactly while busy. */	run?: { taskId: TaskId; inputs: SubmissionId[] };	/** Presentation of the current generation attempt. */	generation?: {		attempt: number;		/** Committed throttled partial of the in-flight response. */		message?: JsonRepresentation<AssistantMessage>;		/** Durable backoff before the next attempt. */		retry?: { at: number; error: string };		/** Provider-side deferred response being polled. */		deferred?: { pollAt: number };	};	/** The current tool round in call order, from the tool-calling answer until the generation's `tools` phase ends it. */	tools?: ToolSlot[];	/**	 * Nested calls of running tool calls, in creation order, so a parent precedes its children and task IDs ascend. A	 * call's nested slots are removed when the call settles.	 */	nestedTools?: NestedToolSlot[];	/** Live compaction tasks in task ID order; absent when none. */	compactions?: CompactionStatus[];	/**	 * The newest compaction failure, so a client can show it after the task ended. A compaction that produces a summary	 * removes it; one that is aborted or finds nothing to compact leaves it.	 */	compactionFailure?: CompactionFailure;};
run có mặt đúng khi hội thoại bận; taskId là generation chịu trách nhiệm trả lời các inputs, và giá trị này được chuyển từ generation này sang generation kế tiếp suốt một run (spec.md:2528–2537).

Chính sách checkpoint của nó cho thấy cách chọn checkpointWhen có chủ đích: lưu base mỗi khi không còn gì đang chạy, tức lúc rảnh, lúc generation giao cho vòng tool, và giữa các tool. Vì một slot chỉ giữ output khi đang chạy, mọi base đều nhỏ, và chuỗi delta không bao giờ dài hơn một generation (live.ts:118–126; spec.md:4092–4101).

Ứng dụng định nghĩa document của mình theo cùng cách. README cho ví dụ app.todos sửa trong một commit rồi đọc lại bằng harness.snapshot() (README.md:728–747); HarnessOptions.conversationCreated cho phép tạo document của ứng dụng trong mọi hội thoại, và extension đọc document của mình qua api hoặc input.read, coi document vắng mặt là giá trị mặc định (11-extension-state.ts:32–56).

Commit: bề mặt giao dịch#

Mọi thay đổi đi qua một callback nhận tx (kiểu Tx). Những gì callback ghi được lưu cùng nhau khi nó trả về, hoặc bị bỏ hết nếu nó ném lỗi (README.md:97). Ví dụ 01-documents.ts:28–32 append một entry và sửa một document trong cùng một commit. Phần ghi của Tx:

Tx: tạo hội thoại, fork, append entry, tạo task
pi/packages/durable/src/types.tsdòng 932–952TypeScript
/** Create a conversation with explicitly selected ownership. */createConversation(options: { readonly ownership: ConversationOwnership }): Promise<ConversationRecord>;/** Create a history fork at one concrete visible entry with explicitly selected ownership. */forkConversation(	parentConversationId: ConversationId,	at: EntryId,	options: { readonly ownership: ConversationOwnership },): Promise<ConversationRecord>;/** Returned records are Session-owned immutable values and may be shared with commit listeners. */appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord>;/** The token supplies `kind` and types `data`. */appendEntry<D extends JsonValue>(	token: Entry<D>,	conversationId: ConversationId,	value: TypedEntryDraft<NoInfer<D>>,): Promise<TypedEntry<D>>;createTask<I, S extends { phase: string }, R, H extends object>(	task: Task<I, S, R, H>,	input: I,	options: TaskOptions,): Promise<TaskId<R>>;
Phần đọc nằm ngay trên (types.ts:910–930); phần submission (createSubmission, settleSubmission, placeSubmission) và document (doc, retireDoc) nằm ngay dưới (types.ts:953–1005).

Có ba cửa vào cùng một cơ chế, khác nhau ở phạm vi mặc định:

  • harness.commit(tx => ...): commit của Session, không gắn với hội thoại nào; tx.createTask() khi đó phải được cho conversationId.
  • conversation.commit(tx => ...): cùng commit nhưng gắn với hội thoại đó, nên tx.createTask() mặc định thuộc hội thoại này (harness.ts:141–143).
  • runtime.commit((tx, current) => next) trong phase của task và api.commit() trong tool: commit còn đóng dấu byTaskId lên entry, và với task thì giá trị trả về là trạng thái kế tiếp của task, ghi trong cùng commit (types.ts:266–278; xem chương task).

Bốn quy tắc cần thuộc trước khi viết commit (chi tiết thực thi ở chương storage):

  1. Đọc bảng trước, ghi bảng sau. Bảng là hội thoại, entry, task và submission. Sau lần ghi bảng đầu tiên, mọi lần đọc bảng ném ReadAfterWrite; document vẫn đọc ghi được (spec.md:1728–1734; errors.ts:16–22).
  2. Không làm việc chậm bên trong. Callback giữ dòng commit của cả Session; không được chờ mô hình, tool, mạng, con người hay một commit lồng (spec.md:5521–5523).
  3. Đọc qua tx, không qua Session. snapshot(), getTask() và các lần đọc khác có thể xếp hàng sau chính commit đang giữ dòng; gọi chúng bên trong callback có thể treo vĩnh viễn, và chỉ treo khi trượt cache nên lỗi lúc có lúc không (spec.md:5524–5528).
  4. Draft chết khi callback xong. tx.doc() trả về một draft của Chord; giá trị gán vào được sao chép ngay và phải là JSON nghiêm ngặt; đọc hay ghi draft sau khi callback xong sẽ ném lỗi (spec.md:1519–1535).

Submission và inbox#

Host không append entry trực tiếp vào một hội thoại có thể đang bận. Nó gửi một submission: hoặc input của người dùng (có thể khởi động một run), hoặc một entry cần ghi (write). submit() trả về ngay sau khi submission được ghi bền vào storage (admission), không đợi câu trả lời; đối tượng Submission trả về là thứ để chờ (spec.md:1002–1011).

SubmissionDraft và Submission
pi/packages/durable/src/harness/types.tsdòng 58–95TypeScript
/** * The entry an input is placed as, instead of `pi.user`: an application kind, and data the transcript shows, such as its * author. Its model message is still the input's user message. */export type InputEntry = { readonly kind: string; readonly data?: JsonValue };/** Host submission: user input that may start a run, or a passive entry write. */export type SubmissionDraft = {	readonly requestId?: string;} & (	| {			readonly type: "input";			readonly content: UserInput;			readonly whenBusy?: "steer" | "followUp" | "reject";			/** Place the input as this entry; default `pi.user` without data. The kind must not start with `pi.`. */			readonly entry?: InputEntry;	  }	| {			readonly type: "write";			readonly entry: EntryDraft;			readonly content?: never;			readonly whenBusy?: never;	  });export type InputSubmissionDraft = Extract<SubmissionDraft, { readonly type: "input" }>;export type SettledSubmissionRecord = SubmissionRecord & {	readonly status: "done" | "unanswered";};/** Awaitable host object for one durably admitted submission. */export interface Submission {	readonly id: SubmissionId;	status(context: Context): Promise<SubmissionRecord>;	wait(context: Context): Promise<SettledSubmissionRecord>;	abort(context: Context): Promise<"aborted" | "already_placed" | "settled">;}

Mỗi submission có một bản ghi bền với vòng đời riêng. Với input: queued (đã nhận, chưa vào transcript), placed (đã thành entry, thuộc một run đang chạy), rồi done (kèm answer là ID entry câu trả lời) hoặc unanswered (kèm reason). Write không có trạng thái placed: đặt vào transcript là xong.

SubmissionRecord: bốn trạng thái của một input
pi/packages/durable/src/types.tsdòng 476–512TypeScript
export type SubmissionRecord =	| (SubmissionRecordBase & {			readonly type: "input";	  } & (				| {						/** Admitted but not yet represented in the transcript. */						readonly status: "queued";						readonly entry?: never;						readonly answer?: never;						readonly reason?: never;						readonly detail?: never;				  }				| {						/** Added to the transcript and owned by an active run. */						readonly status: "placed";						readonly entry: EntryId;						readonly answer?: never;						readonly reason?: never;						readonly detail?: never;				  }				| {						/** Successfully answered user input. */						readonly status: "done";						readonly entry: EntryId;						readonly answer: EntryId;						readonly reason?: never;						readonly detail?: never;				  }				| {						/** Terminal input that can no longer receive an answer. */						readonly status: "unanswered";						readonly entry?: EntryId;						readonly answer?: never;						readonly reason: string;						readonly detail?: JsonValue;				  }			))
Nhánh write nằm ngay sau (types.ts:513–540); trường chung id, conversationId, requestId, trace ở types.ts:465–473.

Admission: một commit quyết định số phận

Conversation.submit() chuyển cho Submissions.submit(), vốn bật scheduler (một lời gọi xin tiến triển thì không bao giờ phải chờ một Harness đang tạm dừng) rồi chạy toàn bộ việc nhận trong một commit:

Submissions.submit(): resume() rồi một commit chạy admitSubmission()
pi/packages/durable/src/harness/submissions.tsdòng 75–93TypeScript
/** Admit a submission in one commit; see `admitSubmission()`. */async submit(conversationId: ConversationId, draft: SubmissionDraft, context: Context): Promise<Submission> {	this.#resume();	const parent = draft.type === "input" ? this.#telemetry : context.telemetry;	return createTypedSpanStarter(parent, [DURABLE_TELEMETRY_SCHEMA])(		"pi.durable.submit",		{			"submission.type": draft.type,			"conversation.id": conversationId,		},		async (span) => {			if (draft.type === "input") span.addLink({ context: context.telemetry });			try {				const { id, status } = await this.#session.commitWith(					(tx) => admitSubmission(tx, conversationId, draft, this.#now(), this.#queueModes(), span),					withTelemetry(span, context),				);				span.setAttributes({ "submission.id": id, result: status });				return new SubmissionHandle(id, this);
Span pi.durable.submit chỉ mang ID và trạng thái, không mang nội dung (README.md:84).

admitSubmission() đọc từ trên xuống như một cây quyết định. Đầu tiên là trùng requestId và write chỉ để hiển thị:

admitSubmission() (1): requestId trùng thì trả bản cũ; write chỉ để hiển thị được append ngay
pi/packages/durable/src/harness/submissions.tsdòng 209–228TypeScript
if (draft.type === "input" && draft.entry !== undefined) checkInputEntry(draft.entry);if (draft.requestId !== undefined) {	const existing = await tx.submissionByRequest(conversationId, draft.requestId);	if (existing !== undefined) {		if (existing.type !== draft.type) {			throw new Error(`Request ${draft.requestId} already identifies a submission of type ${existing.type}`);		}		return { id: existing.id, status: existing.status === "unanswered" ? "done" : existing.status };	}}const fields = { conversationId, requestId: draft.requestId, trace: span?.propagation?.inject(span) };if (	draft.type === "write" &&	isDisplayOnly(draft.entry) &&	!hasQueuedReset((await tx.doc(InboxDoc, conversationId)).items)) {	const entry = await tx.appendEntry(conversationId, draft.entry);	const write = { ...fields, type: "write", status: "done", entry: entry.id } as const;	return { id: (await tx.createSubmission(write)).id, status: "done" };}
Write "chỉ để hiển thị" là write chỉ có kind và data, không có model, head hay edits (inbox.ts:131–137). Nó không đổi gì mô hình thấy, nên được append và chốt done ngay, kể cả giữa một run; ngoại lệ duy nhất là khi có một reset đang xếp hàng.

Tiếp theo là trường hợp bận, hoặc rảnh nhưng inbox còn mục chờ:

admitSubmission() (2): bận thì xếp hàng; inbox còn mục thì xếp hàng rồi chạy boundary final
pi/packages/durable/src/harness/submissions.tsdòng 229–265TypeScript
const live = await tx.doc(LiveDoc, conversationId);const busy = live.run !== undefined;if (	draft.type === "input" &&	draft.whenBusy === "steer" &&	live.run !== undefined &&	span?.propagation !== undefined) {	const run = await tx.task(live.run.taskId);	if (run?.trace !== undefined) span.addLink({ context: span.propagation.extract(run.trace) });}if (busy && draft.type === "input" && draft.whenBusy === "reject") throw new ConversationBusy(conversationId);// A boundary reads the table, so it is prepared before the first table write; a busy one needs none.const boundary = busy ? undefined : await prepareBoundary(tx, conversationId, queueModes);if (boundary === undefined || boundary.inbox.items.length > 0) {	const { id } = await tx.createSubmission({		...fields,		type: draft.type,		status: "queued",	});	// Hosts may leave optional fields `undefined`; drafts take strict JSON.	const value = copyJson(draft.type === "write" ? draft.entry : draft.content, {		omitUndefinedProperties: true,	});	const items = (boundary?.inbox ?? (await tx.doc(InboxDoc, conversationId))).items;	if (draft.type === "write") items.push({ id, mode: "write", entry: value as JsonObject });	else {		const mode = draft.whenBusy === "steer" ? "steer" : "followUp";		const content = value as JsonRepresentation<UserInput>;		const entry = draft.entry === undefined ? {} : { entry: pickInputEntry(draft.entry) };		items.push({ id, mode, content, ...entry });	}	if (boundary === undefined) return { id, status: "queued" };	const { users } = await applyBoundary(tx, boundary, "final", now);	if (users.length > 0) await startRun(tx, conversationId, live, users);	return { id, status: draft.type === "write" ? "done" : users.at(-1) === id ? "placed" : "queued" };}
Bận được định nghĩa đúng một cách: pi.live.run có mặt. whenBusy: "reject" ném ConversationBusy và không ghi gì (errors.ts:48–57). Khi rảnh mà inbox còn mục (ví dụ sau một run thất bại), mục mới xếp sau chúng và một boundary final chạy ngay trong commit này.

Cuối cùng là trường hợp phổ biến nhất, hội thoại rảnh và inbox rỗng:

admitSubmission() (3): rảnh thì đặt entry ngay; input thì tạo luôn run
pi/packages/durable/src/harness/submissions.tsdòng 266–283TypeScript
	if (draft.type === "write") {		if (isStale(boundary, draft.entry)) {			const stale = { status: "unanswered", reason: "stale" } as const;			return {				id: (await tx.createSubmission({ ...fields, type: "write", ...stale })).id,				status: "done",			};		}		const entry = await tx.appendEntry(conversationId, draft.entry);		const write = { ...fields, type: "write", status: "done", entry: entry.id } as const;		return { id: (await tx.createSubmission(write)).id, status: "done" };	}	const entry = await appendInput(tx, conversationId, draft.content, draft.entry, now);	const input = { ...fields, type: "input", status: "placed", entry: entry.id } as const;	const { id } = await tx.createSubmission(input);	await startRun(tx, conversationId, live, [id]);	return { id, status: "placed" };}
Một write có head trỏ về trước phạm vi đang hoạt động là stale: đặt nó sẽ làm sống lại lịch sử mà head mới hơn đã cắt (inbox.ts:144–147). Với input, appendInput() ghi entry pi.user (hoặc kind riêng của input) và startRun() tạo task generation đầu tiên (generation.ts:752–760).

Bảng chuyển trạng thái đầy đủ ở spec.md:2539–2552. Tóm tắt các nhánh trên:

Tình huốngBản ghi submissionGhi kèm trong cùng commit
requestId đã có trong hội thoạikhông ghi gì, trả về submission cũkhông có
Write chỉ để hiển thị, không có reset đang chờdone, kèm entryentry
Input, rảnh, inbox rỗngplaced, kèm entryentry pi.user, task pi.generation, pi.live.run
Write khác, rảnh, inbox rỗngdone, hoặc unanswered lý do staleentry (nếu không stale)
Bậnqueuedmục mới trong pi.inbox
Rảnh nhưng inbox còn mụcqueued, rồi có thể thành placed hoặc done ngaymục mới xếp cuối pi.inbox, rồi một boundary final trong cùng commit đặt các mục được chọn và có thể mở run
Bận và whenBusy: "reject"không có bản ghiném ConversationBusy

Steer, follow-up và boundary

Mục trong inbox được đặt vào transcript ở các boundary (ranh giới giữa các lượt). Boundary postTools chạy khi một vòng tool kết thúc mà run còn tiếp; boundary final chạy khi run trả lời xong. Write luôn được đặt; tin nhắn steer (chen vào lượt đang chạy) được đặt ở cả hai; follow-up (xếp hàng sau) chỉ ở final, và khởi động run kế tiếp:

applyBoundary(): chọn write, steer và follow-up
pi/packages/durable/src/harness/inbox.tsdòng 76–86TypeScript
const { conversationId, inbox } = boundary;const items = inbox.items;const reset = hasQueuedReset(items);const final = at === "final" || reset;const pick = (mode: "steer" | "followUp", queueMode: QueueMode): number[] => {	const indexes = items.flatMap((item, index) => (item.mode === mode ? [index] : []));	return queueMode === "all" ? indexes : indexes.slice(0, 1);};const writes = items.flatMap((item, index) => (item.mode === "write" ? [index] : []));const users = [...pick("steer", boundary.steeringMode), ...(final ? pick("followUp", boundary.followUpMode) : [])];users.sort((a, b) => a - b);
steeringMode và followUpMode mặc định là one-at-a-time: mỗi boundary lấy mục đầu tiên của loại đó; all lấy tất cả (agent.ts:78–79). Write được đặt trước, rồi tới input, đều theo thứ tự ID, nên input xếp trước một reset sẽ chạy trong ngữ cảnh mới (spec.md:2584–2592).

Một reset đang chờ biến boundary postTools thành final, và các input của run hiện tại kết thúc unanswered với lý do reset, vì ngữ cảnh của chúng đã bị cắt trước khi có câu trả lời (spec.md:2613–2617). Ví dụ 20-inbox.ts chạy đủ steer, follow-up, write và rút lại khi đang bận.

requestId, wait() và abort()

requestId làm cho việc gửi lại idempotent (gọi lại nhiều lần vẫn cho một kết quả) trong phạm vi một hội thoại: lần gửi thứ hai với cùng ID, kể cả sau khi tiến trình khởi động lại, trả về đúng submission đã có (README.md:129–138). Dùng lại một requestId cho loại submission khác thì bị từ chối. Đây là cách host đạt "đúng một lần" (exactly-once): nếu commit có thể đã thành công trước khi tiến trình chết, cứ gửi lại với cùng requestId. Bài "Pi Durable on Cloudflare" áp dụng đúng mẫu này cho lệnh tạo hội thoại: client gửi lại các yêu cầu chưa được xác nhận với cùng request ID.

wait(), abort() và bộ quan sát commit giải phóng người chờ
pi/packages/durable/src/harness/submissions.tsdòng 115–150TypeScript
async wait(id: SubmissionId, context: Context): Promise<SettledSubmissionRecord> {	this.#resume();	// Check and register on the line so no settling publication falls between them.	const found = await this.#session.readOnLine(async () => {		const record = await this.#storage.submission(id, context);		if (record === undefined) throw new Error(`Submission ${id} does not exist`);		if (isSettled(record)) return { promise: Promise.resolve(record) };		// Close rejects registered waiters synchronously and may begin during the read.		if (this.#closed) throw closedError(this.#session);		return { promise: this.#waiters.add(id, context) };	});	return found.promise;}/** Withdraw a queued submission and remove its inbox item; placed inputs and settled submissions are reported. */abort(id: SubmissionId, context: Context, conversationId?: ConversationId): Promise<AbortResult | "not_found"> {	return this.#session.commitWith(async (tx) => {		const record = await tx.submission(id);		if (record === undefined || (conversationId !== undefined && record.conversationId !== conversationId)) {			return "not_found";		}		if (record.status === "queued") {			tx.settleSubmission(id, { status: "unanswered", reason: "aborted" });			await removeInboxItem(tx, record.conversationId, id);			return "aborted";		}		return record.status === "placed" ? "already_placed" : "settled";	}, context);}#observe(publication: CommitPublication): void {	for (const change of publication.changes) {		if (change.type !== "submission" || !isSettled(change.value)) continue;		this.#waiters.resolve(change.value.id, change.value);	}}
wait() kiểm tra và đăng ký người chờ trên dòng commit, nên không thể có commit chốt nào lọt vào giữa. Người chờ được giải phóng bởi một bộ nghe commit nội bộ, tức chỉ khi việc chốt đã nằm trong storage. Huỷ context của wait() chỉ huỷ việc chờ; rút lại submission là việc của abort(), và chỉ rút được khi còn queued.

Các lý do unanswered mà code v1.4.0 thực sự ghi:

Lý doKhi nàoNơi ghi
abortedRút lại khi còn xếp hàng; Conversation.abort(); task của run bị huỷsubmissions.ts:137, inbox.ts:160, generation.ts:312
staleWrite có head trỏ về trước phạm vi đang hoạt độnginbox.ts:93, submissions.ts:268
resetMột reset cắt ngữ cảnh giữa rungeneration.ts:726
model_error, no_modelMô hình trả lỗi không thử lại được; hội thoại không có mô hìnhgeneration.ts:584, generation.ts:396
run_failedRun trước thất bại và settings.afterFailedRun là withdrawgeneration.ts:424
faulted, hoặc lý do orphanTask của run lỗi lập trình, hoặc mất định nghĩa và bị huỷlive.ts:263–266

Mặc định (afterFailedRun: "keep"), một run thất bại để nguyên các mục xếp hàng sau nó cho tới khi có input mới hoặc write xếp hàng chạy boundary; continue chạy run kế tiếp với chúng; withdraw chốt chúng unanswered (README.md:220; generation.ts:415–428).

Extension và Registry#

Một mục tiêu của Pi Durable là đổi được code mà không dừng agent đang chạy. Nhưng state bền lại tham chiếu tới code: hội thoại chọn tool nào, task thuộc định nghĩa nào. Cách giải: tách hẳn hai thứ. Code (tool, section prompt, hook, wrapper, định nghĩa task) nằm trong extension có tên, cài vào một registry do ứng dụng sở hữu, chỉ sống trong bộ nhớ tiến trình. Storage chỉ lưu tên. Mỗi lần dùng, tên được giải (resolve) lại với registry hiện tại (spec.md:2644–2649).

Extension: tools, sections, hooks, wraps, tasks
pi/packages/durable/src/harness/types.tsdòng 363–381TypeScript
/** Built by `hook()`; matches tasks by name. */export type HookRegistration = { readonly task: string; readonly handlers: object };/** Built by `wrapTool()` and `wrapSection()`; targets a tool name or a section key. Wrappers are pure. */export type Wrap<Tool extends ToolRegistration = ToolRegistration> =	| { readonly tool: string; wrap(tool: Tool): Tool }	| { readonly section: string; wrap(section: PromptSection<Tool>): PromptSection<Tool> };/** Named bundle of code; installed in a registry and selected by conversations by name. */export interface Extension<Tool extends ToolRegistration = ToolRegistration> {	readonly name: string;	readonly tools?: readonly Tool[];	readonly sections?: readonly PromptSection<Tool>[];	readonly hooks?: readonly HookRegistration[];	/** Apply where this extension is selected, in order. */	readonly wraps?: readonly Wrap<Tool>[];	/** Resolved by name for every task, whichever conversations select this extension. */	readonly tasks?: readonly AnyTask[];}
  • tools: tool của extension; tool trùng tên của extension đứng sau thay tool của extension đứng trước, ở hội thoại chọn cả hai.
  • sections: các mảnh system prompt (mục System prompt bên dưới).
  • hooks: hook(GenerationTask, {...}) hay hook(ToolTask, {...}) đăng ký handler theo tên task; chỉ chạy ở hội thoại chọn extension này. Danh sách hook của các task dựng sẵn: harness/types.ts:851–907.
  • wraps: wrapTool(tool, fn) và wrapSection(key, fn) bọc một tool hay section theo tên, ở nơi extension bọc được chọn, và áp lên tool nào đang thắng.
  • tasks: định nghĩa task; khác với mọi thứ khác, chúng được giải theo tên cho mọi task, bất kể hội thoại nào chọn extension, vì một task đang chờ phải tìm lại được code của nó sau khởi động lại.
define.ts: các hàm định nghĩa chỉ là hàm đồng nhất có kiểu
pi/packages/durable/src/harness/define.tsdòng 5–10, 28–36TypeScript
/** Identity function that types an extension. */export function defineExtension<Tool extends ToolRegistration = ToolRegistration>(	extension: Extension<Tool>,): Extension<Tool> {	return extension;}/** Hook handlers for tasks with `task`'s name. */export function hook<K extends AnyTask>(task: K, handlers: Partial<HooksOf<K>>): HookRegistration {	return { task: task.definition.name, handlers };}/** Wrap the tool named like `tool` wherever the wrapping extension is selected. */export function wrapTool<Tool extends ToolRegistration>(tool: Tool, wrapper: (tool: Tool) => Tool): Wrap<Tool> {	return { tool: tool.name, wrap: wrapper };}
defineExtension() trả về chính đối số của nó, hook() chỉ ghi lại tên task. Mọi kiểm tra dồn về registry.install() (spec.md:2759–2763).
Registry: cài, thay tại chỗ, gỡ, công bố đồng bộ
pi/packages/durable/src/harness/registry.tsdòng 60–93TypeScript
class RegistryImpl<Tool extends ToolRegistration> implements Registry<Tool> {	#current = new RegistryState<Tool>([]);	readonly #listeners = new Set<() => void>();	snapshot(): RegistrySnapshot<Tool> {		return this.#current;	}	subscribe(listener: () => void): () => void {		this.#listeners.add(listener);		return () => this.#listeners.delete(listener);	}	install(extension: Extension<Tool>): void {		validateExtension(extension);		const current = this.#current.installed();		const index = current.findIndex((installed) => installed.name === extension.name);		const next =			index < 0 ? [...current, extension] : current.map((installed, at) => (at === index ? extension : installed));		this.#publish(next);	}	uninstall(extension: Extension): void {		const current = this.#current.installed();		if (!current.some((installed) => installed.name === extension.name)) return;		this.#publish(current.filter((installed) => installed.name !== extension.name));	}	/** Build and validate the next state, which throws on a task name collision, then publish it synchronously. */	#publish(extensions: readonly Extension<Tool>[]): void {		this.#current = new RegistryState(extensions);		for (const listener of [...this.#listeners]) listener();	}}
Cài một extension trùng tên thay nó tại chỗ, giữ vị trí cài đặt, trong một lần công bố; gỡ thì lọc theo tên, bất kể đối tượng nào được truyền vào. new RegistryState(...) ném lỗi nếu hai task trùng tên (registry.ts:18–31) trước khi #current bị gán, nên một lần cài hỏng không công bố gì.

validateExtension() đòi tên tool và khoá section không trùng trong một extension, khoá section khớp ^[a-z][a-z0-9_-]*$, và cấm khoá instructions, vốn dành cho instructions của agent (registry.ts:95–109). Ba task dựng sẵn pi.generation, pi.tool và pi.compaction luôn có trong mọi registry và không phải extension (registry.ts:9–10); Harness.open() từ chối registry thiếu chúng, nên hãy luôn tạo bằng createRegistry().

Thay code khi đang chạy (reload) theo một quy tắc đơn giản: việc đã bắt đầu giữ code nó đã lấy. Một lời gọi tool đang chạy kết thúc với cài đặt cũ; mỗi phase của task giải hook và agent một lần từ ảnh chụp (snapshot) registry lúc phase bắt đầu; phase, request hay lời gọi kế tiếp dùng code mới (README.md:464–472; spec.md:3799–3813). Sau khởi động lại, host cài lại cùng các extension; task đang chờ của một extension chạy tiếp khi extension đó được cài. Ví dụ 10-registry-reload.ts minh hoạ thay tại chỗ, wrapper và gỡ.

Agent của từng conversation#

Mỗi hội thoại có thể chạy với cấu hình riêng: subagent dùng mô hình rẻ hơn và không có tool subagent; một chế độ lập kế hoạch chỉ cho đọc; một hội thoại review có instructions riêng. Cấu hình này phải sống qua khởi động lại và theo đúng fork. Pi Durable lưu nó trong document pi.agent, dưới dạng tên:

AgentState: những gì một hội thoại đã chọn, lưu theo tên
pi/packages/durable/src/harness/types.tsdòng 411–433TypeScript
/** Stored choices of one conversation; names, not objects. Unset fields follow the host. */export type AgentState = {	model?: ModelRef;	thinkingLevel?: ModelThinkingLevel;	/** An array selects exactly these extensions, in order. An object edits the host default selection. */	extensions?: string[] | { add?: string[]; remove?: string[] };	/** Filters the selected extensions' tools: the enabled tools. An array enables exactly these, in order. */	tools?: string[] | { remove: string[] };	/**	 * Filters the enabled tools the model may call (`callers` includes `model`): the tools offered to it. Never widens:	 * other tools can still call the ones it leaves out.	 * - Unset: every such tool except deferred ones.	 * - An array: exactly these, in order, deferred ones too.	 * - An object: every such tool except deferred ones and those in `remove`, in registry order, followed by the	 *   deferred tools in `add` that are not in `remove`, in `add` order. Names of tools that are not deferred or not	 *   registered have no effect in `add`.	 */	modelTools?: string[] | { remove?: string[]; add?: string[] };	/** Rendered after every extension section, as the section `instructions`. */	instructions?: string;	/** Directory within the environment's file system, passed to `HarnessOptions.env`. */	cwd?: string;};

Trường nào không đặt thì theo host (cài đặt chung của Harness). Thay đổi đi qua configure(), nhận các đối tượng extension và tool rồi lưu tên của chúng, nên không gõ nhầm tên được: một trường được truyền thì thay trọn trường đã lưu, null xoá nó về mặc định, undefined giữ nguyên (harness/types.ts:435–451).

configure(): sửa pi.agent trong bất kỳ commit nào
pi/packages/durable/src/harness/agent.tsdòng 87–91, 120–127TypeScript
/** Apply one change to `pi.agent`: a given field replaces the stored one, `null` clears it, `undefined` changes nothing. */export async function configure(tx: Tx, conversationId: ConversationId, change: AgentChange): Promise<void> {	const state = await tx.doc(AgentDoc, conversationId);	applyChange(state, change);}function applyChange(state: Draft<AgentState>, change: AgentChange): void {	const set = <K extends keyof AgentState>(key: K, value: AgentState[K] | null | undefined) => {		if (value === undefined) return;		if (value === null) delete state[key];		else state[key] = value as Draft<AgentState>[K];	};	set("model", change.model === undefined || change.model === null ? change.model : { ...change.model });	set("thinkingLevel", change.thinkingLevel);
Vì là một hàm trên tx, configure() chạy được trong cả commit tạo hội thoại (tham số agent của root(), createConversation(), fork()); Conversation.configure() chỉ là bản chạy trong commit riêng (harness.ts:118–120).

Kết quả giải là kiểu Agent:

Agent: lựa chọn đã giải với registry và cài đặt
pi/packages/durable/src/harness/types.tsdòng 453–468TypeScript
/** A conversation's agent resolved against a registry snapshot and the settings. */export type Agent<Tool extends ToolRegistration = ToolRegistration> = {	readonly model?: ModelRef;	readonly thinkingLevel: ModelThinkingLevel;	readonly extensions: readonly Extension<Tool>[];	/** The tools a request offers, in order: enabled, callable by the model, and selected by `modelTools`. */	readonly tools: readonly Tool[];	/** The tools nested calls (`executeTool()`) resolve among, in order: enabled and callable by tools. */	readonly callable: readonly Tool[];	/** Enabled deferred tools the model may call and is not offered yet, in registry order: what `tool_search` loads. */	readonly deferred: readonly Tool[];	/** Extension sections, then `instructions` when set. */	readonly sections: readonly PromptSection<Tool>[];	readonly instructions?: string;	readonly cwd?: string;};

conversation.agent() đọc pi.agent đã commit, lấy ảnh chụp registry hiện tại và cài đặt hiện tại, rồi gọi resolveAgent(); nó không bao giờ ghi:

HarnessImpl.resolveAgent(): document đã commit cộng registry cộng settings
pi/packages/durable/src/harness/harness.tsdòng 266–277TypeScript
/** Resolve a conversation's committed `pi.agent` against `snapshot`, or the current one, and the current settings. */async resolveAgent(	id: ConversationId,	snapshot: RegistrySnapshot<Tool> | undefined,	context: Context,): Promise<Agent<Tool>> {	const registry = snapshot ?? this.#options.registry.snapshot();	const state = await this.snapshot(AgentDoc, id, context);	return resolveAgent(state, registry, resolveSettings(this.#hostSettings()), (error, extension) =>		this.#report(error, { type: "extension_error", conversationId: id, extension }, context),	);}
resolveAgent(): chọn extension, ghép tool theo tên, lọc thành tools, callable, deferred
pi/packages/durable/src/harness/agent.tsdòng 229–236, 259–270TypeScript
const extensions = selectExtensions(state?.extensions, snapshot, settings);const composed = new Map<string, Tool>();for (const extension of extensions) for (const tool of extension.tools ?? []) composed.set(tool.name, tool);const sections = new Map<string, PromptSection<Tool>>();for (const extension of extensions) {	for (const section of extension.sections ?? []) sections.set(section.key, section);}const enabled = filterTools([...composed.values()], state?.tools);const callableBy = (caller: "model" | "tools") => (tool: Tool) =>	tool.callers === undefined || tool.callers.includes(caller);const modelCallable = enabled.filter(callableBy("model"));const tools = offeredTools(modelCallable, state?.modelTools);const offered = new Set(tools);const deferred = modelCallable.filter((tool) => tool.deferred === true && !offered.has(tool));const callable = enabled.filter(callableBy("tools"));const instructions = state?.instructions;const agentSections = [...sections.values()];if (instructions !== undefined) agentSections.push({ key: INSTRUCTIONS_KEY, render: () => instructions });
Giữa hai đoạn là vòng áp wrapper: wrapper ném lỗi hoặc đổi tên tool thì tool đó bị bỏ khỏi lần giải này và lỗi được báo qua onReport (agent.ts:336–353).

Thuật toán đầy đủ có ở spec.md:2827–2843. Bốn danh sách tool dễ lẫn:

Danh sáchGồmDùng cho
enabled (nội bộ)Tool của các extension được chọn (trùng tên thì cái sau thắng tại chỗ), qua wrapper, rồi qua bộ lọc toolsGiới hạn trên của mọi thứ khác
toolsEnabled, callers có model, qua bộ lọc modelTools; tool deferred chỉ có mặt khi modelTools nêu tênTool được mời trong request tới mô hình
callableEnabled, callers có toolsLời gọi lồng qua api.executeTool(), script của code mode
deferredTool deferred mô hình được gọi nhưng chưa được mờiThứ tool_search có thể nạp

modelTools chỉ thu hẹp, không bao giờ mở rộng tools (README.md:347): configure({ modelTools: [codemode] }) cho mô hình chỉ thấy codemode trong khi script của nó vẫn gọi được read và bash. Phần cách tool chạy thuộc chương task.

Ai giải agent, và khi nào, quyết định một thay đổi có hiệu lực lúc nào (spec.md:2881–2893). Bước chuẩn bị của generation giải một lần cho mỗi request, rồi cố định mô hình, tùy chọn stream và tool được mời trong checkpoint của request. Mỗi phase của task giải nhiều nhất một lần. Task tool giải tên tool khi chạy; nếu tool không còn trong tools của agent (bộ lọc, lựa chọn hay registry đã đổi), lời gọi nhận kết quả lỗi tool_unavailable và run tiếp tục. Một cạm bẫy từ chính thiết kế "lưu tên": { add, remove } trong extensions luôn sửa lựa chọn mặc định của host, không sửa một lựa chọn đã chép từ hội thoại chủ (spec.md:837–841). Ví dụ 07-configuration.ts chạy qua mọi trường; 27-plan-mode.ts và 28-reviewer.ts dựng chế độ lập kế hoạch và một hội thoại review từ đó.

System prompt: section và entry hệ thống theo vị trí#

Prompt cache của provider là một tiền tố token khớp chính xác; đổi system prompt hay danh sách tool ở đầu request là trượt cache toàn bộ (bài "Prompt Caching In Agents"). Pi Durable xử lý việc này bằng cách không có state prompt bền nào. Trước mỗi request, các section của extension được chọn render ra prompt mong muốn; transcript chỉ lưu entry pi.system ở đúng vị trí của chúng, mỗi entry là một bản vá (patch) theo kiểu SystemMessage của pi-ai, và chỉ phần thay đổi mới được append (spec.md:3658–3662). Khái niệm system message giữa hội thoại của pi-ai được bàn ở chương Pi 1.0.

PromptInput và PromptSection
pi/packages/durable/src/harness/types.tsdòng 342–361TypeScript
/** Input to system prompt section rendering for one request preparation. */export type PromptInput<Tool extends ToolRegistration = ToolRegistration> = {	readonly conversationId: ConversationId;	/** The request's resolution; `agent.tools` are the tools offered in this request. */	readonly agent: Agent<Tool>;	/** Built by `HarnessOptions.env` for this preparation; `undefined` without an environment. */	readonly env: ExecutionEnv | undefined;	/** Sections already in effect after replaying the active transcript. */	readonly shown: Readonly<Record<string, string>>;	/** Committed document reads. */	readonly read: DocumentReader;};/** One system prompt section; the agent's sections render in order before each request. */export type PromptSection<Tool extends ToolRegistration = ToolRegistration> = {	readonly key: string;	render(input: PromptInput<Tool>, context: Context): string | undefined | Promise<string | undefined>;	/** Default true: wrap the text as `<key>\n...\n</key>`. */	readonly tag?: boolean;};
Section là hàm thuần của đầu vào: agent đã giải (kể cả tool được mời), môi trường dựng cho request này, các section đang hiển thị, và một bộ đọc document đã commit. Trả về undefined thì section bị bỏ.
renderSections(): render theo thứ tự, bọc thẻ, lỗi thì giữ chữ cũ
pi/packages/durable/src/harness/prompt.tsdòng 21–48TypeScript
/** * Render the agent's sections in order. `undefined` omits a section; tagged text is wrapped as `<key>\n...\n</key>`. A * section that throws keeps its shown text, if any, and is reported; errors after `context` is aborted propagate. */export async function renderSections<Tool extends ToolRegistration>(	sections: readonly PromptSection<Tool>[],	input: PromptInput<Tool>,	shown: ReadonlyMap<string, string>,	report: (error: unknown, section: PromptSection<Tool>) => void,	context: Context,): Promise<Map<string, string>> {	const desired = new Map<string, string>();	for (const section of sections) {		let text: string | undefined;		try {			text = await section.render(input, context);		} catch (error) {			if (context.abortSignal?.aborted) throw error;			report(error, section);			const kept = shown.get(section.key);			if (kept !== undefined) desired.set(section.key, kept);			continue;		}		if (text === undefined) continue;		desired.set(section.key, section.tag === false ? text : `<${section.key}>\n${text}\n</${section.key}>`);	}	return desired;}
Một section ném lỗi giữ nguyên chữ đang hiển thị (nếu có) và được báo, request vẫn được gửi; lỗi sau khi invocation bị huỷ thì lan ra ngoài. Instructions của agent luôn là section cuối, khoá instructions (agent.ts:268–270).

Rồi planSystemEntries() so prompt mong muốn với những gì phát lại (replay) từ các system message đang có trong ngữ cảnh:

planSystemEntries(): baseline sau head, cặp xoá/thêm khi đổi thứ tự, hoặc bản vá tối thiểu
pi/packages/durable/src/harness/prompt.tsdòng 54–93TypeScript
/** * Plan the `pi.system` entries that make the replayed sections and tools of `view` equal `desired` and `tools` in values * and order. * * - A head marker with no later `pi.system` entry in context: one complete baseline that omits every retained earlier *   `pi.system` entry, written even when it restates the replayed values. * - Otherwise, when a minimal section patch would leave a different order: remove every shown section, then re-add *   every desired section in order. * - Otherwise the minimal patch of changed values and `null` removals, or nothing. * * Tool changes ride on the last planned entry, or on one entry of their own. */export function planSystemEntries(	view: ContextView,	desired: ReadonlyMap<string, string>,	tools: readonly Tool[],	timestamp: number,): SystemDraft[] {	const head = view.head;	if (head !== undefined && !view.entries.some((entry) => SystemEntry.is(entry) && entry.id > head.id)) {		const edits: ContextEdit[] = view.entries			.filter((entry) => SystemEntry.is(entry))			.map((entry) => ({ target: entry.id, action: "omit" }));		const baseline = systemEntry(			Object.fromEntries(desired),			{ toolsRemoved: [], toolsAdded: tools.map(toToolDeclaration) },			timestamp,		);		return [edits.length === 0 ? baseline : { ...baseline, edits }];	}	const sections = planSections(replaySections(view.messages), desired);	const changes = planTools(getCurrentTools(view.messages), tools);	if (changes.toolsRemoved.length === 0 && changes.toolsAdded.length === 0) {		return sections.map((patch) => systemEntry(patch, undefined, timestamp));	}	if (sections.length === 0) return [systemEntry(undefined, changes, timestamp)];	return sections.map((patch, index) =>		systemEntry(patch, index === sections.length - 1 ? changes : undefined, timestamp),	);}
Mọi entry đều có content: "": mọi phần của prompt, kể cả lời mở đầu, là một section có tên (prompt.ts:144–158; spec.md:140–141).

Ba nhánh, theo thứ tự ưu tiên:

  1. Sau một head marker mà chưa có pi.system nào sau nó (vừa reset hay compaction): ghi một baseline đầy đủ mọi section và mọi tool, kèm chỉnh sửa omit cho mọi pi.system cũ còn sót trong phạm vi. Lý do: một SystemMessage luôn là bản vá, không thể tự xoá các bản vá trước nó (spec.md:3755–3766).
  2. Thứ tự section phải đổi: hai entry trong một commit, entry đầu xoá mọi section bằng null, entry sau thêm lại theo thứ tự mong muốn; nếu chỉ khẳng định lại giá trị thì phát lại sẽ không ra đúng thứ tự (spec.md:3748–3753).
  3. Còn lại: bản vá tối thiểu gồm giá trị đổi và null cho section bị bỏ; không đổi gì thì không có entry nào.

Thay đổi tool đi kèm entry cuối cùng (hoặc một entry riêng): khai báo được so bằng declarationsEqual() của pi-ai và ghi bằng toToolDeclaration(), nên metadata riêng của ứng dụng gắn trên tool không bao giờ lọt vào transcript (spec.md:3771–3778). Một tool thôi resolve được sẽ xuất hiện trong toolsRemoved của delta kế tiếp, và quay lại khi nó resolve được lần nữa.

Vì input của run được commit trước khi generation render prompt, entry pi.system đầu tiên nằm sau entry pi.user đầu tiên trong transcript. Bước dẫn xuất ngữ cảnh đưa baseline đó lên đầu request, vì provider chỉ coi system message đứng đầu là prompt và bộ tool ban đầu (spec.md:284–302); hai bản sửa lỗi ở 1.1.0 và 1.3.0 là về đúng chuyện này (CHANGELOG.md:123, CHANGELOG.md:56). Ví dụ 15-system-prompt.ts in ra các entry pi.system khi section, instructions và wrapper thay đổi.

Harness.open, HarnessOptions và Settings#

Harness.open(): kiểm registry, dựng HarnessImpl, hoà giải task còn sống
pi/packages/durable/src/harness/harness.tsdòng 538–563TypeScript
export const Harness = {	/** Open a Harness over storage. The registry may keep changing while the Harness runs. */	async open<Tool extends ToolRegistration>(		storage: Storage,		options: HarnessOptions<Tool>,		context: Context,	): Promise<Harness> {		context.abortSignal?.throwIfAborted();		const snapshot = options.registry.snapshot();		const missing = BUILTIN_TASKS.filter((task) => snapshot.task(task.definition.name) === undefined);		if (missing.length > 0) {			const names = missing.map((task) => task.definition.name).join(", ");			throw new Error(`Registry lacks built-in tasks ${names}; create it with createRegistry()`);		}		const harness = new HarnessImpl(storage, options, context);		try {			await harness.openTasks(context);		} catch (error) {			// The caller's context may be what failed open: close without it, and rethrow the open error.			// A failing Storage close fails the Session, which reports it.			await harness.close(withoutAbortSignal(context)).catch(() => {});			throw error;		}		return harness;	},};
openTasks() đổi các task còn ở running (tiến trình trước chết giữa chừng) về pending và đếm số lần khởi động lại; nó không chạy task nào. Scheduler chỉ bật khi host gọi resume() hoặc một lời gọi xin tiến triển như submit(), wait(), waitForTask(); bên xem chỉ đọc không bao giờ bật nó (spec.md:744–755). Nhờ vậy host có thể cài extension, gieo dữ liệu, rồi mới cho chạy.

Hội thoại gốc được tạo lười. Lần gọi root() đầu tiên tạo hội thoại ID 1, chạy hook tạo hội thoại, áp agent rồi chạy init, tất cả trong một commit; các lần sau, kể cả sau khi mở lại, chỉ trả về nó và bỏ qua cả hai tham số (spec.md:736–741):

#create(): root, hội thoại mới và fork dùng chung một commit
pi/packages/durable/src/harness/harness.tsdòng 401–418TypeScript
async #create(target: CreateTarget, options: CreateOptions, context: Context): Promise<Conversation> {	this.assertUsable();	const id = await this.commitWith(async (tx) => {		if (target.kind === "root" && (await tx.conversation(ROOT_CONVERSATION_ID)) !== undefined) {			return ROOT_CONVERSATION_ID;		}		const record =			target.kind === "root"				? await tx.createRootConversation()				: target.kind === "fork"					? await tx.forkConversation(target.parentId, target.at, { ownership: target.ownership })					: await tx.createConversation({ ownership: target.ownership });		if (options.agent !== undefined) await configure(tx, record.id, options.agent);		if (options.init !== undefined) await options.init(tx, record.id);		return record.id;	}, context);	return new ConversationImpl(id, this.#host);}
Handle trả về là ConversationImpl, chỉ giữ id và tham chiếu tới Harness (harness.ts:105–112). Handle thuộc về Harness đang mở; sau khi mở lại phải lấy lại (spec.md:1059–1060).

HarnessOptions

HarnessOptions (1): telemetry, models, identity, registry, settings, env, conversationCreated
pi/packages/durable/src/harness/types.tsdòng 609–634TypeScript
export type HarnessOptions<Tool extends ToolRegistration = ToolRegistration> = {	/** Host-owned adapter for content-free admission/phase telemetry; propagation and metrics are optional. */	readonly telemetry?: TelemetryContext;	/**	 * pi-ai model access used by generation, compaction, hooks and tools: a `Models`, or a function returning the current	 * one, called at each use, so a host can replace its model configuration without reopening the Harness.	 */	readonly models: Models | (() => Models);	/**	 * How model requests identify the app to providers, merged field by field over `DEFAULT_HARNESS_IDENTITY`	 * (`{ attribution: PI_ATTRIBUTION }`, provider attribution to pi). It applies to every request through the Harness's	 * models, including hooks and tools, and overrides the identity the `Models` was created with; a request's own	 * `identity` overrides it. Pass `{ attribution: null }` to send no attribution headers.	 */	readonly identity?: ClientIdentity;	readonly registry: RegistryReader<Tool>;	/** Run settings, or a function returning the current ones; read at each use either way. */	readonly settings?: HarnessSettings | (() => HarnessSettings | undefined);	/** Builds a conversation's environment at each use. Never called on the Session line; may be async. */	readonly env?: (target: EnvTarget, context: Context) => ExecutionEnv | undefined | Promise<ExecutionEnv | undefined>;	/**	 * Runs in every commit that creates or forks a conversation, raw `tx.createConversation()` included, after the	 * built-in `pi.*` documents and before the conveniences apply `agent` and run `init`. A fork already has its copies.	 * Table reads throw `ReadAfterWrite`, as in `init`; a throw fails the creating commit.	 */	readonly conversationCreated?: (tx: Tx, conversation: ConversationRecord) => void | Promise<void>;
Phần còn lại là now, setTimer, onWake và onReport (harness/types.ts:635–663).
models, identity
Models của pi-ai (chương pi-ai), hoặc một hàm trả về nó, được gọi mỗi lần dùng. identity là một ClientIdentity được trộn lên DEFAULT_HARNESS_IDENTITY (ghi nhận nguồn là pi với OpenRouter, NVIDIA NIM, OpenCode) và áp cho mọi request đi qua Harness, kể cả từ hook và tool; { attribution: null } để không gửi gì (README.md:80; harness.ts:187–199).
telemetry
Một TelemetryContext của pi-telemetry do host sở hữu; bỏ trống thì không làm gì. Schema DURABLE_TELEMETRY_SCHEMA chỉ có ID và metadata điều khiển, không bao giờ có message, đối số hay kết quả (README.md:82–90). Chi tiết ở chương pi-env và telemetry.
env
Hàm dựng môi trường thực thi (ExecutionEnv) cho một hội thoại từ ID, cwd trong pi.agent và một bộ đọc document đã commit; không bao giờ được gọi trên dòng commit, được gọi lại mỗi lần dùng (harness.ts:279–285; spec.md:918–935). Môi trường Node và pi-env ở chương pi-env; cách tool dùng nó ở chương task.
conversationCreated
Chạy trong mọi commit tạo hoặc fork hội thoại, sau năm document dựng sẵn, để host tạo document của mình ở mọi hội thoại; đọc bảng ở đây ném ReadAfterWrite vì việc tạo hội thoại đã là một lần ghi bảng.
now, setTimer, onWake, onReport
Đồng hồ và bộ hẹn giờ của host (chạy được trên đồng hồ giả); onWake thay bộ hẹn giờ cho các task đang ngủ bằng báo thức của host, như alarm của Durable Object (mục Cloudflare ở chương storage); onReport nhận các lỗi không làm hỏng thao tác gọi, kèm ReportInfo cho biết chuyện gì xảy ra (types.ts:148–188).

Settings: đọc trực tiếp, không bao giờ lưu

Chính sách chạy dùng chung cho mọi hội thoại (lựa chọn extension mặc định, tùy chọn stream, thử lại, ngưỡng compaction, nhịp commit tiến độ, cách chạy tool, chế độ hàng đợi, thời gian giữ bộ nhớ, giới hạn khởi động lại) đi qua settings. Nó được đọc lại ở mỗi lần dùng và không bao giờ được chép hay lưu, nên một object có getter hoặc một hàm làm cho cài đặt "sống" theo file cài đặt của người dùng mà không cần commit nào (README.md:403–425). Settings phải đồng bộ, vì chế độ hàng đợi được đọc ngay trên dòng commit.

resolveSettings(): mỗi trường trên giá trị mặc định, object thì trộn
pi/packages/durable/src/harness/agent.tsdòng 64–85TypeScript
/** Resolve the host settings: every field over its built-in default, object fields merged. */export function resolveSettings(settings: HarnessSettings | undefined): Settings {	const extensions = settings?.extensions;	return {		...(extensions === undefined ? {} : { extensions }),		stream: { ...settings?.stream },		retry: { ...DEFAULT_RETRY_POLICY, ...settings?.retry },		compaction: { ...DEFAULT_COMPACTION_POLICY, ...settings?.compaction },		// Field by field, so an explicitly undefined interval keeps its default.		progress: {			partialIntervalMs: settings?.progress?.partialIntervalMs ?? DEFAULT_PROGRESS_POLICY.partialIntervalMs,			outputIntervalMs: settings?.progress?.outputIntervalMs ?? DEFAULT_PROGRESS_POLICY.outputIntervalMs,		},		toolExecution: settings?.toolExecution ?? "parallel",		steeringMode: settings?.steeringMode ?? "one-at-a-time",		followUpMode: settings?.followUpMode ?? "one-at-a-time",		contextRetentionMs: settings?.contextRetentionMs ?? DEFAULT_CONTEXT_RETENTION_MS,		documentRetentionMs: documentRetention(settings?.documentRetentionMs),		afterFailedRun: settings?.afterFailedRun ?? "keep",		restartLimit: restartLimit(settings?.restartLimit),	};}
Mặc định: thử lại 3 lần, nền 2 giây; compaction giữ 16.384 token dự trữ, giữ nguyên khoảng 20.000 token gần nhất, chạy nền từ 32.768 token trước ngưỡng chặn; commit tiến độ mỗi 100 ms; tool chạy song song; lấy từng mục một khỏi hàng đợi; giữ ngữ cảnh và document rảnh 10 phút; afterFailedRun: "keep"; restartLimit 5 (agent.ts:20–37).

Hàm currentSettings() biến cả hai dạng thành một hàm gọi ở mỗi lần dùng (harness.ts:201–204); riêng documentRetentionMs được đọc ở mỗi commit (harness.ts:220–221). Đổi cài đặt không tạo commit nào và không phát sự kiện nào; một UI hiển thị mô hình hay tool đã giải phải gọi lại conversation.agent().

Quan sát: viewState, watch, watchEvents#

Mọi thứ một UI cần đều là state đã commit (README.md:476). Câu trả lời đang stream nằm trong pi.live.generation.message, output của tool đang chạy nằm trong slot của nó, hàng đợi nằm trong pi.inbox. Không có "kênh sự kiện" riêng nào chạy song song với storage. Vì vậy một client vào muộn, hay kết nối lại, chỉ cần lấy state hiện tại rồi nhận các thay đổi sau đó; không có gì phải phát lại. Đổi lại, tiến độ chỉ hiện ra theo nhịp commit: câu trả lời dở và output tool được commit nhiều nhất mỗi 100 ms theo mặc định, nên crash mất tối đa khoảng đó (README.md:502).

Có ba cách quan sát một hội thoại, cùng dựa trên một "mount" (bản dựng view):

ConversationView: bản ghi hội thoại, entry đang hoạt động, năm document dựng sẵn
pi/packages/durable/src/harness/view.tsdòng 25–32TypeScript
/** Structural mount of one conversation's active transcript and built-in documents (spec §9.3). */export type ConversationView = {	readonly conversation: ConversationRecord;	/** Raw active entries, as `ContextView.entries`: the head marker, then the non-head entries from its head. */	readonly entries: readonly EntryRecord[];	/** Built-in conversation documents keyed by kind; absent documents are absent. */	readonly docs: Readonly<Record<string, JsonObject>>;};
Kind và trường của năm document này là giao thức công khai; document của bên thứ ba không được mount mà được quan sát qua documentState() hay watchDoc() riêng (spec.md:4785–4786).
  1. viewState()Một state Chord chỉ đọc, có thể huỷ (AttachedReplicatedState<ConversationView>), cập nhật sau mỗi commit chạm tới hội thoại. Hợp cho UI cục bộ: view.subscribe(render).
  2. watch()Cùng view đó, giao từng frame một: giá trị bất biến cộng đúng các phép toán Chord của commit đã tạo ra nó, callback bất đồng bộ, không chồng nhau. Hợp để gửi ops cho một client từ xa áp lại (README.md:489–498).
  3. watchEvents()Bộ chuyển đổi thử nghiệm dịch các commit thành sự kiện có hình dạng như sự kiện của coding agent (message_start, tool_execution_update, ...), bắt đầu bằng một sự kiện snapshot.

Harness giữ nhiều nhất một mount cho mỗi hội thoại. Observer đầu tiên dựng nó trên dòng commit từ transcript và document đã commit; observer cuối cùng rời đi thì nó bị bỏ (view.ts:142–166). Mỗi revision tiếp theo được dẫn xuất từ publication của commit, không đọc lại storage:

advance(): dịch một publication thành phép toán trên view
pi/packages/durable/src/harness/view.tsdòng 196–232TypeScript
const docOps: Op[] = [];const entryOps: Op[] = [];let entries = mount.value.entries;// Entry writes are published in ID order.for (const change of publication.changes) {	if (change.type === "entry" && change.value.conversationId === id) {		const entry = change.value;		const value = entry as unknown as JsonValue;		if (entry.head === undefined) {			entryOps.push(["p", ["entries"], entries.length, 0, [value]]);			entries = [...entries, entry];			continue;		}		// A head marker keeps the non-head entries from its head, which are always a suffix, and goes in front.		const target = entry.head;		let kept = entries.findIndex((candidate) => candidate.head === undefined && candidate.id >= target);		if (kept < 0) kept = entries.length;		entryOps.push(["p", ["entries"], 0, kept, [value]]);		entries = [entry, ...entries.slice(kept)];		continue;	}	if (change.type !== "document" || change.conversationId !== id) continue;	const kind = change.record.kind;	if (!MOUNTED_KINDS.has(kind) || change.record.key !== undefined) continue;	const path: NonEmptyPath = ["docs", kind];	const mounted = mount.docs.get(kind);	if (change.value === null) {		if (mounted?.id !== change.record.id) continue;		mount.docs.delete(kind);		docOps.push(["d", path]);	} else if (mounted?.id === change.record.id && mounted.version === change.version) {		for (const op of change.ops) docOps.push(prefixed(op, path));	} else {		mount.docs.set(kind, { id: change.record.id, version: change.version! });		docOps.push(["s", path, change.value]);	}}
Entry không có head là một phép splice ở cuối entries. Một head marker được đặt lên đầu và các entry trước head bị cắt. Phép toán của document được dời xuống dưới ["docs", kind]; document mới tạo hay đổi version thì được đặt cả giá trị. Spec cho ví dụ ["s", ["generation", "message"], v] trở thành ["s", ["docs", "pi.live", "generation", "message"], v] (spec.md:4796–4799). Về bảy loại phép toán của Chord, xem chương Chord.

Áp lực ngược: quy tắc 100 frame

Commit không bao giờ chờ người xem. Một watch chậm (hoặc chưa start()) giữ tối đa 100 frame chưa giao; frame thứ 101 thay toàn bộ phần chưa giao bằng một frame duy nhất chứa trọn view mới nhất dưới dạng phép thay gốc ["r", value]:

CommittedWatch.advance(): quá 100 frame thì gộp thành một phép thay gốc
pi/packages/durable/src/session/observation.tsdòng 226–237TypeScript
advance(value: T, ops: readonly Op[], context: Context): void {	if (this.#end !== undefined || this.#retired) return;	if (value === null) this.#retired = true;	if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) {		this.#pending.length = 0;		const replacement = this.#replace?.() ?? value;		this.#pending.push({ value: replacement, ops: [["r", replacement as JsonValue]], context });	} else {		this.#pending.push({ value, ops, context });	}	if (this.#started) this.#schedule();}
Giới hạn là hằng MAX_PENDING_WATCH_FRAMES = 100 (observation.ts:16). Không đo byte, không sao chép, không diff lại. Đây là quan sát hội tụ (convergent), không phải nhật ký kiểm toán: ai cần mọi chuyển trạng thái phải tự lưu chúng vào entry (spec.md:4742–4749).

watchEvents: sự kiện kiểu coding agent

AgentEvent: snapshot cộng các sự kiện áp lên trên nó
pi/packages/durable/src/harness/events.tsdòng 60–99TypeScript
/** Experimental agent event, shaped like the coding agent's session events (spec §9.4). */export type AgentEvent =	| SnapshotEvent	| { type: "run_start"; inputs: readonly SubmissionId[] }	| { type: "run_end"; inputs: readonly SubmissionId[] }	| { type: "turn_start" }	| { type: "turn_end" }	| { type: "message_start"; message: Message }	/** `usage` is the partial's current usage, as in the coding agent's JSON mode. */	| { type: "message_update"; usage: Usage; changes: readonly MessageChange[] }	| { type: "message_end"; entry: EntryRecord }	| ({ type: "tool_execution_start"; args: JsonObject } & ToolEventCall)	| ({			type: "tool_execution_update";			/** A front trim and then an append of the retained window, or its replacement. */			output?: { trimStart?: number; append?: string } | { set: string };			details?: JsonValue;			diagnostics?: readonly ToolDiagnostic[];	  } & ToolEventCall)	/**	 * A model-issued call ends with its result `entry`, a nested call with its `result`. Both are absent when the tool	 * task faulted or was orphaned, or when the call's slot left `pi.live` unfinished: its run ended, or its parent	 * settled first.	 */	| ({ type: "tool_execution_end"; entry?: EntryRecord; result?: NestedToolExecutionResult } & ToolEventCall)	| { type: "inbox_update"; items: readonly QueuedItem[] }	| { type: "submission"; record: SubmissionRecord }	| { type: "auto_retry_start"; attempt: number; at: number; errorMessage: string }	| { type: "auto_retry_end"; attempt: number }	| { type: "deferred_poll"; pollAt: number }	| { type: "entry_appended"; entry: EntryRecord }	| { type: "agent_changed"; agent: AgentState }	| { type: "usage_changed"; usage: UsageState }	| { type: "task_failed"; taskId: TaskId; kind: string; message: string }	| { type: "compaction_start"; taskId: TaskId; reason: CompactionReason; blocking: boolean }	/**	 * The task's receipt tells whether it produced a summary; the summary entry has its own events. `error` is set when	 * the compaction failed, faulted, or was orphaned, as in `pi.live.compactionFailure`.	 */	| { type: "compaction_end"; taskId: TaskId; reason: CompactionReason; error?: string };

Mỗi commit sinh một lô (batch) sự kiện, theo một thứ tự cố định: bắt đầu tool, message dở, cập nhật tool, thử lại; rồi entry theo thứ tự append (kết thúc của một tool đứng ngay trước message_start của kết quả của nó, như coding agent); rồi kết thúc compaction, lỗi task, turn_end, run_end; rồi submission; rồi inbox, agent, usage; cuối cùng compaction_start, run_start, turn_start (spec.md:4970–4983). message_update chỉ mang delta: phần text nối thêm, phần đối số tool nối thêm, nên partial được commit dày cũng không nhân dữ liệu trên đường truyền.

watchEvents(): bám vào cùng mount, dịch từng publication
pi/packages/durable/src/harness/events.tsdòng 171–199TypeScript
const views = conversationViews(harness);let watch!: CommittedWatch<readonly AgentEvent[]>;let snapshot!: SnapshotEvent;await views.attach(	conversationId,	async (initial, release, storage) => {		// Generations whose held outcome already ended their turn, read on the line with the snapshot.		const query = { conversationId, kind: "pi.generation", status: "completing" } as const;		const completing = await scanAll((cursor) => storage.scanTasks(query, 100, cursor, context));		const held = new Set<TaskId>(completing.map((record) => record.id));		let current = initial;		snapshot = snapshotOf(initial);		// Batches are the watch's values; an overflow delivers a snapshot of the newest view instead.		watch = new CommittedWatch<readonly AgentEvent[]>(			[],			release,			(error) => views.report(error),			() => [snapshotOf(current)],		);		return {			publication: (before, after, ops, publication, commitContext) => {				current = after;				const events = translate(conversationId, before, after, ops, publication, held);				if (events.length > 0) watch.advance(events, [], commitContext);			},			closeSession: (failure) => watch.closeSession(failure),		};	},	context,
Lô tràn cũng theo quy tắc 100: tham số thứ tư của CommittedWatch thay các lô chưa giao bằng một snapshot của state mới nhất. Spec gọi bộ chuyển đổi này là thử nghiệm, chỉ phủ một hội thoại (không phủ cây hội thoại nó sở hữu), và giao thức có thể đổi không báo trước (spec.md:4823–4828).

Ví dụ 21-late-join.ts gắn một view và một luồng sự kiện vào giữa một run; 19-json.ts in toàn bộ luồng của một run dưới dạng sự kiện (--events) hoặc phép toán thô (--ops), như chế độ JSON của coding agent (chương Các chế độ chạy). Để quan sát một document riêng lẻ, dùng harness.documentState() hoặc harness.watchDoc(); cơ chế bên dưới được mổ xẻ ở chương storage.

Lần theo một input được trả lời#

README tóm vòng đời của một input bằng năm dòng:

README: một input được trả lời, dưới dạng entry và task
pi/packages/durable/README.mddòng 106–113Markdown
One answered input, as entries and tasks:```textsubmit(input) → pi.user  pi.generation → pi.system (only if the prompt or tools changed), pi.assistant (tool calls)    pi.tool × n → pi.tool-result × n   (owned by the generation, which waits for them)  pi.generation → pi.assistant (answer) → submission done```

Phần này mở rộng năm dòng đó thành từng commit, với code thật ở mỗi bước. Kịch bản: hội thoại gốc đang rảnh, đã cấu hình mô hình, registry có CodingTools (bốn tool read, write, edit, bash, không có section, tools/index.ts:31–39) và một extension có section preamble như ví dụ Coding của README (README.md:232–236). Người dùng gửi "Đọc notes.txt rồi tóm tắt". Mô hình gọi read một lần, nhận nội dung file, rồi trả lời. Phần bên trong task (reservation, wait, thử lại) chỉ được lướt qua; chương task đi sâu vào chúng.

Ví dụ minh hoạ: ID giả định storage mới, root() vừa tạo hội thoại 1 và năm document 2–6
(pi.live = 2, pi.inbox = 3, pi.usage = 4, pi.provider = 5, pi.agent = 6).

 #   ai commit          ghi gì                                                  sự kiện watchEvents
 C1  Submissions        entry 7 pi.user · submission 8 placed                    message_start/end(7)
                        task 9 pi.generation pending {prepare, attempt 1}        submission(8)
                        pi.live.run = {taskId 9, inputs [8]}                     run_start([8]) turn_start
 C2  scheduler          task 9 pending → running                                 (không có)
 C3  generation 9       entry 10 pi.system (baseline: section + toolsAdded)      message_start/end(10)
     (prepare)          task 9 checkpoint {request, model, cutoff 10}
 C4  generation 9       pi.live.generation = {attempt 1}                         (không có)
     (request)          ... các commit partial, mỗi ~100 ms ...                  message_start, message_update...
 C5  generation 9       entry 11 pi.assistant (toolCall read) · pi.usage         message_end(11) usage_changed
                        task 12 pi.tool pending, chủ là task 9
                        pi.live.tools = [read: pending] · bỏ generation
                        task 9 waiting on [12] (allSettled)
 C6  scheduler          task 12 pending → running                                (không có)
 C7  tool 12 (call)     pi.live.tools[0] running · checkpoint {execute, args}     tool_execution_start
                        ... execute(), output dở được commit theo nhịp ...       tool_execution_update...
 C8  tool 12            entry 13 pi.tool-result · slot done, entry 13             tool_execution_end
                        task 12 terminal {kind model, entryId 13}                message_start/end(13)
 C9  scheduler          task 9 waiting → running (phase tools)                   (không có)
 C10 generation 9       bỏ pi.live.tools · task 14 pi.generation pending          turn_end turn_start
     (tools)            run.taskId 9 → 14 · task 9 terminal {entryId 11}
 C11 scheduler          task 14 pending → running                                (không có)
 C12 generation 14      prompt và tool không đổi: không có pi.system mới          (không có)
     (prepare)          task 14 checkpoint {request, cutoff 13}
 C13 generation 14      pi.live.generation = {attempt 1}, rồi các partial         message_start, message_update...
 C14 generation 14      entry 15 pi.assistant (câu trả lời) · pi.usage            message_end(15) turn_end
     (answer)           submission 8 done {entry 7, answer 15}                   run_end([8]) submission(8)
                        bỏ pi.live.run, generation · task 14 terminal            usage_changed
     → Submission.wait() trả về {status "done", entry 7, answer 15}
Mỗi dòng là một commit nguyên tử. Transcript cuối cùng: 7 pi.user, 10 pi.system, 11 pi.assistant (lời gọi tool), 13 pi.tool-result, 15 pi.assistant. Thứ tự sự kiện trong một lô theo spec.md:4970–4979; message_start của câu trả lời chỉ xuất hiện riêng khi đã có partial được commit trước đó.

C1: admission tạo input, run và generation đầu tiên

Đã xem ở mục Submission: hội thoại rảnh và inbox rỗng nên admitSubmission() append entry pi.user, tạo submission placed rồi gọi startRun(), vốn tạo task pi.generation thuộc hội thoại và đặt pi.live.run (generation.ts:752–769). Bốn bản ghi này lưu cùng nhau hoặc không lưu gì. Nếu tiến trình chết ngay sau C1, lần mở sau thấy một task pending và chạy nó; host gửi lại với cùng requestId thì nhận lại submission 8.

C2: scheduler giữ chỗ cho task

Scheduler theo dõi các commit, thấy task 9 có thể chạy, và trong một commit của chính nó đổi trạng thái sang running rồi mới gọi handler của phase (scheduler.ts:1540–1547). Trạng thái running được lưu là để lần mở sau biết task này đã bị cắt ngang và đếm số lần khởi động lại.

C3: prepare ghi system prompt theo vị trí

Phase prepare: render section, lập kế hoạch entry pi.system, chuyển sang request
pi/packages/durable/src/harness/generation.tsdòng 155–176, 190–207TypeScript
const view = await runtime.context(conversationId, context);const shown = replaySections(view.messages);let env: ExecutionEnv | undefined;try {	env = await runtime.env(context);} catch (error) {	if (context.abortSignal?.aborted) throw error;	runtime.report(error, { type: "host", callback: "env" });}const input: PromptInput = { conversationId, agent, env, shown: Object.fromEntries(shown), read: runtime };const desired = await renderSections(	agent.sections,	input,	shown,	(error, section) =>		runtime.report(error, {			type: "extension",			...optionalExtension(sectionExtension(agent, section.key)),		}),	context,);const entries = planSystemEntries(view, desired, agent.tools, runtime.now());await runtime.commit(async (tx) => {	let cutoff = (await tx.scanEntries({ conversationId }, 1)).items[0]?.id;	for (const entry of entries) cutoff = (await tx.appendEntry(SystemEntry, conversationId, entry)).id;	if (cutoff === undefined) throw new Error(`Conversation ${conversationId} has no entries to send`);	// Checked in this commit, so a compaction admitted during preparation counts.	if (threshold === "background" && (await tx.doc(LiveDoc, conversationId)).compactions === undefined) {		await createCompaction(tx, conversationId, { reason: "threshold" });	}	const request = {		attempt,		...(compacted === undefined ? {} : { compacted }),		model,		thinkingLevel,		streamOptions: settings.stream,		cutoff,	};	return { status: "running", checkpoint: { phase: "request", ...request } };}, context);
cutoff là entry mới nhất mà request sẽ thấy. Mô hình, mức suy nghĩ và tùy chọn stream được cố định trong checkpoint request, nên một lần gửi lại sau khôi phục dùng đúng chúng. Phần bị lược là kiểm tra ngưỡng compaction (generation.ts:177–189).

C4: request, stream và partial

Phase request commit pi.live.generation = { attempt }, lấy ngữ cảnh tại cutoff, chạy hook beforeRequest, rồi gọi mô hình với sessionId lấy từ pi.provider (generation.ts:209–236). Trong lúc stream, partial được commit vào pi.live.generation.message với nhiều nhất một commit đang bay (generation.ts:441–498). Đầu phase, convertPartial() biến một partial còn sót từ lần thử bị cắt ngang thành entry pi.assistant có stop reason aborted (generation.ts:430–439): đó chính là "phần trả lời dở được giữ và đánh dấu bị huỷ" trong bài công bố.

C5: câu trả lời gọi tool mở một vòng tool

startToolRound(): append câu trả lời, tạo task tool do generation sở hữu, chờ chúng
pi/packages/durable/src/harness/generation.tsdòng 648–674TypeScript
await runtime.commit(async (tx): Promise<Next> => {	const live = await tx.doc(LiveDoc, conversationId);	const entry = await appendAssistant(tx, conversationId, message);	const slots: ToolSlot[] = [];	const tools: TaskId<ToolTaskResult>[] = [];	const pending: string[] = [];	for (const call of calls) {		if (!offered.has(call.name)) {			const unavailable = harnessError("tool_unavailable", `Tool ${call.name} is not available`);			const result = await appendToolResult(tx, conversationId, call, unavailable, { timestamp: runtime.now() });			slots.push({ callId: call.id, name: call.name, status: "done", entry: result.id });			continue;		}		if (sequential && tools.length > 0) {			pending.push(call.id);			slots.push({ callId: call.id, name: call.name, status: "pending" });			continue;		}		const taskId = await createToolTask(tx, runtime, entry.id, call.id);		tools.push(taskId);		slots.push({ callId: call.id, name: call.name, taskId, status: "pending" });	}	delete live.generation;	live.tools = slots;	const checkpoint = { phase: "tools", assistant: entry.id, tools, pending } as const;	return { status: "waiting", checkpoint, on: tools, policy: "allSettled" };}, context);
Lời gọi tới một tool không có trong request nhận ngay kết quả tool_unavailable. Mọi lời gọi khác có một task pi.tool mà chủ là generation (ownership: { kind: "task" }, generation.ts:330–337). Generation không chạy code nào trong lúc waiting, và vẫn giữ pi.live.run.

C7 và C8: tool ghi ý định, chạy, rồi ghi kết quả

Commit ý định: slot running và checkpoint execute, rồi mới execute()
pi/packages/durable/src/harness/tool.tsdòng 168–178TypeScript
await runtime.commit(async (tx) => {	const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);	if (slot !== undefined) slot.status = "running";	// A nested slot shows the arguments the call runs with, which repair, hooks, and coercion may have changed.	// Compared with the input, which the slot got at admission: reading the draft would track every leaf.	const changed = task.input.kind === "nested" && !jsonEqual(task.input.call.arguments as JsonValue, final);	if (changed && slot !== undefined && "parentTaskId" in slot) slot.arguments = final;	const intent = { phase: "execute", arguments: final, replay: tool.replay ?? "unsafe" } as const;	return { status: "running", checkpoint: intent };}, context);await run(runtime, task.input, call, tool, final, context);
Ý định (đối số cuối cùng và chính sách replay) được lưu trước khi tool chạy. Nếu tiến trình chết sau commit này, lần mở sau vào phase execute: tool chạy lại chỉ khi cả chính sách đã lưu lẫn chính sách hiện tại là safe; nếu không, mô hình nhận kết quả lỗi interrupted kèm output đã commit (tool.ts:180–198).
settle(): entry pi.tool-result, slot done, task terminal trong một commit
pi/packages/durable/src/harness/tool.tsdòng 675–686, 696TypeScript
	const timing = { timestamp: runtime.now(), durationMs };	const entry = await appendToolResult(tx, conversationId, call, result, timing);	if (slot !== undefined && !("parentTaskId" in slot)) {		finishSlot(slot);		slot.entry = entry.id;	}	// Tools build control objects freely; drop keys set to undefined so the task result is strict JSON.	const control =		result.control === undefined || ending.status !== "completed"			? {}			: { control: copyJson(result.control as JsonValue, { omitUndefinedProperties: true }) as ToolControl };	settled = { kind: "model", entryId: entry.id, ...control };return { status: "terminal", outcome: { status: "completed", result: settled } };
appendToolResult() ghi entry có data.diagnostics và cộng usage của tool vào pi.usage trong cùng commit (tool.ts:885–913).

C10: hết vòng tool, giao run cho generation kế tiếp

finishToolRound(): boundary postTools rồi handOver sang generation mới
pi/packages/durable/src/harness/generation.tsdòng 708–713, 722–736TypeScript
await runtime.commit(async (tx): Promise<Next> => {	const boundary = await prepareBoundary(tx, conversationId, runtime.settings);	if (added.length > 0) await addTools(tx, conversationId, added);	const live = await tx.doc(LiveDoc, conversationId);	const now = runtime.now();	if (terminate || handoff !== undefined) {	} else {		const { users, reset } = await applyBoundary(tx, boundary, "postTools", now);		if (reset) {			// The queued reset cut the run's context before an answer.			endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "reset" });			if (users.length > 0) await startRun(tx, conversationId, live, users);		} else {			delete live.tools;			delete live.nestedTools;			if (live.run?.taskId === runtime.taskId) live.run.inputs.push(...users);			handOver(live, runtime.taskId, await createGeneration(tx, conversationId));		}	}	return { status: "terminal", outcome: { status: "completed", result: { entryId: assistant } } };}, context);
Boundary postTools là nơi tin nhắn steer đang chờ được đặt vào transcript và nhập vào run hiện tại (live.run.inputs.push). Generation 9 kết thúc completed; quyền điều khiển run chuyển sang generation 14 mà không có lúc nào hội thoại "rảnh" (generation.ts:771–774).

Generation 14 lặp lại prepare; lần này prompt và tool không đổi nên planSystemEntries() trả về mảng rỗng và không có entry pi.system mới: prompt cache của provider vẫn ấm.

C14: câu trả lời cuối chốt submission

answer(): append câu trả lời, boundary final, endRun, có thể mở run kế tiếp
pi/packages/durable/src/harness/generation.tsdòng 604–621TypeScript
await runtime.commit(async (tx): Promise<Next> => {	// Queue modes are read on the Session line, when the boundary is decided.	const boundary = await prepareBoundary(tx, conversationId, runtime.settings);	const live = await tx.doc(LiveDoc, conversationId);	const entry = await appendAssistant(tx, conversationId, message);	const result: Next = { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } };	const { users, reset } = await applyBoundary(tx, boundary, "final", runtime.now());	if (continuation !== undefined && users.length === 0 && !reset) {		const user = { role: "user", content: continuation, timestamp: runtime.now() } as const;		await tx.appendEntry(UserEntry, conversationId, { model: [user] });		handOver(live, runtime.taskId, await createGeneration(tx, conversationId));		delete live.generation;		return result;	}	endRun(tx, live, runtime.taskId, { status: "done", answer: entry.id });	if (users.length > 0) await startRun(tx, conversationId, live, users);	return result;}, context);
Boundary được chuẩn bị trước lần ghi bảng đầu tiên vì nó đọc head marker (quy tắc đọc trước ghi sau). Nếu một follow-up đang chờ, applyBoundary(..., "final") đặt nó và startRun() mở run kế tiếp ngay trong commit này.
endRun(): chốt đúng các input của run, rồi dọn presentation
pi/packages/durable/src/harness/live.tsdòng 134–146TypeScript
/** * End the run owned by `taskId`: settle each of its inputs and remove `run`. Always removes `generation`, `tools`, and * `nestedTools`, whose presentation belongs to the ending run. */export function endRun(tx: Tx, live: Draft<LiveState>, taskId: TaskId, settlement: SubmissionSettlement): void {	if (live.run?.taskId === taskId) {		for (const id of live.run.inputs) tx.settleSubmission(id, settlement);		delete live.run;	}	delete live.generation;	delete live.tools;	delete live.nestedTools;}

Commit C14 chứa bản ghi submission 8 ở trạng thái done. Bộ quan sát commit của Submissions thấy nó trong publication và giải phóng mọi người đang wait() (submissions.ts:145–150). Host đọc câu trả lời bằng tx.entry(AssistantEntry, settled.answer), như Quick Start (README.md:62–67). Ví dụ chạy được gần nhất với trace này là 17-coding-tools.ts: một lượt dùng tool trên JSONL, để lại thư mục storage để bạn mở ra xem.

API công khai và các ví dụ#

Gốc package (@earendil-works/pi-durable) xuất mọi thứ cần để dựng một harness; phần đầu của index.ts:

index.ts: định nghĩa document và entry, lỗi, agent, compaction, các hàm define
pi/packages/durable/src/index.tsdòng 1–26TypeScript
export { defineDoc, defineDocFamily } from "./documents.ts";export {	AssistantEntry,	CompactionEntry,	defineEntry,	ResetEntry,	SystemEntry,	ToolResultEntry,	UserEntry,} from "./entries.ts";export { ConversationBusy, ReadAfterWrite, SessionFailed, StorageRequestError } from "./errors.ts";export {	AgentDoc,	configure,	DEFAULT_COMPACTION_POLICY,	DEFAULT_PROGRESS_POLICY,	DEFAULT_RESTART_LIMIT,	DEFAULT_RETRY_POLICY,} from "./harness/agent.ts";export {	type CompactionCheckpoint,	type CompactionInput,	CompactionTask,	type SummaryRequest,} from "./harness/compaction.ts";export { defineExtension, defineTool, hook, section, wrapSection, wrapTool } from "./harness/define.ts";
NhómGiá trị xuất raỞ đâu
Mở và chạyHarness, DEFAULT_HARNESS_IDENTITY, createRegistry, createSession, MemoryStorage, ROOT_CONVERSATION_IDindex.ts:41, :53, :126–127, :216
Định nghĩadefineDoc, defineDocFamily, defineEntry, defineExtension, defineTool, hook, section, wrapTool, wrapSection, defineTaskindex.ts:26, :128
Entry dựng sẵnUserEntry, AssistantEntry, SystemEntry, ToolResultEntry, ResetEntry, CompactionEntryindex.ts:2–10
Document dựng sẵnAgentDoc, LiveDoc, InboxDoc, ProviderDoc, UsageDoc, NestedCallDocindex.ts:42–52, :124
Task dựng sẵn (để đăng ký hook)GenerationTask, ToolTask, CompactionTaskindex.ts:35–40, :57–64
Cấu hìnhconfigure, DEFAULT_RETRY_POLICY, DEFAULT_COMPACTION_POLICY, DEFAULT_PROGRESS_POLICY, DEFAULT_RESTART_LIMITindex.ts:12–19
Quan sát, telemetrywatchEvents (thử nghiệm), DURABLE_TELEMETRY_SCHEMAindex.ts:27–34, :56
LỗiConversationBusy, ReadAfterWrite, SessionFailed, StorageRequestErrorindex.ts:11
KiểuBề mặt Harness (Conversation, Submission, Agent, HarnessOptions, ...) và bề mặt Session/storage (Tx, Storage, EntryRecord, TaskRecord, ...)index.ts:65–123, :129–215

Phần còn lại tách thành các subpath để không kéo phụ thuộc không cần (package.json:8–111): /tools (read, write, edit, bash, CodingTools), /codemode, /tool-search, /env và /env/node, /images (và /images/node, /images/cloudflare), các backend /storage/memory, /storage/indexeddb, /storage/jsonl(/node), /storage/sqlite(/node, /cloudflare), và /testing. Gốc package tải TypeBox vì task tool kiểm tra đối số; README ước khoảng 23 MB RSS đỉnh khi không bundle, khoảng 4 MB khi bundle có tree-shaking (README.md:817).

Các ví dụ trong test/examples

Ba mươi hai ví dụ chạy được bằng node --conditions=source --experimental-strip-types test/examples/<file> từ thư mục package (README.md:839–843); đa số dùng provider giả (faux) của pi-ai, vài ví dụ dùng OpenAI khi có OPENAI_API_KEY. Mười bốn ví dụ đầu dạy từng tầng, từ dưới lên:

Ví dụDạy gì
00-conversationSession trần trên MemoryStorage; mọi lần ghi nằm trong commit(); hội thoại ownerless
01-documentsDocument rewindable; snapshot() và snapshotAsOf() tại từng entry
02-forksFork tại một entry: thấy entry của cha tới đó; document asOf được sao chép độc lập
03-owned-conversationsTask nền sở hữu một hội thoại con; task, hội thoại và document registry trong một commit
04-chord-statedocumentState(): state Chord chỉ đọc, không bao giờ tạo document
05-watcheswatchDoc(): revision gốc rồi frame tuần tự, quy tắc 100 frame
06-harnessHarness với registry; root() với agent và init; agent lưu tên, giải khi đọc
07-configurationconfigure(), settings có getter, metadata riêng trên tool, gỡ rồi cài lại extension
08-harness-conversationsHandle không giữ state, entry có kiểu, createConversation() và fork() với agent
09-contextDẫn xuất ngữ cảnh: head, edit, message bị loại, thứ tự kết quả tool, kết quả tổng hợp
10-registry-reloadThay extension tại chỗ, wrapper theo tên, gỡ
11-extension-stateExtension có document riêng: tool ghi, section hiển thị cho mô hình
12-tasksTask tự định nghĩa: lưu ý định, làm, lưu kết quả
13-recoveryĐóng giữa chừng trên SQLite, mở lại, task chạy tiếp; memo chống lặp hiệu ứng; sleep bền

Mười tám ví dụ sau (14 đến 31) là code kiểu sản phẩm, có bảng mô tả riêng trong README (README.md:845–864): một câu hỏi và câu trả lời (14), system prompt (15), mô hình thật (16), tool lập trình trên JSONL (17), chế độ in (18) và JSON (19) như pi -p và pi --mode json, inbox (20), vào muộn (21), subagent tiền cảnh (22) và subagent nền bền qua khởi động lại (23), task con (24), compaction (25), một coding agent nhỏ (26), plan mode (27), reviewer (28), sandbox theo hội thoại (29), thay tool cho một số hội thoại (30), và reload rồi khởi động lại (31).

Quyết định thiết kế#

Lưu trước, hiển thị sau, không có ngoại lệ

Bất biến số 3 của đặc tả: "All visible progress is durable. There is no volatile publication path" (spec.md:67), và "visible-undurable publication" đứng trong danh sách không làm (spec.md:5606–5618). Lý do là các mục tiêu nhiều người dùng và vào muộn: nếu UI được phép thấy thứ chưa lưu, một client kết nối lại sau crash có thể thấy "quá khứ bị viết lại". Khi mọi thứ hiển thị đều đã lưu, kết nối lại chỉ là lấy state hiện tại. Cái giá là tiến độ đi theo nhịp commit, và mỗi token stream ra đều thành một lần ghi; Pi Durable trả giá đó bằng throttle 100 ms, delta của Chord và chính sách checkpoint của pi.live để chuỗi delta luôn ngắn.

Mọi thứ harness chạy là một task

Bài công bố viết: "Everything the harness runs, from calling the model to executing a tool, is a task." Vòng lặp của pi-agent-core (chương agent-core) là một hàm async giữ state trong bộ nhớ: chết là mất. Ở đây mỗi bước là một phase của một máy trạng thái, và state cần để làm tiếp nằm trong checkpoint đã commit. Generation sở hữu các task tool của vòng nó; hội thoại sở hữu generation; huỷ chạy từ dưới lên. Thiết kế này cho khôi phục từ checkpoint cuối, nhưng buộc mọi hiệu ứng ngoài phải nghĩ về chạy lại: tool phải khai báo replay, task phải "lưu ý định, làm, lưu kết quả" (12-tasks.ts:10–14). Tên mã cũ của harness, Pico, vẫn còn trong vài chú thích (sqlite/storage.ts:151).

Lưu tên, không lưu code

pi.agent chỉ chứa tên extension và tên tool; registry nói tiến trình này chạy được gì lúc này (spec.md:843–848). Nhờ vậy reload không cần migration và một hội thoại cũ mở được trong một tiến trình thiếu vài extension: tên chưa cài bị bỏ qua khi giải, và có hiệu lực lại khi được cài. Cái giá: không có gì báo lỗi khi một extension được chọn mà không có mặt, nên một extension bảo vệ (permission hook) vắng mặt nghĩa là chạy không có bảo vệ.

Prompt là bản vá theo vị trí

Thay vì dựng lại system prompt mỗi request, transcript giữ đúng những gì mô hình đã thấy, ở đúng vị trí, và chỉ append phần khác. Lựa chọn này phục vụ prompt cache và tính di chuyển giữa provider: chuỗi section đã render là bản ghi có thẩm quyền, kể cả khi renderer sau này thay đổi (spec.md:3780–3784). Cái giá: section phải tất định, và đổi thứ tự tốn hai entry.

Checkpoint là chính sách của định nghĩa, không phải heuristic của backend

Đặc tả cấm storage tự đếm byte hay tự chèn checkpoint, và coi một định nghĩa không bao giờ checkpoint là lỗi của định nghĩa (spec.md:1569–1576); "automatic checkpoint heuristic" nằm trong danh sách không làm (spec.md:5612). Người định nghĩa document biết nó thay đổi thế nào: pi.live chọn "khi rảnh", pi.inbox chọn "khi rỗng". Bản 1.4.0 bỏ giá trị mặc định để buộc mỗi định nghĩa trả lời câu hỏi đó. Cơ chế base và delta được mổ xẻ ở chương storage.

Sự kiện là bộ chuyển đổi, không phải nguồn sự thật

Không có nhật ký sự kiện ngữ nghĩa trong lõi Session (spec.md:5610). watchEvents() dẫn xuất sự kiện từ commit và có thể bỏ qua chuyển trạng thái trung gian khi tràn; kết quả chính xác của một input luôn đến từ Submission.wait(). Một TUI vẽ từ ConversationView, sự kiện chỉ dùng cho hiệu ứng chuyển cảnh (spec.md:4986–4987).

Một tiến trình sở hữu một storage

Không có khoá liên tiến trình (README.md:790), và trộn CRDT hay nhiều người ghi ngoại tuyến không nằm trong phạm vi. "Nhiều người cùng điều khiển một agent" nghĩa là nhiều client nói chuyện với một tiến trình sở hữu storage, như một Durable Object cho mỗi session trong bài "Pi Durable on Cloudflare".

Cạm bẫy và trường hợp biên#

  • Append entry thô vào hội thoại đang bận. Chỉ Harness được ghi ngữ cảnh mô hình vào một hội thoại có run; một entry chen vào lúc generation đang chuẩn bị có thể làm lệch entry pi.system của nó. Dùng write submission (spec.md:5470–5473).
  • Input gửi giữa run mặc định là follow-up. Nó chờ tới khi run trả lời mới được đặt. Muốn chen vào vòng tool hiện tại thì whenBusy: "steer".
  • Write chỉ để hiển thị vượt lên trước. Nó được append ngay, nên có thể đứng trước các mục gửi sớm hơn còn đang chờ, trừ khi có reset đang chờ (README.md:517).
  • Hàng đợi sau một run thất bại. Mặc định các follow-up nằm lại trong pi.inbox, và wait() của chúng không trả về, cho tới khi có input mới hoặc write xếp hàng chạy boundary (spec.md:5474–5480).
  • Huỷ wait() không rút submission. Rút là submission.abort(), và chỉ khi còn queued.
  • root() chỉ áp agent và init một lần. Lần gọi sau, kể cả sau khi đổi code, bỏ qua chúng; muốn đổi thì configure().
  • Lựa chọn và bản sao. { add, remove } luôn sửa lựa chọn mặc định của host; ghi nó vào một subagent đã chép lựa chọn từ chủ sẽ thay lựa chọn đã chép. Bản sao pi.agent của hội thoại do task sở hữu là bản sao một lần (spec.md:5571–5581).
  • Watch hội tụ, không kiểm toán. Quá 100 frame thì các trạng thái trung gian bị gộp. Cần mọi chuyển trạng thái thì ghi chúng thành entry (spec.md:5551–5555).
  • Handle không sống qua lần mở lại. Conversation, Submission thuộc Harness đã trả chúng; sau khi mở lại, lấy lại bằng harness.conversation(id) và harness.submission(id).
  • Đặt tên với tiền tố pi. Không bị chặn (trừ kind của input), nhưng va chạm với hành vi dựng sẵn, ví dụ một entry tự đặt kind pi.system sẽ bị phát lại như system message.

Đọc code ở đâu#

Mọi đường dẫn tính từ packages/durable/. Thứ tự dưới đây đi từ khái niệm tới hiện thực.

#FileĐọc để hiểu
1README.mdQuick Start, Concepts, rồi các mục Busy Conversations, Watching, Errors
2docs/spec.md §1–2Bất biến, bản ghi lõi, bề mặt công khai của Harness (đặc tả là chuẩn, code thắng khi lệch)
3src/types.tsMọi bản ghi: ID, hội thoại, entry, submission, task, document, Tx, Session, Storage
4src/entries.ts, src/documents.tsToken entry và document
5src/harness/types.tsBề mặt Harness: Conversation, Submission, Extension, AgentState, HarnessOptions, hook
6src/harness/harness.tsHarness.open, #create, hook tạo hội thoại, handle
7submissions.ts, inbox.tsAdmission, boundary, wait, abort
8live.ts, provider.ts, usage.tsDocument dựng sẵn và hàm tiện ích của chúng (endRun, recordUsage)
9registry.ts, define.ts, agent.tsRegistry, giải agent, settings
10prompt.ts, context.tsEntry pi.system theo vị trí, dẫn xuất ngữ cảnh
11view.ts, events.tsMount view và bộ chuyển đổi sự kiện
12generation.ts, tool.tsChỉ các commit trong trace ở trên; phần còn lại ở chương task
13src/index.ts, package.jsonBề mặt xuất ra và các subpath
14test/examples/00 tới 13 theo thứ tự, rồi 14, 17, 20, 21

Tóm tắt#

  • Pi Durable là một harness agent bền, thử nghiệm, phát hành cùng Pi 1.0; nó không dùng AgentSession hay pi-agent-core, và không thay thế coding agent.
  • Quy tắc lõi: entry bất biến, bản ghi task và document Chord được commit nguyên tử; chỉ state đã commit là quan sát được.
  • Hội thoại là transcript entry bất biến; parent cho fork, owner cho subagent; head marker định nghĩa ngữ cảnh đang hoạt động.
  • Document là JSON có kiểu với phạm vi, lịch sử, chính sách fork và chính sách checkpoint tường minh; năm document dựng sẵn mang agent, danh tính provider, state đang chạy, inbox và chi phí.
  • submit() nhận input hoặc write bền rồi trả về; requestId cho exactly-once; bận thì xếp hàng thành steer hoặc follow-up, đặt vào ở boundary.
  • Code nằm trong extension của registry tiến trình; hội thoại lưu tên, agent được giải lại mỗi lần dùng; system prompt là các entry pi.system theo vị trí, chỉ gửi phần thay đổi.
  • UI quan sát bằng viewState(), watch() hoặc watchEvents(), tất cả dẫn xuất từ commit, hội tụ, giới hạn 100 frame.

18Durable: session, commit, document và storage#

Chương trước dựng mô hình khái niệm của Pi Durable từ phía người dùng thư viện: hội thoại, entry, document, submission, agent. Chương này đi xuống một tầng, tới phần bảo đảm lời hứa "lưu trước, hiển thị sau": Session với dòng commit duy nhất, transaction và cách nó gom một commit, document bên trong (tracker, base, delta, lịch sử), fork, quan sát, giao diện Storage và năm backend, các quy tắc lỗi, cách chạy trên Cloudflare Durable Object, và cuối cùng là những dòng cụ thể một commit ghi xuống đĩa. Task và scheduler nằm ở chương task; delta và state nhân bản của Chord nằm ở chương Chord.

Vai trò: tầng nằm dưới Harness#

Harness là một SessionImpl có thêm scheduler, submission và view (harness.ts:207). Session cũng dùng được một mình qua createSession(storage, options), như các ví dụ 00 đến 05: không có task chạy, không có generation, chỉ có bản ghi, document và commit (session.ts:71–87).

SessionImpl: một dòng commit, cache tracker document, publication đã commit
pi/packages/durable/src/session/session.tsdòng 105–121TypeScript
/** * Session kernel: one mutation line, the loaded document tracker cache, and committed publication. * * The cache is bounded by use: a document with no watch or document state attached leaves it once it was not read or * written for the retention period, and the next access loads it from Storage. Eviction runs as a job on the mutation * line, so no transaction ever holds a tracker that leaves. * * Only committed state is observable. Every commit callback, preparation, Storage settlement, adoption, and * publication enqueue runs while the line is held; listeners run later. * * Storage failures are final. Every Storage call goes through one guard: the first error a Storage method throws, other * than for a cancelled caller, fails the Session, as does a commit it cannot adopt. That call gets the error; every later * call and every Storage call still underway gets `SessionFailed`; close listeners run at once with the error, and the * Session then closes itself, Storage included, and `closed` settles. Nothing retries: transient errors are the * Storage's to retry. Reopening recovers from what was committed. */export class SessionImpl implements Session {

Bên dưới Session là Storage, ranh giới lưu trữ. Hợp đồng chia việc rõ ràng: Session chịu trách nhiệm về ý nghĩa (bản ghi hợp lệ, tổ tiên đúng, chuyển trạng thái hợp lệ) và tuần tự hoá các commit; Storage chịu trách nhiệm về tính nguyên tử, quyền sở hữu ID toàn cục, tính bất biến của hội thoại và entry, sự nhất quán của bản ghi document, và trả về giá trị đã tách (detached) khỏi bộ nhớ của nó (types.ts:1166–1178). Vì Session đã giữ một dòng commit, backend không cần thêm mutex phía người gọi (spec.md:5231–5233).

Mô hình tư duy: đường đi của một commit#

session.commit(callback)           hoặc conversation.commit, runtime.commit, api.commit
  └─ #enqueue ── dòng commit: một promise chain, mỗi lúc một việc
       └─ #runCommit
            1  new Transaction(host, context, scope)
            2  await callback(tx)        tx.appendEntry · tx.doc · tx.createTask · tx.settleSubmission ...
               │                         đọc bảng trước, ghi bảng sau; draft document là của riêng tx
               ├─ ném lỗi ──────────────▶ settleFailure(): huỷ mọi draft, không ghi gì
            3  tx.settleSuccess()        niêm phong; prepare() mọi draft → giá trị mới + Op[]
               │                         #assemble(): kiểm tra, rồi checkpointWhen → base hay delta
               └─ không có gì để ghi ───▶ discard(), xong
            4  storage.commit(writes)    một lô nguyên tử → Seq
               └─ ném lỗi ──────────────▶ Session thất bại (guard), không công bố gì
            5  tx.adopt(seq)             đổi con trỏ tracker sang giá trị đã chuẩn bị
            6  #publish(seq, ...)        listener nội bộ (scheduler, submission, view) rồi listener của host,
                                         chạy đồng bộ chỉ để chụp giá trị bất biến
            7  thả dòng; callback của watch và document state chạy sau, ngoài dòng
Bảy bước của một commit. Không có thứ gì được công bố trước bước 4 thành công; listener chỉ chụp giá trị ở bước 6, còn code của người dùng (watch, subscriber) chạy sau khi dòng đã được thả.

Ba từ vựng của chương: dòng commit (mutation line) là hàng đợi tuần tự mà mọi commit và mọi lần đọc nhiều bước đều đi qua; publication là tập thay đổi bất biến của một commit thành công (CommitPublication, types.ts:900–904); incarnation là một kiếp sống của một document, từ lúc tạo tới lúc nghỉ hưu, có ID riêng không bao giờ dùng lại.

Session: một dòng commit nguyên tử#

"Một dòng commit" nghe như một khái niệm lớn, nhưng hiện thực chỉ là một chuỗi promise:

#enqueue(): mỗi việc chạy sau việc trước, lỗi không làm đứt chuỗi
pi/packages/durable/src/session/session.tsdòng 896–903TypeScript
#enqueue<T>(job: () => Promise<T>): Promise<T> {	const run = this.#tail.then(job);	this.#tail = run.then(		() => undefined,		() => undefined,	);	return run;}
Mọi commit, mọi lần dọn cache, và mọi lần đọc cần nhìn một trạng thái đã commit nhất quán đều xếp vào chuỗi này. Nhờ vậy giữa hai bước của một việc không có commit nào chen vào.
commitWith() và readOnLine(): hai cách vào dòng
pi/packages/durable/src/session/session.tsdòng 237–257TypeScript
commitWith<T>(change: (tx: Transaction) => T | Promise<T>, context: Context, scope?: TransactionScope): Promise<T> {	try {		this.assertUsable();	} catch (error) {		return Promise.reject(error);	}	return this.#enqueue(() => this.#runCommit(change, context, scope));}/** Internal: run a read-only job on the mutation line so multi-read derivations observe one committed state. */readOnLine<T>(job: () => Promise<T>): Promise<T> {	try {		this.assertUsable();	} catch (error) {		return Promise.reject(error);	}	return this.#enqueue(async () => {		this.#assertHealthy();		return job();	});}
commitWith() là bản nội bộ của commit(), nhận thêm scope (hội thoại mặc định cho createTask() và task được đóng dấu byTaskId). readOnLine() dùng khi một lần đọc gồm nhiều truy vấn, như dựng mount view hay chụp biên ngữ cảnh, để chúng thấy cùng một trạng thái.

Toàn bộ một commit nằm trong #runCommit(). Nửa đầu chạy callback và gom lô ghi:

#runCommit() (1): callback, niêm phong, lô rỗng thì không chạm storage
pi/packages/durable/src/session/session.tsdòng 716–746TypeScript
async #runCommit<T>(	change: (tx: Transaction) => T | Promise<T>,	context: Context,	scope?: TransactionScope,): Promise<T> {	this.#assertHealthy();	context.abortSignal?.throwIfAborted();	const tx = new Transaction(this.#host, context, scope);	let result: T;	try {		result = await change(tx);		// A callback that caught a failed read must not commit, nor succeed as if it had.		this.#assertHealthy();	} catch (error) {		await tx.settleFailure();		this.#sweepDocuments();		throw error;	}	let writes: readonly StorageWrite[];	try {		writes = await tx.settleSuccess();	} catch (error) {		// Settlement aborted every change already.		this.#sweepDocuments();		throw error;	}	if (writes.length === 0) {		tx.discard();		this.#sweepDocuments();		return result;	}
context.abortSignal?.throwIfAborted() chỉ được kiểm tra trước khi bắt đầu; đây là điểm nhận (admission) của commit. Sau đó kiểm tra sức khoẻ lần nữa: một callback đã bắt và nuốt lỗi đọc storage không được commit như thể không có gì. Commit không có gì để ghi thì không tới storage, không tốn số thứ tự Seq.

Nửa sau ghi lô, nhận nó vào bộ nhớ, rồi công bố:

#runCommit() (2): storage.commit, adopt, publish
pi/packages/durable/src/session/session.tsdòng 747–768TypeScript
	let seq: Seq;	try {		// Once admitted, caller cancellation does not interrupt Storage settlement.		seq = await this.#storage.commit(writes, withoutAbortSignal(context));	} catch (error) {		// The guard has failed the Session.		tx.discard();		throw error;	}	let documents: DocumentCommitChange[];	try {		documents = tx.adopt(seq);	} catch (error) {		// Storage already committed; a failed adoption leaves memory behind durable state.		this.fail(error);		throw error;	}	for (const document of documents) this.#touchDocument(addressId(document.record));	this.#publish(seq, writes, documents, context);	this.#sweepDocuments();	return result;}
Ba điểm quan trọng. Lô được gửi với withoutAbortSignal(context): một khi đã nhận, huỷ phía người gọi không cắt ngang việc ghi. Nếu storage ném lỗi, lớp chặn lỗi đã làm Session thất bại và không có gì được công bố. Nếu storage đã ghi mà việc nhận vào bộ nhớ (adopt) thất bại, bộ nhớ đã tụt sau storage, nên Session cũng thất bại.
#publish(): listener nội bộ trước, listener của host sau
pi/packages/durable/src/session/session.tsdòng 802–818TypeScript
	if (changes === undefined) return;	for (const document of documents) changes.push(document);	const publication: CommitPublication = { seq, changes };	// The commit is durable: a failing listener neither fails the commit nor skips the others.	for (const listener of [...this.#internalListeners]) {		contained(			() => listener(publication, context),			(error) => this.fail(error, context),		);	}	for (const listener of [...this.#commitListeners]) {		contained(			() => listener(publication, context),			(error) => this.report(error, LISTENER, context),		);	}}
Listener nội bộ (scheduler, submission, view, task graph, observer document) giữ bộ nhớ đồng bộ với storage, nên một listener nội bộ ném lỗi làm Session thất bại; listener của host ném lỗi chỉ bị báo qua onReport. Commit đã bền thì vẫn đứng (spec.md:5330–5333). Ngay trước đó, publication phát sự kiện telemetry pi.durable.entry và pi.durable.submission (session.ts:776–801).

Đây là chỗ hiện thực bất biến "chỉ state đã commit là quan sát được": view, watch, waiter của submission và scheduler đều tiến lên từ publication, và publication chỉ tồn tại sau khi storage.commit() trả về. Listener phải chạy đồng bộ và không được gọi API của Session; callback của watch và document state được lên lịch chạy sau (microtask), ngoài dòng (spec.md:1697–1711).

Đóng Session cũng đi qua dòng (session.ts:495–529): niêm phong việc nhận commit mới, chạy listener đóng, gọi beforeClose() (Harness dùng nó để báo hiệu và chờ mọi invocation của task, harness.ts:396–399), rồi một việc trên dòng xoá cache và đóng storage. Các commit đã nhận trước khi niêm phong vẫn hoàn tất. closed trả về { reason: "closed" } hoặc { reason: "failed", error }.

Transaction: một commit chứa gì#

Thứ một commit gửi xuống storage là một mảng StorageWrite:

StorageWrite: bốn loại bản ghi bảng và bốn lệnh document
pi/packages/durable/src/types.tsdòng 851–868TypeScript
/** One record or document mutation in an atomic storage commit. */export type StorageWrite =	| { readonly type: "conversation"; readonly value: ConversationRecord }	| { readonly type: "entry"; readonly value: EntryRecord }	| { readonly type: "task"; readonly value: TaskRecord<JsonValue, JsonValue, JsonValue> }	| { readonly type: "submission"; readonly value: SubmissionRecord }	| {			readonly type: "document.create";			readonly record: DocumentCreate;			readonly content: Extract<DocumentContent, { readonly kind: "base" }>;	  }	| { readonly type: "document.copy"; readonly record: DocumentCreate; readonly source: DocumentCopySource }	| {			readonly type: "document.change";			readonly id: DocumentId;			readonly content: DocumentContent;	  }	| { readonly type: "document.retire"; readonly id: DocumentId };
Hội thoại và entry là bản ghi tạo mới, bất biến. Task và submission là bản ghi thay thế trọn: mỗi lần đổi trạng thái là ghi lại toàn bộ bản ghi. Document có bốn lệnh: tạo (luôn kèm một base), sao chép từ nguồn (cho fork), đổi (một base hoặc một delta), nghỉ hưu.

Transaction gom các lệnh đó trong lúc callback chạy. Trạng thái của nó cho thấy cách gom:

Transaction: lô ghi, task ứng viên, submission, kế hoạch document
pi/packages/durable/src/session/transaction.tsdòng 207–224TypeScript
/** Atomic batch; conversation and entry writes stage eagerly, while task and document writes assemble later. */readonly #writes: StorageWrite[] = [];readonly #createdConversationIds = new Set<ConversationId>();readonly #forkSourceConversationIds = new Set<ConversationId>();readonly #forkSourceDocumentIds = new Set<DocumentId>();/** One entry per task touched by a public read, candidate write, or document-owner lookup. */readonly #tasksById = new Map<TaskId, TransactionTask>();/** Submissions created by this transaction, by ID. */readonly #submissions = new Map<SubmissionId, SubmissionRecord>();/** Submission settlements and placements in staging order; resolved against the latest candidate record during assembly. */readonly #submissionChanges: { readonly id: SubmissionId; readonly change: SubmissionChange }[] = [];/** Write and publication plans of every staged incarnation, built during assembly. */readonly #plans: DocumentPlan[] = [];/** Every document acquisition or retirement marker in staging order. */readonly #documents: DocumentEntry[] = [];/** Latest transaction-local incarnation or retirement marker at each logical address. */readonly #latestDocumentByAddress = new Map<string, DocumentEntry>();
Hội thoại và entry được đẩy vào #writes ngay khi gọi, theo đúng thứ tự gọi. Task và submission nằm trong map theo ID: ghi nhiều lần trong một commit thì chỉ bản ghi ứng viên cuối cùng được gửi. Document được lên kế hoạch lúc niêm phong.

Đọc bảng trước, ghi bảng sau

#read() và #write(): lần ghi bảng đầu tiên khoá mọi lần đọc bảng sau đó
pi/packages/durable/src/session/transaction.tsdòng 950–968TypeScript
#read<T>(method: string, read: () => Promise<T>): Promise<T> {	try {		this.#assertOpen();		if (this.#hasTableWrite) throw new ReadAfterWrite(method);		return this.#track(read());	} catch (error) {		return Promise.reject(error);	}}#write<T>(write: () => Promise<T>): Promise<T> {	try {		this.#assertOpen();		this.#hasTableWrite = true;		return this.#track(write());	} catch (error) {		return Promise.reject(error);	}}

Vì sao có quy tắc này? Mọi lần đọc bảng trong transaction đi thẳng tới storage, tức trạng thái đã commit; các lần ghi trong transaction chưa nằm ở đó. Nếu cho đọc sau khi ghi, code sẽ dễ tưởng mình đọc được thứ mình vừa ghi. Thay vào đó, các hàm tạo trả về luôn bản ghi vừa tạo, và lần đọc sau lần ghi đầu tiên ném ReadAfterWrite (spec.md:1728–1734). Document không bị quy tắc này: draft của chính transaction là đọc-thấy-ghi (read-your-writes). Một số tra cứu nội bộ cũng không bị tính, như kiểm tra task chủ dựa trên bản ghi ứng viên mới nhất (transaction.ts:1003–1006), và settleSubmission(), placeSubmission(), vốn chỉ ghi lại một thay đổi để giải quyết lúc gom lô (transaction.ts:466–481). Đó là lý do prepareBoundary() phải được gọi đầu commit: nó đọc head marker, một lần đọc bảng.

Cấp ID và đóng dấu entry

#appendEntry(): kiểm hội thoại, cấp ID, giải head self, đóng dấu byTaskId
pi/packages/durable/src/session/transaction.tsdòng 365–386TypeScript
#appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord> {	return this.#write(async () => {		await this.#requireConversation(conversationId);		this.#assertOpen();		const id = await this.#host.storage.mintId<EntryId>();		this.#assertOpen();		const { head, ...rest } = value;		// Undefined `head` and `byTaskId` are omitted by the copy.		const record = copyJson(			{				...rest,				id,				conversationId,				head: head === "self" ? id : head,				byTaskId: this.#scope.taskId,			},			TABLE_JSON_COPY_OPTIONS,		) as unknown as EntryRecord;		this.#writes.push({ type: "entry", value: record });		return record;	});}
copyJson(..., { omitUndefinedProperties: true }) vừa tách bản ghi khỏi đối tượng của người gọi vừa bỏ trường undefined, để bản ghi là JSON nghiêm ngặt. Bản ghi trả về là giá trị bất biến dùng chung với listener của commit.

ID đến từ storage.mintId(), gọi bất đồng bộ vì một storage từ xa có thể cấp ID bền (spec.md:1359–1361). Các backend làm việc này khác nhau. MemoryStorage và SqliteStorage dùng một bộ đếm trong bộ nhớ (memory.ts:427–431, sqlite/storage.ts:212–216); SQLite ghi next_id xuống cùng mỗi commit. IndexedDbStorage ghi mỗi lần cấp trong một transaction riêng (indexeddb.ts:442–452). Hệ quả với SQLite: một ID đã cấp cho một transaction bị huỷ sau commit cuối cùng có thể được cấp lại sau khi mở lại; một ID đã commit thì không bao giờ, đúng bất biến "ID không bao giờ bị dùng lại sau một lần ghi đã commit" (spec.md:69). Hội thoại gốc không xin ID: createRootConversation() dùng ID 1 dành sẵn (transaction.ts:291–294), và idFromNumber() chỉ gắn nhãn kiểu, không làm gì lúc chạy (ids.ts:3–6).

tx.createTask() theo cùng khuôn: kiểm task chủ (tồn tại; không completing, không terminal, không bị đánh dấu huỷ; task con không được là task nền và phải ở cùng hội thoại với chủ), tính checkpoint ban đầu bằng definition.initial(input), cấp ID, và dựng bản ghi trạng thái pending (transaction.ts:388–451). Chủ được kiểm lại trên bản ghi ứng viên cuối cùng lúc gom lô, nên một task không thể tạo việc con trong chính commit kết thúc nó (transaction.ts:880–902).

Niêm phong và gom lô

settleSuccess(): niêm phong, từ chối thao tác còn treo, prepare mọi draft
pi/packages/durable/src/session/transaction.tsdòng 696–720TypeScript
/** * Seal after callback success, prepare every open change, and assemble the atomic batch. * Any failure aborts every change before Storage admission. */async settleSuccess(): Promise<readonly StorageWrite[]> {	this.#sealed = true;	if (this.#pendingOperations.size > 0) {		this.#abortChanges();		await Promise.allSettled(this.#pendingOperations);		this.#context = BACKGROUND_CONTEXT;		throw new Error("Session commit callback settled before its pending Tx operations");	}	try {		// Synchronously prepare every open change; this revokes every draft.		for (const document of this.#documents) {			if (document.change !== undefined) document.prepared = document.change.prepare();		}		return await this.#assemble();	} catch (error) {		this.#abortChanges();		throw error;	} finally {		this.#context = BACKGROUND_CONTEXT;	}}
Nếu callback trả về trong khi một thao tác tx còn treo (ví dụ một tx.doc() không được await), commit bị từ chối với "Session commit callback settled before its pending Tx operations". Đây là cách thực thi cạm bẫy "việc bắn rồi quên trên draft" của đặc tả (spec.md:5464–5467).
#assemble() (cuối): submission, task, rồi document; checkpointWhen chạy sau cùng
pi/packages/durable/src/session/transaction.tsdòng 850–875TypeScript
for (const { id, change } of this.#submissionChanges) {	const current = this.#submissions.get(id) ?? (await storage.submission(id, this.#context));	if (current === undefined) throw new Error(`Submission ${id} does not exist`);	const next = applySubmissionChange(current, change);	if (next !== current) this.#submissions.set(id, next);}const writes = this.#writes;for (const value of this.#submissions.values()) writes.push({ type: "submission", value });for (const task of this.#tasksById.values()) {	if (task.write !== undefined) writes.push({ type: "task", value: task.write.record });}for (const plan of plans) {	const change = plan.change;	// Checkpoint predicates run last, after every validation.	if (plan.content?.type === "document.change" && plan.content.content.kind === "delta" && change?.loaded) {		const { prepared, version } = change;		const info = { deltasSinceBase: change.loaded.deltasSinceBase };		if (change.definition!.checkpointWhen(prepared.value, prepared.ops, info)) {			plan.content = { ...plan.content, content: { version, kind: "base", value: prepared.value } };		}	}	if (plan.content !== undefined) writes.push(plan.content);	if (plan.retire) writes.push({ type: "document.retire", id: plan.record.id });}return writes;
Phần trước đó của #assemble() lên kế hoạch document, chặn ghi vào nguồn của fork, kiểm chủ và trạng thái task, và cho mọi document của một task vừa terminal nghỉ hưu cùng nó (transaction.ts:784–848). Predicate checkpointWhen chạy cuối cùng, sau mọi kiểm tra, mỗi thay đổi đúng một lần.

Vậy thứ tự trong một lô là: hội thoại và entry theo thứ tự gọi, rồi submission, rồi task, rồi lệnh document (nội dung trước, nghỉ hưu sau). Storage không được dựa vào thứ tự đó cho document: một lô có nhiều nhất một lệnh nội dung cho mỗi incarnation và nội dung luôn được áp trước nghỉ hưu, bất kể vị trí trong mảng (spec.md:5273–5278).

Bên trong document: tracker, base, delta#

Bài toán: pi.live đổi mỗi 100 ms trong lúc mô hình stream. Ghi lại cả giá trị JSON mỗi lần là tốn; chỉ ghi phần khác thì lúc đọc phải phát lại một chuỗi dài. Pi Durable giải bằng hai lớp. Trong bộ nhớ, mỗi document đã nạp có một tracker của Chord giữ revision bất biến hiện tại; một commit mở một change, sửa trên draft, rồi prepare() sinh ra giá trị mới cộng một lô phép toán (spec.md:1428–1466). Trong storage, mỗi thay đổi được lưu hoặc là một base (giá trị đầy đủ) hoặc một delta (lô phép toán), và đọc là lấy base mới nhất rồi áp các delta sau nó.

LoadedDocument: một incarnation trong cache của Session
pi/packages/durable/src/session/transaction.tsdòng 87–98TypeScript
/** One committed document incarnation owned by the Session tracker cache. */export type LoadedDocument = {	readonly addressId: string;	readonly record: DocumentRecord;	/** Persisted definition version; older while the tracked value is migrated only in memory. */	storedVersion: number;	/** Definition version whose shape the tracked value has; access with another version reloads from Storage. */	readonly valueVersion: number;	/** Stored deltas after the newest base; advanced by adoption so the next predicate call needs no read. */	deltasSinceBase: number;	readonly tracker: Tracker<JsonObject>;};
storedVersion có thể cũ hơn valueVersion khi giá trị mới chỉ được migrate trong bộ nhớ. deltasSinceBase được duy trì khi nhận commit, nên lúc đánh giá checkpointWhen không cần đọc storage.

Cache này có giới hạn theo mức dùng, không theo lịch sử: một document không có ai quan sát rời bộ nhớ khi không được đọc hay ghi trong documentRetentionMs (mặc định 10 phút); document đang được quan sát thì không bao giờ rời; việc dọn chạy trên dòng commit nên không transaction nào mất tracker nó đang giữ (spec.md:1679–1695; session.ts:626–640).

Lấy draft: nạp hoặc tạo

#acquire(): có thì beginChange, chưa có thì initial() rồi cấp ID
pi/packages/durable/src/session/transaction.tsdòng 612–647TypeScript
async #acquire(entry: DocumentEntry, seed: JsonValue | undefined, skipLoad: boolean): Promise<Draft<JsonObject>> {	const definition = entry.definition!;	const loaded = skipLoad		? undefined		: await this.#host.load(definition, entry.addressId, entry.address, this.#context);	this.#assertOpen();	if (loaded !== undefined) {		checkRecordScope(definition, loaded.record);		checkRecordVersion(definition, loaded.record, loaded.storedVersion);		entry.target = { kind: "loaded", document: loaded };		entry.change = loaded.tracker.beginChange();		return entry.change.state;	}	const scope = entry.address.scope;	if (scope.kind === "conversation") await this.#requireConversation(scope.conversationId);	if (scope.kind === "task") {		const task = await this.#currentTask(scope.taskId);		if (task === undefined) throw new Error(`Task ${scope.taskId} does not exist`);		if (task.state.status === "terminal") throw new Error(`Task ${scope.taskId} is terminal`);	}	this.#assertOpen();	const value = copyJson(		definition.family === true ? definition.initial(seed) : definition.initial(),	) as JsonObject;	const id = await this.#host.storage.mintId<DocumentId>();	this.#assertOpen();	const tracker = track(value);	entry.target = {		kind: "created",		record: documentCreate(definition, entry.address, id),		version: definition.version,		tracker,	};	entry.change = tracker.beginChange();	return entry.change.state;}
Chỉ tx.doc() tạo document; snapshot(), documentState() và watchDoc() trả undefined khi vắng. Lần lấy đầu tiên của một địa chỉ được ghi nhớ trước khi await, nên gọi tx.doc() hai lần trong một commit trả về cùng một draft (transaction.ts:536–565). Giá trị từ initial() được sao chép vào quyền sở hữu của kernel, và bị từ chối nếu không phải JSON nghiêm ngặt.

Base hay delta

planDocument(): đổi version thì base, có phép toán thì delta, không có gì thì không ghi
pi/packages/durable/src/session/transaction.tsdòng 1040–1061TypeScript
case "loaded": {	const loaded = target.document;	const definition = document.definition!;	const prepared = document.prepared!;	const version = definition.version;	const id = loaded.record.id;	// A version change stores a base even without operations; otherwise only a change stores a delta.	const content =		loaded.storedVersion < version			? ({ type: "document.change", id, content: { version, kind: "base", value: prepared.value } } as const)			: prepared.ops.length > 0				? ({ type: "document.change", id, content: { version, kind: "delta", ops: prepared.ops } } as const)				: undefined;	const change = {		tracker: loaded.tracker,		prepared,		version,		loaded,		definition,	};	return { ...plan, record: loaded.record, change, ...(content === undefined ? {} : { content }) };}
Document mới tạo luôn được ghi một base (document.create). Một document đã có, thay đổi rỗng, thì không ghi gì và không công bố gì.

Delta chưa phải là quyết định cuối: ở bước cuối của #assemble() (xem excerpt ở mục trước), mỗi delta của document đã nạp được đưa qua checkpointWhen(value, ops, { deltasSinceBase }), và true thì nó thành base mang giá trị đầy đủ. Tạo mới và chuyển version luôn là base và không gọi predicate (spec.md:1542–1562). Đặc tả cho hai mẫu chính sách:

  • chặn độ dài phát lại trực tiếp: (_v, _ops, info) => info.deltasSinceBase >= 31;
  • checkpoint khi trống, cho document thay đổi dày: (value) => value.generation === undefined && value.tools === undefined (spec.md:1578–1590), đúng là ý của pi.live.

Sau khi storage ghi xong, adopt() chỉ đổi con trỏ:

adopt(): đổi con trỏ tracker, cập nhật version và đếm delta
pi/packages/durable/src/session/transaction.tsdòng 734–756TypeScript
const change = plan.change;if (change !== undefined) {	const { tracker, prepared, loaded, version } = change;	// A new incarnation is adopted unless it retires in the same commit; a loaded one only when it changed.	if (loaded === undefined ? !plan.retire : prepared.ops.length > 0) tracker.adopt(prepared);	else prepared.abort();	if (loaded !== undefined) {		if (loaded.storedVersion < version) loaded.storedVersion = version;		if (plan.content?.type === "document.change") {			if (plan.content.content.kind === "base") loaded.deltasSinceBase = 0;			else loaded.deltasSinceBase++;		}	} else if (!plan.retire) {		this.#host.install({			addressId: plan.addressId,			record,			storedVersion: version,			valueVersion: version,			deltasSinceBase: 0,			tracker,		});	}}
tracker.adopt(prepared) không diff, không áp phép toán, không cấp phát: giá trị mới đã được tính lúc prepare(), trước khi gửi xuống storage. Giá trị đó vừa là revision của tracker, vừa là giá trị được công bố (spec.md:1461–1466).

latest và rewindable trong storage

Với document session, task và document hội thoại latest, một base đã commit cho phép storage vứt các bản ghi cũ hơn. Với document rewindable, base chỉ chặn độ dài phát lại, không bao giờ cho phép xoá lịch sử còn được tham chiếu (spec.md:1569–1574). Backend tham chiếu viết đúng hai dòng đó:

MemoryStorage: base của document current-only thay toàn bộ lịch sử; nghỉ hưu thì xoá
pi/packages/durable/src/storage/memory.tsdòng 845–852TypeScript
} else if (action.content !== undefined) {	const revision = { ...action.content, seq } as DocumentRevision;	if (revision.kind === "base" && isCurrentOnly(stored!.record)) stored!.revisions = [revision];	else stored!.revisions.push(revision);}if (action.retire && action.create === undefined) stored!.record = { ...stored!.record, retiredAt: seq };if (action.retire && isCurrentOnly(stored!.record)) stored!.revisions = [];
SQLite làm tương tự bằng DELETE FROM document_revisions (sqlite/storage.ts:922–925); JSONL viết lại file sidecar (mục JSONL).

Đọc lịch sử: snapshotAsOf()

snapshotAsOf(token, conversationId, at) trả giá trị của một document rewindable như nó là tại entry at, cụ thể là giá trị cuối cùng của commit chứa entry đó:

snapshotAsOf(): entry → commitSeq → incarnation sống tại seq đó → giá trị tại seq đó
pi/packages/durable/src/session/session.tsdòng 475–492TypeScript
return this.#enqueue(async () => {	this.#assertHealthy();	const storedEntry = await this.#storage.entry(conversationId, at, context);	if (storedEntry === undefined) {		throw new Error(`Entry ${at} is not visible from conversation ${conversationId}`);	}	const address: DocumentAddress = {		...resolved.address,		scope: { kind: "conversation", conversationId: storedEntry.entry.conversationId },	};	const record = await this.#storage.findDocument(address, storedEntry.commitSeq, context);	if (record === undefined) return undefined;	const stored = await this.#storage.document(record.id, storedEntry.commitSeq, context);	if (stored === undefined) {		throw new Error(`Historical document ${record.id} (${record.kind}) cannot be read`);	}	return materializeDocument(definition, stored);});
Địa chỉ được đổi sang hội thoại sở hữu entry: hỏi một fork về một entry nó thừa kế sẽ đọc incarnation lịch sử của cha, vì bản sao của fork được tạo muộn hơn (spec.md:1412–1422). Không bao giờ tạo document trong quá khứ.
Hiện thực hoá: các revision tới seq, base mới nhất, áp chuỗi delta
pi/packages/durable/src/storage/memory.tsdòng 659–676TypeScript
private materializeDocument(id: DocumentId, at: DocumentPoint): StoredDocument | undefined {	const stored = this.state.documents.get(id);	if (stored === undefined) return undefined;	if (at !== "current" && isCurrentOnly(stored.record)) {		throw new StorageRequestError(`Document ${id} does not retain historical content`);	}	if (!isAliveAt(stored.record, at)) return undefined;	const revisions = at === "current" ? stored.revisions : stored.revisions.filter((revision) => revision.seq <= at);	let baseIndex = revisions.length - 1;	while (baseIndex >= 0 && revisions[baseIndex]!.kind !== "base") baseIndex--;	const base = revisions[baseIndex];	if (base?.kind !== "base") throw new Error(`Document ${id} is missing a required base`);	const value = applyImmutableBatches(		base.value,		documentDeltaBatches(id, base.version, revisions, baseIndex + 1),	) as JsonObject;	return { record: stored.record, version: base.version, value, deltasSinceBase: revisions.length - baseIndex - 1 };}
Một incarnation sống trong khoảng nửa mở createdAt <= at < retiredAt. Hỏi lịch sử của một document chỉ giữ hiện tại ném StorageRequestError, lỗi chỉ làm hỏng lần đọc đó. Thiếu base, hay delta vượt qua ranh giới version, là hỏng dữ liệu (spec.md:5259–5271).

Version và migration

Migration chạy khi truy cập, qua token được truyền vào: version lưu bằng version của token thì dùng luôn; cũ hơn thì gọi migrate(value, storedVersion); mới hơn, hoặc cũ hơn mà không có migrate, thì từ chối (documents.ts:178–208). Đọc chỉ migrate trong bộ nhớ, không bao giờ ghi; lần tx.doc() đầu tiên sau đó ghi một base ở version mới, kể cả khi giá trị không đổi (loaded.storedVersion < version trong planDocument()). Lịch sử rewindable không bao giờ bị viết lại; giá trị lịch sử được migrate sau khi phát lại (spec.md:1603–1625). Cache cũng tôn trọng version: một tracker chỉ phục vụ token cùng version với giá trị của nó; token khác version (ví dụ từ code extension vừa reload) làm cache bị bỏ và nạp lại từ storage (session.ts:865–894).

Fork: thấy entry của cha mà không sao chép#

Fork một hội thoại dài phải rẻ (không chép hàng nghìn entry) và nhất quán (nhánh con thấy đúng tiền tố của cha tại điểm fork, kể cả khi cha chạy tiếp). Pi Durable không chép entry nào. Bản ghi hội thoại con chỉ mang parent: { conversationId, at }, và mọi lần quét entry đi ngược chuỗi tổ tiên với một mức trần giảm dần:

visibleEntries(): entry của chính mình, rồi của cha tới parent.at, rồi của ông tới mức trần nhỏ hơn
pi/packages/durable/src/storage/memory.tsdòng 678–701TypeScript
private *visibleEntries(	conversationId: ConversationId,	minEntryId: number = Number.NEGATIVE_INFINITY,	maxEntryId: number = Number.POSITIVE_INFINITY,): Generator<EntryRecord> {	if (!this.state.conversations.has(conversationId)) {		throw new StorageRequestError(`Unknown conversation: ${conversationId}`);	}	let currentId = conversationId;	let upperEntryId = maxEntryId;	while (true) {		const ids = this.state.entryIds.get(currentId) ?? [];		for (let index = upperBound(ids, upperEntryId) - 1; index >= 0; index--) {			const id = ids[index];			if (id < minEntryId) break;			yield this.state.entries.get(id)!;		}		const conversation = this.state.conversations.get(currentId)!;		if (conversation.parent === undefined) break;		upperEntryId = Math.min(upperEntryId, conversation.parent.at);		if (upperEntryId < minEntryId) break;		currentId = conversation.parent.conversationId;	}}
SQLite làm cùng việc đó bằng một truy vấn cho mỗi đoạn của chuỗi (sqlite/storage.ts:348–383); quét tăng dần đọc đoạn của gốc trước rồi tới từng fork (spec.md:5235–5242). Tìm head marker mới nhất cũng đi ngược cùng chuỗi đó (memory.ts:491–514).

Hai quy tắc làm cho điều này đúng. Entry bất biến, nên tiền tố của cha tới at không bao giờ đổi. Và trong một Session, ID được cấp bên trong callback, tức trên dòng commit, nên chúng tăng theo thứ tự commit: "entry của cha có ID <= at" chính xác là "entry của cha tới lúc fork". (Bản thân storage chấp nhận entry được commit lệch thứ tự ID, và bộ kiểm thử tuân thủ có một ca cho việc đó, storage-conformance.ts:251; chỉ là Session không bao giờ làm vậy.) Nhánh con gồm các entry tới E kể cả khi cùng commit đó còn append entry sau E; state document tại E là state cuối cùng của commit chứa E (spec.md:1629–1633).

Document thì khác: chúng thay đổi, nên nhánh con cần bản riêng. Chính sách fork của từng document quyết định bản đó lấy từ đâu:

forkGiá trị của nhánh con
asOfgiá trị của cha tại commit của E (cần rewindable)
currentgiá trị đã commit của cha lúc commit fork chạy
initialkhông chép gì; lần truy cập đầu ở nhánh con gọi initial()
prepareForkDocumentCopies(): asOf quét tại seq của entry, current quét hiện tại
pi/packages/durable/src/session/forks.tsdòng 25–59TypeScript
/** Select every persisted conversation document copied by one fork. */export async function prepareForkDocumentCopies(	storage: Storage,	parentConversationId: ConversationId,	at: EntryId,	childConversationId: ConversationId,	context: Context,): Promise<readonly ForkDocumentCopy[]> {	const entry = await storage.entry(parentConversationId, at, context);	if (entry === undefined) throw new Error(`Entry ${at} is not visible from conversation ${parentConversationId}`);	const copies: ForkDocumentCopy[] = [];	const copiedAddresses = new Set<string>();	await collectCopies(		storage,		{ kind: "conversation", conversationId: entry.entry.conversationId },		entry.commitSeq,		"asOf",		childConversationId,		copies,		copiedAddresses,		context,	);	await collectCopies(		storage,		{ kind: "conversation", conversationId: parentConversationId },		"current",		"current",		childConversationId,		copies,		copiedAddresses,		context,	);	return copies;}
collectCopies() quét các document còn sống trong phạm vi đó, giữ những cái có đúng chính sách, cấp ID mới và dựng bản ghi tạo cho nhánh con (forks.ts:61–97). Không cần định nghĩa document: document của extension không có mặt vẫn được chép nguyên version lưu (spec.md:1644–1649).

Mỗi bản chép đi xuống storage dưới dạng lệnh document.copy mang bản ghi của con và một nguồn chính xác { id, at }. Storage tự hiện thực hoá nguồn và lưu thành base ban đầu, độc lập, của con, ở version lưu của nguồn (memory.ts:291–328; sqlite/storage.ts:871–890); một storage từ xa làm việc này phía server. Nếu ngay trong commit tạo fork có code truy cập document được chép, #acquireForkCopy() đọc nguồn và đổi bản chép thành một lệnh tạo bình thường (transaction.ts:649–674).

Một commit fork không được sửa nguồn của nó
pi/packages/durable/src/session/transaction.tsdòng 904–923TypeScript
#rejectForkSourceWrites(plans: readonly DocumentPlan[]): void {	for (const plan of plans) {		if (plan.content === undefined && !plan.retire) continue;		const record = plan.record;		if (this.#forkSourceDocumentIds.has(record.id)) {			throw new Error(`Cannot change fork source document ${record.id} in the fork transaction`);		}		const scope = plan.record.scope;		if (			scope.kind === "conversation" &&			this.#forkSourceConversationIds.has(scope.conversationId) &&			record.scope.kind === "conversation" &&			record.fork === "current"		) {			throw new Error(				`Cannot fork conversation ${scope.conversationId} while changing its current-policy documents`,			);		}	}}
Bản chép đọc state đã commit trước lô. Nếu cùng commit lại sửa một document current của cha, sẽ có hai nguồn "hiện tại" khác nhau; thay vì chọn ngầm một cái, transaction từ chối. Hãy commit thay đổi của cha trước (spec.md:1651–1654).

Với năm document dựng sẵn, kết quả là: pi.agent (asOf) được chép; pi.live, pi.inbox, pi.usage, pi.provider (initial) được hook tạo hội thoại tạo mới; và createAgent() trả về sớm cho fork để không đè bản chép (agent.ts:174–176). Task và document của task không bao giờ được chép. Ví dụ 02-forks.ts fork tại entry đầu tiên và cho thấy nhánh con thấy "hello" mà không thấy "goodbye", với ghi chú asOf đúng như lúc fork.

Quan sát: CommittedStateSource và CommittedWatch#

Chương trước giới thiệu viewState(), watch() và quy tắc 100 frame từ phía người dùng. Bên dưới, cả view lẫn document riêng lẻ đều dùng hai lớp trong observation.ts: CommittedStateSource làm nguồn cho một state nhân bản (replicated state) của Chord, và CommittedWatch làm watch tuần tự. Cả hai được cấp revision bất biến và lô phép toán chính xác của từng commit; không ai diff lại hay sao chép.

documentState() và watchDoc() nạp document trên dòng commit, rồi gắn observer trong cùng việc đó, nên ảnh chụp ban đầu và đăng ký nhận commit sau là nguyên tử: commit nào trước lúc gắn đã nằm trong ảnh chụp, commit nào sau thì được giao (spec.md:4704–4707). Phần gắn:

#attachDocument(): theo dõi đúng một incarnation qua listener nội bộ
pi/packages/durable/src/session/session.tsdòng 847–862TypeScript
const observed = { version: loaded.valueVersion };unsubscribeCommit = this.observeCommits((publication, context) => {	for (const change of publication.changes) {		if (change.type !== "document" || change.record.id !== loaded.record.id) continue;		// A document state's frames carry no caller cancellation; a watch observes its own cancellation.		const frameContext = observer instanceof CommittedStateSource ? withoutAbortSignal(context) : context;		const ops = observedOperations(observed, change);		// A migration-only base changes nothing for an observer of the new version.		if (ops.length === 0) continue;		// A retired incarnation never changes again: its observers keep their last value but pin nothing.		if (change.value === null) unpin();		observer.advance(change.value, ops, frameContext);	}});unsubscribeClose = this.subscribeClose(() => observer.closeSession(this.#failure));return { observer, detach };
Observer được ghim (pin) incarnation vào cache, nên document đang được quan sát không bao giờ bị dọn. Nghỉ hưu giao một frame [["r", null]] rồi bỏ ghim; observer không bao giờ đi theo incarnation thay thế. Một observer dựng ở version khác nhận giá trị mới dưới dạng phép thay gốc thay vì phép toán cho hình dạng cũ (session.ts:934–942).

CommittedStateSource hiện thực giao kèo nguồn của Chord: attach() trả một ảnh chụp cố định cộng con trỏ (cursor), activate(listener) cài listener duy nhất và xả mọi frame đã đệm (spec.md:4615–4650; observation.ts:25–78). Frame được giao qua microtask, không bao giờ ngay trong commit (observation.ts:103–119). Phần còn lại, gồm cursor liên tục và việc áp phép toán lên state phía client, là việc của Chord (chương Chord).

CommittedWatch thì tự giao, từng frame một, chờ listener xong mới giao frame sau:

CommittedWatch.#drain(): tuần tự, value tiến trước callback, lỗi listener thì dừng
pi/packages/durable/src/session/observation.tsdòng 248–275TypeScript
async #drain(): Promise<void> {	if (this.#running || this.#end !== undefined || !this.#started) return;	this.#running = true;	try {		while (this.#end === undefined) {			const frame = this.#pending.shift();			if (frame === undefined) break;			this.#value = frame.value;			const deliveryContext = withoutAbortSignal(frame.context);			try {				await this.#listener!(frame.value, frame.ops, deliveryContext);			} catch (error) {				if (this.#end === undefined) {					this.#report(error);					this.#terminate({ reason: "listener_error", error: toError(error) });				}				break;			}			if (frame.value === null) {				this.#terminate({ reason: "retired" });				break;			}		}	} finally {		this.#running = false;		if (this.#end === undefined && this.#pending.length > 0) this.#schedule();	}}
Commit không bao giờ chờ callback. watch.value tiến tới revision của frame ngay trước khi callback được gọi. Listener ném lỗi kết thúc watch với listener_error và bỏ các frame đang chờ; frame null kết thúc nó với retired. Các lý do kết thúc khác (stopped, cancelled, session_closed, session_failed) nằm ở types.ts:1010–1015.

Mount view của hội thoại dùng đúng hai lớp này (view.ts:110–140), chỉ khác là revision được dẫn xuất từ publication thay vì lấy thẳng từ một tracker. Khi Session đóng, mọi state và watch kết thúc tại lúc niêm phong: state giữ giá trị cuối, commit hoàn tất trong lúc đóng không được giao nữa (spec.md:4697–4698).

Giao diện Storage và scan#

Giao diện mà mọi backend phải hiện thực nhỏ một cách có chủ đích: một phương thức ghi, một phương thức cấp ID, các lần tra theo khoá, các lần quét theo cursor, ba phương thức cho document, và close(). "Ordered scans are cursor-based. Exact identity lookups are keyed" (spec.md:5085).

Storage: commit, mintId, tra cứu, quét, và ba phương thức document
pi/packages/durable/src/types.tsdòng 1179–1200, 1258–1270TypeScript
export interface Storage {	/**	 * Atomically persist one batch and return its sequence. Once resolved, later reads through this storage observe it.	 */	commit(writes: readonly StorageWrite[], context: Context): Promise<Seq>;	/**	 * Return a fresh branded candidate from the Session-global numeric ID namespace, greater than every ID minted or stored	 * before, also across reopen: scans order by ID as creation order, and the Harness relies on it.	 */	mintId<I extends Id<string>>(): Promise<I>;	/** Look up one conversation by exact ID. */	conversation(id: ConversationId, context: Context): Promise<ConversationRecord | undefined>;	/** Scan conversations in `query.order` (default ascending) by ID. */	scanConversations(		query: ConversationQuery,		limit: number,		cursor: Cursor | undefined,		context: Context,	): Promise<Page<ConversationRecord, Cursor>>;	/** Resolve the incarnation occupying one exact logical address at the selected point. */	findDocument(address: DocumentAddress, at: DocumentPoint, context: Context): Promise<DocumentRecord | undefined>;	/** Materialize one specific incarnation by ID at the selected point without following a replacement at its address. */	document(id: DocumentId, at: DocumentPoint, context: Context): Promise<StoredDocument | undefined>;	/** Scan incarnations alive in one exact scope at the selected point. */	scanDocuments(		query: DocumentQuery,		limit: number,		cursor: Cursor | undefined,		context: Context,	): Promise<Page<DocumentRecord, Cursor>>;
Phần lược bớt là các lần tra và quét entry, task, submission, và submissionByRequest() phục vụ requestId (types.ts:1201–1256). entry(conversationId, id) chỉ trả về khi entry nhìn thấy được qua tổ tiên của hội thoại, kèm commitSeq mà việc đọc lịch sử document cần.
DocumentContent và StoredDocument: base hoặc delta vào, giá trị đã hiện thực hoá ra
pi/packages/durable/src/types.tsdòng 823–849TypeScript
/** Complete checkpoint or Chord operation batch selected by the owning Session. */export type DocumentContent =	| {			readonly version: number;			readonly kind: "base";			readonly value: JsonObject;	  }	| {			readonly version: number;			readonly kind: "delta";			readonly ops: readonly Op[];	  };/** Exact persisted source selected for a definition-free document copy. */export type DocumentCopySource = {	readonly id: DocumentId;	readonly at: DocumentPoint;};/** Detached materialized value and stored definition version at a selected point. */export type StoredDocument = {	readonly record: DocumentRecord;	readonly version: number;	readonly value: JsonObject;	/** Deltas replayed after the selected base to materialize `value`. */	readonly deltasSinceBase: number;};
Bản ghi base/delta là chuyện riêng của backend; giao diện chỉ thấy DocumentContent đi vào và StoredDocument đi ra, cùng DocumentPoint là Seq | "current" (types.ts:805–821).

Các truy vấn quét có bộ lọc vừa đủ cho Harness: hội thoại theo task chủ hoặc hội thoại chủ, entry theo khoảng ID, task theo hội thoại, kind, trạng thái, cờ huỷ, cờ nền, submission theo hội thoại và trạng thái (types.ts:764–803). Bộ lọc chủ phải có chỉ mục, vì việc duyệt cây sở hữu (huỷ cả cây, chờ cả cây rảnh) dựa vào chúng thay vì quét mọi hội thoại (spec.md:5243–5245). Cursor là JSON của backend, người gọi chỉ đưa lại cho đúng lần quét đó trên đúng storage đó; nó mang theo chiều quét:

scanStart() và nextCursor(): cursor nhớ chiều quét, từ chối chiều khác
pi/packages/durable/src/storage/scan.tsdòng 15–40TypeScript
export function scanStart(	requested: ScanOrder | undefined,	cursor: Readonly<Record<string, unknown>> | undefined,	fallback: ScanOrder,): ScanStart {	if (requested !== undefined && requested !== "ascending" && requested !== "descending") {		throw new StorageRequestError(`Invalid scan order: ${String(requested)}`);	}	if (cursor === undefined) return { order: requested ?? fallback, after: undefined };	const { after, order: stored } = cursor;	if (typeof after !== "number" || !Number.isSafeInteger(after))		throw new StorageRequestError("Invalid storage cursor");	if (stored !== undefined && stored !== "ascending" && stored !== "descending") {		throw new StorageRequestError("Invalid storage cursor");	}	const order = stored ?? fallback;	if (requested !== undefined && requested !== order) {		throw new StorageRequestError(`The cursor continues a ${order} scan; the query asks for ${requested}`);	}	return { order, after };}/** The continuation of a scan whose last returned item has `id`. */export function nextCursor(id: Id<string>, order: ScanOrder): { readonly after: number; readonly order: ScanOrder } {	return { after: id, order };}
Cursor sai hay lệch chiều ném StorageRequestError: lỗi của một lần đọc không hợp lệ, không có hậu quả bền, chỉ làm hỏng lần đọc đó (mục Lỗi). Quy tắc chiều quét được thêm ở 1.1.0 như một thay đổi phá vỡ cho backend tự viết (CHANGELOG.md:103).

Ngoài các chữ ký, hợp đồng còn đòi vài điều backend phải tự kiểm khi commit: một ID chỉ thuộc một bảng; hội thoại, entry và document chỉ được tạo một lần; mỗi địa chỉ logic có nhiều nhất một incarnation hiện tại; delta không được vượt ranh giới version (memory.ts:733–815). Và mọi lần đọc trả về giá trị đã tách: chuỗi bất biến được dùng chung, mảng và object thì không (spec.md:1736–1743).

MemoryStorage: ngữ nghĩa tham chiếu#

Năm backend đi kèm (README.md:761–767):

BackendMở bằngGhi chú
Memorynew MemoryStorage() (gốc package)Không lưu gì; ngữ nghĩa tham chiếu
SQLiteopenNodeSqliteStorage(file)Một file; WAL với synchronous = NORMAL
JSONLopenNodeJsonlStorage(dir, context)File chỉ-append trong một thư mục; giữ toàn bộ lịch sử trong bộ nhớ
IndexedDBawait IndexedDbStorage.open(name)Chỉ trình duyệt
Durable ObjectopenDurableObjectSqliteStorage(ctx.storage)SQLite của một Durable Object; commit là transaction của nó

MemoryStorage là ngữ nghĩa tham chiếu: nó sao chép giá trị ghi được giữ lại và mọi kết quả đọc, cố ý mô phỏng ranh giới sở hữu mà mã hoá/giải mã của SQLite và JSONL tự nhiên tạo ra; đây là để tuân thủ, không phải để phòng thủ (spec.md:5383–5387). Cách nó đạt tính nguyên tử là mẫu đáng học:

commit() = prepareCommit(writes).apply(): kiểm mọi thứ trước, rồi áp không thể lỗi
pi/packages/durable/src/storage/memory.tsdòng 263–289TypeScript
async commit(writes: readonly StorageWrite[], _context: Context): Promise<Seq> {	return this.prepareCommit(writes).apply();}/** Validate and detach one commit without changing observable state. */prepareCommit(writes: readonly StorageWrite[], seq: Seq = seqFromNumber(this.nextSeq)): PreparedMemoryCommit {	this.assertOpen();	if (!Number.isSafeInteger(seq) || seq < this.nextSeq) {		throw new Error(`Commit sequence ${seq} does not strictly increase`);	}	const detachedWrites = freeze(this.resolveDocumentCopies(writes.map((write) => clone(write))));	this.checkGlobalIds(detachedWrites);	const documentActions = this.prepareDocumentActions(detachedWrites);	this.checkDocumentActions(documentActions);	let applied = false;	return {		seq,		writes: detachedWrites,		apply: () => {			if (!applied) {				applied = true;				this.applyPreparedCommit(detachedWrites, documentActions, seq);			}			return seq;		},	};}
prepareCommit() sao chép và đóng băng lô, giải các lệnh document.copy, kiểm quyền sở hữu ID và lệnh document, mà không chạm state. apply() chỉ còn việc cập nhật map và chỉ mục, không có bước nào có thể thất bại giữa chừng. Cách chia hai bước này còn được JSONL dùng lại: nó chuẩn bị bằng MemoryStorage, ghi file, rồi mới apply().

State bên trong là các map theo ID cộng các mảng ID đã sắp xếp làm chỉ mục: theo hội thoại, theo chủ, theo trạng thái, theo request ID, theo địa chỉ và phạm vi document (memory.ts:72–92). ID bắt đầu từ 2, Seq từ 1 (memory.ts:259–260).

Lược đồ SQLite#

Tên bảng và chỉ mục là một lịch sử bất biến: không bao giờ đổi hay xoá giá trị đã có, đổi tên thì thêm mục mới, để migration cũ vẫn render đúng tên gốc (schema.ts:5–38). DDL của schema version 1 nằm trong createSqliteMigrations():

Lược đồ (1): metadata, record_ids, conversations, entries
pi/packages/durable/src/storage/sqlite/migrations.tsdòng 14–41TypeScript
const initialSchema: readonly string[] = [	`CREATE TABLE ${tables.durableMetadata} (		singleton INTEGER PRIMARY KEY CHECK (singleton = 1),		next_id TEXT NOT NULL,		next_seq INTEGER NOT NULL	) STRICT`,	`INSERT INTO ${tables.durableMetadata} (singleton, next_id, next_seq) VALUES (1, '2', 1)`,	`CREATE TABLE ${tables.recordIds} (		id INTEGER PRIMARY KEY,		record_type TEXT NOT NULL CHECK (record_type IN ('conversation', 'entry', 'task', 'submission', 'document'))	) STRICT`,	`CREATE TABLE ${tables.conversations} (		id INTEGER PRIMARY KEY,		owner_conversation_id INTEGER,		owner_task_id INTEGER,		record TEXT NOT NULL CHECK (json_valid(record))	) STRICT`,	`CREATE INDEX ${indexes.conversationsByOwnerConversation} ON ${tables.conversations} (owner_conversation_id, id)`,	`CREATE INDEX ${indexes.conversationsByOwnerTask} ON ${tables.conversations} (owner_task_id, id)`,	`CREATE TABLE ${tables.entries} (		id INTEGER PRIMARY KEY,		conversation_id INTEGER NOT NULL,		head INTEGER,		commit_seq INTEGER NOT NULL,		record TEXT NOT NULL CHECK (json_valid(record))	) STRICT`,	`CREATE INDEX ${indexes.entriesByConversation} ON ${tables.entries} (conversation_id, id DESC)`,	`CREATE INDEX ${indexes.entryHeadsByConversation} ON ${tables.entries} (conversation_id, id DESC) WHERE head IS NOT NULL`,
next_id là TEXT vì node:sqlite từ chối số nguyên vượt khoảng an toàn của JavaScript (chú thích ở dòng 13). record_ids hiện thực "một không gian ID toàn cục": mỗi ID ghi nhận bảng sở hữu nó. Chỉ mục một phần trên head IS NOT NULL giúp tìm head marker mới nhất bằng một lần tra.
Lược đồ (2): tasks và submissions
pi/packages/durable/src/storage/sqlite/migrations.tsdòng 42–65TypeScript
`CREATE TABLE ${tables.tasks} (	id INTEGER PRIMARY KEY,	conversation_id INTEGER NOT NULL,	kind TEXT NOT NULL,	status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'waiting', 'completing', 'terminal')),	abort_requested INTEGER NOT NULL CHECK (abort_requested IN (0, 1)),	background INTEGER NOT NULL CHECK (background IN (0, 1)),	record TEXT NOT NULL CHECK (json_valid(record))) STRICT`,`CREATE INDEX ${indexes.tasksByStatus} ON ${tables.tasks} (status, id)`,`CREATE INDEX ${indexes.tasksByConversation} ON ${tables.tasks} (conversation_id, id)`,`CREATE INDEX ${indexes.tasksByKind} ON ${tables.tasks} (kind, id)`,`CREATE INDEX ${indexes.tasksByAbortRequested} ON ${tables.tasks} (abort_requested, id)`,`CREATE INDEX ${indexes.tasksByBackground} ON ${tables.tasks} (background, id)`,`CREATE TABLE ${tables.submissions} (	id INTEGER PRIMARY KEY,	conversation_id INTEGER NOT NULL,	request_id TEXT,	status TEXT NOT NULL CHECK (status IN ('queued', 'placed', 'done', 'unanswered')),	record TEXT NOT NULL CHECK (json_valid(record))) STRICT`,`CREATE INDEX ${indexes.submissionsByRequest} ON ${tables.submissions} (conversation_id, request_id)`,`CREATE INDEX ${indexes.submissionsByConversation} ON ${tables.submissions} (conversation_id, id)`,`CREATE INDEX ${indexes.submissionsByStatus} ON ${tables.submissions} (status, id)`,
Cột để lọc (trạng thái, kind, cờ) đứng riêng và có chỉ mục; toàn bộ bản ghi nằm ở cột record dạng JSON, có CHECK (json_valid(record)). Ràng buộc trạng thái của version 1 chưa có sleeping.
Lược đồ (3): documents và document_revisions
pi/packages/durable/src/storage/sqlite/migrations.tsdòng 66–89TypeScript
`CREATE TABLE ${tables.documents} (	id INTEGER PRIMARY KEY,	kind TEXT NOT NULL,	family INTEGER NOT NULL CHECK (family IN (0, 1)),	key_value TEXT NOT NULL,	scope_kind TEXT NOT NULL CHECK (scope_kind IN ('session', 'conversation', 'task')),	owner_id INTEGER NOT NULL,	created_at INTEGER NOT NULL,	retired_at INTEGER,	record TEXT NOT NULL CHECK (json_valid(record))) STRICT`,`CREATE INDEX ${indexes.documentsByAddress}	ON ${tables.documents} (kind, scope_kind, owner_id, family, key_value, created_at DESC, retired_at)`,`CREATE INDEX ${indexes.documentsByScope} ON ${tables.documents} (scope_kind, owner_id, id)`,`CREATE INDEX ${indexes.documentsByScopeKind} ON ${tables.documents} (scope_kind, owner_id, kind, id)`,`CREATE TABLE ${tables.documentRevisions} (	document_id INTEGER NOT NULL,	seq INTEGER NOT NULL,	kind TEXT NOT NULL CHECK (kind IN ('base', 'delta')),	version INTEGER NOT NULL,	content TEXT NOT NULL CHECK (json_valid(content)),	PRIMARY KEY (document_id, seq)) STRICT`,`CREATE INDEX ${indexes.documentRevisionsByKind} ON ${tables.documentRevisions} (document_id, kind, seq DESC)`,
documents giữ một hàng cho mỗi incarnation với các cột địa chỉ (kind, family, key, phạm vi, chủ) và vòng đời (created_at, retired_at là số Seq). document_revisions giữ base và delta theo (document_id, seq); chỉ mục (document_id, kind, seq DESC) tìm base mới nhất trước một seq.

Schema version 2 dựng lại bảng tasks để thêm trạng thái sleeping, vì SQLite không đổi được ràng buộc CHECK tại chỗ (migrations.ts:91–116). Bộ chạy migration chung nằm trong Chord và chạy mọi migration còn thiếu trong một transaction, ghi version vào bảng durable_schema, và từ chối một database đã được migrate bởi bản mới hơn:

applySqliteMigrations() của Chord: một transaction, từ chối schema mới hơn
pi/packages/chord/src/sqlite/migrations.tsdòng 25–42TypeScript
await database.transaction(async (transaction) => {	await transaction.exec(`CREATE TABLE IF NOT EXISTS ${table} (		singleton INTEGER PRIMARY KEY CHECK (singleton = 1),		version INTEGER NOT NULL CHECK (version >= 0)	) STRICT`);	await transaction.run(`INSERT OR IGNORE INTO ${table} (singleton, version) VALUES (1, 0)`);	const row = await transaction.get<SchemaRow>(`SELECT version FROM ${table} WHERE singleton = 1`);	if (row === undefined) throw new Error("SQLite schema metadata is missing");	const currentVersion = migrations.at(-1)?.version ?? 0;	if (row.version > currentVersion) {		throw new Error(`SQLite schema version ${row.version} is newer than supported version ${currentVersion}`);	}	for (const migration of migrations) {		if (migration.version <= row.version) continue;		for (const statement of migration.statements) await transaction.exec(statement);		await transaction.run(`UPDATE ${table} SET version = ? WHERE singleton = 1`, migration.version);	}});
Vì vậy CHANGELOG 1.4.0 cảnh báo: mở một storage SQLite bằng 1.4.0 sẽ tự migrate lên version 2, và sau đó 1.3.0 trở về trước không mở được nữa, nên không thể quay lui bản triển khai qua mốc này (CHANGELOG.md:9).

SQLite: commit, adapter và prefix#

Một transaction SQL là một commit của Session (spec.md:5391):

SqliteStorage.commit(): một db.transaction cho cả lô
pi/packages/durable/src/storage/sqlite/storage.tsdòng 187–210TypeScript
async commit(writes: readonly StorageWrite[], _context: Context): Promise<Seq> {	this.assertOpen();	const documentActions = this.prepareDocumentActions(writes);	const candidateNextId = this.candidateNextId(writes);	const seq = await this.db.transaction(async (transaction) => {		const metadata = await transaction.get<MetadataRow>(			`SELECT next_id, next_seq FROM ${this.schema.tables.durableMetadata} WHERE singleton = 1`,		);		if (metadata === undefined) throw new Error("Durable SQLite metadata is missing");		const committedSeq = seqFromNumber(metadata.next_seq);		await this.checkGlobalIds(transaction, writes);		await this.checkDocumentActions(transaction, documentActions);		for (const write of writes) await this.applyTableWrite(transaction, write, committedSeq);		await this.applyDocumentActions(transaction, documentActions, committedSeq);		await transaction.run(			`UPDATE ${this.schema.tables.durableMetadata} SET next_id = ?, next_seq = ? WHERE singleton = 1`,			String(Math.max(Number(metadata.next_id), candidateNextId)),			committedSeq + 1,		);		return committedSeq;	});	this.nextId = Math.max(this.nextId, candidateNextId);	return seq;}
Seq là next_seq đọc trong chính transaction, rồi tăng một. Kiểm quyền sở hữu ID và lệnh document chạy trước mọi lần ghi; một lỗi bất kỳ làm cả transaction rollback, nên chỉ mục và bảng không bao giờ lệch nhau (ca tuân thủ "rolls back record tables and secondary indexes when a document command fails", storage-conformance.ts:1478).

Từng loại ghi đi vào bảng của nó (sqlite/storage.ts:795–854): hội thoại và entry là INSERT thuần (ghi trùng thì lỗi khoá chính, đúng tính bất biến), task và submission là INSERT ... ON CONFLICT(id) DO UPDATE vì chúng là bản ghi thay thế trọn. Mọi ID đi qua claimId() vào record_ids. Các chuỗi dùng làm khoá chỉ mục (kind, request_id, key_value) được lưu dưới dạng JSON string, vì một số binding SQLite thay thế ký tự surrogate lẻ, và mã hoá JSON giữ danh tính không mất mát (sqlite/storage.ts:74–75). Đọc một document gói hai truy vấn (bản ghi, rồi base và đuôi delta) trong một transaction để chúng thấy cùng một trạng thái (sqlite/storage.ts:549–553), và việc ghi revision cho document current-only xoá các revision cũ khi gặp base (sqlite/storage.ts:922–950).

Lõi khả chuyển và adapter

SqliteStorage không gọi API của Node. Nó nói chuyện với một facade bất đồng bộ SqliteDatabase của Chord gồm exec, run, get, all, transaction, close; callback của transaction nhận một handle riêng, mọi việc trong transaction phải dùng handle đó, và adapter phải xếp hàng mọi thao tác khác cho tới khi transaction xong (chord/src/sqlite/database.ts:18–38; README.md:792–799). Hai adapter đi kèm:

  • Node: openNodeSqliteStorage(path, options) mở file bằng node:sqlite qua openNodeSqliteDatabase() của Chord (sqlite/node.ts:14–22), vốn bật journal_mode = WAL, synchronous = NORMAL và auto-checkpoint WAL mỗi 1.000 trang (chord/src/sqlite/node.ts:179–192). README nói rõ hệ quả: commit sống qua crash của tiến trình, nhưng commit mới nhất có thể mất khi mất điện hay hỏng máy chủ (README.md:765).
  • Cloudflare Durable Object: openDurableObjectSqliteStorage(ctx.storage, options), xem mục Cloudflare bên dưới.

Prefix: chung database với bảng của host

Từ 1.3.0, { prefix: "pi_" } đặt tiền tố cho mọi bảng và chỉ mục, để Pi chung một database với bảng của ứng dụng (CHANGELOG.md:48). Storage tự render tên đã có tiền tố vào SQL của nó, nên adapter không cần hỗ trợ gì (schema.ts:53–61). Tiền tố phải là một định danh SQLite, không được bắt đầu bằng sqlite_ (chord/src/sqlite/schema.ts:1–9); host còn có thể giữ thêm tiền tố riêng, như _cf_ trong Durable Object; đổi tiền tố không migrate dữ liệu cũ (README.md:773–777).

JSONL#

Backend JSONL giữ dữ liệu trong các file chỉ-append trong một thư mục, dùng khả năng FileSystem khả chuyển từ /env chứ không phải toàn bộ ExecutionEnv (spec.md:5410–5421):

  • main.jsonl: ghi bảng, bản ghi document, và đúng một marker cho mỗi commit;
  • doc-<id>.jsonl: nội dung (base, delta) của một incarnation document;
  • task-<id>.jsonl: các bản thay thế của một task còn sống.
MainOperation, MainMarker, SidecarRecord: marker tham chiếu bản ghi sidecar bằng (seq, ordinal)
pi/packages/durable/src/storage/jsonl/storage.tsdòng 36–60TypeScript
type MainOperation =	| Extract<StorageWrite, { readonly type: "conversation" | "entry" | "submission" | "document.retire" }>	| { readonly type: "task"; readonly value: StoredTask }	| { readonly type: "task.sidecar"; readonly id: TaskId; readonly ordinal: number }	| { readonly type: "document.create"; readonly record: DocumentCreate; readonly ordinal: number }	| { readonly type: "document.change"; readonly id: DocumentId; readonly ordinal: number };type MainMarker = {	readonly format: typeof FORMAT_VERSION;	readonly type: "commit";	readonly seq: Seq;	readonly writes: readonly MainOperation[];};type SidecarPayload =	| { readonly type: "task"; readonly value: StoredTask }	| { readonly type: "document"; readonly id: DocumentId; readonly content: DocumentContent };type SidecarRecord = {	readonly format: typeof FORMAT_VERSION;	readonly type: "record";	readonly seq: Seq;	readonly ordinal: number;	readonly payload: SidecarPayload;};
Hội thoại, entry, submission, task đã terminal và việc nghỉ hưu document nằm thẳng trong marker. Task còn sống và nội dung document nằm ở sidecar; marker chỉ ghi ordinal của bản ghi sidecar trong commit đó (jsonl/storage.ts:384–449).

Giao thức công bố có ba bước: append mọi bản ghi sidecar, append một marker hoàn chỉnh liệt kê chúng, và chỉ công bố trong bộ nhớ sau khi marker được ghi thành công (spec.md:5423–5429). Code theo đúng thứ tự đó:

JsonlStorage.commit(): sidecar, fsync tuỳ chọn, marker, rồi mới apply
pi/packages/durable/src/storage/jsonl/storage.tsdòng 278–307TypeScript
async commit(writes: readonly StorageWrite[], context: Context): Promise<Seq> {	this.assertUsable();	const prepared = this.memory.prepareCommit(writes);	const encoded = this.encodeCommit(prepared.seq, prepared.writes);	const reclamations = this.planReclamations(prepared.writes, encoded);	const sidecars = await Promise.all(		[...encoded.sidecars].map(async ([file, content]) => ({			file,			content,			path: await this.resolveFile(file, context),		})),	);	for (const sidecar of sidecars) {		const result = await this.fs.appendFile(sidecar.path, sidecar.content, context);		if (!result.ok) throw this.poison(errorFromFile(`append to ${sidecar.file}`, result.error));	}	if (this.fsync) {		for (const sidecar of sidecars) {			const result = await this.fs.flushFile(sidecar.path, context);			if (!result.ok) throw this.poison(errorFromFile(`flush of ${sidecar.file}`, result.error));		}	}	const marker = await this.fs.appendFile(this.mainPath, encoded.marker, context);	if (!marker.ok) throw this.poison(errorFromFile(`append to ${MAIN_FILE}`, marker.error));	const seq = prepared.apply();	this.adoptSidecarState(prepared.writes);	await this.reclaimSidecars(reclamations, context);	return seq;}
Chuẩn bị bằng MemoryStorage nghĩa là mọi kiểm tra ngữ nghĩa xảy ra trước khi chạm đĩa. Một lần append thất bại thì không biết đã ghi được bao nhiêu, nên storage bị đầu độc (poisoned): mọi lời gọi sau ném JsonlStoragePoisonedError cho tới khi mở lại (jsonl/storage.ts:837–845). Trên Session, lỗi đó lại làm Session thất bại như mọi lỗi storage khác.

fsync và độ bền

fsync mặc định là false (jsonl/storage.ts:77–80). Không có nó, JSONL bảo đảm nhất quán khi tiến trình crash, không bảo đảm sống qua mất điện hay lỗi kernel. Với { fsync: true }, mọi sidecar bị ảnh hưởng được flush trước khi append marker, để một marker sống sót không bao giờ đi trước dữ liệu sidecar của nó; main.jsonl không bị flush ở mỗi commit, nên commit cuối đã được xác nhận vẫn có thể mất (spec.md:5430–5441).

Thu hồi và khôi phục

Sau khi marker đã công bố state, JSONL dọn dẹp như một việc bảo trì có thể làm lại: một base của document current-only viết lại sidecar của nó chỉ còn base đó; nghỉ hưu một document current-only hay kết thúc một task thì xoá sidecar. Việc viết lại đi qua một file tạm .reclaim rồi đổi tên (jsonl/storage.ts:451–533). Khi mở, recover() đọc lại mọi thứ (jsonl/storage.ts:535–765):

  1. Cắt dòng ráchByte cuối file không phải xuống dòng nghĩa là lần ghi cuối bị cắt giữa chừng; file bị cắt về dòng hoàn chỉnh cuối cùng (jsonl/storage.ts:793–804).
  2. Kiểm markerseq trong main.jsonl phải tăng nghiêm ngặt; dòng hoàn chỉnh mà hỏng là lỗi JsonlCorruptionError, không phải dữ liệu bị bỏ qua.
  3. Xác nhận sidecarMỗi bản ghi sidecar chỉ có hiệu lực khi một marker tham chiếu nó; đuôi chưa được xác nhận bị cắt bỏ; thiếu dữ liệu đã được xác nhận là hỏng dữ liệu và việc mở thất bại (jsonl/storage.ts:767–784).
  4. Phát lại vào bộ nhớMỗi marker được áp vào MemoryStorage nội bộ với đúng seq của nó, rồi thu hồi những gì còn dở.

Adapter Node truyền một NodeExecutionEnv làm FileSystem (jsonl/node.ts:5–12); lõi /storage/jsonl chạy được ở nơi khác (Bun, Durable Object) với một FileSystem khác (README.md:790).

IndexedDB#

Backend mới nhất (thêm ở 1.4.0, CHANGELOG.md:16) chạy trong trình duyệt. Nó dựng các object store tương ứng một-một với bảng SQLite, kể cả record_ids và meta giữ nextId, nextSeq, với chỉ mục ghép cùng hình dạng (indexeddb.ts:335–375).

IndexedDbStorage: commit là một transaction readwrite trên mọi store; mintId bền ngay
pi/packages/durable/src/storage/indexeddb.tsdòng 420–452TypeScript
async commit(writes: readonly StorageWrite[], _context: Context): Promise<Seq> {	this.assertOpen();	const documentActions = this.prepareDocumentActions(writes);	return this.transaction(ALL_STORES, "readwrite", async (transaction) => {		const metaStore = transaction.objectStore(STORES.meta);		const metadata = await requestResult(metaStore.get(META_KEY) as IDBRequest<MetaRow>);		if (metadata === undefined) throw new Error("Durable IndexedDB metadata is missing");		const seq = seqFromNumber(metadata.nextSeq);		await this.checkGlobalIds(transaction, writes);		await this.checkDocumentActions(transaction, documentActions);		for (const write of writes) await this.applyTableWrite(transaction, write, seq);		await this.applyDocumentActions(transaction, documentActions, seq);		let nextId = metadata.nextId;		for (const write of writes) {			const id = writeId(write);			if (id !== undefined) nextId = Math.max(nextId, id + 1);		}		await requestResult(metaStore.put({ key: META_KEY, nextId, nextSeq: seq + 1 } satisfies MetaRow));		return seq;	});}async mintId<I extends Id<string>>(): Promise<I> {	this.assertOpen();	return this.transaction(STORES.meta, "readwrite", async (transaction) => {		const store = transaction.objectStore(STORES.meta);		const metadata = await requestResult(store.get(META_KEY) as IDBRequest<MetaRow>);		if (metadata === undefined) throw new Error("Durable IndexedDB metadata is missing");		if (!Number.isSafeInteger(metadata.nextId)) throw new Error("ID space is exhausted");		await requestResult(store.put({ ...metadata, nextId: metadata.nextId + 1 }));		return idFromNumber<I>(metadata.nextId);	});}
Khác SQLite và Memory, mintId() ở đây tự commit bộ đếm trong một transaction riêng, nên ID đã cấp không bao giờ được cấp lại kể cả khi transaction của Session bị huỷ.

Một chủ duy nhất phải giữ database tại mỗi thời điểm; README khuyên bọc vòng đời của nó trong một Web Lock thay vì mở ở nhiều tab hay worker cạnh tranh, và gọi navigator.storage.persist() khi cần giữ dữ liệu lâu (README.md:769–771). Ghi chú của lớp nói đúng điều đó: "One owner must serialize access to each database" (indexeddb.ts:377).

Bộ kiểm thử tuân thủ và benchmark#

Một backend tự viết không cần đọc hết đặc tả để biết mình đúng: /testing xuất một bộ kiểm thử tuân thủ (conformance suite) độc lập với test runner.

registerStorageConformance(): gắn các ca vào describe/it của Vitest hoặc Jest
pi/packages/durable/src/testing/runner.tsdòng 12–25TypeScript
/** Registers the runner-independent cases with a Vitest/Jest-compatible test runner. */export function registerStorageConformance(	runner: StorageConformanceRunner,	name: string,	withStorage: StorageConformanceProvider,): void {	const cases = createStorageConformance({		assertions: createExpectAssertions(runner.expect),		withStorage,	});	runner.describe(name, () => {		for (const testCase of cases) runner.it(testCase.name, testCase.run);	});}
withStorage phải gọi và chờ callback đúng một lần cho mỗi ca, ví dụ mở storage, chạy, rồi đóng trong finally (README.md:801–815).

Bộ kiểm thử có 25 ca, mỗi ca là một hợp đồng cụ thể, ví dụ ca đầu tiên kiểm ID 1 dành cho hội thoại gốc và việc tạo lại nó bị từ chối (storage-conformance.ts:98–105). Các ca khác phủ commit nguyên tử và rollback, giá trị đã tách, quét fork sâu qua mọi mức trần, quét hai chiều với cursor, document rewindable và incarnation nửa mở, bản chép document độc lập, base khi chuyển version, vòng đời create-plus-retire, chuỗi đặc biệt trong khoá chỉ mục, và từ chối mọi thao tác sau close(). Trong repo, bộ này chạy trên Memory, SQLite, JSONL, IndexedDB (qua fake-indexeddb), các biến thể "across reopen" của ba backend lưu bền, adapter Durable Object có prefix trên một storage giả lập bằng node:sqlite, và adapter đó trên Durable Object thật trong workerd (memory-storage.test.ts:8, sqlite-storage.test.ts:113–117, jsonl-storage.test.ts:123–125, indexeddb-storage.test.ts:106–108, sqlite-cloudflare.test.ts:10, test-workers/storage.test.ts:13; cấu hình workerd ở test-workers/vitest.config.ts:7). Câu "The semantic conformance suite covers memory, SQLite, and JSONL" của đặc tả (spec.md:5280) đã cũ so với code: IndexedDB và Durable Object cũng được phủ.

Cùng chỗ đó có bộ dữ liệu và kịch bản benchmark dùng lại được (storage-benchmark.ts:24–34): đọc (tra entry chính xác, quét trang 100 entry, quét task có lọc, tra địa chỉ document giữa nhiều document, đọc lịch sử trước và sau một base mới hơn, quét và tra head qua 8 tầng fork; storage-benchmark.ts:284) và ghi (commit một entry, 100 entry, một lô trộn entry/task/submission/document; :409). npm run bench:storage chạy chúng trên memory, SQLite và JSONL bằng vitest bench, kèm đo thời gian mở lại (storage.bench.ts:18; package.json:124); bench:storage:memory đo bộ nhớ ở quy mô 1k và 10k bản ghi.

Lỗi: ba quy tắc và code thực thi chúng#

README tóm cách xử lý lỗi bằng ba quy tắc, để host biết chuyện gì xảy ra mà không phải đọc code (README.md:821–825; bản đầy đủ ở spec.md:5282–5377). Phần này nối từng quy tắc với code thực thi nó.

Ba lớp lỗi của Session: ReadAfterWrite, StorageRequestError, SessionFailed
pi/packages/durable/src/errors.tsdòng 16–46TypeScript
/** A transaction read a table after its first table write. Read every required row before writing. */export class ReadAfterWrite extends Error {	constructor(method: string) {		super(`Tx.${method}() cannot read tables after the first table write`);		this.name = "ReadAfterWrite";	}}/** * A Storage read rejected an invalid request, such as an unknown conversation or a cursor from another scan, with no * durable effect. Unlike any other error a Storage throws, it fails only that read, not the Session; from `commit()` it is * fatal like any other. */export class StorageRequestError extends Error {	constructor(message: string) {		super(message);		this.name = "StorageRequestError";	}}/** * The Session failed, so nothing runs or commits any more. It fails when a Storage method throws, a commit cannot be * adopted, or the Harness's own bookkeeping throws (a scheduler commit, an internal commit listener, a registry whose * `snapshot()` throws). `cause` is that first error. Reopen the Session; reopening recovers from what was committed. */export class SessionFailed extends Error {	constructor(cause: unknown) {		super("Session failed after a storage error; close and reopen it", { cause });		this.name = "SessionFailed";	}}

Quy tắc 1: lỗi storage kết thúc Harness

Mọi thành phần của Session và Harness đọc ghi qua GuardedStorage, một lớp bọc quanh storage thật. Mọi lời gọi đi qua một hàm duy nhất:

GuardedStorage.#call(): lỗi đầu tiên làm Session thất bại, trừ hai ngoại lệ của lần đọc
pi/packages/durable/src/session/session.tsdòng 1091–1117TypeScript
/** * Run one backend call under the guard. `read` is a read's context; `commit()` and `mintId()` pass none, so nothing * exempts their errors: once a batch is admitted, whether it committed is unknown. A call still underway when another * fails the Session ends with `SessionFailed` too, whatever it returns: nothing it read or wrote is used. */async #call<T>(read: Context | undefined, run: () => Promise<T>): Promise<T> {	this.#assertHealthy();	if (this.#closing) throw new Error("Session is closed");	let result: T;	// An async wrapper, so a backend that throws synchronously is guarded too.	const call = (async () => run())();	this.#underway.add(call);	try {		result = await call;	} catch (error) {		this.#assertHealthy();		// An invalid read, or one its caller cancelled, says nothing about the Storage; it fails that call only.		const exempt =			read !== undefined && (error instanceof StorageRequestError || read.abortSignal?.aborted === true);		if (!exempt) this.#latch.fail(error);		throw error;	} finally {		this.#underway.delete(call);	}	this.#assertHealthy();	return result;}
Một lời gọi còn đang chạy khi lời gọi khác làm Session thất bại cũng kết thúc bằng SessionFailed, bất kể nó trả về gì, vì không thứ gì nó đọc hay ghi được dùng nữa.
SessionImpl.fail(): lỗi đầu tiên thắng, niêm phong, đóng, báo một lần
pi/packages/durable/src/session/session.tsdòng 217–230TypeScript
/** * Internal: fail the Session with `error`, the first wins, and report it once. Admission ends, and close listeners run * now; they read the error from `failure`. The Storage guard calls this; the Harness also calls it for a throw in its * scheduler's own commits. */fail(error: unknown, context?: Context): void {	if (this.#failure !== undefined) return;	this.#failure = { error };	this.#failed.reject(new SessionFailed(error));	// Seal first, so a report handler that calls back finds the Session failed. Closing runs the close listeners	// synchronously; a failing backend close is the caller's to see, and here nobody waits.	this.close(BACKGROUND_CONTEXT).catch(() => {});	this.report(error, { type: "session_failed" }, context);}
Niêm phong trước khi báo, để một handler onReport gọi ngược lại vào Harness thấy nó đã thất bại.

Từ lúc đó: lời gọi gặp lỗi nhận chính lỗi đó; mọi lời gọi sau và mọi lần chờ đang treo nhận SessionFailed với cause là lỗi đầu (util.ts:57–61); watch và luồng sự kiện kết thúc với session_failed (observation.ts:219–224); task đang chạy nhận tín hiệu huỷ; onReport nhận lỗi một lần với type: "session_failed"; Session tự đóng và closed trả { reason: "failed", error }. Không có gì được thử lại; storage tự thử lại lỗi tạm thời bên trong phương thức của nó. Phục hồi là mở lại: commit nguyên tử nên storage nhất quán, và gửi lại với cùng requestId tìm thấy submission đã được nhận nếu commit kịp thành công. Host khởi động lại theo mẫu:

README: khởi động lại khi Harness thất bại
pi/packages/durable/README.mddòng 827–833Markdown
`harness.closed` settles once the Harness has closed, by `close()` or after a storage error, so a host restarts on failure like this:```typescriptharness.closed.then((end) => {	if (end.reason === "failed") reopen(end.error);});```

Quy tắc này có từ 1.2.0, khi lớp lỗi StorageRejected cũ bị bỏ và mọi lỗi storage, kể cả lỗi đọc, trở thành lỗi chết (CHANGELOG.md:66). Lý do: một lỗi đọc giữa chừng một quyết định của scheduler để bộ nhớ ở trạng thái không biết đúng hay sai; dừng hẳn và dựng lại từ storage đơn giản và an toàn hơn đoán.

Quy tắc 2: lần đọc không hợp lệ chỉ hỏng lần đọc đó

Biến exempt trong #call() là toàn bộ quy tắc: chỉ lần đọc (có read context) được miễn, và chỉ khi lỗi là StorageRequestError hoặc người gọi đã tự huỷ. commit() và mintId() không truyền context đọc nên không bao giờ được miễn: một khi lô đã được nhận, không biết nó đã commit hay chưa (session.ts:973–980). Bản 1.4.0 sửa chính README cho khớp điểm này (CHANGELOG.md:41). Backend ném StorageRequestError cho cursor hỏng (scan.ts:25–26), hội thoại không tồn tại, hay lịch sử mà document không giữ (memory.ts:662–664). Một callback commit đã bắt một lần đọc thất bại thì không commit được nữa, vì #runCommit() kiểm sức khoẻ Session sau callback.

Quy tắc 3: code của bạn chỉ hỏng đơn vị của nó

Code ném lỗiHậu quảỞ đâu
Callback commit, conversationCreated, init, initial, migrate, checkpointWhen của documentCommit đó rollback và rejectsession.ts:729–733
Phase của taskTask kết thúc faulted, báo task_faultedchương task
execute() của tool, hoặc dựng môi trường thất bạiMô hình nhận kết quả lỗichương task
HookĐược báo và bỏ qua; riêng beforeTool ném lỗi thì chặn lời gọitool.ts:154–157
Section của promptGiữ chữ đang hiển thị, báo, vẫn gửi requestprompt.ts:37–43
Listener của host (commit, close, watch, subscriber)Được báo, các listener khác vẫn chạy; watch kết thúc listener_errorsession.ts:916–928
Listener nội bộ của Session, hoặc snapshot() của RegistryReaderLàm Session thất bại như lỗi storage, vì bộ nhớ hoặc khả năng lập lịch đã mấtspec.md:5325–5333
onReportBị bỏ, vì không còn chỗ nào để báoharness.ts:443–447

Mọi báo cáo tới onReport(error, info) kèm info.type nói chuyện gì đã xảy ra (session_failed, task_faulted, task_parked, migration_failed, task_outdated, task_error, extension_error, host_error, listener_error), để host phân biệt một Session hỏng (thường do nền tảng reset) với một task lỗi (README.md:218).

Cloudflare Durable Object#

Agent phần lớn thời gian ngồi chờ giữa các request, và "Keeping a machine running around the clock wastes compute and quickly gets expensive", như bài "Pi Durable on Cloudflare" (2026-10-16) mở đầu. Durable Object của Cloudflare ngủ đông (hibernate) khi rảnh và thức dậy khi có request, tin nhắn WebSocket hay alarm. Bài viết dựng một agent chat nhiều người dùng: mỗi session là một Durable Object có SQLite, định địa chỉ bằng session ID, vì một Harness gắn với đúng một storage. Phần này đi qua phía storage và mẫu host; cách task ngủ và lập lịch nằm ở chương task.

Adapter: commit là transaction của Durable Object

openDurableObjectSqliteStorage(): SqliteStorage trên SQLite của Durable Object
pi/packages/durable/src/storage/sqlite/cloudflare.tsdòng 6–15TypeScript
/** * Open durable storage on a SQLite-backed Durable Object, optionally under a table prefix. Calls given the same * storage object share an operation queue. */export function openDurableObjectSqliteStorage(	storage: DurableObjectSqliteStorage,	options: SqliteStorageOptions = {},): Promise<SqliteStorage> {	return SqliteStorage.open(new DurableObjectSqliteDatabase(storage), options);}
DurableObjectSqliteDatabase của Chord: một hàng đợi cho mỗi storage object, transaction của ctx.storage
pi/packages/chord/src/sqlite/cloudflare.tsdòng 56–65, 162–174TypeScript
const storageQueues = new WeakMap<DurableObjectSqliteStorage, SerialQueue>();function queueFor(storage: DurableObjectSqliteStorage): SerialQueue {	let queue = storageQueues.get(storage);	if (queue === undefined) {		queue = new SerialQueue();		storageQueues.set(storage, queue);	}	return queue;}	/** `ctx.storage.transaction()` commits when the callback resolves and rolls back when it rejects. */	transaction<T>(callback: (transaction: SqliteExecutor) => Promise<T>): Promise<T> {		return this.queue.hold(() =>			this.storage.transaction(async () => {				const transaction = new DurableObjectSqliteTransaction(this.storage);				try {					return await callback(transaction);				} finally {					transaction.active = false;				}			}),		);	}
API ctx.storage.sql của Cloudflare là đồng bộ; adapter bọc nó thành facade bất đồng bộ của Chord và xếp hàng các lời gọi phía sau một transaction đang chạy. Hàng đợi được dùng chung qua một WeakMap theo storage object, nên nhiều storage Pi (với prefix khác nhau) trên cùng Durable Object tự phối hợp với nhau (CHANGELOG.md:52). Số nguyên được bind như double, nên một bigint ngoài khoảng an toàn bị từ chối thay vì mất chính xác (chord/src/sqlite/cloudflare.ts:67–75).

Muốn dùng chung database với bảng của host, hãy tạo một adapter và dùng nó cho cả Pi lẫn mọi truy vấn của host; gọi thẳng ctx.storage sẽ đi vòng qua hàng đợi và có thể bị rollback cùng một transaction của Pi (README.md:779–789).

Mẫu host: blockConcurrencyWhile, onWake và alarm

Một Durable Object có thể bị trục xuất (evict) bất cứ lúc nào, và trong lúc ngủ không có timer nào chạy. Vì vậy Harness không tự giữ timer cho task đang ngủ khi host truyền onWake; nó báo cho host biết khi nào Session cần một tiến trình:

Wake: active khi có việc chạy được hoặc đang chạy; at là giấc ngủ sớm nhất
pi/packages/durable/src/harness/types.tsdòng 666–673TypeScript
/** * When a Session next needs a running process (`Harness.wake()`). `active`: some task can run now or is running, or a * reconcile pass such as open's recovery has not landed yet, so a process that dies must be woken again soon, for * example by a recovery alarm. `at`: the earliest sleep not yet due, * when there is one. Neither: nothing needs a process until new work is submitted. Tasks blocked on a missing or * unmigratable definition, `completing` tasks, and waits on live tasks do not count; the work they wait for does. */export type Wake = { readonly active: boolean; readonly at?: number };
Task bị chặn vì thiếu định nghĩa, task completing, và việc chờ trên task còn sống không được tính; công việc mà chúng chờ thì được tính.

Worker dùng cho test của package hiện thực đúng mẫu mà README mô tả (README.md:166–210):

Mở Harness trong blockConcurrencyWhile: alarm dự phòng trước, arm trước resume()
pi/packages/durable/test-workers/worker.tsdòng 49–82TypeScript
constructor(ctx: DurableObjectState, env: unknown) {	super(ctx, env);	this.#state = ctx;	void ctx.blockConcurrencyWhile(async () => {		// A fallback before anything can throw: an object whose open or recovery fails is started again.		const fallback = Date.now() + HEARTBEAT_MS;		const current = await ctx.storage.getAlarm();		if (current === null || current > fallback) await ctx.storage.setAlarm(fallback);		this.#offset = ctx.storage.kv.get<number>("offset") ?? 0;		const faux = fauxProvider();		faux.setResponses(Array.from({ length: 8 }, () => this.#step()));		const models = createModels();		models.setProvider(faux.provider);		const registry = createRegistry();		registry.install(defineExtension({ name: "test", tasks: [Reminder] }));		this.#harness = await Harness.open(			await openDurableObjectSqliteStorage(ctx.storage),			{				models,				registry,				now: () => this.now(),				onWake: (wake) => {					this.wakes.push(wake);					// A failed alarm write resets the object; it arms again when it next starts.					void this.arm(wake).catch(() => ctx.abort("Alarm write failed"));				},			},			context,		);		// Armed before `resume()` runs anything, as for any call that can start work.		await this.arm(this.#harness.wake());		this.#harness.resume();	});}
blockConcurrencyWhile bắt mọi sự kiện chờ tới khi khởi tạo xong; Cloudflare chạy constructor khi tạo lần đầu và lại chạy sau mỗi lần ngủ đông hay bị trục xuất. Alarm dự phòng được đặt trước mọi thứ có thể ném lỗi, để một object mở hỏng vẫn được khởi động lại. Ghi alarm thất bại thì ctx.abort() reset object; lần khởi động sau sẽ arm lại.
alarm(), arm() và submit(): arm trước mọi lời gọi có thể bắt đầu việc
pi/packages/durable/test-workers/worker.tsdòng 102–124TypeScript
async alarm(): Promise<void> {	await this.arm({ ...this.#harness.wake(), active: true });	this.#harness.resume();}/** The alarm a host arms for `wake`: the earliest sleep, or a recovery heartbeat while work can run. */async arm(wake: Wake): Promise<void> {	const heartbeat = wake.active ? Date.now() + HEARTBEAT_MS : undefined;	const sleep = wake.at === undefined ? undefined : wake.at - this.#offset;	const at = heartbeat === undefined || sleep === undefined ? (heartbeat ?? sleep) : Math.min(heartbeat, sleep);	if (at === undefined) await this.#state.storage.deleteAlarm();	else await this.#state.storage.setAlarm(at);}async root(): Promise<Conversation> {	return this.#harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } });}async submit(text: string, requestId: string): Promise<void> {	// Arm before admitting work: a process that dies after the commit is still woken.	await this.arm({ ...this.#harness.wake(), active: true });	await (await this.root()).submit({ type: "input", content: text, requestId }, context);}
Quy tắc trung tâm là ghi chú của README: hãy arm trước mọi lời gọi có thể bắt đầu việc (submit, compact, abortTask, abort, thay đổi registry), vì onWake chạy sau commit; một alarm sớm là vô hại, một alarm thiếu thì bỏ rơi công việc (README.md:195–196). Alarm của Durable Object chạy ít nhất một lần, nên handler phải idempotent; bài blog nhấn mạnh đúng điều này.

Trong lúc có việc, arm() đặt một nhịp tim (heartbeat) 60 giây, không bao giờ muộn hơn giấc ngủ sớm nhất: nếu object bị trục xuất giữa chừng, alarm đánh thức nó, Harness.open() đưa task đang running về pending, và resume() chạy tiếp. Ở đây requestId cũng là thứ cho exactly-once: bài blog lưu một biên nhận cùng commit tạo hội thoại, và client gửi lại các yêu cầu chưa được xác nhận với cùng request ID.

Timer và bộ nhớ trong Workers

nativeTimer: từ chối timer nền trong Cloudflare Workers
pi/packages/durable/src/timers.tsdòng 6–18TypeScript
/** * Cloudflare Workers: a pending timer keeps a Durable Object from hibernating or being evicted, even an unreferenced * one (`nodejs_compat` gives timers an `unref()` that changes nothing). */const inWorkers = typeof navigator !== "undefined" && navigator.userAgent === "Cloudflare-Workers";/** * The default `HarnessOptions.setTimer`: `setTimeout`, waiting out delays beyond its limit in steps. A background timer * is unreferenced, so it never keeps the process alive. It is declined in Cloudflare Workers and wherever timers cannot * be unreferenced. */export const nativeTimer: SetTimer = (callback, delayMs, options) => {	if (options.background && inWorkers) return undefined;
Một timer đang chờ giữ Durable Object không ngủ đông được, kể cả timer đã unref(). Timer nền chỉ để giải phóng bộ nhớ, nên chúng bị từ chối; Session khi đó dọn document rảnh ở cuối mỗi commit thay vì theo timer (session.ts:674–684). Đây là một bản sửa của 1.4.0 (CHANGELOG.md:40).

Giới hạn bộ nhớ 128 MB của một Durable Object cũng để lại dấu vết trong CHANGELOG: bộ mã hoá base64 dùng cho ảnh từng tốn khoảng 140 MB heap cho một ảnh 3 MiB và được viết lại ở 1.3.0 (CHANGELOG.md:57). Các test của test-workers/ chạy trong tích hợp Vitest của Cloudflare Workers, gồm cả bộ kiểm thử tuân thủ trên một Durable Object có prefix. Bài blog dẫn tới repo mẫu earendil-works/pi-durable-cloudflare, với một subagent tìm kiếm là một hội thoại do task tool sở hữu, khai báo replay-safe để khi object bị trục xuất giữa lời gọi thì chạy lại và dùng lại đúng hội thoại con cũ.

Ví dụ: một commit ghi những dòng nào#

Ghép mọi thứ lại với commit C1 của trace ở chương trước: người dùng gửi một input tới hội thoại gốc đang rảnh, inbox rỗng, không có requestId. Lần theo code:

  1. CallbackadmitSubmission() lấy draft pi.live (document 2) và, qua prepareBoundary(), đọc head marker (bảng) rồi lấy draft pi.inbox (document 3) mà không sửa. Rồi appendInput() cấp entry 7, createSubmission() cấp submission 8, startRun() cấp task 9 và gán live.run.
  2. Niêm phongDraft của document 2 sinh một phép set lên ["run"]; draft của document 3 không có phép nào nên không ghi gì. checkpointWhen của pi.live trả true (không có generation, không tool nào chạy), nên thay đổi được ghi thành base.
  3. Lô[entry 7, submission 8, task 9, document.change 2 (base)], đúng thứ tự entry, submission, task, document.

Trên SQLite, SqliteStorage.commit() chạy các câu lệnh sau trong một transaction:

Ví dụ minh hoạ: storage mới; commit 1 là root() (hội thoại 1, document 2–6), đây là commit 2.
Câu lệnh theo đúng thứ tự trong storage.ts; JSON rút gọn bằng "...".

SELECT next_id, next_seq FROM durable_metadata WHERE singleton = 1          → ('7', 2)
SELECT record_type FROM record_ids WHERE id = ?        (7, 8, 9: chưa thuộc bảng nào)
SELECT record FROM documents WHERE id = ?              (2: có, chưa nghỉ hưu)
SELECT id FROM documents WHERE kind = ? AND scope_kind = ? ... AND retired_at IS NULL

INSERT OR IGNORE INTO record_ids (id, record_type) VALUES (7, 'entry')
INSERT INTO entries (id, conversation_id, head, commit_seq, record)
  VALUES (7, 1, NULL, 2,
  '{"model":[{"role":"user","content":"Đọc notes.txt rồi tóm tắt","timestamp":1761104000000}],
    "kind":"pi.user","id":7,"conversationId":1}')

INSERT OR IGNORE INTO record_ids (id, record_type) VALUES (8, 'submission')
INSERT INTO submissions (id, conversation_id, request_id, status, record)
  VALUES (8, 1, NULL, 'placed',
  '{"conversationId":1,"type":"input","status":"placed","entry":7,"id":8}')  ON CONFLICT(id) DO UPDATE ...

INSERT OR IGNORE INTO record_ids (id, record_type) VALUES (9, 'task')
INSERT INTO tasks (id, conversation_id, kind, status, abort_requested, background, record)
  VALUES (9, 1, '"pi.generation"', 'pending', 0, 0,
  '{"id":9,"conversationId":1,"kind":"pi.generation","version":1,"input":{},
    "background":false,"abortRequested":false,
    "state":{"status":"pending","checkpoint":{"phase":"prepare","attempt":1}}}')  ON CONFLICT(id) DO UPDATE ...

SELECT record FROM documents WHERE id = ?              (2)
DELETE FROM document_revisions WHERE document_id = ?   (2: pi.live là latest, base cũ bị bỏ)
INSERT INTO document_revisions (document_id, seq, kind, version, content)
  VALUES (2, 2, 'base', 1, '{"run":{"taskId":9,"inputs":[8]}}')

UPDATE durable_metadata SET next_id = '10', next_seq = 3 WHERE singleton = 1
Bảng documents không đổi: hàng của document 2 vẫn là (2, '"pi.live"', 0, '""', 'conversation', 1, 1, NULL, ...). Cột kind của task và key_value của document là chuỗi JSON (có ngoặc kép), vì encodeIndexedString(). Trường requestId, trace, head, byTaskId vắng mặt vì copyJson bỏ giá trị undefined.

Trên JSONL, cùng lô đó thành ba dòng mới trong ba file, theo đúng giao thức sidecar trước, marker sau:

Ví dụ minh hoạ: cùng commit 2 trên JSONL (mỗi bản ghi là một dòng; ở đây xuống dòng cho dễ đọc).

1. append  task-9.jsonl   (task còn sống → sidecar, ordinal 0)
   {"format":1,"type":"record","seq":2,"ordinal":0,"payload":{"type":"task","value":{"id":9,
    "conversationId":1,"kind":"pi.generation","version":1,"input":{},"background":false,
    "abortRequested":false,"state":{"status":"pending","checkpoint":{"phase":"prepare","attempt":1}}}}}

2. append  doc-2.jsonl    (nội dung document → sidecar, ordinal 1)
   {"format":1,"type":"record","seq":2,"ordinal":1,"payload":{"type":"document","id":2,
    "content":{"version":1,"kind":"base","value":{"run":{"taskId":9,"inputs":[8]}}}}}

   với { fsync: true }: flush task-9.jsonl và doc-2.jsonl ở đây

3. append  main.jsonl     (marker: entry và submission nằm thẳng trong đó)
   {"format":1,"type":"commit","seq":2,"writes":[
     {"type":"entry","value":{"model":[...],"kind":"pi.user","id":7,"conversationId":1}},
     {"type":"submission","value":{"conversationId":1,"type":"input","status":"placed","entry":7,"id":8}},
     {"type":"task.sidecar","id":9,"ordinal":0},
     {"type":"document.change","id":2,"ordinal":1}]}

4. áp vào MemoryStorage nội bộ (lúc này commit mới được công bố)
5. thu hồi: doc-2.jsonl được viết lại chỉ còn dòng của bước 2
   (ghi doc-2.jsonl.reclaim rồi đổi tên), vì pi.live là latest và vừa có base mới
Nếu tiến trình chết giữa bước 2 và bước 3, lần mở sau thấy hai dòng sidecar không có marker nào xác nhận và cắt bỏ chúng: commit 2 coi như chưa từng xảy ra, và host cần gửi lại input, tốt nhất với cùng requestId. Nếu chết sau bước 3, commit đã bền; bước 5 chỉ là dọn dẹp và sẽ được làm lại lúc khôi phục.

Sau khi storage trả về Seq 2, adopt() đổi con trỏ tracker của document 2, và publication { seq: 2, changes: [entry 7, submission 8, task 9, document 2] } đến các listener: scheduler thấy task 9 pending và xếp lịch; mount view của hội thoại 1 (nếu có người xem) thêm một phép splice vào entries và một phép set dưới ["docs", "pi.live", "run"]; watchEvents() phát message_start, message_end, submission, run_start, turn_start. Không ai trong số đó thấy gì trước khi transaction SQL ở trên commit, hay trước khi marker nằm trong main.jsonl.

Quyết định thiết kế#

Một dòng commit cho mỗi Session, mở rộng bằng nhiều Session

Không có đồng thời bên trong một Session: commit, đọc nhiều bước và dọn cache xếp hàng trên một chuỗi promise. Nhờ đó các bất biến dễ giữ và dễ kiểm: ID tăng theo thứ tự commit, đọc-trước-ghi-sau có nghĩa, publication không bao giờ trộn hai commit, và backend không cần mutex riêng. Cái giá là một callback chậm chặn cả Session, nên đặc tả cấm mọi hiệu ứng ngoài bên trong transaction (spec.md:1697–1701). Khi cần mở rộng, mô hình là thêm Session, như một Durable Object cho mỗi session trong bài blog Cloudflare, không phải thêm luồng vào một Session.

Storage ngây thơ, Session thông minh

Storage tin Session về ngữ nghĩa và chỉ thực thi những gì cấu trúc dữ liệu đòi hỏi: nguyên tử, sở hữu ID, tạo-một-lần, một incarnation hiện tại cho mỗi địa chỉ, không có delta vượt version. Nó không chạy code định nghĩa nào: "Storage executes no definition code and never receives an unused complete candidate with a selected delta" (spec.md:1564–1567). Hệ quả là mỗi backend nhỏ (memory.ts 864 dòng, SQLite 957, JSONL 846, IndexedDB 1.208) và kiểm được bằng cùng một bộ tuân thủ; một backend mới là hiện thực mười sáu phương thức.

Bảng là bản ghi, document là cây Chord

Hội thoại, entry, task và submission là bản ghi phẳng: tạo một lần hoặc thay trọn, với vài cột được tách ra để làm chỉ mục và phần còn lại là JSON. Document là cây JSON thay đổi từng chút, nên được lưu bằng phép toán Chord, có lịch sử khi cần. Pi Durable không dịch phép toán Chord sang hàm JSON của SQL: SQLite lưu thẳng các bản ghi Chord (spec.md:5400–5402), và đó là một mục trong danh sách không làm (spec.md:5615). Đánh đổi: không truy vấn được nội dung document bằng SQL, đổi lại một đường ghi duy nhất cho mọi backend.

Lỗi storage là lỗi chết

Trước 1.2.0, một số lỗi storage có thể được bỏ qua. Từ 1.2.0, mọi lỗi (trừ hai ngoại lệ của lần đọc) làm Session thất bại và tự đóng; không có gì được thử lại. Lựa chọn này đổi một chút sẵn sàng lấy sự đơn giản của tính đúng: bộ nhớ không bao giờ phải đoán storage đang ở trạng thái nào, vì trạng thái đúng duy nhất là thứ đọc lại được sau khi mở lại, và commit nguyên tử bảo đảm nó nhất quán.

Entry chia sẻ, document sao chép

Fork không chép entry vì entry bất biến: một mức trần ID trên chuỗi tổ tiên là đủ. Document thì thay đổi, nên được chép (phía server, bằng lệnh document.copy) theo chính sách mà định nghĩa của nó khai báo. Đặc tả nhấn mạnh current, initial và asOf là ngữ nghĩa sản phẩm chứ không phải tối ưu hoá (spec.md:5537–5538).

Độ bền được nói rõ cho từng backend

Không backend nào giả vờ bền hơn thực tế: SQLite chạy WAL với synchronous = NORMAL (sống qua crash tiến trình, có thể mất commit cuối khi mất điện); JSONL mặc định chỉ nhất quán qua crash tiến trình, fsync là tuỳ chọn; Durable Object dùng transaction của chính nền tảng. Ứng dụng chọn mức bền bằng cách chọn backend và tuỳ chọn, không phải bằng một cờ chung.

Cạm bẫy và trường hợp biên#

  • Transaction dài và đọc trong commit. Await mô hình, mạng hay một commit lồng bên trong callback chặn cả Session; gọi snapshot() hay getTask() bên trong có thể treo vĩnh viễn khi trượt cache (spec.md:5521–5528).
  • Việc bắn rồi quên trên draft. Một tx.doc() không được await làm commit bị từ chối lúc niêm phong; sửa draft sau khi callback xong thì ném lỗi.
  • Đọc bảng sau khi ghi. Đọc mọi hàng cần thiết trước lần ghi bảng đầu tiên; hàm cần đọc bảng (như prepareBoundary()) phải chạy đầu commit.
  • Fork trong lúc sửa cha. Một commit vừa fork vừa sửa document current của cha bị từ chối; commit thay đổi của cha trước.
  • Không bao giờ checkpoint. checkpointWhen: () => false hợp với document ít đổi hay sống ngắn (như document của task); với document đổi dày nó làm chuỗi phát lại và dung lượng lưu của document current-only tăng không giới hạn khi document còn sống (spec.md:5534–5536).
  • Đổi tên kind. Kind và đường mount là giao thức đã lưu; migration không đổi được kind, phải chép tường minh rồi cho nghỉ hưu (spec.md:5539–5543).
  • Sửa revision bất biến. Giá trị từ snapshot(), state và watch có thể dùng chung object với tracker; không có đóng băng lúc chạy, nên sửa chúng làm hỏng cache một cách lặng lẽ. Cần sửa thì sao chép trước (spec.md:5544–5546).
  • Hai tiến trình, một storage. Không có khoá liên tiến trình; với IndexedDB, hai tab cùng mở một database là sai. Một Harness mới chỉ được mở sau khi close() của Harness cũ đã xong (spec.md:3820–3823).
  • Không quay lui được qua schema SQLite version 2. 1.4.0 tự migrate; 1.3.0 trở về trước từ chối database đó. Đổi prefix cũng không migrate dữ liệu cũ.
  • JSONL cho agent chạy lâu. Bộ nhớ lớn theo lịch sử, main.jsonl không được nén, và không có fsync thì commit đã xác nhận có thể mất khi mất điện (spec.md:5600–5602).
  • Kết quả lớn trong bản ghi task. Bản ghi task terminal còn được giữ để tra; kết quả lớn nên nằm trong entry hay document sống lâu, outcome chỉ giữ ID (spec.md:5560–5562).
  • Gọi thẳng ctx.storage trên Durable Object. Bỏ qua hàng đợi của adapter và có thể bị rollback cùng một transaction của Pi; dùng chung một DurableObjectSqliteDatabase.

Đọc code ở đâu#

Mọi đường dẫn tính từ packages/durable/, trừ khi ghi khác.

#FileĐọc để hiểu
1docs/spec.md §3–4, §10–11Document, transaction, hợp đồng storage, lỗi, backend
2src/types.tsStorage, StorageWrite, DocumentRecord, DocumentContent, Tx, Session
3src/session/session.ts#enqueue, #runCommit, #publish, cache document, snapshotAsOf, GuardedStorage
4src/session/transaction.tsQuy tắc bảng, appendEntry, createTask, doc/#acquire, settleSuccess, #assemble, adopt, planDocument
5src/documents.ts, src/ids.tsĐịa chỉ document, kiểm version, migration; nhãn ID
6src/session/forks.ts, observation.tsChép document khi fork; nguồn state và watch
7src/storage/scan.ts, memory.tsCursor; backend tham chiếu (đọc prepareCommit, visibleEntries, materializeDocument)
8sqlite/migrations.ts, sqlite/storage.ts, chord/src/sqlite/Lược đồ, commit, đọc lịch sử; facade và adapter
9jsonl/storage.tsSidecar, marker, fsync, thu hồi, khôi phục
10indexeddb.tsCùng hợp đồng trên object store
11src/errors.tsBa lớp lỗi; đọc cùng GuardedStorage
12testing/storage-conformance.ts, storage-benchmark.tsHợp đồng dưới dạng 25 ca; kịch bản benchmark
13test-workers/worker.ts, src/timers.tsMẫu host Durable Object, timer trong Workers
14test/examples/00 tới 05, 13, 17Session trần, document, fork, state, watch; khôi phục trên SQLite; JSONL để mở ra xem

Tóm tắt#

  • Session là một dòng commit (một chuỗi promise) cộng một cache tracker document; Harness kế thừa nó.
  • Một commit đi qua bảy bước: callback, niêm phong và chuẩn bị, gom lô, storage.commit(), đổi con trỏ tracker, publication, thả dòng; không gì được công bố trước khi storage trả về.
  • Transaction gom entry và hội thoại theo thứ tự gọi, task và submission theo bản ứng viên cuối, document theo kế hoạch; đọc bảng phải trước lần ghi bảng đầu tiên.
  • Document lưu base hoặc delta theo checkpointWhen của định nghĩa; latest được thu hồi, rewindable giữ lịch sử cho snapshotAsOf(); migration chạy lúc truy cập.
  • Fork chia sẻ entry qua mức trần trên chuỗi tổ tiên và chép document theo chính sách asOf, current, initial.
  • Năm backend (Memory, SQLite, JSONL, IndexedDB, Durable Object) hiện thực cùng giao diện nhỏ và cùng bộ kiểm thử tuân thủ 25 ca.
  • Lỗi storage làm Session thất bại và tự đóng; lần đọc không hợp lệ chỉ hỏng chính nó; code của người dùng chỉ hỏng đơn vị của nó. Phục hồi là mở lại.

19Durable: task, scheduler, generation và tool#

Chương này đi vào phần chạy việc của Pi Durable: task là gì, scheduler chạy nó ra sao, và ba task dựng sẵn (generation, tool, compaction) biến một input thành câu trả lời theo cách sống sót được khi tiến trình chết. Trung tâm của chương là một lần theo dấu: tiến trình bị SIGKILL giữa một lời gọi tool, rồi một tiến trình mới mở lại storage và gọi resume().

Vai trò: mọi thứ harness chạy là một task#

Trong coding agent, vòng lặp agent sống trong bộ nhớ: lượt nào đang chạy, tool nào đang chờ, mô hình đã trả về được bao nhiêu chữ, tất cả là biến của một tiến trình (chương pi-agent-core). Tiến trình chết thì phần đó mất; file session chỉ giữ những message đã xong. Pi Durable muốn điều ngược lại: tiến trình bị giết giữa chừng, tiến trình sau mở lại cùng storage và làm tiếp đúng bước đang dở. Muốn vậy, "đang làm dở cái gì" phải là dữ liệu đã commit chứ không phải biến.

Câu trả lời là task: một máy trạng thái bền (durable state machine) lưu checkpoint ở mỗi bước, nên một tiến trình khởi động lại sẽ tiếp tục từ checkpoint cuối (README.md:99). Bài công bố "Pi Durable" gói ý này trong một câu: "everything the harness runs is a task". Một câu trả lời không phải một vòng lặp mà là một chuỗi task dựng sẵn: pi.generation gọi mô hình, sở hữu các task pi.tool ứng với từng lời gọi tool, chờ chúng xong rồi trao lần chạy (run) cho generation kế tiếp; pi.compaction tóm tắt transcript khi cửa sổ ngữ cảnh (context window) sắp đầy (spec.md:3945–3951).

Task đến từ ba nguồn. Harness tạo task dựng sẵn khi xử lý submission. Extension mang định nghĩa task riêng trong trường tasks. Host hoặc tool tạo task trong một commit bằng tx.createTask(). Chỉ một thành phần chạy chúng: TaskScheduler trong packages/durable/src/harness/scheduler.ts. Scheduler nằm giữa hai chương lân cận: bên dưới là Session, dòng commit nguyên tử và storage (chương Durable: session, commit, document và storage); bên trên là Harness, conversation, submission và extension (chương Pi Durable: mô hình khái niệm). Chương này không giải thích lại hai tầng đó mà chỉ đường sang khi cần.

Ba task dựng sẵn có mặt trong mọi registry; extension không được dùng lại tên của chúng
pi/packages/durable/src/harness/registry.tsdòng 9–10, 18–30TypeScript
/** Built-in task definitions every registry holds; they are not an extension and cannot be removed or replaced. */export const BUILTIN_TASKS: readonly AnyTask[] = [GenerationTask, ToolTask, CompactionTask];	constructor(extensions: readonly Extension<Tool>[]) {		this.#extensions = extensions;		this.#byName = new Map(extensions.map((extension) => [extension.name, extension]));		const tasks = new Map<string, AnyTask>();		for (const task of BUILTIN_TASKS) tasks.set(task.definition.name, task);		for (const extension of extensions) {			for (const task of extension.tasks ?? []) {				const name = task.definition.name;				if (tasks.has(name)) throw new Error(`Task ${name} of extension ${extension.name} is already installed`);				tasks.set(name, task);			}		}		this.#tasks = tasks;
Harness.open() từ chối registry thiếu một trong ba task này (harness.ts:546–551). Task của extension được tra theo tên lưu trong bản ghi, nên tên task là một phần của định dạng lưu trữ.

Mô hình tư duy: phase, checkpoint và commit#

Hãy hình dung một task như một hàm dài được cắt thành nhiều đoạn; mỗi đoạn là một phase. Giữa hai đoạn, mọi thứ đoạn sau cần được ghi vào một checkpoint JSON và commit. Tiến trình chết giữa một phase thì phase đó chạy lại từ đầu với checkpoint cuối đã commit. Không gì khác sống sót: biến cục bộ, promise, timer đều mất. Vì thế mọi thiết kế trong chương quy về một câu hỏi: trước khi làm việc có hậu quả bên ngoài (gọi API, chạy lệnh), task đã ghi lại những gì?

            tx.createTask() trong một commit
                       │
                       ▼
    ┌───────────► pending ◄─────────── open(): running → pending, restarts + 1
    │                  │                  close(): running → pending, không đếm
    │   handover       │ reservation (commit của scheduler)
    │                  ▼
    └──────────── running ── phase handler gọi runtime.commit(...) ──┐
                   │ │ │                                               │
                   │ │ └─► running, checkpoint mới ── phase kế, cùng invocation
                   │ ├───► waiting { on, policy } ── mọi task trong on xong ──► running
                   │ └───► sleeping { until } ─── đồng hồ tới until ────────► running
                   │
                   └─► kết quả ── còn việc sở hữu đang sống ──► completing ──┐
                            │                                                  ▼
                            └───────────────────────────────────────────► terminal
                                  completed · failed · aborted · orphaned · faulted
Vòng đời một task. Checkpoint mới, waiting, sleeping và kết quả là do code của task commit qua runtime.commit(); reservation, đối soát lúc mở và đóng, handover, completing → terminal, fault và orphan là do scheduler ghi.

Từ vựng dùng trong phần còn lại của chương:

TừNghĩaỞ đâu
definitionĐối tượng TaskDefinition: tên, version, initial(), bảng phase, abort(), migrate(). Sống trong registry, không được lưu.types.ts:334
recordTaskRecord: bản ghi JSON trong storage, được thay nguyên khối ở mỗi commit.types.ts:690
invocationMột lần chạy trong bộ nhớ của task, ở chế độ run hoặc abort, với AbortController riêng. Không bền.scheduler.ts:105
reservationCommit của scheduler đổi pending/waiting/sleeping thành running và tạo invocation.scheduler.ts:1466
stepQuyết định đồng bộ trên dòng commit (mutation line) của Session trước mỗi phase: chạy tiếp, dừng, hay ghi faulted.scheduler.ts:1886
memoGiá trị JSON nhỏ trong record, người ghi đầu tiên thắng; sống qua checkpoint, mất khi task kết thúc.spec.md:2053
owner, việc sở hữu thườngMỗi task thuộc về conversation của nó hoặc một task khác. "Việc sở hữu thường" (ordinary owned work) là các task sống bên dưới, không tính nhánh background.spec.md:2316
ý định huỷ (cancellation intent)Abort mark của một owner còn sống, hoặc kết quả đang giữ khác completed. Nó lan xuống việc sở hữu thường.scheduler.ts:2398
blocked, parkedTask không chạy được: thiếu hoặc lệch definition, migration hỏng, hoặc bị đỗ vì crash quá nhiều lần. Lý do được suy ra, không lưu.scheduler.ts:448

Spec gọi khuôn mẫu cốt lõi là "bánh mì kẹp hiệu ứng" (effect sandwich): commit ý định, làm hiệu ứng bên ngoài, commit kết quả. Mở lại khi đang ở phase ý định nghĩa là hiệu ứng có thể đã xảy ra, và phase phải tự chọn: thử lại an toàn, hỏi trạng thái bên ngoài, hoặc ghi nhận là bị gián đoạn. Ba lựa chọn này sẽ xuất hiện lại nguyên văn trong task pi.tool.

Spec §5.2: khuôn mẫu ý định, hiệu ứng, kết quả
pi/packages/durable/docs/spec.mddòng 2040–2051Markdown
### 5.2 Effect sandwich```textcommit intent phaseperform external effectcommit outcome or next phase```Reopening in an intent phase means the effect may have happened. The phasehandler retries safely, polls an external handle, or records interruption.Deferred providers are represented by a durable phase containing their handleand next poll time.
Câu cuối quan trọng: phản hồi hoãn (deferred) của provider cũng chỉ là một phase bền chứa handle và thời điểm hỏi lại. Generation dùng đúng cách này cho phase poll.

Data model: TaskRecord, TaskState, TaskOutcome#

Ba kiểu trong packages/durable/src/types.ts mô tả toàn bộ trạng thái bền của một task. Bắt đầu từ kết quả, vì năm loại kết thúc nói nhiều về triết lý thiết kế:

TaskOutcome: năm cách một task kết thúc
pi/packages/durable/src/types.tsdòng 561–596TypeScript
/** Durable reason and optional result recorded when a task becomes terminal. */export type TaskOutcome<R> =	| {			readonly status: "completed";			readonly result: R;			readonly error?: never;			readonly reason?: never;	  }	/** Expected task or domain failure explicitly committed by its implementation. */	| {			readonly status: "failed";			readonly error: TaskOutcomeError;			readonly result?: R;			readonly reason?: never;	  }	/** Explicit cancellation handled by the task's abort protocol. */	| {			readonly status: "aborted";			readonly reason?: string;			readonly result?: R;			readonly error?: never;	  }	/** Task that cannot resume because its definition or migration is unavailable. */	| {			readonly status: "orphaned";			readonly reason: string;			readonly result?: never;			readonly error?: never;	  }	/** Runtime-detected contract failure, such as an uncaught throw or no durable progress. */	| {			readonly status: "faulted";			readonly error: TaskOutcomeError;			readonly result?: never;			readonly reason?: never;	  };
failed và aborted có thể mang result: một tool bị gián đoạn kết thúc failed nhưng vẫn kèm ID của entry kết quả. orphaned và faulted không bao giờ có result, vì code của task không quyết định chúng.
  • completed, failed: do chính task commit. failed là thất bại nghiệp vụ có chủ đích (mô hình trả lỗi, thẻ bị từ chối).
  • aborted: handler abort() của task đã chạy và tự quyết định kết quả, tức là task đã có cơ hội dọn dẹp.
  • orphaned: task bị huỷ nhưng không có code nào chạy được (definition thiếu hoặc không nhận bản ghi), nên hiệu ứng bên ngoài có thể còn sót. reason là lý do bị chặn (spec.md:2229–2234).
  • faulted: runtime phát hiện vi phạm hợp đồng: phase ném lỗi, hoặc phase trả về mà không tiến bộ bền (scheduler.ts:1826–1832).

Bản ghi bọc trạng thái trong các trường định danh và lập lịch. Hai nhóm trường đáng chú ý: nhóm bất biến (conversationId, owner, background, abandonOnRestart) dựng nên cây sở hữu, và nhóm phục hồi (abortRequested, abortReason, restarts) cho scheduler biết phải làm gì khi mở lại.

TaskRecord: memo chỉ tồn tại khi task còn chạy được
pi/packages/durable/src/types.tsdòng 689–704TypeScript
/** Complete replacement record for one durable task state machine. */export type TaskRecord<I, S, R> = TaskRecordBase<I, R> &	(		| {				readonly state: Extract<					TaskState<S, R>,					{ readonly status: "pending" | "running" | "waiting" | "sleeping" }				>;				/** Small first-writer-wins values retained while the task can run. */				readonly memos?: Readonly<Record<string, JsonValue>>;		  }		| {				readonly state: Extract<TaskState<S, R>, { readonly status: "completing" | "terminal" }>;				readonly memos?: never;		  }	);
Kiểu hợp (union) buộc memos biến mất khi task đã có kết quả; scheduler làm điều đó trong withState() (scheduler.ts:2473–2478).

Ví dụ minh hoạ dưới đây là bản ghi của một lời gọi bash mà tiến trình trước bị giết sau khi đã ghi ý định. Đây là thứ tiến trình mới thấy trong storage sau khi open() chạy xong (các số ID là bịa, cấu trúc theo đúng kiểu ở trên):

Ví dụ minh hoạ
{
  "id": 57, "conversationId": 1, "kind": "pi.tool", "version": 2,
  "input": { "kind": "model", "assistant": 55, "callId": "call_b" },
  "owner": 54,                      // task pi.generation đã tạo lời gọi này
  "background": false, "abortRequested": false,
  "startedAt": 1761120000000, "restarts": 1,
  "state": {
    "status": "pending",            // open() vừa hạ running xuống pending
    "checkpoint": { "phase": "execute",
                    "arguments": { "command": "npm test" }, "replay": "unsafe" }
  }
}
Không có memos: tool chưa ghi memo nào. Không có kết quả: bản ghi vẫn sống.

Mỗi checkpoint là một bản thay thế đầy đủ, không phải bản vá (spec.md:1927–1928). Tx công khai cũng không có thao tác ghi đè task: task chỉ đổi trạng thái của chính nó qua runtime, còn mọi chuyển trạng thái khác (reservation, đối soát khi mở lại, handover, fault, orphan) thuộc về scheduler, và abort mark thuộc về abortTask() (spec.md:1945–1948).

Định nghĩa một task: defineTask và TaskDefinition#

defineTask() chỉ là một hàm đồng nhất (identity) để TypeScript suy ra kiểu; nó gói definition vào một đối tượng Task. Hàm này nằm trong src/tasks.ts, không phải src/harness/define.ts (file kia chứa defineTool, hook, section, wrapTool).

defineTask(): một hàm đồng nhất có kiểu
pi/packages/durable/src/tasks.tsdòng 3–8TypeScript
/** Define an executable task. Register it in the registry so a Harness can run tasks of its kind. */export function defineTask<I, S extends { phase: string }, R, H extends object = object>(	definition: TaskDefinition<I, S, R, H>,): Task<I, S, R, H> {	return { definition };}

Bốn tham số kiểu: I là input (bất biến, lưu trong bản ghi), S là kiểu hợp các checkpoint, mỗi nhánh có trường phân biệt phase, R là kết quả, H là giao diện hook mà task cho extension cắm vào.

TaskDefinition: tên, version, checkpoint đầu, bảng phase, abort, migrate
pi/packages/durable/src/types.tsdòng 333–362TypeScript
/** Executable durable state machine definition, registered in the registry by `name`. */export type TaskDefinition<I, S extends { phase: string }, R, H extends object> = {	/** Registered task kind persisted in `TaskRecord.kind`. */	readonly name: string;	/** Definition version persisted with live input and checkpoints. */	readonly version: number;	/** First durable checkpoint for a newly created task. */	initial(input: I): S;	/** Exhaustive phase map; each handler receives the task narrowed to its phase. */	readonly phases: {		readonly [P in S["phase"]]: PhaseHandler<I, Extract<S, { phase: P }>, S, R, H>;	};	/** Runs in a fresh invocation after an abort mark and must commit a terminal outcome. */	abort(task: RunningTask<I, S, R>, runtime: TaskRuntime<I, S, R, H>, context: Context): Promise<void>;	/** Convert a record stored by any older supported version; runs at reservation. */	migrate?(		input: JsonValue,		checkpoint: JsonValue,		fromVersion: number,	): {		input: I;		checkpoint: S;	};	readonly hooks?: H;};/** Typed executable task definition. */export interface Task<I, S extends { phase: string }, R, H extends object> {	readonly definition: TaskDefinition<I, S, R, H>;}
Bảng phase là kiểu ánh xạ trên S["phase"]: thiếu một phase là lỗi biên dịch, và mỗi handler nhận task đã được thu hẹp về đúng nhánh checkpoint của nó.

Hợp đồng của một phase handler được ghi ngay trên kiểu của nó: handler phải commit một checkpoint khác hoặc một kết quả qua runtime.commit(); trả về mà không tiến bộ bền thì task bị faulted (types.ts:222–230). Quy tắc này chặn một lỗi khó thấy: phase chạy xong mà quên commit; nếu không có nó, step sẽ thấy checkpoint cũ và gọi lại đúng phase đó, lặp vô hạn. abort() chạy trong một invocation mới sau khi có abort mark và cũng phải commit một kết quả. migrate() chạy lúc reservation để nâng bản ghi của version cũ.

TaskOwnership, JoinPolicy và TaskOptions
pi/packages/durable/src/types.tsdòng 364–391TypeScript
/** Who owns a task: its conversation (a top-level task) or another task of the same conversation (a child task). */export type TaskOwnership = { readonly kind: "conversation" } | { readonly kind: "task"; readonly taskId: TaskId };/** How a waiting task treats the tasks it waits on (spec §5.5). */export type JoinPolicy = "failFast" | "allSettled";/** Creation options for a durable task. */export type TaskOptions = {	/** Omitted: inherit the creator's carrier. "root": start at the adapter root without a stored carrier. */	readonly trace?: TelemetryCarrier | "root";	/** Immutable creation-time links, attached to the spans of this task's first invocation. */	readonly links?: readonly TelemetryCarrier[];	/** Required: a task always names its owner (spec §5.5). */	readonly ownership: TaskOwnership;	/**	 * Default: the owner task's conversation, or the transaction's bound conversation; required for conversation-owned	 * tasks created by Session commits that are not bound to a conversation.	 */	readonly conversationId?: ConversationId;	/** Conversation-owned tasks only: excluded from ordinary idle waits, conversation aborts, and cascades. */	readonly background?: boolean;	/**	 * Its creator awaits it only in memory and does not resume after a restart, so it is pointless once the Harness that	 * created it is gone. When scheduling starts in a later Harness, the task, if still live, is abort-marked with reason	 * `restart`, and the mark cascades to its ordinary owned work, before any of it runs again.	 */	readonly abandonOnRestart?: boolean;};
ownership là bắt buộc: không có task nào vô chủ. background chỉ dành cho task do conversation sở hữu; abandonOnRestart sẽ được giải thích ở mục restart.

Ví dụ hoàn chỉnh nhỏ nhất trong repo là task đếm nhịp của test/examples/13-recovery.ts. Nó in "tick n", ngủ 50 ms, rồi sang nhịp sau, cho tới khi đếm đủ:

Một task thật: memo trước hiệu ứng, sleep giữa các phase, kết quả ở phase cuối
pi/packages/durable/test/examples/13-recovery.tsdòng 21–54TypeScript
const Ticker = defineTask<{ to: number }, { phase: "tick"; n: number }, string>({	name: "example.ticker",	version: 1,	initial: () => ({ phase: "tick", n: 1 }),	phases: {		tick: async (task, runtime, taskContext) => {			const n = task.state.checkpoint.n;			// Save the intent before the effect. A memo keeps the first value			// written under its name, so if the process dies after printing but			// before the next checkpoint is saved, the rerun sees the memo and			// does not print the same tick twice.			if ((await runtime.memo(`printed-${n}`, taskContext)) === undefined) {				await runtime.memo(`printed-${n}`, true, taskContext);				console.log(`tick ${n}`);			}			reachedTick(n);			// Save the outcome: the final result, or the next tick after a short			// sleep. The sleep is stored with the checkpoint, so the task holds no			// invocation meanwhile, and a Harness opened later continues it once			// its time has come.			if (n === task.input.to) {				await runtime.commit(					() => ({ status: "terminal", outcome: { status: "completed", result: `counted to ${n}` } }),					taskContext,				);				return;			}			await runtime.sleepUntil(runtime.now() + 50, { phase: "tick", n: n + 1 }, taskContext);		},	},	abort: async (_task, runtime, taskContext) => {		await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);	},});
Task chỉ có một phase tick nhưng checkpoint mang số n; mỗi lần sleepUntil() là một checkpoint mới, nên "đã tới nhịp nào" là dữ liệu bền.

Đọc ví dụ theo con mắt của một crash. Giả sử tiến trình chết ngay sau console.log("tick 2"), trước khi sleepUntil() commit. Checkpoint cuối đã lưu vẫn là { phase: "tick", n: 2 }, nên tiến trình sau chạy lại phase tick với n = 2. Nếu không có memo, "tick 2" sẽ in hai lần. Dòng 32 đọc memo printed-2: lần chạy đầu đã commit nó trước khi in, nên lần chạy lại thấy và bỏ qua. Ví dụ tự đóng Harness đúng ở thời điểm đó để mô phỏng crash (13-recovery.ts:60–62). Lưu ý giới hạn: memo đặt trước console.log nghĩa là nếu crash xảy ra giữa memo và lệnh in, "tick 2" sẽ không bao giờ được in. Memo chuyển "có thể in hai lần" thành "có thể không in lần nào"; chọn cái nào là việc của tác giả task.

TaskRuntime: những gì một phase được làm#

Mỗi phase handler nhận ba tham số: bản ghi đang chạy, một TaskRuntime, và một Chord Context (mang tín hiệu huỷ và span telemetry). Runtime là cánh cửa duy nhất của task ra thế giới bền.

TaskRuntime (phần 1): định danh, cấu hình, commit và memo
pi/packages/durable/src/types.tsdòng 245–282TypeScript
export interface TaskRuntime<I, S, R, H extends object> extends DocumentObserver, DocumentReader {	readonly taskId: TaskId<R>;	readonly conversationId: ConversationId;	/**	 * Aborted when the run is signalled by `abortTask()`, the Harness closes, or the invocation ends. Work still using it	 * after the invocation ended, such as a detached wait, is cancelled; it could not write anything anyway.	 */	readonly signal: AbortSignal;	/** Registry snapshot of the current phase; refreshed at every phase boundary. */	readonly registry: RegistrySnapshot;	/** The task's conversation's agent, resolved at most once per phase, at first use, and fixed for the phase. */	agent(context: Context): Promise<Agent>;	/** `HarnessOptions.settings`, resolved at each access. */	readonly settings: Settings;	/** `HarnessOptions.models`, resolved at each access. */	readonly models: Models;	/** Calls `HarnessOptions.env` for the task's conversation; rejects with its error. */	env(context: Context): Promise<ExecutionEnv | undefined>;	/** Handlers of this task's name from the extensions its conversation selects, in extension order. */	readonly hooks: HookRunner<H>;	/**	 * Commit on the Session line after rereading the task. Rejects when the task is terminal, the invocation ended, the	 * Harness is closing, or, in a run invocation, the task carries an abort mark. A returned state replaces the task's	 * state in the same commit; returning nothing leaves it unchanged. `tx.createTask()` defaults to the task's	 * conversation. Inside the callback read only through `tx`: `snapshot()`, `documentState()`, `watchDoc()`, `getTask()`, and the other Session and runtime reads may queue on the line the callback holds, and then never resolve.	 */	commit(		change: (			tx: Tx,			current: RunningTask<I, S, R>,		) => NextTaskState<S, R> | undefined | Promise<NextTaskState<S, R> | undefined>,		context: Context,	): Promise<void>;	/** Read a durable memo of this task. */	memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;	/** Store `candidate` unless a memo already exists; return the durable winner. */	memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
agent() được phân giải nhiều nhất một lần mỗi phase và cố định trong phase đó; settings và models được đọc lại ở mỗi lần truy cập. Vì vậy đổi model giữa chừng chỉ có hiệu lực từ request sau: agent của phase prepare đã cố định, và generation còn ghim model vào checkpoint request (xem mục generation).
TaskRuntime (phần 2): chờ, huỷ việc con, đọc kết quả, timer và sleep
pi/packages/durable/src/types.tsdòng 283–298, 312–317, 324–330TypeScript
/** Committed task record. */getTask<T>(id: TaskId<T>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, T> | undefined>;/** Resolve with the task's terminal receipt; rejects when the invocation ends. */waitForTask<T>(id: TaskId<T>, context: Context): Promise<SettledTask<T>>;/** * Abort a task this task owns, as `Harness.abortTask()` does, and resolve once it is terminal. Rejects for a task * another owns; resolves at once for a terminal one. */abortOwned(id: TaskId, context: Context): Promise<void>;/** * Live tasks this task owns directly, in ID order: also those an earlier invocation created, before a restart. Tasks * in conversations it owns are not included. Copies of the committed records. */ownedTasks(context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue>[]>;/** Outcomes of terminal tasks, in order; rejects when one is missing or not terminal. Used after a wait. */outcomes<T>(ids: readonly TaskId<T>[], context: Context): Promise<TaskOutcome<T>[]>;/** * Call `callback` once after about `delayMs` on the Harness's timer (`HarnessOptions.setTimer`), for pacing work * inside a phase, such as throttled progress commits. Not durable: the invocation's end disarms it, and after its end * nothing is armed. Returns a function that disarms it. Wait for time between phases with `sleepUntil()`. */setTimer(callback: () => void, delayMs: number): () => void;/** * Commit a sleep: the task holds no invocation until the Harness clock reaches `until`, then resumes at `checkpoint`. * Resolves once the sleep is stored, not when the time comes; the phase returns right after. Returning * `{ status: "sleeping", until, checkpoint }` from `commit()` is the same sleep, for one that must commit together * with other writes. */sleepUntil(until: number, checkpoint: S, context: Context): Promise<void>;
waitForTask() chỉ là chờ trong bộ nhớ, kết thúc cùng invocation. Muốn chờ bền thì commit trạng thái waiting; muốn chờ thời gian bền thì sleepUntil().

commit(): đọc lại, kiểm tra cổng, rồi thay trạng thái

Bên trong, runtime.commit() là một commit trên dòng commit (xem chương storage) đi qua hàm #gated() của scheduler:

#gated(): mọi commit của runtime đọc lại task trên dòng commit trước khi chạy callback
pi/packages/durable/src/harness/scheduler.tsdòng 2327–2349TypeScript
#gated<T>(	invocation: Invocation,	change: (tx: Transaction, current: ErasedRunningTask) => T | Promise<T>,	context: Context,): Promise<T> {	if (invocation.ended) return Promise.reject(endedError(invocation));	return this.#session.commitWith(		async (tx) => {			if (invocation.ended) throw endedError(invocation);			if (this.#closing) throw closedError(this.#session);			const found = this.#live.get(invocation.taskId);			if (found === undefined) throw new Error(`Task ${invocation.taskId} is terminal`);			if (found.state.status !== "running") throw new Error(`Task ${invocation.taskId} is ${found.state.status}`);			const current = found as ErasedRunningTask;			if (invocation.mode === "run" && current.abortRequested) {				throw new Error(`Task ${invocation.taskId} has a durable abort mark`);			}			return change(tx, current);		},		context,		{ conversationId: invocation.conversationId, taskId: invocation.taskId },	);}
Phạm vi giao dịch (dòng 2347) gắn conversation và task: tx.createTask() mặc định vào conversation của task, và mọi entry nối thêm được ghi byTaskId.

Bốn điều kiện từ chối ở dòng 2335–2343 là hàng rào an toàn của cả hệ thống. Invocation đã kết thúc (ví dụ một promise bị bỏ rơi cố commit muộn) thì không được ghi. Harness đang đóng thì không được ghi. Task không còn running thì không được ghi. Và một invocation ở chế độ run không được commit khi bản ghi đã có abort mark: từ lúc mark được commit, chỉ handler abort() mới được quyết định. Khi callback trả về một trạng thái, #commitState() thay trạng thái trong cùng commit đó:

#commitState(): kiểm tra wait và sleep, giữ kết quả thành completing, xoá bộ đếm restart
pi/packages/durable/src/harness/scheduler.tsdòng 1942–1963TypeScript
async #commitState(	tx: Transaction,	invocation: Invocation,	current: ErasedRunningTask,	next: NextTaskState<Checkpoint, JsonValue>,): Promise<void> {	if (next.status === "waiting") await this.#validateWait(tx, invocation, current, next.on, next.policy);	if (next.status === "sleeping") validateSleep(invocation, current.id, next.until);	if (next.status === "terminal") {		const overlay = overlayOf(tx);		await this.#loadScopes(false);		for (const record of overlay.tasks.values()) await this.#loadChain(parentOf(record), overlay);		if (this.#hasOwnedLive(current.id, overlay)) {			tx.setTask(withState(progressed(current), { status: "completing", outcome: next.outcome }));			return;		}	}	// A state the task committed that differs from its current one is progress: its restart count starts over. The	// same checkpoint committed again, for example as intent before an effect, is not.	const same = jsonEqual(current.state as unknown as JsonValue, next as unknown as JsonValue);	tx.setTask(withState(same ? current : progressed(current), next));}
Dòng 1961: commit lại đúng checkpoint hiện tại (như một intent ghi trước hiệu ứng) không tính là tiến bộ; mọi trạng thái khác xoá restarts qua progressed().

Vì checkpoint, entry, document và task con nằm trong cùng một commit, "đã gọi model và nhận được câu trả lời" với "đã nối câu trả lời vào transcript" không bao giờ lệch nhau: hoặc cả hai được lưu, hoặc không cái nào (spec.md:1929–1937).

memo(), sleepUntil(), setTimer()

memo() và sleepUntil() trong runtime
pi/packages/durable/src/harness/scheduler.tsdòng 2152–2173TypeScript
memo: ((name: string, ...rest: readonly unknown[]) => {	if (rest.length === 1) {		return this.#read(invocation, async () => memoOf(this.#live.get(invocation.taskId), name));	}	const candidate = rest[0] as JsonValue;	return this.#gated(		invocation,		(tx, current) => {			const winner = memoOf(current, name);			if (winner !== undefined) return winner;			tx.setTask({ ...current, memos: { ...current.memos, [name]: candidate } } as AnyTaskRecord);			return candidate;		},		rest[1] as Context,	);}) as ErasedRuntime["memo"],sleepUntil: (until, checkpoint, context) =>	this.#gated(		invocation,		(tx, current) => this.#commitState(tx, invocation, current, { status: "sleeping", checkpoint, until }),		context,	),
Ghi memo là một commit có cổng; nếu tên đã có giá trị thì trả về giá trị đó, nên hai ứng viên cạnh tranh luôn nhận cùng một người thắng (test ở harness-tasks.test.ts:208–211).

setTimer() thì ngược lại, không bền chút nào: nó chỉ để nhịp công việc bên trong một phase, như việc commit phần trả lời đang stream mỗi 100 ms. Invocation kết thúc là mọi timer của nó bị gỡ, và gọi sau khi kết thúc thì không đặt gì (scheduler.ts:2290–2302).

Tạo task con, đọc kết quả, môi trường

TaskRuntime không có createTask(): task con được tạo bằng tx.createTask() bên trong runtime.commit(), cùng commit với checkpoint (tool thì có api.createTask(), xem mục tool). Transaction kiểm tra quan hệ sở hữu ngay khi tạo:

tx.createTask(): task con sống trong conversation của owner, không được là background, bắt đầu pending
pi/packages/durable/src/session/transaction.tsdòng 408–420, 425–447TypeScript
const ownership = options.ownership;let owner: AnyTaskRecord | undefined;if (ownership.kind === "task") {	// Validated again against the owner's final candidate during assembly.	owner = await this.#currentTask(ownership.taskId);	this.#assertOpen();	if (owner === undefined) throw new Error(`Task owner ${ownership.taskId} does not exist`);	if (options.background === true) throw new TypeError("A child task cannot be background");	if (options.conversationId !== undefined && options.conversationId !== owner.conversationId) {		throw new Error(`A child task lives in its owner's conversation ${owner.conversationId}`);	}}const conversationId = owner?.conversationId ?? options.conversationId ?? this.#scope.conversationId;const checkpoint = definition.initial(input);const id = await this.#host.storage.mintId<TaskId<R>>();this.#assertOpen();const record = copyJson(	{		id,		conversationId,		kind: definition.name,		version: definition.version,		input,		trace:			options.trace === "root"				? undefined				: (options.trace ?? this.#context.telemetry.propagation?.inject(this.#context.telemetry)),		links: options.links,		...(owner === undefined ? {} : { owner: owner.id }),		background: options.background ?? false,		abortRequested: false,		...(options.abandonOnRestart === true ? { abandonOnRestart: true } : {}),		state: { status: "pending", checkpoint },	},	TABLE_JSON_COPY_OPTIONS,) as unknown as AnyTaskRecord;
Dòng 425: checkpoint đầu do definition.initial(input) tính ngay khi tạo, nên bản ghi mới đã đủ để chạy mà không cần thêm thông tin nào.

Sau một lần chờ, task đọc kết quả của các task nó chờ bằng runtime.outcomes(ids), hàm này từ chối nếu có task chưa terminal (scheduler.ts:2220–2231). runtime.env() gọi HarnessOptions.env cho conversation của task, với cwd hiện tại của agent (harness.ts:280–285). runtime.conversation(id) trả về một handle gắn với invocation: mọi thao tác của nó bị từ chối sau khi invocation kết thúc, nhưng submission đã nhận vẫn bền (harness.ts:498–533). runtime.context() đọc transcript và context của mô hình; scheduler giữ dải entry đã đọc của mỗi conversation trong bộ nhớ để lần đọc sau chỉ quét phần mới (scheduler.ts:2258–2285).

Cây sở hữu: task con, background, abort từ dưới lên#

Task và conversation cùng tạo thành một cây sở hữu duy nhất. Mỗi task khai báo owner khi được tạo: conversation của nó (task cấp cao nhất) hoặc một task khác (task con, luôn sống trong conversation của owner). Mỗi conversation hoặc vô chủ (ownerless) hoặc thuộc về một task, và đó là cách duy nhất cây vượt qua ranh giới conversation. Cạnh owner bất biến (spec.md:2308–2314). Đừng nhầm với quan hệ parent của fork: fork quyết định entry nào được thừa kế, còn owner quyết định abort và idle lan tới đâu (spec.md:247–250).

 c1 (root, ownerless)
 ├── G  pi.generation            owner: conversation c1
 │   ├── T1 pi.tool "subagent"    owner: G
 │   │   └── c2 (owner: T1)       conversation của subagent
 │   │       └── G2 pi.generation owner: conversation c2  → vẫn là việc sở hữu thường của T1
 │   └── T2 pi.tool "bash"        owner: G
 └── B  app.anchor  background   owner: conversation c1, ranh giới
     └── c3 (owner: B)
         └── G3 pi.generation    không thuộc việc sở hữu thường của c1
Việc sở hữu thường của G gồm T1, T2 và G2 (đi qua conversation c2 mà T1 sở hữu). B là background nên mọi thứ dưới B bị loại khỏi phép duyệt thường.

Hai quy tắc suy ra từ cây (spec.md:2322–2354):

  1. Abort chảy xuốngÝ định huỷ của một owner còn sống (abort mark, hoặc kết quả đang giữ khác completed) đánh dấu abort cho toàn bộ việc sở hữu thường của nó. Task background là ranh giới, trừ khi bị abort trực tiếp hoặc qua Conversation.abort(context, { background: true }).
  2. Việc con không sống lâu hơn lúc owner kết thúcMột task commit kết quả trong khi việc sở hữu thường còn sống thì được lưu thành completing; scheduler chỉ ghi terminal khi bên dưới không còn gì sống. Đây là dòng 1954–1956 của #commitState() ở trên.

Chờ: waiting với on và policy

Một phase có thể commit { status: "waiting", checkpoint, on, policy }. Task không giữ invocation nào trong lúc chờ và tiếp tục ở checkpoint khi mọi task trong on đã terminal. allSettled chờ tất cả; failFast thêm một việc: task đầu tiên trong on kết thúc (hoặc đang giữ) với kết quả khác completed làm mọi task còn sống khác trong on bị đánh dấu abort. Bản thân task đang chờ không bị đánh dấu; nó tỉnh dậy khi tất cả đã xong và tự đọc kết quả bằng runtime.outcomes() (spec.md:2364–2383). Scheduler kiểm tra lời chờ trước khi chấp nhận:

#validateWait(): task phải tồn tại, failFast chỉ trên task mình sở hữu, không được tạo chu trình chờ
pi/packages/durable/src/harness/scheduler.tsdòng 1973–2000TypeScript
async #validateWait(	tx: Transaction,	invocation: Invocation,	current: ErasedRunningTask,	on: readonly TaskId[],	policy: string,): Promise<void> {	if (invocation.mode === "abort") throw new Error(`Abort handler of task ${current.id} cannot wait`);	const overlay = overlayOf(tx);	const targets: TaskId[] = [];	for (const id of on) {		const member = overlay.tasks.get(id) ?? this.#live.get(id) ?? (await this.#storage.task(id, this.#context));		if (member === undefined) throw new Error(`Task ${id} does not exist`);		if (policy === "failFast" && member.owner !== current.id) {			throw new Error(`Task ${current.id} can wait failFast only on tasks it owns; ${id} is not one`);		}		// A live child already holds the waiter (task-owned tasks are never background): waiting on it adds no edge. A		// terminal task has no edges.		if (member.state.status !== "terminal" && member.owner !== current.id) targets.push(id);	}	if (targets.length === 0) return;	const cycle = await this.#waitCycle(current.id, targets, overlay);	if (cycle !== undefined) {		throw new Error(			`Task ${current.id} cannot wait on ${cycle[0]}: ${[current.id, ...cycle].join(" -> ")} is a wait cycle`,		);	}}
Chờ một task con đang sống không thêm cạnh mới (con vốn đã giữ cha ở completing), nên chỉ những task không thuộc quyền sở hữu mới đi qua phép tìm chu trình #waitCycle(). Handler abort() không được chờ.

Kết quả bị giữ: completing

Sau mỗi commit có liên quan, một pass đối soát (reconcile; "pass" là một lần quét của scheduler, khác với lượt của mô hình) kiểm tra các task completing. Hàm #finalize() duyệt theo vòng: hoàn tất một task có thể giải phóng owner ngay phía trên nó, owner đó vào danh sách của vòng sau.

#finalize(): biến completing thành terminal khi bên dưới không còn việc sống
pi/packages/durable/src/harness/scheduler.tsdòng 856–879TypeScript
let round = [...this.#finalizeChecks];this.#finalizeChecks.clear();const done = new Set<TaskId>();const overlay = overlayOf(tx);while (round.length > 0) {	const next = new Set<TaskId>();	for (const id of round) {		const record = overlay.tasks.get(id) ?? this.#live.get(id);		if (record?.state.status !== "completing" || done.has(id)) continue;		if (this.#hasOwnedLive(id, overlay, done)) continue;		const outcome = record.state.outcome;		const terminal = withState(record, { status: "terminal", outcome });		tx.setTask(terminal);		(overlay.tasks as Map<TaskId, AnyTaskRecord>).set(id, terminal);		done.add(id);		// REMINDER: only the scheduler writes `faulted` and `orphaned` (spec §5.4); their cleanup waits for this commit.		if (outcome.status === "faulted" || outcome.status === "orphaned") {			await this.#settleOutcome(tx, record, outcome);		}		const above = this.#nearestLiveAbove(record, overlay);		if (above?.state.status === "completing") next.add(above.id);	}	round = [...next];}
Dòng 872–874: kết quả do scheduler tự ghi (faulted, orphaned) chỉ được dọn dẹp phía Harness (kết thúc run, đánh dấu slot tool) trong commit cuối này, nên một generation bị fault vẫn giữ pi.live.run cho tới khi các tool của nó xong.

Lan truyền abort và thứ tự từ dưới lên

abortTask() chỉ commit abort mark rồi chờ invocation đang chạy của task kết thúc (scheduler.ts:545–572). Việc đánh dấu cả cây con nằm ở một commit riêng của pass đối soát:

#reconcile(): suy abort mark từ ý định huỷ, áp failFast, rút input đang xếp hàng, hoàn tất completing
pi/packages/durable/src/harness/scheduler.tsdòng 785–819TypeScript
await this.#session.commitWith(async (tx) => {	if (this.#closing) return;	const queued = await this.#loadScopes(cascade);	// A cascade from an abandoned owner passes its `restart` reason on; any other intent marks, or upgrades a	// `restart` mark, as a request. Collected first, so a request wins over a `restart` mark of the same pass.	const marks = new Map<TaskId, { readonly record: AnyTaskRecord; readonly reason: "restart" | undefined }>();	const mark = (record: AnyTaskRecord, reason: "restart" | undefined): void => {		const staged = marks.get(record.id);		if (staged !== undefined && staged.reason === undefined) return;		if (record.abortRequested && (record.abortReason === undefined || reason !== undefined)) return;		marks.set(record.id, { record, reason });	};	// Every live foreground task below a live owner with cancellation intent, which is its nearest one: a walk down	// from each owner with intent does not enter another, whose own walk covers what is below it. Derived again on	// every pass, as loading edges can reveal a cancelled owner.	for (const id of this.#intent) {		const owner = this.#live.get(id)!;		// Only an owner abandoned after a restart, and nothing else, passes its reason on.		const restart = owner.abortReason === "restart" && !failedOutcome(owner);		for (const { node, live } of this.#walkDown(id, { enter: (below) => !this.#intent.has(below) })) {			if (live !== undefined && !node.background) mark(live, restart ? "restart" : undefined);		}	}	for (const id of checks) {		const waiter = this.#live.get(id);		if (waiter?.state.status !== "waiting" || !(await this.#anyFailed(waiter.state.on))) continue;		// Every other live task: the failed one keeps its own outcome.		for (const member of waiter.state.on) {			const record = this.#live.get(member);			if (record !== undefined && !failedOutcome(record)) mark(record, undefined);		}	}	for (const { record, reason } of marks.values()) tx.setTask(withAbortMark(record, reason));	for (const id of queued) if (this.#belowCancelled({ conversation: id })) await this.#withdrawInputs(tx, id);	await this.#finalize(tx);
Mark được suy ra trong commit riêng thay vì trong commit của owner, nên crash ở giữa không làm mất gì: lần mở sau chạy lại đúng phép suy luận này từ bản ghi bền (spec.md:2156–2159).

Thứ tự từ dưới lên đến từ một dòng điều kiện: task có abort mark coi việc sở hữu thường còn sống của nó là thứ đang chờ, nên invocation abort của nó chỉ được reserve khi bên dưới đã terminal. Nhờ vậy handler abort() luôn thấy kết quả cuối cùng của các task con, và mỗi task tự hoàn tác hiệu ứng của chính nó.

#waitingOn(): task bị đánh dấu abort chờ việc con, task waiting chờ phần còn sống của on
pi/packages/durable/src/harness/scheduler.tsdòng 1576–1586TypeScript
/** * Live tasks a task waits for before its next invocation: its live ordinary owned work when abort-marked, since * abort runs bottom-up, otherwise the live part of the `on` of a wait. */#waitingOn(record: AnyTaskRecord, first = false): readonly TaskId[] {	if (record.abortRequested) return this.#ownedLive(record.id, { first });	if (record.state.status !== "waiting") return [];	if (!first) return record.state.on.filter((id) => this.#live.has(id));	const live = record.state.on.find((id) => this.#live.has(id));	return live === undefined ? [] : [live];}

Hệ quả cần nhớ: handler abort() không tạo được task con (task của nó đã mang abort mark, nên createTask với owner đó bị từ chối). Muốn bù trừ (compensate) ở mức cha thì làm ngay trong handler, hoặc tạo task background do conversation sở hữu và chờ bằng runtime.waitForTask() (spec.md:2385–2394).

Scheduler: open, reserve, run, step#

Mỗi Harness có một TaskScheduler, tạo trong constructor của HarnessImpl với các callback của Harness: phân giải agent, settings, môi trường, đồng hồ, onWake, timer, và settleOutcome để dọn dẹp phía Harness khi scheduler tự ghi kết quả (harness.ts:228–249). Scheduler không biết gì về run hay loại task; tri thức đó nằm trong callback (spec.md:2273). Bất biến trung tâm được ghi ngay ở đầu lớp:

Bất biến: mọi chuyển trạng thái do một callback tuần tự trên dòng commit quyết định và ghi
pi/packages/durable/src/harness/scheduler.tsdòng 231–252TypeScript
/** * Durable task scheduler of one Harness. * * `#live` mirrors every committed non-terminal task record: pending, running, waiting, sleeping, and completing. The * synchronous commit listener updates it on the Session line, so code running on the line reads exactly the committed * state from it. * * Tasks and conversations form one ownership tree (spec §5.5): a task's parent is its owner task, or its conversation; * a conversation's parent is its owner task, if any. Walks up that tree decide what reaches one task: a cancelling * owner, a scope. `#below` lists the tree downward around live work, so what lies below one node is a walk down from * it: a task's live ordinary owned work, which holds its outcome as `completing` and delays its abort handler, a * scope's tasks, a cascade's reach. No pass visits every live task; the sets below hold each pass's candidates. * * The ownership indexes are bounded by live work: an owner edge and an ended task's ownership fields are kept only while * something is listed below them in `#below`, so only the chains above live tasks are known, whole. Work that appears * later below a dropped node finds it unknown and loads its chain from storage again. * * Invariant: every task transition is decided and written by one callback serialized on the Session line. That covers * reservation, marks, runtime commits, finalization, and the synchronous step before each phase, which applies the * precedence rules and writes a fault or handover. Handlers and joins run off the line. An invocation ends inside the * step that decides its end, so a runtime commit it queued either lands before that decision or is rejected. */
#live là bản sao trong bộ nhớ của mọi bản ghi chưa terminal, cập nhật đồng bộ bởi listener commit #observe() (scheduler.ts:624), nên code chạy trên dòng commit đọc đúng trạng thái đã commit.

open(): đối soát sau crash

open(): nạp task sống, hạ running xuống pending và đếm restart; không chạy gì
pi/packages/durable/src/harness/scheduler.tsdòng 374–405, 413–416TypeScript
async open(context: Context): Promise<void> {	this.#session.observeCommits((publication) => this.#observe(publication));	this.#session.subscribeClose(() => this.#seal());	this.#unsubscribeRegistry = this.#registry.subscribe(() => {		this.#notifyBlocked();		this.#kick();	});	const limit = this.#restartLimit();	const parked: AnyTaskRecord[] = [];	await this.#session.commitWith(async (tx) => {		// Every table read before the first write.		const scans = [];		for (const status of LIVE_STATUSES) {			scans.push(await scanAll((cursor) => tx.scanTasks({ status }, SCAN_PAGE_SIZE, cursor)));		}		for (const records of scans) {			for (const record of records) {				this.#track(undefined, record);				this.#resumed.add(record.id);				if (record.abandonOnRestart === true) this.#abandoned.add(record.id);				if (record.state.status === "running") {					const restarts = (record.restarts ?? 0) + 1;					const pending = withState(record, { status: "pending", checkpoint: record.state.checkpoint });					tx.setTask({ ...pending, restarts } as AnyTaskRecord);					if (restarts >= limit && !record.abortRequested) parked.push(record);				}				if (record.state.status === "waiting" && record.state.policy === "failFast") {					this.#failFastChecks.add(record.id);				}			}		}	}, context);	// Derive abort marks a crash left unapplied below cancelled owners, and finalize held outcomes.	this.#cascadePending = true;	this.#scheduleReconcile();	this.#scheduleWake();
Mọi lượt quét bảng chạy trước lần ghi đầu tiên trong commit (quy tắc đọc trước ghi của transaction). Không handler nào chạy trong open().

Đọc kỹ dòng 394–398: một bản ghi còn running khi mở nghĩa là tiến trình trước chết trong lúc chạy nó, vì close() luôn ghi các task nó dừng về pending (xem join() bên dưới). waiting và sleeping giữ nguyên; chúng không có invocation nên không có gì bị cắt ngang. Mở mà không gọi resume() thì không chạy gì cả, điều một công cụ chỉ đọc (inspector) cần (spec.md:744–755).

resume(), kick và drain

resume() bật lập lịch, đánh thức các sleep đã tới hạn rồi gọi #kick() (scheduler.ts:423–427). submit() và Submission.wait() (submissions.ts:75–77, :115–116), waitForTask(), waitForIdle() (harness.ts:344–352), compact() và abort() của conversation cũng gọi nó, nên host chờ một submission sau khi mở lại thì công việc cũ cũng chạy tiếp.

#kick() và #drain(): một vòng lặp reservation, không bao giờ commit đồng bộ từ listener
pi/packages/durable/src/harness/scheduler.tsdòng 1437–1460TypeScript
#kick(): void {	this.#scheduleWake();	this.#dirty = true;	if (this.#draining || !this.#enabled || this.#closing) return;	this.#draining = true;	// Never commit synchronously from a commit or registry listener.	queueMicrotask(() => void this.#drain());}async #drain(): Promise<void> {	try {		while (this.#dirty && this.#enabled && !this.#closing) {			this.#dirty = false;			for (const reservation of await this.#reserve()) this.#start(reservation);		}	} catch (error) {		// As in `#reconcile()`: the reservation commit runs no extension code.		this.#failSession(error);	} finally {		this.#draining = false;		// A wakeup that arrived after the loop's last check still needs its pass.		if (this.#dirty) this.#kick();	}}
Mọi thay đổi (commit có task, đổi registry, timer) chỉ bật cờ #dirty; một microtask chạy #drain() cho tới khi không còn gì đổi.

#reserve(): chọn task chạy được trong một commit

#run() và #step(): một step trước mỗi phase

#run(): handler của phase hiện tại, rồi một step quyết định có chạy phase kế không
pi/packages/durable/src/harness/scheduler.tsdòng 1711–1737TypeScript
async #run(reservation: Reservation): Promise<void> {	const invocation = reservation.invocation;	const state = { task: reservation.task, snapshot: reservation.snapshot, reported: undefined as ReportedTask };	const phase: Phase = { snapshot: () => state.snapshot, task: () => state.task, agent: undefined };	const runtime = this.#runtime(invocation, phase);	let current = await this.#step(invocation, (tx, next) =>		this.#decide(tx, next, undefined, state, invocation.extension, invocation.context),	);	while (current !== undefined && !this.#closing) {		const task = current;		current = await this.#phase(invocation, task, async () => {			const checkpoint = task.state.checkpoint;			// Each phase handler resolves its agent afresh, at first use.			phase.agent = undefined;			let previous: PhaseResult;			try {				await erased(state.task).phases[checkpoint.phase]!(task, runtime, invocation.context);				previous = { checkpoint };			} catch (error) {				previous = { checkpoint, failure: { error } };			}			return this.#step(invocation, (tx, next) =>				this.#decide(tx, next, previous, state, invocation.extension, invocation.context),			);		});	}}
Một invocation có thể chạy nhiều phase liên tiếp: phase trả về, step thấy checkpoint đã đổi, và phase mới chạy ngay trong cùng invocation, không cần reserve lại.

Step là một commit trên dòng commit; nó đọc bản ghi đã commit và áp quy tắc đầu tiên khớp. Spec liệt kê sáu quy tắc (spec.md:1988–2012): (1) task đã terminal, completing, waiting hay sleeping thì dừng; (2) Harness đang đóng thì dừng, giữ checkpoint; (3) chế độ run mà có abort mark thì dừng, invocation abort sẽ chạy sau; (4) phase ném lỗi thì ghi faulted; (5) checkpoint đã đổi thì làm mới registry snapshot rồi chạy tiếp hoặc handover; (6) checkpoint không đổi thì ghi faulted. Quy tắc 1 và 2 nằm trong #step(), phần còn lại trong #decide():

#decide(): quy tắc 3 đến 6, và handover khi definition được thay
pi/packages/durable/src/harness/scheduler.tsdòng 1816–1855TypeScript
): Decision {	// 3. abort mark: end; a fresh abort invocation starts once the task's ordinary owned work is gone.	if (current.abortRequested) return false;	// An owner's cancellation intent ends it too, before its cascade marks it; reservation then holds it back.	if (!current.background && this.#belowCancelled(parentOf(current))) {		this.#cascadePending = true;		this.#scheduleReconcile();		return false;	}	if (previous === undefined) return true;	// 4. uncaught error.	if (previous.failure !== undefined) return { fault: previous.failure.error };	// 6. no durable progress.	if (jsonEqual(current.state.checkpoint, previous.checkpoint)) {		const message = `Task ${current.kind} phase ${previous.checkpoint.phase} returned without durable progress`;		return { fault: new Error(message) };	}	// 5. progress: refresh the snapshot; hand over to a replacement definition that can take the task.	state.snapshot = this.#registry.snapshot();	const next = state.snapshot.task(current.kind);	if (next !== state.task) {		if (next !== undefined && canReserve(next, current)) {			tx.setTask(withState(current, { status: "pending", checkpoint: current.state.checkpoint }));			return false;		}		if (state.reported === undefined || state.reported.task !== next) {			state.reported = { task: next };			this.#report(				new Error(`Task ${current.id} keeps running under its old ${current.kind} definition`),				{					type: "task_outdated",					...this.#aboutTask({ ...recordTask(current), extension }),					reason: next === undefined ? "missing_task" : "incompatible_task",				},				context,			);		}	}	return true;}
Dòng 1820–1824: task nằm dưới một owner có ý định huỷ cũng dừng ngay, kể cả khi cascade chưa kịp đánh dấu nó.
#step(): invocation kết thúc ngay bên trong callback quyết định việc kết thúc
pi/packages/durable/src/harness/scheduler.tsdòng 1886–1905TypeScript
async #step(	invocation: Invocation,	decide: (tx: Transaction, current: ErasedRunningTask) => Decision,): Promise<ErasedRunningTask | undefined> {	let fault: { readonly error: unknown } | undefined;	try {		const continued = await this.#session.commitWith(async (tx) => {			const found = this.#live.get(invocation.taskId);			const current = found?.state.status === "running" ? (found as ErasedRunningTask) : undefined;			const decision = current !== undefined && !this.#closing ? decide(tx, current) : false;			if (decision === true) return current;			this.#end(invocation);			if (decision !== false) {				fault = { error: decision.fault };				const message = errorMessage(decision.fault);				invocation.fault = { status: "faulted", error: { message } };				await this.#terminate(tx, current!, invocation.fault);			}			return undefined;		}, withoutAbortSignal(invocation.context));
Vì #end() chạy bên trong commit, một runtime.commit() mà invocation xếp hàng trước đó hoặc đã vào storage trước step (và được tính), hoặc tới sau và bị từ chối bởi #gated().

close(): không phải crash

Khi Harness đóng, scheduler báo hiệu mọi invocation, chờ chúng trả về, rồi ghi các task bị dừng về pending thẳng vào storage. Vì thế lần mở sau không đếm restart cho chúng; chỉ một tiến trình chết thật mới để lại bản ghi running.

join(): báo hiệu, chờ, rồi ghi pending lúc đóng
pi/packages/durable/src/harness/scheduler.tsdòng 520–536TypeScript
async join(): Promise<void> {	const stopped = [...this.#invocations.values()];	for (const invocation of stopped) invocation.controller.abort();	await Promise.allSettled(stopped.map((invocation) => invocation.done));	if (stopped.length === 0) return;	// A close is not a crash: a task it stopped is `pending` again, so the next open counts no restart for it. Read	// on the line after commits admitted before the seal, such as an abort mark, so the write keeps them. A failure	// leaves them `running`, counted as if cut off; the Storage guard fails the Session and reports it.	await this.#writeAtClose(() =>		stopped.flatMap((invocation) => {			const record = this.#live.get(invocation.taskId);			if (record?.state.status !== "running") return [];			const value = withState(record, { status: "pending", checkpoint: record.state.checkpoint });			return [{ type: "task", value } as const];		}),	).catch(() => {});}
Đóng chờ cả code bỏ qua tín hiệu huỷ, nên một tool không tôn trọng AbortSignal làm close() treo (spec.md:5591–5593).

Sleep, timer và wake#

Trước 1.4.0, task có runtime.sleep(): chờ ngay trong phase. CHANGELOG giải thích vì sao nó bị xoá: chờ trong phase thì giữ một invocation, và sau restart phase chạy lại từ đầu nên lần chờ cũng bắt đầu lại (CHANGELOG.md:7). Một backoff 10 phút, mỗi lần tiến trình khởi động lại, lại đếm 10 phút từ đầu; và một Durable Object đang chờ không thể ngủ đông (hibernate). Bây giờ sleep là một trạng thái:

  • runtime.sleepUntil(until, checkpoint) commit { status: "sleeping", until, checkpoint } và resolve khi đã lưu, không phải khi tới giờ; phase trả về ngay sau đó. Muốn commit sleep cùng các thay đổi khác thì trả trạng thái đó từ runtime.commit() (README.md:162).
  • Việc sau lúc chờ nằm ở phase kế, và những gì nó cần nằm trong checkpoint. Deadline được tính một lần rồi lưu, nên không bị đẩy lùi mỗi lần chạy lại (spec.md:2417–2431).
  • until phải là số hữu hạn không âm, và handler abort() không được ngủ (scheduler.ts:2489–2494). Abort mark kết thúc giấc ngủ ngay: #notDue() coi task có mark là đã tới hạn (scheduler.ts:1430–1435).

Phần còn lại là ai đánh thức. Scheduler không biết tiến trình có còn sống tới lúc đó không, nên nó trả lời câu hỏi "khi nào Session cần một tiến trình chạy" bằng wake():

wake(): active khi có việc chạy được ngay, at là giấc ngủ sớm nhất chưa tới hạn
pi/packages/durable/src/harness/scheduler.tsdòng 477–513TypeScript
wake(): Wake {	const now = this.#now();	// A reconcile pass with work to do is work too: it finalizes held outcomes and marks cascades, at open as	// recovery. A process that dies before it lands must be woken again. A pass with nothing to do is not.	let active = this.#invocations.size > 0 || this.#reconcilingWork > 0 || this.#reconcileWork();	let at: number | undefined;	let snapshot: RegistrySnapshot | undefined;	let snapshotFailed = false;	for (const record of this.#live.values()) {		const state = record.state;		if (state.status === "completing") continue;		if (state.status === "waiting" && !record.abortRequested && state.on.some((id) => this.#live.has(id)))			continue;		if (state.status === "sleeping" && !record.abortRequested && state.until > now) {			at = at === undefined ? state.until : Math.min(at, state.until);			continue;		}		if (active) continue;		if (this.#parked(record)) continue;		// An abort invocation waits for the live work below it, which counts itself.		if (record.abortRequested && this.#waitingOn(record, true).length > 0) continue;		// A task no definition can take waits for a registry change, except an abort-marked one, which is orphaned.		if (!record.abortRequested || record.abortReason !== undefined) {			if (snapshot === undefined && !snapshotFailed) {				try {					snapshot = this.#registry.snapshot();				} catch (error) {					snapshotFailed = true;					this.#report(error, REGISTRY);				}			}			if (snapshot !== undefined && "reason" in this.#fit(record, snapshot.task(record.kind))) continue;		}		active = true;	}	return at === undefined ? { active } : { active, at };}
Task bị chặn, task completing và lời chờ trên task còn sống không tính là active; việc chúng chờ mới tính. Một pass đối soát đang chờ chạy cũng là active (dòng 481), vì nó có thể hoàn tất kết quả bị giữ sau crash.

Có hai cách dùng giá trị này. Mặc định, khi lập lịch đang bật, scheduler đặt một timer "foreground" cho at; timer này giữ tiến trình Node sống, vì một task đang ngủ là việc còn dang dở. Với HarnessOptions.onWake, scheduler không đặt timer nào mà báo cho host mỗi khi giá trị đổi; host tự đặt báo thức (alarm) và gọi resume() khi nó reo:

Mọi timer của Session và Harness đi qua HarnessOptions.setTimer, chia hai loại. Timer "background" chỉ giải phóng bộ nhớ (đuổi document, context không dùng); host có thể từ chối bằng cách trả undefined, và bản mặc định từ chối chúng trong Cloudflare Workers vì một timer đang chờ giữ Durable Object không cho ngủ đông. Timer "foreground" nhịp hoặc tiếp tục công việc; host ném lỗi hay từ chối thì Harness tự đặt bằng setTimeout và báo lỗi một lần (timers.ts:12–36, timers.ts:46–79). Truyền cả setTimer và now thì Harness chạy trên đồng hồ giả; mọi tiến trình mở cùng một storage phải dùng cùng một đồng hồ, vì deadline được lưu theo now (README.md:164).

Mẫu dùng onWake cho một Durable Object của Cloudflare (đặt báo thức dự phòng trước Harness.open, báo thức nhịp tim khi active, gọi resume() trong alarm()) nằm ở README.md:166–210; phần lưu trữ của nó thuộc chương storage. Test harness-generation-recovery.test.ts:165–210 cho thấy cả chu trình: một backoff retry được lưu thành sleeping tới 61 000, sau khi mở lại wake() trả { active: false, at: 61_000 }, gọi resume() sớm không gửi request nào, và khi đồng hồ tới 61 000 thì request được gửi lại.

Restart, parking, task bị chặn và migration#

Vòng lặp crash và việc đỗ task

Có một mặt trái của "mở lại là chạy tiếp": công việc làm tiến trình chết một cách tất định, ví dụ cạn bộ nhớ trước checkpoint kế, sẽ được chạy tiếp và lại giết tiến trình, mãi mãi (README.md:216). Phòng tuyến là trường restarts. open() cộng một cho mỗi bản ghi còn running (đã thấy ở dòng 394–398); trạng thái khác mà task tự commit xoá bộ đếm (progressed()). Khi restarts chạm settings.restartLimit (mặc định 5, agent.ts:56–62), task bị đỗ: không được reserve, hiện trong inspect() và task graph với lý do restart_limit, không tính là active trong wake(), và được báo một lần qua onReport với kiểu task_parked.

retry(): Harness.retryTask() xoá bộ đếm của một task bị đỗ và bật lập lịch
pi/packages/durable/src/harness/scheduler.tsdòng 433–442TypeScript
async retry(id: TaskId, context: Context): Promise<void> {	await this.#session.commitWith(async (tx) => {		const current = await tx.task(id);		if (current === undefined) throw new Error(`Task ${id} does not exist`);		const status = current.state.status;		if (status === "terminal" || status === "completing" || !this.#parked(current)) return;		tx.setTask(progressed(current));	}, context);	this.resume();}

Hai chi tiết cho thấy cơ chế được nghĩ kỹ. Thứ nhất, mọi task đang chạy khi tiến trình chết đều bị đếm, không chỉ thủ phạm, nên một stream dài chạy cạnh thủ phạm cũng có thể bị đỗ; vì vậy host được khuyên hiển thị task bị đỗ và cho người dùng nút retryTask() (spec.md:2201–2206). Thứ hai, abort mark đặt lại bộ đếm (withAbortMark() xoá restarts, scheduler.ts:2389–2396), nên handler abort() của một task bị đỗ vẫn chạy; nhưng nếu chính handler đó bị cắt ngang đủ số lần, task kết thúc faulted với lý do restart_limit (dòng 1522–1529 của #reserve()). Test mô phỏng crash bằng một storage "chết" (mọi lời gọi treo, nên ngay cả bản ghi pending lúc đóng cũng không được ghi), mở lại ba lần và kiểm tra task bị đỗ với restarts: 3, rồi retryTask() chạy nó tới xong (harness-blocked.test.ts:135–177).

Definition thiếu, cũ hơn, hay cần migration

Bản ghi lưu kind và version; code nằm trong registry của tiến trình. Hai thứ có thể lệch nhau: extension chưa được cài lại sau restart, hoặc đã được nâng version. Mỗi lần reserve, scheduler tra definition theo tên trong snapshot registry hiện tại:

#resolve() và #fit(): cùng version thì chạy, version cũ hơn thì migrate, còn lại thì bị chặn
pi/packages/durable/src/harness/scheduler.tsdòng 1588–1619TypeScript
/** Resolve the record's definition by kind, migrating an older stored version. */#resolve(record: RunnableTaskRecord, snapshot: RegistrySnapshot): Resolution {	const fit = this.#fit(record, snapshot.task(record.kind));	if ("reason" in fit) return { kind: "blocked", reason: fit.reason };	if (!fit.migrates) return { kind: "ready", task: fit.task, record };	const definition = erased(fit.task);	try {		if (definition.migrate === undefined) throw missingMigration(record, definition);		const migrated = definition.migrate(record.input, record.state.checkpoint, record.version);		const state = { ...record.state, checkpoint: copyJson(migrated.checkpoint) };		const migratedRecord = { ...record, version: definition.version, input: copyJson(migrated.input), state };		return { kind: "ready", task: fit.task, record: migratedRecord as RunnableTaskRecord };	} catch (error) {		this.#failedMigrations.set(record.id, { task: fit.task, error });		// Blocked now without a commit: the graph and the host's wake must hear it.		queueMicrotask(() => this.#notifyBlocked());		this.#scheduleWake();		const extension = installerOf(snapshot, fit.task);		this.#report(error, { type: "migration_failed", ...this.#aboutTask({ ...recordTask(record), extension }) });		return { kind: "blocked", reason: "migration_failed" };	}}#fit(record: Pick<AnyTaskRecord, "id" | "version">, task: AnyTask | undefined): Fit {	if (task === undefined) return { reason: "missing_task" };	const version = task.definition.version;	if (version === record.version) return { task, migrates: false };	if (version < record.version) return { reason: "task_too_old" };	const failed = this.#failedMigrations.get(record.id);	if (failed?.task === task) return { reason: "migration_failed", error: failed.error };	return { task, migrates: true };}
Migration chỉ được tính ở đây, bản ghi đã migrate được ghi cùng commit chuyển sang running. Migration hỏng được nhớ theo đối tượng definition, nên chỉ thử lại khi registry trả về một definition khác (dòng 1617).

Nguyên tắc quan trọng: Harness không bao giờ kết thúc một task chỉ vì thiếu code, cả lúc mở lẫn về sau. Task bị chặn giữ nguyên bản ghi, vẫn sống, vẫn làm idle wait phải chờ, và được xét lại mỗi khi registry đổi (spec.md:2218–2227). Chỉ khi task bị chặn bị huỷ thì nó mới kết thúc orphaned, vì không còn code nào để chạy handler abort() (abort() ở scheduler.ts:553–561, và dòng 1533–1537 của #reserve()). Ví dụ migration thật duy nhất trong các task dựng sẵn là pi.tool version 2, thêm trường phân biệt kind cho input:

pi.tool version 2 migrate bản ghi version 1: lời gọi cũ đều do mô hình phát ra
pi/packages/durable/src/harness/tool.tsdòng 125–134TypeScript
export const ToolTask = defineTask<ToolTaskInput, ToolTaskCheckpoint, ToolTaskResult, ToolHooks>({	name: "pi.tool",	version: 2,	initial: () => ({ phase: "call" }),	// Version 1 had only model-issued calls.	migrate: (input, checkpoint) => ({		input: { kind: "model", ...(input as { assistant: EntryId; callId: string }) },		checkpoint: checkpoint as ToolTaskCheckpoint,	}),	phases: {

Khi definition được thay ngay trong lúc chạy (extension được cài lại cùng tên), task đang chạy không bị ngắt: ở ranh giới phase kế, #decide() thấy đối tượng definition đã khác; nếu definition mới nhận được bản ghi thì step ghi task về pending (dòng 1837–1839) và pass reservation sau chạy nó bằng code mới. Nếu không nhận được, task chạy tiếp bằng code cũ và báo task_outdated (spec.md:2294–2304).

abandonOnRestart: task mà người tạo chỉ chờ trong bộ nhớ

Một số task chỉ có ý nghĩa với người tạo ra chúng trong cùng tiến trình. Ví dụ: một tool không replay-safe gọi một tool lồng nhau rồi await kết quả. Sau restart, tool cha không chạy lại (nó sẽ nhận kết quả interrupted), nên chẳng ai cần kết quả của lời gọi con nữa; để nó chạy tiếp chỉ tốn tiền và có thể gây hiệu ứng thừa. TaskOptions.abandonOnRestart đánh dấu những task như vậy (types.ts:385–390). Khi một Harness sau bắt đầu lập lịch, pass reservation đầu tiên đánh dấu abort chúng với abortReason: "restart" (dòng 1473–1480 ở trên), và lý do này lan xuống việc sở hữu thường. Mở chỉ để đọc thì không đánh dấu gì (spec.md:2244–2256). Handler abort() của pi.tool đọc lý do đó để nói đúng điều đã xảy ra:

Abort của pi.tool: bị huỷ, bị gián đoạn bởi restart, hay chưa bao giờ bắt đầu
pi/packages/durable/src/harness/tool.tsdòng 200–211TypeScript
abort: async (task, runtime, context) => {	const call = await readCall(runtime, task.input, context);	// Abandoned after a restart (`TaskOptions.abandonOnRestart`): say whether the call may have run.	const [code, message] =		task.abortReason !== "restart"			? ["aborted", `Tool ${call.name} was aborted`]			: task.state.checkpoint.phase === "execute"				? ["interrupted", `Tool ${call.name} was interrupted by a restart and may have partially run`]				: ["abandoned", `Tool ${call.name} was not started: its caller ended with a restart`];	const ending = { status: "aborted" } as const;	await settle(runtime, task.input, call, ending, (slot) => fromSlot(slot, code, message), context);},
Đã ghi ý định (execute) thì tool có thể đã chạy một phần: interrupted. Chưa ghi (call) thì chắc chắn chưa chạy: abandoned.

Hai điểm khác biệt với abort thường. Task có mark restart mà thiếu definition thì chờ definition thay vì bị orphaned, để handler abort() của nó vẫn chạy và dọn dẹp được khi extension được cài lại; một yêu cầu abort thật (abortTask(), conversation abort) thay mark restart và khi đó task mới bị orphaned (scheduler.ts:448–453). Người dùng hiếm khi phải tự đặt cờ này: lời gọi lồng nhau và task con mà một tool không replay-safe tạo qua api.createTask() tự nhận nó (tool.ts:376).

pi.generation: từ transcript tới câu trả lời#

Một task pi.generation ứng với một câu trả lời của mô hình, kể cả các lần retry và poll của câu trả lời đó. Một lần chạy (run) gồm nhiều lượt (turn) nên gồm nhiều generation nối tiếp: generation trả lời bằng lời gọi tool sở hữu các task tool, chờ chúng, rồi trao run cho một generation mới. Input của task rỗng; các input của run nằm trong document pi.live, không nằm trong task (spec.md:4181–4182). Đây là document mà mọi task dựng sẵn cùng ghi:

LiveState (pi.live): điều khiển run và phần trình bày của generation, tool, compaction đang chạy
pi/packages/durable/src/harness/live.tsdòng 81–109TypeScript
/** Built-in live conversation state: run control and presentation of the current generation and tool round. */export type LiveState = {	/** Run control: the task that settles the run's inputs, and those inputs; present exactly while busy. */	run?: { taskId: TaskId; inputs: SubmissionId[] };	/** Presentation of the current generation attempt. */	generation?: {		attempt: number;		/** Committed throttled partial of the in-flight response. */		message?: JsonRepresentation<AssistantMessage>;		/** Durable backoff before the next attempt. */		retry?: { at: number; error: string };		/** Provider-side deferred response being polled. */		deferred?: { pollAt: number };	};	/** The current tool round in call order, from the tool-calling answer until the generation's `tools` phase ends it. */	tools?: ToolSlot[];	/**	 * Nested calls of running tool calls, in creation order, so a parent precedes its children and task IDs ascend. A	 * call's nested slots are removed when the call settles.	 */	nestedTools?: NestedToolSlot[];	/** Live compaction tasks in task ID order; absent when none. */	compactions?: CompactionStatus[];	/**	 * The newest compaction failure, so a client can show it after the task ended. A compaction that produces a summary	 * removes it; one that is aborted or finds nothing to compact leaves it.	 */	compactionFailure?: CompactionFailure;};
run có mặt đúng khi conversation bận: taskId là task đang chịu trách nhiệm settle các input. Mọi thứ khác là trình bày cho UI, và cũng là nơi lưu phần đầu ra dở dang để phục hồi.

Checkpoint của generation là một kiểu hợp năm phase:

 prepare ──commit pi.system, ghim cutoff──► request ──stream, partial ≤ 1 lần/100 ms──► classify
    │
    └─ vượt ngưỡng chặn ──► waiting trên compaction chặn ──► prepare

 classify chọn một nhánh, mỗi nhánh là một commit:
   stop / length .............. answer(): final boundary, settle input, có thể mở run mới
   toolUse + lời gọi .......... tools: waiting allSettled trên pi.tool × n
                                  └─► finishToolRound() ──► generation mới, cùng run
   error, tràn context ........ waiting trên compaction chặn (chỉ một lần) ──► prepare
   error, thử lại được ........ retry: sleeping tới until ──► prepare, attempt + 1
   deferred ................... poll: sleeping tới pollAt ──► fetchDeferred() ──► classify
   lỗi khác ................... kết thúc failed; input unanswered với model_error
Các phase của pi.generation. Mọi lần chờ (compaction, tool, backoff, poll) là trạng thái waiting hoặc sleeping đã commit, không có lần chờ nào trong bộ nhớ.

prepare: dựng system prompt và chốt request

prepare phân giải agent của conversation (một lần cho phase), kiểm tra model có tồn tại không (không thì kết thúc failed với lý do no_model), render các section của system prompt và tính các entry pi.system cần thêm. Cơ chế section và entry hệ thống theo vị trí thuộc chương mô hình khái niệm; ở đây chỉ cần biết planSystemEntries() trả về những entry mô tả phần prompt và tool đã đổi (generation.ts:155–176). Phần cuối của phase là nơi các quyết định được ghi:

Cuối prepare: ngưỡng compaction, rồi một commit nối pi.system và chuyển sang request
pi/packages/durable/src/harness/generation.tsdòng 176–207TypeScript
const entries = planSystemEntries(view, desired, agent.tools, runtime.now());const threshold =	compacted === undefined		? thresholdCompaction(view, entries, resolved.contextWindow, compactionPolicy(settings, model))		: undefined;if (threshold === "blocking") {	// Compact first and prepare again; the transcript is unchanged until the compaction appends.	await runtime.commit(async (tx): Promise<Next> => {		const child = await createCompaction(tx, conversationId, { reason: "threshold" }, runtime.taskId);		const checkpoint = { phase: "prepare", attempt, compacted: child, threshold: true } as const;		return { status: "waiting", checkpoint, on: [child], policy: "allSettled" };	}, context);	return;}await runtime.commit(async (tx) => {	let cutoff = (await tx.scanEntries({ conversationId }, 1)).items[0]?.id;	for (const entry of entries) cutoff = (await tx.appendEntry(SystemEntry, conversationId, entry)).id;	if (cutoff === undefined) throw new Error(`Conversation ${conversationId} has no entries to send`);	// Checked in this commit, so a compaction admitted during preparation counts.	if (threshold === "background" && (await tx.doc(LiveDoc, conversationId)).compactions === undefined) {		await createCompaction(tx, conversationId, { reason: "threshold" });	}	const request = {		attempt,		...(compacted === undefined ? {} : { compacted }),		model,		thinkingLevel,		streamOptions: settings.stream,		cutoff,	};	return { status: "running", checkpoint: { phase: "request", ...request } };}, context);
Hai nhánh: vượt ngưỡng chặn thì không nối gì mà chờ một compaction do chính generation sở hữu (dòng 183–187); còn lại thì một commit nối entry hệ thống, có thể tạo compaction nền, và ghim request (dòng 190–207).

request: gửi, stream, và phân loại

request: chuyển partial cũ thành entry, gửi context đến cutoff, cho beforeRequest thay message
pi/packages/durable/src/harness/generation.tsdòng 209–236TypeScript
request: async (task, runtime, context) => {	const { attempt, compacted, model: ref, thinkingLevel, streamOptions, cutoff } = task.state.checkpoint;	const conversationId = runtime.conversationId;	await runtime.commit(async (tx) => {		const live = await tx.doc(LiveDoc, conversationId);		await convertPartial(tx, live, conversationId);		live.generation = { attempt };		return undefined;	}, context);	const model = runtime.models.getModel(ref.provider, ref.modelId);	if (model === undefined) return failNoModel(runtime, ref, context);	const view = await runtime.context(conversationId, context, { at: cutoff });	let messages = view.messages;	await runtime.hooks.each("beforeRequest", async (hook) => {		const replaced = await hook({ messages }, runtime, context);		if (replaced !== undefined) messages = replaced.messages;	});	const options: SimpleStreamOptions = {		...streamOptions,		signal: runtime.signal,		telemetry: context.telemetry,		sessionId: await ensureProviderSessionId(runtime, context),		...(thinkingLevel === "off" ? {} : { reasoning: thinkingLevel }),	};	const message = await streamResponse(runtime, model, messages, options, attempt, context);	const request = { attempt, compacted, model: ref, cutoff, messages: view.messages };	await classify(runtime, request, message, context);},
Dòng 212–217 là phần phục hồi: nếu lần thử trước bị cắt ngang sau khi đã commit một phần câu trả lời, phần đó được nối thành một entry pi.assistant với stop reason aborted trước khi gửi lại.

Hai chi tiết đáng dừng lại. Context được đọc at: cutoff (dòng 220), không phải "transcript hiện tại", nên lần gửi lại sau crash gửi đúng những message đã ghim; entry partial vừa được chuyển đổi nằm sau cutoff, và context derivation cũng loại mọi message assistant có stop reason aborted, error hay deferred khỏi các request sau (context.ts:10, context.ts:222–229). Hook beforeRequest chạy lại ở mỗi lần thử, kể cả lần phục hồi, và kết quả của nó chỉ dùng cho request đó, không được lưu. Test harness-generation-recovery.test.ts:125–163 kiểm tra đúng điều này: sau khi mở lại, transcript là pi.user, pi.assistant (aborted), pi.assistant và request gửi lại chỉ chứa message của người dùng.

Stream: partial được commit tối đa 100 ms một lần

streamResponse(): throttle partial, một commit đang bay, dừng sạch trước khi phân loại
pi/packages/durable/src/harness/generation.tsdòng 459–497TypeScript
const flush = (): void => {	timer = undefined;	const partial = pending;	pending = undefined;	if (partial === undefined || stopped) return;	inFlight = (async () => {		// Copy synchronously: the provider keeps mutating its partial.		const message = copyJson(partial, { omitUndefinedProperties: true });		await runtime.commit(async (tx) => {			const live = await tx.doc(LiveDoc, runtime.conversationId);			live.generation ??= { attempt };			assignJson(live.generation as Draft<Record<string, JsonValue>>, "message", message);			return undefined;		}, context);	})()		.catch((error: unknown) => {			// Rejections after an abort mark or close are expected; the committed state stays consistent.			if (!runtime.signal.aborted) runtime.report(error);		})		.finally(() => {			inFlight = undefined;			if (pending !== undefined && !stopped) timer = runtime.setTimer(flush, interval);		});};try {	const events = runtime.models.streamSimple(model, { messages: [...messages] }, options);	for await (const event of events) {		// A partial without content, such as pi-ai's opening `start` event, shows nothing; a deferred response		// never gets past it, so it never leaves a partial.		if (event.type === "done" || event.type === "error" || event.partial.content.length === 0) continue;		pending = event.partial;		if (timer === undefined && inFlight === undefined) timer = runtime.setTimer(flush, interval);	}	return await events.result();} finally {	stopped = true;	timer?.();	await inFlight;}
Bản sao đồng bộ ở dòng 466 cần thiết vì provider tiếp tục sửa đối tượng partial của nó. Khối finally dừng throttle và chờ commit đang bay, nên không có partial cũ nào rơi vào sau kết quả.

Khoảng cách là settings.progress.partialIntervalMs, mặc định 100 ms (agent.ts:34–37). Đây là thoả hiệp giữa độ mượt và số lần ghi: một crash mất tối đa khoảng đó, và host có storage ở xa có thể nới ra, ví dụ 500 ms (README.md:502). Timer dùng runtime.setTimer(), nên invocation kết thúc là throttle dừng theo.

classify: một commit cho mỗi kết cục

classify() trước hết thoát nếu tín hiệu đã bị huỷ (abort mark hoặc đóng: invocation abort hoặc lần chạy sau sẽ xử lý), rồi xử lý deferred bằng một sleep tới pollAt (mặc định 5 giây sau, và luôn muộn hơn lần poll trước, generation.ts:511–531), gọi các hook afterResponse, và rẽ nhánh: toolUse có lời gọi sang vòng tool, stop/length sang trả lời. Nhánh lỗi là nơi retry và compaction gặp nhau:

Đi qua từng nhánh. Tràn context (pi-ai isContextOverflow()), khi compaction bật, generation chưa compaction lần nào (compacted rỗng) và phép chọn điểm cắt tìm được chỗ cắt: một commit nối entry lỗi, tạo compaction do generation sở hữu với lý do overflow, rồi chờ nó (allSettled) với checkpoint prepare mang theo compacted và văn bản lỗi. Khi prepare chạy lại, nó kiểm tra compaction có hoàn tất với một entryId không; không thì thất bại model_error (generation.ts:141–146). Lần tràn thứ hai thì không compaction nữa, vì compacted đã có giá trị. Lỗi tạm thời (isRetryableAssistantError()) mà còn lượt (attempt <= maxRetries, mặc định 3 lần thử lại với delay gốc 2 giây, agent.ts:20–25): nối entry lỗi và ngủ tới now + retryDelayMs(policy, attempt). Phase retry sau đó quay lại prepare với attempt kế, nên thay đổi agent hay settings trong lúc chờ có hiệu lực (generation.ts:238–250). Mọi trường hợp khác: nối entry lỗi, kết thúc run với input unanswered lý do model_error, và settings.afterFailedRun quyết định số phận của các input xếp hàng phía sau (generation.ts:411–428).

Mọi câu trả lời, kể cả lỗi và partial bị huỷ, đi qua appendAssistant(), hàm nối entry và cộng usage của message vào document pi.usage trong cùng commit, theo khoá provider/model (generation.ts:739–750, usage.ts:24–39). Vì vậy lần thử thất bại cũng được tính tiền, còn lần thử bị cắt ngang chưa có phản hồi thì không có gì để tính.

answer: final boundary và onYield

answer(): hook onYield có thể tiếp tục run; nếu không, settle input done và mở run cho follow-up
pi/packages/durable/src/harness/generation.tsdòng 597–622TypeScript
async function answer(runtime: Runtime, message: AssistantMessage, context: Context): Promise<void> {	let continuation: UserInput | undefined;	await runtime.hooks.each("onYield", async (hook) => {		if (continuation !== undefined) return;		continuation = (await hook(message, runtime, context))?.continue;	});	const conversationId = runtime.conversationId;	await runtime.commit(async (tx): Promise<Next> => {		// Queue modes are read on the Session line, when the boundary is decided.		const boundary = await prepareBoundary(tx, conversationId, runtime.settings);		const live = await tx.doc(LiveDoc, conversationId);		const entry = await appendAssistant(tx, conversationId, message);		const result: Next = { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } };		const { users, reset } = await applyBoundary(tx, boundary, "final", runtime.now());		if (continuation !== undefined && users.length === 0 && !reset) {			const user = { role: "user", content: continuation, timestamp: runtime.now() } as const;			await tx.appendEntry(UserEntry, conversationId, { model: [user] });			handOver(live, runtime.taskId, await createGeneration(tx, conversationId));			delete live.generation;			return result;		}		endRun(tx, live, runtime.taskId, { status: "done", answer: entry.id });		if (users.length > 0) await startRun(tx, conversationId, live, users);		return result;	}, context);}
Continuation của onYield chỉ được dùng khi boundary không chọn input nào của người dùng và không có reset; một continuation bị bỏ qua không được thử lại.

"Final boundary" là điểm đặt các mục đang xếp hàng trong inbox sau một câu trả lời (xem mục inbox). Nếu boundary chọn được follow-up, startRun() tạo một generation mới cho chúng ngay trong commit đó (generation.ts:752–769).

Vòng tool: generation sở hữu, chờ, rồi trao run

startToolRound(): một commit nối câu trả lời, tạo task tool do generation sở hữu, rồi chờ
pi/packages/durable/src/harness/generation.tsdòng 636–674TypeScript
const conversationId = runtime.conversationId;const messages =	request.messages ?? (await runtime.context(conversationId, context, { at: request.cutoff })).messages;const offered = new Set(getCurrentTools(messages).map((tool) => tool.name));// Read as the round starts; a tool is resolved as its tool task resolves it.const tools = (await runtime.agent(context)).tools;const sequential =	runtime.settings.toolExecution === "sequential" ||	calls.some(		(call) =>			offered.has(call.name) && tools.find((tool) => tool.name === call.name)?.executionMode === "sequential",	);await runtime.commit(async (tx): Promise<Next> => {	const live = await tx.doc(LiveDoc, conversationId);	const entry = await appendAssistant(tx, conversationId, message);	const slots: ToolSlot[] = [];	const tools: TaskId<ToolTaskResult>[] = [];	const pending: string[] = [];	for (const call of calls) {		if (!offered.has(call.name)) {			const unavailable = harnessError("tool_unavailable", `Tool ${call.name} is not available`);			const result = await appendToolResult(tx, conversationId, call, unavailable, { timestamp: runtime.now() });			slots.push({ callId: call.id, name: call.name, status: "done", entry: result.id });			continue;		}		if (sequential && tools.length > 0) {			pending.push(call.id);			slots.push({ callId: call.id, name: call.name, status: "pending" });			continue;		}		const taskId = await createToolTask(tx, runtime, entry.id, call.id);		tools.push(taskId);		slots.push({ callId: call.id, name: call.name, taskId, status: "pending" });	}	delete live.generation;	live.tools = slots;	const checkpoint = { phase: "tools", assistant: entry.id, tools, pending } as const;	return { status: "waiting", checkpoint, on: tools, policy: "allSettled" };}, context);
Tool chỉ được chấp nhận nếu request đã mời (offer) nó: tập tool được phát lại từ chính context đã gửi (getCurrentTools(), dòng 639), không phải từ agent hiện tại. Lời gọi tới tool không được mời nhận kết quả tool_unavailable ngay, không có task.

Vòng là tuần tự khi settings.toolExecution là sequential hoặc bất kỳ tool nào được gọi khai báo executionMode: "sequential"; khi đó chỉ lời gọi đầu có task, các lời gọi còn lại nằm trong pending của checkpoint và phase tools tạo từng cái một (generation.ts:264–282). Khi mọi task tool đã terminal, phase tools kết thúc vòng:

finishToolRound(): afterTools, rồi terminate/handoff kết thúc run, hoặc postTools trao run cho generation mới
pi/packages/durable/src/harness/generation.tsdòng 700–736TypeScript
await runtime.hooks.each("afterTools", (hook) => hook(assistant, results, runtime, context));// Every call of the round, including those answered without a task, must ask to terminate.const terminate =	slots.length > 0 &&	slots.every((slot) => slot.taskId !== undefined && controls.get(slot.taskId)?.terminate === true);const added = [...controls.values()].flatMap((control) => control?.addTools ?? []);// The last handoff in call order wins.const handoff = [...controls.values()].findLast((control) => control?.handoff !== undefined)?.handoff;await runtime.commit(async (tx): Promise<Next> => {	const boundary = await prepareBoundary(tx, conversationId, runtime.settings);	if (added.length > 0) await addTools(tx, conversationId, added);	const live = await tx.doc(LiveDoc, conversationId);	const now = runtime.now();	if (terminate || handoff !== undefined) {		if (handoff !== undefined) {			const message = { role: "user", content: handoff, timestamp: now } as const;			const entry = await tx.appendEntry(ResetEntry, conversationId, { head: "self", model: [message] });			boundary.head = entry.id;		}		const { users } = await applyBoundary(tx, boundary, "final", now);		endRun(tx, live, runtime.taskId, { status: "done", answer: assistant });		if (users.length > 0) await startRun(tx, conversationId, live, users);	} else {		const { users, reset } = await applyBoundary(tx, boundary, "postTools", now);		if (reset) {			// The queued reset cut the run's context before an answer.			endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "reset" });			if (users.length > 0) await startRun(tx, conversationId, live, users);		} else {			delete live.tools;			delete live.nestedTools;			if (live.run?.taskId === runtime.taskId) live.run.inputs.push(...users);			handOver(live, runtime.taskId, await createGeneration(tx, conversationId));		}	}	return { status: "terminal", outcome: { status: "completed", result: { entryId: assistant } } };}, context);
Dòng 731: steer được chọn ở boundary postTools gia nhập input của run đang chạy. Dòng 732: handOver() chuyển pi.live.run.taskId sang generation mới; các input vẫn placed cho tới câu trả lời cuối.

Các control trong kết quả tool được áp dụng ở đây (spec.md:3586–3594): terminate chỉ có hiệu lực khi mọi lời gọi của vòng đều yêu cầu, giống vòng lặp agent của pi; handoff nào cũng đủ, lấy cái cuối theo thứ tự lời gọi, và nối một entry pi.reset với head: "self", đúng như reset(handoff); addTools ghi tên tool vào pi.agent để request sau mời chúng (agent.ts:93–118). Generation kết thúc completed với entryId của câu trả lời gọi tool; run thì sống tiếp trong generation mới.

Handler abort() của generation chỉ chạy sau khi các task tool của nó đã terminal (thứ tự từ dưới lên). Nó huỷ phản hồi deferred nếu đang poll, chuyển partial đã commit thành entry, nối kết quả aborted cho các lời gọi tuần tự chưa bắt đầu, và settle input unanswered lý do aborted (generation.ts:284–315).

Bốn hook của generation

HookKhi nàoCách ghép nhiều handler
beforeRequestTrước mỗi lần thử, kể cả phục hồi; chỉ áp cho request đóChuỗi thay thế: handler sau nhận message của handler trước
afterResponseMọi message kết thúc của provider, trước khi phân loạiTất cả quan sát
onYieldCâu trả lời cuối; { continue } nối một tin nhắn người dùng và tiếp tục runContinuation đầu tiên thắng
afterToolsKhi mọi tool của vòng đã terminalTất cả quan sát

Bảng trên tóm tắt spec.md:2912–2920. Lỗi thường trong hook được báo qua onReport và handler kế vẫn chạy; lỗi huỷ thì luôn lan ra (scheduler.ts:2096–2113). Spec cũng nói rõ: hook chạy ngoài dòng commit, nên một crash trước commit tiêu thụ kết quả của nó có thể làm hook chạy lại (spec.md:2909–2910).

pi.tool: ý định trước, hiệu ứng sau#

Mỗi lời gọi tool là một task pi.tool riêng. Với lời gọi do mô hình phát ra, task thuộc về generation và input chỉ gồm ID của entry câu trả lời cùng callId: lời gọi được đọc lại từ entry đó, nên bản ghi terminal (được giữ mãi) vẫn nhỏ. Lời gọi lồng nhau (nested) phải mang lời gọi trong input vì không entry nào chứa nó.

Input, checkpoint và kết quả của pi.tool
pi/packages/durable/src/harness/tool.tsdòng 58–85TypeScript
/** * A model-issued call, read from its assistant entry, or a nested call a running tool made through `executeTool()`, * whose call the input carries because no entry holds it. */export type ToolTaskInput =	| { kind: "model"; assistant: EntryId; callId: string }	| {			kind: "nested";			parent: TaskId;			parentCallId: string;			key: string;			call: ToolCall;			/** `false`: commit no running output, details, or diagnostics to the slot; the result still gets them. */			progress?: false;	  };export type ToolTaskCheckpoint =	| { phase: "call" }	/** Durable intent: the final arguments and the replay policy recorded before execution. */	| { phase: "execute"; arguments: JsonObject; replay: "safe" | "unsafe" };/** * A model-issued call's result is its transcript entry; a nested call's result is in its caller's * `NestedCallDoc`, so the receipt stays small and the result retires with the caller. A version 1 task that was * already holding its outcome as `completing` when the Harness upgraded finishes with a version 1 result, * `{ entryId, control? }` without `kind`; treat a result without `kind` as a model-issued call's. */export type ToolTaskResult = { kind: "model"; entryId: EntryId; control?: ToolControl } | { kind: "nested" };
Toàn bộ ý tưởng phục hồi nằm ở phase execute: nó lưu đối số cuối cùng (sau khi sửa, hook và ép kiểu) và chính sách replay trước khi tool chạy.

Phía tác giả tool, mọi thứ khai báo trên ToolRegistration, mở rộng Tool của pi-ai:

Phase call: phân giải, kiểm tra, hook, ghi ý định, chạy

Đi theo thứ tự. Tên tool được tra trong agent.tools (lời gọi của mô hình) hoặc agent.callable (lời gọi lồng nhau) của lần phân giải agent trong phase này (tool.ts:214–223); không thấy thì trả tool_unavailable. prepareArguments sửa các lỗi đối số mô hình hay mắc (như mảng gửi dưới dạng chuỗi JSON), rồi đối số được kiểm tra bằng validateToolArguments() của pi-ai. Chuỗi hook beforeTool có thể thay đối số hoặc chặn; chặn đầu tiên thắng, và hook ném lỗi thì lời gọi bị chặn với nội dung lỗi (dòng 154–157). Đối số được kiểm tra lại sau hook. Rồi đến commit then chốt ở dòng 168–177: slot của lời gọi trong pi.live.tools chuyển sang running và checkpoint thành { phase: "execute", arguments, replay }. Chỉ sau đó run() mới dựng môi trường và gọi execute().

Phase execute: chỉ phục hồi mới tới đây

Phase execute: chạy lại chỉ khi cả chính sách đã lưu và khai báo hiện tại đều là safe
pi/packages/durable/src/harness/tool.tsdòng 180–198TypeScript
/** Recovery after intent: rerun only when the stored and the current policy both say `safe`. */execute: async (task, runtime, context) => {	const { arguments: args, replay } = task.state.checkpoint;	const call = await readCall(runtime, task.input, context);	const tool = await resolveTool(runtime, task.input, call.name, context);	if (replay === "safe" && tool?.replay === "safe") {		// The rerun reports from scratch; clear what the interrupted attempt published.		await runtime.commit(async (tx) => {			const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);			if (slot !== undefined) clearProgress(slot);			return undefined;		}, context);		return run(runtime, task.input, call, tool, args, context);	}	const message = `Tool ${call.name} was interrupted and may have partially run`;	// `failed` records cancellation intent, so the call's owned conversations, left unsupervised, are aborted.	const ending = { status: "failed", message } as const;	await settle(runtime, task.input, call, ending, (slot) => fromSlot(slot, "interrupted", message), context);},
Lần chạy lại dùng đối số đã lưu và không gọi lại beforeTool. Không chạy lại thì kết quả là lỗi interrupted dựng từ phần đầu ra đã commit trong slot.

Quy tắc "cả hai đều safe" có hai chiều: khai báo hiện tại là unsafe có thể phủ quyết một replay đã được lưu là safe, nhưng khai báo hiện tại là safe không nâng cấp được một intent đã lưu là unsafe; tool không còn phân giải được (extension chưa cài lại, bị bỏ chọn) thì bị coi là unsafe (spec.md:3601–3607). Test chạy cả ba tổ hợp và đếm số lần chạy (harness-tools-recovery.test.ts:136–163).

Kết quả interrupted kết thúc task với trạng thái failed, không phải completed (dòng 196). Lý do có chủ đích: failed là ý định huỷ, nên mọi conversation mà lời gọi sở hữu (ví dụ subagent của nó), giờ không còn ai giám sát, bị abort theo. Một kết quả isError mà tool tự trả về thì vẫn completed và việc con của nó sống tiếp (spec.md:4356–4365). Thông điệp mà mô hình đọc được lấy từ test:

Mô hình nhận phần đầu ra đã commit, rồi khối chẩn đoán của harness
pi/packages/durable/test/harness-tools-recovery.test.tsdòng 128–131TypeScript
expect(result).toMatchObject({ isError: true, details: { run: 1 } });expect(text(result)).toBe(	"run 1\n|<harness>\n[error] Tool work was interrupted and may have partially run\n</harness>",);

Chạy tool: đầu ra, giới hạn, thời gian

api.output(chunk) đẩy văn bản vào một OutputBuffer có giới hạn (của Chord) và đánh dấu tiến độ; các commit tiến độ ghi phần đầu ra được giữ lại vào slot. Nhịp commit thích nghi: thay đổi đầu tiên sau một lúc rảnh được commit ngay, sau đó mỗi commit đẩy lần kế ra ít nhất outputIntervalMs (mặc định 100 ms) và thêm thời gian theo kích thước đã ghi ở 100 KiB/s (spec.md:3307–3317; mã ở tool.ts:479–532). Vì thế một crash mất tối đa một cửa sổ throttle đầu ra, và kết quả interrupted mang đúng phần đã commit đó. Phần cuối của run():

run(): dựng môi trường ngay trước khi chạy, đo thời gian, biến lỗi ném ra thành kết quả lỗi
pi/packages/durable/src/harness/tool.tsdòng 421–458TypeScript
	let result: ToolExecutionResult;	let ending = COMPLETED;	// Execution time of this attempt; a rerun after recovery measures only itself.	let durationMs: number | undefined;	try {		env = await runtime.env(context);		const startedAt = performance.now();		try {			result = await tool.execute(args, api, context);		} finally {			durationMs = Math.round(performance.now() - startedAt);		}	} catch (error) {		if (runtime.signal.aborted) {			ended = true;			for (const waiter of await progress.stop()) waiter.reject(error);			throw error;		}		result = { isError: true, diagnostics: [toolDiagnostic("tool_error", errorMessage(error))] };		// A throw, from `execute()` or from building the environment, ends the task `failed`, which cancels what the call		// owned; it no longer supervises it. The error text is already in the result.		ending = { status: "failed", message: `Tool ${call.name} threw` };	}	ended = true;	// No admission starts after `ended`; let those underway commit, so settlement aborts and lists their calls.	await Promise.allSettled(admissions);	reported.output.end();	// Details still waiting for a progress commit settle with the terminal commit, the final flush.	const pending = await progress.stop();	try {		const settled = await finalResult(runtime, input, call, tool, result, reported, context);		await settle(runtime, input, call, ending, () => settled, context, durationMs);	} catch (error) {		for (const waiter of pending) waiter.reject(error);		throw error;	}	for (const waiter of pending) waiter.resolve();}
Dòng 434–438: lỗi xảy ra khi invocation đã bị báo hiệu (abort, đóng) thì lan ra thay vì thành kết quả; invocation abort hoặc lần mở sau sẽ quyết định. Dòng 446: chờ các lời gọi lồng nhau đang được nhận, để bước settle thấy hết chúng.

Môi trường được dựng bằng HarnessOptions.env cho mỗi lần chạy, từ cwd hiện tại của conversation, nên một lần chạy lại sau phục hồi có thể thấy thư mục khác lần đầu (test ở harness-tools-recovery.test.ts:165–211). durationMs đo bằng đồng hồ đơn điệu, chỉ cho lần thử này.

Ba kênh kết quả và structuredOutput

Kết quả có ba kênh: output cho mô hình, structuredOutput cho chương trình gọi tool (lời gọi lồng nhau, script code mode), và details cho UI (types.ts:155–175). Transcript chỉ lưu output và details. Trước khi settle, finalResult() lấy phần đầu ra giữ lại làm output nếu tool không trả, chạy chuỗi afterTool, giới hạn văn bản, rồi kiểm tra structuredOutput với structuredOutputSchema (tool.ts:540–603, tool.ts:607–628). Giá trị sai hợp đồng làm lời gọi lồng nhau thành kết quả lỗi với chẩn đoán invalid_structured_output; với lời gọi của mô hình (vốn không bao giờ thấy kênh này) giá trị chỉ bị bỏ và host được báo qua onReport. Mọi chẩn đoán (diagnostic) được nối vào cuối nội dung thành một khối <harness>, nên message lưu trong transcript đúng bằng thứ mô hình đã đọc, còn danh sách có cấu trúc nằm trong data.diagnostics của entry (tool.ts:885–913).

settle(): dọn lời gọi con, rồi một commit kết quả

settle(): huỷ lời gọi lồng nhau còn chạy, nối entry pi.tool-result, đánh dấu slot done, kết thúc task
pi/packages/durable/src/harness/tool.tsdòng 646–660, 674–697TypeScript
// A crash in between leaves the call in `execute`: a safe rerun reattaches to the calls, an interruption ends here.// Every live nested call the task owns, also those an earlier attempt made and this one never called again.const nestedIds = (await runtime.ownedTasks(context))	.filter((record) => record.kind === ToolTask.definition.name && (record.input as ToolTaskInput).kind === "nested")	.map((record) => record.id);// Read before the settling commit: no admission starts after `ended`, and those underway were awaited.const madeNested = (await runtime.memo(MADE_NESTED_CALLS, context)) === true;// Mark them all before waiting for any: one may run until a sibling is cancelled.await Promise.all(nestedIds.map((id) => runtime.abortOwned(id, context)));await runtime.commit(async (tx) => {	const conversationId = runtime.conversationId;	const live = await tx.doc(LiveDoc, conversationId);	const slot = toolSlot(live, runtime.taskId);	const result = build(slot);	let settled: ToolTaskResult;	} else {		const timing = { timestamp: runtime.now(), durationMs };		const entry = await appendToolResult(tx, conversationId, call, result, timing);		if (slot !== undefined && !("parentTaskId" in slot)) {			finishSlot(slot);			slot.entry = entry.id;		}		// Tools build control objects freely; drop keys set to undefined so the task result is strict JSON.		const control =			result.control === undefined || ending.status !== "completed"				? {}				: { control: copyJson(result.control as JsonValue, { omitUndefinedProperties: true }) as ToolControl };		settled = { kind: "model", entryId: entry.id, ...control };	}	// A call that made no nested calls, in any attempt, has no slots below it; skipping the scan keeps many leaves	// linear. Committed before the read: no nested call is admitted after `ended`.	if (madeNested) removeNestedSlots(live, runtime.taskId);	if (ending.status === "aborted") return { status: "terminal", outcome: { status: "aborted", result: settled } };	if (ending.status === "failed") {		const error = { message: ending.message };		return { status: "terminal", outcome: { status: "failed", error, result: settled } };	}	return { status: "terminal", outcome: { status: "completed", result: settled } };}, context);
Lời gọi lồng nhau được tìm qua ownedTasks(), tức là cả những lời gọi một lần thử trước đã tạo mà lần chạy lại không gọi nữa. Tất cả bị đánh dấu abort trước khi chờ cái nào, vì một lời gọi có thể chạy mãi cho tới khi anh em của nó bị huỷ.

Các control (terminate, handoff, addTools) chỉ được ghi vào kết quả khi task kết thúc completed (dòng 682–685); generation đọc chúng ở phase tools. usage của kết quả được cộng vào pi.usage.tools[tên tool] trong cùng commit.

Hook của tool và memo: quyết định không lặp lại sau crash

Hook beforeTool (chặn hoặc sửa đối số) và afterTool (thay kết quả) chạy trong invocation của task tool, ngoài dòng commit. Harness không tự lưu quyết định của hook. Thứ nó lưu là hệ quả: đối số cuối cùng trong ý định. Vì vậy sau khi ý định đã được ghi, phục hồi không bao giờ gọi lại beforeTool (spec.md:3456–3464). Nhưng crash xảy ra trước ý định thì phase call chạy lại từ đầu và hook được hỏi lần nữa. Một hook mà câu trả lời phải ổn định, ví dụ xin người dùng phê duyệt, dùng api.memo(name, candidate) (mẫu approval:decision ở harness-tools.test.ts:810–816): HookApi chính là runtime của task, nên memo nằm trong TaskRecord.memos, người ghi đầu thắng, và tồn tại qua crash (spec.md:2922–2923). Test chứng minh cả hai nửa:

Phase call bị cắt ngang trước ý định: beforeTool được hỏi lại, memo giữ quyết định đầu, tool chỉ chạy một lần
pi/packages/durable/test/harness-tools-recovery.test.tsdòng 213–250TypeScript
it("reruns beforeTool when interrupted before intent, and executes once", async () => {	const path = await sqlitePath();	const setup = chatSetup();	let runs = 0;	addTool(		setup.registry,		tool("work", async () => {			runs++;			return { output: [] };		}),	);	const reached = deferred();	let asked = 0;	const decisions: string[] = [];	addHooks(setup.registry, ToolTask, {		beforeTool: async (_call, api, callContext) => {			asked++;			// A durable first-writer-wins decision survives the rerun.			decisions.push(await api.memo("test:decision", `attempt ${asked}`, callContext));			if (asked === 1) {				reached.resolve();				await aborted(callContext.abortSignal!);			}			return undefined;		},	});	setup.faux.setResponses([call("work"), DONE]);	let opened = await open(path, setup);	const id = (await opened.root.submit({ type: "input", content: "go" }, context)).id;	await reached.promise;	await opened.harness.close(context);	opened = await open(path, setup);	expect((await (await opened.harness.submission(id, context))!.wait(context)).status).toBe("done");	expect([asked, runs]).toEqual([2, 1]);	expect(decisions).toEqual(["attempt 1", "attempt 1"]);	await opened.harness.close(context);});
Test cắt ngang bằng close(); với việc phase chạy lại từ đầu thì close và crash như nhau, chỉ khác ở bộ đếm restart. Hook và task dùng chung một không gian tên memo, nên tác giả hook nên đặt tiền tố cho tên (ở đây là test:decision, spec.md:5519–5520).

Lời gọi lồng nhau, callers và tool hoãn nạp#

Một script code mode hay một cầu nối MCP gọi các tool khác từ bên trong một tool. Nếu những lời gọi đó là lời gọi hàm bình thường, crash làm mất dấu cái nào đã chạy, lần chạy lại sẽ chạy lại tất cả, và chúng không có hook, giới hạn đầu ra hay hiển thị riêng. Pi Durable biến mỗi lời gọi như vậy thành một task pi.tool riêng, do task của tool gọi sở hữu, qua api.executeTool():

executeTool(): khoá theo thứ tự gọi, nhận lời gọi trong một commit, chờ trong bộ nhớ, đọc kết quả từ NestedCallDoc
pi/packages/durable/src/harness/tool.tsdòng 390–418TypeScript
executeTool: async (name, toolArgs, callContext, options = {}) => {	assertLive();	const key = options.key ?? String(++sequence);	if (options.key !== undefined) checkKey(options.key);	// The memo first, once per attempt; a failed one is tried again by the next call.	madeNestedMemo ??= runtime.memo(MADE_NESTED_CALLS, true, callContext).catch((error: unknown) => {		madeNestedMemo = undefined;		throw error;	});	// Registered now, so cleanup waits for it.	const admission = madeNestedMemo.then(() =>		startNestedCall(			runtime,			call.id,			name,			toolArgs,			{ key, progress: options.progress, abandonOnRestart: !resumes },			callContext,		),	);	admissions.push(admission);	const id = await admission;	const settled = await runtime.waitForTask(id, callContext);	const stored = (await runtime.snapshot(NestedCallDoc, runtime.taskId, key, callContext))?.result;	// A copy the tool may change; a nested call the scheduler faulted or orphaned stored none.	return stored === undefined		? fallbackResult(id, name, settled.state.outcome)		: (copyJson(stored as unknown as JsonValue) as unknown as NestedToolExecutionResult);},
Khoá mặc định là vị trí của lời gọi trong lần thử này: 1, 2, ... Lời gọi lồng nhau có ID <callId>/<key>. Khoá tường minh không được là số nguyên dương, để không bao giờ trùng khoá mặc định (tool.ts:939–949).
startNestedCall(): khoá đã dùng thì gắn lại task cũ, khoá mới thì tạo task con
pi/packages/durable/src/harness/tool.tsdòng 744–770TypeScript
await runtime.commit(async (tx) => {	// Opened, or created empty for a key not made yet.	const state = await tx.doc(NestedCallDoc, runtime.taskId, key, null);	const existing = state.taskId;	if (existing !== undefined) {		const input = (await tx.task(existing))?.input as ToolTaskInput | undefined;		const same =			input?.kind === "nested" &&			input.call.name === name &&			jsonEqual(input.call.arguments as JsonValue, call.arguments as JsonValue);		if (!same) throw new Error(`Nested call ${call.id} was already made with another tool or other arguments`);		id = existing as TaskId<ToolTaskResult>;		replayed = state.result !== undefined;		return undefined;	}	const input: ToolTaskInput = {		kind: "nested",		parent: runtime.taskId,		parentCallId,		key,		call,		...(options.progress === false ? { progress: false } : {}),	};	const created = await tx.createTask(ToolTask, input, {		ownership: { kind: "task", taskId: runtime.taskId },		abandonOnRestart: options.abandonOnRestart,	});
Cùng khoá nhưng khác tên tool hay khác đối số thì ném lỗi: lần chạy lại đã đi đường khác. Dòng 769: lời gọi do một tool không replay-safe tạo ra nhận abandonOnRestart.

Mỗi lời gọi lồng nhau là một thành viên của NestedCallDoc, một họ document (document family) có phạm vi task của tool gọi, khoá theo khoá lời gọi, chứa { taskId, result? }. Một document cho mỗi lời gọi, nên nhận một lời gọi mới không phải viết lại các lời gọi khác; tất cả nghỉ hưu cùng tool gọi (tool.ts:101–114). Kết quả lưu ở đó đúng bằng thứ executeTool() trả về, không có output dành cho mô hình, nên một ảnh hay đuôi đầu ra bash chỉ được lưu một lần. Từ đó suy ra hai hành vi khi restart:

  • Tool gọi replay-safe chạy lại và gọi theo cùng thứ tự (hoặc cùng khoá): mỗi executeTool() gắn lại vào task đã có, đã xong hay còn chạy, thay vì tạo mới. Đây không phải exactly-once: một lời gọi con bị crash cắt ngang trước khi commit kết quả theo chính sách replay của nó (spec.md:3201–3209). Test: tool gọi chạy hai lần, con safe chạy hai lần, con unsafe một lần và trả interrupted, tổng cộng vẫn chỉ có hai task con (harness-nested-tools-restart.test.ts:400–463).
  • Tool gọi không replay-safe không bao giờ chạy lại, nên các lời gọi con chưa xong của nó bị bỏ (abandoned) trước khi kịp chạy lại: con đã ghi ý định nhận interrupted, con chưa ghi nhận abandoned (harness-nested-tools-restart.test.ts:93–166).

Ai được gọi tool nào

Ba khái niệm liên quan: callers trên tool (mô hình, tool khác, hay cả hai), modelTools trên agent của conversation (thu hẹp những tool được mời mô hình), và deferred (tool chỉ được mời sau khi được nạp). Phép phân giải agent ghép chúng lại:

resolveAgent(): tools mời mô hình, deferred chờ nạp, callable cho lời gọi lồng nhau
pi/packages/durable/src/harness/agent.tsdòng 259–266TypeScript
const enabled = filterTools([...composed.values()], state?.tools);const callableBy = (caller: "model" | "tools") => (tool: Tool) =>	tool.callers === undefined || tool.callers.includes(caller);const modelCallable = enabled.filter(callableBy("model"));const tools = offeredTools(modelCallable, state?.modelTools);const offered = new Set(tools);const deferred = modelCallable.filter((tool) => tool.deferred === true && !offered.has(tool));const callable = enabled.filter(callableBy("tools"));

Lời gọi của mô hình được tra trong tools, lời gọi lồng nhau trong callable. Cách dùng điển hình: tool codemode là callers: ["model"] nên script không khởi chạy script; các tool MCP có thể khai báo callers: ["tools"] để mô hình không bao giờ được mời chúng mà chỉ gọi qua script (README.md:333–347). Tool hoãn nạp được nạp bởi tool tool_search, bản sao của coding agent (chương Codemode, MCP và tool search):

tool_search: xếp hạng Agent.deferred bằng BM25 và nạp kết quả qua control.addTools
pi/packages/durable/src/tool-search/tool-search.tsdòng 60–85TypeScript
// Searching changes what the model is offered; scripts have their own `searchTools()`.callers: ["model"],replay: "safe",async execute({ query, limit }, api, context) {	if (query.trim() === "") throw new Error("query must not be empty");	const max = limit ?? DEFAULT_TOOL_SEARCH_LIMIT;	if (!Number.isInteger(max) || max <= 0) throw new Error("limit must be a positive integer");	const candidates = (await api.agent(context)).deferred;	const documents = candidates.map((tool) => ({ name: tool.name, text: searchText(tool) }));	const loaded = rankDocuments(query, documents, max);	const text =		loaded.length === 0			? "No matching tools found."			: `Loaded ${loaded.length} tool${loaded.length === 1 ? "" : "s"}. They are available from your next call:\n${loaded					.map((name) => {						const description = candidates.find((tool) => tool.name === name)?.description ?? "";						return `- ${name}: ${description.trim().split(/\r?\n/)[0]}`;					})					.join("\n")}`;	const details: ToolSearchToolDetails = { loaded };	return {		output: [{ type: "text", text }],		details,		...(loaded.length === 0 ? {} : { control: { addTools: loaded } }),	};},
Nạp bằng control thay vì sửa document trực tiếp: addTools được áp ở phase tools của generation, ghi tên vào modelTools.add của pi.agent.

Vì pi.agent là document rewindable và fork theo asOf, tool đã nạp thuộc về từng nhánh và vẫn được mời sau compaction. Tool đã nạp đứng sau các tool khác theo thứ tự nạp, nên một lần nạp chỉ nối thêm khai báo vào transcript và tiền tố đã cache ở provider vẫn giữ (spec.md:3270–3281), đúng lập luận của bài "Prompt Caching In Agents" về nạp tool cộng dồn.

Inbox: steer, follow-up và write được đặt ở đâu#

Người dùng gõ thêm trong lúc agent đang làm. Tin nhắn đó vào transcript ở đâu? Vị trí không tuỳ tiện: kết quả tool phải đứng ngay sau lời gọi của nó, và prompt cache của provider là một tiền tố chính xác. Pi Durable xếp input và write gửi tới một conversation bận (trừ write chỉ để hiển thị, xem bên dưới) vào document pi.inbox rồi đặt chúng ở hai loại ranh giới (boundary): postTools sau một vòng tool, và final sau một câu trả lời (spec.md:2579–2592).

Boundarywritesteerfollow-up
postTools (sau vòng tool)tất cảcái đầu hoặc tất cả, theo steeringModekhông
final (sau câu trả lời)tất cảcái đầu hoặc tất cảcái đầu hoặc tất cả, theo followUpMode
  • Steer (whenBusy: "steer") được đặt sau vòng tool hiện tại và gia nhập run đang chạy: ID của nó được nối vào pi.live.run.inputs (dòng 731 của finishToolRound()). Mặc định mỗi boundary chỉ đặt một steer (one-at-a-time, agent.ts:78–79).
  • Follow-up (mặc định) được đặt khi run trả lời, và mở run kế (startRun() trong answer()).
  • Write chỉ có kind và data (không model, head, edits) không đổi gì mô hình thấy, nên được nối ngay cả khi đang bận, không qua inbox, trừ khi đứng sau một reset đang xếp hàng (submissions.ts:220–228). Write có model, head hay edits chờ boundary như một steer.

Một write mang head trỏ về trước điểm bắt đầu của dải context hiện hành là "cũ" (stale): đặt nó sẽ mang lại phần lịch sử mà một head mới hơn đã cắt, nên nó được settle unanswered với lý do stale (inbox.ts:144–147). Bản tóm tắt compaction chính là head write, nên quy tắc này sắp xếp luôn các compaction chạy song song, như mục sau cho thấy. Khi run thất bại, các mục xếp hàng ở yên trong inbox tới input hay write kế tiếp, trừ khi settings.afterFailedRun nói khác (spec.md:2559–2566).

Compaction là một task#

Compaction (nén ngữ cảnh) ở Pi Durable dùng cùng ý tưởng với coding agent (chương Compaction): tóm tắt một tiền tố cũ của context, nối một entry pi.compaction chứa bản tóm tắt với head trỏ vào entry đầu tiên được giữ nguyên văn; lịch sử thô vẫn ở trong storage. Khác biệt là nó là một task bền pi.compaction, với ba cách khởi động (spec.md:4471–4475):

Khởi động bởiOwnerBackgroundGeneration chờ?Đặt bản tóm tắt
Conversation.compact()conversationkhôngkhôngwrite submission
generation, trên ngưỡng nềnconversationcókhôngwrite submission
generation, trên ngưỡng chặn hoặc khi tràngenerationkhôngcónối thẳng
createCompaction(): owner quyết định vừa có chặn hay không, vừa có background hay không
pi/packages/durable/src/harness/compaction.tsdòng 242–259TypeScript
/** * Create a compaction task with its status in this commit. `owner` is the generation that waits for it (a blocking * compaction); without one it is conversation-owned, and `background` unless it is manual. */export async function createCompaction(	tx: Tx,	conversationId: ConversationId,	input: CompactionInput,	owner?: TaskId,): Promise<TaskId<CompactionResult>> {	const ownership =		owner === undefined ? ({ kind: "conversation" } as const) : { kind: "task" as const, taskId: owner };	const background = owner === undefined && input.reason !== "manual";	const taskId = await tx.createTask(CompactionTask, input, { ownership, conversationId, background });	const status = { taskId, reason: input.reason, blocking: owner !== undefined, attempt: 1 };	addCompactionStatus(await tx.doc(LiveDoc, conversationId), status);	return taskId;}

Ngưỡng tính theo cửa sổ của mô hình hiện tại. Mặc định reserveTokens 16 384, keepRecentTokens 20 000, backgroundTokens 32 768 (agent.ts:27–32). Trên contextWindow - reserveTokens, request kế phải chờ một compaction chặn; trên contextWindow - reserveTokens - backgroundTokens (khi backgroundTokens > 0), một compaction nền bắt đầu trong khi conversation vẫn làm việc. Cả hai chỉ áp dụng khi phép chọn điểm cắt tìm được chỗ cắt (generation.ts:344–361). modelOverrides cho phép một harness phục vụ cả mô hình 32k lẫn 1M, theo khoá chính xác "provider/modelId" (compaction.ts:261–271). Ước lượng kích thước lấy usage thật của câu trả lời gần nhất sau head marker, cộng ước lượng của các message sau nó (compaction.ts:326–346).

Phase select: chọn điểm cắt, hỏi beforeCompact, rồi ghim request tóm tắt
pi/packages/durable/src/harness/compaction.tsdòng 117–156TypeScript
select: async (task, runtime, context) => {	const { conversationId } = runtime;	const agent = await runtime.agent(context);	const settings = runtime.settings;	const ref = agent.model;	const model = ref === undefined ? undefined : runtime.models.getModel(ref.provider, ref.modelId);	if (ref === undefined || model === undefined) return failNoModel(runtime, ref, context);	const policy = compactionPolicy(settings, ref);	const view = await runtime.context(conversationId, context);	const cut = selectCut(view, policy.keepRecentTokens);	if (cut === undefined) return complete(runtime, context);	const firstKept = view.entries[cut]!.id;	const { reason, instructions } = task.input;	const compaction = {		reason,		entries: view.entries.slice(0, cut),		messages: summarizedMessages(view, cut),		firstKept,		...(instructions === undefined ? {} : { instructions }),	};	let decision: { readonly decline: true } | { readonly summary: string } | undefined;	await runtime.hooks.each("beforeCompact", async (hook) => {		if (decision === undefined) decision = await hook(compaction, runtime, context);	});	if (decision !== undefined && "decline" in decision) return complete(runtime, context);	if (decision !== undefined) return place(runtime, firstKept, decision.summary, context);	const request: SummaryRequest = {		attempt: 1,		model: ref,		thinkingLevel: agent.thinkingLevel,		streamOptions: settings.stream,		maxTokens: Math.min(			Math.floor(0.8 * policy.reserveTokens),			model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,		),		tail: view.entries.reduce((tail, entry) => (entry.id > tail ? entry.id : tail), firstKept),		firstKept,	};	await runtime.commit(() => ({ status: "running", checkpoint: { phase: "summarize", ...request } }), context);},
beforeCompact: quyết định đầu tiên thắng, { decline: true } hoàn tất không tóm tắt, { summary } đặt bản tóm tắt do hook cung cấp ngay. maxTokens là min(floor(0.8 × reserveTokens), model.maxTokens).

Phase summarize dựng lại các message cần tóm tắt từ context tại tail (bất biến, nên đúng là dải mà select đã chọn), tuần tự hoá thành văn bản thuần ([User]: ..., [Tool result]: ... cắt ở 2 000 ký tự, bỏ system message và thinking), và gửi một request riêng không có tool với cacheRetention: "none" (compaction.ts:157–218). Usage của mọi lần thử được cộng vào pi.usage ngay cả khi bản tóm tắt bị bỏ. Lỗi tạm thời ngủ rồi thử lại như generation; thất bại thì ghi pi.live.compactionFailure cho UI.

placeSummary(): compaction chặn nối thẳng; compaction của conversation đi qua admission như một write
pi/packages/durable/src/harness/compaction.tsdòng 434–466TypeScript
async function placeSummary(	tx: Tx,	runtime: Runtime,	current: RunningTask<CompactionInput, CompactionCheckpoint, CompactionResult>,	live: Draft<LiveState>,	firstKept: EntryId,	summary: string,): Promise<Next> {	removeCompactionStatus(live, runtime.taskId);	delete live.compactionFailure;	const text = `${SUMMARY_PREFIX}${summary}${SUMMARY_SUFFIX}`;	const entry = {		kind: CompactionEntry.kind,		head: firstKept,		model: [{ role: "user", content: [{ type: "text", text }], timestamp: runtime.now() }],		data: { reason: current.input.reason },	} satisfies EntryDraft;	const result: CompactionResult =		current.owner === undefined			? {					submissionId: (						await admitSubmission(							tx,							runtime.conversationId,							{ type: "write", requestId: `compaction:${runtime.taskId}`, entry },							runtime.now(),							runtime.settings,						)					).id,				}			: { entryId: (await tx.appendEntry(runtime.conversationId, entry)).id };	return { status: "terminal", outcome: { status: "completed", result } };}
Việc nhận submission và kết quả completed nằm trong cùng một commit, nên crash không thể tách chúng. Kết quả trả về submissionId để host theo dõi lúc bản tóm tắt thực sự được đặt, hoặc settle stale (README.md:555–560).

Vì sao hai đường đặt khác nhau? Compaction chặn chạy khi generation đang giữ run và chờ nó, nên không ai khác viết vào conversation; nối thẳng là an toàn. Compaction nền hay thủ công chạy song song với run, mà chỉ Harness mới được nối vào một conversation đang bận, nên bản tóm tắt phải là một write submission đặt ở boundary kế (compaction.ts:428–433). Khi nhiều compaction cùng chạy, quy tắc stale chọn kẻ thắng mà không cần phối hợp: compaction nền B chọn ở tail 100, cắt ở 70; trong lúc B tóm tắt, compaction chặn A cắt ở 150 và nối bản tóm tắt; bản tóm tắt của B tới sau, head 70 nằm trước 150, nên settle stale (spec.md:2600–2611).

Lần theo một SIGKILL giữa lời gọi tool#

Giờ ghép mọi mảnh lại. Kịch bản: host Node dùng SQLite, registry có CodingTools (trong đó có bash) và extension subagent của 22-subagent-foreground.ts, tool khai báo replay: "safe". Người dùng gửi input S1. Generation G1 trả lời bằng hai lời gọi trong cùng một vòng song song: subagent (lời gọi a) và bash với npm test (lời gọi b). Subagent tạo conversation con c2, gửi submission S2, và generation G2 của c2 đang stream câu trả lời. npm test đã in vài dòng. Đúng lúc đó tiến trình bị kill -9.

Ví dụ minh hoạ: những gì đã commit lúc tiến trình chết
c1 pi.live.run   = { taskId: G1, inputs: [S1] }                    S1: placed
c1 pi.live.tools = [ { callId: a, taskId: Ta, running, details: { conversationId: c2 } },
                     { callId: b, taskId: Tb, running, output: "> npm test\n  12 passing\n" } ]
G1  pi.generation  waiting  { phase: tools, on: [Ta, Tb], policy: allSettled }
Ta  pi.tool        running  { phase: execute, arguments: { task: … }, replay: safe }
Tb  pi.tool        running  { phase: execute, arguments: { command: "npm test" }, replay: unsafe }
c2 (owner Ta)    pi.live.run = { taskId: G2, inputs: [S2] }        S2: placed, requestId "subagent:Ta"
                 pi.live.generation = { attempt: 1, message: partial đã commit }
G2  pi.generation  running  { phase: request, attempt: 1, model, cutoff, … }
Phần không được commit (vài chục mili giây token cuối của G2, vài dòng đầu ra cuối của npm test) mất theo tiến trình. Mọi thứ ở trên đều đã nằm trong SQLite.
  1. 1. Mở lạiTiến trình mới cài lại đúng các extension vào registry trước khi mở, để công việc phục hồi chạy được ngay (spec.md:732–734), rồi gọi Harness.open(await openNodeSqliteStorage(path), …). open kiểm tra ba task dựng sẵn và gọi scheduler.open() (harness.ts:540–561). Đối soát hạ Ta, Tb, G2 từ running xuống pending với restarts: 1; G1 đang waiting nên giữ nguyên (scheduler.ts:394–398). Không handler nào chạy; pi.live vẫn hiển thị hai slot running với đầu ra cũ, và đó đúng là trạng thái một UI mới kết nối sẽ thấy.
  2. 2. resume()Host gọi harness.resume() (README.md:124); một submission.wait() cũng có tác dụng tương tự. Pass reservation đầu tiên: không task nào mang abandonOnRestart (Ta là replay-safe, G2 do Harness tạo khi nhận S2), nên nó reserve luôn. G1 bị bỏ qua vì #waitingOn() còn thấy Ta sống. Ta, Tb, G2 cùng version với definition hiện tại, nên một commit chuyển cả ba sang running và tạo ba invocation (scheduler.ts:1488–1553).
  3. 3. G2: generation đang streamPhase request chạy lại từ đầu. Commit đầu tiên của nó nối partial đã commit thành một entry pi.assistant với stop reason aborted trong c2, có tính usage (generation.ts:430–439). Rồi nó gửi lại đúng các message tới cutoff đã ghim, với model, mức suy nghĩ và stream option đã ghim; entry aborted không vào request (generation.ts:209–236). Câu trả lời stop đi vào answer(): một commit nối câu trả lời, settle S2 done, xoá run của c2 và kết thúc G2 completed. Trạng thái mới xoá restarts.
  4. 4. Ta: tool replay-safeỞ phase execute, chính sách đã lưu và khai báo hiện tại đều là safe, nên Ta xoá tiến độ cũ của slot và gọi lại execute() với đối số đã lưu, không hỏi lại beforeTool (tool.ts:185–192). Tool subagent được viết để chạy lại vô hại: scanConversations({ ownerTaskId }) tìm thấy c2 thay vì tạo con mới, và request ID subagent:${api.taskId} làm submit() trả về chính S2 (22-subagent-foreground.ts:52–68; tra request ID ở submissions.ts:210–217). wait() trả về khi G2 settle S2. Ta nối pi.tool-result cho lời gọi a và kết thúc completed. Vì S2 được settle trong commit terminal của G2, lúc Ta settle thì c2 không còn việc sống, nên Ta đi thẳng tới terminal thay vì completing.
  5. 5. Tb: tool không replay-safePhase execute thấy replay: "unsafe", không chạy lại, mà dựng kết quả lỗi từ slot: đầu ra đã commit, details cuối, các chẩn đoán đã ghi, cộng chẩn đoán interrupted (tool.ts:194–197, tool.ts:824–840). Mô hình sẽ đọc phần đầu ra đã commit, rồi một khối <harness> chứa dòng [error] Tool bash was interrupted and may have partially run, đúng dạng mà test kiểm với một lệnh bash thật (harness-tools-recovery.test.ts:338–364). Tb kết thúc failed kèm entryId.
  6. 6. G1 tỉnh dậyCommit terminal của Ta và Tb đi qua #track(), hàm đưa task đang chờ chúng (G1) về danh sách chạy được (scheduler.ts:1026). Pass reservation kế thấy on đã terminal hết, chuyển G1 từ waiting sang running, và phase tools gọi finishToolRound(): đọc kết quả, chạy afterTools, không có terminate hay handoff, nên áp boundary postTools, tạo generation G3 và trao run cho nó. G1 kết thúc completed; S1 vẫn placed.
  7. 7. G3 và submissionG3 chuẩn bị request mới. Context có câu trả lời gọi tool của G1 và hai kết quả theo thứ tự lời gọi: câu trả lời của subagent, và lỗi interrupted của bash với phần đầu ra một phần. Mô hình tự quyết định chạy lại npm test hay không. Khi G3 trả lời, answer() settle S1 done. Host lấy lại submission bằng harness.submission(id) và wait() (README.md:138); cuối cùng pi.live của c1 trở về {}, như test khẳng định (harness-tools-recovery.test.ts:125–132).

Ba biến thể làm rõ vì sao từng chi tiết tồn tại:

  • Tiến trình chết trước khi Tb ghi ý định. Checkpoint vẫn là call, nên cả phase chạy lại: phân giải, kiểm tra, beforeTool lần nữa (memo giữ quyết định cũ), ghi ý định, rồi chạy lệnh lần đầu tiên. Không có lỗi interrupted, vì chắc chắn lệnh chưa chạy.
  • Không cài lại extension subagent. Tên tool của Ta không phân giải được, nên bị coi là unsafe: Ta nhận interrupted và kết thúc failed. failed là ý định huỷ; nếu lúc đó c2 còn G2 sống (thường là vậy, vì G2 đang gửi lại request), Ta giữ completing, pass đối soát đánh dấu abort G2, handler abort của G2 chuyển partial và settle S2 unanswered với lý do aborted; chỉ khi đó Ta mới terminal. Đây chính là lý do kết quả interrupted dùng failed (tool.ts:195–196).
  • Cùng một crash lặp lại năm lần liên tiếp trước khi Ta, Tb, G2 kịp commit trạng thái mới: cả ba bị đỗ với lý do restart_limit, conversation vẫn bận, và host phải gọi retryTask().

Task con và subagent qua ví dụ#

Checkout với bốn payment (ví dụ 24)

test/examples/24-child-tasks.ts là ví dụ chuẩn của cây sở hữu bên trong một conversation. Mỗi payment trừ tiền (hiệu ứng), ngủ 100 ms chờ ngân hàng xác nhận mà không giữ invocation, rồi hoàn tất; thẻ hết hạn bị từ chối ngay. Handler abort() của payment tự hoàn tiền, tức là mỗi task hoàn tác hiệu ứng của chính nó:

Payment: thất bại ngay với thẻ hết hạn, ngủ chờ xác nhận, tự hoàn tiền khi bị huỷ
pi/packages/durable/test/examples/24-child-tasks.tsdòng 30–60TypeScript
const Payment = defineTask<PaymentInput, PaymentState, PaymentResult>({	name: "example.payment",	version: 1,	initial: () => ({ phase: "charge" }),	phases: {		charge: async (task, runtime, taskContext) => {			const card = task.input.card;			if (card.startsWith("expired")) {				const error = { message: `${card} declined` };				await runtime.commit(() => ({ status: "terminal", outcome: { status: "failed", error } }), taskContext);				return;			}			charged.add(card);			// The bank confirms after a moment: wait for it without holding an invocation.			await runtime.sleepUntil(runtime.now() + 100, { phase: "confirm" }, taskContext);		},		confirm: async (task, runtime, taskContext) => {			const card = task.input.card;			await runtime.commit(				() => ({ status: "terminal", outcome: { status: "completed", result: { card } } }),				taskContext,			);		},	},	// Each payment undoes its own effect when aborted.	abort: async (task, runtime, taskContext) => {		const refunded = charged.delete(task.input.card);		console.log(`  payment ${task.input.card} aborted${refunded ? ", refunded" : ""}`);		await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);	},});
Checkout: tạo bốn task con và chờ failFast trong cùng một commit, rồi đọc kết quả ở phase decide
pi/packages/durable/test/examples/24-child-tasks.tsdòng 62–98TypeScript
// The checkout creates its payments as child tasks and waits for all of them.// With `failFast`, the first payment that fails aborts the others; the checkout// itself is not aborted and decides its outcome once every payment is done.type CheckoutState = { phase: "pay" } | { phase: "decide"; payments: TaskId<PaymentResult>[] };const Checkout = defineTask<{ cards: string[] }, CheckoutState, string>({	name: "example.checkout",	version: 1,	initial: () => ({ phase: "pay" }),	phases: {		pay: async (task, runtime, taskContext) => {			await runtime.commit(async (tx) => {				const payments: TaskId<PaymentResult>[] = [];				for (const card of task.input.cards) {					payments.push(await tx.createTask(Payment, { card }, { ownership: { kind: "task", taskId: task.id } }));				}				return { status: "waiting", checkpoint: { phase: "decide", payments }, on: payments, policy: "failFast" };			}, taskContext);		},		decide: async (task, runtime, taskContext) => {			const outcomes = await runtime.outcomes(task.state.checkpoint.payments, taskContext);			console.log(`  payments: ${outcomes.map((outcome) => outcome.status).join(", ")}`);			const paid = outcomes.every((outcome) => outcome.status === "completed");			await runtime.commit(				() =>					paid						? { status: "terminal", outcome: { status: "completed", result: "order placed" } }						: { status: "terminal", outcome: { status: "failed", error: { message: "payment failed" } } },				taskContext,			);		},	},	// Runs only after every payment is done, so the refunds have already happened.	abort: async (_task, runtime, taskContext) => {		console.log("  checkout aborted");		await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);	},});
Bốn task con và trạng thái waiting nằm trong cùng một commit: không có thời điểm nào checkout đã tạo payment mà chưa ghi là đang chờ chúng.

Ví dụ chạy ba kịch bản. Một thẻ bị từ chối: payment của expired-2 kết thúc failed; pass đối soát thấy checkout đang chờ failFast và đánh dấu abort ba payment còn sống; chúng đang ngủ, mà abort mark kết thúc giấc ngủ, nên handler abort của từng cái chạy và hoàn tiền; khi cả bốn terminal, checkout tỉnh dậy ở decide và đọc [aborted, failed, aborted, aborted] (spec.md:2396–2402). Khách huỷ: abortTask(checkout) đánh dấu checkout, cascade đánh dấu các payment, handler abort của payment chạy trước, rồi mới tới Checkout.abort, nên dòng "checkout aborted" luôn in sau các dòng hoàn tiền. Tiến trình dừng giữa chừng: Harness đóng khi các payment đang ngủ; Harness mới chỉ cần waitForTask(id), lời gọi này bật lập lịch, và các deadline đã lưu đưa payment tới confirm (24-child-tasks.ts:137–143). Trong lúc chạy, ví dụ in cây task từ task graph (24-child-tasks.ts:106–116).

Subagent tiền cảnh (ví dụ 22)

Subagent là một conversation con do task của lời gọi tool sở hữu. Tool tạo nó bên trong api.commit() với ownership: { kind: "task", taskId: api.taskId }, rồi điều khiển qua api.conversation(id):

Tool subagent replay-safe: tìm lại con cũ, request ID suy từ task ID, details để UI gắn vào con
pi/packages/durable/test/examples/22-subagent-foreground.tsdòng 42–75TypeScript
defineTool({	name: "subagent",	description: "Delegate a self-contained task to a subagent and get its answer back.",	parameters: Type.Object({ task: Type.String({ description: "What the subagent should do" }) }),	// Safe to rerun after a crash: a rerun finds the child it already created and the submission it already made.	replay: "safe",	execute: async (args, api, callContext) => {		const { task } = args;		// The child is owned by this tool call's task, so aborting the call aborts the child, and the call		// finishes only once the child's work is done.		const child = await api.commit(async (tx) => {			// Ownership records the child: a rerun of this call finds it instead of creating another.			const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];			if (existing !== undefined) return existing.id;			// Starts as a copy of this conversation's agent: model, thinking level, cwd, extensions, tools.			const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });			// Without this extension, the child is not offered this tool.			await configure(tx, created.id, { extensions: { remove: [Subagent] } });			return created.id;		}, callContext);		// A UI watching the parent sees this and can attach to the child.		await api.details({ conversationId: child }, callContext);		const handle = (await api.conversation(child, callContext))!;		// The request ID makes a rerun get back the submission it made before the crash.		const request = { type: "input", content: task, requestId: `subagent:${api.taskId}` } as const;		const settled = await (await handle.submit(request, callContext)).wait(callContext);		if (settled.status !== "done" || settled.type !== "input") {			throw new Error(`Subagent failed: ${settled.status}`);		}		const text = await answerText(api, settled.answer, callContext);		return { output: [{ type: "text", text }], details: { conversationId: child } };	},}),
Request ID dùng api.taskId, không dùng ID lời gọi của provider: ID đó không duy nhất trong một Session (spec.md:3500–3503).

Mỗi dòng có chủ đích phục hồi. scanConversations({ ownerTaskId }) dùng index sở hữu để lần chạy lại tìm lại con thay vì tạo con thứ hai; request ID ổn định làm submit() lần hai trả về submission cũ; configure(... { extensions: { remove: [Subagent] } }) chặn subagent tự gọi subagent. Quan hệ sở hữu cho hành vi còn lại miễn phí: huỷ lời gọi thì huỷ con, execute() ném lỗi hay bị interrupted (kết thúc failed) cũng huỷ con, và conversation cha chỉ idle khi con đã idle (README.md:672–676). UI tìm con qua details của lời gọi và in sự kiện của con thụt vào dưới lời gọi (22-subagent-foreground.ts:113–125).

Subagent nền bền vững (ví dụ 23)

Subagent nền phải sống lâu hơn các lượt của agent chính: Esc của người dùng ở conversation chính và lệnh chờ idle không được chạm tới nó. Ví dụ 23 dùng hai task background do conversation chính sở hữu. Anchor sở hữu conversation con và chỉ có một phase commit completed ngay lần chạy đầu; chừng nào con còn việc sở hữu thường, kết quả đó được giữ ở completing, rồi anchor ở yên trạng thái terminal. Vì là background, nó là ranh giới mà phép duyệt thường dừng lại, nhưng abort({ background: true }) vẫn đi xuyên qua được, và một owner đã terminal thì không bao giờ lan abort xuống (23-subagent-background.ts:57–72). Mỗi tin nhắn gửi subagent do một reporter chuyển:

Một commit: anchor background, conversation con do anchor sở hữu, cấu hình con, và một reporter background
pi/packages/durable/test/examples/23-subagent-background.tsdòng 184–204TypeScript
const result = await api.commit(async (tx) => {	const state = await tx.doc(Subagents, api.conversationId);	// Both tasks belong to the main conversation and are background: its Esc and idle waits skip them.	const background = { ownership: { kind: "conversation" }, background: true } as const;	if (action === "spawn") {		if (Object.hasOwn(state.agents, name)) return `${name} already exists; use send.`;		const anchor = await tx.createTask(Anchor, null, background);		// Owned by a task of the main conversation: starts as a copy of the main agent.		const child = await tx.createConversation({ ownership: { kind: "task", taskId: anchor } });		// Subagents cannot start subagents, and know who they are.		await configure(tx, child.id, {			extensions: { remove: [SubagentTools] },			instructions: `You are the subagent "${name}". Answer the main agent's requests.`,		});		state.agents[name] = { conversationId: child.id, reported: [] };	}	const conversationId = state.agents[name]!.conversationId;	const input = { name, conversationId, message, followUp: action === "send" && followUp === true };	state.reporters[api.taskId] = await tx.createTask(Reporter, input, background);	return action === "send" ? `Sent to ${name}.` : `Started ${name}.`;}, callContext);
Reporter: gửi một lần nhờ request ID, quyết định báo cáo trong một commit, rồi đăng follow-up cho agent chính
pi/packages/durable/test/examples/23-subagent-background.tsdòng 86–108, 112–123TypeScript
deliver: async (reporter, runtime, taskContext) => {	const { name, conversationId, message, followUp } = reporter.input;	// While the subagent is busy, a steer reaches it at its next step and a follow-up after its current answer.	const subagent = (await runtime.conversation(conversationId, taskContext))!;	const request = { type: "input", content: message, whenBusy: followUp ? "followUp" : "steer" } as const;	const submission = await subagent.submit({ ...request, requestId: `subagent:${reporter.id}` }, taskContext);	const settled = await submission.wait(taskContext);	// One commit decides the report and records the answer as delivered, so a restart does not decide again.	await runtime.commit(async (tx) => {		const next = (report?: string) => ({ status: "running", checkpoint: { phase: "report", report } }) as const;		// `aborted`: stopped, or withdrawn while queued. Nothing to report.		if (settled.status === "unanswered") {			return next(settled.reason === "aborted" ? undefined : `[subagent ${name} failed: ${settled.reason}]`);		}		// Always an answered input here; the check tells TypeScript.		if (settled.type !== "input") return next();		const agent = (await tx.doc(Subagents, runtime.conversationId)).agents[name]!;		if (agent.reported.includes(settled.answer)) return next();		agent.reported.push(settled.answer);		const answer = (await tx.entry(AssistantEntry, settled.answer))?.model?.[0] as AssistantMessage;		return next(`[subagent ${name} answered, no reply needed] ${textOf(answer)}`);	}, taskContext);},report: async (reporter, runtime, taskContext) => {	const report = reporter.state.checkpoint.report;	if (report !== undefined) {		const main = (await runtime.conversation(runtime.conversationId, taskContext))!;		const input = { type: "input", content: report, whenBusy: "followUp" } as const;		await main.submit({ ...input, requestId: `subagent-report:${reporter.id}` }, taskContext);	}	await runtime.commit(		() => ({ status: "terminal", outcome: { status: "completed", result: null } }),		taskContext,	);},
Hai request ID khác nhau, cùng suy từ ID của reporter: một cho tin nhắn gửi subagent, một cho báo cáo gửi về. Crash trước mỗi lần nhận thì thử lại; crash sau thì nhận lại đúng submission cũ, nên subagent nhận tin một lần và agent chính nhận báo cáo một lần.

Một chi tiết ngược với ví dụ 22: tool quản lý subagent nền khai báo replay: "unsafe", vì chạy lại lệnh stop sau crash có thể dừng nhầm công việc mới hơn (23-subagent-background.ts:145–147). Mô hình thấy lời gọi bị gián đoạn và có thể hỏi status. Replay-safe hay không là quyết định nghiệp vụ, không phải mặc định nên bật.

Code mode bền#

Code mode cho mô hình viết JavaScript gọi các tool của conversation như await tools.read({ path }), chạy trong sandbox QuickJS của pi-codemode; cách codemode hoạt động trong coding agent nằm ở chương Codemode, MCP và tool search. Ở Pi Durable, vấn đề khó hơn: một script là trạng thái VM trong bộ nhớ, và chú thích đầu file nói thẳng vấn đề lẫn lời giải:

codemode.ts: phát lại tất định từ journal thay vì chụp trạng thái VM
pi/packages/durable/src/codemode/codemode.tsdòng 1–36TypeScript
/** * Durable code mode: a tool that runs model-written JavaScript in a QuickJS sandbox, whose script survives a restart. * * Problem: a script is in-memory VM state. A restart while * *     const results = await Promise.all(paths.map((path) => tools.read({ path }))); * * waits for the last of 200 reads would lose the script and every read it made. * * Solution: deterministic replay (`CodemodeSandboxBase.start()` in pi-codemode). The VM only runs when the host hands * it a result; given the same start data, clock, seed, and results in the same order, it makes the same calls with * the same arguments. The tool is replay-safe: after a restart, `execute()` runs again, starts the script again from * the stored start data, and hands it the results it got before, in the order it got them, from a journal written * before each delivery. Nested tool calls reattach to their tasks by key (`c<VM call id>`), so they never run twice * unless their own replay policy reruns them; their results come from `NestedCallDoc`. Then the script continues live. * * Documents, scoped to the code mode call's task and gone when it settles: * - `pi.codemode.run`: the start data, written once before the VM starts, and the outcome, written once when the call *   decides it (with the store writes, in one transaction), so a rerun after that returns it without a VM. * - `pi.codemode.journal`: one entry per delivered result, in delivery order: the VM call ID, the clock value, the *   calls the VM had issued after processing it (once acknowledged), and a global's outcome, which nothing else stores. * * Each result's acknowledged call count is recorded with the next journal commit, or at once when the VM has processed * everything delivered: a script waiting on a long call is where a restart usually finds it. A sequential script * therefore costs one journal commit and one count commit per result, on top of its nested calls' own; that is the * price of checking the most common crash point. * * A call is launched only once the count of calls issued in the drain that made it is committed. So every key an earlier * attempt admitted is at most its last recorded count; a replay checks every recorded count, so when it releases its * calls it has made every call an earlier attempt could have admitted, and later calls are new. * * A rerun refuses to replay (no new nested calls; nested calls admitted earlier may still resume on their own and are * aborted by the call's cleanup) when this module's version or the VM's fingerprint changed, when the deadline passed, * or when the script diverges: it makes another call, or calls in another order, than the journal says. Each ends the * call with an error result. */
Không cần chụp (snapshot) bộ nhớ của VM: chỉ cần lưu những thứ đi vào VM (dữ liệu khởi động và các kết quả theo thứ tự), vì VM tất định sẽ tự tái tạo phần còn lại.

Lời giải dựa trên một tiền đề: VM chỉ chạy khi host trao cho nó một kết quả, và mọi nguồn bất định khác đã bị khoá ở chế độ tất định của pi-codemode:

pi-codemode vm.ts: đồng hồ ảo, ngẫu nhiên có seed, UTC, không WeakRef, thứ tự giao kết quả cố định
pi/packages/codemode/src/runtime/vm.tsdòng 23–37TypeScript
* Deterministic mode (`data.deterministic`, used by `CodemodeSandboxBase.start()`): a script must make the same* calls with the same arguments every time it runs with the same results, so a host can replay it after a restart.* The VM is otherwise deterministic, because it only runs when the host delivers something and has no timers.* What remains is fixed here:* - Time: every WASI clock reads virtual time, `startedAt` and then the `now` of the result being processed, never going backwards.*   While the context is created, the realtime clock reads a value derived from the seed instead, because QuickJS*   seeds `Math.random()` from the first clock read, and which read comes first is the engine's business.* - Randomness: `random_get` draws from a PRNG over the seed.* - Timezone: UTC, not the host's.* - Garbage collection: `WeakRef` and `FinalizationRegistry` are not available; collection timing is not.* - Delivery order: results carry a `seq` and are applied strictly in that order, buffering early ones, because a*   transport (RPC replies) may deliver them in another order than the host sent them.* The VM reports `started` with its fingerprint before the script runs, and `idle` with the number of calls issued* after each drain, so the host can check a replay step by step.*/

Tool codemode là callers: ["model"] và replay: "safe" (codemode.ts:236–248). Mỗi tools.x() trong script là một lời gọi lồng nhau với khoá c<id>, trong đó id là số thứ tự VM đặt cho lời gọi. Ba document lưu những gì cần cho phát lại: pi.codemode.run (dữ liệu khởi động, ghi một lần trước khi VM chạy; và kết cục, ghi một lần), pi.codemode.journal (mỗi kết quả đã giao một mục, theo thứ tự giao), cả hai có phạm vi task; và pi.codemode.store cho store()/load(), phạm vi conversation, fork theo asOf (codemode.ts:98–171).

execute(): kết cục đã có thì trả ngay không cần VM; lần đầu ghi dữ liệu khởi động; lần chạy lại kiểm tra version và deadline
pi/packages/durable/src/codemode/codemode.tsdòng 408–435, 449–459TypeScript
const found = await api.commit(async (tx): Promise<Found> => {	const run = await tx.doc(CodemodeRunDoc, api.taskId);	if (run.result !== undefined) return { done: copyJson(run.result as JsonValue) as ToolExecutionResult };	const rerun = run.start !== undefined;	if (run.start === undefined) {		const store = await tx.doc(CodemodeStoreDoc, api.conversationId);		const timeout = sourceOptions.timeoutMs;		run.start = {			version: CODEMODE_MODULE_VERSION,			startedAt: now,			seed,			...(timeout === undefined ? {} : { deadline: now + timeout }),			tools,			namespaces,			globals,			maxOutputTokens: sourceOptions.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,			store: copyJson(store.values as JsonValue) as Record<string, JsonValue>,		};	}	const journal = await tx.doc(CodemodeJournalDoc, api.taskId);	return {		start: copyJson(run.start as unknown as JsonValue) as unknown as Start,		journal: copyJson(journal.entries as unknown as JsonValue) as unknown as JournalEntry[],		firstIssued: journal.firstIssued,		rerun,	};}, context);if ("done" in found) return found.done;if (this.start.version !== CODEMODE_MODULE_VERSION) {	return this.decide(		errorResult(			`The script cannot resume: it started under code mode version ${this.start.version}, this is ${CODEMODE_MODULE_VERSION}.`,		),	);}const remaining = this.start.deadline === undefined ? undefined : this.start.deadline - this.api.now();if (remaining !== undefined && remaining <= 0) {	return this.decide(errorResult(`Script timed out: its deadline passed while it was interrupted.`));}
seed và startedAt được tính ở mỗi lần gọi execute() nhưng chỉ lần đầu được ghi; lần chạy lại dùng giá trị đã lưu, nên Date.now() và Math.random() trong script cho cùng kết quả.

Lúc chạy thật, một lời gọi xong được nối vào journal rồi mới giao cho VM, mỗi lúc chỉ một commit journal đang bay, và số lời gọi VM đã phát sau khi xử lý từng kết quả được ghi lại (codemode.ts:962–1006). Một lời gọi chỉ được khởi chạy khi con số đó đã commit, nên mọi khoá mà lần thử trước có thể đã nhận đều không vượt quá con số đã ghi cuối cùng. Sau restart, execute() chạy lại, khởi động VM từ start, giao lại các kết quả trong journal theo đúng thứ tự, mỗi kết quả khi VM đã phát lời gọi tương ứng; kết quả của tool lấy từ lời gọi lồng nhau theo khoá, của global lấy từ journal. Rồi script chạy tiếp như thật. Test: script echo a; hold; echo b bị cắt ở hold, chạy lại thì echo mỗi khoá chỉ chạy một lần, VM khởi động hai lần (codemode.test.ts:212–227); đồng hồ và số ngẫu nhiên giữ nguyên qua restart (codemode.test.ts:259–272).

Lần chạy lại kết thúc lời gọi bằng một kết quả lỗi đã lưu, thay vì phát lại, trong các trường hợp (spec.md:3916–3941):

  • Version của module code mode đã đổi (CODEMODE_MODULE_VERSION, hiện là 2, codemode.ts:82–86) hoặc deadline timeout_ms đã qua trong lúc bị gián đoạn: kiểm tra trước khi khởi động VM (dòng 449–459 ở trên).
  • Fingerprint của sandbox đã đổi: VM báo fingerprint trước khi script chạy; khác với cái đã ghi thì lời gọi kết thúc trước khi giao hay gắn lại bất cứ thứ gì (codemode.ts:615–642).
  • Script đi đường khác: số lời gọi sau một kết quả khác con số đã ghi, VM chờ mà không phát lời gọi mà journal có, một khoá hay global bị gọi với tên hoặc đối số khác, hay script kết thúc ở chỗ lần trước còn chạy tiếp (codemode.ts:699–735, codemode.ts:492–510). Khi đó kết quả lỗi được lưu, rồi VM bị dừng bằng một lỗi mà script không bắt được.

Một trường hợp ngược lại không phải kết cục: host đóng sandbox trong lúc script chạy (ví dụ khi tắt máy) thì lời gọi không lưu gì và kết thúc cùng invocation, để lần sau phát lại script (codemode.ts:481–491). Các lời gọi lồng nhau đang chạy lúc restart tự theo chính sách replay của chúng; bước dọn dẹp của tool code mode abort những cái lần chạy lại không dùng tới.

ExecutionEnv và các tool dựng sẵn#

Tool chạm vào file và tiến trình chỉ qua api.env, không bao giờ qua một môi trường bắt được lúc dựng tool. Task tool dựng môi trường cho từng lời gọi bằng HarnessOptions.env với ID và cwd của conversation, nên một thư mục hay một sandbox riêng cho mỗi conversation không cần wrapper nào (spec.md:3283–3290). Không có môi trường thì tool dựng sẵn ném lỗi, và lỗi đó thành một kết quả lỗi bình thường (tools/env.ts:4–8).

ExecutionEnv = FileSystem + Shell: id là không gian tên file, exec nhận chuỗi hoặc argv
pi/packages/durable/src/env/index.tsdòng 208–216, 359–374TypeScript
/** Portable filesystem capability. Operations return failures rather than throwing. */export interface FileSystem {	/**	 * The file namespace: equal ids see the same files at the same paths, whatever their `cwd`. Every local Node	 * environment shares one id; each container or remote host has its own.	 */	readonly id: string;	cwd: string;	absolutePath(path: string, context: Context): Promise<Result<string, FileError>>;export interface Shell {	/**	 * Run a command. A string runs through the environment's shell. An array runs `command[0]` directly with the rest	 * as its arguments, without a shell, so they reach the program unparsed. Aborting the context or a timeout kills	 * only this command's processes.	 */	exec(		command: string | readonly string[],		options: ShellExecOptions | undefined,		context: Context,	): Promise<Result<ShellExecResult, ExecutionError>>;	/** Kill every command this environment still runs; for its owner's shutdown, never for one request. */	cleanup(context: Context): Promise<void>;}export interface ExecutionEnv extends FileSystem, Shell {}
Mọi thao tác trả về Result thay vì ném lỗi cho các thất bại dự kiến (index.ts:3–17). Huỷ context chỉ dừng lời gọi đó; cleanup() là việc tắt máy của chủ sở hữu, không bao giờ của một request.

Những thành viên quan trọng ngoài phần đọc ghi cơ bản:

  • id: hai môi trường cùng id thấy cùng file ở cùng đường dẫn. Mọi NodeExecutionEnv cục bộ có chung "node:local" (node.ts:612–626); mỗi container hay máy từ xa có id riêng. Hàng đợi ghi file dựa vào nó (bên dưới).
  • exec(command, options, context): chuỗi thì chạy qua shell của môi trường, mảng argv thì chạy thẳng chương trình không qua shell, nên host dựng lệnh từ dữ liệu (tên file) không phải quote. Mỗi đoạn onOutput ghi nó đến từ stdout hay stderr; spill ghi toàn bộ đầu ra ra file khi vượt ngưỡng; window cho phép môi trường ở xa bỏ qua đầu ra nằm ngoài phần đuôi mà người gọi sẽ giữ (index.ts:300–331).
  • openBinaryReader(): đọc theo vị trí trên một file đã mở, kể cả khi đường dẫn bị đổi tên; scanLines đếm và định vị dòng trong một lượt bên trong môi trường, nên tool read không phải chuyển cả file (index.ts:95–108). openDirReader() đọc thư mục theo trang (index.ts:159–168).
  • listFiles(), tuỳ chọn: liệt kê cả cây như fd, bỏ những gì git ignore, có giới hạn. NodeExecutionEnv không cài đặt nó; RemoteExecutionEnv và LocalExecutionEnv của pi-env thì có, và người gọi tự duyệt khi thiếu hoặc gặp not_supported (README.md:456–460). Môi trường từ xa thuộc chương pi-env.

Môi trường là thứ được tin cậy, không phải ranh giới cô lập: kiểm tra đường dẫn chuẩn trước khi mở không ngăn được một lần đổi tên hay tráo symlink đồng thời (spec.md:3416–3419). Bộ test tuân thủ registerEnvConformance() cho phép kiểm một môi trường tự viết, giống như với storage (README.md:454).

Các tool dựng sẵn

@earendil-works/pi-durable/tools cung cấp read, write, edit, bash (cùng powershell), port từ tool của coding agent (chương Công cụ), và extension CodingTools gồm bốn tool đầu; không có gì tự cài chúng (tools/index.ts:27–39). Không tool nào trong số này khai báo replay, nên tất cả đều là unsafe, kể cả read: một read bị gián đoạn cũng trả kết quả interrupted thay vì đọc lại.

  • bash giữ phần đuôi đầu ra (retain: "tail"), đẩy từng đoạn vào api.output(), spill toàn bộ ra file khi vượt 50 KiB hoặc 2 000 dòng và báo đường dẫn qua chẩn đoán full_output, trả structuredOutput: { output, truncated, fullOutputPath?, exitCode }, biến exit khác 0 thành kết quả lỗi có chẩn đoán exit_code nhưng vẫn mang đầu ra, và ném lỗi khi timeout (bash.ts:87–158).
  • read đọc qua openBinaryReader, phát hiện ảnh theo nội dung, và đọc lại một lần nếu file đổi trong lúc đọc (read.ts:89–131). Không có bộ xử lý ảnh, PNG, JPEG, GIF và WebP nằm trong giới hạn của mô hình được gửi nguyên, các ảnh khác bị từ chối; với bộ xử lý Photon (WebAssembly, chạy trong worker thread trên Node), ảnh được xoay theo EXIF, đổi định dạng và thu nhỏ cho vừa (README.md:249–258).
  • write và edit tuần tự hoá thao tác đọc-sửa-ghi trên cùng một file:
withFileMutationQueue(): một hàng đợi cho mỗi cặp (id của file system, đường dẫn chuẩn)
pi/packages/durable/src/tools/file-mutation-queue.tsdòng 33–55TypeScript
export async function withFileMutationQueue<T>(	env: ExecutionEnv,	path: string,	fn: () => Promise<T>,	context: Context,): Promise<T> {	const key = await mutationKey(env, path, context);	// Take the slot without awaiting, so no other call can take it in between.	const previous = queues.get(key) ?? Promise.resolve();	let release = (): void => {};	const done = new Promise<void>((resolve) => {		release = resolve;	});	const tail = previous.then(() => done);	queues.set(key, tail);	await previous;	try {		return await fn();	} finally {		release();		if (queues.get(key) === tail) queues.delete(key);	}}
Khoá là env.id cộng đường dẫn chuẩn (file-mutation-queue.ts:7–10), nên hai lời gọi với hai đối tượng môi trường mới cho cùng một file vẫn xếp hàng, còn file khác hay file system khác không bao giờ phải chờ. Đây không phải khoá chống lại bash hay tiến trình khác.

Task graph và telemetry#

harness.taskGraph(context) trả về mọi task đang sống của Session dưới dạng một Chord state (xem chương Chord), dành cho bảng task trong UI hay để gỡ lỗi; watchTaskGraph() trả cùng giá trị qua một watch. Mỗi nút giữ bản ghi đã bỏ input, payload checkpoint, payload kết quả và memo:

TaskGraphState và TaskGraphNode: trạng thái đã commit, cạnh owner, lý do bị chặn
pi/packages/durable/src/harness/task-graph.tsdòng 19–49TypeScript
/** A live task's durable status without its checkpoint and outcome payloads (spec §9.5). */export type TaskGraphState =	| { readonly status: "pending" | "running"; readonly phase: string }	| {			readonly status: "waiting";			readonly phase: string;			readonly on: readonly TaskId[];			readonly policy: JoinPolicy;	  }	/** Asleep until the Harness clock reaches `until`. */	| { readonly status: "sleeping"; readonly phase: string; readonly until: number }	/** Outcome held until its ordinary owned work drains. */	| { readonly status: "completing"; readonly outcome: TaskOutcome<JsonValue>["status"] };export type TaskGraphNode = {	readonly id: TaskId;	readonly kind: string;	readonly conversationId: ConversationId;	/** Owner task; absent for a conversation-owned task. */	readonly owner?: TaskId;	readonly background: boolean;	readonly abortRequested: boolean;	readonly state: TaskGraphState;	/**	 * Why it cannot run here, when it cannot: no registered definition takes it (`missing_task`, `task_too_old`,	 * `migration_failed`), or the restart limit parked it (`restart_limit`). Follows registry changes.	 */	readonly blocked?: BlockedReason;	/** Conversations this task owns, in ID order. */	readonly conversations: readonly ConversationId[];};
running là trạng thái bền: sau khi mở lại, task từng chạy hiện là pending cho tới khi được reserve. blocked được suy ra và đi theo thay đổi registry, kể cả khi không có commit nào (task-graph.ts:159).

Graph được dựng một lần trên dòng commit ở lần quan sát đầu, rồi tiến theo publication của từng commit: một nút xuất hiện cùng commit tạo task và biến mất cùng commit làm task terminal (task-graph.ts:203–251). Nó chỉ chứa task sống: khi task anchor của một subagent đã terminal, task sau trong conversation con là nút cấp cao nhất, và UI muốn đặt nó dưới cha phải đọc ConversationRecord.owner (spec.md:5052–5056).

Về telemetry, mỗi phase invocation là một span pi.durable.phase bao cả commit quyết định của nó, nhưng không bao phase kế, một lần chờ bền hay một restart. Thuộc tính gồm ID và tên task, phase, loại (run/abort), attempt, resumed (lần chạy đầu của một task nạp trong tiến trình này) và outcome (next=<phase>, waiting, sleeping, done, faulted, aborted); sự kiện pi.durable.checkpoint và pi.durable.replay đánh dấu đổi phase và lời gọi lồng nhau được trả từ kết quả đã lưu (telemetry.ts:77–115; span được mở trong scheduler.ts:1739–1803). Schema không chứa nội dung: chỉ ID và siêu dữ liệu điều khiển, không có message, đối số hay kết quả (README.md:82). pi-telemetry và cách host nối nó vào thuộc chương pi-env, telemetry và evals.

Quyết định thiết kế#

Vòng lặp agent thành dữ liệu, scheduler thì chung
Thay vì một vòng lặp bền đặc biệt cho mô hình và tool, Pi Durable cắt vòng lặp thành các task dựng sẵn và chạy chúng bằng một scheduler không biết gì về run hay loại task (spec.md:2273). Cùng một bộ máy phục hồi, abort, migration, đỗ task và task graph phục vụ generation, tool, compaction lẫn task của extension. Cái giá là số commit: mỗi lượt có vài commit cho checkpoint, cộng commit tiến độ tối đa 100 ms một lần trong lúc stream. settings.progress tồn tại vì giá đó đáng kể với storage ở xa (types.ts:524–533).
Không có đường hiển thị nào chưa bền
Bất biến 2 và 3 của spec: document chỉ được công bố sau khi storage commit thành công, và mọi tiến độ hiển thị đều bền (spec.md:63–68). Hệ quả là UI đọc pi.live chứ không nghe một kênh sự kiện riêng, và kết quả interrupted sau crash mang đúng phần đầu ra mà UI đã thấy, cộng một chẩn đoán nói lời gọi bị gián đoạn. Spec liệt kê "visible-undurable publication" trong các non-goal (spec.md:5604–5610).
Mặc định unsafe, và cả hai bên phải đồng ý mới replay
Harness không thể biết một hiệu ứng bên ngoài đã xảy ra hay chưa. Chạy lại mặc định sẽ nhân đôi deploy hay thanh toán; im lặng bỏ qua sẽ giấu sự thật khỏi mô hình. Pi Durable chọn nói thật: mô hình nhận kết quả "đã bị gián đoạn và có thể đã chạy một phần" kèm phần đầu ra đã commit, và tự quyết định bước tiếp (README.md:277). Replay là cam kết của tác giả tool, nên khai báo hiện tại được quyền phủ quyết.
Chờ là trạng thái, không phải await
Bản 1.4.0 xoá sleep trong phase vì nó giữ invocation và làm deadline trôi sau mỗi restart (CHANGELOG.md:7); retry backoff, poll phản hồi deferred và backoff của compaction cũng chuyển sang sleep (CHANGELOG.md:33). Cùng với onWake, một Durable Object có thể ngủ đông trong lúc chờ backoff 10 phút và chỉ thức dậy khi alarm reo.
Đồng thời có cấu trúc thay cho phụ thuộc tự do
Không có "task A phụ thuộc task B" tách rời; thứ tự đến từ việc một task chờ task khác, và mọi task đều có owner (spec.md:2091–2093). Đổi lại, ba điều tự đúng: không công việc nào rò rỉ sau khi owner kết thúc (completing), abort luôn tới được việc con, và việc bù trừ chạy từ dưới lên ở đúng tầng đã gây ra hiệu ứng.
Thiếu code thì chặn, không kết thúc
Một extension chưa được cài lại sau restart là tình huống phục hồi được, nên task chờ thay vì bị huỷ; chỉ một yêu cầu huỷ thật mới biến nó thành orphaned, và tên kết quả nói rõ không có code dọn dẹp nào đã chạy (spec.md:2218–2242). Điều này là điều kiện để tải lại extension ngay trong tiến trình mà không phải đóng Harness.
Phát lại tất định thay vì chụp VM
Code mode lưu dữ liệu vào và thứ tự kết quả, không lưu bộ nhớ của QuickJS. Journal nhỏ, đọc được, kiểm tra được từng bước (đếm lời gọi sau mỗi kết quả), và một sandbox khác (fingerprint khác) hay một module khác (version khác) bị từ chối thay vì phát lại sai.
Chỉ mục sở hữu giới hạn theo công việc đang sống
Scheduler không duyệt mọi task ở mỗi pass; cascade bắt đầu từ task có ý định huỷ, việc con và phạm vi idle là các phép đi xuống trên một chỉ mục trong bộ nhớ chỉ giữ chuỗi phía trên task còn sống. CHANGELOG 1.2.0 ghi lại hiệu quả: chuỗi 500 task sở hữu nhau từ 12 s xuống 50 ms, 8 000 lời gọi lồng nhau song song từ 18,8 s xuống 2,7 s (CHANGELOG.md:95–96).

Cạm bẫy và trường hợp biên#

  • Việc sau một await trong phase không bền. Phase chạy lại từ đầu; mọi thứ cần sau một lần chờ phải nằm trong checkpoint của phase kế (README.md:162). Giá trị ngẫu nhiên, thời điểm, hay giá trị đọc từ document mà lần chạy lại phải thấy giống hệt thì ghi vào memo, checkpoint hoặc input trước khi dùng (spec.md:3458–3462).
  • Phase quên commit thì bị faulted, không phải "chạy lại cho tới khi được" (quy tắc 6).
  • Giao dịch dài. Callback của commit giữ dòng commit; đừng await mô hình, mạng, tiến trình hay một commit lồng trong đó. Đọc qua snapshot(), getTask() bên trong callback có thể treo vì nó xếp hàng sau chính callback đang giữ dòng; hãy đọc qua tx (spec.md:5521–5528).
  • Tool dựng sẵn đều unsafe, kể cả read. Đừng khai báo replay: "safe" cho tool có hiệu ứng ngoài mà không có khoá idempotent; lời gọi lồng nhau của tool safe vẫn theo chính sách replay của riêng chúng.
  • Chạy lại thấy môi trường hiện tại: cwd của conversation có thể đã đổi giữa lần chạy đầu và lần chạy lại (spec.md:5582–5584).
  • Việc con giữ owner. Công việc tiền cảnh mà một extension khởi động trong conversation của subagent giữ lời gọi tool, và qua đó giữ cả run; hỏi tiếp subagent trong lúc tool đang completing kéo dài việc giữ. Việc không được giữ owner phải tạo do conversation sở hữu và background (spec.md:5502–5512).
  • Handler abort() không tạo được task con và không chờ, không ngủ được. Bù trừ ngay trong handler, hoặc qua task background do conversation sở hữu và chờ bằng waitForTask() (spec.md:5513–5518).
  • failFast chỉ áp cho task mình sở hữu, và lời chờ khép kín chu trình (chờ chính mình, chờ owner đang giữ mình) bị từ chối ngay khi commit.
  • Task bị đỗ vẫn giữ conversation bận. Một crash giết mọi task đang chạy, không chỉ thủ phạm; host nên hiển thị task bị đỗ và nút retryTask().
  • Mọi tiến trình mở cùng storage phải dùng cùng đồng hồ, vì until được lưu theo HarnessOptions.now. Và chỉ một tiến trình được sở hữu một storage tại một thời điểm; không có khoá giữa các tiến trình (README.md:790).
  • Code không tôn trọng tín hiệu huỷ làm close() treo, vì close join mọi invocation. Huỷ một Context chỉ huỷ lời gọi hay lần chờ đó, không bao giờ huỷ công việc chung (spec.md:2117–2120).
  • Tiền của subagent bị tính hai lần nếu tool trả usage của conversation con trong kết quả của nó: pi.usage của con đã tính rồi (spec.md:5499–5501). Compaction cũng tốn một request tóm tắt mỗi lần thử, kể cả khi bản tóm tắt kết thúc stale.
  • Tên bắt đầu bằng pi. cho task, document và entry thuộc về phần dựng sẵn theo quy ước; không có gì cưỡng chế (spec.md:5565–5568).

Đọc code ở đâu#

Thứ tựFileĐọc để hiểu
1README.mdConcepts, Persist and Resume (sleep, crash loop, blocked), Tools, Code Mode, Abort and Subagents, Child Tasks. Đọc trước để có từ vựng.
2spec.md §5Mô hình task chuẩn tắc: định nghĩa, effect sandwich, abort và sở hữu, đồng thời có cấu trúc, sleep và wake. Sau đó §7.3 (tool), §7.6 (code mode), §8 (task dựng sẵn).
3types.tsTaskRuntime, TaskDefinition, TaskOptions (208–391); TaskOutcome, TaskState, TaskRecord (554–704).
4packages/durable/test/examples/13-recovery.ts, packages/durable/test/examples/24-child-tasks.tsHai task viết tay ngắn: memo, sleep, phục hồi; task con, failFast, abort từ dưới lên. 12-tasks.ts là ví dụ ý định, hiệu ứng, kết quả.
5packages/durable/src/harness/scheduler.tsTheo thứ tự: chú thích lớp (231–252), open (374), #observe (624), #reconcile (776), #reserve (1466), #run/#decide/#step (1711–1921), #runtime (2085). Bỏ qua lúc đầu phần chỉ mục sở hữu (1000–1290).
6packages/durable/src/harness/live.tspi.live, endRun() và việc dọn dẹp khi scheduler tự ghi faulted/orphaned.
7packages/durable/src/harness/generation.tsNăm phase, streamResponse(), classify(), answer(), vòng tool.
8packages/durable/src/harness/tool.tsPhase call/execute, run(), finalResult(), settle(), lời gọi lồng nhau.
9packages/durable/src/harness/inbox.ts, submissions.ts:201Boundary postTools/final, write chỉ để hiển thị, stale.
10packages/durable/src/harness/compaction.ts, packages/durable/src/harness/context.tsBa cách khởi động compaction, chọn điểm cắt, đặt bản tóm tắt; cách context loại message lỗi và tự bù kết quả tool bị thiếu.
11packages/durable/src/codemode/codemode.ts, vm.ts:23–37Journal, phát lại, phân kỳ; chế độ tất định của VM.
12packages/durable/src/env/index.ts, packages/durable/src/env/node.ts, src/tools/Giao diện môi trường, bản cài Node, bốn tool dựng sẵn và hàng đợi ghi file.
13packages/durable/test/harness-tasks-recovery.test.ts, packages/durable/test/harness-tools-recovery.test.ts, packages/durable/test/harness-generation-recovery.test.ts, packages/durable/test/harness-nested-tools-restart.test.ts, packages/durable/test/harness-blocked.test.tsBằng chứng chạy được cho mọi hành vi phục hồi trong chương: mỗi tên test là một câu khẳng định.
14packages/durable/src/harness/task-graph.ts, packages/durable/src/harness/telemetry.tsGraph task sống và schema telemetry không chứa nội dung.

Tóm tắt#

  • Mọi thứ harness chạy là một task: một máy trạng thái bền mà mỗi bước là một checkpoint đã commit. Phase chạy lại từ đầu sau crash, nên việc có hậu quả đi theo khuôn ý định, hiệu ứng, kết quả.
  • TaskState có sáu trạng thái. Code của task chỉ đổi trạng thái của chính nó qua runtime.commit() (checkpoint mới, chờ, ngủ, kết quả); reservation, đối soát, handover, fault, orphan thuộc về scheduler; mọi chuyển trạng thái đều được quyết định trên dòng commit.
  • Chờ (waiting) và ngủ (sleeping) là trạng thái đã lưu, không phải await; wake()/onWake cho host biết khi nào cần một tiến trình.
  • Cây sở hữu làm cho abort chảy xuống, chạy từ dưới lên, và không việc con nào sống lâu hơn lúc owner kết thúc; background là ranh giới.
  • pi.generation ghim request, commit partial tối đa 100 ms một lần, biến retry và poll thành sleep, compaction rồi thử lại một lần khi tràn context, và trao run cho generation kế sau mỗi vòng tool.
  • pi.tool ghi đối số cuối và chính sách replay trước khi chạy; sau crash nó chỉ chạy lại khi cả hai bên nói safe, còn lại trả cho mô hình một kết quả interrupted với phần đầu ra đã commit. Lời gọi lồng nhau gắn lại theo khoá; code mode phát lại script tất định từ journal.
  • Restart được đếm; task bị crash cắt ngang năm lần liên tiếp bị đỗ chờ retryTask(). Thiếu definition thì chặn, không bao giờ tự kết thúc; chỉ một yêu cầu huỷ mới làm task orphaned.

20pi-env, telemetry và evals#

Chương này gom ba package đứng quanh lõi, mỗi cái trả lời một câu hỏi khác nhau. pi-env (@earendil-works/pi-env): làm sao để tool của một agent Pi Durable chạy trên máy khác, qua SSH, mà kết quả giống hệt khi chạy tại chỗ. pi-telemetry (@earendil-works/pi-telemetry): làm sao để các package của Pi mô tả những gì chúng làm (span, sự kiện (event), metric) mà không buộc vào một backend quan sát nào. pi-evals (packages/evals, không phát hành): làm sao đo hành vi của coding agent, đặc biệt là tài liệu của nó có giúp mô hình làm đúng việc hay không. Cách Pi Durable dùng một ExecutionEnv nằm ở chương Durable: task, scheduler, generation và tool; ở đây ta xem phần hiện thực từ xa.

pi-env: tool chạy ở máy khác, phần bền ở lại#

Bài toán: một worker Pi Durable chạy trên máy của bạn (hay trong một tiến trình server), giữ storage, khoá API và lịch sử hội thoại. Code cần sửa và lệnh cần chạy lại nằm trên một máy khác: một máy GPU, một điện thoại Android chạy Termux, một máy Windows. Mang cả worker sang đó nghĩa là mang theo credential và storage. pi-env tách hai thứ: "tool của agent chạy trên máy khác, thường qua SSH, còn worker Durable, storage và credential của nó ở lại máy cục bộ" (README.md:3–4).

README: hai nửa của pi-env
pi/packages/env/README.mddòng 6–10Markdown
- `pi-env` (`daemon/`): a small Rust program that runs on the remote machine. It speaks a framed protocol on stdin and  stdout ([docs/protocol.md](docs/protocol.md)) and performs file operations and commands there.- `RemoteExecutionEnv`: a Durable `ExecutionEnv` that talks to the daemon through a `Connection`. Its results match  `NodeExecutionEnv` running on the remote machine; only error messages may differ  ([docs/semantics.md](docs/semantics.md)).
Câu cần nhớ: kết quả của RemoteExecutionEnv khớp với NodeExecutionEnv chạy trên máy từ xa; chỉ thông báo lỗi được phép khác.

Package phụ thuộc Chord (chỉ để lấy kiểu Context) và pi-durable (để lấy giao diện ExecutionEnv và các kiểu kết quả) (package.json:47–50). Bản đầu tiên ra ở 1.0.4 (2026-10-05) với RemoteExecutionEnv, Connection và phần khởi động qua SSH; 1.2.0 thêm listFiles, LocalExecutionEnv và pseudo-terminal (CHANGELOG.md:7–24). Giao diện nó hiện thực thuộc Pi Durable, vốn được ghi là thử nghiệm, nên pi-env cũng di chuyển theo.

Mô hình tư duy: client áp luật của Node, daemon làm system call#

 Máy cục bộ                                             Máy từ xa
 Harness · task pi.tool · tool bash/read/edit/write
     │ ExecutionEnv (FileSystem + Shell)
 RemoteExecutionEnv   giải đường dẫn, điểm kiểm abort, map mã lỗi,
     │                cỡ lần đọc của readFile, luật kết quả của exec
     │ request(op, json, payload) · cancel · kill
 Connection ── spawn: ssh -T … -- host '~/.pi/mobile/tools/pi-env-<sha>' serve --token <hex>
     │   stdin:  frame request · cancel · ping           ──────▶  pi-env (Rust)
     │   stdout: "PI-ENV <token>\n", rồi chỉ frame      ◀──────  system call · tiến trình · spill
     │                                                             watch (cổng của NodeFileWatcher)
 storage · credential · gọi mô hình: ở lại                    listFiles · pty
Một Connection quản đúng một tiến trình daemon tại một thời điểm; daemon được khởi động lười ở request đầu và khởi động lại sau khi mất. ExecutionEnv của Durable được định nghĩa ở durable/src/env/index.ts:374.

Chuẩn đối chiếu là NodeExecutionEnv của Durable chạy trên máy từ xa; bộ kiểm tuân thủ (conformance) cho env của Durable và một test vi sai (differential) kiểm từng luật (semantics.md:3–4). Để đạt điều đó, việc được chia rõ:

semantics.md: phần nào ở client, phần nào ở daemon
pi/packages/env/docs/semantics.mddòng 6–19Markdown
## Split of work| Concern | Where ||---|---|| Path resolution: `~` (remote home), `file://`, relative to `cwd`; on Windows drive-relative paths against the remote's working directories | client, the remote system's `path` rules || Abort checkpoints of each method | client, as `NodeExecutionEnv` || Error code mapping (`ENOENT` → `not_found`, ...) | client, as `NodeExecutionEnv`'s `toFileError` || `readFile`'s read sizes, size limit and decoding (`StringDecoder`, byte-order mark kept) over the daemon's `open(path, "r")` | client || `writeFile` in 512 KiB writes with an abort check before each; `appendFile` without checks | client, over one open file || Line reader | client, positional reads and Durable's `StreamDecoder` || Watching: snapshots, native events, polling | daemon, a port of Durable's `NodeFileWatcher` || Timeout validation, result precedence (callback error, timeout, abort, spill failure, exit code) | client || System calls, processes, spill files, line scans | daemon || Command output decoding: per stream, WHATWG UTF-8, only a leading byte-order mark dropped | daemon (`encoding_rs`) |
Client giữ mọi luật mà Node áp trước và sau system call (đường dẫn, điểm kiểm abort, map mã lỗi, cách readFile chia lần đọc); daemon làm system call, quản tiến trình, file tràn (spill: toàn bộ output được ghi ra một file tạm khi vượt ngưỡng), quét dòng, và chạy watcher ngay cạnh file.

Lý do cách chia này cần thiết chứ không phải tuỳ chọn: nếu daemon tự diễn giải đường dẫn hay tự quyết khi nào kiểm abort, nó sẽ phải sao chép lại hành vi của Node bằng Rust, và mỗi khác biệt nhỏ là một kết quả tool khác đi. Giữ luật ở client, chạy đúng code TypeScript đó, thì chỉ còn system call là phải khớp, và system call được kiểm bằng conformance.

Triển khai qua SSH#

Mọi lần gọi ssh đi qua cùng một bộ tham số, chặt hơn mặc định ở mọi chỗ có thể:

Tin một host key

Vì StrictHostKeyChecking=yes, máy lạ bị từ chối với HostKeyUnknownError. scanHostKey(target) kết nối một lần với accept-new vào một file known-hosts tạm, qua đúng đường mà kết nối thật sẽ đi, rồi trả về các dòng key và fingerprint (qua ssh-keygen -lf); comment ghi rõ hiển thị fingerprint không phải là xác thực, chủ máy phải so với một bản lấy qua kênh khác (ssh.ts:177–207). acceptHostKey chỉ nhận dòng thuần cho đúng alias, và từ chối với HostKeyChangedError nếu key cùng loại đã có mà khác; muốn đổi phải forgetHostKey trước (ssh.ts:253–286). File known-hosts được ghi lại nguyên tử, mỗi file một thay đổi tại một thời điểm (ssh.ts:224–251).

Phát hiện hệ thống và triển khai daemon

detectPlatform() chạy một probe POSIX (uname -s, uname -m, uname -o, $HOME, $TMPDIR, $LD_PRELOAD) qua shell đăng nhập; không được thì thử PowerShell; gặp Git Bash làm shell mặc định của SSH trên Windows thì hỏi lại PowerShell; với Windows, echo %OS% phân biệt cmd.exe với PowerShell. Termux nhận thêm cảnh báo: thiếu TMPDIR, thiếu termux-exec, Android có thể tạm dừng Termux (ssh.ts:288–353). Package chở sẵn daemon cho mọi hệ được hỗ trợ trong bin/pi-env-<platform>-<arch>/ (ssh.ts:355–359).

Kết nối thật là một Connection mà lệnh khởi động là một hàm, chạy lại ở mỗi lần khởi động: phát hiện hệ thống một lần, kiểm (và nếu cần triển khai) daemon, rồi trả về dòng lệnh ssh:

sshConnectionFor(): mỗi lần khởi động daemon đều kiểm lại binary
pi/packages/env/src/ssh.tsdòng 489–511TypeScript
/** A `Connection` whose every start detects the remote system (once), verifies or deploys the daemon, then starts it. */function sshConnectionFor(options: SshConnectOptions, state: SshState): Connection {	return new Connection({		command: async () => {			if (state.remote === undefined) {				const remote = await detectPlatform(options);				for (const warning of remote.warnings) options.onLog?.(`${warning}\n`);				state.remote = remote;			}			// A deployment that just verified the binary counts for the first start.			const file =				state.verified ??				(await deployDaemon(options, state.remote, options.binary ?? packagedDaemon(state.remote)));			state.verified = undefined;			return [				options.ssh ?? "ssh",				...sshArguments(options),				launchCommand(state.remote, file, options.loginShell === true),			];		},		...(options.onLog === undefined ? {} : { onLog: options.onLog }),	});}
Một lần triển khai vừa kiểm binary được tính cho lần khởi động đầu (state.verified), nên connectSsh không kiểm hai lần. Từ lần khởi động thứ hai, kiểm hash là một vòng SSH thêm trước khi chạy daemon.

Hai cách lấy kết nối. connectSsh(target) phát hiện và triển khai ngay, và ném lỗi tại chỗ (ví dụ HostKeyUnknownError). sshConnection(target) không làm gì cho tới request đầu tiên; lỗi (mất mạng, host key chưa tin) thành lỗi spawn_error của request đó, và request sau thử lại (ssh.ts:513–539). Cách lười quan trọng với Durable: tạo một môi trường (trong HarnessOptions.env) không bao giờ cần mạng.

Giao thức: dòng đồng bộ, frame và hello#

Client chạy pi-env serve --token <hex> và nói chuyện qua stdin/stdout của tiến trình đó; stderr chỉ là chẩn đoán cho log (protocol.md:3–4). Vấn đề đầu tiên là file khởi động của shell: .bashrc của máy từ xa có thể in một banner lên stdout trước khi daemon chạy. Token giải quyết việc đó: dòng đầu tiên daemon in ra là PI-ENV <token>\n với token lấy từ dòng lệnh (daemon/src/main.rs:472–477), và client bỏ mọi thứ trước dòng đó:

Connection.#onData(): tìm dòng đồng bộ, chuyển phần nhiễu sang log
pi/packages/env/src/connection.tsdòng 335–355TypeScript
#onData(session: Session, chunk: Buffer): void {	if (!session.live) return;	session.lastSeen = Date.now();	session.chunks.push(chunk);	session.buffered += chunk.length;	if (!session.synced) {		// Shell startup files may print before the daemon runs; skip everything before its sync line.		const marker = Buffer.from(`PI-ENV ${session.token}\n`, "utf8");		const all = session.peek(session.buffered);		const index = all.indexOf(marker);		if (index === -1) {			if (session.buffered > 1024 * 1024) {				session.consume(session.buffered - marker.length);			}			return;		}		const noise = all.subarray(0, index).toString("utf8").trim();		if (noise !== "") this.#options.onLog?.(noise);		session.consume(index + marker.length);		session.synced = true;	}
Token là 16 byte ngẫu nhiên, tạo mới cho mỗi lần khởi động (connection.ts:280–281). Nhiễu quá 1 MiB thì phần cũ bị bỏ, chỉ giữ đủ để còn tìm được dòng đồng bộ.

Sau dòng đó, stdout chỉ còn frame. Mọi frame, cả hai chiều, có cùng khung: u32 length, u8 type, u32 id, u32 jsonLength, JSON, rồi payload thô; số nguyên big-endian, frame tối đa 16 MiB (protocol.md:12–27). Phía client:

frame(): JSON và payload thô trong một khung có độ dài
pi/packages/env/src/connection.tsdòng 105–118TypeScript
/** Lone surrogates become U+FFFD, as Node encodes strings for system calls. */function wellFormed(_key: string, value: unknown): unknown {	return typeof value === "string" ? value.toWellFormed() : value;}function frame(type: number, id: number, json: Json, payload: Uint8Array = new Uint8Array(0)): Buffer {	const body = Buffer.from(JSON.stringify(json, wellFormed), "utf8");	const header = Buffer.alloc(13);	header.writeUInt32BE(9 + body.length + payload.length, 0);	header.writeUInt8(type, 4);	header.writeUInt32BE(id, 5);	header.writeUInt32BE(body.length, 9);	return Buffer.concat([header, body, payload]);}
toWellFormed() đổi surrogate lẻ thành U+FFFD trước khi mã hoá UTF-8, đúng cách Node mã hoá chuỗi đường dẫn và tham số cho system call. Nội dung file và output đi dạng bytes thô trong payload, không bao giờ qua JSON.

Phía daemon, read_frame kiểm độ dài; JSON không parse được vẫn giữ khung nguyên vẹn (frame tới với Value::Null và được trả lời lỗi EINVAL), còn lỗi khung thì kết thúc kết nối (daemon/src/frame.rs:49–82). Sáu loại frame:

typeTênChiềuNghĩa
1requestclient → daemonjson.op là tên thao tác
2resultdaemon → clientRequest id thành công và kết thúc
3errordaemon → clientRequest id thất bại và kết thúc: { code, message, syscall?, path? }
4eventdaemon → clientTiến độ của request đang chạy (output của exec, pty; thay đổi của watch)
5cancelclient → daemonHuỷ request id; { mode: "kill" } là giết mà không huỷ
6pingcả haiCòn sống; bên nhận bỏ qua

Bảng gốc ở protocol.md:28–39. code là mã kiểu Node (ENOENT, EISDIR, ERR_FS_EISDIR...) hoặc một mã thực thi (shell_unavailable, spawn_error, timeout, aborted, unknown); chỉ mã là hợp đồng, thông báo là chẩn đoán. Đường dẫn luôn tuyệt đối: client giải ~, đường dẫn tương đối và URL file:// trước khi gửi.

Request đầu tiên là hello { protocol }; daemon trả về phiên bản giao thức và package, hệ điều hành và kiến trúc theo cách viết của Rust (linux, macos, x86_64, aarch64), thư mục home, tmpdir theo luật os.tmpdir() của Node, thư mục làm việc (kể cả thư mục làm việc theo ổ của Windows), pid, và features: các thao tác thêm vào giao thức 1 sau bản đầu (hiện chỉ listFiles) (main.rs:148–160; protocol.md:71–77). Client từ chối mọi phiên bản khác 1 (connection.ts:309–311), và nodePlatform(info) đổi tên sang cách viết của Node (win32, darwin, x64, arm64) (connection.ts:55–64).

Bên trong daemon: luồng, huỷ, liveness#

Daemon là một chương trình Rust nhỏ, phụ thuộc encoding_rs, ignore (crate của fd và ripgrep), notify, serde_json và libc hoặc windows-sys (daemon/Cargo.toml:8–25). Một luồng đọc frame từ stdin và phân phối:

Một Control được đăng ký cho mỗi request trước khi nó chạy, nên một cancel gửi ngay sau request không bao giờ mất (protocol.md:45–51; exec.rs:39–58). Huỷ exec hay pty giết process group của lệnh và kết thúc với aborted; scanLines dừng giữa các khúc, listFiles giữa các mục; watch dừng và trả {}.

Trả lời đi trước output

Output: một người ghi stdout, frame control luôn đi trước output số lượng lớn
pi/packages/env/daemon/src/output.rsdòng 1–4, 71–88Rust
//! The single writer of stdout. Control frames (results, errors, pings, watch events) always go before bulk command//! output, so a slow link delays output but never a cancel's result or a ping. Bulk output is bounded: readers of//! commands without an output window wait while too much of it is unsent, which slows the command down like a full//! pipe would.    pub fn run(&self, writer: &mut impl Write) {        loop {            let (frame, unsent) = {                let mut queues = self.queues.lock().unwrap();                loop {                    if let Some(frame) = queues.control.pop_front() {                        break (frame, None);                    }                    if let Some((frame, unsent)) = queues.bulk.pop_front() {                        queues.bulk_bytes -= frame.payload.len();                        break (frame, unsent);                    }                    if queues.closed {                        return;                    }                    queues = self.queued.wait(queues).unwrap();                }            };
Kết quả của một lệnh đi sau output của nó; kết quả có payload trên 64 KiB cũng xếp hàng sau output, để một link chậm làm trễ dữ liệu lớn chứ không trễ câu trả lời nhỏ hay ping (main.rs:362–375). Output của lệnh không có cửa sổ chờ khi hơn 4 MiB chưa gửi, làm chậm lệnh như một pipe đầy.

Liveness: không để lại tiến trình mồ côi

Mỗi bên gửi ping mỗi 5 giây. Daemon giết mọi process group nó đã khởi động và thoát sau 30 giây không nhận được byte nào, hoặc khi stdin kết thúc (main.rs:494–520). Client tính ngược lại: 30 giây im lặng là kết nối đã mất, mọi request đang chờ bị từ chối với { code: "unknown", lost: true }, và request sau khởi động một daemon mới (connection.ts:301–307; protocol.md:62–67). Hệ quả có chủ đích: một điện thoại mất mạng giữa chừng không để lại lệnh nào chạy trên máy từ xa. Với lệnh có hiệu ứng, kết quả lúc đó là không biết: comment của Connection nói thẳng "for mutations their outcome is then unknown" (connection.ts:177–180).

RemoteExecutionEnv: hiện thực ExecutionEnv#

ExecutionEnv của Durable là FileSystem cộng Shell: khoảng hai mươi phương thức file (đọc, ghi, reader nhị phân và theo dòng, đọc thư mục, watch...) và exec cộng cleanup; mọi phương thức trả về Result chứ không ném lỗi, và nhận Context làm tham số cuối (durable/env/index.ts:209–284; chương task giải thích từng phương thức). RemoteExecutionEnv hiện thực đúng giao diện đó; cấu hình gồm connection, id (không gian tên file: hai môi trường cùng id thấy cùng file, ví dụ pi-env:gpu), cwd, và tuỳ chọn shellPath, shellEnv, watch (remote-env.ts:51–61).

Đường dẫn được giải ở client, theo luật của máy từ xa

#resolve(): resolvePath của Node, nhưng với home, dấu phân cách và thư mục làm việc của máy từ xa
pi/packages/env/src/remote-env.tsdòng 373–402TypeScript
/** Node's `resolvePath` on the remote system, with its home directory and working directories. */async #resolve(path: string): Promise<string> {	const info = await this.connection.info();	const windows = nodePlatform(info).platform === "win32";	const paths = windows ? win32 : posix;	let normalized = path;	if (normalized === "~") {		normalized = info.home;	} else if (normalized.startsWith("~/") || (windows && normalized.startsWith("~\\"))) {		normalized = paths.join(info.home, normalized.slice(2));	} else if (normalized.startsWith("file://")) {		try {			normalized = fileURLToPath(normalized, { windows });		} catch {			// Keep malformed URLs as ordinary paths, as Node does.		}	}	if (paths.isAbsolute(normalized)) return paths.resolve(normalized);	if (windows) {		// A drive-relative path on another drive: Node on Windows resolves it against that drive's working directory		// (`=D:`), else its own working directory if on that drive, else the drive's root.		const drive = /^([a-zA-Z]:)(?![\\/])/.exec(normalized)?.[1];		if (drive !== undefined && drive.toLowerCase() !== this.cwd.slice(0, 2).toLowerCase()) {			let base = info.driveCwds[drive.toUpperCase()] ?? info.cwd;			if (base.slice(0, 2).toLowerCase() !== drive.toLowerCase() && base[2] === "\\") base = `${drive}\\`;			return win32.resolve(base, normalized);		}	}	return paths.resolve(this.cwd, normalized);}
path.win32 hay path.posix được chọn theo hello, nên một worker trên macOS giải đúng D:foo của một máy Windows: theo thư mục làm việc của ổ D: (biến =D:), nếu không có thì theo thư mục làm việc của daemon nếu cùng ổ, nếu không thì gốc ổ.

Mọi thao tác file đi qua #fileOp: giải đường dẫn, kiểm abort trước, chạy, và với vài thao tác kiểm abort cả sau, đúng các điểm kiểm của NodeExecutionEnv; lỗi được map về FileError (remote-env.ts:404–428). Bảng map mã lỗi là bảng của toFileError trong Node env: ENOENT thành not_found, EACCES/EPERM thành permission_denied, ENOTDIR, EISDIR, EINVAL... còn lại là unknown (errors.ts:8–34).

Handle chết theo daemon

Reader nhị phân, reader theo dòng và reader thư mục giữ một handle số do daemon cấp. Handle chỉ có nghĩa với tiến trình daemon đã mở nó; sau khi kết nối mất và một daemon mới được khởi động, gửi handle cũ tới daemon mới sẽ đọc nhầm file khác. Connection chặn điều đó bằng id của session:

request(): request gắn với một session thì thất bại thay vì tới một daemon mới
pi/packages/env/src/connection.tsdòng 203–213TypeScript
async request(op: string, json: Json, options: RequestOptions = {}): Promise<Reply> {	if (options.session !== undefined) {		const current = this.#session;		if (current === undefined || current.id !== options.session || !current.live) {			throw lost("pi-env connection lost");		}		return this.#send(current, op, json, options);	}	const { session } = await this.#connect();	return this.#send(session, op, json, options);}
Mỗi Handle nhớ session của câu trả lời đã cấp nó và gửi mọi request của mình kèm session đó (remote-env.ts:133–158). Daemon giới hạn 4.096 handle mở mỗi kết nối, quá thì EMFILE (main.rs:42–43).

Đọc và ghi theo khúc, nhiều khúc cùng bay

Một vòng request qua SSH tốn thời gian, nên đọc tuần tự từng khúc sẽ chậm. readRange() giữ tới 8 lệnh pread cùng bay; một lần đọc ngắn (chạm cuối file) huỷ các giả định của những lần đang bay và đọc tiếp ngay sau nó, đúng như đọc tuần tự (remote-env.ts:94–131). readTextFile/readBinaryFile mở file như open(path, "r") của Node, rồi theo đúng cách readFile của Node chia lần đọc: kích thước 0 (FIFO, thiết bị) thì đọc tuần tự 64 KiB tới hết, không giải mã thì một lần cho file nhỏ hay các khúc 512 KiB, giải mã UTF-8 thì bằng StringDecoder (remote-env.ts:451–524). Ghi đi chiều ngược lại:

Một chi tiết nhỏ cho thấy mức độ đòi hỏi "khớp với Node": listDir sắp tên theo bytes trên POSIX như scandir của libuv, dùng bytes gốc (raw) của tên không phải UTF-8; trên Windows thì giữ thứ tự của file system như daemon báo (remote-env.ts:704–710). Các luật mà thư viện chuẩn không tự làm (mkdir đệ quy, rm, mkdtemp, tmpdir, /dev/null, FIFO...) được liệt kê ở semantics.md:21–42.

Lệnh và cửa sổ output#

cleanup() dành cho lúc chủ môi trường tắt: nó gửi cancel với mode: "kill" cho mọi lệnh môi trường này còn chạy, giết mà không huỷ, nên lệnh kết thúc với trạng thái của tiến trình bị giết (137), đúng như NodeExecutionEnv (remote-env.ts:950–954; connection.ts:221–225). Phía daemon, ngữ nghĩa lệnh theo semantics.md:44–60: chuỗi chạy qua /bin/bash, which bash, rồi sh với -c, mảng argv chạy thẳng; thư mục làm việc phải tồn tại; môi trường là của daemon, rồi shellEnv, rồi env. Mỗi lệnh có một session (process group) mới với signal mặc định và mask rỗng, nên timeout, abort và cleanup() giết cả cây bằng SIGKILL:

sys/unix.rs: setsid trước exec, giết cả process group, 128 + signal như shell
pi/packages/env/daemon/src/sys/unix.rsdòng 266–304Rust
    // SAFETY: only async-signal-safe calls between fork and exec.    unsafe {        command.pre_exec(|| {            // A new session, so a kill reaches every descendant; default signal handling and an empty mask, as libuv sets.            libc::setsid();            for signal in 1..32 {                if signal != libc::SIGKILL && signal != libc::SIGSTOP {                    libc::signal(signal, libc::SIG_DFL);                }            }            let mut mask: libc::sigset_t = std::mem::zeroed();            libc::sigemptyset(&mut mask);            libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut());            Ok(())        });    }}pub fn after_spawn(_child: &Child) {}/// Kill the command's process group, or the process if the group is gone.pub fn kill_tree(pid: u32) {    let pid = pid as i32;    // SAFETY: plain signal delivery.    unsafe {        if libc::kill(-pid, libc::SIGKILL) != 0 {            libc::kill(pid, libc::SIGKILL);        }    }}/// A process killed by a signal has no exit code; report 128 + the signal, as the shell does.pub fn exit_code(status: ExitStatus) -> i64 {    i64::from(        status            .code()            .unwrap_or_else(|| 128 + status.signal().unwrap_or(0)),    )}

Sau khi tiến trình thoát, output vẫn được gom cho tới khi cả hai pipe đóng, hoặc 100 ms không có output: một tiến trình con giữ pipe mở không làm lệnh treo mãi (exec.rs:382–385).

Cửa sổ output: không chuyển những gì sẽ bị bỏ

Bài toán: yes | head -c 2G trên máy từ xa sinh hai gigabyte output, trong khi tool bash của Durable chỉ giữ phần đuôi (mặc định 2.000 dòng hoặc 50 KiB cuối, durable/harness/tool.ts:294–296; bash.ts:151) và commit tiến độ mỗi 100 ms. Gửi cả hai gigabyte qua SSH để rồi vứt gần hết là lãng phí, và nó làm nghẽn mọi thứ khác trên link. Giao diện của Durable có sẵn chỗ cho điều này:

ShellExecOptions.window: phần đuôi người gọi giữ, và nhịp nó lấy mẫu
pi/packages/durable/src/env/index.tsdòng 310–331TypeScript
	spill?: ShellSpillOptions;	/**	 * The caller keeps only this tail of the output, so the environment may omit output outside it and report the omission	 * as `info.skipped`. Without it, every chunk is delivered.	 */	window?: ShellOutputWindow;}/** * The tail of the combined output a caller keeps, and how often it samples it. An environment that transfers output * over a slow link uses it to omit what the caller would drop anyway and to send no faster than the caller commits. */export interface ShellOutputWindow {	/** UTF-8 bytes of decoded text kept at the end of the output. */	maxBytes: number;	/** Lines kept at the end of the output. */	maxLines: number;	/** Minimum pause between the caller's samples of the output. */	minIntervalMs: number;	/** Each sample also pauses the caller in proportion to its size at this rate. */	bytesPerSecond: number;}
Đổi lại, môi trường báo phần đã bỏ qua ShellOutputInfo.skipped (bytes, newlines, endsWithNewline), và chunk đi sau một lần bỏ luôn chứa nhiều hơn cửa sổ ít nhất một byte hoặc một dòng, nên phần bị bỏ chắc chắn không thể nằm trong phần đuôi được giữ (durable/env/index.ts:347–357).

Durable dựng cửa sổ từ giới hạn output của tool (chỉ khi tool giữ phần đuôi) và nhịp commit tiến độ của nó (durable/harness/tool.ts:333–341), tool bash chuyển nó vào exec (durable/tools/bash.ts:104–107), và RemoteExecutionEnv gửi nguyên nó tới daemon. Ở đó, window.rs giữ output chưa giao, gộp nó lại, và thay phần chắc chắn nằm ngoài cửa sổ bằng số đếm:

window.rs: cắt tiền tố dài nhất mà phần còn lại vẫn chứng minh nó nằm ngoài cửa sổ; nhịp giao theo nhịp commit của người gọi
pi/packages/env/daemon/src/window.rsdòng 1–3, 97–104, 166–176Rust
//! Durable's output window (`ShellExecOptions.window`, `ShellOutputInfo.skipped`): undelivered command output is held//! and coalesced; text followed by more than the window (by a byte or a line) can never be in the caller's tail and is//! replaced by counts. Delivery follows the caller's commit pace, and waits while an earlier frame is still unsent.    /// Whether the text after a cut still proves the cut text is outside the window.    fn proves(&self, bytes: usize, newlines: usize) -> bool {        bytes > self.window.max_bytes || newlines > self.window.max_lines    }    /// Replace the longest prefix whose remainder still proves exclusion by counts.    fn trim(&mut self) {        let mut removed = String::new();    /// Deliver everything held: one event carrying the skip and all text after it, or one event per segment.    pub fn take(&mut self) -> Vec<Event> {        let segments = std::mem::take(&mut self.segments);        let delivered = self.bytes;        self.bytes = 0;        self.newlines = 0;        // The caller writes at most the window per sample, and paces itself by what it wrote.        let pace = Duration::from_secs_f64(            delivered.min(self.window.max_bytes) as f64 / self.window.bytes_per_second,        );        self.next_send = Instant::now() + self.window.min_interval.max(pace);
Một lần giao gửi một event mang skipped cùng toàn bộ text sau nó, rồi hẹn lần giao kế sau max(minIntervalMs, phần đã giao / bytesPerSecond). Vòng lặp của lệnh chỉ giao khi tới hạn và frame trước đã được ghi ra stdout, nên tối đa một frame output của mỗi lệnh đang chờ (exec.rs:386–392).

Phía client, event.skipped thành info.skipped của onOutput, tool bash gọi api.output(text, info.skipped), và OutputBuffer.push(chunk, skipped) của Chord cộng số đếm vào tổng và bỏ phần đã lưu trước đó, vì không gì đứng trước chỗ bị bỏ còn có thể nằm trong cửa sổ (chord/src/output.ts:154–182). Kết quả: phần đuôi giữ lại và số byte, số dòng bị bỏ y hệt khi chạy tại chỗ, nhưng chỉ phần đuôi đi qua mạng. Đây là ý của dòng "honors output windows so dropped command output is not transferred" trong changelog bản đầu (CHANGELOG.md:23).

Watch, listFiles và terminal#

Watch chạy cạnh file

Daemon chạy một bản Rust của NodeFileWatcher của Durable ngay cạnh file, nên một lần quét không tốn vòng request nào: sự kiện gốc (inotify, FSEvents) chỉ kích một lần quét lại có debounce, và thay đổi là hiệu giữa hai ảnh chụp cộng các đường dẫn của sự kiện (daemon/src/watch.rs:1–3). Windows và file system mạng hay FUSE thì poll; hết watch gốc thì chuyển sang poll và báo overflow (semantics.md:62–68). Request watch kéo dài tới khi bị huỷ: event ready khi đã phủ xong, rồi change với paths hoặc overflow. Phía client, RemoteWatcher lo phần mất kết nối:

listFiles và LocalExecutionEnv

listFiles là phương thức tuỳ chọn của ExecutionEnv: liệt kê cả cây dưới một thư mục như fd --hidden --exclude .git mà không theo symlink, bỏ những gì git ignore bên trong work tree, có giới hạn maxEntries (mặc định 50.000), maxBytes (4 MiB) và timeoutMs (10 s) với cờ truncated (protocol.md:101–112). Daemon dùng crate ignore của fd và ripgrep (daemon/src/list.rs:1–2), và trả kết quả dạng bytes chứ không phải JSON:

listFilesOver(): kiểm features trước, payload là các bản ghi kind byte + đường dẫn + NUL
pi/packages/env/src/list-files.tsdòng 36–71TypeScript
const aborted = abortResult<FileListing>(context.abortSignal, path);if (aborted) return aborted;try {	if (!(await connection.info()).features?.includes("listFiles")) {		return err(new FileError("not_supported", "The pi-env daemon does not list files; update it", path));	}	const { json, payload } = await connection.request(		"listFiles",		// Fields left undefined are not sent; the daemon applies the defaults.		{			path,			hidden: options?.hidden,			gitignore: options?.gitignore,			exclude: options?.exclude,			maxEntries: options?.maxEntries,			maxBytes: options?.maxBytes,			timeoutMs: options?.timeoutMs,		},		context.abortSignal ? { signal: context.abortSignal } : {},	);	const afterAbort = abortResult<FileListing>(context.abortSignal, path);	if (afterAbort) return afterAbort;	// Records of a kind byte, the UTF-8 path, and a NUL.	const entries: FileListEntry[] = [];	const bytes = Buffer.from(payload.buffer, payload.byteOffset, payload.length);	for (let start = 0; start < bytes.length; ) {		const end = bytes.indexOf(0, start);		if (end === -1) break;		const kind = KINDS[bytes[start]!];		if (kind !== undefined) entries.push({ path: bytes.toString("utf8", start + 1, end), kind });		start = end + 1;	}	return ok({ entries, truncated: json.truncated === true });} catch (error) {	return err(toFileError(error, path));}
Daemon phát hành trước listFiles không có tên đó trong hello.features, nên client trả not_supported mà không gửi request, và người gọi tự duyệt cây (durable/README.md:456–460).

Vì NodeExecutionEnv không có listFiles, pi-env thêm LocalExecutionEnv: NodeExecutionEnv cộng listFiles qua một daemon trên chính máy này (new Connection({ command: [localDaemon()] })), để môi trường cục bộ và từ xa liệt kê bằng cùng một code (list-files.ts:79–102). Connection thuộc về người gọi, và người gọi đóng nó.

Terminal

openTerminal(connection, options) chạy một chương trình (mặc định shell đăng nhập của user) trong một pseudo-terminal của daemon, dẫn đầu một session mới với terminal làm terminal điều khiển; output là bytes thô của terminal, không giải mã như exec (protocol.md:134–151). RemoteTerminal có write() (chia input thành các khúc 1 MiB vì frame tối đa 16 MiB), resize() (chương trình nhận SIGWINCH), close() (giết cả session), và foreground() trả về process group đang ở tiền cảnh khi nó không phải chính chương trình, như một trình soạn thảo mà shell đang chạy (terminal.ts:26–90). ptyWrite, ptyResize, ptyForeground đánh địa chỉ request pty đang chạy bằng id của nó, và được trả lời theo thứ tự đến (đã thấy ở mục daemon). Terminal chưa hỗ trợ Windows (ENOTSUP).

Lần theo một lệnh bash trên máy từ xa#

Kịch bản: một Harness của Pi Durable chạy trên laptop, môi trường là new RemoteExecutionEnv({ connection: sshConnection(target).connection, id: "pi-env:gpu", cwd: "/home/me/project" }) như ví dụ của README (README.md:25–28), host key của gpu-box đã được tin, và đây là lệnh đầu tiên của tiến trình. Mô hình gọi tool bash với { "command": "pytest -q" } (lệnh là giả định). Các bước theo code, không phải in ra từ một lần chạy.

  1. 1. Tool bashTask pi.tool của Durable chạy execute của tool bash, vốn gọi env.exec(command, { cwd, env, inheritEnv, timeout, onOutput, spill, window }, context); window là đuôi 2.000 dòng / 50 KiB và nhịp 100 ms (bash.ts:95–110).
  2. 2. Kiểm ở clientRemoteExecutionEnv.exec kiểm timeout, giải cwd theo luật của máy từ xa, dựng biến môi trường (shellEnv rồi env khi kế thừa), rồi gọi connection.request("exec", …) với onStart (để cleanup() biết lệnh này) và onEvent.
  3. 3. Khởi động lườiChưa có session, nên #start() gọi hàm lệnh của sshConnectionFor: detectPlatform() (một vòng SSH), deployDaemon() kiểm hash ở xa và nếu thiếu thì tải lên, rồi trả về ["ssh", …sshArguments(target), "'/home/me/.pi/mobile/tools/pi-env-<32 hex>'"]. Connection spawn nó với serve --token <hex> (connection.ts:268–283).
  4. 4. Đồng bộ và hello.bashrc ở xa in một dòng chào; client chuyển nó vào onLog khi thấy PI-ENV <token>. Request đầu là hello { protocol: 1 }; thông tin máy (home, tmpdir, hệ điều hành) được nhớ cho mọi lần giải đường dẫn sau.
  5. 5. Request exec#send cấp id, đăng ký pending, gắn listener huỷ (abort của Context thành một frame cancel), rồi ghi frame(REQUEST, id, { …json, op: "exec" }) vào stdin của ssh (connection.ts:240–262).
  6. 6. Daemon chạy lệnhserve_frames đăng ký Control, cho exec một luồng riêng; exec::run chọn shell (/bin/bash -c "pytest -q"), kiểm cwd tồn tại, spawn với setsid, ghi pid vào tập process group, và mở hai luồng đọc pipe (exec.rs:242–320).
  7. 7. Output qua cửa sổBytes từ pipe được giải mã UTF-8 theo từng luồng, đẩy vào Pending; phần chắc chắn nằm ngoài đuôi được thay bằng số đếm. Tới hạn và frame trước đã ra stdout thì một event { kind: "output", stream, skipped? } mang text làm payload được xếp vào hàng bulk.
  8. 8. Về tới clientNgười ghi stdout của daemon gửi frame control trước, bulk sau; #onData tách frame, #dispatch gọi onEvent, RemoteExecutionEnv gọi onOutput(text, context, { stream, skipped }), tool bash gọi api.output(text, skipped), và OutputBuffer của Chord giữ đuôi; Progress quyết định khi nào commit phần đã giữ vào storage (chương task).
  9. 9. Kết quảTiến trình thoát; daemon gom output tới khi hai pipe đóng hoặc 100 ms im lặng, giao phần còn giữ, rồi gửi result { exitCode } qua hàng bulk, nên nó tới sau output của chính lệnh đó. exec trả ok({ exitCode }); tool dựng kết quả từ output đã giữ (bash.ts:126–135).
Ví dụ minh hoạ: các frame trên stdin/stdout của ssh (id và kích thước là giả định; ping bị lược)

 stdout  "Welcome to gpu-box\n"                                    → onLog, bỏ qua
 stdout  "PI-ENV 3f9c…e1\n"                                        → synced
 stdin   [len][1][id 1][jsonLen]{"protocol":1,"op":"hello"}
 stdout  [len][2][id 1][jsonLen]{"protocol":1,"version":"1.4.0","os":"linux","arch":"x86_64",
                                  "home":"/home/me","tmpdir":"/tmp",…,"features":["listFiles"]}
 stdin   [len][1][id 2][jsonLen]{"command":"pytest -q","cwd":"/home/me/project","env":{},
                                  "inheritEnv":true,"spill":{…},"window":{"maxBytes":51200,
                                  "maxLines":2000,"minIntervalMs":100,"bytesPerSecond":102400},"op":"exec"}
 stdout  [len][4][id 2][jsonLen]{"kind":"output","stream":"stdout"}  payload: "....F.\n"
 stdout  [len][4][id 2][jsonLen]{"kind":"output","stream":"stdout"}  payload: "1 failed, 5 passed\n"
 stdout  [len][2][id 2][jsonLen]{"exitCode":1}                      → kết quả lỗi exit_code của tool
Khung frame theo frame(): u32 độ dài, u8 loại, u32 id, u32 độ dài JSON, JSON, payload. Thứ tự khoá JSON là thứ tự JSON.stringify của { ...json, op }.

Nếu Wi-Fi rớt giữa chừng: sau 30 giây không nhận được byte nào, daemon giết process group của pytest và thoát; client, cũng sau 30 giây im lặng, huỷ session và từ chối request exec với { code: "unknown", lost: true }. exec trả err(ExecutionError("unknown", …)), tool bash ném lại lỗi đó, và Durable biến nó thành một kết quả lỗi vẫn mang output đã giữ (bash.ts:138–143). Lệnh tiếp theo khởi động một daemon mới (kiểm lại hash, không tải lại nếu đã có). Nếu chính tiến trình Durable chết thì lại là chuyện khác: pipe stdin của ssh đóng theo nó, và giao thức hứa daemon giết mọi thứ khi stdin kết thúc; nếu EOF không tới được máy từ xa thì giới hạn 30 giây im lặng vẫn dọn dẹp (protocol.md:64–65; đường đi của EOF qua ssh là suy luận, không có test trong repo). Khi mở lại, Durable xử lý lời gọi tool bị cắt ngang theo chính sách replay của tool (chương task).

pi-telemetry: hợp đồng span, sự kiện, metric#

Bài toán: pi-ai, pi-agent-core và Pi Durable đều muốn mô tả việc chúng làm (một request tới mô hình mất bao lâu, một phase của task kết thúc ra sao), nhưng người dùng của chúng dùng backend quan sát khác nhau: OpenTelemetry, Sentry, log thuần, hay không gì cả. Gắn một SDK cụ thể vào thư viện là bắt mọi người theo nó. pi-telemetry là "vendor-neutral telemetry contracts and typed schema utilities": một hợp đồng span dựa trên callback, một context no-op dùng chung, propagation qua tiến trình, metric, một adapter tham chiếu trong bộ nhớ, schema có kiểu, và không có exporter, không có span "hiện tại" toàn cục, không phụ thuộc backend nào (README.md:3–15). Package được tách ra ở commit 6b461b75b "feat: extract telemetry package" (2026-08-05); bản 1.4.0 thêm propagation, link, metric, TelemetryStore và renderTrace (CHANGELOG.md:3–20).

Từ vựng theo bảng của README (README.md:48–55): span là bản ghi có thời gian của một thao tác; span lồng nhau thành cây; attribute là một sự kiện có tên gắn vào span (cache.hit: true); event là một điểm trong span, không có độ dài; status là ok hoặc error; context là nơi một span mới sẽ được gắn vào cây. Span là dữ liệu chẩn đoán, không phải state nghiệp vụ.

Không có end(). startSpan(options, callback) giữ span mở cho tới khi giá trị hay promise của callback xong; thành công là ok, ném hay reject là error trừ khi đã đặt status tường minh (README.md:108–121). Hợp đồng của một adapter được liệt kê thành từng điều: gọi callback đồng bộ đúng một lần, giữ nguyên giá trị trả về và giá trị reject, gộp setAttributes (giá trị sau thay giá trị trước, undefined bị bỏ qua), các phương thức ghi là đồng bộ, và bỏ qua mọi lời gọi sau khi span đã xong (README.md:127–139). Đầu vào có kiểu được tin; adapter không cần kiểm hay nuốt lỗi của chính nó (CHANGELOG 1.4.0, CHANGELOG.md:7).

No-op và adapter trong bộ nhớ

NOOP_TELEMETRY_CONTEXT: một span trơ đông cứng dùng chung, kể cả cho span con
pi/packages/telemetry/src/noop.tsdòng 4–37TypeScript
async function startNoopSpan<T>(_options: SpanOptions, callback: (span: TelemetrySpan) => T | Promise<T>): Promise<T> {	return callback(noopTelemetrySpan);}const noopCounter: TelemetryCounter = Object.freeze({ add: () => {} });const noopUpDownCounter: TelemetryUpDownCounter = Object.freeze({ add: () => {} });const noopHistogram: TelemetryHistogram = Object.freeze({ record: () => {} });/** Shared instruments that never inspect or retain metadata, measurements, or contexts. */export const NOOP_TELEMETRY_METRICS: TelemetryMetrics = Object.freeze({	counter: () => noopCounter,	upDownCounter: () => noopUpDownCounter,	histogram: () => noopHistogram,});/** Shared propagation capability that neither inspects nor retains its inputs. */export const NOOP_TELEMETRY_PROPAGATION: TelemetryPropagation = Object.freeze({	inject: () => undefined,	extract: () => NOOP_TELEMETRY_CONTEXT,});const noopTelemetrySpan: TelemetrySpan = {	metrics: NOOP_TELEMETRY_METRICS,	propagation: NOOP_TELEMETRY_PROPAGATION,	startSpan: startNoopSpan,	addLink: () => {},	addEvent: () => {},	setAttributes: () => {},	setStatus: () => {},};Object.freeze(noopTelemetrySpan);/** Shared telemetry context used when an application does not provide one. */export const NOOP_TELEMETRY_CONTEXT: TelemetrySpan = noopTelemetrySpan;
async là có chủ đích: một lần ném đồng bộ trong callback thành một promise bị reject với đúng giá trị đó. Đây là span mà BACKGROUND_CONTEXT của Chord trả về khi không có span nào (chương Chord).

InMemoryTelemetryContext là hiện thực tham chiếu, dùng cho test, chẩn đoán cục bộ, hoặc ứng dụng muốn ghi lại trong tiến trình mà không cần exporter. Nó cài đặt cùng lúc TelemetryContext, TelemetryPropagation, TelemetryMetrics và TelemetryStore, ghi span với id số tăng dần, và trả về ảnh chụp đã tách khỏi bộ nhớ của nó (memory.ts:282–299). Phần quyết định status và kết thúc:

startInMemorySpan(): span cha đã xong thì span con trơ; lỗi thành status error nếu chưa đặt tường minh
pi/packages/telemetry/src/memory.tsdòng 182–191, 230–246TypeScript
async function startInMemorySpan<T>(	state: InMemoryTelemetryState,	parent: InMemoryParent | undefined,	options: SpanOptions,	callback: (span: TelemetrySpan) => T | Promise<T>,): Promise<T> {	if (parent?.span?.settled) return startInertSpan(state, options, callback);	const recordedSpan = createSpan(state, parent, options);	state.spans.push(recordedSpan);	contextCarriers.set(span, recordedSpan.context);	try {		return await callback(span);	} catch (error) {		if (!recordedSpan.explicitStatus)			recordedSpan.status =				error instanceof Error					? { status: "error", error: { name: error.name, message: error.message } }					: { status: "error" };		throw error;	} finally {		recordedSpan.settled = true;		recordedSpan.endedMs = recordedSpan.startedMs + performance.now() - recordedSpan.clock;		recordedSpan.endSequence = state.nextEndSequence++;	}}
Bộ nhớ không có trần và chỉ trong tiến trình; README khuyên tạo instance mới cho mỗi phạm vi ghi và không ghi thuộc tính nhạy cảm (README.md:285).

Propagation, link và metric

Để một trace đi qua ranh giới tiến trình, adapter có thể cài đặt TelemetryPropagation: inject(context) trả về một carrier, một map chuỗi sang chuỗi an toàn với JSON mà định dạng do adapter quyết (W3C traceparent cũng vừa), và extract(carrier) trả về một context cha ở phía nhận; carrier lạ hay vắng thì rơi về gốc. Transport chỉ chở carrier, không bao giờ chở context hay span; và metadata của trace là chẩn đoán, không bao giờ là phân quyền (README.md:143–167). Adapter trong bộ nhớ dùng ba khoá pi-memory-scope, pi-memory-span, pi-memory-trace, nên carrier của nó sống qua JSON và qua các tiến trình độc lập (memory.ts:307–324). Link nối hai đoạn công việc mà không đổi cha, ví dụ một activation với mọi lời gọi đã kích nó (README.md:169–181).

Metric (counter, up-down counter, histogram) là một năng lực tuỳ chọn của cùng adapter, lộ ra qua context.metrics, để một lời gọi mô hình lồng sâu vẫn ghi được metric mà không cần cấu hình riêng (metrics.ts:10–41). Histogram nhận thêm một context để gắn phép đo với span (exemplar). Quy tắc: chỉ dùng thuộc tính có ít giá trị (hướng, phương thức), không dùng id request, id người dùng, prompt hay đường dẫn tuỳ ý (README.md:205–209).

Đọc lại: TelemetryStore và renderTrace

TelemetryStore tách truy vấn khỏi ghi nhận: findTraces(query) (trace mới nhất trước, lọc theo tên span sớm nhất, khoảng thời gian, errors, thuộc tính, phân trang bằng cursor), getTrace(traceId), findMetrics(query) (index.ts:90–95). Hai hiện thực: adapter trong bộ nhớ và telemetry-sqlite của Chord (chương Chord). Store chỉ trả span đã xong; trace id là span id của gốc, nên span từ nhiều store được gộp theo trace id, và quan hệ cha con, không phải việc thời gian nằm lồng nhau, định nghĩa cây (README.md:289–297). renderTrace(spans) in một rừng span theo thời gian, coi cha vắng mặt là gốc, kèm thuộc tính, thời lượng, lỗi, offset của event và link (render.ts:9–49).

Bộ conformance

@earendil-works/pi-telemetry/testing xuất createTelemetryAdapterConformance(factory): 14 ca kiểm độc lập với test runner, mỗi ca một fixture mới; fixture cung cấp context và đổi span đã xong của backend thành RecordedTelemetrySpan chuẩn hoá (testing/types.ts:4–21). Bộ ca kiểm năng lực được mang qua mọi context, việc nhận callback đồng bộ đúng một lần, danh tính kết quả và lỗi, status tự động và tường minh, gộp thuộc tính, thứ tự event, lời gọi trơ sau khi xong, quan hệ cha con lồng và đồng thời, link; với fixture có propagation và metrics thì kiểm thêm vòng JSON, nhiều chặng adapter, và thứ tự phép đo (README.md:341). Đây là cách một adapter OpenTelemetry hay Sentry do người khác viết chứng minh nó tuân hợp đồng.

Schema telemetry có kiểu#

API tầng thấp cố ý mở: tên span là chuỗi bất kỳ, thuộc tính là một túi bất kỳ, để adapter dùng chung được. Package miền (pi-ai, Durable) muốn ngược lại: một từ vựng đóng, để mọi chỗ phát ra đúng tên và đúng thuộc tính. Schema là dữ liệu JSON tuần tự hoá được, mô tả từng span (mô tả, quy tắc cha, thuộc tính lúc bắt đầu kèm required, thuộc tính lúc hoàn tất, event, quy tắc status) và từng metric (index.ts:128–177). defineTelemetrySchema() chỉ là hàm định danh có kiểu: nó trả lại chính đối số và không kiểm gì lúc chạy.

createTypedSpanStarter(): lúc chạy chỉ là startSpan; mọi việc kiểm nằm ở kiểu
pi/packages/telemetry/src/index.tsdòng 427–457TypeScript
function bindTypedSpanStarter<Schemas extends TelemetrySchemaTuple>(	telemetryContext: TelemetryContext,): TypedSpanStarter<Schemas> {	const startSpan = (		name: SpanNameInSchemas<Schemas>,		attributes: SpanAttributes,		callback: (			span: SpanInSchema<Schemas[number], SpanNameInSchemas<Schemas>>,			startChildSpan: TypedSpanStarter<Schemas>,		) => unknown,	): Promise<unknown> =>		telemetryContext.startSpan({ name, attributes }, (span) =>			callback(				span as SpanInSchema<Schemas[number], SpanNameInSchemas<Schemas>>,				bindTypedSpanStarter<Schemas>(span),			),		);	return startSpan as TypedSpanStarter<Schemas>;}/** * Bind an explicit parent context to the combined span vocabulary of one or more schemas. * Schema values are used only for type inference; no runtime schema validation is performed. */export function createTypedSpanStarter<const Schemas extends TelemetrySchemaTuple>(	telemetryContext: TelemetryContext,	_schemas: Schemas & UniqueTelemetrySchemas<Schemas>,): TypedSpanStarter<Schemas> {	return bindTypedSpanStarter<Schemas>(telemetryContext);}
Callback nhận thêm một starter con đã gắn vào span vừa mở. Kiểu (định nghĩa ở phần trên của file) có một overload cho mỗi span: tên lạ, thiếu thuộc tính bắt buộc, khoá thừa, giá trị ngoài tập đóng, event không khai báo đều là lỗi biên dịch; tên span trùng giữa các schema trong một mảng cũng vậy (UniqueTelemetrySchemas).

startAttributes và endAttributes chỉ nói khi nào thuộc tính thường đã biết; cả hai thành thuộc tính thường của cùng một span, và "end" nghĩa là làm giàu lúc hoàn tất, luôn tuỳ chọn, vì một đường lỗi sớm hay huỷ có thể không bao giờ có giá trị đó (README.md:424–453). createTypedMetrics(metrics, schemas) làm việc tương tự cho metric, khác ở chỗ nó đọc và giữ metadata của instrument (mô tả, đơn vị) lúc chạy để chuyển cho adapter; nó vẫn không kiểm thuộc tính lúc chạy (metrics.ts:128–145).

Pi Durable và telemetry#

Host đưa một adapter vào HarnessOptions.telemetry; vắng thì là no-op (durable/harness/types.ts:609–611). Durable dùng nó cho hai span ngắn và vài metric, mô tả bằng DURABLE_TELEMETRY_SCHEMA. Lời hứa quan trọng nhất nằm ở README: thuộc tính chỉ chứa ID và metadata điều khiển, không bao giờ chứa message, đối số hay kết quả; ID trỏ vào transcript (durable/README.md:82).

Span phase được mở trong scheduler. Đoạn đầu cho thấy hai ý thiết kế cùng lúc:

#phase(): cha là carrier lưu trong task; span được đưa xuống Context của invocation
pi/packages/durable/src/harness/scheduler.tsdòng 1739–1765TypeScript
/** A phase owns its decision commit, not the next handler, a durable wait, or recovery. */#phase<T>(invocation: Invocation, task: ErasedRunningTask, run: () => Promise<T>): Promise<T> {	const parent =		task.trace === undefined			? this.#telemetry			: (this.#telemetry.propagation?.extract(task.trace) ?? this.#telemetry);	const checkpoint = task.state.checkpoint;	const attempt = checkpoint.attempt;	const resumed = this.#resumed.delete(task.id);	return createTypedSpanStarter(parent, [DURABLE_TELEMETRY_SCHEMA])(		"pi.durable.phase",		{			"task.id": task.id,			"task.name": task.kind,			phase: checkpoint.phase,			kind: invocation.mode,			resumed,			...(attempt === undefined ? {} : { attempt }),		},		async (span) => {			if (span.propagation !== undefined && invocation.links !== undefined) {				for (const carrier of invocation.links) span.addLink({ context: span.propagation.extract(carrier) });			}			const previous = invocation.context;			invocation.context = withTelemetry(span, previous);			const started = performance.now();			try {
Phần sau đặt outcome, đặt status lỗi với tên task_faulted, task_failed, task_orphaned và thông báo cắt còn 500 ký tự, rồi ghi thời lượng vào histogram kèm span làm exemplar (scheduler.ts:1766–1802).

Ý thứ nhất: cha của span không phải context đang chạy mà là task.trace, một carrier được inject lúc task được tạo và lưu trong bản ghi task (durable/types.ts:646–650; tạo ở transaction.ts:435–438). Một task chạy qua nhiều lần khởi động lại, ở nhiều tiến trình khác nhau; nhờ carrier nằm trong storage, phase chạy sau một lần crash vẫn gắn vào trace của nơi đã tạo ra task. README của pi-telemetry cho phép đúng điều này: context và span không bao giờ được lưu, nhưng một carrier đã inject có thể được lưu làm dữ liệu tương quan chẩn đoán tuỳ chọn (README.md:515). Submission cũng lưu carrier của span admission (submissions.ts:219). Ý thứ hai: withTelemetry(span, previous) của Chord đặt span vào Context của invocation, nên mọi thứ phase gọi xuống (pi-ai, tool) đều nhận span làm cha qua tham số, không qua ngữ cảnh ngầm.

Nguyên tắc "không chứa nội dung" được giữ cả ở đường lỗi: khi admission thất bại, span chỉ nhận status error mà không có thông báo, vì "Error text may contain caller content" (submissions.ts:93–97). Còn lưu trace ở đâu là việc của host: một adapter OpenTelemetry, hoặc createSqliteTelemetry(db) của Chord trên chính database SQLite của ứng dụng. Theo README, pi-ai nhận một TelemetrySpan cha qua tuỳ chọn telemetry của request và xuất AI_TELEMETRY_SCHEMA; pi-agent-core giữ instrumentation và schema riêng của nó (README.md:497–511).

pi-evals: đo hành vi của coding agent#

packages/evals là "behavioral evals for Pi's coding agent, built with vitest-evals" (README.md:3). Nó là package riêng tư, không phát hành (package.json:4), xuất hiện từ commit eafe11fb9 "feat(coding-agent): add vitest eval harness (#7085)" (2026-07-25). Đối tượng được đo là coding agent (AgentSession, xem chương AgentSession), không phải Pi Durable, và mỗi lần chạy gọi một mô hình thật: cần PI_PROVIDER và PI_MODEL cùng credential trong ~/.pi/agent/auth.json (README.md:22–28; docker.ts:67–89).

Hai loại eval

  • Host eval (*.eval.ts), chạy bằng Vitest ngay trên máy (npm run eval:host): smoke.eval.ts hỏi thủ đô của Pháp với noTools: "all" và kiểm câu trả lời, lỗi, provider, số token (smoke.eval.ts:5–18); documentation-audit.eval.ts cho agent đối chiếu từng trang trong packages/coding-agent/docs với code, và nộp kết luận match/mismatch/inconclusive qua một tool tuỳ chỉnh có schema chặt (documentation-audit.eval.ts:9–65).
  • Eval độ nâng của tài liệu (documentation lift, *.docs.eval.ts): mỗi ca chạy trong hai container cô lập, without_docs và with_docs, và báo cáo chênh lệch tỉ lệ đạt (README.md:5–10). Năm bộ ở v1.4.0: custom provider, extension, model, provider OpenAI, TUI.

Một eval độ nâng trông như thế này: cấu hình một mô hình mới cho Pi đang chạy, nạp lại, rồi so cấu hình thật với kết quả mong đợi bằng một judge khớp chặt:

models.docs.eval.ts: chấm bằng trạng thái thật của runtime, không bằng lời của mô hình
pi/packages/evals/evals/models.docs.eval.tsdòng 25–40TypeScript
const harness = createPiDocumentationEvalHarness({	output: ({ session }) => inspectAddedModel(session.modelRuntime, PROVIDER_ID, MODEL_ID),});const judge = StructuredOutputJudge({ expected, match: "strict", allowExtras: false });describeEval("Add model to existing provider", { harness, judges: [judge], judgeThreshold: null }, (it) => {	it("adds the model without replacing existing models", async ({ run }) => {		await run([			{				type: "prompt",				content: `Configure this running Pi installation with a new \`${PROVIDER_ID}/${MODEL_ID}\` model. Do not create project-local configuration. Show it as “${MODEL_NAME}”. It accepts text, supports reasoning, has a 32,768-token context window and a 4,096-token maximum output, and has no usage cost.`,			},			{ type: "reload" },		]);	});});
judgeThreshold: null nghĩa là điểm thấp là dữ liệu, không phải lỗi hạ tầng; assertion của Vitest chỉ dành cho bất biến của bộ test bị vỡ (README.md:127–129).

Harness: một coding agent cô lập cho mỗi lần chạy

createPiCodingAgentHarness() là bộ chuyển đổi cho vitest-evals. Mỗi lần chạy, runPiCodingAgent() tạo một thư mục gốc tạm với workspace, home và thư mục agent riêng, đổi HOME và PI_CODING_AGENT_DIR sang đó, nạp đúng credential của provider đã chọn vào một InMemoryCredentialStore, gieo file fixture vào workspace (từ chối đường dẫn thoát ra ngoài), dựng session với thinkingLevel: "off", và từ chối nếu có extension nào ngoài những extension inline mà chính harness thêm (harness.ts:272–365). Sau khi chạy, nó gom transcript thành sự kiện, usage (token, lời gọi tool, chi phí ước tính nếu mô hình có giá), hash SHA-256 của system prompt, và lưu file session.jsonl làm artifact (harness.ts:390–431). Biến thể tài liệu được dựng ở đây:

Biến thể without_docs: cắt mục docs khỏi system prompt mặc định; chỉ tool file, không shell
pi/packages/evals/src/harness.tsdòng 484–506, 523–537TypeScript
/** Documentation evals intentionally exclude shell and unrestricted network tools. */export const DOCUMENTATION_EVAL_TOOLS = ["read", "write", "edit", "grep", "find", "ls"] as const;export function resolveDocumentationVariant(	value: string | undefined = process.env.PI_EVAL_VARIANT,): DocumentationVariant {	if (value === "without_docs" || value === "with_docs") return value;	throw new TypeError('PI_EVAL_VARIANT must be "without_docs" or "with_docs".');}export function excludePiDocumentation(defaultPrompt: string): string {	const documentationStartMarker = "\n<docs>\n";	const documentationEndMarker = "\n</docs>";	const documentationStart = defaultPrompt.indexOf(documentationStartMarker);	if (documentationStart === -1) throw new Error("Default Pi system prompt has no Pi documentation section.");	const documentationEnd = defaultPrompt.indexOf(documentationEndMarker, documentationStart);	if (documentationEnd === -1) throw new Error("Default Pi system prompt has no complete Pi documentation section.");	const cwdStart = defaultPrompt.lastIndexOf("\n<cwd>\n");	if (cwdStart < documentationEnd) throw new Error("Default Pi system prompt has no working-directory section.");	return (		defaultPrompt.slice(0, documentationStart) + defaultPrompt.slice(documentationEnd + documentationEndMarker.length)	);}export function createPiDocumentationEvalHarness<TOutput extends JsonValue>(	options: DocumentationHarnessOptions | DocumentationHarnessWithOutput<TOutput> = {},) {	if (process.env.PI_EVAL_CONTAINER !== "1" || !resolveSandboxIdentity()) {		throw new Error("Documentation evals must run in the isolated container sandbox.");	}	const variant = resolveDocumentationVariant();	return createPiCodingAgentHarness({		...options,		name: variant,		tools: options.tools ?? [...DOCUMENTATION_EVAL_TOOLS],		...(variant === "without_docs" ? { transformSystemPrompt: excludePiDocumentation } : {}),		expectedPiDocumentation: variant === "with_docs",	});}
Ngoài prompt, image without_docs còn xoá hẳn README.md, CHANGELOG.md, docs/ và examples/ của coding agent (README.md:82–90). verifySystemPrompt kiểm prompt thật của mỗi lần chạy có đúng biến thể không (harness.ts:257–270).

Container: mô hình không đọc được bài chấm

Một eval có thể bị "giải" bằng cách đọc judge hay fixture. Thiết kế chặn điều đó bằng quyền file. Dockerfile build workspace một lần, dựng hai target without_docs và with_docs từ cùng một runtime đã cài, và đặt mọi mã nguồn của evaluator ở chế độ chỉ root đọc được (docker/Dockerfile:12–44). Entrypoint kiểm nội dung image theo danh sách cho phép, kiểm tài liệu của package nội bộ không lộ cho mô hình, chạy thử một tiến trình với UID của sandbox để chắc nó không đọc được mã evaluator, và kiểm coding agent được nạp từ dist/ (docker/entrypoint.ts:27–105). Bên trong lần chạy, harness tự hạ quyền vĩnh viễn trước khi gửi prompt:

enterToolSandbox(): bắt đầu là root để nạp evaluator, rồi setuid xuống UID không đặc quyền và tự kiểm
pi/packages/evals/src/harness.tsdòng 154–188TypeScript
async function enterToolSandbox(root: string, identity: SandboxIdentity | undefined): Promise<void> {	if (!identity) return;	if (		typeof process.getuid !== "function" ||		typeof process.geteuid !== "function" ||		typeof process.setuid !== "function" ||		typeof process.setgid !== "function" ||		typeof process.setgroups !== "function"	) {		throw new Error("The eval filesystem sandbox requires POSIX user APIs.");	}	if (process.getuid() !== 0 || process.geteuid() !== 0) {		throw new Error("The eval runner must start as root before entering the unprivileged tool sandbox.");	}	const protectedPaths = await protectTransformedModules();	await chownTree(root, identity);	process.setgroups([]);	process.setgid(identity.gid);	process.setuid(identity.uid);	if (process.getuid() !== identity.uid || process.geteuid() !== identity.uid) {		throw new Error("Failed to enter the unprivileged eval tool sandbox.");	}	for (const path of protectedPaths) {		try {			await readFile(path);		} catch (error) {			if (error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "ENOENT")) {				continue;			}			throw error;		}		throw new Error(`The unprivileged eval process can read a transformed eval module: ${path}`);	}}
Module đã được Vite biến đổi (bản biên dịch của file eval) được chmod 600 trước khi hạ quyền, rồi harness thử đọc lại từng file: đọc được là lỗi. Mỗi container chạy --read-only, /tmp là tmpfs, credential được mount chỉ đọc, UID sandbox là 65532 (docker.ts:91–124).

README nói rõ giới hạn: tool của eval tài liệu chỉ gồm read, write, edit, grep, find, ls, nhưng container vẫn cần mạng để gọi provider, nên Docker một mình không chứng minh được code do agent viết không dùng mạng (README.md:90).

Kế hoạch, chạy, ghép cặp

src/cli.ts điều phối một lần so sánh: build hai image (id phải khác nhau), phát hiện ca trong cả hai biến thể và đòi hai tập ca giống hệt, ghi protocol.json kèm digest, chạy từng nhiệm vụ trong một container mới (tuần tự), ghi observations.jsonl sau mỗi nhiệm vụ, rồi tóm tắt; còn cặp bị chặn thì thoát mã khác 0 (cli.ts:114–192). Kế hoạch được lập trước khi chạy bất cứ thứ gì:

createTaskPlan(): mỗi (ca, lần chạy) có hai nhánh, thứ tự đảo theo số lần chạy
pi/packages/evals/src/plan.tsdòng 39–59TypeScript
export function createTaskPlan(	cases: readonly DiscoveredEvalCase[],	model: string,	runsPerVariant: number,): EvalTask[] {	if (!model.includes("/") || model.startsWith("/") || model.endsWith("/")) {		throw new TypeError("Model identity must contain a provider and model.");	}	if (!Number.isSafeInteger(runsPerVariant) || runsPerVariant < 1) {		throw new TypeError("Runs per variant must be a positive integer.");	}	const tasks: EvalTask[] = [];	for (const evalCase of cases) {		for (let runNumber = 1; runNumber <= runsPerVariant; runNumber += 1) {			const variants: DocumentationVariant[] =				runNumber % 2 === 1 ? ["without_docs", "with_docs"] : ["with_docs", "without_docs"];			for (const variant of variants) tasks.push({ ...evalCase, variant, model, runNumber });		}	}	return tasks;}
Đảo thứ tự without_docs/with_docs giữa các lần lặp là để giảm thiên lệch do thứ tự (README.md:80). Mặc định một lần mỗi biến thể; tăng bằng --runs-per-variant khi cần đo độ ổn định (README.md:57–63).

Báo cáo ghép từng cặp (ca, mô hình, lần chạy). Một cặp chỉ được tính khi mỗi nhánh có đúng một quan sát có điểm; thiếu, trùng, bị bỏ qua, chưa chạy, không chấm hay lỗi đều làm cặp bị chặn, kèm lý do (report.ts:249–281). "Đạt" nghĩa là điểm ≥ 1. Tỉ lệ đạt của từng bộ và độ nâng (treatment − control) chỉ được công bố khi bộ đó không có cặp nào bị chặn; token, lời gọi tool, thời gian và chi phí được so trung bình theo cặp, và telemetry vắng mặt được giữ là "không có" chứ không coi là 0 (report.ts:359–413; README.md:103–105). Báo cáo còn gắn cờ no-lift, negative-delta, control-saturated, treatment-saturated và flaky (cùng ca, cùng biến thể mà lúc đạt lúc không) (report.ts:336–357). "Telemetry" trong ngữ cảnh này là số liệu usage do harness gom, không liên quan tới pi-telemetry.

Quyết định thiết kế#

Một chuẩn đối chiếu duy nhất, và test để giữ nó

Mục tiêu của pi-env không phải "một môi trường từ xa hợp lý" mà là "giống hệt NodeExecutionEnv chạy ở đó". Đó là lý do có bảng chia việc: luật nằm ở client bằng TypeScript, system call ở daemon. Mục tiêu đó được kiểm bằng hai cách: bộ kiểm tuân thủ env của Durable chạy trên RemoteExecutionEnv qua một pipe và trên LocalExecutionEnv (test/conformance.test.ts:17–45), và một test vi sai dựng một cặp môi trường cục bộ và từ xa trên cùng máy rồi so các chuỗi thao tác ngẫu nhiên và chính các tool của Durable (test/differential.test.ts:24–33; README.md:63–66). Không có test như vậy, mọi khác biệt nhỏ (thứ tự readdir, một mã lỗi) sẽ lộ ra như một tool hành xử khác khi đổi máy.

SSH làm xác thực, daemon không mở cổng

Daemon không lắng nghe trên mạng; nó chỉ nói chuyện qua stdin/stdout của một phiên SSH. Xác thực, mã hoá và quyền là việc của SSH, và pi-env siết SSH tới mức tối thiểu: không chuyển tiếp gì, không dùng chung kết nối, không lệnh từ cấu hình, host key nghiêm theo file và alias riêng, key đổi không bao giờ được tự chấp nhận. Binary được đặt tên theo nội dung và được kiểm hash trước mỗi lần khởi động, nên một binary bị sửa trên máy từ xa không được chạy, và hai phiên bản không bao giờ đè lên nhau (README.md:20–21).

Không mồ côi, đổi lại không tiếp tục

Daemon giết mọi thứ khi kết nối im 30 giây hay stdin kết thúc. Lựa chọn ngược lại (để lệnh chạy tiếp và cho client nối lại) sẽ cần id phiên, đệm output và một giao thức tiếp tục; đổi lại, nó để lại tiến trình không ai quản khi một điện thoại mất mạng. pi-env chọn đơn giản và an toàn: lệnh chết theo kết nối, request đang bay thất bại với lost: true, và với thao tác có hiệu ứng, kết quả là "không biết". Đây cũng là ngữ nghĩa mà Durable đã có cho tool bị cắt ngang, nên hai tầng khớp nhau.

Câu trả lời nhỏ không chờ dữ liệu lớn

Một hàng control và một hàng bulk ở daemon, cộng cửa sổ output, giải hai vấn đề của link chậm: ping và kết quả của một lệnh huỷ không bị kẹt sau megabyte output, và output mà người gọi sẽ vứt đi thì không bao giờ được gửi. Cả hai dựa vào thông tin chỉ người gọi biết (nó giữ phần đuôi bao nhiêu, commit nhanh cỡ nào), nên Durable mới thêm ShellOutputWindow vào giao diện ExecutionEnv.

Telemetry: callback, tham số tường minh, không chứa nội dung

Span dựa trên callback (không có end()) nên không thể quên đóng span hay đóng hai lần, và status tự suy ra từ kết quả của callback. Context đi theo tham số chứ không theo AsyncLocalStorage, nên package chạy được trong Node, Bun, trình duyệt và worker (README.md:519), và hai lời gọi đồng thời không bao giờ lẫn cha. Đầu vào được tin để đường ghi không tốn công kiểm; kiểm nằm ở kiểu, qua schema. Và các schema của Pi cố ý chỉ chứa ID và metadata điều khiển, để bật telemetry không bao giờ có nghĩa là gửi prompt hay output của tool cho một bên thứ ba.

Eval: thà không có con số còn hơn một con số sai

Ghép cặp chính xác theo (ca, mô hình, lần chạy), lập kế hoạch toàn bộ trước khi chạy, và giữ lại tỉ lệ đạt khi có bất kỳ cặp nào bị chặn: thiết kế này chấp nhận mất kết quả của cả một lần chạy dài thay vì công bố một độ nâng tính trên một tập con không đầy đủ. Cô lập bằng quyền file (mã evaluator chỉ root đọc được, harness hạ quyền trước khi gọi mô hình) để mô hình không thể đọc bài chấm.

Cạm bẫy và trường hợp biên#

  • Mất kết nối giữa một thao tác ghi. Request đang bay thất bại với { code: "unknown", lost: true }; nó có thể đã chạy xong ở xa. isConnectionLost(error) phân biệt trường hợp này với một lỗi daemon trả về (connection.ts:125–128).
  • Handle không sống qua lần khởi động lại. Reader đang mở khi kết nối mất sẽ thất bại ở lần đọc kế, kể cả khi kết nối đã có lại; mở lại reader.
  • Laptop ngủ là lệnh từ xa chết. 30 giây không có byte nào là daemon giết process group. Build dài trên máy từ xa không sống qua lúc client ngủ.
  • Lần khởi động nào cũng tốn thêm vòng SSH. Phát hiện hệ thống chỉ một lần cho mỗi sshConnection, nhưng kiểm hash daemon chạy lại ở mỗi lần khởi động, trước request đầu tiên sau một lần mất kết nối.
  • Môi trường đăng nhập. Mặc định daemon không chạy qua shell đăng nhập, nên lệnh không thấy những gì ~/.profile đặt; bật loginShell: true nếu cần (ssh.ts:463–472). Locale cũng không được chuyển tiếp.
  • Daemon cũ. listFiles trả not_supported với daemon trước 1.2.0; người gọi phải có đường duyệt cây của mình.
  • Windows và Termux. Terminal chưa có trên Windows; chuỗi lệnh trên Windows chạy qua Git Bash như NodeExecutionEnv, argv chạy thẳng (README.md:58–59). Termux cần TMPDIR, termux-exec và termux-wake-lock.
  • Known-hosts riêng. ~/.ssh/known_hosts và file toàn cục không được đọc; key phải được chấp nhận vào file của ứng dụng, dưới alias của môi trường.
  • Telemetry không phải nơi chứa dữ liệu. Đừng lưu TelemetryContext hay span vào bản ghi; chỉ carrier được phép, và chỉ như dữ liệu tương quan tuỳ chọn. Adapter trong bộ nhớ không có trần; metric chỉ nên có thuộc tính ít giá trị (README.md:513–517).
  • Adapter tự viết phải mang năng lực đi theo. Từ 1.4.0, metrics và propagation phải có trên gốc, trên mọi span con và trên context đã extract, kể cả span trơ; bộ conformance kiểm điều đó (CHANGELOG.md:9).
  • Eval tốn tiền và lộ nội dung. Mỗi nhánh gọi mô hình thật; artifact chứa prompt, câu trả lời, code sinh ra và output của tool (README.md:107). Một lần lặp không chứng minh được độ ổn định. Eval tài liệu từ chối chạy ngoài container sandbox (harness.ts:526–528).

Tài liệu lệch code

  • README của pi-env nói daemon được "named by its SHA-256"; tên file thật dùng 32 ký tự hex đầu (128 bit) của hash (ssh.ts:380), còn việc kiểm trước khi chạy so cả hash đầy đủ.
  • RFC 0019 ghi endpoint pi.dev/install?version=…; coding agent ở v1.4.0 gọi /api/report-install (xem mục trên).

Đọc code ở đâu#

Thứ tự đọc cho từng package; đường dẫn tính từ gốc repo.

#FileĐọc để hiểu
1env/README.md, docs/protocol.md, docs/semantics.mdHai nửa của pi-env, giao thức frame, luật khớp với NodeExecutionEnv
2durable/src/env/index.tsGiao diện ExecutionEnv, ShellOutputWindow, ShellOutputInfo.skipped
3env/src/connection.tsKhởi động lười, dòng đồng bộ, frame, session, ping, lost
4env/src/ssh.tsTham số SSH, host key, phát hiện hệ thống, triển khai daemon
5env/src/remote-env.ts, errors.tsHiện thực ExecutionEnv: đường dẫn, đọc/ghi theo khúc, exec, cleanup
6watch.ts, list-files.ts, terminal.tsWatcher nối lại, listFiles, LocalExecutionEnv, terminal
7daemon/src/main.rs, frame.rs, output.rsVòng frame, phân luồng, control trước bulk, liveness
8exec.rs, window.rs, sys/unix.rsChạy lệnh, cửa sổ output, process group
9watch.rs, list.rs, pty.rsWatcher cạnh file, liệt kê theo gitignore, pseudo-terminal
10test/conformance.test.ts, test/differential.test.tsCách lời hứa "giống NodeExecutionEnv" được kiểm
11telemetry/README.mdKhái niệm, hợp đồng adapter, propagation, metric, store, schema, bảo mật
12telemetry/src/index.ts, metrics.tsMọi kiểu của hợp đồng, schema và starter có kiểu
13noop.ts, memory.ts, render.tsAdapter no-op, adapter tham chiếu, in trace
14testing/conformance.tsHợp đồng adapter ở dạng chạy được
15durable/src/harness/telemetry.ts, submissions.tsSchema của Durable; span submit; #phase trong scheduler.ts
16evals/README.mdHai loại eval, cách chạy, biến thể tài liệu, đọc kết quả
17evals/src/harness.tsMột coding agent cô lập cho mỗi lần chạy, sandbox, biến thể prompt
18cli.ts, plan.ts, docker.ts, report.tsĐiều phối, kế hoạch, container, ghép cặp và độ nâng
19docker/Dockerfile, entrypoint.ts, evals/Hai image, kiểm image, các bộ eval

Tóm tắt#

  • pi-env cho Pi Durable một ExecutionEnv trên máy khác: một daemon Rust nói giao thức frame qua stdin/stdout của SSH, còn worker, storage và credential ở lại máy cục bộ.
  • Client áp mọi luật của Node (đường dẫn, abort, mã lỗi, cách chia lần đọc), daemon làm system call; kết quả khớp NodeExecutionEnv trên máy đó, được giữ bằng conformance và test vi sai.
  • SSH bị siết: không chuyển tiếp, không chia sẻ kết nối, host key nghiêm theo file và alias riêng; daemon đặt tên theo hash và được kiểm trước mỗi lần khởi động.
  • Frame có JSON và payload thô; trả lời đi trước output số lượng lớn; cửa sổ output bỏ phần người gọi sẽ vứt và báo lại bằng số đếm; 30 giây im lặng là daemon giết mọi thứ.
  • pi-telemetry là hợp đồng span dựa trên callback, context tường minh, no-op và adapter trong bộ nhớ, propagation bằng carrier, link, metric, store và bộ conformance; schema có kiểu chỉ kiểm lúc biên dịch.
  • Durable phát span pi.durable.submit và pi.durable.phase cùng ba metric, không chứa nội dung; carrier lưu trong bản ghi task giữ trace qua các lần khởi động lại.
  • pi-evals đo coding agent: host eval chạy tại chỗ, eval tài liệu so with_docs với without_docs trong container cô lập, ghép cặp chính xác và giữ lại con số khi có cặp bị chặn.

21So sánh và lộ trình học#

Tới đây bạn đã thấy hai cách Pi ghi lại và chạy một cuộc hội thoại với mô hình: coding agent (session JSONL dạng cây, vòng lặp trong bộ nhớ) và Pi Durable (storage với dòng commit nguyên tử, mọi việc là task có checkpoint). Chương này đặt chúng cạnh nhau, vì so sánh là cách nhanh nhất để thấy mỗi lựa chọn thiết kế giải quyết vấn đề gì và phải trả giá gì. Cuối chương là lộ trình đọc code và các bài tập lần theo code.

Hai cách ghi lại một cuộc hội thoại#

Coding agent: một file, một cái cây

Mỗi session là một file JSONL: dòng đầu là header, các dòng sau là entry. Mọi entry có id và parentId, nên cả file là một cái cây; “nhánh đang hoạt động” là đường đi từ gốc tới lá.

Pi Durable: một storage, nhiều loại bản ghi

Một storage (bộ nhớ, SQLite, JSONL, IndexedDB hay Durable Object) chứa nhiều cuộc hội thoại, cùng với submission, task và document. Mọi thay đổi đi qua một dòng commit nguyên tử duy nhất.

Coding agent: mọi entry chung một đáy (id, parentId) và thuộc một trong mười một loại
pi/packages/coding-agent/src/core/session-manager.tsdòng 57–62, 182–194TypeScript
export interface SessionEntryBase {	type: string;	id: string;	parentId: string | null;	timestamp: string;}/** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */export type SessionEntry =	| SessionMessageEntry	| ThinkingLevelChangeEntry	| ModelChangeEntry	| UsageEntry	| CompactionEntry	| BranchSummaryEntry	| CustomEntry	| CustomMessageEntry	| ContextEditEntry	| LabelEntry	| SessionInfoEntry;
Các loại entry được giải thích kỹ ở chương Session.
Pi Durable: cuộc hội thoại là một bản ghi riêng; entry tách phần cho mô hình (model) khỏi phần cho ứng dụng (data)
pi/packages/durable/src/types.tsdòng 396–409, 427–443TypeScript
/** Immutable identity, history ancestry, and task ownership of a transcript scope. */export type ConversationRecord = {	readonly id: ConversationId;	/** Fork source and inclusive parent entry through which history is inherited. */	readonly parent?: {		readonly conversationId: ConversationId;		readonly at: EntryId;	};	/** Creator edge used for attribution, subtree abort, and subtree idle waits. */	readonly owner?: {		readonly conversationId: ConversationId;		readonly taskId: TaskId;	};};/** Immutable transcript event with separate model-facing and application-facing payloads. */export type EntryRecord = {	readonly id: EntryId;	readonly conversationId: ConversationId;	/** Application-defined entry discriminator. */	readonly kind: string;	/** Messages contributed to model context; absent for display or bookkeeping entries. */	readonly model?: readonly Message[];	/** JSON payload consumed by views, extensions, or bookkeeping logic. */	readonly data?: JsonValue;	/** First entry in the active context selected by this entry. */	readonly head?: EntryId;	/** Context-only overrides of earlier visible entries. */	readonly edits?: readonly ContextEdit[];	/** Task that appended this entry, when it was produced by durable work. */	readonly byTaskId?: TaskId;};
Một fork chỉ là một cuộc hội thoại mới có parent trỏ vào cuộc hội thoại gốc tại một entry; lịch sử không bị sao chép. Xem chương Pi Durable.

Điểm khác dễ thấy nhất là mỗi entry của Durable mang hai phần tách bạch: model là các tin nhắn đóng góp vào ngữ cảnh mô hình, data là JSON tuỳ ý dành cho giao diện và extension. Coding agent giải quyết cùng nhu cầu bằng hai loại entry khác nhau: custom (dữ liệu của extension, không vào ngữ cảnh) và custom_message (vào ngữ cảnh, có details không gửi cho mô hình).

Một điểm giống nhau đáng chú ý: cả hai đều chỉ nối thêm, kể cả khi muốn “sửa” lịch sử. Coding agent có entry context_edit trỏ vào một entry cũ và thay hoặc bỏ phần nó đóng góp vào ngữ cảnh; Durable có trường edits với hành động omit hoặc replace. Bản gốc luôn còn đó; chỉ phép chiếu ra ngữ cảnh mô hình là thay đổi.

Bảng đối chiếu#

Khía cạnhCoding agentPi Durable
Đơn vị lưu trữMột file JSONL cho mỗi sessionMột storage chứa mọi hội thoại, task, submission, document
Cách ghiNối một dòng cho mỗi entry, không có giao dịchCommit nguyên tử gom entry, document, task, submission; được đánh số tuần tự
Định danhChuỗi 8 ký tự ngẫu nhiên lấy từ UUIDSố nguyên có “nhãn kiểu” (EntryId, TaskId…), tăng dần
Rẽ nhánhTrong cùng file, qua parentId; fork tạo file mới có parentSessionFork là hội thoại mới với parent: {conversationId, at}
Ngữ cảnh mô hìnhĐường từ gốc tới lá; compaction mới nhất cắt tại firstKeptEntryId; áp dụng context_editTừ “head marker” mới nhất (reset hoặc compaction) tới entry cuối; áp dụng edits
Trạng thái ngoài transcriptEntry custom của extension; file cài đặtDocument có kiểu, phạm vi session/hội thoại/task, chính sách lịch sử và fork
Công việc đang chạyTrong bộ nhớ của Agent; mất khi tiến trình chếtBản ghi task với checkpoint; chạy tiếp sau khi mở lại
Steer và follow-upHàng đợi trong bộ nhớ của AgentDocument pi.inbox và trạng thái của SubmissionRecord
Người xemMột giao diện (TUI, RPC hoặc SDK) trong cùng tiến trìnhBất kỳ số client nào qua viewState(), watch(), watchEvents()
CompactionChạy khi tới ngưỡng, chặn lượt tiếp theo; ghi compaction entryTask nền bắt đầu trước ngưỡng; đặt bản tóm tắt ở ranh giới lượt kế tiếp
Trạng thái phát hànhỔn định (1.x)Thử nghiệm, API có thể đổi giữa các bản

Cây trong một file: lá là dòng cuối#

Thiết kế cây của coding agent đáng học vì nó rất rẻ. Thêm một entry chỉ là nối một dòng; entry mới luôn là con của lá hiện tại, rồi trở thành lá:

Ghi entry: file chỉ được tạo khi đã có hội thoại thật, sau đó mỗi entry là một lần appendFileSync
pi/packages/coding-agent/src/core/session-manager.tsdòng 1172–1196TypeScript
_persist(entry: SessionEntry): void {	if (!this.persist || !this.sessionFile) return;	if (!this.flushed) {		if (!this._hasConversation()) return;		const fd = openSync(this.sessionFile, "wx");		try {			for (const e of this.fileEntries) {				writeFileSync(fd, `${JSON.stringify(e)}\n`);			}		} finally {			closeSync(fd);		}		this.flushed = true;	} else {		appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);	}}private _appendEntry(entry: SessionEntry): void {	this.fileEntries.push(entry);	this.byId.set(entry.id, entry);	this.leafId = entry.id;	this._persist(entry);}

Rẽ nhánh còn rẻ hơn: branch() không ghi gì cả, nó chỉ dời con trỏ lá trong bộ nhớ. Entry tiếp theo sẽ có parentId trỏ vào điểm rẽ, và nhánh mới tự nhiên xuất hiện:

branch(): chỉ dời con trỏ lá, không sửa hay xoá entry nào
pi/packages/coding-agent/src/core/session-manager.tsdòng 1573–1584TypeScript
/** * Start a new branch from an earlier entry. * Moves the leaf pointer to the specified entry. The next appendXXX() call * will create a child of that entry, forming a new branch. Existing entries * are not modified or deleted. */branch(branchFromId: string): void {	if (!this.byId.has(branchFromId)) {		throw new Error(`Entry ${branchFromId} not found`);	}	this.leafId = branchFromId;}

Khi mở lại file, Pi không cần lưu con trỏ lá riêng: nó lấy entry cuối cùng trong file làm lá. Vì entry mới luôn được nối vào cuối, entry cuối chính là nơi bạn dừng lại.

Khi nạp file, lá là entry cuối cùng đọc được
pi/packages/coding-agent/src/core/session-manager.tsdòng 1103–1111TypeScript
private _buildIndex(): void {	this.byId.clear();	this.labelsById.clear();	this.labelTimestampsById.clear();	this.leafId = null;	for (const entry of this.fileEntries) {		if (entry.type === "session") continue;		this.byId.set(entry.id, entry);		this.leafId = entry.id;

Hai cách dựng ngữ cảnh#

Pi Durable không có cây trong một hội thoại; transcript của mỗi hội thoại là một dãy entry tăng dần. Thay vào đó, một số entry là head marker: chúng có trường head chỉ ra entry đầu tiên của ngữ cảnh đang hoạt động. pi.reset bắt đầu lại từ đầu, pi.compaction giữ bản tóm tắt và “dẫn đầu” entry đầu tiên được giữ lại. Ngữ cảnh luôn là head marker mới nhất cộng đoạn từ head của nó tới entry cuối:

deriveRange(): head marker mới nhất, các chỉnh sửa, thứ tự kết quả tool, rồi system message ở đầu
pi/packages/durable/src/harness/context.tsdòng 123–155TypeScript
/** * Derive the active transcript and model context of one conversation within captured bounds. * * H = newest visible head marker; the range runs from `H.head` (or transcript start) through the tail. Per target, * the newest edit in the range wins. Context entries are H followed by the range's non-head entries. */export async function deriveContext(	storage: Storage,	conversationId: ConversationId,	bounds: ContextBounds | undefined,	context: Context,): Promise<ContextView> {	if (bounds === undefined) return emptyView();	return deriveRange(bounds, await scanRange(storage, conversationId, rangeQuery(bounds), context)).view;}function emptyView(): ContextView {	return { head: undefined, entries: [], contributions: [], messages: [] };}/** Derive the context view from the entries scanned within `bounds`. */function deriveRange(bounds: ContextBounds, entries: readonly EntryRecord[]): ContextRange {	const edits = new Map<EntryId, ContextEdit>();	// Edits of every entry in the range count, including older head markers that `selectActive()` drops.	for (const entry of entries) for (const edit of entry.edits ?? []) edits.set(edit.target, edit);	const active = selectActive(bounds.head, entries);	const contributions = active.map((entry) => contribute(entry, edits.get(entry.id)));	const { settled, open } = settle([], contributions.flat());	const baseline = findBaseline(bounds.head, active);	const messages = leadWithSystem([...settled, ...orderToolResults(open)], baseline);	const view = { head: bounds.head, entries: active, contributions, messages };	return { bounds, entries, view, edited: new Set(edits.keys()), settled, open, baseline };}

Cả hai đều phải xử lý chung một tình huống khó: lời gọi tool không có kết quả (tiến trình chết giữa chừng, hoặc người dùng huỷ). Coding agent xử lý ở tầng pi-ai, ngay trước khi gửi request: nó bỏ qua tin nhắn assistant kết thúc bằng lỗi hoặc bị huỷ, và chèn một kết quả tool giả báo lỗi cho mọi lời gọi tool mồ côi, vì API của provider bắt buộc mỗi tool call phải có kết quả:

pi-ai: kết quả giả cho tool call mồ côi, bỏ qua lượt bị lỗi hoặc huỷ
pi/packages/ai/src/api/transform-messages.tsdòng 179–207, 216–224TypeScript
// Second pass: insert synthetic empty tool results for orphaned tool calls// This preserves thinking signatures and satisfies API requirementsconst result: Message[] = [];let pendingToolCalls: ToolCall[] = [];let existingToolResultIds = new Set<string>();// System messages are transparent to tool-call accounting: one that lands between a tool// call and its results is held back and emitted after the results (synthetic ones// included), so it never causes a duplicate result for a call that is answered later.const heldSystemMessages: Message[] = [];const closePendingToolCalls = () => {	if (pendingToolCalls.length > 0) {		for (const tc of pendingToolCalls) {			if (!existingToolResultIds.has(tc.id)) {				result.push({					role: "toolResult",					toolCallId: tc.id,					toolName: tc.name,					content: [{ type: "text", text: "No result provided" }],					isError: true,					timestamp: Date.now(),				} as ToolResultMessage);			}		}		pendingToolCalls = [];		existingToolResultIds = new Set();	}	result.push(...heldSystemMessages);	heldSystemMessages.length = 0;};		// Skip errored/aborted assistant messages entirely.		// These are incomplete turns that shouldn't be replayed:		// - May have partial content (reasoning without message, incomplete tool calls)		// - Replaying them can cause API errors (e.g., OpenAI "reasoning without following item")		// - The model should retry from the last valid state		const assistantMsg = msg as AssistantMessage;		if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {			continue;		}

Durable chọn cách khác, sớm hơn: ý định gọi tool được commit trước khi tool chạy, nên sau sự cố harness biết chính xác tool nào đang chạy dở. Tool khai báo replay: "safe" thì được chạy lại; tool khác nhận kết quả lỗi interrupted kèm phần output đã kịp commit (chương Durable: task). Phần dựng ngữ cảnh vẫn có lưới an toàn tương tự pi-ai cho lịch sử bị cắt ngang:

Durable: các lượt bị huỷ, lỗi hoặc hoãn không vào ngữ cảnh; kết quả thiếu được thay bằng một câu giải thích
pi/packages/durable/src/harness/context.tsdòng 10–11TypeScript
const EXCLUDED_STOP_REASONS: ReadonlySet<AssistantMessage["stopReason"]> = new Set(["aborted", "error", "deferred"]);const MISSING_RESULT_TEXT = "Tool result unavailable: history ends before this call completed.";

Hai mô hình thực thi#

Coding agent

  1. Người dùng gửi prompt; AgentSession chuẩn bị ngữ cảnh rồi gọi Agent.prompt().
  2. Vòng lặp trong pi-agent-core stream câu trả lời, chạy tool, phát sự kiện.
  3. Mỗi tin nhắn hoàn tất được nối vào file session.
  4. Tiến trình chết: phần đang stream và tool đang chạy mất; lần sau mở session, bạn bảo Pi làm tiếp.

Pi Durable

  1. submit() commit một submission (có thể kèm requestId để không bao giờ gửi trùng).
  2. Task pi.generation gọi mô hình, commit từng phần câu trả lời vào document pi.live, rồi commit entry pi.assistant.
  3. Mỗi tool call là một task pi.tool do generation sở hữu; generation chờ chúng rồi trao lượt cho generation kế tiếp.
  4. Tiến trình chết: mở lại storage, resume() chạy tiếp từ checkpoint cuối cùng.

Lộ trình đọc code#

Nếu chỉ có thời gian đọc mười lăm file, hãy đọc theo thứ tự này. Mỗi file có chương tương ứng để đọc kèm.

#FileĐọc để hiểuChương
1ai/src/types.tsTin nhắn, nội dung, mô hình, sự kiện stream: “ngôn ngữ chung” của mọi tầngpi-ai
2ai/src/api/anthropic-messages.tsMột adapter provider hoàn chỉnh: dựng request, đọc stream, tính usagepi-ai
3ai/src/api/transform-messages.tsChuyển lịch sử giữa các provider, vá tool call mồ côipi-ai
4agent/src/agent-loop.tsVòng lặp agent: lượt, tool, steer, follow-upagent-core
5coding-agent/src/core/agent-session.tsMột prompt đi qua coding agent: extension, lưu session, retry, compactionAgentSession
6coding-agent/src/core/session-manager.tsĐịnh dạng session, cây, dựng ngữ cảnh từ nhánhSession
7coding-agent/src/core/system-prompt.ts và tools/edit.tsSystem prompt và một tool điển hìnhCông cụ
8coding-agent/src/core/compaction/compaction.tsKhi nào và cắt ở đâu để nén ngữ cảnhCompaction
9coding-agent/src/core/extensions/types.tsToàn bộ bề mặt API của extension và danh mục sự kiệnExtensions
10tui/src/tui.tsVẽ lại theo vi saipi-tui
11extensions/codemode/tool.tsTool codemode và cách nó gọi các tool khácCodemode
12chord/src/state.tsState quan sát được, nền của mọi “view” trong DurableChord
13durable/src/types.tsMọi bản ghi bền: hội thoại, entry, submission, task, documentPi Durable
14durable/src/session/transaction.tsMột commit chứa gì và vì sao nó nguyên tửDurable storage
15durable/src/harness/scheduler.ts và harness/tool.tsTask, checkpoint, phục hồi sau sự cố, replay của toolDurable tasks

Bài tập lần theo code#

Mỗi bài là một câu hỏi bạn trả lời bằng cách đọc code, kèm gợi ý nơi bắt đầu. Không cần chạy gì; nếu muốn chạy thử, hãy làm trong một thư mục tạm hoặc container, vì Pi chạy với quyền của bạn.

  1. Một lần bấm Enter. Từ lúc bạn gõ một câu trong editor tới khi token đầu tiên hiện ra, những hàm nào được gọi, theo thứ tự nào? Gợi ý: bắt đầu từ cách chế độ interactive gọi AgentSession.prompt() (chương Các chế độ chạy), rồi theo Agent.prompt() vào runLoop và streamAssistantResponse.
  2. Đọc một session có hai nhánh. Mở một file trong thư mục session của bạn (vị trí ở chương Session), tìm hai entry có cùng parentId. Dự đoán ngữ cảnh mà mô hình nhận được khi lá nằm ở nhánh thứ nhất, rồi đối chiếu với hàm dựng ngữ cảnh trong session-manager.ts.
  3. Chặn lệnh nguy hiểm. Ví dụ permission-gate.ts chặn rm -rf bằng sự kiện tool_call. Tìm nơi runner gọi các handler của sự kiện này và giải thích vì sao trả về { block: true } làm mô hình nhận một kết quả lỗi thay vì tool bị bỏ qua lặng lẽ. Gợi ý: runner.ts:1249 dừng ở handler đầu tiên chặn, AgentSession nối nó vào hook beforeToolCall của agent (agent-session.ts:653), và vòng lặp agent đổi lệnh chặn thành kết quả lỗi (agent-loop.ts:755).
  4. Vì sao prompt cache vẫn trúng sau khi thêm tool. Khi một extension bật thêm tool giữa hội thoại, request kế tiếp có làm thay đổi phần đầu của prompt không? Gợi ý: declareToolChanges trong agent-loop.ts và chương Pi 1.0.
  5. Compaction ở hai nơi. So sánh điều kiện kích hoạt compaction của coding agent với settings.compaction của Durable (reserveTokens, keepRecentTokens, backgroundTokens). Bên nào bắt người dùng chờ, bên nào không, và vì sao?
  6. Giết tiến trình giữa chừng. Trong Durable, nếu tiến trình chết lúc tool bash đang chạy npm test, sau khi mở lại thì mô hình thấy gì? Còn nếu tool được khai báo replay: "safe"? Gợi ý: ví dụ test/examples/13-recovery.ts của package durable và chương Durable: task. Muốn chạy ví dụ, cài dependency ở gốc repo bằng npm install --ignore-scripts rồi làm theo hướng dẫn trong README của package.
  7. Một fork không sao chép gì. Trong Durable, khi fork một hội thoại tại entry e, các truy vấn entry của hội thoại mới thấy những entry nào, và bằng cơ chế nào mà không cần sao chép? Gợi ý: src/session/forks.ts và chương Durable storage.

Tóm tắt#

  • Coding agent: một file JSONL dạng cây, chỉ nối thêm; lá là dòng cuối; rẽ nhánh chỉ là dời con trỏ; công việc đang dở sống trong bộ nhớ và lịch sử được “vá” khi gửi đi.
  • Pi Durable: một storage với dòng commit nguyên tử; entry tách model khỏi data; head marker chọn ngữ cảnh; mọi việc là task có checkpoint nên chạy tiếp được sau sự cố.
  • Cả hai cùng tôn trọng một nguyên tắc: không bao giờ xoá hay sửa lịch sử; mọi “chỉnh sửa” là một bản ghi mới thay đổi phép chiếu ra ngữ cảnh mô hình.

22Thuật ngữ và nguồn tham khảo#

Bảng dưới gom các thuật ngữ dùng trong tài liệu, cách gọi tiếng Việt được dùng thống nhất, nghĩa ngắn gọn, và nơi chúng được định nghĩa trong code. Khi gặp một từ lạ ở bất kỳ chương nào, hãy quay lại đây rồi bấm vào định nghĩa.

Mô hình và vòng lặp#

Thuật ngữTrong tài liệuNghĩaĐịnh nghĩa
harnessharnessPhần mềm bao quanh mô hình: system prompt, tool, vòng lặp, lớp phiên dịch.Lịch sử và triết lý
API, providerAPI (giao thức), provider (nhà cung cấp)API là giao thức dây (Anthropic Messages, OpenAI Responses…); provider là bên cung cấp (anthropic, openai, openrouter…). Một provider có thể dùng API của provider khác. Bản đồ đầy đủ ở chương Provider.Model
contextngữ cảnh (của một request)System prompt, danh sách tin nhắn và tool gửi cho mô hình trong một lần gọi.Context
messagetin nhắnMột trong bốn vai: system, user, assistant, toolResult.Message
tool calllời gọi toolKhối nội dung trong tin nhắn assistant: tên tool, id, đối số.ToolCall
usageusageSố token vào/ra, đọc/ghi cache và chi phí của một phản hồi.Usage
stop reasonlý do dừngstop, length, toolUse, error, aborted, deferred…StopReason
assistant message eventsự kiện streamCác sự kiện bắt đầu, delta văn bản, delta suy nghĩ, delta tool call, kết thúc, lỗi.AssistantMessageEvent
thinking levelmức suy nghĩMức lý luận yêu cầu mô hình dùng, ánh xạ sang tham số riêng của từng provider.ThinkingLevel
agent loopvòng lặp agentGọi mô hình, chạy các tool nó gọi, lặp lại tới khi nó không gọi tool nữa.runLoop
turn, runlượt, lần chạyLượt: một phản hồi của mô hình cùng các tool call của nó. Lần chạy: các lượt từ một input tới câu trả lời cuối.AgentEvent
agent messagetin nhắn agentTin nhắn của LLM cộng các loại tin nhắn tuỳ biến mà ứng dụng khai báo thêm.AgentMessage
steering, follow-uptin nhắn steer, follow-upSteer chen vào sau lượt đang chạy; follow-up chờ agent làm xong rồi mới vào.Agent

Coding agent#

Thuật ngữTrong tài liệuNghĩaĐịnh nghĩa
AgentSessionAgentSessionBộ điều phối của coding agent: nối Agent, session, cài đặt, mô hình, extension, tool.AgentSession
session, entrysession, entryFile JSONL của một hội thoại; mỗi dòng sau header là một entry có id và parentId.SessionEntry
branch, leafnhánh, láLá là entry hiện tại; nhánh hoạt động là đường từ gốc tới lá. Rẽ nhánh là dời lá về một entry cũ.branch()
compactioncompaction (nén ngữ cảnh)Thay phần lịch sử cũ bằng một bản tóm tắt; bản gốc vẫn nằm trong file.CompactionEntry
branch summarytóm tắt nhánhBản tóm tắt của nhánh vừa rời đi, gắn vào chỗ rẽ nhánh mới.BranchSummaryEntry
context editchỉnh sửa ngữ cảnhEntry chỉ-nối-thêm thay hoặc bỏ phần đóng góp vào ngữ cảnh của một entry cũ.ContextEditEntry
extensionextensionModule TypeScript chạy trong tiến trình Pi, đăng ký tool, lệnh, sự kiện, giao diện.ExtensionAPI
skillskillThư mục chỉ dẫn (SKILL.md) chỉ được nạp toàn văn khi cần; system prompt chỉ liệt kê tên và mô tả.Skill
prompt templateprompt templateFile Markdown được mở rộng thành tin nhắn khi gõ /tên.PromptTemplate
project trustproject trustQuyết định có nạp cài đặt và tài nguyên của thư mục dự án hay không; không phải sandbox.security.md
componentcomponent (TUI)Đơn vị giao diện terminal: nhận độ rộng, trả về các dòng văn bản.Component
codemodecodemodeTool cho mô hình viết JavaScript chạy trong sandbox QuickJS để gọi các tool khác.chương Codemode
tool exposuremức hiển thị toolTool được khai báo thẳng cho mô hình, chỉ cho codemode, trì hoãn tới khi tìm, hay ẩn.chương Codemode

Chord và Pi Durable#

Thuật ngữTrong tài liệuNghĩaĐịnh nghĩa
Context (Chord)Context của ChordĐối tượng mang tín hiệu huỷ và span telemetry, truyền qua mọi lời gọi bất đồng bộ của Durable. Khác với “context” của pi-ai.Context, BACKGROUND_CONTEXT
HarnessHarnessMột storage đang mở cùng bộ máy chạy agent trên nó.Harness
conversationhội thoại (conversation)Một transcript; có thể là fork của hội thoại khác hoặc do một task sở hữu.ConversationRecord
entry (Durable)entryBản ghi bất biến của transcript, tách model (cho mô hình) và data (cho ứng dụng).EntryRecord
head markerhead markerEntry có trường head (reset, compaction) chọn điểm bắt đầu của ngữ cảnh hoạt động.ContextBounds
submissionsubmissionMột input hoặc một lần ghi entry được đưa vào hội thoại; có trạng thái queued, placed, done, unanswered.SubmissionRecord
task, checkpointtask, checkpointMáy trạng thái bền; mỗi bước lưu checkpoint để chạy tiếp sau khi khởi động lại.TaskState, defineTask
documentdocumentState JSON có kiểu, lưu cạnh transcript và thay đổi trong cùng commit.DocumentRecord, defineDoc
extension (Durable)extensionGói có tên gồm tool, section của system prompt, hook, wrapper và task.defineExtension
replayphát lại (replay)Chạy lại một tool sau sự cố; chỉ với tool khai báo replay: "safe".chương Durable: task
execution environmentmôi trường thực thiNơi tool đọc ghi file và chạy tiến trình: máy cục bộ, máy từ xa qua pi-env, sandbox.ExecutionEnv

Nguồn tham khảo#

Mã nguồn và tài liệu chính thức

Blog

Dữ liệu và hệ sinh thái

  • Các phiên làm việc thật của Mario khi phát triển Pi được công bố tại huggingface.co/datasets/badlogicgames/pi-mono (README.md:182). Đây là nguồn rất tốt để xem session JSONL thật trông thế nào.
  • Trên npm đã có các package bên thứ ba xây trên Pi Durable, ví dụ backend PostgreSQL, môi trường thực thi trên Fly.io Sprites, host trên Cloudflare Durable Object hay subagent bền. Chúng là mã của cộng đồng, không thuộc repo và không được kiểm chứng trong tài liệu này.