第 13 讲TUI、Codemode、MCP 与自动化
把 Pi 从一个 CLI 变成一套可嵌入、可编排、可互联的工程组件:终端渲染层、脚本沙箱、外部工具协议,以及 print / JSON / RPC / SDK 四种自动化边界。
§13.0学习目标
理解 pi-tui 的差分渲染与 CSI 2026 同步输出如何消除闪烁,掌握组件体系、焦点/键位、bracketed paste 与内联图片协议。
掌握 codemode 的 QuickJS 沙箱模型:tools.*、ALL_TOOLS / searchTools / describeTool、store/load 小状态与 codemode.mode。
掌握 pi-mcp 的 transport 抽象与 McpClient 生命周期,理解 mcp__server__tool 命名空间与四级 exposure 的延迟暴露。
能按进程边界为 print / JSON / RPC / SDK 选型,会用 RPC 命令目录与 createAgentSession() 驱动 Pi。
§13.1pi-tui:差分渲染与组件体系
pi-tui 是 monorepo 里最底层的交互包,职责一句话:把终端变成一块可以局部重绘的画布。类比 Java 世界,它相当于终端版的微型 UI 框架——只是绘制单位从像素换成了字符单元(cell),布局靠行数组而不是 LayoutManager。
两个渲染器,一个 TUI 接口
所有组件只面向共享的 TUI 接口编程(组件增删、焦点、浮层、输入、生命周期、终端查询、渲染),只有构造应用时才选择具体渲染器:
TuiMainScreen:渲染进终端主缓冲区,保留回滚区(scrollback)。Pi 的默认交互模式就是它。TuiAltScreen:渲染进备用缓冲区的固定高度视口,应用自己拥有滚动(VStack/HStack/ScrollView预留区域、独立滚动),停止时恢复主缓冲并打印完整文档。相当于 vim / htop 那种“全屏接管”。
两者可以在运行期切换:interactive-mode.ts 中 renderer 的类型就是 TuiMainScreen | TuiAltScreen,switchTuiMode() 会迁移组件列表、焦点,甚至用 captureRenderState() / restoreRenderState() 保留主屏渲染状态——这是第 8 讲讲过的“regular / fullscreen 双模式”的底层实现。
packages/tui/README.md「Rendering modes」一节绘制的三种策略;两个渲染器都会把更新批次包进同步输出转义序列。TuiMainScreen uses three rendering strategies: 1. First Render: Output all lines without clearing scrollback 2. Width Changed or Change Above Viewport: Clear screen and fully re-render 3. Normal Update: Move the cursor to the first changed line, clear to the end, and render changed lines Both renderers wrap updates in synchronized output (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering.
差分渲染为什么能防闪烁
防闪烁来自两层机制的叠加。第一层是“少画”:组件 render(width) 返回字符串数组(一行一个元素,且行宽不得超过 width),TUI 缓存上一帧,逐行对比后只把变化的行写回终端,避免“先清屏再全量重画”造成的空白帧。第二层是“原子呈现”:即便只写三行,终端默认也可能逐行上屏,肉眼看到中间状态;CSI 2026(Synchronized Output)用 \x1b[?2026h … \x1b[?2026l 把整批写操作标记为一个更新事务,终端在收到结束符之前不呈现任何中间状态。前者减少工作量,后者保证中间态不可见,缺一都可能闪。
焦点、键位与 bracketed paste
输入侧的模型同样是极简的:TUI 维护一个焦点组件,tui.setFocus(editor) 让 Editor 接收键盘输入;组件通过 handleInput(data) 处理原始字节,用 matchesKey(data, 'ctrl+c') / Key.up 这类工具做键位匹配。注意 raw 模式下 Ctrl+C 不再触发 SIGINT,应用必须自己拦截退出。容器型组件(对话框、选择器)若内嵌输入框,需实现 Focusable 接口并把 focused 状态传播给子输入组件——否则中文/日文等 IME 的候选窗会定位到错误位置。
粘贴侧启用 bracketed paste mode(\x1b[?2004h):终端把一次粘贴包在 \x1b[200~ … \x1b[201~ 标记内。超过 10 行的大段粘贴会被加上标记单独处理,避免把整段日志误当作逐条命令立即执行。另有一条纪律:组件应当缓存 render() 结果、仅在失效时重算(README 的 Caching 示例),这与 Swing 里“paint() 里不要做重活”是同一条纪律。
组件体系与交互模式的组装
文本与基础
src/components/ 基础件
- Text / TruncatedText
- Spacer / Box / Container
- MouseRegion(鼠标区域)
输入
编辑与补全
- Input / Editor
- autocomplete(路径 + 斜杠命令)
- kill-ring / undo-stack
内容展示
富内容渲染
- Markdown
- Loader / CancellableLoader
- SelectList / SettingsList
- Image(内联图片)
布局与视口
备用屏专用
- VStack / HStack
- ScrollView(区域独立滚动)
- 滚动条拖拽 / 流式跟随底部
coding-agent 的交互模式在这些原子上又自建了一层领域组件:消息区的 assistant-message、user-message、tool-execution、diff,状态栏 footer,各类选择器(model-selector、tree-selector、session-selector)等,全部位于 src/modes/interactive/components/。也就是说,“聊天界面”本身只是组件树的一次组装:底部 Editor 持焦点、上方追加 Markdown 消息、流式期间插 Loader,全屏模式换成 setLayoutRoot() 的 VStack + ScrollView 结构。
内联图片协议
Image 组件支持两种终端图形协议:Kitty graphics protocol(Kitty / Ghostty / WezTerm)与 iTerm2 inline images;不支持的终端回退为文本占位。注意备用屏的差别:Kitty 图片支持随视口垂直裁剪,而 iTerm2 协议没有“删除 / 裁剪既有 placement”的操作,重绘时旧图会残留在屏幕上,所以 TuiAltScreen 中 iTerm2 图片一律回退为文本;TuiMainScreen 不受影响。
差分渲染 + CSI 2026 的组合,几乎就是 Swing 的两件老兵器:脏区重绘(RepaintManager 只合并重画 damage region)与双缓冲(BufferStrategy.show() 一次性翻转帧缓冲)。“只更新变化行”对应脏区合并,CSI 2026 对应缓冲翻转——没有同步输出的逐行重绘,等价于一边 paint() 一边把半成品 show() 给用户。而 TuiMainScreen 与 TuiAltScreen 的取舍,则是“往控制台追加日志”和“JFrame 全屏接管、自己管理视口”的取舍。
§13.2codemode:让模型写脚本编排工具
先给一句类比:普通工具调用是“每搬一块砖都要回办公室汇报一次”,codemode 是把图纸交给工头,让他自己把 20 次搬运跑完,只交回验收单。技术上,codemode 是一个内置扩展工具:模型发出的工具调用参数是原始 JavaScript 源码(不是 JSON、不是 markdown 代码围栏),它被作为 async 函数体放进 QuickJS(编译为 WebAssembly)沙箱执行——顶层 await 和 return 都可用。沙箱没有 Node API、文件系统、网络和定时器;脚本触达外界的唯一途径就是注入的 tools 与 models。
docs/codemode.md 与 packages/codemode/README.md「How it works」绘制:每次 execute() 启动一个 worker 线程,实例化全新的 QuickJS VM;工具调用以消息中继到宿主线程执行后回传。沙箱里的全局对象
脚本环境是刻意收窄的。全部能力可以列在一张表里:
| 全局 | 作用 |
|---|---|
tools.<name>(args) | 调用工具。工具标识符中非法字符替换为 _:MCP 工具 mcp__dev-radius__search 即 tools.mcp__dev_radius__search。 |
text(value) / image(value) | 向输出追加文本 / 图片项;image() 接受 base64 data URL 或 image block,并额外落盘临时文件。 |
return value / exit() | 顶层 return 等价于 text();exit() 立即成功结束脚本。 |
store(key, value) / load(key) | 跨 codemode 调用保存小 JSON 状态(详见下文)。 |
ALL_TOOLS | 列出全部可调用工具 { name, description },含描述中未列出的工具。 |
searchTools(query, {limit?, namespace?}) | 按 BM25 相关度排序工具(默认返回 8 个),可限定命名空间。 |
describeTool(name) / describeNamespace(name) | 取工具描述与 TypeScript 声明;后者返回 MCP 服务器的 { name, description?, instructions?, tools }。 |
models | 列出并运行非 LLM 模型:classify() 分类器与 generateImages() 图像模型(chat 模型只列出、不可运行)。 |
console.log(...) | 输出汇集到结果末尾的 <console_output> 块。 |
工具调用的返回形状
脚本拿到的返回值比模型直接看到的更完整:有输出 schema 的工具返回结构化值——bash 解析为 { output, truncated, full_output_path?, exit_code, wall_time_seconds },其中 output 上限 1 MiB(模型直看仅 2000 行 / 50KB);MCP 工具返回完整 CallToolResult(含 isError、structuredContent);read 读图片返回 image block,可直接交给 image()。调用失败、被拦截或参数非法都会以携带工具错误文本的 Error reject——批量并行时用 Promise.allSettled() 保住成功的那些。
store / load:成功才写入的小状态
store(key, value) 在字符串键下保存 JSON 值,load(key) 读回,存 undefined 删除键。关键语义有三条:只有成功的脚本才保留写入——每个写了 store 的成功脚本会向会话追加一条 codemode-store 自定义条目,因此恢复会话后值还在;由于会话是条目树,每个分支只看到自己路径上的写入(事件溯源的自然结果);容量上限为单值 262144 字符、全部合计 1048576 字符,定位是游标、ID、摘要这类小状态,不是数据存储。
codemode.mode:on / only
codemode 激活期间,设置项 codemode.mode 决定其他工具如何呈现给模型:on(默认)时已声明工具保持声明,描述里追加“如何从脚本调用”的说明;only 时内置与扩展工具从模型视角隐藏,改在 codemode 工具描述中列出,模型只能通过脚本触达它们。工具声明共享约 3000 估算 token 的预算(codemode.inlineBudget),放不下的靠 searchTools() 发现——所以 MCP 服务器陆续连上时,codemode 描述保持稳定,不会破坏前文缓存。
import { CodemodeSandbox } from "@earendil-works/pi-codemode"; const sandbox = new CodemodeSandbox({ timeoutMs: 60_000, tools: [ { name: "read", execute: async (args, { signal }) => { const { path } = args as { path: string }; return await readFile(path, "utf8"); }, }, ], }); const result = await sandbox.execute(` const source = await tools.read({ path: "package.json" }); text("bytes " + source.length); return JSON.parse(source).name; `); await sandbox.close();
与普通工具调用的本质区别,可以类比“每次 RPC 都回控制器”和“把逻辑下推成存储过程 / 服务端批处理脚本”:普通工具调用中,每一次 toolCall → toolResult 都要经过一次模型请求-回填循环,上下文随调用次数线性膨胀;codemode 把“循环、并行、过滤”下推进沙箱,嵌套调用从不进入 LLM 上下文,只有脚本的最终输出返回。代价是失败语义变了——存储过程执行到一半失败,前半的写操作通常已生效;codemode 同样不回滚失败前已执行的真实工具调用。此外 QuickJS 是解释器,胶水逻辑与过滤足够快,但重计算比 V8 慢。
§13.3pi-mcp:独立 MCP 客户端与命名空间
pi-mcp 是一个小而独立的 MCP(Model Context Protocol)客户端——不依赖官方 MCP SDK,也不依赖其他 pi 包。类比 Java:与其引入庞大的 grpc-java 运行时,不如按协议自己实现一个薄客户端;MCP 的协议面(JSON-RPC 分帧、初始化握手、工具列表与调用)小到值得自己维护,还换来完全的 transport 控制权。
transport 抽象与 McpClient 生命周期
职责切分一句话:transport 拥有分帧与 I/O,client 拥有请求关联、初始化、超时、取消、服务器请求与协议级辅助。四种 transport:
StdioTransport:以command + args启动子进程,stdin/stdout 上做 JSON-RPC 分帧;关闭时按规范先关 stdin,再 SIGTERM,最后 SIGKILL,作用于整个进程组(连带 npx / uvx 包装器)。StreamableHttpTransport:远程服务器{ url, headers },支持流式 HTTP 会话、GET 长连接重连与Last-Event-ID断点续传;可注入 fetch 做代理。旧的 HTTP+SSE 传输不支持。PipeTransport:程序由别处启动(例如另一台机器),open只负责接管其 stdout/stderr,返回McpPipe(write/closeStdin/close/exited)。createInMemoryTransportPair()(@earendil-works/pi-mcp/testing):内存对传,客户端与适配器的单元测试。
生命周期就是四个调用:connect → listTools → callTool → close。协议版本 2025-11-25,可协商接受 2025-06-18 / 2025-03-26 / 2024-11-05 的服务器。
import { McpClient, StdioTransport } from "@earendil-works/pi-mcp"; const transport = new StdioTransport({ command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], }); const client = new McpClient({ name: "my-client", version: "1.0.0", roots: [{ uri: "file:///workspace", name: "workspace" }], }); await client.connect(transport); const tools = await client.listTools(); const result = await client.callTool("search", { query: "MCP" }); await client.close();
coding-agent 集成:配置、命名空间与延迟暴露
配置来源两级:用户级 ~/.pi/agent/mcp.json 与项目级 .pi/mcp.json(后者需先授予 project trust,同名条目覆盖用户级)。工具统一注册为 mcp__<server>__<tool>:服务器名只允许字母、数字、_、-;工具名中其他字符替换为 _,同一服务器内替换后撞名的工具全部加 hash 后缀;名字限制在提供商接受的 64 字符内。有资源的服务器还会挂三个资源工具:list_mcp_resources、list_mcp_resource_templates、read_mcp_resource。
核心概念是四级 exposure,它决定了工具“如何被模型够到”:
| exposure | 行为 | 典型用途 |
|---|---|---|
codemode(默认) | 可从 codemode 脚本调用,但既不声明给模型,也不列进 codemode 描述;脚本用 searchTools() / describeTool() / ALL_TOOLS 发现。 | 通用 MCP 服务器,尤其是需要脚本组合、过滤调用的场景。 |
deferred | 不声明,直到 tool_search 为下一次模型调用载入匹配项;载入记录进会话,在该分支保持声明。 | 工具很多、但需要被模型直接调用的大服务器。 |
direct | 像内置工具一样声明给模型,同时仍可从 codemode 调用。 | 小而高频的工具集。 |
hidden | 已注册但不可达。 | 应当保持不可用的服务器或工具。 |
toolExposure 可按工具覆盖服务器级 exposure:键是精确工具名或 * 模式,精确名优先,模式间按出现顺序取第一个匹配。默认 exposure 是 codemode 这一点常被误解:MCP 工具连上后不会自动出现在模型工具列表里,而是等脚本按需发现——这正是为几十上百个工具的大服务器控制上下文成本的设计。带 codemode / deferred 工具的服务器会列在系统提示的 mcp_servers 段(一行 description),该段变化时以追加方式进入对话而不是改写工具声明,从而保住前文缓存。
安全上没有旁路:每个 MCP 调用都走 Pi 的 tool pipeline,扩展的 tool_call / tool_result 处理器(含权限门)对 MCP 工具同样生效;codemode 发起的调用会携带 codemode 调用 ID 作为 parentToolCallId。
/** Whether a tool comes from MCP: a server tool (`mcp__<server>__<tool>`) or a resource tool. */ export function isMcpToolName(name: string): boolean { return name.startsWith("mcp__") || MCP_RESOURCE_TOOLS.has(name); } export type McpExposure = "codemode" | "deferred" | "direct" | "hidden"; /** Whether a tool name matches any of the entries, each an exact name or a pattern. */ export function createToolNameMatcher(entries: readonly string[]): (name: string) => boolean { const names = new Set(entries.filter((entry) => !entry.includes("*"))); const patterns = entries.filter((entry) => entry.includes("*")).map(toolPatternRegExp); return (name) => names.has(name) || patterns.some((pattern) => pattern.test(name)); }
MCP 在 Java 工程师眼里就是“带标准化服务发现的 RPC”:MCP 服务器是注册中心里的服务提供者,listTools 是服务目录查询,callTool 是带 JSON Schema 参数校用的方法调用,mcp__server__tool 命名空间相当于全限定类名防止跨服务方法重名。四级 exposure 则是注册中心里的可见性策略:direct 是全量导出接口,deferred 是懒加载的客户端 stub(用到才生成代理),codemode 是只暴露给批处理引擎的内部接口,hidden 是不发布的私有实现。而 transport 抽象(stdio / HTTP / pipe / 内存)对应 JDK 里 SocketChannel、HttpClient 与进程管道在同一 SPI 下的可替换实现。
§13.4自动化四接口:print / JSON / RPC / SDK
第 3 讲我们已经用过 --print 和 --mode json。这一节把它们放回同一张选型表,看进程边界这一个维度如何决定架构:你的代码和 Pi 的 agent 循环之间,隔着零层、一层只读管道,还是一条双向 JSONL 通道?
docs/cli-integration.md 的模式表绘制。选型口诀:只要最终文本用 print;要结构化进度用 JSON;要双向控制且可能换语言用 RPC;在 Node/Bun 内嵌且要完整 API 用 SDK。RPC 协议:记录族、关联与分帧
RPC 模式让 Pi 作为长驻子进程运行,协议有四类记录:stdin 的 Command(要求 Pi 执行 prompt、查状态、改配置、管会话)、stdout 的 response(命令成败与数据)、stdout 的 session event(run / message / tool / queue / compaction / retry 活动流),以及双向的 Extension UI 记录(扩展对话框、通知跨 RPC 转发——TUI 专属能力在 RPC 下不可用或降级)。命令可带字符串 id,响应回带同一 id,处理是异步的,必须按 id 关联而不是按顺序;bash_execution_update 是唯一带命令 id 的会话事件。
{"id":"req-1","type":"get_state"} {"id":"req-1","type":"response","command":"get_state","success":true,"data":{"...":"..."}} {"id":"req-2","type":"prompt","message":"Review this repository"} {"id":"req-2","type":"response","command":"prompt","success":true,"data":{"disposition":"started"}} // 成功响应只表示“已受理/排队/已处理”,不代表模型工作完成; // 之后要继续消费事件流,等 agent_settled 才是“不会再自动继续”。
分帧是严格 JSONL:一行一个 JSON 对象、以 LF 结尾(可剥离前导 CR)。文档特别警告不要用 Node.js 的 readline——它会把 U+2028 / U+2029 也当行边界,而它们在 JSON 字符串里完全合法;应按字节/UTF-8 流解码后只在 LF 切分。stdout 必须持续读取(客户端不读会因背压卡死 Pi),stderr 只放诊断、绝不能当协议数据解析。停机顺序:关闭子进程 stdin 触发优雅停机,给足时间再 kill;SIGTERM / SIGHUP 同样触发优雅停机,停机开始后的第二次信号立即退出。
RPC 命令目录
Prompting
驱动对话
- prompt / steer / follow_up
- abort / clear_queue
- new_session
State & Model
读取与切换
- get_state / get_messages
- set_model / cycle_model
- get_available_models
Thinking & Queue
推理与排队策略
- set/cycle_thinking_level
- set_steering_mode
- set_follow_up_mode
Compaction & Retry
压缩与重试
- compact / set_auto_compaction
- set_auto_retry
- abort_retry
Bash
直接执行命令
- bash(输出事件带 id)
- abort_bash
Session
持久化与树
- get_entries / get_tree / fork / clone
- switch_session / export_html
- get_last_assistant_text
流式期间发 prompt 必须显式给 streamingBehavior:"steer" 在当前 assistant turn 的工具调用之后、下一次 LLM 调用之前插入;"followUp" 等整个 run 结束再投递——与 SDK 的 steer() / followUp() 语义一一对应。
JSON 事件流的骨架
JSON 模式输出一个 session 头 + 会话事件,然后退出;RPC 输出同样的会话事件但没有 session 头。基本序列是 agent_start → turn_start → message_start → message_update… → message_end → turn_end → agent_end(willRetry) → agent_settled。两个要点:message_update 在线上是增量(delta-only),重组流式文本靠 delta 累加,最终以 message_end 的消息为权威;agent_end 只结束一次低层 run,自动重试、溢出恢复、压缩重试、steering 都可能跟在后面,判断“结束”要等 agent_settled。
TypeScript SDK:进程内嵌
SDK 不是 CLI 模式,而是把 agent 直接嵌进 Node.js / Bun 进程:createAgentSession() 返回的 session 拥有一段对话、模型与工具、排队消息、压缩状态和扩展运行时。默认使用工作目录、发现的资源、存储的设置与凭据;prompt() 在 run(含自动重试)结束后 resolve。两个工程要点:需要流式输出就先订阅再 prompt(session.subscribe());会话不再使用时调 session.dispose()。另外 SDK 会话不加载内置扩展——要让 codemode / tool_search / MCP 可用,需把 createCodemodeExtension()、createToolSearchExtension()、createMcpExtension() 加进 DefaultResourceLoader 的 extensionFactories,并在 MCP 扩展连接服务器前调用 session.bindExtensions()。
import { createAgentSession } from "@earendil-works/pi-coding-agent"; const { session } = await createAgentSession(); try { await session.prompt("What files are in the current directory?"); console.log(session.getLastAssistantText()); } finally { session.dispose(); } // 先订阅、再 prompt:流式收 text_delta const unsubscribe = session.subscribe((event) => { if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { process.stdout.write(event.assistantMessageEvent.delta); } });
export interface CreateAgentSessionOptions { /** Working directory for project-local discovery. Default: process.cwd() */ cwd?: string; /** Global config directory. Default: ~/.pi/agent */ agentDir?: string; model?: Model<any>; thinkingLevel?: ThinkingLevel; tools?: string[]; excludeTools?: string[]; customTools?: ToolDefinition[]; resourceLoader?: ResourceLoader; sessionManager?: SessionManager; settingsManager?: SettingsManager; sessionStartEvent?: SessionStartEvent; } export interface CreateAgentSessionResult { session: AgentSession; extensionsResult: LoadExtensionsResult; modelFallbackMessage?: string; }
§13.5动手实践
-
用 SDK 写一个约 30 行的驱动程序
createAgentSession()创建会话,subscribe()收集事件并打印工具调用(关注tool_execution_start/tool_execution_end),prompt()发一句“列出当前目录文件”,最后dispose()。再换成SessionManager.inMemory()跑一遍,观察不再落盘会话文件。 -
接入一个 MCP 文件系统服务器
pi mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem .,然后pi mcp list验证连接。进会话后试两种路径:给服务器"exposure": "direct"让工具直接声明;保持默认codemode,在/mcp里观察工具状态,并用一个 codemode 脚本调tools.mcp__filesystem__*完成一次读文件。 -
开启 codemode 批量重命名 20 个文件
准备 20 个临时文件,
pi --tools +codemode启动,要求模型“用 codemode 脚本一次性重命名它们”。对照图 13-2 观察行为:一次工具调用内完成全部文件操作,上下文里只出现一份脚本输出;再故意让脚本中途失败一次,验证失败前的重命名不会被回滚。
§13.6常见误区
误区一:把 codemode 当成“更快的普通工具”
它改变的是上下文经济学与失败语义:嵌套调用不进入 LLM 上下文、可以并行与过滤,但失败脚本之前的真实副作用不会回滚,未 await 的调用会被取消。把它用于幂等、可并行的批量操作才是它的主场。
误区二:以为 MCP 工具连接后就“出现在模型工具列表里”
默认 exposure 是 codemode:既不声明给模型,也不列进 codemode 描述,只能被脚本按需发现。想直接声明要显式给 direct(或用 toolExposure 精确放开个别工具)。
误区三:RPC 收到 success: true 就当任务完成
prompt 的成功响应只表示受理/排队/已处理(disposition),模型失败与中止出现在后续事件流里。判断完成要等 agent_settled;订阅要发生在发送 prompt 之前。
误区四:用 readline 读 JSONL,或在备用屏期待 iTerm2 图片滚动
Node.js readline 会把 U+2028/U+2029 当行边界,破坏 JSON 字符串;必须按字节流只按 LF 切分。图片方面,iTerm2 协议无法删除/裁剪 placement,TuiAltScreen 一律回退为文本占位,只有主屏正常渲染 iTerm2 内联图片。
§13.7自测问答
Q1差分渲染为什么能防闪烁?只做“更新变化行”够不够?
防闪烁是两层机制叠加:其一,TUI 缓存上一帧、逐行对比,只把变化行写回终端,避免清屏-全量重画造成空白帧;其二,CSI 2026 同步输出(\x1b[?2026h … \x1b[?2026l)把整批写操作标记为原子更新,终端在结束符之前不呈现中间状态。只做第一层仍可能逐行上屏看到“撕裂”的中间画面,所以两个渲染器都会包裹同步输出。
Q2codemode 与普通工具调用的本质区别是什么?
普通工具调用中每次 toolCall→toolResult 都要回到模型一次(请求-执行-回填-再请求),上下文随调用次数线性增长;codemode 把循环、并行、过滤下推进 QuickJS 沙箱,嵌套工具调用从不进入 LLM 上下文,只有脚本最终输出返回。副作用语义也随之改变:失败前的调用已真实生效、不回滚。适合批量、组合、需要过滤大结果的工具链。
Q3store() 的写入什么时候持久化?分支之间如何可见?
只有成功结束的脚本才保留写入:每个写了 store 的成功脚本会追加一条 codemode-store 自定义会话条目,所以恢复会话后值仍在。由于会话是条目树,每个分支只能看到自己路径上的写入,别的分支写入的值不可见。单值上限 262144 字符、合计上限 1048576 字符。
Q4MCP 的四级 exposure 各是什么行为?默认是哪一级?
codemode(默认):可从脚本调用但不声明、不进 codemode 描述,靠 searchTools()/describeTool()/ALL_TOOLS 发现;deferred:不声明,直到 tool_search 为下次调用载入;direct:像内置工具一样声明且可从脚本调用;hidden:注册但不可达。可用 toolExposure 按工具(精确名或 * 模式)覆盖服务器级设置。
Q5RPC 与 SDK 各自的进程边界是什么?如何选型?
RPC 是子进程 + 双向 JSONL边界:宿主向 pi 的 stdin 写命令(带 id 关联),从 stdout 收响应与事件,长生命周期,适合其他语言、进程隔离、IDE 与自定义客户端。SDK 是进程内边界:宿主 Node/Bun 直接持有 AgentSession,用 TypeScript 方法与事件订阅驱动,无 IPC 开销、API 完整,但崩溃域与内存和宿主共享。此外 print(一次性最终文本)与 JSON(一次性事件流)覆盖单向场景。
Q6mcp__server__tool 命名规则如何处理非法字符与重名?
服务器名只允许字母、数字、_、-;工具名中其他字符全部替换为 _,同一服务器内替换后重名的工具统一加 hash 后缀,整体限制在提供商接受的 64 字符内。这也影响 codemode:JS 标识符里的非法字符同样替换为 _,如 mcp__dev-radius__search 在脚本中是 tools.mcp__dev_radius__search。