# Pi 扩展详细参考（中文）

> 本文件说明 BRB 的 Pi 扩展实现，供开发者查阅。
> 产品入口见 README.md / README.zh-CN.md。

---

# BRB（Pi 扩展）

让 Pi 直接驱动**你已登录的 ChatGPT 网页会话**：

```
你的需求 ──> ChatGPT 出方案 ──> Pi 执行 ──> 结果回传 ──> ChatGPT 判断下一步 ──循环
```

BRB 替代“网页出方案 → 手工复制 → Pi 粘贴执行 → 手工复制结果 → 粘回网页”的人工循环。

- 不消耗其它 CLI/IDE 编程助手的订阅额度（走网页端，额度是独立的一套）
- 不修改 Pi 的 provider，Pi 仍用你自己配置的模型执行
- **零运行时依赖**（只用 Node 内置模块）

---

## 使用说明

### 你打的命令

用**普通消息**输入 `brb <动作>`，即可驱动一轮工作。中英文都可以：

| 中文 | English | 作用 | 副作用 |
|---|---|---|---|
| `brb 交接` | `brb handoff` | 把当前进度发给 ChatGPT，取回方案，**不改代码** | 无 |
| `brb 执行` | `brb exec` | 按当前已确认方案执行 → `npm test` → 提交推送 | **需确认** |
| `brb 一轮` | `brb round` | 交接 → 方案 → 执行 → 回执，完整一轮 | **需确认** |
| `brb 状态` | `brb status` | 只读查看 git / 测试 / 会话 / 当前方案 | 无 |
| `brb 新会话` | `brb new` | 申请**新建一条会话**（一次性授权，30 分钟有效）。这是唯一的申请入口；纯 `start` 不会自己建 |
| `brb 会话` | `brb conversations` | 只读列出本桥自动新建过的会话与今日额度；**不会删除任何东西** | 无 |

### 副作用登记表：护栏必须能被强制执行

一个 guard 存在，并不代表它真正**生效**。这个仓库已经有过四次"看起来对、实际是空的"：
派生类型退化成 `string`、`bind` 打印成功却没写注册表、进程级护栏被写进持久配置、
停滞硬上限绕过占位符守卫。四个表面不同的缺陷，同一句话：

> **安全性质只存在于实现者的意图中，却没有成为可执行的 invariant。**

`paths.ts` 是所有 durable 路径的唯一 resolver。测试隔离时，记录类状态会重定向。运行配置读取真实文件后写入隔离副本。会话锁和浏览器 mutex 永不重定向，因为锁必须跟随被保护对象。

`freshness.ts` 是发送边界的新鲜屏障（BUG-012 / DECISION-009）：每次真实 `message.send` 前，都会重新从 registry / config 文件解析目标（绕开长寿命进程的内存快照）；无身份、无绑定、无 id 一律 fail-closed，并在副作用发生前再次复检，封住 attach→send 窗口。

`sideEffects.ts` 把"哪些操作会改变外部状态"从**文档事实**变成**代码事实**。
每个操作声明等级、生命周期、是否外部、是否影响账号、可逆性、是否需要显式意图、
是否需要一次性授权、重试策略、**模糊结果策略**、测试是否允许，以及**执行它的 primitive 名字**。

以下三条让它真正成为约束，而不只是清单：

| 机制 | 它挡住的 |
|---|---|
| `SIDE_EFFECTS` 是 `as const`，`SideEffectId` 由它派生 | 派生自被拓宽的数组会得到 `string` —— 看起来像约束、什么也不约束（BUG-005 的原样重演） |
| `Capability` 带 brand，`requestCapability` 是唯一构造者 | 调用方无法用对象字面量伪造一张授权；`tsc` 拒绝 |
| `assertCapability` 运行时再查一次 | 扩展由 jiti 加载、**不做类型检查**，所以类型只在 `npm test` 生效；这一条挡住强转 |

以及最要紧的一条：**`tests/sideeffects-test.mjs` 会扫描源码**，找出每个 primitive 的调用点，
任何出现在未声明文件里的调用都会让测试失败。这正是能抓住
`rotate-conversation → ChatGptPage.open` 那类旁路的检查。

**两个 Level 2 操作都已接入，而且是结构性的：**

| 操作 | 门 |
|---|---|
| `conversation.create` | `ChatGptPage.open` 拒绝无 id 的 URL；唯一入口 `createConversation(capability)` |
| `message.send` | `page.ask(capability, text, …)` —— **capability 是第一个参数**，漏传就编译不过 |

`assertCapability` 在运行时再查一次（jiti 不做类型检查，类型只在 `npm test` 生效）。
当前 **8 个 `page.ask` 调用点**（扩展内 2 个、工具 6 个）全部经过策略层，
而扫描断言会**指名**任何漏传的调用点 —— 已用变异测试证明。

**测试工具默认没有任何外部写权限** —— 不是"有权限但记得关掉"，而是默认就没有。

**重试策略也统一定义在登记表中，而且只有这一处定义：**

```
retry = never | idempotent | reconcile-before-retry
ambiguousOutcome = 布尔（这个操作的结果**能否**未知）
```

组合不变量：**结果可能未知 ⇒ 不得盲目重试** —— 只有 `reconcile-before-retry`，在**先证明上一次没有发生**之后才允许再执行。

`retryDecision()` 读登记表，而停滞台阶**把策略当输入**（`retryVerdict`），不自己判断。
已用变异测试证明：把 `message.send` 的策略改成 `never` 后，行为会确定性地发生变化，并导致测试失败。
**"未知不等于可以再做一次"** 是这条的原文。

### 权限变更是独立于 level 的另一个维度

`consent.issue` 是**本地、可逆的文件写** —— 按副作用算它是 Level 1。
但它也是这个仓库里**影响最大的一次写入**：正是这次写入使"新建一条会话"成为可能。

所以裁决称之为 **confused-deputy 边界**，并给了与 `level` 正交的字段：

```
authorityEffect = none | grant | revoke
consent.issue   = level 1 + authorityEffect: grant
consent.consume = level 1 + authorityEffect: revoke
```

（用 `revoke` 而不是再加一个 boolean —— 以后撤销权限不需要发明第二个字段。）

**结构性约束**：签发意图需要一张 **`HumanIntentProof`**，而它：

- 带 brand，对象字面量伪造不了；
- 只能由 `mintHumanIntent(source)` 构造，而 `source` 必须是**可枚举的写入者之一**：
  `control-command`（生产路径，人打的控制命令）或 `test-fixture`；
- **`test-fixture` 在非测试模式下被拒** —— 测试夹具可以构造测试状态，
  但**不能变成一条生产后门**；
- `mintHumanIntent` 会**查登记表**：若 `consent.issue` 不再是 `grant`，它就拒绝签发 ——
  这就是 `authorityEffect` 的 consumer，确保它不是一个只记录不用的字段。

签发时把来源记进 `reason`（`…｜由 control-command 签发`），所以审计线能说出**谁**问的。

扫描断言**每个 `issueIntent(` 调用点都传了证明**；唯一允许的例外必须显式标注 `boundary-check:`
（那条测试正是在验证"不传证明会被拒"）。已用变异证明：生产路径去掉证明 → 扫描指名 `index.ts`。

### 所有权在每一层都要声明，但仪式只在 Level 2

裁决把这条边界划得很清楚：

> **Level 1 = "必须声明 ownership"，不等于"必须申请 capability"。**
> Level 1 若也引入 Capability，会把安全仪式**泛化成形式主义**。

所以扫描**遍历全部操作**（Level 0/1/2 一视同仁），而未声明的调用点一律 fail；
但 Level 1 的操作获取 capability 时**不需要证据**，也不得声明 `requiresIntent` / `requiresGrant`。

**空的 `primitives` 列表必须写明理由**（`noPrimitivesBecause`）——
空列表意味着扫描对那个操作什么都没覆盖，而**"没覆盖"与"漏了"从外面看是一样的**。
当前只有两个：`conversation.rotate`（由 create 与本地提交组成，两者各自已被覆盖）与
`diagnostic.read`（只读，没有 mutation 原语可扫）。

已用变异证明：把一个 Level 1 原语（`bindingStore.bind`）放进未声明的文件 → 扫描指名该文件。

**策略字段活性审计**：每个字段要么**有人读**（点名 consumer 与 test），要么**明确标为文档** ——
**没有第三种状态**，因为第三种状态就是缺陷本身（看起来像 enforcement、实际不影响行为）。

### 三闸门：桥不会因为"缺东西"就替你建

自动创建现在只是一条受控的编排路径：未绑定线程在执行真正需要会话的操作时，先按持久策略决定是否询问或进入创建，再由既有创建/握手/提交管线完成。它不改变显式 `/brb bind <url>`、`brb new`、
`/brb start` 的入口语义，也不会在 session start 或 status/doctor/help/config 时触发。

> "没有绑定，所以我可以创建缺的那个前提。"

它把**缺少执行前提**和**获得创建外部资源的授权**混为一谈了。后果很具体：会产生 14 条对话，
而且清理成本由账号所有者承担。

现在这件事被拆成三个都必须回答"是"的问题：

| 闸门 | 问题 | 谁能打开 |
|---|---|---|
| **Intent** | 有人要求这次新建/轮换吗？ | `brb 新会话` 或已确认的自动创建引导 |
| **Grant** | 对这一次创建有有效的一次性授权吗？ | 对应当前操作的一次性 grant，30 分钟失效、消费即销 |
| **Budget** | 即使有授权，保险丝还在吗？ | 无人——它只能拒绝，不能授权 |

**Intent 和 Grant 不能由恢复、重试、并发 start、探测或测试凭空制造。** `autoCreateOnUnbound` 是持久策略：`ask` 会在每个新的未绑定阶段 inline 询问一次，答案只保存在当前 Pi 线程内存；`on` 直接进入既有授权流程；`off` 保持 `NO_BINDING`。每次操作仍单独签发并消费一次 grant。
这才是重点：要防的不是
"某个 bug 多建了一条"，而是"**自动机制自己决定了要创造外部资源**"。

额度保留为**保险丝**而不是政策。它本身不能授予任何权限，只负责防止 bug、循环和竞态
在短时间内创建大量会话。

**为什么会有额度**：未绑定线程第一次开始需要会话的操作可能触发一次自动创建。
这在单次是可控的，累积起来却是负担——账号里会堆满只有一句话的对话，一条条删很烦，
**而批量删除会被 ChatGPT 的安全机制当成可疑行为**。所以额度存在的意义不是限制正常使用，
而是让"堆积"在还小的时候就被看见。额度用完后的处理方式会在拒绝消息中说明。

额度设成 `0` 是测试或排查时的正确选择：BRB 不会继续执行创建流程，也不会对你的账号产生任何影响。

**测试工具默认就带这道闸门。**`scripts/` 下会真正驱动浏览器的工具在加载扩展前把额度压到 `0`。因此，它们在结构上不可能创建会话。只有明确测试“创建会话”时，才可显式放行。
| `brb 回执` | `brb report` | 只把最近结果回传取下一步，不执行 | 无 |
| `brb 停轮` | `brb stop` | 停止自动接力并清除待确认动作 | 无 |
| `brb 确认` | `brb confirm` | 确认高风险动作 | 写 |
| `brb 取消` | `brb cancel` | 取消待确认动作 | 无 |

### `交接` 和 `一轮` 可以带任务正文

```
brb 一轮 把 3d-2 做完并推送
brb round finish the handshake migration
```

正文会成为本轮的**规范请求**，并生成一个 `qid`。规则：

- **只有 `交接/handoff` 和 `一轮/round` 接受正文。** 其余命令多一个词就是普通对话——比如 `brb 状态 X` 不会触发。
- 正文只做**边缘归一化**（CRLF→LF、去掉首尾空白），**内部原样保留**：缩进、Markdown、代码围栏、标点、Unicode 都不动。规范化存在的意义是消除传输差异，不是改变表达。
- 正文**上限 8000 字符**（按 Unicode code point 计）。超限**本地拒绝**：不截断、不摘要、不转发。因为那都会让"你确认的请求"和"ChatGPT 实际收到的请求"不一致。
- 带正文的 `一轮` **不需要已有方案**——方案正是这一轮要去取的。它先创建待确认动作，`brb 确认` 后才执行。
- **新正文会让旧方案立即失效**：如果当前方案对应任务 A，你发 `brb 一轮 B`，在 B 的方案返回前 `brb 执行` 不会执行 A 的方案。
- 不带正文时沿用当前任务；**没有可沿用的任务就本地拒绝**，并告诉你正确用法。

确认绑定为 `会话 + 轮次 + 方案 + qid + 动作 + HEAD + TTL`。所以改正文会让旧确认自动失效。

### 有副作用的动作要二次确认

`执行` 和 `一轮` 不会立刻动手，而是先创建一个**待确认动作**，绑定当前会话与仓库状态：

```
你：brb 一轮
pi：[控制] brb 一轮 是有副作用的动作，需要确认。
    待确认：round sid=a1b2... r=3 plan=8f2c1d... head=04069bd
    确认：brb 确认  |  brb confirm

你：brb 确认
pi：[控制] 确认通过，开始 round。
```

如果这期间**方案、轮次、会话或仓库 HEAD 发生变化**，确认会失效并拒绝执行。这是为了防止"你看过方案之后仓库动过，确认却执行了另一份方案"——这种 stale confirmation 比误触发更难发现，因为一切看起来都正常。

### 匹配规则：整条消息精确匹配

**`brb` 不是保留字，只有上面 16 条完整命令被保留。** 所以日常讨论这个项目本身不会被拦截：

```
brb 状态              → 命令
brb status            → 命令
brb 状态 详细一点      → 普通对话（状态不接受正文）
brb 一轮 把 3d-2 做完   → 命令（一轮接受正文）
brb status please     → 普通对话
brb 状态机还没接好     → 普通对话
我看了 BRB 的实现          → 普通对话
brb statuss           → 普通对话
继续 / 执行 / 状态 / 停   → 普通对话
```

规则细节：

- **只有 `交接` 与 `一轮` 有正文位**，其余命令多一个词就是普通对话——一个到处接受尾巴的命令必须猜命令在哪里结束
- 只接受 ASCII 空格/制表符；不做 Unicode 归一化、不做模糊匹配、不接受同义词
- 必须单行。粘贴的日志里出现 `brb 状态` 不会触发
- 判断由 `control.ts` 的确定性 parser 做，**在模型看到消息之前**（`pi.on("input")` 钩子）。不依赖模型理解

### 两个已废弃的前缀

如果你看到「旧写法已废弃」的提示，原因如下。两者都会被**只读提示**一次并指向新写法，但**都不是可用的别名**——一个有真实副作用的控制面保留第二种能工作的拼法，歧义就会回来。

| 前缀 | 废弃原因 |
|---|---|
| `>>` | **它是 Markdown 的嵌套引用语法。** pi 的 TUI 把用户消息按 Markdown 渲染，所以 `>>一轮` 显示成 `│ │ 一轮`，用户完全看不出自己打了什么。parser 是对的，真机体验是坏的 |
| `桥控` | 非中文用户不可用 |

第一次设计留下的教训值得记住：当时只审了"输入分发"和"语法"两层，漏了"显示渲染"层。所以任何输入语法冻结前应该走一遍这 7 层：

```
Dispatch → Grammar → Render → IME → Transport → Device → Safety
```

### 两个入口的区别

| 入口 | 谁处理 | 场景 |
|---|---|---|
| `brb <动作>` （普通消息） | 输入钩子拦截 → 驱动 agent | 日常推进工作 |
| `/brb <子命令>` （斜杠命令） | 扩展直接执行 | 排查问题、手动操作 |

## 快速开始

### 1. 准备自动化浏览器

```
/brb launch
```

需要 ChatGPT 的 BRB 操作会自行尝试启动自动化浏览器；`/brb launch` 仍可用于手动启动或排障。浏览器必须在**带调试端口**的情况下启动：已经开着的浏览器无法事后接管，Chrome 136+ 还只在你显式传 `--user-data-dir` 时才认这个端口。所以有两种模式：

| 模式 | 登录 | 隔离性 | 适用 |
|---|---|---|---|
| `dedicated`（默认） | 在自动化窗口里登录**一次** | 完全独立，不干扰日常浏览器 | 推荐 |
| `existing` | 不用登录，直接复用真实 profile | 与日常浏览器共用，需先完全退出该浏览器 | 想让桥接用现有登录态 |

`dedicated` 不损失任何东西：ChatGPT 会话历史存在服务端，在自动化 profile 里打开同一个会话 URL 就是同一个线程。

`/brb launch` 启动或复用浏览器后，会先判断当前文档是否是正常的 ChatGPT 页面：浏览器错误页或连接失败会提示检查网络或代理（配置了代理时会列出地址），不会误报成登录问题；正常页面出现登录界面才提示手动登录；已经登录但编辑器尚未挂载时会提示页面仍在准备。无法确认时停止操作，可运行 `/brb doctor` 查看状态；不会自动重试、创建或发送。

### 2. 体检

```
/brb doctor          # CDP / 编辑器 / 发送按钮 / 复制源 / 剪贴板钩子
/brb sources         # 列出最新那一轮可用的所有复制源
```

### 3. 绑定会话（可选）

```
/brb bind            # 多标签页时让你选；只有一个时直接绑定
```

不绑定也能开始需要会话的操作：默认 `ask` 会在每个新的未绑定阶段 inline 询问一次；`on` 直接进入既有授权流程，`off` 保持 `NO_BINDING`。用 `/brb auto-create ask|on|off` 设置策略；仍可随时用 `/brb bind <url>` 手动绑定。

### 4. 开始推进

```
brb 一轮
```

## 绑定：哪个 pi 线程用哪个 ChatGPT 会话

持久化的不是"一个全局会话"，而是：

```
本地 pi 线程  ↔  Bridge Workstream (bindingId)  ↔  ChatGPT 会话
```

- **主键是 pi session id**，不是 cwd、也不是会话名。`cwd` 只用于分组——实测过 session **查找**是按 cwd 分目录的（换目录 `-c` 会落到另一个会话），所以用 cwd 做路由会以完全相同的方式串线。
- **`bindingId` 是 workstream 身份**，每个 binding 有**自己的 runtime state 文件**（rounds / checkpoint / 锁）。所以换了会话、重开 pi，进度不会丢。`/brb status` 会显示可选 provenance：`source=manual|auto|start`；老记录显示 `legacy/unknown`，不做迁移。
- **一个会话只能被一个线程绑定。** 两个线程绑同一会话会被拒绝并报出持有者——这正是这个模型要堵的 bug。

```
/brb bind            # 把当前线程绑到当前标签页的会话（多标签页时会让你选）
/brb bind <url>      # 绑到指定会话
/brb status          # 看本线程绑的是哪个、以及全部绑定
```

没有绑定时，`ask` / `plan` / `read` / `copy` / `sources` / `start` 只在 `autoCreateOnUnbound` 的 `ask|on` 策略允许后进入既有创建管线；`ask` 的确认只对当前新的未绑定阶段有效，成功绑定或 `/brb unbind` 后会清除。绝不采用“当前标签页”。状态/doctor/help/config 等只读操作不会触发创建或启动浏览器。明确给出的 URL 始终优先，失败不会回退新建。`/brb bind <url>` 只接受 `https://chatgpt.com/c/<id>` 或项目作用域 `https://chatgpt.com/g/g-p-…/c/<id>`；无法确认时停止操作，先拒绝且不写入注册表。`source=manual` 或 `origin=explicit` 的 `PROVISIONAL` 会显示“手动绑定”，不冒充“自动创建”。

自动路径仍逐次经过 `conversation.create` capability、Intent/Grant/Budget 三闸门、无消息候选握手、身份/所有权校验和注册表提交。若已有 ChatGPT 页面可供只读观察，BRB 会先确认账号身份；无法确认时显示“身份 UNKNOWN：创建前无法验证当前登录账号；不打开新会话、不提交绑定，也不自动重试。”，并在打开 ChatGPT 会话前停止操作。没有可观察的既有页面时，才在创建后复核；复核失败会关闭本次打开的页面并保留既有 orphan 记录。新页尚无 `/c/<id>` 时以 CDP `targetId` 绑定为 `PROVISIONAL`。`/c/WEB:<uuid>` 只是 draft route（`conversationIdOf()` 仍为 null），不会进入 ownership/affinity key；首次 SEND 在同一 pinned target 上记录 `materializationPending`、operation id 和提交时间，然后在有界窗口内等待 durable `/c/<id>`。超时保持 PROVISIONAL，不重发、不再 CREATE，固定提示为“消息已提交，但新会话身份尚未完成 materialize。BRB 将保持 PROVISIONAL，并在下一次操作前重新检查。不会自动重发。”下一次操作先 reconcile，再按 lineage、所有权和 affinity 原子升级为 `BOUND`。创建前会固定 target 快照，legacy/当前标签页/扫描结果永不替代它；不确定结果会登记带状态的 orphan，既不重试也不声称已绑定，确定性无效目标不登记 orphan。`/brb orphan clear <id>` 只解除本地阻断，不删除远端可能存在的会话。

绑定存在 `~/.pi/agent/chatgpt-web-bridge-bindings.json`；每个 binding 的状态在 `~/.pi/agent/chatgpt-web-bridge-state/<bindingId>.json`。

## 一轮循环里发生了什么

```
① pi 发 HELLO 建立握手      →  ChatGPT 回 ACK，校验 sid / 版本 / 契约 hash / 能力 hash
② pi 发 ROUND（一行 FRAME + 回执 + 仓库摘要）
③ ChatGPT 回一份 PLAN（含 FRAME、DECISION、STEPS、FILES、COMMANDS…）
④ pi 做协议闸门校验
   ├─ 通过        → 执行
   ├─ 结构错误     → 发 REPAIR，要求同一轮重发（最多 2 次）
   └─ 采集不可信   → 禁止执行，但**不**发 REPAIR（这是 pi 自己的读取问题）
⑤ pi 执行 → 跑测试 → 提交推送
⑥ 回执发给 ChatGPT，回到 ②
```

终止条件由 ChatGPT 的 `DECISION` 与 6 条独立边界共同决定，详见 [`docs/protocol/WIRE-SPEC.md`](docs/protocol/WIRE-SPEC.md)。

## `/brb` 斜杠命令

| 命令 | 作用 |
|---|---|
| `/brb` 或 `status` | 浏览器状态、绑定会话、循环状态、边界状态 |
| `/brb launch` | 按当前模式启动自动化浏览器 |
| `/brb auto-create [ask\|on\|off]` | 查看或设置未绑定自动创建策略 |
| `/brb mode dedicated\|existing` | 切换运行模式 |
| `/brb open [url]` | 在自动化浏览器里打开 URL |
| `/brb bind [url]` | 绑定会话；指定 URL 必须是 `chatgpt.com` 的 `/c/<id>` 或 `/g/g-p-…/c/<id>` 会话 URL |
| `/brb doctor` | 三层诊断：LOCAL 始终先输出；BROWSER/CDP 只读探测；仅 CDP 可达时再做 PAGE 诊断。不可达或无法附着时降级，不启动浏览器 |
| `/brb ask <text>` | 发消息 + 等待 + 显示回复 |
| `/brb sources` | 列出最新那一轮的复制源（含索引、类型、标签） |
| `/brb copy [index]` | 点一键复制取方案，可指定源索引 |
| `/brb plan <task>` | ask + copy，方案填入输入框 |
| `/brb start <task>` | 启动自动接力 |
| `/brb stop` | 停止自动接力 |
| `/brb resume` | 断线后判定恢复策略（只报告，不自动执行） |
| `/brb kill` | 关闭桥接启动的浏览器 |
| `/brb config [json]` | 查看 / 增量修改配置；逐键报告已应用、环境变量管理而忽略、未知或无效字段；只读回显标注环境变量来源 |
| `/brb orphan clear <id>` | 将 PENDING 创建残留标记为已丢弃；只解除本地阻断，不删除远端会话 |

未知 slash 子命令的用法清单从 `BRB_COMMANDS` 按 `order` 派生。`状态/会话/停轮/回执/取消/确认/交接/一轮/执行/新会话` 及其 English aliases 是 input hook 控制命令，使用 `brb <动作>`，不属于 `/brb` slash 分发。

## LLM 工具接口

Pi 里的模型也可以直接调用 `brb` 工具：

```
brb { action: "plan", text: "把结果反馈给 ChatGPT 并取下一步" }
brb { action: "ask" | "read" | "copy" | "sources" | "status" | "doctor" | "bind" }
brb { action: "copy", index: 0 }            // 指定复制源索引
brb { action: "copy", label: "^复制回复" }   // 按标签正则选源
```

## 配置

`~/.pi/agent/chatgpt-web-bridge.json`（首次运行自动生成）

| 字段 | 默认 | 说明 |
|---|---|---|
| `port` | `9222` | CDP 调试端口 |
| `browser` | `chrome` | `chrome` / `edge` / `auto` |
| `profileMode` | `dedicated` | 见上文 |
| `profileDir` | `~/.pi-chrome-profile` | dedicated 模式的 profile 目录 |
| `profileDirectory` | `Default` | existing 模式下用哪个 profile |
| `conversationUrl` | — | 绑定的会话 URL |
| `copyMode` | `auto` | `auto` = 代码块 → 整轮 → 任意 → DOM；也可强制 `code` / `turn` / `dom` |
| `copyLabel` | — | 按标签正则强制指定复制源，优先级高于 `copyMode` |
| `inputMode` | `trusted` | `trusted` = 真实 Delete 键 + `Input.insertText`，页面看到的事件全是 `isTrusted=true`；`paste` 略快但合成事件 `isTrusted=false` |
| `maxIterations` | `8` | 接力轮数上限（边界判据负责停止，不再需要留大余量） |
| `maxNewConversationsPerDay` | `3` | 桥**自己**新建会话的额度（滚动 24 小时）。计入自动创建的绑定、`PENDING` 创建残留，以及带会话 id 的已解决残留；已清除且没有会话 id 的残留不计入。设为 `0` 就彻底不建，只能绑定已有会话 |
| `autoCreateOnUnbound` | `ask` | 未绑定线程需要会话时：`ask` 在每个新的未绑定阶段询问一次，答案只在当前 Pi 线程内存；`on` 自动进入（仍受逐次授权/额度约束）、`off` 保持 `NO_BINDING`；旧配置缺失按 `ask` 读取 |
| `promptTokens` | `6000` | 单轮 ROUND 的 token 预算 |
| `digestTokens` | `2000` | 每轮仓库摘要的 token 预算 |
| `waitTimeoutMs` | `900000` | 单轮等待上限 |
| `submitDelayMs` | `1500` | 提交后等待页面反应的间隔 |
| `donePattern` | 见代码 | 额外的停止标记 |
| `resultTemplate` | 见代码 | 回执模板，占位符 `{{iteration}}` `{{tools}}` `{{result}}` |

```
/brb config {"digestTokens":500,"maxIterations":15}
```

## 协议

**[`docs/protocol/WIRE-SPEC.md`](docs/protocol/WIRE-SPEC.md)** 是线上协议的完整文档：三层版本号、四个裁决域、会话身份与 canonical 哈希、HELLO / HS / ROUND / RESUME / REPAIR / RESYNC 报文（样本由真实 builder 生成）、错误码与归因域、发送状态三值语义、checkpoint 规则、恢复决策表。

那份文档**不是第二份真相**：权威值始终是 `handshake.ts` / `protocol.ts` 里的常量。`npm test` 会读取文档并核对其中引用的每个常量——改代码不改文档会直接让测试失败。

三层版本号各有职责：

| 常量 | 值 | 管什么 |
|---|---|---|
| `WIRE_ID` | `pi-bridge/1` | 只管 framing |
| `HANDSHAKE_VERSION` | `1` | 协商语义 |
| `PLAN_CONTRACT_VERSION` | `2` | PLAN 正文 |

**一个关键设计**：`done` 是终态而不是 checkpoint。PLAN 回复本身只证明方案写出来了，**不证明它执行过**——所以远端确认走的是**下一轮 ROUND 的 `prev=`**，未确认的方案在恢复时绝不自动重放。

## 当前状态

**已真机验证：**

- HELLO → ACK 校验 → 会话建立
- 契约 v2 稳态（实测 8 轮 token：旧 ~40800 → 新 ~16804，**省 59%**）
- 协议闸门 → REPAIR → 对方纠正格式后重发
- 从真实对话历史做 frame discovery 并给出恢复决策
- `brb <动作>` 控制语法的 pass-through 边界

**仅离线验证（策略正确，但未在真实路径生效）：**

- `planRecovery()` 的自动触发——目前断线**需要手动** `brb resume`
- 每轮自动 commit + push 尚未接入

**测试全部离线，`npm test` 一次跑完，当前全绿。** 各套件的具体断言数由 `npm test` 自己打印——这里不列数字，以免文档比代码先过期。

| 套件 | 覆盖 |
|---|---|
| `scripts/release/check-imports.mjs` | 跨模块引用是否都已导入、是否有残留未使用导入 |
| `tests/runtime-test.mjs` | 运行时要求：**「进程发不出请求」必须与「端点不可达」分开**，否则会报出并不存在的成因 |
| `tests/bugs-test.mjs` | **登记表自身的账**：状态摘要由条目派生并逐字校验、编号升序、PASS 必须带证据档位、待验项必须可勾 |
| `tsc --noEmit`（`npm run check:types`） | **类型检查**。扩展由 jiti 转译、不做检查，所以在此之前源码里的类型约束**没有东西在执行** —— 已两次致害 |
| `tests/protocol-test.mjs` | 协议解析、仓库摘要、护栏、循环边界 |
| `tests/handshake-test.mjs` | 握手建立、版本与 hash、FRAME 关联、REPAIR / RESUME、终态、恢复状态机 |
| `tests/store-test.mjs` | 共享状态存储、writer 锁、陈旧锁接管、方案新鲜度判定 |
| `tests/binding-test.mjs` | 线程↔会话绑定注册表、一会话一 owner 不变量、per-binding 状态路径 |
| `tests/mutex-test.mjs` | 全局浏览器资源（剪贴板）的短时跨进程互斥、陈旧锁接管、超时行为 |
| `tests/consent-test.mjs` | 三闸门：Intent / Grant / Budget 的与关系、一次性与时效、额度不能单独授权 |
| `tests/sideeffects-test.mjs` | 副作用登记表：策略、capability 不可伪造、**权威路径扫描**（新增旁路会让它失败） |
| `tests/negative-test.mjs` | **canonical negative tests**：在**内存副本**上注入违规，断言对应不变量确定性 RED；类型层用隔离 compile fixture |
| `tests/delivery-test.mjs` | 投递校验：计数加一才算送达、「没送到」与「回复还没来」的分界、失败文案与台阶的往返 |
| `tests/identity-test.mjs` | 消息身份判据：窗口滑动的八组向量（T1–T8）、选轮按最高轮号而非文档顺序、无 id 时 fail-closed |
| `tests/copysources-test.mjs` | 复制源分类：四种属性组合、有代码块时的两个按钮、消歧规则的边界 |
| `scripts/live/rounds-check.mjs` | 真实交付多轮：每轮独立做消息身份校验（id 是新的 + 逐字一致 + 含本轮 token） |
| `scripts/live/stop-recovery-check.mjs` | BUG-007 真机：长思考中途点停止，断言不返回裸占位符 + 下一轮恢复（2 条真实消息） |
| `scripts/live/misroute-check.mjs` | BUG-013 真机：页面偏离目标会话时 ask 必须在提交前拒绝且不发送任何东西（0 消息成本） |
| `tests/drift-test.mjs` | A5+A16：binding provenance（revision/updatedAt/updatedBy）、注册表写锁串行化、drift 观察语义（首观察不报/变化报一次/不重复报）+ 结构断言 |
| `tests/scenarios/drift-scenario.mjs` | A5+A16 LIVE 场景：同进程两阶段观察 + 另一真实 OS 进程 canonical 写入（写锁竞争、drift from/to、config 未重启生效；沙箱状态根，0 消息） |
| `tests/page-handle-lifecycle-test.mjs` | BUG-020/023/027 家族：每个页面获取站点都必须在自己路径上关闭 CDP 会话、`carriedPage` 在停止/关会话路径上收拢、且没有任何 `open/createConversation` 丢弃返回值（结构性钉住，防止第四次复发） |
| `tests/recovery-test.mjs` | A13：recovery 约束（registry 单源/effect 引用/预算跨 kind 不重置/reload safety/absorbing give-up）；`--live` 跑一次无消息 browser-gone→relaunch |
| `tests/read-routing-test.mjs` | BUG-024/O1/O2（裁决 #38）：读取类命令（sources/copy/read/resume）按本线程绑定解析、无绑定 fail-closed、未定型页面不当空历史、doctor 保留 allowCurrentTab、`/brb open` 复用同会话标签；含隔离根行为测试 |
| `tests/composer-readiness-test.mjs` | BUG-028（裁决 #38）：用脚本页驱动真实 `ask()`——编辑器就绪等待在 baseline 之前；超时 → 有界失败且零远端副作用；通用/登录错误不退化 |
| `tests/run-test.mjs` | A3：RunContext 生命周期 / classifyFailure 矩阵（协议错误 terminal）/ give-up 事件契约 / Canary 只读 seam / round identity |
| `tests/diagnostics-test.mjs` | A18：诊断数据最小化+secret redaction（Bearer/sk-/query token/上下文 hex 掩码；协议身份保留；give-up 事件白名单） |
| `tests/authorization-test.mjs` | A10+A12：consume 单事务原子性/restore 唯一归还/过期拒耗；capability 单操作作用域/已耗 grant 不可 replay/test-policy 矩阵 |
| `tests/crash-consistency-test.mjs` | A9：崩溃一致性（原子写 temp/rename、损坏如实报告不静默重置、stale 锁接管、orphan 兜底、current+predecessor 单写） |
| `tests/durable-policy-test.mjs` | A8+A15+A19：ambiguousOutcome 统一 reconcile-before-retry；诊断纯读无写；schema 版本/未来版本不崩溃/legacy 容忍 |
| `scripts/live/a3-live-cases.mjs` | A3 LIVE（真机、0 消息、需运行中的自动化浏览器）：`live-a` 瞬态失败→轻恢复→run 继续；`live-b` 持续失败→阶梯跑满→give-up→吸收→显式新 run 新 runId |
| `tests/identity-resource-test.mjs` | A11+A14：三身份轴新鲜度（消息 id 集合/会话 id 稳定/revision 单调）+ 锁陈旧语义唯一 + 盘点 stale/held/stray |
| `tests/a17-test.mjs` | A17：principal 提取/affinity 三态矩阵/发送前屏障（无绕过）/bind 时验证/不自动解绑 |
| `scripts/live/a17-switch-check.mjs` | A17 账号切换判别（人在场）：phase0 基线 → 人工切账号 → phase1 观察变化 → 切回 → phase2 恢复（0 消息） |
| `tests/brb-commands-test.mjs` | BRB 斜杠补全（对标 qbtn）：`BRB_COMMANDS` 单一 metadata / dispatch 集合相等（当前命令表）/ 单测矩阵 / completer 纯函数 |
| `tests/onboarding-test.mjs` | onboarding（裁决 #17）：classify 新/老用户静默迁移、7 步状态机、pushPolicy 语义、真实 git 仓库集成（init/bare remote/ahead/dirty） |
| `tests/autobind-onboarding-test.mjs` | BRB-AUTOBIND-ONBOARDING（#42-B）：T01–T21 自动触发、一次确认、额度/能力闸门、orphan/single-flight、握手/身份/所有权与 legacy freshness 矩阵；全程离线、0 条真实消息 |
| `tests/autobind-browser-preflight-test.mjs` | BUG-035/036/040：隔离状态根（`stateRoot()` ✗，来自环境变量开关 ✗）与真实 `load-ext` 门验证 `ask` 的阶段内存、`auto-create` 持久策略、拒绝与诊断命令不启动浏览器，以及已绑定启动器抛错仍以结构化降级退出 |
| `tests/doctor-offline-test.mjs` | BUG-037/042：doctor 的 LOCAL → BROWSER/CDP → PAGE 分层；完全离线、CDP 可达但页面无法附着时有界降级、以及 conversations 离线只读 |
| `tests/user-visible-brand-test.mjs` | BUG-041 遗留门禁：扫描每个 `src/**/*.ts`，拒绝回流已退役的用户可见品牌文本；既有冻结的环境与状态文件标识符不受影响 |
| `tests/qa-defects-regression-test.mjs` | BUG-038/039：用法由 `BRB_COMMANDS` 派生且说明 input hook；非 ChatGPT URL 零注册表写入，普通和项目作用域会话 URL 可绑定，手动 provenance 如实显示 |
| `tests/bug044-config-quota-truth-test.mjs` | BUG-044 / R6：真实 slash/input handler + 隔离状态根；环境变量管理的配置逐键回执与只读来源标记，以及自动创建绑定、PENDING 残留、已清除无 id / 有 id 残留的 24 小时额度口径 |
| `tests/bug045-page-state-attribution-test.mjs` | BUG-045：真实 `/brb launch` handler + 隔离状态根和离线 CDP 页；错误页/不可达、正常未登录、已登录未就绪三态互斥，另有 READY 与 UNKNOWN 控制组；代理指引、零新页和单次只读探测，并防止 `ask-file` 绕过共享分类 |
| `tests/bug030-target-identity-test.mjs` | BUG-030（裁决 #43/#44）：N1–N11 target lineage、INVALID_TARGET、PROVISIONAL→BOUND、orphan 状态机与 single-flight；独立离线回归 |
| `tests/bug031-web-draft-materialization-test.mjs` | BUG-031（裁决 #46）：W01–W16 DRAFT_WEB 分类、同 target materialization lineage、有界超时、reconcile-before-send、原子升级与 fail-closed ownership/legacy/active-tab 边界；独立离线回归 |
| `tests/bug043-identity-first-test.mjs` | BUG-043：已有 ChatGPT 页面但账号身份 UNKNOWN 时，真实 slash handler 在创建前拒绝；断言未调用新标签页端点，且进度不声称已创建 |
| `tests/shadow-test.mjs` | T0 shadow 采样：默认关/config 或 env 开、采集时脱敏（A18）、JSONL 追加与失败静默、provenance authority=none、不触碰 loop 决策面 |
| `scripts/live/bind-tool-check.mjs` | 绑定验收：走**工具动作**入口、检查磁盘注册表、另起进程确认 `status` 认得它 |
| `tests/driver-test.mjs` | 协议驱动核心：握手门、闸门三分支、REPAIR 循环、settle 与终态 |
| `tests/control-test.mjs` | 控制命令 parser、精确匹配边界、确认绑定 |
| `tests/control-hook-test.mjs` | 输入钩子接线、pass-through 行为 |
| `tests/tool-status-test.mjs` | 工具 ask/plan 底部状态接线（裁决 #25）：formatter 共享、onTick 动态进度、finally 清状态、非 ask/plan 无进度循环 |
| `tests/idle-status-test.mjs` | Idle badge（裁决 #26）：四态文案、两层协调器（transient 覆盖/restore 恢复）、session 隔离、无 timer 结构断言 |
| `tests/reply-lifecycle-test.mjs` | BUG-018 回复生命周期回归：streaming 从未观测时前缀不得返回（sawStreaming + 4x 窗口 + copyTurnFound 独立信号）、正常快速路径不回归、超快回复 fallback、stall 兑底；含变异反证 |
| `scripts/release/check-docs.mjs` | 文档与代码一致性（含本页引用的配置默认值） |

## 开发与诊断工具

`tools/` 里的脚本**不经过 pi**，而是直接驱动浏览器或加载扩展。修改选择器或检查状态时，无需反复启动 pi。

```bash
npm install          # 只装 jiti（扩展本身零依赖）

npm run status       # 桥接状态
npm run doctor       # 深度体检
npm run sources      # 列出最新轮的复制源
npm run e2e          # 临时标签页跑一次完整往返
npm run smoke        # 35 项冒烟：风控信号 / 多复制源 / 超长文本 / 连续复制
npm run test:sources # 32 项：复制源归属、剪贴板防护、跨轮隔离
npm run test:loop    # 验证自动接力循环（不消耗模型额度）
npm test             # 全部离线套件
npm run tabs         # 列出自动化浏览器标签页
npm run map          # 把页面上的复制按钮映射回所属轮次
npm run models       # 探测模型/思考强度菜单结构
```

| 脚本 | 用途 |
|---|---|
| `tests/support/load-ext.mjs --door <slash\|input\|tool> [-- <载荷> \| --file <路径>]` | 用 mock pi 加载扩展，经**显式选择的门**驱动真实生产入口；无参 = 仅自检描述 |
| `tests/support/harness.mjs` | load-ext 与测试共用的 harness 核心：identity + 隔离两层保护，可 import |
| `tests/paths-test.mjs` | state-root 三类根（记录重定向 / 配置 overlay / 资源锁不动） |
| `tests/browser-discovery-test.mjs` | 浏览器发现与选择（仅 Chrome 与 Edge；粘性解析；显式 executable 的 UNSUPPORTED；profile 隔离） |
| `tests/browser-port-policy-test.mjs` | 调试端口归属分类（FREE / BRB_CDP / FOREIGN_CDP / NON_CDP；只连接自己启动的实例；绝不结束他人进程） |
| `tests/browser-proxy-attribution-test.mjs` | 代理可达性与失败归因（代理不可达不得归因成"未登录"） |
| `tests/browser-command-test.mjs` | `/brb browser` 只读视图与显式改选清除粘性解析（与 `/brb config browser=…` 同源） |
| `tests/harness-test.mjs` | harness self-test：载荷保真、三门到达、无身份写 fail-closed、默认运行不碰真实状态 |
| `tests/freshness-test.mjs` | BUG-012 send 边界回归：不重启进程改 durable state，断言旧目标永不用于发送（spy 级） |
| `scripts/live/cdp-poke.mjs <tabs\|eval\|nav\|html>` | CDP 通用探针，调选择器时最常用 |
| `scripts/live/copy-map.mjs` | 分析复制按钮与轮次容器的从属关系 |
| `scripts/release/check-imports.mjs` | 交叉核对跨模块引用是否都已导入 |
| `scripts/live/nav-probe.mjs <url>` | 带加载事件的导航，排查页面卡住 |
| `scripts/live/ask-file.mjs` | 把文件里的长提示词发进会话并取回方案 |

所有脚本按自身位置解析路径，克隆到任何目录都能跑。硬性前提是浏览器已由桥接启动（`/brb launch`）。

## 风控与运行环境（实测，非推测）

`scripts/live/smoke-test.mjs` 会直接读取页面侧的探测信号：

| 信号 | 实测值 |
|---|---|
| `navigator.webdriver` | `false`（未传 `--enable-automation`） |
| UA 里是否有 headless 标记 | 无 |
| `window.chrome` | 存在 |
| `navigator.plugins` / `languages` | 5 个插件 / `zh-CN,zh` |
| 窗口尺寸 | 1418x940（真实窗口） |
| DevTools 控制台探测陷阱 | 未触发 |
| **本扩展注入事件的 `isTrusted`** | **全为 true（`inputMode: trusted` 下无 untrusted 事件）** |

判断：

- **网络/TLS 层零额外风险。** 所有流量走这个真实浏览器自己的网络栈，不额外发 HTTP——**不调用** `chatgpt.com/backend-api/conversation`。Cloudflare 的 `cf_clearance` 绑 IP+UA，复用同一 profile 因而持续有效。
- **页面 JS 层原本有一处暴露**：合成 `paste` 事件的 `isTrusted === false`。改用 `inputMode: trusted` 后该暴露消失。
- **仍然存在的差异**：没有鼠标轨迹、没有滚动、消息间隔短。这是**行为层面**的异常，单点检测不到，但账号侧行为分析能看出来。建议别长时间无人值守密集跑。
- **条款层面**：自动化操作网页 UI 不是官方支持的用法，风险自负。

## 窗口位置与数据来源

**窗口位置、层级、遮挡、最小化都不影响功能。** 脚本读取的是页面 DOM，而非任何后台接口。

- 启动参数里的 `--disable-backgrounding-occluded-windows` / `--disable-background-timer-throttling` / `--disable-renderer-backgrounding` 就是防止 Chrome 在遮挡时节流渲染
- **唯一例外**：走真实剪贴板兜底读取时需要把标签页调到前台
- **注意标签页丢弃**：Chrome 的"内存节省"会卸载后台标签页。长时间运行时建议关闭它

## 已知限制

- **浏览器必须由桥接启动**。`existing` 模式需要先完全退出该浏览器
- `/brb copy` 走真实剪贴板兜底时会覆盖你当前的剪贴板内容
- 代码块的「复制」按钮偶尔无法捕获，此时会退回读取 DOM（`via` 会标 `dom-fallback`，内容等价）
- **不要指望 `[data-testid="send-button"]` 的 `.click()`**：实测它可以在按钮存在且 enabled 的情况下完全不生效。提交以 trusted Enter 键为主，点击只作兜底，且每次提交都会校验"输入框是否清空 / 是否出现新一轮"
- **断线不会自动恢复**：需要手动 `brb resume`
- 选择器依赖 ChatGPT 当前 DOM；页面改版后，先运行 `/brb doctor` 和 `/brb sources`
- 只在 Windows 上验证过（Chrome 152 / Node 24）
- **缺陷台账权威在 [`docs/internal/BUGS.md`](docs/internal/BUGS.md)**（含每条的状态/证据档位/验收项，由 `bugs-test.mjs` 强制总览与条目一致）。本页不维护第二份编号状态表——以避免再次漂移。当前规模：17 条（以台账实时为准）

## ⚠️ 已知故障模式：渲染进程假死（真实发生过）

标签页完全卡住，CDP `Runtime.evaluate` 超时，**人也无法滚动页面**。

触发条件（按贡献度）：

1. **一次性粘贴超长提示词**。当时一次向输入框粘贴 36k 字符。ProseMirror 要在单个事务里解析，随后 React 要重渲染整条线程。**这是主因。**
2. **固定 500ms 轮询**。每秒两次序列化整棵 DOM，在长会话中会持续造成压力。
3. 反复 attach / `Page.bringToFront` / 重开标签页。

对应防护：

| 防护 | 做法 |
|---|---|
| 输入分块 | `chunkForInput()` 按行切块，**单块上限 2000 字符**，块间让出事件循环。单个超长行也会被硬切 |
| 轮询退避 | 10s 内 500ms → 60s 内 1.2s → 3 分钟内 2.5s → 之后 4s |
| 提示词预算 | `promptTokens` / `digestTokens` |

**卡住之后的恢复动作**（会话在服务端，重载无损）：

```bash
# 让浏览器进程重载标签页 —— 由浏览器进程处理，通常能绕过卡死的渲染进程
node --input-type=module -e "
const t = (await (await fetch('http://127.0.0.1:9222/json/list')).json()).find(x => x.url.includes('/c/'));
const ws = new WebSocket(t.webSocketDebuggerUrl);
await new Promise(r => ws.addEventListener('open', r));
ws.send(JSON.stringify({ id: 1, method: 'Page.reload' }))"
```

实测有效：重载后页面恢复响应，对话内容完整。

## Node 版本隔离

本仓库需要 **Node 18+**（CDP 走全局 `fetch`）。但这台机器的系统默认 Node 是 **16.14.2**，
**不能动它** —— LayaAir 依赖它。所以隔离是**每次调用**做的，不是改全局默认。

```bash
bridge --which          # 看会选中哪个 Node
bridge npm test         # 用它跑测试
bridge node scripts/live/cdp-poke.mjs tabs
```

`scripts/dev/with-node.mjs` 解析顺序（先匹配先用）：

| # | 来源 | 说明 |
|---|---|---|
| 1 | `BRIDGE_NODE` | 显式覆盖 |
| 2 | `NVM_HOME/v<.nvmrc 里的版本>` | 本仓库声明的是 **24.20.0** |
| 3 | `tools/node22` | pi 自己跑的那个 Node |
| 4 | `NVM_HOME/v*` 中 ≥18 的最新一个 | 其它已装的 |
| 5 | PATH 上的 `node`（若 ≥18） | 兜底 |

选定 Node 后，包装器会**把它的目录前置到子进程的 `PATH`**，因此 `npm` / `npx` 也会解析到同一个 Node。
**你的 shell 不受影响** —— `node --version` 在包装器之外仍然是 16。

> 这个解析器**本身必须能在 Node 16 上跑**（它不用 `fetch`、不用任何 18+ 的语法），
> 否则它就无法自救 —— 一个需要它所解析之物的解析器解析不了任何东西。

**设置这一隔离机制的原因**：在此之前，Node 版本不匹配会被报成「浏览器没有启动」，
因为探测端点的工具把 `fetch is not defined` 也吞成了 `running: false`。
这一假阴性把排查方向引向了不存在的成因，即 Chrome（见 `docs/internal/BUGS.md` BUG-009）。
现在版本不满足要求时，包装器会直接说明要求与当前版本。

## 安装到 pi

```bash
git clone https://github.com/yedan1122/brb.git ~/.pi/agent/extensions/@yedan1122-brb
```

pi 会自动发现 `~/.pi/agent/extensions/*/index.ts`，之后 `/reload` 或重启即可用。

本仓库里的 [`AGENTS.md`](AGENTS.md) 记录了控制词表与工作约定，pi 在该目录下工作时会自动加载。
