pi2web 0.2.0 → 0.2.1

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.
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
@@ -21,16 +21,49 @@ npm 包名为 `pi2web`,全局命令同名(`pi2web`)。安装时会准备 `
21
21
  pi2web --host 127.0.0.1 --port 11318
22
22
  pi2web --data-dir ~/.pi/remote-pi
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-mini`、无 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
@@ -142,18 +193,24 @@ Web UI 的 Agent 列表支持右键 `Archive`。Archive 只设置数据库记录
142
193
 
143
194
  ```bash
144
195
  npm pack --dry-run
145
- npm install -g ./pi2web-0.2.0.tgz
196
+ npm install -g ./pi2web-0.2.1.tgz
146
197
  ```
147
198
 
148
199
  ## Web UI
149
200
 
150
- Web 端覆盖 Android 客户端的主要浏览器可实现能力:
201
+ Web 端能力:
151
202
 
152
203
  - Workspace 添加、目录浏览、文本文件分页及二进制提示。
153
204
  - Workspace 目录右键在交互式 Terminal 中打开;Terminal 与 Agent 显示在同一列表,支持刷新重连、清屏和关闭。
154
205
  - 创建/恢复 Session、Agent 列表、停止与 abort;Agent 右键 Archive。
155
206
  - Prompt、steer、follow-up 和流式对话。
156
- - 折叠消息、Thinking、Tool、Retry 和 Extension UI 事件。
207
+ - 对话区只保留用户输入、Agent 输出、Thinking、Extension 对话框和错误;Tool call、Session bash 输出、Retry、Summarization retry 等过程事件自动折叠成一张"活动"卡片,卡片标题实时显示最新一项的名称和时间,展开后逐条查看,再点击单条查看内容。
208
+ - 同一次 Tool call 的 start / update / end 合并为一张卡片(参数 + 输出 + 结果),结束后显示 Pi 记录的执行耗时。
209
+ - Tool 自己发起的调用(codemode 脚本、MCP 批量调用)带 `parentToolCallId`,会嵌套显示在发起它的那张卡片里。
210
+ - steer / follow-up 发送后会提示消息是被当前回合直接接收还是进入队列;有排队消息时输入框上方显示队列条,可一键清空并把内容放回输入框。
211
+ - 工具栏 `MCP` 按钮列出当前 Agent 读取到的 MCP 服务器(来源、传输方式、exposure),可启用/停用;修改写入 `mcp.json`,在 Agent 下次加载时生效。
212
+ - 输入 `/` 浏览并补全当前目录可用的 prompt template 和 skill(由 Pi session 自行展开)。
213
+ - 输入 `!命令` 在 Agent Session 内执行 Shell 并把输出写入上下文,`!!命令` 只执行不写入上下文。
157
214
  - Session 命名、Tree Navigate、Undo/Fork、Compact。
158
215
  - 模型和 Thinking level 切换。
159
216
  - 输入 `@` 浏览并引用 Workspace 文件或目录。
@@ -162,7 +219,100 @@ Web 端覆盖 Android 客户端的主要浏览器可实现能力:
162
219
 
163
220
  Terminal 是以 Remote Pi 进程用户身份运行的完整宿主机 Shell。Workspace 路径只决定初始 cwd,并不是安全沙箱;Shell 可以访问 Workspace 之外的文件及继承到的环境变量。获得配对码的人实际上也获得了该用户的 Shell 权限,请勿将服务直接暴露到不可信网络。每个服务进程最多同时运行 8 个 Terminal,每个 Terminal 只保留最近 512 KiB 输出用于浏览器重连。
164
221
 
165
- 浏览器安全模型与 Android 不同:配对码默认仅保存在当前页面的 JS 内存中;连接成功后会询问是否保存,只有用户确认才写入 localStorage。服务地址、当前 Workspace/Agent 和事件 sequence cursor 会保存在 localStorage。浏览器通知要求 HTTPS 安全上下文(`localhost` 可使用 HTTP);通过局域网 IP 的 HTTP 地址访问时无法启用。自定义 Pi TUI Component 无法在浏览器通用渲染。
222
+ 配对码默认仅保存在当前页面的 JS 内存中;连接成功后会询问是否保存,只有用户确认才写入 localStorage。服务地址、当前 Workspace/Agent 和事件 sequence cursor 会保存在 localStorage。浏览器通知要求 HTTPS 安全上下文(`localhost` 可使用 HTTP);通过局域网 IP 的 HTTP 地址访问时无法启用。自定义 Pi TUI Component 无法在浏览器通用渲染。
223
+
224
+ ### 六键 Session 键盘(WebHID)
225
+
226
+ 在 Chrome/Edge 中通过 HTTPS 或 localhost 打开 Web UI,在“配置 → 六键 Session 键盘”连接设备。每个槽位可以绑定已有 Session、换色或解绑;即使 Session 已不可用,也能在这里清理绑定。绑定仅保存在当前浏览器;按键切换 Session,灯光显示活动状态。语音输入不使用键盘槽位。
227
+
228
+ 目标固件是 **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`:
229
+
230
+ - Vendor HID:VID/PID `303A:8360`、Usage Page `0xFF00`、Usage `1`、Report ID `6`、Channel `2`。
231
+ - 主机发送 `{id, m, p}`,不追加换行;WebHID report body 为 63 bytes,其中最多 61 bytes 是 JSON 分片。
232
+ - `v.oai.thstatus` 带请求 ID(0~998),串行等待同 ID 的 **LF 终止回复**,回复后间隔约 50ms 再发送下一条;按键通知也以 LF 终止。
233
+
234
+ 这些约定针对上述 Nozzala 固件,不是所有相同 VID/PID 设备的通用规范。连接前请退出其他控制该键盘的 ChatGPT/Codex App,避免设备占用或灯光互相覆盖。
235
+
236
+ 灯效恢复使用服务器活动快照:`state.activity.bashIds` 表示在途的独立 Shell,`state.activity.dialogIds` 表示待回答的扩展对话框。这些 ID 只保存在服务进程内存中,不写入数据库或 Pi Session;LLM 的 `idle` 不代表它们已结束。回放缺口通过 `agent_snapshot.state` 恢复,随后按序补发快照构建期间的事件。每次订阅在回放完成后、实时事件开始前发送 `agent_state` 当前状态;首次 `fromNow` 订阅或已有游标但无新事件时也会发送,因此刷新页面无需等下一段 Shell 输出才能恢复灯效。旧服务端未提供 `activity` 时仍按普通 Agent 状态降级显示。不支持 WebHID 或安全上下文的浏览器会静默跳过自动连接,配置页保留原因说明。
237
+
238
+ 活动快照中的 `state.activity.dialogRequests` 还包含待回答对话框的原始请求(不含用户答案),刷新或回放缺口后可恢复回答控件。服务端复用同一份内存请求记录,前端按 request ID 避免重复创建对话框;已结束的请求不会再出现在快照中。
239
+
240
+ 卸载 Agent 会通过同一条可回放事件流发送 `agent_unloaded`;在线客户端据此设为 `unloaded`、清除活动灯效并关闭待回答控件,无需重连。
241
+
242
+ ## 外部语音听写到输入框(实验性)
243
+
244
+ [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 开多个网页标签时,同一段文字可能写入多个标签的输入框,请在可信设备上只开一个用于听写的页面。
245
+
246
+ **受限 voice token** 只可调用 `POST /api/v1/draft` 和 `GET /api/v1/system/status`;不能读取 Agent、使用终端/文件、访问其他工作区 API,也不能连接 WebSocket 或作为网页配对码登录。普通网页仍用配对码访问完整 API。配置方式:
247
+
248
+ ```bash
249
+ pi2web --create-voice-token # 创建或替换,只输出一次 token
250
+ pi2web --revoke-voice-token # 停用
251
+ ```
252
+
253
+ 如果在全新数据目录首次创建 voice token,CLI 会将首次生成的**网页配对码输出到 stderr**,而 stdout 仍只包含 voice token,便于重定向到凭据文件。`--no-auth` 模式下不检查 token,仅适合可信的本机回环环境。语音听写接口定义见 `packages/protocol/`。
254
+
255
+ ## 多 Agent 协作中枢(Collab Hub)
256
+
257
+ 在"人 ↔ 单个 Agent"之上叠加一层协作中枢:多个 Agent 只通过结构化 HTTP API 交互,中枢负责身份、状态机推进、盲评、去重、僵局告警和人工升级。设计文档见 [`COLLAB_PLAN.md`](COLLAB_PLAN.md)。
258
+
259
+ 两个场景:
260
+
261
+ - **Review Loop**(`kind:"review"`):盲审提 Issue → 全体交叉投票 / 重复项合并投票 → 分轮讨论收敛 → 直接输出报告。确认的问题成为 `confirmed` Action Items;人可从 finished 状态直接复核,或指定开发 Agent 先修复再由原 Reviewer 复核。Web 看板新建的 Review 默认开启 `policy.consensusReview:true`;首次流程可选**先开发后评审**(`policy.implementationFirst:true`)。
262
+ - **Panel Scoring**(`kind:"scoring"`):盲提名维度 → 确定性重复项归并 → 投票锁 rubric → 盲打分 → 辩论与争议维度复评分 → 出分。下一轮会带回上一轮 tally/amendment;rubric、分数分布和辩论上下文通过强类型工具完整交给 Agent。投票时 **reject 必须带理由,否则整批 422、一票不写**(旧行为是在 200 里把这一票标成 rejected,而 Agent 的回合已经结束,面板会永远等它)。
263
+
264
+ 两种凭证:**配对码**代表人,可以建会话、登记参与者、强制推进、裁定升级;**participantToken** 代表一个 Agent,只能操作自己所在的会话。会话和参与者只能由人创建,Agent 无法自助加入。
265
+
266
+ 参与者通常只在 `draft` / `implementing` / `collecting`——scoring 会话则是 `nominating`——阶段登记,中枢会当场派发当前任务。Review finished 后还允许追加 `implementer`,为下一次“修复后复核”做准备;Reviewer Panel 保持不变。`role` 只能是 `implementer` / `reviewer`,人使用配对码而不是参与者席位。
267
+
268
+ 每个座位都绑定一个**本机 pi2web Agent**(`agentId` 必填,且必须是一个已存在的 `profile=collab` Agent——中枢唤不醒的座位只会卡住整个阶段,因此登记/改绑时直接 403);同一个协作 Agent 在任意时刻只能占一个 active seat,避免进程内工具无法确定它代表哪个会话。每个 implementation wave 也只允许一个 implementer,因为所有 Agent 仍共享同一工作区。轮到它干活时,中枢直接 prompt(忙碌时用 follow-up 排队)。任务会一直保留在持久化队列中,直到 Agent 真正调用 `collab_get_task` 领取,而不是在 follow-up 刚入队时就当成已送达。
269
+
270
+ 协作座位要用**协作 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 不受影响。
271
+
272
+ 绑错了 Agent(或该 Agent 被删了、卡死了)就改绑:`POST /sessions/{sessionId}/participants/{participantId}/binding -d '{"agentId":"agent-..."}'`——改绑会轮换 participantToken,并把这个座位当前欠的任务重新推给新 Agent(即使旧 Agent 已经收到过)。任务队列在中枢侧持久化,且只有 Agent 实际调用 `collab_get_task` 领取后条目才会销掉;所以看板上的“N 未送达”表示任务尚未被扩展领取,超过两分钟会直接报警。
273
+
274
+ **中枢是推送式的**:任何一方交完自己的活就应当结束回合,绝不要 sleep 轮询。正常 Review 不会回头要求开发方逐条回应;会话结束时中枢推送 `session_result`。人启动 `fix_then_review` 后,中枢才把全部 confirmed Action Items 推给选定开发 Agent;开发 `/ready` 后再主动唤醒原 Reviewer。
275
+
276
+ > 凭证说明:participantToken 只以 SHA-256 hash 存储,明文只在登记(`/participants`)、改绑(`/binding`)和复核重开(`/recheck` 返回的 `participantTokens`)的 **API 响应**里出现一次,数据库里没有任何明文副本。内置 Pi 协作扩展根本不使用它:它走进程内 bridge,按 `agentId` 认领任务,token 不会进入模型上下文或 Agent 会话。看板也不展示它——人用配对码操作,没人需要把一个长期凭证贴到页面上;要拿就直接读接口响应。
277
+
278
+ 最小流程(review):
279
+
280
+ ```bash
281
+ H="Authorization: Bearer $PAIRING_CODE"; B=http://127.0.0.1:11318/api/v1/collab
282
+ curl -H "$H" -X POST $B/sessions -d '{"kind":"review","title":"支付回调评审","workspaceId":"ws-...","subject":{"type":"commit_range","value":"HEAD~1..HEAD"}}'
283
+ curl -H "$H" -X POST $B/sessions/$SID/participants -d '{"role":"reviewer","displayName":"reviewer-security","agentId":"agent-..."}' # 返回一次性 participantToken
284
+ curl -H "$H" -X POST $B/sessions/$SID/participants -d '{"role":"implementer","displayName":"impl","agentId":"agent-..."}'
285
+ curl -H "$H" -X POST $B/sessions/$SID/advance -d '{}' # 开闸,中枢开始派活
286
+ curl -H "Authorization: Bearer $PTOKEN" $B/sessions/$SID/digest # Agent 侧:我现在该干什么
287
+ ```
288
+
289
+ ### Review 与人工触发的修复—复核
290
+
291
+ ```
292
+ draft ──advance──▶ [implementing] ──显式 POST /ready──▶ collecting ──▶ validating / merge / discussion ──▶ finished
293
+ │
294
+ finished ── recheck(review_only) ───────────────────────────────────────────────────────────────────────▶ collecting ◀─┤
295
+ finished ── recheck(fix_then_review) ──▶ implementing ──POST /ready─────────────────────────────────────▶ collecting │
296
+ └─ round+1
297
+ ```
298
+
299
+ - 首次勾选 `implementationFirst` 时,唯一的开发 Agent 先收到 `implement` 工单;只有强类型工具显式提交 `/ready` 才会固定 baseline 并召集 Reviewer。Agent 进入 idle 不再被当作完成,避免失败或漏交被误判为可评审。
300
+ - `collecting` 是盲审。每个 Reviewer 必须提交 `reviewComplete=true`;新 Finding 强制带 `location.path`、evidence 和当前 baselineId。
301
+ - 共识阶段中,每个 Reviewer 对本轮其他人的新 Finding 投 approve/reject,可提出重复项合并;合并必须全票通过。争议经过 `issue_discussing ↔ issue_reconsidering` 收敛。**争议的终局不再一律甩给人**:讨论结束(用满 `maxConsensusRounds`,或某条 Finding 的票型连续两轮完全没变而提前判定“已经吵不动了”)后,中枢按严重度裁决——除报告人外全员 reject 的问题直接 `wontfix`(`blocker`/`critical` 除外)、panel 分裂的 `major` 及以上升级人工、`minor`/`nit` 按多数决,平票保留。只有真正需要判断的分歧才进人工队列。
302
+ - 报告人随时可以撤回自己不再坚持的 Finding:`collab_withdraw_issue`,或在 `collab_submit_issue_discussions` 的 `withdrawals[]` 里和其余答辩一次提交(提交工具会结束回合,所以两者必须同一次调用)。撤回是正常动作,不是失败。
303
+ - Reviewer 的投票允许**分批**:只投当前工单列出的那些 Finding 即可,别人后来才提交的 Finding 会由中枢重新派工单回来,不会因为“集合在你读代码时变大了”而整批 422。反过来,**没有任何可投票的 Finding 时,这个座位不会被派工单,阶段也不会等它**(单 Reviewer 会话,或它欠的 Finding 都被撤回/合并了)——强类型提交工具没有“空票”这种提交。
304
+ - 共识完成后直接 `finished`。有效 Finding 从临时 `open` 转成 `confirmed`,不会触发开发方回应;报告结论为 `approved`、`follow_up_required` 或 `changes_required`。
305
+ - finished 看板提供两个入口:**复核当前代码**调用 `POST /recheck {"mode":"review_only"}`;**推进到修复 → 复核**先选择一个已存在或新添加的 implementer,再调用 `POST /recheck {"mode":"fix_then_review","implementerParticipantIds":[...]}`。
306
+ - 修复 Agent 收到全部 confirmed Action Items,完成后统一 `/ready`。原 Reviewer 在新 baseline 上收到 `issuesToRecheck`,并在 `/findings` 中提交 `rechecks:[{"issueId":"...","outcome":"resolved|still_present","rationale":"..."}]`;同时仍可发现新的 Finding。
307
+ - 每个工单都带上**panel 名单、评分/严重度口径、共识与撤回规则、以及本次评审的 `baseline.changedFiles` 范围**:Reviewer 不知道同伴是谁、不知道 `major` 和 `minor` 的界线、不知道一票 reject 的后果时,投出来的票就没有共同标准。工单里的指令只出现工具名,不出现 HTTP 端点。
308
+ - 每多一轮复核就多一段档案:`GET /sessions/:id/review-rounds`(**仅人**)按轮返回该轮复核了哪些老问题(`resolved` / `still_present` / 尚未回报的 `pending`)、该轮新发现了哪些问题,以及该轮的 baseline 与结论。看板把它渲染成“各轮评审 / 复核结果”,最新一轮在最上面。
309
+ - 每轮 finished 都发送 `session_result`。重新开启时,中枢会轮换参与者凭证并主动推送新任务,旧的结束消息不会污染新一轮。
310
+
311
+ 人用 `GET /report` 查看每轮结果。校验失败返回 `422` 且带 `fieldErrors`,基线过期返回 `409 STALE_BASELINE`,预算耗尽返回 `429`;所有 Agent 提交都需要 `clientRequestId` 做幂等。
312
+
313
+ 人工升级(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 连续升级刷爆邮箱)。
314
+
315
+ 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
316
 
167
317
  ## API
168
318
 
@@ -175,17 +325,19 @@ Terminal 是以 Remote Pi 进程用户身份运行的完整宿主机 Shell。Wor
175
325
  - `GET/POST /api/v1/workspaces`
176
326
  - `GET /api/v1/workspaces/:id/tree|file|stat`
177
327
  - `GET /api/v1/sessions`
178
- - `GET/POST /api/v1/agents`
328
+ - `GET/POST /api/v1/agents`(创建时可带 `profile`:`default`(默认)或 `collab`;`collab` 会加载协作工具,供协作看板占座使用)
179
329
  - `GET/DELETE /api/v1/agents/:id`
330
+ - `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` 阶段接受,必须为每个争议维度给一个合刻度的分数)
331
+ - `GET /api/v1/collab/escalations`、`POST /api/v1/collab/escalations/:id/resolve`
180
332
  - `GET/POST /api/v1/terminals`
181
333
  - `GET/DELETE /api/v1/terminals/:id`
182
334
  - 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
187
-
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)。
335
+ - 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` 表示排队等待)。
336
+ - `GET /api/v1/agents/:id/queue` 返回排队中的 steer / follow-up 消息,`DELETE` 清空并返回被清掉的消息,便于客户端放回输入框;队列变化通过 `queue_update` 事件推送。WebSocket 的 `clear-queue` 命令等价。
337
+ - `GET /api/v1/agents/:id/mcp` 返回该 Agent cwd 下的 MCP 服务器配置(全局 `mcp.json` 与受信任项目的 `.pi/mcp.json`),`POST` 用 `{name, enabled}` 启用或停用。只有项目被 Pi 显式信任时才会写入项目级覆盖,否则写全局文件。修改在 Agent 下次加载时生效。
338
+ - `GET /api/v1/agents/:id/commands` 返回该 Agent cwd 下可用的 prompt template 和 skill(`{name, description, argumentHint, source}`)。`prompt` 接口本身会展开 `/命令`、skill 和 prompt template。
339
+ - `POST /api/v1/agents/:id/bash` 在 Agent Session 内执行命令(`{command, excludeFromContext}`),输出通过 `bash_execution_update` 事件流式推送并记录到 Session;`POST /api/v1/agents/:id/bash-abort` 取消。
340
+ - WebSocket `/api/v1/ws` 支持 sequence、replay、snapshot、`fromNow` 和 Agent command;`subscribe_collab` 推送协作事件。
189
341
 
190
342
  运行时使用真实 `@earendil-works/pi-coding-agent` SDK,并复用 Pi CLI 的 `~/.pi/agent` 模型、认证和设置。`MockBackend` 只用于自动化测试。
191
343
 
package/dist/agents.js CHANGED
@@ -8,12 +8,22 @@ export class MockBackend {
8
8
  state;
9
9
  messages = [];
10
10
  timer;
11
+ queue = { steering: [], followUp: [] };
11
12
  constructor(agentId, cwd, sessionId = randomUUID()) { this.state = { agentId, sessionId, cwd, status: 'idle' }; }
12
13
  emit(e) { this.emitter.emit('event', e); }
14
+ /** The body of a turn. Tests override it to keep a run streaming while they inspect queued input. */
15
+ runTurn() { return new Promise(r => { this.timer = setTimeout(r, 0); }); }
13
16
  async prompt(message) { if (this.state.status === 'streaming')
14
- throw Object.assign(new Error('Agent is busy'), { code: 'AGENT_BUSY' }); this.state.status = 'streaming'; this.emit({ type: 'agent_start', message }); await new Promise(r => { this.timer = setTimeout(r, 0); }); this.messages.push({ role: 'user', content: message }); this.emit({ type: 'message_start', role: 'assistant' }); this.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: `Mock response: ${message}` } }); this.emit({ type: 'message_end', message: `Mock response: ${message}` }); this.state.status = 'idle'; this.emit({ type: 'agent_end' }); }
15
- async steer(message) { this.emit({ type: 'steer', message }); }
16
- async followUp(message) { this.emit({ type: 'follow_up', message }); }
17
+ throw Object.assign(new Error('Agent is busy'), { code: 'AGENT_BUSY' }); this.state.status = 'streaming'; this.emit({ type: 'agent_start', message }); await this.runTurn(); this.messages.push({ role: 'user', content: message }); this.emit({ type: 'message_start', role: 'assistant' }); this.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: `Mock response: ${message}` } }); this.emit({ type: 'message_end', message: `Mock response: ${message}` }); this.state.status = 'idle'; this.emit({ type: 'agent_end' }); }
18
+ async steer(message) { const queued = this.state.status === 'streaming'; if (queued)
19
+ this.queue.steering.push(message); this.emit({ type: 'steer', message }); if (queued)
20
+ this.emitQueue(); return queued ? 'queued' : 'handled'; }
21
+ async followUp(message) { const queued = this.state.status === 'streaming'; if (queued)
22
+ this.queue.followUp.push(message); this.emit({ type: 'follow_up', message }); if (queued)
23
+ this.emitQueue(); return queued ? 'queued' : 'handled'; }
24
+ async getQueue() { return { steering: [...this.queue.steering], followUp: [...this.queue.followUp] }; }
25
+ async clearQueue() { const cleared = await this.getQueue(); this.queue = { steering: [], followUp: [] }; this.emitQueue(); return cleared; }
26
+ emitQueue() { this.emit({ type: 'queue_update', steering: [...this.queue.steering], followUp: [...this.queue.followUp] }); }
17
27
  async abort() { if (this.state.status === 'streaming') {
18
28
  if (this.timer)
19
29
  clearTimeout(this.timer);
@@ -32,9 +42,16 @@ export class MockBackend {
32
42
  async navigate(_entryId) { return { cancelled: false }; }
33
43
  async fork(_entryId) { return {}; }
34
44
  async extensionResponse(_requestId, _value) { }
45
+ async listCommands() { return [{ name: 'mock', description: 'Mock prompt template', source: 'prompt' }]; }
46
+ async runBash(command, excludeFromContext = false) { const id = randomUUID(), output = `Mock bash: ${command}`; this.emit({ type: 'bash_execution_start', id, command }); this.emit({ type: 'bash_execution_update', id, delta: `${output}\n` }); const result = { id, output, exitCode: 0, cancelled: false, truncated: false }; this.messages.push({ role: 'bashExecution', command, output, exitCode: 0, cancelled: false, truncated: false, timestamp: Date.now(), excludeFromContext }); this.emit({ type: 'bash_execution_end', command, ...result }); return result; }
47
+ abortBash() { }
35
48
  subscribe(listener) { this.emitter.on('event', listener); return () => this.emitter.off('event', listener); }
36
49
  async dispose() { await this.abort(); this.state.status = 'stopped'; this.emitter.removeAllListeners(); }
37
50
  }
51
+ const toStringList = (value) => Array.isArray(value) ? value.filter((x) => typeof x === 'string') : [];
52
+ const emptyQueue = () => ({ steering: [], followUp: [] });
53
+ /** A pending dialog blocks the run until a human answers it, which is a different wait than streaming. */
54
+ const runStatus = (entry, streaming) => entry.dialogRequests.size ? 'waiting_for_user' : streaming ? 'streaming' : 'idle';
38
55
  export class AgentManager {
39
56
  workspaces;
40
57
  factory;
@@ -45,15 +62,65 @@ export class AgentManager {
45
62
  this.workspaces = workspaces;
46
63
  this.factory = factory;
47
64
  }
48
- async create(workspaceId, relativeCwd = '.', sessionFile) { const ws = this.workspaces.get(workspaceId); if (!ws)
65
+ async resolveCwd(workspaceId, relativeCwd = '.') { const ws = this.workspaces.get(workspaceId); if (!ws)
49
66
  throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' }); const cwd = await this.workspaces.resolve(ws, relativeCwd); if (!(await import('node:fs/promises')).stat(cwd).then(s => s.isDirectory()))
50
- throw new Error('cwd is not a directory'); const id = `agent-${randomUUID()}`, backend = await this.factory(id, cwd, sessionFile), now = new Date().toISOString(); const record = { ...(await backend.getState()), workspaceId, createdAt: now, lastActiveAt: now }; this.attach(id, record, backend); return { ...record }; }
51
- connect(entry, backend) { entry.backend = backend; backend.subscribe(event => { const record = entry.record; record.status = event.type === 'agent_start' || event.type === 'auto_retry_start' || (event.type === 'agent_end' && event.willRetry === true) ? 'streaming' : event.type === 'agent_end' || event.type === 'agent_settled' ? 'idle' : record.status; if (event.type === 'session_info_changed')
52
- record.sessionName = typeof event.name === 'string' && event.name.trim() ? event.name.trim() : undefined; record.lastActiveAt = new Date().toISOString(); const item = { id: randomUUID(), sequence: entry.nextSequence++, timestamp: Math.floor(Date.now() / 1000), event }; entry.events.push(item); if (entry.events.length > 1000)
53
- entry.events.shift(); for (const listener of this.listeners.get(record.agentId) ?? [])
54
- listener(item); for (const listener of this.globalListeners)
55
- listener(record.agentId, item); }); }
56
- attach(id, record, backend) { const entry = { record, backend, events: [], nextSequence: 1 }; this.connect(entry, backend); this.agents.set(id, entry); }
67
+ throw new Error('cwd is not a directory'); return cwd; }
68
+ async create(workspaceId, relativeCwd = '.', sessionFile, profile = 'default') { const cwd = await this.resolveCwd(workspaceId, relativeCwd); const id = `agent-${randomUUID()}`, backend = await this.factory(id, cwd, sessionFile, profile), now = new Date().toISOString(); const record = { ...(await backend.getState()), workspaceId, profile, createdAt: now, lastActiveAt: now }; this.attach(id, record, backend); return { ...record }; }
69
+ /** Build a disposable session so the UI sees the models actually available for this project and its extensions. */
70
+ async capabilityPreview(workspaceId, relativeCwd = '.') { const cwd = await this.resolveCwd(workspaceId, relativeCwd), backend = await this.factory(`preview-${randomUUID()}`, cwd); try {
71
+ return await backend.getCapabilities();
72
+ }
73
+ finally {
74
+ await backend.dispose();
75
+ } }
76
+ connect(entry, backend) {
77
+ entry.backend = backend;
78
+ backend.subscribe(event => {
79
+ if (entry.backend !== backend)
80
+ return;
81
+ const record = entry.record, type = event.type;
82
+ if (type === 'agent_start' || type === 'auto_retry_start')
83
+ entry.streaming = true;
84
+ else if (type === 'agent_end')
85
+ entry.streaming = event.willRetry === true;
86
+ else if (type === 'agent_settled')
87
+ entry.streaming = false;
88
+ if (type === 'queue_update')
89
+ entry.queue = { steering: toStringList(event.steering), followUp: toStringList(event.followUp) };
90
+ // Standalone shell runs and dialogs outlive LLM turns. Track them with the
91
+ // event cursor; keep dialog payloads so reconnecting clients can rebuild controls.
92
+ if (typeof event.id === 'string') {
93
+ if (type === 'bash_execution_start' || type === 'bash_execution_update')
94
+ entry.bashIds.add(event.id);
95
+ else if (type === 'bash_execution_end')
96
+ entry.bashIds.delete(event.id);
97
+ }
98
+ if (typeof event.requestId === 'string') {
99
+ if (type === 'extension_ui_request')
100
+ entry.dialogRequests.set(event.requestId, event);
101
+ else if (type === 'extension_ui_response')
102
+ entry.dialogRequests.delete(event.requestId);
103
+ }
104
+ record.status = runStatus(entry, entry.streaming === true);
105
+ if (type === 'session_info_changed')
106
+ record.sessionName = typeof event.name === 'string' && event.name.trim() ? event.name.trim() : undefined;
107
+ this.publish(entry, event);
108
+ });
109
+ }
110
+ publish(entry, event) {
111
+ const record = entry.record;
112
+ record.lastActiveAt = new Date().toISOString();
113
+ // Every event carries the authoritative status, so clients never have to derive it from event types.
114
+ const item = { id: randomUUID(), sequence: entry.nextSequence++, timestamp: Math.floor(Date.now() / 1000), status: record.status, event };
115
+ entry.events.push(item);
116
+ if (entry.events.length > 1000)
117
+ entry.events.shift();
118
+ for (const listener of this.listeners.get(record.agentId) ?? [])
119
+ listener(item);
120
+ for (const listener of this.globalListeners)
121
+ listener(record.agentId, item);
122
+ }
123
+ attach(id, record, backend) { const entry = { record, backend, events: [], nextSequence: 1, bashIds: new Set(), dialogRequests: new Map(), queue: emptyQueue() }; this.connect(entry, backend); this.agents.set(id, entry); }
57
124
  async restore(records) { for (const saved of records) {
58
125
  try {
59
126
  const ws = this.workspaces.get(saved.workspaceId);
@@ -61,8 +128,8 @@ export class AgentManager {
61
128
  continue;
62
129
  const cwd = await this.workspaces.resolve(ws, pathRelative(ws.rootPath, saved.cwd));
63
130
  const sessionModified = saved.sessionFile ? await import('node:fs/promises').then(fs => fs.stat(saved.sessionFile).then(value => value.mtime.toISOString()).catch(() => undefined)) : undefined;
64
- const record = { ...saved, cwd, status: 'unloaded', lastActiveAt: sessionModified && sessionModified > saved.lastActiveAt ? sessionModified : saved.lastActiveAt };
65
- this.agents.set(saved.agentId, { record, events: [], nextSequence: 1 });
131
+ const record = { ...saved, profile: saved.profile ?? 'default', cwd, status: 'unloaded', lastActiveAt: sessionModified && sessionModified > saved.lastActiveAt ? sessionModified : saved.lastActiveAt };
132
+ this.agents.set(saved.agentId, { record, events: [], nextSequence: 1, bashIds: new Set(), dialogRequests: new Map(), queue: emptyQueue() });
66
133
  }
67
134
  catch { /* Keep server startup resilient to deleted workspaces. */ }
68
135
  } }
@@ -74,9 +141,9 @@ export class AgentManager {
74
141
  return entry.backend; if (entry.record.status === 'stopped')
75
142
  throw Object.assign(new Error('Agent is not running'), { code: 'AGENT_NOT_RUNNING' }); if (entry.loading)
76
143
  return entry.loading; entry.record.status = 'starting'; entry.loading = (async () => { try {
77
- const backend = await this.factory(id, entry.record.cwd, entry.record.sessionFile);
144
+ const backend = await this.factory(id, entry.record.cwd, entry.record.sessionFile, entry.record.profile);
78
145
  const state = await backend.getState();
79
- entry.record = { ...entry.record, ...state, status: state.status === 'stopped' ? 'idle' : state.status };
146
+ entry.record = { ...entry.record, ...state, sessionName: state.sessionName?.trim() || entry.record.sessionName, status: state.status === 'stopped' ? 'idle' : state.status };
80
147
  this.connect(entry, backend);
81
148
  return backend;
82
149
  }
@@ -91,33 +158,67 @@ export class AgentManager {
91
158
  return; if (entry.record.status === 'stopped')
92
159
  throw Object.assign(new Error('Agent is not running'), { code: 'AGENT_NOT_RUNNING' }); const backend = await this.ensureLoaded(id); if (kind === 'abort')
93
160
  return backend.abort(); return backend[kind === 'follow-up' ? 'followUp' : kind](message); }
161
+ /** Queued steering and follow-up messages waiting for the running turn. */
162
+ // Both read through to the backend and leave the cache to `queue_update`, which stays correct when a
163
+ // message is queued while these run.
164
+ async queue(id) { const entry = this.get(id); return entry.backend ? entry.backend.getQueue() : { steering: [...entry.queue.steering], followUp: [...entry.queue.followUp] }; }
165
+ /** Drop queued messages and return them so the caller can restore them to its composer. */
166
+ async clearQueue(id) { const entry = this.get(id); return entry.backend ? entry.backend.clearQueue() : emptyQueue(); }
94
167
  events(id, last = 0) { return this.get(id).events.filter(e => e.sequence > last); }
95
168
  currentSequence(id) { return this.get(id).nextSequence - 1; }
96
- async snapshot(id, messageLimit) { const a = this.get(id), messages = a.backend ? await a.backend.getMessages() : []; if (messageLimit === undefined)
97
- return { state: a.backend ? await a.backend.getState() : { ...a.record }, messages, lastSequence: a.nextSequence - 1 }; const page = this.paginateMessages(messages, messageLimit); return { state: a.backend ? await a.backend.getState() : { ...a.record }, messages: page.items, messagePage: page, lastSequence: a.nextSequence - 1 }; }
169
+ snapshotState(id) {
170
+ const a = this.get(id);
171
+ return { ...a.record, activity: { bashIds: [...a.bashIds], dialogIds: [...a.dialogRequests.keys()], dialogRequests: [...a.dialogRequests.values()], queue: { steering: [...a.queue.steering], followUp: [...a.queue.followUp] } } };
172
+ }
173
+ async snapshot(id, messageLimit) {
174
+ // Capture state and its cursor before yielding. Events emitted while messages
175
+ // are being read must be delivered after, not swallowed by, this snapshot.
176
+ const a = this.get(id), state = this.snapshotState(id), lastSequence = a.nextSequence - 1;
177
+ const messages = a.backend ? await a.backend.getMessages() : [];
178
+ if (messageLimit === undefined)
179
+ return { state, messages, lastSequence };
180
+ const page = this.paginateMessages(messages, messageLimit);
181
+ return { state, messages: page.items, messagePage: page, lastSequence };
182
+ }
98
183
  hasReplayGap(id, last) { const a = this.get(id); const current = a.nextSequence - 1; return last > current || (last > 0 && a.events.length > 0 && last < a.events[0].sequence - 1); }
99
184
  subscribe(id, listener) { this.get(id); if (!this.listeners.has(id))
100
185
  this.listeners.set(id, new Set()); this.listeners.get(id).add(listener); return () => this.listeners.get(id)?.delete(listener); }
101
186
  subscribeAll(listener) { this.globalListeners.add(listener); return () => this.globalListeners.delete(listener); }
102
- async dispose(id) { const a = this.get(id); if (a.loading)
103
- await a.loading.catch(() => { }); await a.backend?.dispose(); a.backend = undefined; a.record.status = 'unloaded'; }
104
- async remove(id) { const a = this.get(id); if (a.loading)
105
- await a.loading.catch(() => { }); await a.backend?.dispose(); this.agents.delete(id); this.listeners.delete(id); }
187
+ async dispose(id) {
188
+ const a = this.get(id);
189
+ if (a.loading)
190
+ await a.loading.catch(() => { });
191
+ await a.backend?.dispose();
192
+ a.backend = undefined;
193
+ a.record.status = 'unloaded';
194
+ a.streaming = false;
195
+ a.bashIds.clear();
196
+ a.dialogRequests.clear();
197
+ a.queue = emptyQueue();
198
+ this.publish(a, { type: 'agent_unloaded' });
199
+ }
200
+ async remove(id) { await this.dispose(id); this.agents.delete(id); this.listeners.delete(id); }
106
201
  async archive(id) { const current = this.get(id).record; if (['starting', 'streaming', 'waiting_for_user', 'stopping'].includes(current.status))
107
202
  throw Object.assign(new Error('Active Agent cannot be archived'), { code: 'AGENT_ACTIVE' }); const record = { ...current, status: 'unloaded' }; await this.remove(id); return record; }
108
- async state(id) { return (await this.ensureLoaded(id)).getState(); }
203
+ // The record status, not the backend's, is authoritative: it also knows about pending dialogs.
204
+ async state(id) { const state = await (await this.ensureLoaded(id)).getState(), record = this.get(id).record; return { ...state, status: record.status, sessionName: state.sessionName?.trim() || record.sessionName }; }
109
205
  async messages(id) { return (await this.ensureLoaded(id)).getMessages(); }
110
206
  async messagePage(id, limit = 25, before) { return this.paginateMessages(await (await this.ensureLoaded(id)).getMessages(), limit, before); }
111
207
  paginateMessages(messages, limit, before) { const safeLimit = Math.min(200, Math.max(1, Math.trunc(Number(limit) || 25))), total = messages.length, end = Math.min(total, Math.max(0, before === undefined ? total : Math.trunc(Number(before) || 0))), start = Math.max(0, end - safeLimit); return { items: messages.slice(start, end), total, start, end, limit: safeLimit, hasMore: start > 0 }; }
112
208
  async capabilities(id) { return (await this.ensureLoaded(id)).getCapabilities(); }
113
- async session(id) { return (await this.ensureLoaded(id)).getSession(); }
114
- async sessionInfo(id) { return (await this.ensureLoaded(id)).getSessionInfo(); }
209
+ async session(id) { const info = await (await this.ensureLoaded(id)).getSession(), name = typeof info.sessionName === 'string' && info.sessionName.trim() ? info.sessionName : this.get(id).record.sessionName; return { ...info, sessionName: name }; }
210
+ async sessionInfo(id) { const info = await (await this.ensureLoaded(id)).getSessionInfo(), name = typeof info.sessionName === 'string' && info.sessionName.trim() ? info.sessionName : this.get(id).record.sessionName; return { ...info, sessionName: name }; }
115
211
  async compact(id, instructions) { return (await this.ensureLoaded(id)).compact(instructions); }
116
212
  async setModel(id, provider, modelId) { return (await this.ensureLoaded(id)).setModel(provider, modelId); }
117
213
  async setThinkingLevel(id, level) { return (await this.ensureLoaded(id)).setThinkingLevel(level); }
118
214
  async setSessionName(id, name) { await (await this.ensureLoaded(id)).setSessionName(name); this.get(id).record.sessionName = name.trim(); }
119
215
  async navigate(id, entryId) { return (await this.ensureLoaded(id)).navigate(entryId); }
120
216
  async fork(id, entryId) { const source = this.get(id), result = await (await this.ensureLoaded(id)).fork(entryId); if (!result.sessionFile)
121
- throw Object.assign(new Error('Session persistence is disabled'), { code: 'SESSION_NOT_PERSISTED' }); const ws = this.workspaces.get(source.record.workspaceId); const agent = await this.create(source.record.workspaceId, pathRelative(ws.rootPath, source.record.cwd), result.sessionFile); return { agent, selectedText: result.selectedText }; }
217
+ throw Object.assign(new Error('Session persistence is disabled'), { code: 'SESSION_NOT_PERSISTED' }); const ws = this.workspaces.get(source.record.workspaceId); const agent = await this.create(source.record.workspaceId, pathRelative(ws.rootPath, source.record.cwd), result.sessionFile, source.record.profile); return { agent, selectedText: result.selectedText }; }
122
218
  async extensionResponse(id, requestId, value) { return (await this.ensureLoaded(id)).extensionResponse(requestId, value); }
219
+ async listCommands(id) { return (await this.ensureLoaded(id)).listCommands(); }
220
+ /** Session-scoped bash. Output is streamed as `bash_execution_update` events and recorded in the Session. */
221
+ async runBash(id, command, excludeFromContext = false) { return (await this.ensureLoaded(id)).runBash(command, excludeFromContext); }
222
+ async abortBash(id) { const entry = this.get(id); if (entry.loading)
223
+ await entry.loading; entry.backend?.abortBash(); }
123
224
  }