跳转到内容

RPC 模式

RPC 模式通过 stdin/stdout 上的 JSON 协议实现编码智能体的无头运行,便于将智能体嵌入其他应用、集成开发环境(IDE)或自定义 UI。

Node.js/TypeScript 用户须知:如果你正在构建 Node.js 应用,建议直接使用 @earendil-works/pi-coding-agent 中的 AgentSession,而不是派生子进程。API 见 src/core/agent-session.ts。基于子进程的 TypeScript 客户端见 src/modes/rpc/rpc-client.ts

Terminal window
pi --mode rpc [options]

常用选项:

  • --provider <name>:设置 LLM 模型提供方(provider)(anthropic、openai、google 等)
  • --model <pattern>:模型模式或 ID(支持 provider/id 和可选的 :<thinking>
  • --name <name> / -n <name>:启动时设置会话(session)显示名称
  • --no-session:禁用会话持久化
  • --session-dir <path>:自定义会话存储目录
  • 命令(Commands):发送到 stdin 的 JSON 对象,每行一条
  • 响应(Responses):JSON 对象,type: "response" 表示命令成功或失败
  • 事件(Events):智能体(agent)事件以 JSON 行形式流式输出到 stdout

所有命令都支持可选的 id 字段,用于请求/响应关联。若提供该字段,对应的响应会包含相同的 idbash_execution_update 事件也会包含其来源 bash 命令的 id

RPC(远程过程调用)模式采用严格的 JSONL 语义,以 LF(\n)作为唯一记录分隔符。

这对客户端很重要:

  • 只按 \n 切分记录
  • 接受可选的 \r\n 输入,去掉末尾的 \r
  • 不要使用会把 Unicode 分隔符当作换行的通用行读取器

特别地,Node 的 readline 不符合 RPC 模式的协议要求,因为它还会按 U+2028U+2029 切分,而这两个字符在 JSON 字符串中是合法的。

向智能体发送一条用户提示词。命令响应在提示词被接受、排队或处理之后发出。接受后事件会继续异步流式输出。

{"id": "req-1", "type": "prompt", "message": "Hello, world!"}

带图片:

{"type": "prompt", "message": "What's in this image?", "images": [{"type": "image", "data": "base64-encoded-data", "mimeType": "image/png"}]}

流式输出期间:如果智能体正在流式输出,必须指定 streamingBehavior 来排队消息:

{"type": "prompt", "message": "New instruction", "streamingBehavior": "steer"}
  • "steer":智能体运行期间将消息排队。它会在当前助手回合完成工具(tool)调用之后、下一次 LLM(大语言模型)调用之前投递。
  • "followUp":等待智能体完成。只有在智能体停止时才投递消息。

如果智能体正在流式输出且未指定 streamingBehavior,命令返回错误。

扩展命令:如果消息是扩展(extension)命令(如 /mycommand),即使在流式输出期间也会立即执行。扩展命令通过 pi.sendMessage() 自行管理 LLM 交互。

输入展开:技能(skill)命令(/skill:name)和提示词模板(/template)会在发送或排队前展开。

响应:

{"id": "req-1", "type": "response", "command": "prompt", "success": true}

success: true 表示提示词已立即被接受、排队或处理。success: false 表示提示词在接受前被拒绝。接受之后的失败通过正常的事件和消息流报告,而不会针对同一请求 id 返回第二条 response

images 字段可选。每张图片使用 ImageContent 格式:{"type": "image", "data": "base64-encoded-data", "mimeType": "image/png"}

在智能体运行期间排队一条引导消息。它会在当前助手回合完成工具调用之后、下一次 LLM 调用之前投递。技能命令和提示词模板会被展开。不允许扩展命令(请改用 prompt)。

{"type": "steer", "message": "Stop and do this instead"}

带图片:

{"type": "steer", "message": "Look at this instead", "images": [{"type": "image", "data": "base64-encoded-data", "mimeType": "image/png"}]}

images 字段可选。每张图片使用 ImageContent 格式(与 prompt 相同)。

响应:

{"type": "response", "command": "steer", "success": true}

关于如何控制引导消息的处理方式,见 set_steering_mode

排队一条后续消息,在智能体完成后处理。只有在智能体没有更多工具调用或引导消息时才投递。技能命令和提示词模板会被展开。不允许扩展命令(请改用 prompt)。

{"type": "follow_up", "message": "After you're done, also do this"}

带图片:

{"type": "follow_up", "message": "Also check this image", "images": [{"type": "image", "data": "base64-encoded-data", "mimeType": "image/png"}]}

images 字段可选。每张图片使用 ImageContent 格式(与 prompt 相同)。

响应:

{"type": "response", "command": "follow_up", "success": true}

关于如何控制后续消息的处理方式,见 set_follow_up_mode

中止当前的智能体操作。

{"type": "abort"}

响应:

{"type": "response", "command": "abort", "success": true}

开启一个全新的会话。可以被 session_before_switch 扩展事件处理器取消。

{"type": "new_session"}

带可选的父会话追踪:

{"type": "new_session", "parentSession": "/path/to/parent-session.jsonl"}

响应:

{"type": "response", "command": "new_session", "success": true, "data": {"cancelled": false}}

如果扩展取消了:

{"type": "response", "command": "new_session", "success": true, "data": {"cancelled": true}}

获取当前会话状态。

{"type": "get_state"}

响应:

{
"type": "response",
"command": "get_state",
"success": true,
"data": {
"model": {...},
"thinkingLevel": "medium",
"isStreaming": false,
"isCompacting": false,
"steeringMode": "all",
"followUpMode": "one-at-a-time",
"sessionFile": "/path/to/session.jsonl",
"sessionId": "abc123",
"sessionName": "my-feature-work",
"autoCompactionEnabled": true,
"messageCount": 5,
"pendingMessageCount": 0
}
}

model 字段是完整的 Model 对象或 nullsessionName 字段是通过 set_session_name 设置的显示名称,未设置时省略。

获取对话中的所有消息。

{"type": "get_messages"}

响应:

{
"type": "response",
"command": "get_messages",
"success": true,
"data": {"messages": [...]}
}

消息是 AgentMessage 对象(见 消息类型)。

切换到指定模型。

{"type": "set_model", "provider": "anthropic", "modelId": "claude-sonnet-4-20250514"}

响应包含完整的 Model 对象:

{
"type": "response",
"command": "set_model",
"success": true,
"data": {...}
}

循环切换到下一个可用模型。如果只有一个可用模型,返回 null 数据。

{"type": "cycle_model"}

响应:

{
"type": "response",
"command": "cycle_model",
"success": true,
"data": {
"model": {...},
"thinkingLevel": "medium",
"isScoped": false
}
}

model 字段是完整的 Model 对象。

列出所有已配置的模型。

{"type": "get_available_models"}

响应包含完整的 Model 对象数组:

{
"type": "response",
"command": "get_available_models",
"success": true,
"data": {
"models": [...]
}
}

为支持的模型设置推理/思考级别。

{"type": "set_thinking_level", "level": "high"}

级别:"off""minimal""low""medium""high""xhigh""max"

"xhigh""max" 仅在所选模型支持时暴露。有些模型(包括 GPT-5.6)两者都暴露。

响应:

{"type": "response", "command": "set_thinking_level", "success": true}

循环切换可用的思考级别。如果模型不支持思考,返回 null 数据。

{"type": "cycle_thinking_level"}

响应:

{
"type": "response",
"command": "cycle_thinking_level",
"success": true,
"data": {"level": "high"}
}

列出当前模型支持的思考级别。对于不支持推理的模型,返回 ["off"]

{"type": "get_available_thinking_levels"}

响应:

{
"type": "response",
"command": "get_available_thinking_levels",
"success": true,
"data": {
"levels": ["off", "minimal", "low", "medium", "high"]
}
}

控制引导消息(来自 steer)的投递方式。

{"type": "set_steering_mode", "mode": "one-at-a-time"}

模式:

  • "all":在当前助手回合完成工具调用之后,一次性投递所有引导消息
  • "one-at-a-time":每个完成的助手回合投递一条引导消息(默认)

响应:

{"type": "response", "command": "set_steering_mode", "success": true}

控制后续消息(来自 follow_up)的投递方式。

{"type": "set_follow_up_mode", "mode": "one-at-a-time"}

模式:

  • "all":智能体完成时一次性投递所有后续消息
  • "one-at-a-time":每次智能体完成投递一条后续消息(默认)

响应:

{"type": "response", "command": "set_follow_up_mode", "success": true}

手动压缩对话上下文以降低 token 用量。

{"type": "compact"}

带自定义指令:

{"type": "compact", "customInstructions": "Focus on code changes"}

响应:

{
"type": "response",
"command": "compact",
"success": true,
"data": {
"summary": "Summary of conversation...",
"firstKeptEntryId": "abc123",
"tokensBefore": 150000,
"estimatedTokensAfter": 32000,
"usage": {
"input": 32000,
"output": 1200,
"cacheRead": 0,
"cacheWrite": 0,
"totalTokens": 33200,
"cost": {"input": 0.01, "output": 0.02, "cacheRead": 0, "cacheWrite": 0, "total": 0.03}
},
"details": {}
}
}

estimatedTokensAfter 是压缩后立即对重建消息上下文做出的启发式估算,并非模型提供方精确的 token 数。usage 报告生成摘要的那一次或多次 LLM 调用,自定义压缩处理器可以省略它。

启用或禁用上下文(context window)接近满载时的自动压缩。

{"type": "set_auto_compaction", "enabled": true}

响应:

{"type": "response", "command": "set_auto_compaction", "success": true}

启用或禁用瞬时错误(过载、速率限制、5xx)时的自动重试。

{"type": "set_auto_retry", "enabled": true}

响应:

{"type": "response", "command": "set_auto_retry", "success": true}

中止正在进行的重试(取消延迟并停止重试)。

{"type": "abort_retry"}

响应:

{"type": "response", "command": "abort_retry", "success": true}

执行 shell 命令并把输出加入对话上下文。命令运行期间,输出以 bash_execution_update 事件流式传输;响应包含最终结果。

{"id": "req-1", "type": "bash", "command": "ls -la"}

带上 id,以便把流式传输的 bash_execution_update 事件与此命令关联。

响应:

{
"id": "req-1",
"type": "response",
"command": "bash",
"success": true,
"data": {
"output": "total 48\ndrwxr-xr-x ...",
"exitCode": 0,
"cancelled": false,
"truncated": false
}
}

如果输出被截断,会包含 fullOutputPath

{
"type": "response",
"command": "bash",
"success": true,
"data": {
"output": "truncated output...",
"exitCode": 0,
"cancelled": false,
"truncated": true,
"fullOutputPath": "/tmp/pi-bash-abc123.log"
}
}

bash 结果如何到达 LLM:

bash 命令立即执行并返回 BashResult。在内部,会创建 BashExecutionMessage 并存入智能体的消息状态。

当发送下一条 prompt 命令时,所有消息(包括 BashExecutionMessage)在发送给 LLM 之前都会经过转换。BashExecutionMessage 会转换成如下格式的 UserMessage

Ran `ls -la`
```
total 48
drwxr-xr-x ...
```

这意味着:

  1. bash 输出会在下一条 prompt 中进入 LLM 上下文,而非立即
  2. 可以在一条 prompt 之前执行多条 bash 命令,所有输出都会被包含

中止正在运行的 bash 命令。

{"type": "abort_bash"}

响应:

{"type": "response", "command": "abort_bash", "success": true}

获取 token 用量、成本统计以及当前上下文窗口占用情况。

{"type": "get_session_stats"}

响应:

{
"type": "response",
"command": "get_session_stats",
"success": true,
"data": {
"sessionFile": "/path/to/session.jsonl",
"sessionId": "abc123",
"userMessages": 5,
"assistantMessages": 5,
"toolCalls": 12,
"toolResults": 12,
"totalMessages": 22,
"tokens": {
"input": 50000,
"output": 10000,
"cacheRead": 40000,
"cacheWrite": 5000,
"total": 105000
},
"cost": 0.45,
"contextUsage": {
"tokens": 60000,
"contextWindow": 200000,
"percent": 30
}
}
}

tokenscost 包含整个会话中的助手消息、工具上报的用量,以及压缩和分支(branch)摘要生成的用量。contextUsage 包含用于压缩和底部显示的实际当前上下文窗口估算值。

当没有模型或上下文窗口可用时,contextUsage 会被省略。压缩后,contextUsage.tokenscontextUsage.percentnull,直到压缩后的新助手响应提供有效的用量数据。

将会话导出为 HTML 文件。

{"type": "export_html"}

自定义路径:

{"type": "export_html", "outputPath": "/tmp/session.html"}

响应:

{
"type": "response",
"command": "export_html",
"success": true,
"data": {"path": "/tmp/session.html"}
}

加载另一个会话文件。可以被 session_before_switch 扩展事件处理器取消。

{"type": "switch_session", "sessionPath": "/path/to/session.jsonl"}

响应:

{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": false}}

如果扩展取消了切换:

{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": true}}

从活动分支上的一条先前用户消息创建新分支。可以被 session_before_fork 扩展事件处理器取消。返回被分支的消息文本。

{"type": "fork", "entryId": "abc123"}

响应:

{
"type": "response",
"command": "fork",
"success": true,
"data": {"text": "The original prompt text...", "cancelled": false}
}

如果扩展取消了分支:

{
"type": "response",
"command": "fork",
"success": true,
"data": {"text": "The original prompt text...", "cancelled": true}
}

把当前活动分支复制成一个位于当前位置的新会话。可以被 session_before_fork 扩展事件处理器取消。

{"type": "clone"}

响应:

{
"type": "response",
"command": "clone",
"success": true,
"data": {"cancelled": false}
}

如果扩展取消了克隆:

{
"type": "response",
"command": "clone",
"success": true,
"data": {"cancelled": true}
}

获取可用于分支的用户消息。

{"type": "get_fork_messages"}

响应:

{
"type": "response",
"command": "get_fork_messages",
"success": true,
"data": {
"messages": [
{"entryId": "abc123", "text": "First prompt..."},
{"entryId": "def456", "text": "Second prompt..."}
]
}
}

按追加顺序获取所有会话条目(不含会话头部)。会话是一个只追加的条目树,条目 id 稳定,因此条目 id 可以用作持久游标:把最后看到的条目 id 作为 since 传入,即可只获取它之后的条目,即使客户端重启也有效。与 get_messages 不同,这里包含压缩前的历史和废弃分支。

{"type": "get_entries"}

带游标:

{"type": "get_entries", "since": "abc123"}

响应:

{
"type": "response",
"command": "get_entries",
"success": true,
"data": {
"entries": [
{"type": "message", "id": "def456", "parentId": "abc123", "timestamp": "...", "message": {"role": "user", "...": "..."}}
],
"leafId": "def456"
}
}

leafId 是当前叶子条目的 id(空会话为 null),客户端只需一次往返即可判断活动分支是否移动。如果 since 不匹配任何条目 id,响应为 success: false

将会话作为条目树获取。每个节点是 {entry, children, label?, labelTimestamp?}。格式良好的会话有单一根节点;孤立条目(父链断裂)也会作为根节点出现。

{"type": "get_tree"}

响应:

{
"type": "response",
"command": "get_tree",
"success": true,
"data": {
"tree": [
{
"entry": {"type": "message", "id": "abc123", "parentId": null, "...": "..."},
"children": [
{"entry": {"type": "message", "id": "def456", "parentId": "abc123", "...": "..."}, "children": []}
]
}
],
"leafId": "def456"
}
}

获取最后一条助手消息的文本内容。

{"type": "get_last_assistant_text"}

响应:

{
"type": "response",
"command": "get_last_assistant_text",
"success": true,
"data": {"text": "The assistant's response..."}
}

如果不存在助手消息,返回 {"text": null}

为当前会话设置显示名称。该名称会出现在会话列表中,便于识别会话。

{"type": "set_session_name", "name": "my-feature-work"}

响应:

{
"type": "response",
"command": "set_session_name",
"success": true
}

当前会话名称可通过 get_statesessionName 字段获取。要在启动 RPC 模式时设置初始名称,请向 pi --mode rpc 进程传入 --name <name>-n <name>

获取可用命令(扩展命令、提示词模板和技能)。可以通过 prompt 命令以 / 为前缀调用它们。

{"type": "get_commands"}

响应:

{
"type": "response",
"command": "get_commands",
"success": true,
"data": {
"commands": [
{"name": "session-name", "description": "Set or clear session name", "source": "extension", "path": "/home/user/.pi/agent/extensions/session.ts"},
{"name": "fix-tests", "description": "Fix failing tests", "source": "prompt", "location": "project", "path": "/home/user/myproject/.pi/agent/prompts/fix-tests.md"},
{"name": "skill:brave-search", "description": "Web search via Brave API", "source": "skill", "location": "user", "path": "/home/user/.pi/agent/skills/brave-search/SKILL.md"}
]
}
}

每个命令包含:

  • name:命令名称(用 /name 调用)
  • description:人类可读的描述(扩展命令可选)
  • source:命令的种类:
    • "extension":扩展中通过 pi.registerCommand() 注册
    • "prompt":从提示词模板 .md 文件加载
    • "skill":从技能目录加载(名称带 skill: 前缀)
  • location:加载位置(可选,扩展命令没有该字段):
    • "user":用户级(~/.pi/agent/
    • "project":项目级(./.pi/agent/
    • "path":通过 CLI(命令行界面)或设置指定的显式路径
  • path:命令源文件的绝对路径(可选)

注意:内置 TUI(终端界面)命令(/settings/hotkeys 等)不包含在内。它们只在交互式模式中处理,通过 prompt 发送不会执行。

事件在智能体运行期间以 JSON 行的形式流式输出到 stdout。事件一般不含 id 字段;bash_execution_update 在其来源 bash 命令提供了 id 时会包含该 id

事件 说明
agent_start 智能体开始处理
agent_end 一次底层智能体运行完成(之后仍可能发生重试、压缩或排队的后续操作)
agent_settled 智能体运行完全平息;没有自动重试、压缩重试或排队的后续操作
turn_start 新回合开始
turn_end 回合完成(包含助手消息和工具结果)
message_start 消息开始
message_update 流式更新(文本/思考/工具调用增量)
message_end 消息完成
bash_execution_update 直接 RPC bash 命令的输出块
tool_execution_start 工具开始执行
tool_execution_update 工具执行进度(流式输出)
tool_execution_end 工具完成
queue_update 待处理的引导/后续队列发生变化
compaction_start 压缩开始
compaction_end 压缩完成
auto_retry_start 自动重试开始(瞬时错误之后)
auto_retry_end 自动重试完成(成功或最终失败)
summarization_retry_scheduled 已为瞬时压缩或分支摘要错误安排重试
summarization_retry_attempt_start 重试的摘要请求开始
summarization_retry_finished 摘要重试循环完成
extension_error 扩展抛出了错误

智能体开始处理提示词时发出。

{"type": "agent_start"}

一次底层智能体运行完成时发出。包含此次运行生成的所有消息。如果 willRetry 为 true,会自动重试。

{
"type": "agent_end",
"messages": [...],
"willRetry": false
}

整个会话级运行平息后发出。此时 Pi 不会继续自动重试、压缩重试或处理排队的后续消息。

{"type": "agent_settled"}

一个回合包含一次助手响应及由此产生的工具调用和结果。

{"type": "turn_start"}
{
"type": "turn_end",
"message": {...},
"toolResults": [...]
}

消息开始和完成时发出。message 字段包含一个 AgentMessage

{"type": "message_start", "message": {...}}
{"type": "message_end", "message": {...}}

助手消息流式输出期间发出。包含增量事件,不提供累积的消息快照。

{
"type": "message_update",
"assistantMessageEvent": {
"type": "text_delta",
"contentIndex": 0,
"delta": "Hello "
}
}

assistantMessageEvent 字段包含以下增量类型之一:

类型 说明
text_start 文本内容块开始
text_delta 文本内容块片段
text_end 文本内容块结束
thinking_start 思考块开始
thinking_delta 思考内容块片段
thinking_end 思考块结束
toolcall_start 工具调用开始
toolcall_delta 工具调用参数片段
toolcall_end 工具调用结束(包含完整的 toolCall 对象)

流式输出文本响应的示例:

{"type":"message_update","assistantMessageEvent":{"type":"text_start","contentIndex":0}}
{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}}
{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":" world"}}
{"type":"message_update","assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Hello world"}}

message_update 有意省略了原先累积的 message 字段和 assistantMessageEvent.partial。需要实时部分消息的客户端必须根据 message_start 及后续事件用 contentIndex 自行组装。以 message_end.message 为准。对于工具调用,缓存 toolcall_delta.deltatoolcall_end.toolCall 包含完成的调用。

直接 bash 命令的每个输出块都会发出一次。id 与命令的 id 匹配,便于客户端把输出关联到正确的命令。

命令运行期间事件会流式传输所有输出,即使最终 bash 响应的 output 被截断。

{
"type": "bash_execution_update",
"id": "req-1",
"delta": "total 48\n"
}

tool_execution_start / tool_execution_update / tool_execution_end

Section titled “tool_execution_start / tool_execution_update / tool_execution_end”

工具开始、流式输出进度和完成执行时发出。

{
"type": "tool_execution_start",
"toolCallId": "call_abc123",
"toolName": "bash",
"args": {"command": "ls -la"}
}

执行期间,tool_execution_update 事件流式传输部分结果(例如 bash 输出按到达顺序流出):

{
"type": "tool_execution_update",
"toolCallId": "call_abc123",
"toolName": "bash",
"args": {"command": "ls -la"},
"partialResult": {
"content": [{"type": "text", "text": "partial output so far..."}],
"details": {"truncation": null, "fullOutputPath": null}
}
}

完成时:

{
"type": "tool_execution_end",
"toolCallId": "call_abc123",
"toolName": "bash",
"result": {
"content": [{"type": "text", "text": "total 48\n..."}],
"details": {...}
},
"isError": false
}

toolCallId 关联事件。tool_execution_update 中的 partialResult 包含迄今为止的累积输出(而不仅是增量),客户端可以在每次更新时直接替换显示内容。

待处理的引导或后续队列发生变化时发出。

{
"type": "queue_update",
"steering": ["Focus on error handling"],
"followUp": ["After that, summarize the result"]
}

压缩运行时发出,无论是手动还是自动。

{"type": "compaction_start", "reason": "threshold"}

reason 字段为 "manual""threshold""overflow"

{
"type": "compaction_end",
"reason": "threshold",
"result": {
"summary": "Summary of conversation...",
"firstKeptEntryId": "abc123",
"tokensBefore": 150000,
"estimatedTokensAfter": 32000,
"usage": {
"input": 32000,
"output": 1200,
"cacheRead": 0,
"cacheWrite": 0,
"totalTokens": 33200,
"cost": {"input": 0.01, "output": 0.02, "cacheRead": 0, "cacheWrite": 0, "total": 0.03}
},
"details": {}
},
"aborted": false,
"willRetry": false
}

如果 reason"overflow" 且压缩成功,willRetrytrue,智能体会自动重试该提示词。

如果压缩被中止,resultnullabortedtrue

如果压缩失败(例如 API 配额耗尽),resultnullabortedfalseerrorMessage 包含错误描述。

瞬时错误(过载、速率限制、5xx)后触发自动重试时发出。

{
"type": "auto_retry_start",
"attempt": 1,
"maxAttempts": 3,
"delayMs": 2000,
"errorMessage": "529 {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}"
}
{
"type": "auto_retry_end",
"success": true,
"attempt": 2
}

最终失败时(超过最大重试次数):

{
"type": "auto_retry_end",
"success": false,
"attempt": 3,
"finalError": "529 overloaded_error: Overloaded"
}

summarization_retry_scheduled / summarization_retry_attempt_start / summarization_retry_finished

Section titled “summarization_retry_scheduled / summarization_retry_attempt_start / summarization_retry_finished”

压缩或分支摘要在瞬时模型提供方错误后重试时发出。这些事件使用与助手回合自动重试相同的重试设置。

{
"type": "summarization_retry_scheduled",
"attempt": 1,
"maxAttempts": 3,
"delayMs": 2000,
"errorMessage": "terminated"
}
{
"type": "summarization_retry_attempt_start",
"source": "compaction",
"reason": "threshold"
}

对于分支摘要,source"branchSummary",且没有 reason 字段。

{
"type": "summarization_retry_finished"
}

扩展抛出错误时发出。

{
"type": "extension_error",
"extensionPath": "/path/to/extension.ts",
"event": "tool_call",
"error": "Error message..."
}

扩展 UI 协议(Extension UI Protocol)

Section titled “扩展 UI 协议(Extension UI Protocol)”

扩展可以通过 ctx.ui.select()ctx.ui.confirm() 等请求用户交互。在 RPC 模式下,这些调用会被转换成基础命令/事件流之上的请求/响应子协议。

有两类扩展 UI 方法:

  • 对话框方法(Dialog)selectconfirminputeditor):在 stdout 上发出 extension_ui_request,并阻塞等待客户端在 stdin 上回传带相同 idextension_ui_response
  • 即发即忘方法(Fire-and-forget)notifysetStatussetWidgetsetTitleset_editor_text):在 stdout 上发出 extension_ui_request,但不期望响应。客户端可以显示这些信息,也可以忽略。

如果对话框方法包含 timeout 字段,超时后智能体侧会用默认值自动解析。客户端无需跟踪超时。

某些 ExtensionUIContext 方法在 RPC 模式下不受支持或功能降级,因为它们需要直接访问 TUI:

  • custom() 返回 undefined
  • setWorkingMessage()setWorkingIndicator()setFooter()setHeader()setEditorComponent()setToolsExpanded() 是空操作
  • getEditorText() 返回 ""
  • getToolsExpanded() 返回 false
  • pasteToEditor() 委托给 setEditorText()(无粘贴/折叠处理)
  • getAllThemes() 返回 []
  • getTheme() 返回 undefined
  • setTheme() 返回 { success: false, error: "..." }

注意:RPC 模式下 ctx.mode"rpc"ctx.hasUItrue,因为对话框和即发即忘方法可通过扩展 UI 子协议正常使用。要用 ctx.mode === "tui" 来守卫依赖真实终端的 TUI 专属功能(如 custom())。

所有请求都有 type: "extension_ui_request"、唯一的 id 以及 method 字段。

提示用户从列表中选择。带 timeout 字段的对话框方法会包含以毫秒为单位的超时;如果客户端未及时响应,智能体会自动用 undefined 解析。

{
"type": "extension_ui_request",
"id": "uuid-1",
"method": "select",
"title": "Allow dangerous command?",
"options": ["Allow", "Block"],
"timeout": 10000
}

期望的响应:带 value(所选选项字符串)或 cancelled: trueextension_ui_response

提示用户进行是/否确认。

{
"type": "extension_ui_request",
"id": "uuid-2",
"method": "confirm",
"title": "Clear session?",
"message": "All messages will be lost.",
"timeout": 5000
}

期望的响应:带 confirmed: true/falsecancelled: trueextension_ui_response

提示用户输入自由文本。

{
"type": "extension_ui_request",
"id": "uuid-3",
"method": "input",
"title": "Enter a value",
"placeholder": "type something..."
}

期望的响应:带 value(输入的文本)或 cancelled: trueextension_ui_response

打开一个带可选预填内容的多行文本编辑器。

{
"type": "extension_ui_request",
"id": "uuid-4",
"method": "editor",
"title": "Edit some text",
"prefill": "Line 1\nLine 2\nLine 3"
}

期望的响应:带 value(编辑后的文本)或 cancelled: trueextension_ui_response

显示通知。即发即忘,不期望响应。

{
"type": "extension_ui_request",
"id": "uuid-5",
"method": "notify",
"message": "Command blocked by user",
"notifyType": "warning"
}

notifyType 字段为 "info""warning""error"。省略时默认为 "info"

在底部/状态栏设置或清除一个状态条目。即发即忘。

{
"type": "extension_ui_request",
"id": "uuid-6",
"method": "setStatus",
"statusKey": "my-ext",
"statusText": "Turn 3 running..."
}

发送 statusText: undefined(或省略)来清除该键的状态条目。

在编辑器上方或下方设置或清除一个部件(文本行块)。即发即忘。

{
"type": "extension_ui_request",
"id": "uuid-7",
"method": "setWidget",
"widgetKey": "my-ext",
"widgetLines": ["--- My Widget ---", "Line 1", "Line 2"],
"widgetPlacement": "aboveEditor"
}

发送 widgetLines: undefined(或省略)来清除部件。widgetPlacement 字段为 "aboveEditor"(默认)或 "belowEditor"。RPC 模式只支持字符串数组;组件工厂会被忽略。

设置终端窗口/标签页标题。即发即忘。

{
"type": "extension_ui_request",
"id": "uuid-8",
"method": "setTitle",
"title": "pi - my project"
}

在输入编辑器中设置文本。即发即忘。

{
"type": "extension_ui_request",
"id": "uuid-9",
"method": "set_editor_text",
"text": "prefilled text for the user"
}

响应只针对对话框方法(selectconfirminputeditor)发送。id 必须与请求匹配。

{"type": "extension_ui_response", "id": "uuid-1", "value": "Allow"}
{"type": "extension_ui_response", "id": "uuid-2", "confirmed": true}

关闭任意对话框方法。扩展会收到 undefined(select/input/editor)或 false(confirm)。

{"type": "extension_ui_response", "id": "uuid-3", "cancelled": true}

失败的命令返回 success: false 的响应:

{
"type": "response",
"command": "set_model",
"success": false,
"error": "Model not found: invalid/model"
}

解析错误:

{
"type": "response",
"command": "parse",
"success": false,
"error": "Failed to parse command: Unexpected token..."
}

源文件:

{
"id": "claude-sonnet-4-20250514",
"name": "Claude Sonnet 4",
"api": "anthropic-messages",
"provider": "anthropic",
"baseUrl": "https://api.anthropic.com",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 200000,
"maxTokens": 16384,
"cost": {
"input": 3.0,
"output": 15.0,
"cacheRead": 0.3,
"cacheWrite": 3.75
}
}
{
"role": "user",
"content": "Hello!",
"timestamp": 1733234567890,
"attachments": []
}

content 字段可以是字符串,也可以是 TextContent/ImageContent 块组成的数组。

{
"role": "assistant",
"content": [
{"type": "text", "text": "Hello! How can I help?"},
{"type": "thinking", "thinking": "User is greeting me..."},
{"type": "toolCall", "id": "call_123", "name": "bash", "arguments": {"command": "ls"}}
],
"api": "anthropic-messages",
"provider": "anthropic",
"model": "claude-sonnet-4-20250514",
"usage": {
"input": 100,
"output": 50,
"cacheRead": 0,
"cacheWrite": 0,
"cost": {"input": 0.0003, "output": 0.00075, "cacheRead": 0, "cacheWrite": 0, "total": 0.00105}
},
"stopReason": "stop",
"timestamp": 1733234567890
}

停止原因:"stop""length""toolUse""error""aborted"

{
"role": "toolResult",
"toolCallId": "call_123",
"toolName": "bash",
"content": [{"type": "text", "text": "total 48\ndrwxr-xr-x ..."}],
"usage": {
"input": 100,
"output": 50,
"cacheRead": 0,
"cacheWrite": 0,
"totalTokens": 150,
"cost": {"input": 0.0003, "output": 0.00075, "cacheRead": 0, "cacheWrite": 0, "total": 0.00105}
},
"isError": false,
"timestamp": 1733234567890
}

usage 可选,报告工具执行的内嵌 LLM 工作。存在时会计入会话 token 和成本总量。

bash RPC 命令创建(而非 LLM 工具调用):

{
"role": "bashExecution",
"command": "ls -la",
"output": "total 48\ndrwxr-xr-x ...",
"exitCode": 0,
"cancelled": false,
"truncated": false,
"fullOutputPath": null,
"timestamp": 1733234567890
}
{
"id": "img1",
"type": "image",
"fileName": "photo.jpg",
"mimeType": "image/jpeg",
"size": 102400,
"content": "base64-encoded-data...",
"extractedText": null,
"preview": null
}
import subprocess
import json
proc = subprocess.Popen(
["pi", "--mode", "rpc", "--no-session"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True
)
def send(cmd):
proc.stdin.write(json.dumps(cmd) + "\n")
proc.stdin.flush()
def read_events():
for line in proc.stdout:
yield json.loads(line)
# 发送提示词
send({"type": "prompt", "message": "Hello!"})
# 处理事件
for event in read_events():
if event.get("type") == "message_update":
delta = event.get("assistantMessageEvent", {})
if delta.get("type") == "text_delta":
print(delta["delta"], end="", flush=True)
if event.get("type") == "agent_end":
print()
break

完整的交互式示例见 test/rpc-example.ts,带类型的客户端实现见 src/modes/rpc/rpc-client.ts

处理扩展 UI 协议的完整示例见 examples/rpc-extension-ui.ts,它配套使用 examples/extensions/rpc-demo.ts 扩展。

const { spawn } = require("child_process");
const { StringDecoder } = require("string_decoder");
const agent = spawn("pi", ["--mode", "rpc", "--no-session"]);
function attachJsonlReader(stream, onLine) {
const decoder = new StringDecoder("utf8");
let buffer = "";
stream.on("data", (chunk) => {
buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
while (true) {
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) break;
let line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
if (line.endsWith("\r")) line = line.slice(0, -1);
onLine(line);
}
});
stream.on("end", () => {
buffer += decoder.end();
if (buffer.length > 0) {
onLine(buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer);
}
});
}
attachJsonlReader(agent.stdout, (line) => {
const event = JSON.parse(line);
if (event.type === "message_update") {
const { assistantMessageEvent } = event;
if (assistantMessageEvent.type === "text_delta") {
process.stdout.write(assistantMessageEvent.delta);
}
}
});
// 发送提示词
agent.stdin.write(JSON.stringify({ type: "prompt", message: "Hello" }) + "\n");
// 按 Ctrl+C 中止
process.on("SIGINT", () => {
agent.stdin.write(JSON.stringify({ type: "abort" }) + "\n");
});