Chương 12Phần 3: Coding agent
Các chế độ chạy: interactive, print, JSON, RPC, SDK
Interactive, print, JSON, RPC và SDK: năm lớp vỏ quanh cùng một AgentSessionRuntime.
Trong chương này17 mục
- Vai trò: năm giao diện, một cơ chế
- Mô hình tư duy: mode là lớp vỏ quanh AgentSessionRuntime
- Từ lệnh pi tới một runtime
- AgentSessionRuntime và services
- Mỗi chế độ gắn extension theo cách riêng
- Print mode
- JSON event mode và stdout dành riêng
- RPC mode: giao thức JSONL hai chiều
- Extension UI đi qua RPC
- RpcClient: phía client
- SDK: nhúng Pi trong tiến trình
- Interactive mode ở mức cấu trúc
- Trace: một phiên RPC có hộp thoại của extension
- Quyết định thiết kế
- Cạm bẫy và trường hợp biên
- Đọc code ở đâu
- Tóm tắt
Vai trò: 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:
## 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ạt | Hàm chạy | Vòng đời | Extension thấy |
|---|---|---|---|---|
| Interactive | stdin và stdout đều là TTY, không có -p/--mode json|rpc | new InteractiveMode(runtime).run() | tới khi người dùng thoát | mode: "tui", hasUI: true |
-p, hoặc stdin/stdout bị chuyển hướng | runPrintMode(runtime, { mode: "text" }) | một lần | "print", hasUI: false | |
| JSON | --mode json | runPrintMode(runtime, { mode: "json" }) | một lần | "json", hasUI: false |
| RPC | --mode rpc | runRpcMode(runtime) | tới khi stdin đóng | "rpc", hasUI: true |
| SDK | import { createAgentSession } | không qua main() | do host quyết | mặ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", … })
main(). Ba hàm chạy ở cuối là “lớp vỏ”; mọi thứ phía trên là chung.ExtensionUIContext. InteractiveMode, runPrintMode, runRpcMode, hoặc host SDK./new, /resume, /fork, import.- Runtime host
- Tên tham số mà các mode dùng cho
AgentSessionRuntime. Mode đọc lạiruntimeHost.sessionmỗi khi session bị thay (qua callback rebind), vì đối tượngAgentSessioncũ 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ọiAgentEventcủ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ủaAgent.agent_settlednghĩ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:
#!/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:
} 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++; }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:
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:
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ỳ:
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:
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:
/** * 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:
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 };}- Hỏi extension
session_before_switchcó thể huỷ thao tác. - Tháo session cũ
teardownCurrent():abort()để lượt (turn) đang chạy được lưu, phátsession_shutdown, rồidispose(). - Dựng lạiGọi factory với cwd của session đích và
session_startlý do"resume". - 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:
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:
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ế độ | mode | uiContext | hasUI | ctx.ui.confirm(...) trả về |
|---|---|---|---|---|
| Interactive | "tui" | hộp thoại thật trong TUI (interactive-mode.ts:2633) | true | lựa chọn của người dùng |
| RPC | "rpc" | giao thức con extension_ui_* | true | câu trả lời của client |
| JSON | "json" | không có, dùng noOpUIContext | false | false ngay lập tức |
"print" | không có, dùng noOpUIContext | false | false ngay lập tức | |
| SDK | mặc định "print" (agent-session.ts:461) | do host truyền | tuỳ host | tuỳ 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:
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:
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:
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}
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():
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:
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ướng | Bản ghi | Kiểu trong rpc-types.ts |
|---|---|---|
| stdin | Lệnh | RpcCommand |
| stdout | Phản hồi { type: "response" } | RpcResponse |
| stdout | Sự kiện session | JsonAgentSessionEvent (cùng hình dạng với JSON mode), cộng extension_error |
| cả hai | Extension UI | RpcExtensionUIRequest (stdout), RpcExtensionUIResponse (stdin) |
Data model của giao thức#
export type RpcCommand = // Prompting | { id?: string; type: "prompt"; message: string; images?: ImageContent[]; streamingBehavior?: "steer" | "followUp" } | { id?: string; type: "steer"; message: string; images?: ImageContent[] } | { id?: string; type: "follow_up"; message: string; images?: ImageContent[] } | { id?: string; type: "abort" } | { id?: string; type: "clear_queue" } | { id?: string; type: "new_session"; parentSession?: string } // State | { id?: string; type: "get_state" } // Model | { id?: string; type: "set_model"; provider: string; modelId: string } | { id?: string; type: "cycle_model" } | { id?: string; type: "get_available_models" } // Thinking | { id?: string; type: "set_thinking_level"; level: ThinkingLevel } | { id?: string; type: "cycle_thinking_level" } | { id?: string; type: "get_available_thinking_levels" } // Queue modes | { id?: string; type: "set_steering_mode"; mode: "all" | "one-at-a-time" } | { id?: string; type: "set_follow_up_mode"; mode: "all" | "one-at-a-time" } // Compaction | { id?: string; type: "compact"; customInstructions?: string } | { id?: string; type: "set_auto_compaction"; enabled: boolean } // Retry | { id?: string; type: "set_auto_retry"; enabled: boolean } | { id?: string; type: "abort_retry" } // Bash | { id?: string; type: "bash"; command: string; excludeFromContext?: boolean } | { id?: string; type: "abort_bash" } // Session | { id?: string; type: "get_session_stats" } | { id?: string; type: "export_html"; outputPath?: string } | { id?: string; type: "switch_session"; sessionPath: string } | { id?: string; type: "fork"; entryId: string } | { id?: string; type: "clone" } | { id?: string; type: "get_fork_messages" } | { id?: string; type: "get_entries"; since?: string } | { id?: string; type: "get_tree" } | { id?: string; type: "get_last_assistant_text" } | { id?: string; type: "set_session_name"; name: string } // Messages | { id?: string; type: "get_messages" } // Commands (available for invocation via prompt) | { id?: string; type: "get_commands" };id tuỳ chọn để ghép với phản hồi. Bốn lệnh đầu tương ứng trực tiếp với prompt(), steer(), followUp(), abort() của AgentSession.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:
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:
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#
/** * 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:
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:
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():
const handleInputLine = async (line: string) => { let parsed: unknown; try { parsed = JSON.parse(line); } catch (parseError: unknown) { output( error( undefined, "parse", `Failed to parse command: ${parseError instanceof Error ? parseError.message : String(parseError)}`, ), ); await waitForRawStdoutBackpressure(); return; } // Handle extension UI responses if ( typeof parsed === "object" && parsed !== null && "type" in parsed && parsed.type === "extension_ui_response" ) { const response = parsed as RpcExtensionUIResponse; const pending = pendingExtensionRequests.get(response.id); if (pending) { pendingExtensionRequests.delete(response.id); pending.resolve(response); } return; } const command = parsed as RpcCommand; try { const response = await handleCommand(command); if (response) { output(response); await waitForRawStdoutBackpressure(); } await checkShutdownRequested(); } catch (commandError: unknown) { output( error( command.id, command.type, commandError instanceof Error ? commandError.message : String(commandError), ), ); await waitForRawStdoutBackpressure(); }};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.
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 };function createDialogPromise<T>( opts: ExtensionUIDialogOptions | undefined, defaultValue: T, request: Record<string, unknown>, parseResponse: (response: RpcExtensionUIResponse) => T,): Promise<T> { if (opts?.signal?.aborted) return Promise.resolve(defaultValue); const id = crypto.randomUUID(); return new Promise((resolve, reject) => { let timeoutId: ReturnType<typeof setTimeout> | undefined; const cleanup = () => { if (timeoutId) clearTimeout(timeoutId); opts?.signal?.removeEventListener("abort", onAbort); pendingExtensionRequests.delete(id); }; const onAbort = () => { cleanup(); resolve(defaultValue); }; opts?.signal?.addEventListener("abort", onAbort, { once: true }); if (opts?.timeout) { timeoutId = setTimeout(() => { cleanup(); resolve(defaultValue); }, opts.timeout); } pendingExtensionRequests.set(id, { resolve: (response: RpcExtensionUIResponse) => { cleanup(); resolve(parseResponse(response)); }, reject, }); output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest); });}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:
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êngeditor()tự dựng promise mà không quacreateDialogPromise, 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ườngtimeout. - Ghi rồi thôi (
notify,setStatus,setWidget,setTitle,setEditorText): ghi một bản ghi và không chờ gì.setWidgetchỉ 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 tractx.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():
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); }});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(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 đó:
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:
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:
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àoextensionFactoriescủaDefaultResourceLoader(sdk.md:116). - Không gọi
bindExtensions(), nên extension chưa nhậnsession_startvà 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:
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().
- constructorLưu runtime, đăng ký
setRebindSessionvàsetBeforeSessionInvalidate, dựng TUI và cây component (:610). - init()Gắn component vào TUI,
ui.start()trước extension đểsession_startdùng được hộp thoại, cài phím, rồirebindCurrentSession()(:999–1105). - bind
bindExtensions({ uiContext, mode: "tui", ... })vàsubscribeToAgent()(:2092–2118). - 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.
// 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):
// 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:
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ện | Interactive 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_update | Cậ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_end | Chố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_update | Vẽ lại danh sách tin steer/follow-up đang chờ |
compaction_*, auto_retry_* | Chỉ báo trạng thái |
agent_settled | Kiể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,…}}
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:
handleInputLine()parse dòng đầu và gọihandleCommand(). Nhánhpromptkhởi độngsession.prompt()mà không await.AgentSessionkiểm tra đã có mô hình và API key, chuẩn bị lần chạy, rồi gọipreflightResult("started")ngay trướcagent.prompt()(agent-session.ts:1873–1899). Vì mọi bản ghi đi qua cùng một hàng đợiwriteRawStdout, phản hồi luôn đứng trướcagent_start.- Các sự kiện từ
agent_starttớimessage_endcủa assistant là chuỗi sự kiện của vòng lặp agent (trace ở chương pi-agent-core), đi quatoJsonEvent()nênmessage_updatechỉ còn delta, vàtoolcall_startmangid,toolName. tool_execution_startđược phát trước khi chuẩn bị lời gọi; ngay sau đó vòng lặp gọibeforeToolCall, tức hooktool_callcủa extension. permission-gate thấyhasUIlà true và gọictx.ui.select(), mà trong RPC làcreateDialogPromise(): mộtextension_ui_requestvới UUID mới được ghi ra, và promise nằm trong bảng chờ. Không cótimeoutvì extension không truyền.- 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ấyidtrong bảng chờ và resolve promise với"Yes". Hook trả vềundefined, bash chạy, kết quả đi quaafterToolCallrồi ra thànhtool_execution_endvà messagetoolResult. - 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.AgentSessionkiểm tra xem có cần thử lại, compaction hay tin chờ không; không có gì nên nó phátagent_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 texttrong 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 "..." | pikhông mở TUI (main.ts:898–900). - RPC từ chối
@file. Gửi ảnh qua trườngimagescủa lệnhprompt. - 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
stopReasontrongmessage_endcủ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
readlinecho 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ảfalsevàselect()trảundefinedngay lập tức. Extension không xéthasUIcó 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ênsession.agent, và khi đó chỉ nhậnAgentEvent. - 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ưngCreateAgentSessionOptionsở v1.4.0 không có trường đó. Cách đúng là truyềnsessionManager: SessionManager.continueRecent(cwd), nhưmain()làm cho-c(main.ts:432–434). pi -c -pkhô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).RpcClientkhông trả lời được hộp thoại của extension. Nếu extension của bạn gọictx.ui.select()hayconfirm()không kèmtimeouthaysignal, một client chỉ dùngRpcClientsẽ để 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 đặttimeoutcho hộp thoại.RpcClientmặc định tìmdist/cli.jstương đối với thư mục hiện tại của client (rpc-client.ts:81); truyềncliPathrõ ràng như ví dụ chính thức.
Đọc code ở đâu#
| Thứ tự | File | Đọc để hiểu |
|---|---|---|
| 1 | how-pi-works.md, cli-integration.md | Các chế độ là gì và chọn chế độ nào. |
| 2 | src/cli.ts, src/main.ts:574 | Đường khởi động: lệnh con, resolveAppMode(), session, factory, rẽ nhánh. |
| 3 | src/cli/args.ts, docs/cli.md | Mọi tuỳ chọn dòng lệnh và cách chúng được phân tích. |
| 4 | agent-session-runtime.ts, agent-session-services.ts | Services theo cwd, factory, thay session và rebind. |
| 5 | src/core/sdk.ts:191, docs/sdk.md | createAgentSession(); sau đó sang chương AgentSession. |
| 6 | print-mode.ts, json-event.ts, output-guard.ts | Chế độ một lần, định dạng trên dây, bảo vệ stdout; tham chiếu sự kiện ở docs/json.md. |
| 7 | rpc-types.ts, jsonl.ts | Data model và khung bản ghi của giao thức. |
| 8 | rpc-mode.ts, docs/rpc.md, rpc-commands.md, rpc-extension-ui.md | Server RPC, từng lệnh, giao thức con của extension UI. |
| 9 | rpc-client.ts, examples/rpc-client.ts, examples/rpc-extension-ui.ts | Phía client, kể cả cách trả lời hộp thoại. |
| 10 | interactive-mode.ts:945 | init(), run() (1148), submit (3156), handleEvent() (3363); phần vẽ ở chương pi-tui. |
| 11 | examples/sdk/, permission-gate.ts | Mườ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ộtAgentSessionRuntimeduy 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ó-phoặ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.logsang 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_updatetrên dây chỉ mang delta. - RPC: lệnh có
idtuỳ chọn và được xử lý đồng thời;prompttrả lời một lần sau preflight kèmdisposition;agent_settledlà tín hiệu hết việc; hộp thoại của extension là cặpextension_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.