pi2web 0.2.0 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +209 -18
  2. package/dist/activity.js +178 -0
  3. package/dist/agent-feed.js +133 -0
  4. package/dist/agent-templates.js +144 -0
  5. package/dist/agents.js +575 -97
  6. package/dist/auth.js +85 -20
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +221 -0
  9. package/dist/collab/hub.js +2888 -0
  10. package/dist/collab/pi-extension.js +494 -0
  11. package/dist/collab/review-flow.js +459 -0
  12. package/dist/collab/routes.js +285 -0
  13. package/dist/collab/schemas.js +281 -0
  14. package/dist/collab/scoring-flow.js +336 -0
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +918 -0
  17. package/dist/collab/types.js +137 -0
  18. package/dist/collab/validate.js +185 -0
  19. package/dist/drafts.js +50 -0
  20. package/dist/http/agent-routes.js +196 -0
  21. package/dist/http/login-limiter.js +53 -0
  22. package/dist/http/mail-settings-routes.js +53 -0
  23. package/dist/http/system-prompt-routes.js +52 -0
  24. package/dist/http/utils.js +43 -0
  25. package/dist/http/web-assets.js +63 -0
  26. package/dist/http/workspace-routes.js +58 -0
  27. package/dist/mail-notifier.js +95 -21
  28. package/dist/main.js +141 -17
  29. package/dist/mcp.js +132 -0
  30. package/dist/metadata-store.js +199 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +288 -49
  33. package/dist/server.js +558 -358
  34. package/dist/session-namer.js +163 -0
  35. package/dist/terminals.js +94 -27
  36. package/dist/timeline.js +185 -0
  37. package/dist/voice.js +196 -40
  38. package/dist/workspaces.js +91 -26
  39. package/dist/ws/agent-socket.js +256 -0
  40. package/dist/ws/terminal-socket.js +46 -0
  41. package/npm-shrinkwrap.json +3685 -0
  42. package/package.json +18 -6
  43. package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__init__.cpython-312.pyc +0 -0
  44. package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__main__.cpython-312.pyc +0 -0
  45. package/packages/protocol/openapi.yaml +8 -2
  46. package/packages/protocol/websocket.schema.json +94 -10
  47. package/web/activity-view.js +121 -0
  48. package/web/app.js +3770 -404
  49. package/web/backend-state.js +269 -0
  50. package/web/collab.html +95 -0
  51. package/web/collab.js +1080 -0
  52. package/web/hid-keyboard.js +453 -0
  53. package/web/index.html +14 -9
  54. package/web/styles.css +73 -0
  55. package/ANDROID_APP_FEATURES.md +0 -256
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Remote Pi
2
2
 
3
- Remote Pi 在开发机上运行 Pi Agent,并通过受 Bearer 配对码保护的 HTTP/WebSocket API 提供 Web UI 和 Android 原生客户端。默认只监听 `127.0.0.1`。
3
+ Remote Pi 在开发机上运行 Pi Agent,并通过受 Bearer 配对码保护的 HTTP/WebSocket API 提供 Web UI。默认只监听 `127.0.0.1`。
4
4
 
5
5
  ## 作为 npm 全局命令安装
6
6
 
7
- 要求 Node.js 20.10 或更高版本:
7
+ 要求 Node.js 22.19.0 或更高版本(由 `@earendil-works/pi-coding-agent` 1.1.x 决定):
8
8
 
9
9
  ```bash
10
10
  npm install -g pi2web
@@ -19,18 +19,51 @@ npm 包名为 `pi2web`,全局命令同名(`pi2web`)。安装时会准备 `
19
19
 
20
20
  ```bash
21
21
  pi2web --host 127.0.0.1 --port 11318
22
- pi2web --data-dir ~/.pi/remote-pi
22
+ pi2web --data-dir ~/.pi/pi2web # 默认值;也可用环境变量 PI2WEB_DIR
23
23
  pi2web --rotate-access-token # 旧配对码立即失效,输出新配对码
24
+ pi2web --set-access-token <token> # 手动设置配对码(16–128 位 URL-safe 字符)
25
+ pi2web --set-access-token-env MY_TOKEN_ENV # 从环境变量读取配对码,推荐方式
26
+ pi2web --no-auth # 关闭配对码认证,默认仅限本机监听
27
+ pi2web --host 0.0.0.0 --no-auth --allow-insecure-no-auth # 危险:允许无认证的远程访问
24
28
  pi2web --help
25
29
  ```
26
30
 
27
31
  服务器会同时提供 API、WebSocket 和 Web UI,无需另起静态文件服务器。请勿直接暴露到公网;远程访问建议使用 SSH tunnel、Tailscale、devtunnel 或配置 HTTPS 的可信反向代理。
28
32
 
29
- 配对失败有速率限制:同一来源地址在 60 秒窗口内累计 10 次失败后会被锁定,锁定时间从 60 秒起按次翻倍,最长 15 分钟,HTTP 返回 `429` 与 `Retry-After`,WebSocket 升级同样受限;一次成功配对立即清除该地址的计数。注意限流按 TCP 来源地址统计,通过 devtunnel、Cloudflare Tunnel 或反向代理访问时所有客户端共用同一个计数桶。Remote Pi 不校验 `Origin` / `Host`,因此可以直接配合内网穿透使用。
33
+ 配对失败有速率限制:同一来源地址在 60 秒窗口内累计 10 次失败后会被锁定,锁定时间从 60 秒起按次翻倍,最长 15 分钟,HTTP 返回 `429` 与 `Retry-After`,WebSocket 升级同样受限;一次成功配对立即清除该地址的计数。注意限流按 TCP 来源地址统计,通过 devtunnel、Cloudflare Tunnel 或反向代理访问时所有客户端共用同一个计数桶。配对码模式下 Remote Pi 不校验 `Origin` / `Host`,因此可以直接配合内网穿透使用。
34
+
35
+ 完全受信任的本地环境(只在本机使用,或通过 SSH tunnel / Tailscale 访问)可以用 `--no-auth` 关掉配对码认证:Web UI 连接时配对码留空即可,也不会再询问是否保存配对码。该选项默认只允许绑定 `127.0.0.1`、`::1` 或 `localhost`,遇到 `0.0.0.0` 等对外地址会直接拒绝启动,避免把无认证的 Agent 控制接口暴露到网络;请勿在不受信任的网络环境中使用。
36
+
37
+ 没有配对码时,浏览器就是最后一道边界,所以单独使用 `--no-auth` 时服务端只响应 `Host` 为 loopback、且 `Origin`(如果带)同为 loopback 的请求,HTTP 和 WebSocket 都一样,其余一律 `403`,也不再返回 `Access-Control-Allow-Origin: *`,以免任意网页读取你的工作区或驱动 Agent。SSH tunnel(访问 `localhost:端口`)不受影响;改写 `Host` 的本机反向代理会被拒绝,请改用配对码模式。
38
+
39
+ 如果确实需要在 `0.0.0.0`、私有 IP 或反向代理后使用无认证模式,必须同时传入例外参数:
40
+
41
+ ```bash
42
+ pi2web --host 0.0.0.0 --no-auth --allow-insecure-no-auth
43
+ ```
44
+
45
+ `--allow-insecure-no-auth` 必须与 `--no-auth` 一起使用;它同时放开监听地址以及 HTTP/WebSocket 的 loopback `Host` / `Origin` 限制,允许远程 Web UI 连接,配对码仍留空。启动时会输出风险警告。**任何能访问该服务的人或网页都可能读取工作区、控制 Agent 和终端**;仅用于已有防火墙、访问控制或可信代理保护的环境,不要直接暴露到公网。不加例外参数时,原有的本机限制保持不变。
46
+
47
+ 配对码默认随机生成;也可以用 `--set-access-token <token>` 或 `--set-access-token-env <环境变量名>` 手动指定。自定义值必须由 16–128 个 URL-safe 字符组成(字母、数字、`.`、`_`、`~`、`-`),这样它能安全用于 HTTP 和 WebSocket 认证。环境变量方式不会把配对码暴露在进程列表中,推荐优先使用。手动设置的配对码同样只保存 sha256 hash,后续可用 `--rotate-access-token` 换回随机码。
48
+
49
+ ## 多 Backend 聚合
50
+
51
+ 一个 Web UI 可以同时连接多个 Remote Pi 服务端(例如家里和实验室各一台):在「配置 → Backend 连接」里点击「添加 Backend」,为每个服务端填一个专属短名称(如 `home` / `lab`)、服务地址和配对码。每个连接使用自己的配对码独立认证,浏览器为它们各自维持实时 WebSocket。
52
+
53
+ 连接多个 backend 时:
54
+
55
+ - Workspace 与 Session(Agent 和 Terminal)列表默认聚合展示所有 backend 的内容;侧栏顶部的过滤器可以只看某个 backend。
56
+ - 连接多个 backend 时,每个 Session 会显示一个短名称标签,标明它来自哪个 backend;Workspace 名称也会带上前缀。
57
+ - 六键 Session 键盘在一个页面内统一绑定到任意 backend 的 Session;按键绑定和键盘灯光按 `backend + Session` 唯一标识,不会混淆。
58
+ - 新建 Agent / Terminal / 打开文件等操作都会路由到所选 Workspace 所属的 backend。
59
+ - 选中某个 Session 时,文件浏览器会跟着切换到**同一个 backend** 下包含该 Session 工作目录的 Workspace。两台机器上可能存在同名路径,不跟随就会把 A 机器的路径 `@` 进发往 B 的 prompt。
60
+ - 配置页中的 MailDispatch 设置属于当前主 Backend(连接列表中第一个已连接项);主 Backend 变化时页面会重新读取设置,不会把一台服务器的值写到另一台。
61
+
62
+ 连接信息(含短名称)保存在浏览器本地存储,并与协作看板(`/collab.html`)共用同一份列表:在任一页面配对过的 backend,另一个页面可以直接复用,不必重新输入配对码。是否保存配对码仍由用户在连接成功后逐个确认。选择了保存配对码的 backend 在页面刷新或重新打开后自动重连,不再弹出配对窗;未保存或自动连接失败时才会弹窗重新输入。旧版本保存过的单个连接会自动迁移为名为「本机」的 backend,已有的键盘绑定也会同步迁移。
30
63
 
31
64
  ## Agent 完成邮件通知(MailDispatch)
32
65
 
33
- 如果启动时同时配置 MailDispatch 消息 API endpoint、API key 环境变量和通知邮箱,Pi 发出 `agent_settled`(不会再自动重试、自动 compact 或执行排队的 follow-up)后,Remote Pi 可以提交事务邮件。MailDispatch 返回 `202` 后邮件进入其持久队列;实际投递由 MailDispatch worker 完成。
66
+ 如果启动时同时配置 MailDispatch 消息 API endpoint、API key 环境变量和通知邮箱,Pi 发出 `agent_settled`(不会再自动重试、自动 compact 或执行排队的 follow-up)后,Remote Pi 可以提交事务邮件。被用户取消的运行(`agent_settled` 带 `aborted`)会写成"已中断"而不是"已完成",语音播报则直接跳过。MailDispatch 返回 `202` 后邮件进入其持久队列;实际投递由 MailDispatch worker 完成。
34
67
 
35
68
  ```bash
36
69
  export REMOTE_PI_MAILDISPATCH_KEY='md_live_...'
@@ -80,7 +113,7 @@ pi2web \
80
113
  - 只有配置 `--voice-stt-model` 时,Web UI 才显示麦克风按钮;识别文字只插入输入框,不会自动发送。
81
114
  - API key 通过 `--voice-api-key-env` 和 `--voice-summary-api-key-env` 指定环境变量名,避免把密钥放入命令行参数。
82
115
 
83
- 语音流目前通过已认证的 Agent WebSocket 发送,只会实时投递,不写入 Session 或事件 replay。浏览器麦克风通常要求 HTTPS 安全上下文(`localhost` 例外)。Android 客户端的流式 PCM 播放和录音 UI 尚未接入。
116
+ 语音流目前通过已认证的 Agent WebSocket 发送,只会实时投递,不写入 Session 或事件 replay。浏览器麦克风通常要求 HTTPS 安全上下文(`localhost` 例外)。
84
117
 
85
118
  ### Edge TTS(仅语音合成)
86
119
 
@@ -108,9 +141,27 @@ pi2web \
108
141
 
109
142
  该模式不配置 `--voice-stt-model`,因此只启用 Agent 摘要播报,不启用语音识别。Edge TTS 使用非官方消费者服务,适合本地和实验性使用。
110
143
 
144
+ ## 自动 Session 命名
145
+
146
+ 自动命名是独立于语音的功能,**默认开启**:Agent 每次任务结束(`agent_settled`)后,如果 Session 还没有名字,就用一个 OpenAI 兼容的接口生成简短标题并写入 Session。不开语音时只生成标题,不会产生任何语音;开了语音播报时仍然只由命名器负责命名,语音负责朗读摘要。
147
+
148
+ 默认值:`--session-name-base-url http://localhost:8313/`、`--session-name-model gpt-5.4-nano`、无 API Key。可用参数:
149
+
150
+ ```
151
+ --session-naming <on|off> 关闭或开启自动命名(默认 on)
152
+ --session-name-base-url <url> OpenAI 兼容的命名接口
153
+ --session-name-model <id> 命名模型 ID
154
+ --session-name-api-key-env <name> 存放 API Key 的环境变量名(默认不带 Key)
155
+ --session-name-language <tag> 标题语言(默认 zh-CN)
156
+ --session-name-request-timeout <ms> 命名请求超时(默认 60000)
157
+ --session-name-max-output-tokens <n> 命名输出 token 上限(默认 2000,推理模型需要较大预算)
158
+ ```
159
+
160
+ 命名失败不会影响 Agent,本次失败会通过 WebSocket 的 `session_namer_event`(`session_name_error`)上报。`GET /api/v1/system/status` 的 `sessionNamingEnabled` 可以确认是否开启。
161
+
111
162
  ## 本地开发
112
163
 
113
- 项目 Review 中发现的问题、修复状态和暂缓的 Android 项目见 [`PROJECT_REVIEW_ISSUES.md`](https://github.com/sxwxs/pi2web/blob/main/PROJECT_REVIEW_ISSUES.md)。
164
+ 项目 Review 中发现的问题和修复状态见 [`PROJECT_REVIEW_ISSUES.md`](https://github.com/sxwxs/pi2web/blob/main/PROJECT_REVIEW_ISSUES.md)。
114
165
 
115
166
  ```bash
116
167
  npm install
@@ -119,13 +170,26 @@ npm test
119
170
  npm run dev -- --port 11318
120
171
  ```
121
172
 
173
+ 源码使用 Prettier 统一格式:`npm run format` 和 `npm run format:check` 覆盖 `src/**/*.ts` 与 `web/*.js`;不要格式化 `dist/` 或网站生成文件 `website/public/`,也不要把纯格式改动与功能修改混在同一次提交。
174
+
122
175
  ## 数据持久化
123
176
 
124
- Remote Pi 的服务端元数据保存在 `~/.pi/remote-pi/remote-pi.db` SQLite 数据库中:
177
+ pi2web 的服务端数据目录是 `~/.pi/pi2web`(`--data-dir` 或 `PI2WEB_DIR` 可覆盖)。旧版本使用的 `~/.pi/remote-pi` 不再读取,请在停止服务后运行一次迁移脚本:
178
+
179
+ ```bash
180
+ python scripts/migrate_data_dir.py --dry-run # 查看计划
181
+ python scripts/migrate_data_dir.py # 复制 ~/.pi/remote-pi -> ~/.pi/pi2web,remote-pi.db 改名为 pi2web.db
182
+ ```
183
+
184
+ 脚本使用 SQLite backup API(包含 WAL 中尚未落盘的数据),不修改源目录;目标目录已有同名文件时会中止,`--force` 会先把它们移到 `pre-migration-<时间>/`。
185
+
186
+ 元数据保存在 `~/.pi/pi2web/pi2web.db` SQLite 数据库中:
125
187
 
126
188
  - `workspaces`:Workspace ID、名称、根目录和创建时间。
127
189
  - `agents`:Agent 与 Workspace/Session 的映射、cwd、状态、创建/最后活动时间以及 Archive 状态。
128
190
  - `sessions`:用于 Session 列表、排序和分页的轻量索引,包括名称、文件路径、消息数量、文件 mtime/size 和最后活动时间。
191
+ - `agents.template`:Agent 创建时选定的 System Prompt 模板快照(JSON),之后不可修改。
192
+ - `agent-templates/*.md`:可分享的 Agent 模板文件,见下文「System Prompt 与 Agent 模板」。
129
193
  - `auth.json`:配对码的 SHA-256 hash、创建和轮换时间;认证数据仍单独保存,不保存配对码明文,文件权限为 `0600`。
130
194
 
131
195
  数据库使用 WAL、短事务、部分索引和批量延迟更新。Session 活动时间不会随每个 streaming token 写入,而是在完整消息、Agent 完成和 Session 信息变化时合并持久化。旧版 `workspaces.json`、`agents.json` 和 `agents_archive.json` 不会被读取、迁移或继续写入。
@@ -142,18 +206,24 @@ Web UI 的 Agent 列表支持右键 `Archive`。Archive 只设置数据库记录
142
206
 
143
207
  ```bash
144
208
  npm pack --dry-run
145
- npm install -g ./pi2web-0.2.0.tgz
209
+ npm install -g ./pi2web-0.2.1.tgz
146
210
  ```
147
211
 
148
212
  ## Web UI
149
213
 
150
- Web 端覆盖 Android 客户端的主要浏览器可实现能力:
214
+ Web 端能力:
151
215
 
152
216
  - Workspace 添加、目录浏览、文本文件分页及二进制提示。
153
217
  - Workspace 目录右键在交互式 Terminal 中打开;Terminal 与 Agent 显示在同一列表,支持刷新重连、清屏和关闭。
154
218
  - 创建/恢复 Session、Agent 列表、停止与 abort;Agent 右键 Archive。
155
219
  - Prompt、steer、follow-up 和流式对话。
156
- - 折叠消息、Thinking、Tool、Retry 和 Extension UI 事件。
220
+ - 对话区只保留用户输入、Agent 输出、Thinking、Extension 对话框和错误;Tool call、Session bash 输出、Retry、Summarization retry 等过程事件自动折叠成一张"活动"卡片,卡片标题实时显示最新一项的名称和时间,展开后逐条查看,再点击单条查看内容。
221
+ - 同一次 Tool call 的 start / update / end 合并为一张卡片(参数 + 输出 + 结果),结束后显示 Pi 记录的执行耗时。
222
+ - Tool 自己发起的调用(codemode 脚本、MCP 批量调用)带 `parentToolCallId`,会嵌套显示在发起它的那张卡片里。
223
+ - steer / follow-up 发送后会提示消息是被当前回合直接接收还是进入队列;有排队消息时输入框上方显示队列条,可一键清空并把内容放回输入框。
224
+ - 工具栏 `MCP` 按钮列出当前 Agent 读取到的 MCP 服务器(来源、传输方式、exposure),可启用/停用;修改写入 `mcp.json`,在 Agent 下次加载时生效。
225
+ - 输入 `/` 浏览并补全当前目录可用的 prompt template 和 skill(由 Pi session 自行展开)。
226
+ - 输入 `!命令` 在 Agent Session 内执行 Shell 并把输出写入上下文,`!!命令` 只执行不写入上下文。
157
227
  - Session 命名、Tree Navigate、Undo/Fork、Compact。
158
228
  - 模型和 Thinking level 切换。
159
229
  - 输入 `@` 浏览并引用 Workspace 文件或目录。
@@ -162,7 +232,100 @@ Web 端覆盖 Android 客户端的主要浏览器可实现能力:
162
232
 
163
233
  Terminal 是以 Remote Pi 进程用户身份运行的完整宿主机 Shell。Workspace 路径只决定初始 cwd,并不是安全沙箱;Shell 可以访问 Workspace 之外的文件及继承到的环境变量。获得配对码的人实际上也获得了该用户的 Shell 权限,请勿将服务直接暴露到不可信网络。每个服务进程最多同时运行 8 个 Terminal,每个 Terminal 只保留最近 512 KiB 输出用于浏览器重连。
164
234
 
165
- 浏览器安全模型与 Android 不同:配对码默认仅保存在当前页面的 JS 内存中;连接成功后会询问是否保存,只有用户确认才写入 localStorage。服务地址、当前 Workspace/Agent 和事件 sequence cursor 会保存在 localStorage。浏览器通知要求 HTTPS 安全上下文(`localhost` 可使用 HTTP);通过局域网 IP 的 HTTP 地址访问时无法启用。自定义 Pi TUI Component 无法在浏览器通用渲染。
235
+ 配对码默认仅保存在当前页面的 JS 内存中;连接成功后会询问是否保存,只有用户确认才写入 localStorage。服务地址、当前 Workspace/Agent 和事件 sequence cursor 会保存在 localStorage。浏览器通知要求 HTTPS 安全上下文(`localhost` 可使用 HTTP);通过局域网 IP 的 HTTP 地址访问时无法启用。自定义 Pi TUI Component 无法在浏览器通用渲染。
236
+
237
+ ### 六键 Session 键盘(WebHID)
238
+
239
+ 在 Chrome/Edge 中通过 HTTPS 或 localhost 打开 Web UI,在“配置 → 六键 Session 键盘”连接设备。每个槽位可以绑定已有 Session、换色或解绑;即使 Session 已不可用,也能在这里清理绑定。绑定仅保存在当前浏览器;按键切换 Session,灯光显示活动状态。语音输入不使用键盘槽位。
240
+
241
+ 目标固件是 **Nozzala CH552G 六键板 `v1-codex-full 1.0.0`**,Build ID `51AE6B3BF94B49FF`。协议参考为 **Nozzala CodexFull WebHID Console v0.1.0(2026-08-31)** 的 `DEVELOPMENT.md` 和 `protocol.js`:
242
+
243
+ - Vendor HID:VID/PID `303A:8360`、Usage Page `0xFF00`、Usage `1`、Report ID `6`、Channel `2`。
244
+ - 主机发送 `{id, m, p}`,不追加换行;WebHID report body 为 63 bytes,其中最多 61 bytes 是 JSON 分片。
245
+ - `v.oai.thstatus` 带请求 ID(0~998),串行等待同 ID 的 **LF 终止回复**,回复后间隔约 50ms 再发送下一条;按键通知也以 LF 终止。
246
+
247
+ 这些约定针对上述 Nozzala 固件,不是所有相同 VID/PID 设备的通用规范。连接前请退出其他控制该键盘的 ChatGPT/Codex App,避免设备占用或灯光互相覆盖。
248
+
249
+ 灯效直接映射服务器维护的 Agent 活动状态(`state.activity`:当前阶段、在途的独立 Shell、待回答的扩展对话框),连接时随 `agent_state` 下发、变化时以 `activity_changed` 推送;这些状态只保存在服务进程内存中,不写入数据库或 Pi Session,LLM 的 `idle` 不代表独立 Shell 或对话框已结束。不支持 WebHID 或安全上下文的浏览器会静默跳过自动连接,配置页保留原因说明。
250
+
251
+ 活动快照中的 `state.activity.dialogRequests` 还包含待回答对话框的原始请求(不含用户答案),刷新或回放缺口后可恢复回答控件。服务端复用同一份内存请求记录,前端按 request ID 避免重复创建对话框;已结束的请求不会再出现在快照中。
252
+
253
+ 卸载 Agent 会通过同一条可回放事件流发送 `agent_unloaded`;在线客户端据此设为 `unloaded`、清除活动灯效并关闭待回答控件,无需重连。
254
+
255
+ ## 外部语音听写到输入框(实验性)
256
+
257
+ [Sotto](https://github.com/sxwxs/Sotto) 可以调用 `POST /api/v1/draft`,body 为 `{text, mode?:"append"|"replace", source?}`。Web UI 收到 `draft_event` 后只把文字写入**当前显示的输入框**,由人检查并手动发送;不指定 Agent、不切换会话、更不能通过 voice token 发 prompt。无选中 Agent 或无页面在线时,文字在服务进程内暂存(最多 50 条、1 小时),页面选择 Agent 或重连后补发,写入后回 `draft_ack`。Sotto 的 `services.pi2web.url` 选择目标 Backend;在同一 Backend 开多个网页标签时,同一段文字可能写入多个标签的输入框,请在可信设备上只开一个用于听写的页面。
258
+
259
+ **受限 voice token** 只可调用 `POST /api/v1/draft` 和 `GET /api/v1/system/status`;不能读取 Agent、使用终端/文件、访问其他工作区 API,也不能连接 WebSocket 或作为网页配对码登录。普通网页仍用配对码访问完整 API。配置方式:
260
+
261
+ ```bash
262
+ pi2web --create-voice-token # 创建或替换,只输出一次 token
263
+ pi2web --revoke-voice-token # 停用
264
+ ```
265
+
266
+ 如果在全新数据目录首次创建 voice token,CLI 会将首次生成的**网页配对码输出到 stderr**,而 stdout 仍只包含 voice token,便于重定向到凭据文件。`--no-auth` 模式下不检查 token,仅适合可信的本机回环环境。语音听写接口定义见 `packages/protocol/`。
267
+
268
+ ## 多 Agent 协作中枢(Collab Hub)
269
+
270
+ 在"人 ↔ 单个 Agent"之上叠加一层协作中枢:多个 Agent 只通过结构化 HTTP API 交互,中枢负责身份、状态机推进、盲评、去重、僵局告警和人工升级。设计文档见 [`COLLAB_PLAN.md`](COLLAB_PLAN.md)。
271
+
272
+ 两个场景:
273
+
274
+ - **Review Loop**(`kind:"review"`):盲审提 Issue → 全体交叉投票 / 重复项合并投票 → 分轮讨论收敛 → 直接输出报告。确认的问题成为 `confirmed` Action Items;人可从 finished 状态直接复核,或指定开发 Agent 先修复再由原 Reviewer 复核。Web 看板新建的 Review 默认开启 `policy.consensusReview:true`;首次流程可选**先开发后评审**(`policy.implementationFirst:true`)。
275
+ - **Panel Scoring**(`kind:"scoring"`):盲提名维度 → 确定性重复项归并 → 投票锁 rubric → 盲打分 → 辩论与争议维度复评分 → 出分。下一轮会带回上一轮 tally/amendment;rubric、分数分布和辩论上下文通过强类型工具完整交给 Agent。投票时 **reject 必须带理由,否则整批 422、一票不写**(旧行为是在 200 里把这一票标成 rejected,而 Agent 的回合已经结束,面板会永远等它)。
276
+
277
+ 两种凭证:**配对码**代表人,可以建会话、登记参与者、强制推进、裁定升级;**participantToken** 代表一个 Agent,只能操作自己所在的会话。会话和参与者只能由人创建,Agent 无法自助加入。
278
+
279
+ 参与者通常只在 `draft` / `implementing` / `collecting`——scoring 会话则是 `nominating`——阶段登记,中枢会当场派发当前任务。Review finished 后还允许追加 `implementer`,为下一次“修复后复核”做准备;Reviewer Panel 保持不变。`role` 只能是 `implementer` / `reviewer`,人使用配对码而不是参与者席位。
280
+
281
+ 每个座位都绑定一个**本机 pi2web Agent**(`agentId` 必填,且必须是一个已存在的 `profile=collab` Agent——中枢唤不醒的座位只会卡住整个阶段,因此登记/改绑时直接 403);同一个协作 Agent 在任意时刻只能占一个 active seat,避免进程内工具无法确定它代表哪个会话。每个 implementation wave 也只允许一个 implementer,因为所有 Agent 仍共享同一工作区。轮到它干活时,中枢直接 prompt(忙碌时用 follow-up 排队)。任务会一直保留在持久化队列中,直到 Agent 真正调用 `collab_get_task` 领取,而不是在 follow-up 刚入队时就当成已送达。
282
+
283
+ 协作座位要用**协作 Agent**:`POST /api/v1/agents -d '{...,"profile":"collab"}'`(看板新建 Agent 时自动带上)。它先调用 `collab_get_task`,扩展再按当前任务只激活一个强类型提交工具,例如 `collab_submit_findings`、`collab_submit_votes` 或 `collab_submit_scores`。工具在进程内直连中枢,自动补全 baseline 与幂等键,模型上下文里不会出现 URL、令牌或内部 ID。Review/Scoring 不按工具名称做武断的“只读”限制:`bash`、`edit`、`write` 都保留,因为禁用其中两个并不能阻止另一个写文件,必要的工作区外临时操作也不应被误伤。任务与系统提示会明确要求不得在工作目录内创建、删除、重命名或修改任何内容,包括重定向输出、临时文件、生成物、缓存和格式化结果;提交时中枢再用 pinned baseline 校验工作区身份,任何实际变化都会得到 `STALE_BASELINE`。只有 `implement` 任务被允许修改工作区。`profile` 缺省是 `default`,普通 Agent 不受影响。
284
+
285
+ 绑错了 Agent(或该 Agent 被删了、卡死了)就改绑:`POST /sessions/{sessionId}/participants/{participantId}/binding -d '{"agentId":"agent-..."}'`——改绑会轮换 participantToken,并把这个座位当前欠的任务重新推给新 Agent(即使旧 Agent 已经收到过)。任务队列在中枢侧持久化,且只有 Agent 实际调用 `collab_get_task` 领取后条目才会销掉;所以看板上的“N 未送达”表示任务尚未被扩展领取,超过两分钟会直接报警。
286
+
287
+ **中枢是推送式的**:任何一方交完自己的活就应当结束回合,绝不要 sleep 轮询。正常 Review 不会回头要求开发方逐条回应;会话结束时中枢推送 `session_result`。人启动 `fix_then_review` 后,中枢才把全部 confirmed Action Items 推给选定开发 Agent;开发 `/ready` 后再主动唤醒原 Reviewer。
288
+
289
+ > 凭证说明:participantToken 只以 SHA-256 hash 存储,明文只在登记(`/participants`)、改绑(`/binding`)和复核重开(`/recheck` 返回的 `participantTokens`)的 **API 响应**里出现一次,数据库里没有任何明文副本。内置 Pi 协作扩展根本不使用它:它走进程内 bridge,按 `agentId` 认领任务,token 不会进入模型上下文或 Agent 会话。看板也不展示它——人用配对码操作,没人需要把一个长期凭证贴到页面上;要拿就直接读接口响应。
290
+
291
+ 最小流程(review):
292
+
293
+ ```bash
294
+ H="Authorization: Bearer $PAIRING_CODE"; B=http://127.0.0.1:11318/api/v1/collab
295
+ curl -H "$H" -X POST $B/sessions -d '{"kind":"review","title":"支付回调评审","workspaceId":"ws-...","subject":{"type":"commit_range","value":"HEAD~1..HEAD"}}'
296
+ curl -H "$H" -X POST $B/sessions/$SID/participants -d '{"role":"reviewer","displayName":"reviewer-security","agentId":"agent-..."}' # 返回一次性 participantToken
297
+ curl -H "$H" -X POST $B/sessions/$SID/participants -d '{"role":"implementer","displayName":"impl","agentId":"agent-..."}'
298
+ curl -H "$H" -X POST $B/sessions/$SID/advance -d '{}' # 开闸,中枢开始派活
299
+ curl -H "Authorization: Bearer $PTOKEN" $B/sessions/$SID/digest # Agent 侧:我现在该干什么
300
+ ```
301
+
302
+ ### Review 与人工触发的修复—复核
303
+
304
+ ```
305
+ draft ──advance──▶ [implementing] ──显式 POST /ready──▶ collecting ──▶ validating / merge / discussion ──▶ finished
306
+ │
307
+ finished ── recheck(review_only) ───────────────────────────────────────────────────────────────────────▶ collecting ◀─┤
308
+ finished ── recheck(fix_then_review) ──▶ implementing ──POST /ready─────────────────────────────────────▶ collecting │
309
+ └─ round+1
310
+ ```
311
+
312
+ - 首次勾选 `implementationFirst` 时,唯一的开发 Agent 先收到 `implement` 工单;只有强类型工具显式提交 `/ready` 才会固定 baseline 并召集 Reviewer。Agent 进入 idle 不再被当作完成,避免失败或漏交被误判为可评审。
313
+ - `collecting` 是盲审。每个 Reviewer 必须提交 `reviewComplete=true`;新 Finding 强制带 `location.path`、evidence 和当前 baselineId。
314
+ - 共识阶段中,每个 Reviewer 对本轮其他人的新 Finding 投 approve/reject,可提出重复项合并;合并必须全票通过。争议经过 `issue_discussing ↔ issue_reconsidering` 收敛。**争议的终局不再一律甩给人**:讨论结束(用满 `maxConsensusRounds`,或某条 Finding 的票型连续两轮完全没变而提前判定“已经吵不动了”)后,中枢按严重度裁决——除报告人外全员 reject 的问题直接 `wontfix`(`blocker`/`critical` 除外)、panel 分裂的 `major` 及以上升级人工、`minor`/`nit` 按多数决,平票保留。只有真正需要判断的分歧才进人工队列。
315
+ - 报告人随时可以撤回自己不再坚持的 Finding:`collab_withdraw_issue`,或在 `collab_submit_issue_discussions` 的 `withdrawals[]` 里和其余答辩一次提交(提交工具会结束回合,所以两者必须同一次调用)。撤回是正常动作,不是失败。
316
+ - Reviewer 的投票允许**分批**:只投当前工单列出的那些 Finding 即可,别人后来才提交的 Finding 会由中枢重新派工单回来,不会因为“集合在你读代码时变大了”而整批 422。反过来,**没有任何可投票的 Finding 时,这个座位不会被派工单,阶段也不会等它**(单 Reviewer 会话,或它欠的 Finding 都被撤回/合并了)——强类型提交工具没有“空票”这种提交。
317
+ - 共识完成后直接 `finished`。有效 Finding 从临时 `open` 转成 `confirmed`,不会触发开发方回应;报告结论为 `approved`、`follow_up_required` 或 `changes_required`。
318
+ - finished 看板提供两个入口:**复核当前代码**调用 `POST /recheck {"mode":"review_only"}`;**推进到修复 → 复核**先选择一个已存在或新添加的 implementer,再调用 `POST /recheck {"mode":"fix_then_review","implementerParticipantIds":[...]}`。
319
+ - 修复 Agent 收到全部 confirmed Action Items,完成后统一 `/ready`。原 Reviewer 在新 baseline 上收到 `issuesToRecheck`,并在 `/findings` 中提交 `rechecks:[{"issueId":"...","outcome":"resolved|still_present","rationale":"..."}]`;同时仍可发现新的 Finding。
320
+ - 每个工单都带上**panel 名单、评分/严重度口径、共识与撤回规则、以及本次评审的 `baseline.changedFiles` 范围**:Reviewer 不知道同伴是谁、不知道 `major` 和 `minor` 的界线、不知道一票 reject 的后果时,投出来的票就没有共同标准。工单里的指令只出现工具名,不出现 HTTP 端点。
321
+ - 每多一轮复核就多一段档案:`GET /sessions/:id/review-rounds`(**仅人**)按轮返回该轮复核了哪些老问题(`resolved` / `still_present` / 尚未回报的 `pending`)、该轮新发现了哪些问题,以及该轮的 baseline 与结论。看板把它渲染成“各轮评审 / 复核结果”,最新一轮在最上面。
322
+ - 每轮 finished 都发送 `session_result`。重新开启时,中枢会轮换参与者凭证并主动推送新任务,旧的结束消息不会污染新一轮。
323
+
324
+ 人用 `GET /report` 查看每轮结果。校验失败返回 `422` 且带 `fieldErrors`,基线过期返回 `409 STALE_BASELINE`,预算耗尽返回 `429`;所有 Agent 提交都需要 `clientRequestId` 做幂等。
325
+
326
+ 人工升级(escalation)进入 `GET /api/v1/collab/escalations`,用 `POST /api/v1/collab/escalations/:id/resolve` 裁定;裁定会真的落地:`issue_dispute` 必须给 `issueDecision`,`budget_exhausted` 传 `extra.tokenBudget` 就提额并解封该参与者,`other`(轮次封顶死锁)传 `extra.maxTotalRounds` 就抬高轮次上限——参数不合法(字符串、比已用量还小、没比现有上限高)直接 422,不会“看似成功实则什么都没做”地用掉那一次机会;裁定事件里的 `applied` 记录实际生效的变更。预算也可以事后单独提:`POST /sessions/:id/participants/:pid/budget -d '{"tokenBudget":600000}'`。`score_dispute` 不走裁定接口,而是用 `POST /sessions/:id/finalize` 一次性给出每个争议维度的分数(该调用同时关掉这条升级;若人工强推跳过了结算,中枢也会自动关掉它,并把这些维度标为 `method:"forced"` 而不是“已收敛”)。**评分会话里只要还有未裁定的升级,面板就停在当前阶段**(不会被下一阶段越过,否则会出现“会话已结束、问题还挂在人手上”),裁定后自动继续;不想等就用 `POST /advance -d '{"force":true,"reason":"..."}'`。配置了 MailDispatch 时,升级和会话停滞会立即发信(不参与聚合,可在配置对话框关闭;同一会话 5 分钟内最多一封,避免 Agent 连续升级刷爆邮箱)。
327
+
328
+ Web 看板在 `/collab.html`(首页顶部"协作"入口):会话列表与创建、参与者登记、Review 动态流程图、逐轮复核档案(每轮一个 section:老问题修好了没有、有没有新问题)、Issue 投票 / 合并 / discussion、待裁定队列、事件时间线,以及 finished 后的“直接复核”和“开发修复后复核”入口,并通过 WebSocket `subscribe_collab` 实时刷新。Review 总结按严重程度列出 confirmed Action Items、文件位置和修复建议。等待状态会同时显示本机 Agent 生命周期:`streaming` / `starting` 表示仍在执行,超过提醒阈值也不判为漏交;只有 `idle` / `unloaded` / `error` 且阶段 API 尚未提交时才适合用 `/retry-waiting` 重新唤醒。`waiting_for_user` 要先处理 Agent 交互;`advance force=true` 会真正跳过未提交结果。
166
329
 
167
330
  ## API
168
331
 
@@ -175,17 +338,45 @@ Terminal 是以 Remote Pi 进程用户身份运行的完整宿主机 Shell。Wor
175
338
  - `GET/POST /api/v1/workspaces`
176
339
  - `GET /api/v1/workspaces/:id/tree|file|stat`
177
340
  - `GET /api/v1/sessions`
178
- - `GET/POST /api/v1/agents`
341
+ - `GET/POST /api/v1/agents`(创建时可带 `profile`:`default`(默认)或 `collab`;`collab` 会加载协作工具,供协作看板占座使用)
179
342
  - `GET/DELETE /api/v1/agents/:id`
343
+ - `POST /api/v1/agents` 可带 `templateId` 选择 Agent 模板;`GET /api/v1/agents/:id/system-prompt` 返回该 Agent 当前生效的 System Prompt、模板快照及来源文件。
344
+ - `GET/PUT /api/v1/system-prompt`:读取 / 写入 Pi 全局 `~/.pi/agent/SYSTEM.md`、`APPEND_SYSTEM.md`(`{kind: "system"|"append", content}`,空内容即删除文件)。
345
+ - `GET /api/v1/system-prompt/preview?workspaceId=&relativeCwd=&templateId=&source=pi-default`:生成指定 cwd 下的有效 System Prompt;`source=pi-default` 忽略 SYSTEM.md / APPEND_SYSTEM.md,显示 Pi 内置 Prompt。
346
+ - `GET /api/v1/agent-templates`、`GET/PUT/DELETE /api/v1/agent-templates/:id`:管理 Agent 模板文件。
347
+ - `GET/POST /api/v1/collab/sessions`、`/sessions/:id`、`/participants`(含 `/participants/:pid/binding`、`/participants/:pid/budget`)、`/advance`、`/ready`、`/recheck`(**仅人**,重开时轮换每个座位的 token 并在响应 `participantTokens` 里返回一次)、`/policy`、`/events`(`?since=` 游标或 `?tail=` 取最新若干条)、`/digest`、`/issues`、`/findings`、`/issue-votes`、`/merge-votes`、`/issue-discussions`、`/review-consensus`、`/review-rounds`(**仅人**)、`/retry-waiting`、`/escalations`、`/report`(**仅人**),以及 scoring 场景的 `/nominations`、`/votes`、`/criteria`、`/scores`、`/analysis`、`/debates`、`/finalize`(**仅人**,且只在 `awaiting_human` 阶段接受,必须为每个争议维度给一个合刻度的分数)
348
+ - `GET /api/v1/collab/escalations`、`POST /api/v1/collab/escalations/:id/resolve`
180
349
  - `GET/POST /api/v1/terminals`
181
350
  - `GET/DELETE /api/v1/terminals/:id`
182
351
  - Terminal WebSocket `/api/v1/terminals/:id/ws` 支持输入、窗口 resize、输出快照和退出事件。
183
- - Agent 的 `state`、`messages`、`capabilities`、`session`、`prompt`、`steer`、`follow-up`、`abort`、`compact`、`model`、`thinking`、`session-name`、`navigate`、`fork`、`archive` 和 `extension-response` 接口。
184
- - WebSocket `/api/v1/ws` 支持 sequence、replay、snapshot、`fromNow` 和 Agent command。
185
-
186
- ## Android App
352
+ - Agent 的 `state`、`messages`、`capabilities`、`session`、`prompt`、`steer`、`follow-up`、`abort`、`compact`、`model`、`thinking`、`session-name`、`navigate`、`fork`、`archive`、`extension-response`、`commands`、`bash` 和 `bash-abort` 接口。`steer` 与 `follow-up` 的响应带 `disposition`(`handled` 表示当前回合已接收,`queued` 表示排队等待)。
353
+ - `GET /api/v1/agents/:id/queue` 返回排队中的 steer / follow-up 消息,`DELETE` 清空并返回被清掉的消息,便于客户端放回输入框;队列变化通过 `queue_update` 事件推送。WebSocket 的 `clear-queue` 命令等价。
354
+ - `GET /api/v1/agents/:id/mcp` 返回该 Agent cwd 下的 MCP 服务器配置(全局 `mcp.json` 与受信任项目的 `.pi/mcp.json`),`POST` 用 `{name, enabled}` 启用或停用。只有项目被 Pi 显式信任时才会写入项目级覆盖,否则写全局文件。修改在 Agent 下次加载时生效。
355
+ - `GET /api/v1/agents/:id/commands` 返回该 Agent cwd 下可用的 prompt template 和 skill(`{name, description, argumentHint, source}`)。`prompt` 接口本身会展开 `/命令`、skill 和 prompt template。
356
+ - `POST /api/v1/agents/:id/bash` 在 Agent Session 内执行命令(`{command, excludeFromContext}`),输出通过 `bash_execution_update` 事件流式推送并记录到 Session;`POST /api/v1/agents/:id/bash-abort` 取消。
357
+ - WebSocket `/api/v1/ws`(协议 v2,不做事件回放):`subscribe_all` 先下发每个 Agent 的当前状态,再推送所有 Agent 的概览事件(状态、活动、队列、对话框、通知);`focus` 选定正在查看的 Agent,只为它推送流式文本、时间线条目变化和用户 Shell 输出;`watch` 订阅已展开的运行中工具的输出;`subscribe_collab` 推送协作事件。
358
+ - 会话内容通过 HTTP 时间线读取:`GET /api/v1/agents/:id/timeline?limit&before` 返回条目摘要(正文较小或需要阅读的条目内联),`GET /api/v1/agents/:id/sessions/:sessionId/items/:itemId` 按需返回完整正文;已完成条目永不改变,响应带 `immutable` 缓存头,浏览器可永久缓存。
359
+
360
+ ## System Prompt 与 Agent 模板
361
+
362
+ Web UI「配置 → 管理 System Prompt…」可以:
363
+
364
+ - 预览当前 Workspace / 工作路径下 Agent 实际会使用的 System Prompt,或忽略全局文件的 Pi 内置 Prompt;
365
+ - 编辑 Pi 自己的全局 `~/.pi/agent/SYSTEM.md`(替换 Pi 基础 Prompt)和 `APPEND_SYSTEM.md`(追加),这两个文件同样作用于 Pi CLI;受信任项目的 `.pi/SYSTEM.md`、`.pi/APPEND_SYSTEM.md` 优先;
366
+ - 管理 Agent 模板。
367
+
368
+ Agent 模板是 `~/.pi/pi2web/agent-templates/<id>.md` 文件,直接复制即可分享,手动放进目录的文件会立即出现:
369
+
370
+ ```markdown
371
+ ---
372
+ name: Code reviewer
373
+ description: 只审查,不修改文件
374
+ mode: append # append:追加在 Pi Prompt 之后;replace:替换 Pi 基础 Prompt(等同 SYSTEM.md)
375
+ ---
376
+ You are a meticulous code reviewer. ...
377
+ ```
187
378
 
188
- `android/` 是 Kotlin + Jetpack Compose 原生客户端。构建和连接说明见 [`android/README.md`](https://github.com/sxwxs/pi2web/blob/main/android/README.md),完整功能见 [`ANDROID_APP_FEATURES.md`](ANDROID_APP_FEATURES.md)。
379
+ 新建 Agent 时可选择模板(默认是 Pi 自己的 Prompt)。模板内容在创建时复制到 Agent 记录中,**之后不能更改**:修改或删除模板文件不影响已有 Agent,Fork 出的 Agent 沿用同一模板。两种模式下 Pi 仍会加上 AGENTS.md 等项目上下文、skills 和 cwd;`replace` 会去掉 Pi 内置的工具说明、规则和文档段落,`APPEND_SYSTEM.md` 仍然生效。Agent 工具栏的「Prompt」按钮显示该 Agent 当前生效的完整 System Prompt。
189
380
 
190
381
  运行时使用真实 `@earendil-works/pi-coding-agent` SDK,并复用 Pi CLI 的 `~/.pi/agent` 模型、认证和设置。`MockBackend` 只用于自动化测试。
191
382
 
@@ -0,0 +1,178 @@
1
+ const SUMMARY_LIMIT = 80;
2
+ const SUMMARY_KEYS = ['command', 'path', 'file_path', 'pattern', 'query', 'url'];
3
+ /** A one-line hint of what a tool call works on: its command, path, pattern... */
4
+ export function toolSummary(args) {
5
+ if (!args || typeof args !== 'object')
6
+ return undefined;
7
+ const record = args;
8
+ const value = SUMMARY_KEYS.map(key => record[key]).find(v => typeof v === 'string' && v.trim()) ??
9
+ Object.values(record).find(v => typeof v === 'string' && v.trim());
10
+ if (typeof value !== 'string')
11
+ return undefined;
12
+ const line = value.trim().replace(/\s+/g, ' ');
13
+ return line.length > SUMMARY_LIMIT ? `${line.slice(0, SUMMARY_LIMIT - 1)}…` : line;
14
+ }
15
+ const str = (value) => (typeof value === 'string' ? value : undefined);
16
+ const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
17
+ /**
18
+ * Server-side state machine over raw Pi events. It is the single source of truth for
19
+ * "what is this Agent doing and since when", so any client can show it at any time,
20
+ * including after switching sessions, reloading or reconnecting.
21
+ */
22
+ export class ActivityTracker {
23
+ now;
24
+ state;
25
+ constructor(now = Date.now) {
26
+ this.now = now;
27
+ this.state = { run: null, phase: { kind: 'idle', since: now() }, bash: [], dialogs: [] };
28
+ }
29
+ snapshot() {
30
+ return structuredClone(this.state);
31
+ }
32
+ /** Forget everything, e.g. when the backend is unloaded. Returns whether anything changed. */
33
+ reset() {
34
+ const s = this.state;
35
+ if (!s.run && s.phase.kind === 'idle' && !s.bash.length && !s.dialogs.length)
36
+ return false;
37
+ this.state = { run: null, phase: { kind: 'idle', since: this.now() }, bash: [], dialogs: [] };
38
+ return true;
39
+ }
40
+ /** The user asked to abort the current run. */
41
+ stopping() {
42
+ if (!this.state.run || this.state.phase.kind === 'stopping')
43
+ return false;
44
+ return this.setPhase({ kind: 'stopping' });
45
+ }
46
+ /** Apply one raw Pi event. Returns whether the activity changed, so callers publish only real changes. */
47
+ apply(event) {
48
+ const e = event, s = this.state;
49
+ switch (event.type) {
50
+ case 'agent_start': {
51
+ const startedRun = !s.run;
52
+ if (startedRun)
53
+ s.run = { startedAt: this.now() };
54
+ return this.setPhase({ kind: 'waiting_llm' }) || startedRun;
55
+ }
56
+ case 'turn_start':
57
+ return this.setPhase({ kind: 'waiting_llm' });
58
+ case 'message_update': {
59
+ const update = e.assistantMessageEvent, type = str(update?.type);
60
+ if (type === 'thinking_start' || type === 'thinking_delta')
61
+ return this.setPhase({ kind: 'thinking' });
62
+ if (type === 'text_start' || type === 'text_delta')
63
+ return this.setPhase({ kind: 'generating' });
64
+ if (type === 'toolcall_start' || type === 'toolcall_delta') {
65
+ const toolName = str(update.partial?.content?.[update.contentIndex]?.name);
66
+ // The name may only arrive with a later delta: fill it in without restarting the timer.
67
+ if (s.phase.kind === 'preparing_tool' && !s.phase.toolName && toolName) {
68
+ s.phase.toolName = toolName;
69
+ return true;
70
+ }
71
+ return this.setPhase({ kind: 'preparing_tool', ...(toolName ? { toolName } : {}) });
72
+ }
73
+ return false;
74
+ }
75
+ case 'tool_execution_start': {
76
+ // Tools called by other tools run inside their caller's time; show only top-level calls.
77
+ if (e.parentToolCallId)
78
+ return false;
79
+ const tool = {
80
+ toolCallId: String(e.toolCallId ?? ''),
81
+ toolName: str(e.toolName) ?? 'tool',
82
+ summary: toolSummary(e.args),
83
+ since: this.now()
84
+ };
85
+ if (s.phase.kind === 'running_tools') {
86
+ s.phase.tools.push(tool);
87
+ return true;
88
+ }
89
+ return this.setPhase({ kind: 'running_tools', tools: [tool] });
90
+ }
91
+ case 'tool_execution_end': {
92
+ if (e.parentToolCallId || s.phase.kind !== 'running_tools')
93
+ return false;
94
+ const tools = s.phase.tools.filter(tool => tool.toolCallId !== e.toolCallId);
95
+ if (tools.length === s.phase.tools.length)
96
+ return false;
97
+ if (tools.length) {
98
+ s.phase.tools = tools;
99
+ return true;
100
+ }
101
+ return this.setPhase({ kind: 'waiting_llm' });
102
+ }
103
+ case 'auto_retry_start': {
104
+ if (!s.run)
105
+ s.run = { startedAt: this.now() };
106
+ const delay = num(e.delayMs);
107
+ return this.setPhase({
108
+ kind: 'retrying',
109
+ attempt: num(e.attempt),
110
+ maxAttempts: num(e.maxAttempts),
111
+ retryAt: delay === undefined ? undefined : this.now() + delay,
112
+ error: str(e.errorMessage)
113
+ }, true);
114
+ }
115
+ case 'compaction_start':
116
+ return this.setPhase({ kind: 'compacting', reason: str(e.reason) });
117
+ case 'compaction_end':
118
+ return this.setPhase({ kind: s.run ? 'waiting_llm' : 'idle' });
119
+ case 'agent_end':
120
+ if (e.willRetry === true)
121
+ return false;
122
+ return this.endRun();
123
+ case 'agent_settled':
124
+ // Settled: nothing of the run is left, compaction included. Shell commands and dialogs stay.
125
+ return this.endRun(true);
126
+ case 'bash_execution_start':
127
+ case 'bash_execution_update': {
128
+ const id = str(e.id);
129
+ if (!id || s.bash.some(b => b.id === id))
130
+ return false;
131
+ s.bash.push({ id, command: str(e.command), since: this.now() });
132
+ return true;
133
+ }
134
+ case 'bash_execution_end': {
135
+ const before = s.bash.length;
136
+ s.bash = s.bash.filter(b => b.id !== e.id);
137
+ return s.bash.length !== before;
138
+ }
139
+ case 'extension_ui_request': {
140
+ const requestId = str(e.requestId);
141
+ if (!requestId || s.dialogs.some(d => d.requestId === requestId))
142
+ return false;
143
+ s.dialogs.push({ requestId, title: str(e.title) ?? str(e.message), since: this.now() });
144
+ return true;
145
+ }
146
+ case 'extension_ui_response': {
147
+ const before = s.dialogs.length;
148
+ s.dialogs = s.dialogs.filter(d => d.requestId !== e.requestId);
149
+ return s.dialogs.length !== before;
150
+ }
151
+ default:
152
+ return false;
153
+ }
154
+ }
155
+ endRun(settled = false) {
156
+ const hadRun = !!this.state.run;
157
+ this.state.run = null;
158
+ // Compaction may continue after the run has ended; agent_settled closes it.
159
+ if (this.state.phase.kind === 'compacting' && !settled)
160
+ return hadRun;
161
+ return this.setPhase({ kind: 'idle' }) || hadRun;
162
+ }
163
+ /**
164
+ * Enter a phase. Staying in the same phase keeps its start time, so a stream of deltas
165
+ * neither resets the timer nor counts as a change. `force` restarts it (e.g. each retry).
166
+ */
167
+ setPhase(next, force = false) {
168
+ const current = this.state.phase;
169
+ if (!force && current.kind === next.kind && Object.keys(next).every(key => current[key] === next[key]))
170
+ return false;
171
+ const phase = { ...next, since: this.now() };
172
+ for (const key of Object.keys(phase))
173
+ if (phase[key] === undefined)
174
+ delete phase[key];
175
+ this.state.phase = phase;
176
+ return true;
177
+ }
178
+ }