# bt-cli — CHANGELOG

## [2026-09-27] v0.9.5 结果不明时先查状态;lookup 找回的流式结果按 SSE 解析

**背景原因**: 老许 v3.9 对 CLI `6f8aafb` 源码验收通过,剩"部署后的真实网络轨迹,确认结果不明时没有第二次付费
POST"。09-27 用本地录制代理(正向代理,DPoP 的 htu 不变)对 Market staging 做了三轮故障注入:
1. 流中途断开:只有一次付费 POST,随后 `executions/lookup` + `GET executions/{id}`,没有重发;但找回的结果被原样
   打印成 `data: {…}`——Market 在 `result.body` 里存的是原始 SSE,CLI 按"拼好的文本"处理了。
2. 上游已执行、响应在返回途中丢失:只有一次付费 POST,也没有重发;但 CLI 只报"网络不可用"就退出,没有按契约
   先查状态,用户不知道这次可能已经执行并扣费。

**修改范围**: `internal/protocol/{exec.go,client.go,errors.go}`、`internal/i18n/i18n.go`、`internal/config/config.go`
(版本号)、测试 `internal/protocol/exec_test.go`、`internal/agent/loop_test.go`、本文件。

**修改内容**:
- `Execution.Content/ToolCalls/Usage`:`result.content_type` 为 `text/event-stream` 或正文以 `data:` 开头时,
  用和实时流同一个 `parseSSE` 解析,拼出回答、工具调用和用量;JSON 结果照旧。
- `Client.Stream`:付费 POST 已建立连接后出现的传输错误(写请求体或等响应头时断开、RST、响应头超时)
  归为 `STREAM_BROKEN`,走现有的"先 lookup、不盲重发"流程;DNS、拨号失败、连代理失败说明请求没发出去,
  仍按普通网络错误返回。
- `STREAM_BROKEN` 的提示改为说明"连接在发出请求后中断、结果未知、没有重新发送、如已执行只扣一次"。
- 回归测试:SSE 结果解析;发出后断线 → lookup 找回且只有一次 POST;lookup 404 → 退出码 3 且只有一次 POST;
  拨号失败不算结果不明;付费 POST 不得带 `Idempotency-Key`/`X-Idempotency-Key` 头(net/http 对带这两个头的
  POST 会在复用连接断开时自动重放)。

**影响 + 部署状态**: 仅客户端行为;付费请求次数不变(仍是一次),断线后多一次状态查询。版本 0.9.4 → 0.9.5。
已推送 GitLab main 与 staging;发布包从该精确提交构建后放到 `https://bt.projectholo.us/dl/`,并在新包上重跑网络轨迹。

## [2026-09-27] 仓库级换行规范 .gitattributes(无功能改动)

**背景原因**: 09-24 老许复核 Risk v3.8 时拒收源码 ZIP:我方 Windows 本地 `core.autocrlf=true` 且仓库没有
`.gitattributes`,默认 `git archive` 会把 LF 转成 CRLF,170 个文件字节变化,内嵌迁移 checksum 随之改变。
09-27 老许明确交付方式:只以远端 staging 分支的精确提交为准,不再用本地打包的归档构建。这个仓库一并加上
同样的规则,防止任何人从任何机器检出或打包时改动字节。

**修改范围**: 新增 `.gitattributes`;本文件。

**修改内容**:
- `* text=auto eol=lf`:Git 中文本一律 LF,检出与 `git archive` 一律输出 LF,覆盖个人的 `core.autocrlf`。
- 常见二进制扩展名显式标 `binary`。
- 提交前核对:Git 中已有文件原本全部是 LF(无 CRLF、无混合),本次没有任何既有文件的内容发生变化,
  与上一个提交相比只多 `.gitattributes` 和本条记录。

**影响 + 部署状态**: 运行代码、构建产物、数据库迁移 checksum 均不变,不需要重新部署。本地三仓已改为
`core.autocrlf=false` 并重新检出,工作区与 Git blob 字节一致。已推送 GitLab main 与 staging。

## [2026-09-20] v0.9.4 v3.5 重试证据、DPoP usage 与 NEEDS_REVIEW=409

**修改内容**:
- 除 429 `Retry-After` 外，付费执行相关重试必须同时有 `safe_to_retry=true` 和
  `upstream_attempted=false`；缺字段、断流、lookup 404/unknown、502/503/504、`*_UNAVAILABLE`、
  review/in-progress 都不再凭猜测重新 POST。允许重试时仍复用相同 idempotency key/body。
- execution view 解析两项持久化证据；`NEEDS_REVIEW` 的规范 HTTP 映射改为 409。
- `/usage` 使用浏览器登录会话的 DPoP token 调 `GET /api/v1/cli/usage`，展示套餐、周期、订阅
  used/limit/unit、余额和可选 today；API key 模式不读取账户余额。

**验证**: protocol/cmd 与本轮 agent 定向测试通过，`go vet ./...`、`go build ./...` 通过。
Windows 全量测试仍只有既有的 `TestRun_ToolLoop`、`TestBash` 因 `bash.exe` 指向无发行版的 WSL shim 失败。
仅本地工作区改动，未重打发布物、未提交、未推送、未部署。

## [2026-09-18 03:10 UTC] v0.9.3 输入 / 即弹出命令菜单

**背景原因**: 用户 09-18 提出:输入 `/` 后下面应该自动出现所有可选命令,像 Claude Code 那样,而不是只有 Tab 补全。

**修改范围**: `internal/ui/lineedit.go`(`EditorOptions.Suggest`、`Suggestion`、菜单渲染与按键处理)、`lineedit_test.go`;
`cmd/input.go`(`slashSuggest`:命令 + 说明,参数菜单)、`cmd/slash_test.go`;README;`internal/config`(0.9.3)。

**修改内容**:
- 行以 `/` 开头时,编辑器在输入行下方画出候选菜单(最多 8 行,选中项反色),随输入实时过滤;↑/↓ 选择(此时不走历史),
  Tab 把选中项填进输入行,Enter 在无参数命令上直接执行、在有参数命令上填入并等参数,Esc 收起直到内容变化。
- 菜单来源:全部会话命令(主名 + 说明 + 参数提示)和四个模式快捷命令;`/mode` 的参数带模式说明,`/thinking`、
  `/permissions`、`/mcp`、`/model`(上次目录)也给参数菜单。
- 菜单行按终端宽度截断不换行;渲染仍按"回到首行、清到屏幕底、重画"的方式,所以菜单出现和消失都不留残影。

**影响 + 部署状态**: 仅终端交互;`--plain` 与非终端不受影响。`go test ./...` 通过。2026-09-18 03:15 UTC 五平台产物已上传 `/dl/`(`sha256sum -c` 五个 OK),下载页 v130,本机 bt.exe 已换,GitLab main = staging。菜单渲染同样只有单测,真实终端里请用户看一眼效果。

## [2026-09-18 02:20 UTC] v0.9.2 会话内 /login 与 /logout

**背景原因**: 用户在会话里敲 `/logout`、`/login`,得到"неизвестная команда"。Claude Code 两条都有;我们的只在命令行
层(`bt login` / `bt logout`)。

**修改范围**: `cmd/root.go`(启动流程拆成 `runSession`,`/login`、`/logout` 以 `errRelogin` 结束当前会话后循环重启)、
`cmd/slash.go`(两条命令)、`cmd/logout.go`(`doLogout` 供两处复用)、`internal/i18n`、`internal/config`(0.9.2)。

**修改内容**: `/logout` 吊销 refresh 家族、清本地凭据,然后回到登录菜单;`/login` 关闭当前对话(已存盘,可 `/resume`)
并显示登录菜单,可换账号或换成 API key。两者都会重建会话(新的 Runner、MCP 连接、快照库)。

**影响 + 部署状态**: 仅交互式会话。`go test ./...` 通过。2026-09-18 02:25 UTC 五平台产物已上传 `/dl/`(`sha256sum -c` 五个 OK),下载页 v129,本机 bt.exe 已换,GitLab main = staging。

## [2026-09-18 01:30 UTC] v0.9.1 未登录时直接给登录菜单;bt login 明确说明浏览器是否已打开

**背景原因**: 用户 09-18 在 cmd.exe 里运行 `bt`,只看到"Не авторизованы. Запустите bt login",问为什么没有跳到网页登录
授权的选项。Claude Code 和 Codex 首次启动都是就地给出登录方式并直接走完,不会让人先去查另一条命令。另外 `bt login`
虽然会自动开浏览器(Windows 用 rundll32),但没说明这一点,浏览器没弹出来时用户不知道该手动打开链接;经典 cmd 里的
链接也不可点击。

**修改范围**: 新增 `cmd/onboard.go`(登录菜单)、`cmd/onboard_test.go`;改 `cmd/root.go`(未登录 + 终端 → 菜单 → 登录 →
继续启动)、`cmd/login.go`(`openBrowser` 返回是否成功;Linux 无 DISPLAY 不尝试)、`internal/i18n`、`internal/config`(0.9.1)。

**修改内容**:
- 终端里未登录运行 `bt`:显示 `[1] 通过浏览器(推荐) [2] API key [q] 退出`,Enter = 浏览器;登录成功后直接进入会话。
  非终端(管道/脚本)仍是原来的短提示和退出码 2。
- `bt login`:打印页面地址和验证码后,浏览器打开成功就说"已打开,没出现就手动打开链接;页面上先登录再核对验证码
  再点 Разрешить",失败就说"请手动打开链接"。

**影响 + 部署状态**: 只影响未登录状态下的交互;已登录用户无变化。`go test ./...` 通过。2026-09-18 01:35 UTC 五平台产物已上传
`/dl/`(`sha256sum -c` 五个 OK),下载页 v128,本机 bt.exe 已换,GitLab main = staging。这次提交顺带带进了 README 里一段不是我写的
改动(外部复核所加,说明公网搜索入口只在 mock 模式存在、live 下回 404):它对应封控仓库里同样未提交的本地 v0.14.1,封控那边
还没有提交和部署,README 的说法暂时超前于线上。

## [2026-09-18 00:00 UTC] v0.9.0 AGENTS.md + /init、/rewind、/review、/permissions、MCP 客户端、行编辑器;刷新令牌跨进程竞争修复

**背景原因**: 用户 09-18 要求把上一轮列出的主流能力全部做掉:项目说明文件、MCP、文件回退、/review 与交互式改规则、
Tab 补全与 Shift+Tab 切模式,并查清本机 CLI 掉线的原因。

**掉线原因(已在老许 staging 库核实)**: `cli_refresh_tokens` 里 QA 账号的家族 a38dd32d 链到 token 142(09-17 15:10:36
本地时间由我的 0.6.0 实测进程换出),之后 19:35:33 Market 日志有一条 `client=bt-cli/0.5.0 POST /api/v1/cli/token → 401`,
同一秒 token 142 被标记 revoked(used_at=0,即它本身从没用过)。也就是用户终端里还开着的旧版 0.5.0 REPL 拿着内存里
早已被换掉的 refresh token 去刷新,服务端按重放吊销整个家族,两边一起掉线。这是代码缺陷,不是偶发。

**修改范围**: 新增 `internal/project`(AGENTS.md)、`internal/checkpoint`(快照)、`internal/mcp`(MCP 客户端,stdio + Streamable
HTTP)、`internal/ui/lineedit.go`(行编辑器)、`internal/auth/reload.go`(重读 + 锁)、`cmd/permissions.go`、`cmd/mcp.go`;改
`cmd/slash.go`、`cmd/root.go`、`cmd/run.go`、`cmd/mode.go`、`internal/agent/loop.go`(`BeforeTool` 钩子)、`internal/tools`
(`Abs`)、`internal/permission`(规则 Add/Remove、`Grants`、mcp__ 分类、规则名允许 `*` 与数字)、`internal/config`
(`permissions.yaml` 合并、`mcp_servers`)、`internal/i18n`、README。

**修改内容**:
- **AGENTS.md / `/init`**:每次请求把 `<配置目录>/AGENTS.md` 和 git 根到工作目录链上的 `AGENTS.md` 按 Codex 的顺序加进
  system prompt(单文件 24 KB、总 64 KB 上限),并说明它们不覆盖权限模式和用户的直接要求。`/init` 让模型用只读工具
  调查后写文件;plan 模式拒绝。`--no-agents-md` / `BT_AGENTS_MD=off`。
- **`/rewind`**:agent 在 write/edit 执行前通过 `BeforeTool` 钩子给文件拍快照(每轮每路径一次;bash 只计数用于提示),
  存在配置目录 `checkpoints/<会话>`,退出删除。回退到某轮之前:恢复文件(新建的删掉),可选同时截断对话并另起会话文件。
- **`/review [ref]`**:`git diff`(默认 HEAD)+ 未跟踪文件清单交给模型只读审查;超 120 KB 改为 `--stat` 让模型自己读。
- **`/permissions`**:列出规则并标明来源(config.yaml / permissions.yaml)与本会话 `[a]` 授权;`allow|ask|deny <规则>`
  立即生效并写入配置目录 `permissions.yaml`(不动用户手写的 config.yaml),`remove` 删除。
- **MCP**:`config.yaml` 的 `mcp_servers`(只读用户级文件)启动 stdio / HTTP 服务器,工具注册为 `mcp__<server>__<tool>`;
  readOnlyHint → 类别 net,其余 → exec_risky;规则支持 `mcp__server__*`。`/mcp`、`/mcp tools [server]`,`--no-mcp`。
- **行编辑器**:终端下用自研 raw 模式编辑器代替整行读入:光标移动、历史(保留草稿)、Tab 补全 `/命令` 和模式名、Shift+Tab
  循环切换模式(不含 bypass)、Ctrl+C 清行、Ctrl+D 退出、长行换行与 CJK 宽度;非终端仍是逐行读。
- **刷新竞争**:刷新前重读钥匙串,发现别的进程已换就采用(access 未到期则不联网),刷新过程用 `refresh.lock` 串行化
  (30 s 视为遗留锁自动清除)。

**审查与验证**: 提交后跑了一轮多角度代码审查(逐行、删除行为、跨文件、Go 陷阱、包装层、复用/简化/效率、深度与
规范、行编辑器专项,外加一轮补漏),核实并修掉 13 条(见 6c70cf8 提交说明:MCP 规则名大小写、另一终端重新登录后
旧进程误用新 token、permissions.yaml 解析失败被静默清空、stdio 子进程 Wait 与读取的时序、两个 `bt -c` 共用快照
目录、/compact 之后 /rewind 的对话索引失效、HTTP MCP 会话丢失不恢复等)。两条未做、记为后续:MCP 工具的
readOnlyHint 由服务器自报,auto 下凭它免询问;仓库里的 AGENTS.md 没有"首次信任"确认。`go vet ./...`、
`go test ./...`(cmd / agent / auth / checkpoint / dpop / mcp / permission / project / protocol / tools / ui)全过,
linux/darwin 交叉编译通过。MCP 对真实 `@modelcontextprotocol/server-filesystem` 实测 14 个工具、调用成功。
补漏一轮又修了 7 条(Windows 控制台零字节读不再当成永久 EOF——权限提问时按 Ctrl+C 或 Ctrl+Z 不会再退出整个会话;ESC 判定改用 PeekConsoleInput 只认真正的按键按下;粘贴的多行剩余部分只给下一次输入、不会自动回答权限提问;--no-tools 下 /review 不再崩;快照 GC 改为 7 天且活动会话刷新目录时间;遗留锁用改名方式抢占避免双方互删)。行编辑器只有单测(strings.Reader 驱动),raw 模式在真实终端(尤其 Windows 控制台)没有手测。

**影响 + 部署状态**: 交互式会话的输入方式整体换成行编辑器,是这一版最大的行为变化;`--plain` 或非终端仍是旧的
逐行读入。发布状态:2026-09-18 00:45 UTC 五平台产物已上传 `https://bt.projectholo.us/dl/`(服务器上
`sha256sum -c` 五个 OK),下载页 v127 已换 0.9.0,本机两处 bt.exe 已换,GitLab main = staging。本机 CLI 仍处于未登录状态,
需要用户自己 `bt login` 后在真实终端试行编辑器、/init、/rewind、/mcp。

## [2026-09-17 22:10 UTC] v0.8.0 команды сессии: /help /model /clear /compact /resume /status /usage /diff /thinking

**背景原因**: 用户 09-18 问 bt 能不能用"/ + 命令"操作(像 Claude Code 的 `/model` 切模型、`/usage` 看额度),要把主流的
配上。此前 REPL 只认 `/exit` 和 0.7.0 加的 `/mode`,其它以 `/` 开头的输入都被当成普通文本发给模型。

**修改范围**: 新增 `cmd/slash.go`、`cmd/slash_test.go`;改 `cmd/mode.go`(`handleSlash` 拆成分发器 + `handleMode`)、
`cmd/root.go`(REPL 状态 `replState`、`runTurn` 抽出来给 `/compact` 复用)、`internal/agent/loop.go`
(`Runner.Totals` / `Requests` 累计用量)、`internal/session/session.go`(`List`、`ID`)、`internal/i18n`、
`internal/config`(0.8.0)、README。

**修改内容**:
- 命令集取 Claude Code 与 Codex 的交集:`/help`、`/model [name]`、`/mode`(已有)、`/clear`(`/new`)、
  `/compact [focus]`、`/resume [n]`、`/status`、`/usage`(`/cost`)、`/diff`、`/thinking [on|off]`、`/exit`。
- `/model`:目录来自 Market 公开接口 `GET /api/v1/models`(信封里字符串数组和 OpenAI 形状都认);目录外的名字
  拒绝,目录拉不到时照用户给的名字切(Market 故障时不至于切不了)。只改本次会话,默认值仍是 config.yaml / `--model`。
- `/compact`:关掉工具、不写会话文件,让模型写一份可续做的摘要(目标、决定、改过的文件、跑过的命令、未决问题),
  历史替换成"摘要 + 确认"两条并另起会话文件;失败或 Ctrl+C 时历史原样保留。前后各给一个 token 估算。
- `/resume`:列最近 10 个会话(时间、消息数、第一句话,当前目录的打 •),选号恢复,之后的消息续写进那个文件。
  当前会话不列出——文件开着时 Windows 不及时更新修改时间,排序会乱,而且恢复到自己没有意义。
- `/usage`:本次会话的请求数、输入/输出/总 token、时长、扣费通道。**订阅剩余额度和余额现在显示不了**:
  核对过 Market 路由,CLI token 只能打 `/cli/session`、`/cli/revoke`、`inference/cli/*`,API key 只能打 `/v1/*` 推理
  和执行查询,没有任何额度/余额接口认这两种凭据。命令里给出个人中心链接;等 Market 加接口后补上。
- 像命令但不认识的输入(`/modle auto`)给提示、不发给模型(省 token);像路径的(`/etc/hosts …`、`/src/main.go:42 …`)
  照常发给模型。
- REPL 横幅提示改为 `/help`。

**影响 + 部署状态**: 只影响交互式会话;一次性调用(`bt "…"`、管道)不变。`go test ./...` 全过(新增目录两种形状、
`/model` 四种情况、`/clear` 换文件、`/resume` 选择与取消、信息类命令不崩)。交互式 REPL 本身没有在真实终端里手测
(本机 CLI 09-18 查到处于未登录状态:`bt auth status` 回"未授权"。原因没有核实,怀疑是 09-17 我在工具
shell 里跑线上实测时,用户终端里的 REPL 同时开着——两个进程共用一个 refresh token,轮换时先后用了同一个旧 token,
服务端按 `CLI_REFRESH_REPLAY` 吊销整个家族,CLI 随即清掉本地凭据。如果属实,同时开两个 `bt` 就会互相踢下线,
需要给刷新加文件锁并在刷新前重读凭据,另行处理)。发布状态:2026-09-17 22:25 UTC 五平台产物已上传
`https://bt.projectholo.us/dl/`(服务器上 `sha256sum -c` 五个 OK),下载页 v126 已换 0.8.0,GitLab main = staging。

## [2026-09-17 09:40 UTC] v0.7.0 режимы разрешений: plan / default / accept-edits / auto / bypass + правила allow/ask/deny

**背景原因**: 用户 09-17 要求参照 Claude Code 和 Codex 做不同的权限模式(要有 auto 和 bypass)。原来只有
两档:每次问(默认)和全不问(`--yes`),中间没有"让它自己干活但别出圈"的档位;而且 `[a]` 一答就是整个
工具放行(答一次 a,之后所有 bash 命令都不再问),免询问清单里还有 `go test`、`python script.py` 这种
实际会执行任意代码的命令。

**机制**(两张表,见 `internal/permission/mode.go` 头部注释):
1. 分类器 `Classify`(`classify.go`)把每次工具调用归到一个动作类别:`read` / `read_sensitive` / `edit` /
   `edit_outside` / `exec` / `exec_risky` / `net` / `forbidden`。
2. "模式 × 类别"表给出 allow / ask / deny。决策顺序:硬禁止 → deny 规则 → plan 模式 → ask 规则 →
   allow 规则与本次会话的 `[a]` 授权 → 模式表 → 没终端则 ask = 拒绝。
- 对应关系:plan = CC plan / Codex Read Only;default = CC default;accept-edits = CC acceptEdits;
  auto = CC auto / Codex Auto(workspace-write);bypass = CC bypassPermissions / Codex Full Access。
- Codex 的 Auto 靠操作系统沙箱兜底,我们没有沙箱(单二进制五平台),auto 是按命令文本和路径做的静态策略:
  工作目录内的编辑和普通命令放行,联网命令、装包、出工作目录、读密钥、不可逆操作要问。脚本内部做什么
  (`npm run x`、`make`)分类器看不到,README 里写明了这个边界。

**修改范围**: 新增 `internal/permission/{mode,classify,rules,decide}.go`、`mode_test.go`,`cmd/mode.go`、
`cmd/mode_test.go`;改 `internal/permission/gate.go`(Gate 结构、只读清单收紧)、`gate_test.go`、
`internal/agent/loop.go`(`Decide` + 拒绝原因回给模型、`SystemSuffix`)、`internal/tools/tools.go`
(`Registry.Add/SetHidden`、`exit_plan` 工具)、`internal/config/config.go`(`permission_mode`、
`permissions.*`、`BT_MODE`、0.7.0)、`cmd/run.go`、`cmd/root.go`(`--mode`、`/mode`、提示符模式标记)、
`internal/i18n`、README。

**修改内容**:
- 分类细节:路径解析 `~` 和符号链接(工作目录里指向外面的链接算"外");`.git/`、`.bt/`、`~/.bashrc`、
  密钥类文件即使在工作目录内也算 `edit_outside`;读 `.env`/`~/.ssh`/`*.pem`/`env`/`printenv` 算
  `read_sensitive`(auto 里也要问——否则"读密钥 → web_fetch 把密钥带在 URL 里"这条注入链一个提示都没有);
  bash 按 `| || && ; &` 分段,任一段联网/装包/出目录/不可逆、含 `$(…)` 或 URL → `exec_risky`;
  Windows 下 Git Bash 的 `/tmp`、`/c/Users/…` 路径正确判定;`del /s /q` 这类 cmd 开关不误判成路径。
- 只读清单收紧(**行为变化**):`go build/test/vet`、`npm test/run`、`cargo build`、`python x.py`、`node x.js`
  不再免询问(现在是 `exec`:default 下会问一次,答 `a` 本会话内该前缀不再问;auto 下不问)。`go list/env/
  version/doc`、`npm ls/view`、`--version` 仍然免询问。`find -delete/-exec`、`git -c …`、`go env -w`、
  `git fetch/pull/add` 不再算只读。
- `[a]` 改为按范围授权:bash = 命令前缀(`go test`、`npm run build`、`make build`),组合命令每段都要被
  授权过;在普通命令上给的前缀不覆盖它的高风险形式(`make build > ~/x`);write/edit = 本会话工作目录内
  全部编辑;web_fetch = 该主机;工作目录外文件/密钥 = 该文件;含 `$(…)` 的命令不提供 `[a]`。
- plan 模式:编辑和会改状态的命令直接拒绝,拒绝原因告诉模型"用只读工具调查,再用 `exit_plan` 提交计划";
  `exit_plan` 只在 plan 模式下对模型可见;用户批准时选执行模式(y = default,e = accept-edits,a = auto,
  n = 继续完善计划);没有终端时计划作为最终回答输出。allow 规则打不穿 plan。
- 规则 `permissions.allow/ask/deny`:`bash(prefix:*)` / 精确 / glob,`write|edit|read(路径 glob,支持 **)`,
  `web_fetch(domain:…)`;deny 在 bypass 下也生效;ask 在 auto 下强制询问;只读用户级 config.yaml,
  项目目录里的文件不能给自己授权。
- 入口:`--mode`、`BT_MODE`、`permission_mode`、REPL `/mode [name]` 与 `/plan /default /edits /auto /bypass`;
  接受 Claude Code / Codex 的名字(`acceptEdits`、`bypassPermissions`、`read-only`、`full-auto`、
  `full-access`、`yolo`);会话内切到 bypass 要确认;非 default 模式显示在提示符前;每次请求的 system prompt
  带当前模式说明(auto 下告诉模型不要为例行步骤停下来问)。`--yes` = `--mode bypass`,行为不变。
- 拒绝原因区分开回给模型:用户拒绝 / plan 只读 / deny 规则 / 无终端,不再统一写"被用户拒绝"。

**验证**: `go test ./...` 全过(新增分类表 50 多条用例、模式矩阵、plan 批准流、规则、`[a]` 范围、`/mode`)。
线上实测(QA 账号、deepseek-chat):`--mode plan` 下模型调查后给出计划,main.go 未被改动;`--mode auto`
无终端下完成改文件 + `go run .`,随后的 `curl … -o page.html` 被拦下,文件未生成。发布状态:2026-09-17
五平台产物已上传 `https://bt.projectholo.us/dl/`(服务器上 `sha256sum -c` 五个 OK),下载页 v125 已换 0.7.0,
GitLab main = staging。交互式提问(y/n/a、plan 批准)只有单测覆盖,没有在真实终端里手点过一遍。

## [2026-09-17 08:10 UTC] v0.6.0 web_search 第一顺位改走平台搜索(封控搜索网关 → Yandex Search API)

**背景原因**: 0.5.0 的 `web_search` 在用户机器上抓 DuckDuckGo/Bing 的 HTML 页面,没有协议保证,在俄罗斯
网络下经常不通,也没法计量。用户 09-17 定:搜索收到平台侧。bt-risk-service v0.14.0 已经上线搜索网关
(`POST https://bt.projectholo.us/tools/web-search`,出站按 Yandex Search API v2 协议,联调环境指向
vendormock 的 1:1 模拟),CLI 这边接上。

**修改范围**: 新增 `cmd/websearch.go`(平台搜索客户端)、`cmd/websearch_test.go`、
`internal/tools/web_platform_test.go`;改 `internal/tools/web.go`(`PlatformSearch` 钩子、provider
`brighttoken`、链顺序)、`cmd/run.go`(接线)、`internal/config/config.go`(0.6.0)、README。

**修改内容**:
- 链顺序:`brighttoken` →(有 `BT_SEARCH_KEY` 时)`brave` → `duckduckgo` → `bing`。`--no-web` / `BT_WEB=off`
  或 `web_base` 为空时不挂平台 provider。
- 鉴权:`X-BT-Proof`。device 会话直接用凭据里封控发的 kid/key;API key 登录或 Core 发的无 kid 密钥,
  CLI 自己向 `{web_base}/guard/key` 申请一把,只放进程内存。401 → 换新密钥重试一次,第二次 401 即失败。
- 网关响应的 `mode`:`live` 直接采用,且不做"结果与查询相关"那道抓取专用的检查(API 结果可信);
  `mock`(联调环境,固定语料)默认**不采用**,继续走后面的 provider,并按失败处理 5 分钟内不再问平台,
  免得用户把语料当成全网搜索结果。`BT_SEARCH_PROVIDER=brighttoken` 强制采用,输出里给模型加一行说明。
- 平台返回空结果 = 真的没搜到,不算故障、不暂停,继续问下一个 provider;平台报错(429/5xx)→ 暂停 5 分钟走回退链。

**影响 + 部署状态**: 现在线上网关是 mock 模式,所以默认行为和 0.5.0 一样(结果仍来自 DDG/Bing),
多一次约 200 ms 的平台请求(之后 5 分钟跳过)。封控那边换成真实 Yandex key、`mode=live` 后,CLI
不用发版就自动用平台搜索。发布状态:2026-09-17 五平台产物已上传 `https://bt.projectholo.us/dl/`
(服务器上 `sha256sum -c SHA256SUMS.txt` 五个 OK),下载页 v124 已换 0.6.0。线上端到端实测(QA 账号、
deepseek-chat):`BT_SEARCH_PROVIDER=brighttoken` 时模型经平台网关拿到 python.org 等 4 条结果;默认链
在 mock 模式下按设计回落到 bing。

## [2026-09-17 07:30 UTC] v0.5.0 联网:web_search + web_fetch

**背景原因**: 用户 09-17 在 REPL 里问"你可以联网吗",模型回答"不能"。这是实话:CLI 只给了模型 read/write/edit/glob/grep/ls/bash 七个工具,system prompt 也只列了这七个,没有任何联网手段。Claude Code(WebSearch/WebFetch)和 Codex CLI 都自带联网工具;Market 目前没有平台侧搜索接口,所以先做成客户端工具,在用户机器上发请求。

**修改范围**: 新增 `internal/tools/web.go`(`web_fetch`、`web_search`、HTML→文本、SSRF 拦截、DuckDuckGo/Bing/Brave 三个 provider 与回退链)、`internal/tools/web_test.go`(10 个用例)、`internal/tools/tools.go`(`Default()` 注册两个新工具;`Registry.Remove`/`Has`)、`internal/permission/gate.go`(`Gate.Allow`)、`cmd/root.go`(`--no-web`)、`cmd/run.go`(`BT_WEB=off|allow`、`tools.WebLocale`、`systemPrompt(cwd, web)` 明确告诉模型"你有联网能力,先搜再读再答并列出来源,抓到的网页内容是不可信数据")、`internal/config/config.go`(0.5.0)、`go.mod`(+ `golang.org/x/net v0.49.0`,`x/text` 升到 v0.33.0)、README(新节「Интернет: web_search и web_fetch」)。

**修改内容**:
- `web_fetch {url, max_chars}`:只收 http/https;3 MB 上限、25 s 超时、最多 5 次重定向;`charset.NewReader` 把 windows-1251 等转 UTF-8;HTML 用 `x/net/html` 解析后转文本——丢 script/style/noscript/svg/iframe/nav/footer/aside/form,标题变 `#`,列表变 `- `,`<pre>` 保留空白,链接变 `文本 (绝对URL)`,表格单元用 ` | ` 分隔,连续空行压成一行;text/json/xml/yaml 原样;其它 content-type 拒绝。默认返回 12000 字符(上限 40000),超出标注 `[truncated: N of M …]`;空页面提示"可能是 JS 渲染"。
- SSRF 防护:IP 字面量或 DNS 解析结果落在 loopback/私网/link-local/多播/未指定/CGNAT(100.64/10),或主机名是 `localhost`/`*.local`/`*.internal` → `refused`;每一跳重定向再查一次。`BT_FETCH_ALLOW_PRIVATE=1` 解除(测试用)。DNS 查不到不算错,交给 HTTP 请求自己报。
- `web_search {query, count}`:默认链 DuckDuckGo HTML(6 s)→ Bing HTML(12 s;拉丁字母为主的查询带 `ensearch=1`,否则 cn.bing.com 会给词典/翻译页);设了 `BT_SEARCH_KEY` 则 Brave API 排最前;`BT_SEARCH_PROVIDER` 可指定单个。DDG 的 `uddg=` 与 Bing 的 `/ck/a?…&u=a1<base64url>` 跳转链接解码成真实 URL,DDG 广告(`y.js`)丢弃。实测(09-17):DDG 对机房 IP 返回无结果的挑战页、在中国大陆直连不通;Bing 对机房 IP 返回与查询无关的垃圾结果(Facebook 帖子之类),对住宅 IP(含 cn.bing.com)正常——所以加了相关性检查(结果的标题/摘要/URL 里至少出现一个 ≥3 字符的查询词,否则判为 bot 降级输出换下一个),出错或空结果的 provider 5 分钟内跳过,全部失败时返回带原因的错误并提示模型改用 `web_fetch` 直接读已知 URL。
- 权限:两个工具 `Mutating()==true`,走 gate——提示里能看到完整查询/URL,`[a]` 本会话始终允许;`--yes`/`BT_WEB=allow` 免问;无终端且无 `--yes` 时拒绝(与写文件同规则);`--no-web`/`BT_WEB=off` 直接不注册,模型看不到。
- 终端显示沿用 v0.4.0 的工具卡片:`⏺ web_search  запрос` → `⎿ N строк` + 前三行预览。

**验证**: `go build/vet/test ./...` 全绿。新用例:HTML→文本(标题/段落/列表/pre 保留,script/style/nav/footer 丢弃);fetch 的 html/json/截断/二进制拒绝/404/非 http 协议;7 种私网/loopback/metadata/CGNAT/localhost 地址拒绝;重定向到 loopback 拒绝;DDG 解析(uddg 解码、广告丢弃、摘要);Bing 解析(ck/a base64 解码);相关性判定;回退链(DDG 挑战页 → Bing 命中、count 截断、DDG 被标记跳过;Bing 垃圾结果 → 带 `web_fetch` 提示的错误);注册与 `Remove`。本机对真实 staging 实测:问当天信息时模型依次调用 `web_search`、`web_fetch` 并给出带来源的回答。

**影响 + 部署状态**: 本机已重装;GitLab main=staging 已推。发布物随后重打。协议层零改动。局限:无 key 的搜索靠抓取公开 HTML,机房/被墙网络下可能不可用(此时模型仍可用 `web_fetch` 读已知 URL);长期方案是让 Market 提供计费的平台侧搜索接口(key 留在服务端),需要老许排期。

## [2026-09-17 05:40 UTC] v0.4.0 终端体验:等待动画、思考链、流式 Markdown + 代码高亮、工具卡片、用量行

**背景原因**: 用户 09-17 装上 0.3.6 试用后要求参照 Claude Code / Codex CLI 把终端做专业:字体颜色、代码高亮、思考链、动态等待。此前 CLI 把模型输出原样打到 stdout:等待期间一片空白;deepseek-reasoner 的 `reasoning_content`(Market 会原样透传,见 09-17 执行记录 #1985)被解析器丢掉,用户要干等十几秒才见到答案;markdown 标记(`**`、`` ` ``、```` ``` ````)原样显示;工具调用只有 `⚙ ls .` / `↳ 11 bytes`;权限提示一行裸文本;出错没有视觉区分。

**修改范围**: 新增 `internal/ui/style.go`(Theme 调色板:Accent 琥珀/Bold/Dim/Italic/Underline/Muted/Tool/Err/Ok/Code;Symbols 伪图形与 ASCII 回退;`PlainRequested`)、`internal/ui/spinner.go`(stderr 上的等待行:帧·文案·耗时·Ctrl+C 提示;非 TTY 全部 no-op;`FmtSeconds` 俄英格式)、`internal/ui/markdown.go`(流式 markdown 渲染:标题/列表/编号/引用/分隔线/行内 `code`·**bold**·*italic*·链接,```` ``` ````代码块用 chroma v2 逐行高亮,monokai;`safeCut` 保证未闭合标记最多滞留一行,标题整行一次输出)、`internal/ui/console.go`(Console:TurnStart/Reasoning/Delta/TurnEnd/TurnAbort/ToolHeader/ToolRunning/Note/Error/Prompt;答案走 stdout,其余走 stderr)、`internal/ui/vt_windows.go`/`vt_other.go`(conhost 开 ENABLE_VIRTUAL_TERMINAL_PROCESSING)、`internal/protocol/types.go`+`stream.go`(`EventReasoning`,解析 `delta.reasoning_content`)、`internal/agent/loop.go`(`Runner.UI`;consume/turn/exec/note 在有 UI 时走 Console,无 UI 时行为不变)、`internal/permission/gate.go`(提问加 Theme/Symbols 着色:`? Разрешить?` + `[y] да [n] нет [a] всегда…`)、`cmd/root.go`(`--plain`、`--no-thinking`;启动 `ui.EnableVT()`;错误经 `printErr` 走红叉;REPL 提示符琥珀 `›`,每轮后空行)、`cmd/run.go`(按 stdout/stderr 是否 TTY 分别定色;`BT_PLAIN`/`NO_COLOR`/`BT_THINKING`)、`cmd/tty.go`(`BT_FORCE_TTY` 调试开关)、`internal/i18n/i18n.go`(10 个新键)、`internal/config/config.go`(0.4.0)、`go.mod`(+ `github.com/alecthomas/chroma/v2 v2.14.0`、`dlclark/regexp2`;`golang.org/x/sys` 转直接依赖)、README(新节「Терминал: как выглядит ответ」;配置目录补 Windows 路径 `%AppData%\bt`——0.3.6 安装时就是因为 README 只写 `~/.config/bt` 而放错了地方)。测试:`internal/ui/markdown_test.go`(纯文本去标记、逐字符流式与整体输出一致(三种色阶 × 6 种输入)、颜色转义、代码高亮、`safeCut` 10 组、行内链接/未闭合、光标行首)、`internal/ui/console_test.go`(非 TTY 只出纯文本且 stderr 为空;TTY 思考链头/正文/耗时/用量行;隐藏思考;工具卡片三行预览;两行错误;spinner 关闭静默/开启绘制并清行;`FmtSeconds`)。

**修改内容**:
- 等待:请求发出即在 stderr 画 `⠋ Думаю… 1,2 с  Ctrl+C — прервать`(90 ms 一帧),首个 token 到达时停止并整行清除;工具执行期间 `⠋ Выполняю…`。stderr 不是终端 → 不画。
- 思考链:首个 `reasoning_content` 到达 → 停止 spinner,打印 `∴ Рассуждение`,正文按 token 以 Muted 色流式输出;首个正文 token 到达 → `─── думал 3,2 с` 然后答案。`--no-thinking`/`BT_THINKING=off` 只显示 `думал N с`。非 TTY 忽略。
- 流式 markdown:逐字符进入状态机,行首判断标题/列表/编号/引用/围栏/分隔线;正文按"安全切点"输出——所有 `` ` ``、`**`、`*`/`_`(仅词首)、`[…](…)` 都闭合且不切在字母与 `*` 之间——所以 `**жирный**` 从不带星号出现,滞留不超过一行;标题整行一次输出。代码块:`── lang ──` 头、chroma 逐行高亮(truecolor→terminal256,16 色→terminal16)、`───` 尾;行本身不加前缀,方便复制。链接:下划线文本 + 灰色 `(url)`(url 与文本相同时不重复)。
- 工具:`⏺ bash  ls -la`(工具名蓝/青色,参数加粗)→ `⎿ 11 строк · 0,4 с` + 前三行 Muted 预览 + `…`;出错 `⎿ 红色错误`。权限问句着色,选项 `[y] да  [n] нет  [a] всегда для этого инструмента ›`。
- 收尾行:`deepseek-chat · 3,7 с · ↑54 ↓61 ток.`(仅 TTY,stderr)。错误:`✖ 文案`,第二行 Muted `id запроса: …`。
- 输出分流:答案 stdout(重定向到文件得到干净 markdown,不带转义),其余 stderr。`--plain`/`BT_PLAIN`/`NO_COLOR` 回到 v0.3 的平铺输出(`Runner.UI=nil`)。Windows conhost 启动时开启 VT。

**验证**: `go build/vet/test ./...` 全绿(ui 包新增 14 个用例)。用 `BT_FORCE_TTY=1 COLORTERM=truecolor` 对真实 staging 抓 escape 码核对:deepseek-chat 列表/标题/Python 代码块高亮/行内粗体与代码/用量行;deepseek-reasoner 出现 `∴ Рассуждение` 正文与 `─── думал 1,0 с` 再答 71;带工具的 `read a.txt` 出现 `⏺ read` 卡片与预览。

**影响 + 部署状态**: 本机已重装(`%LOCALAPPDATA%\Programs\bt\bt.exe` 与 `WindowsApps\bt.exe`),GitLab main=staging 已推;09-17 06:10 UTC 用户拍板后 `scripts/release.sh` 打了 `bt_0.4.0_*` 五平台产物 + SHA256SUMS.txt 上传 bt.projectholo.us/dl/,下载页(brighttoken-demo v122)同步换名/大小/SHA/日期。协议层零改动,Market 侧不用动。二进制约大 3 MB(chroma 词法表)。

## [Unreleased, superseded by v0.9.4] OPERATION_IN_PROGRESS 只查状态，GLOBAL_CORE_UNAVAILABLE 重试前先 lookup

- `OPERATION_IN_PROGRESS` 与旧别名 `IDEMPOTENT_REQUEST_IN_PROGRESS` 现在和 `NEEDS_REVIEW` 一样先查 `executions/lookup`；状态未查明时直接返回当前错误，绝不重新 POST 模型请求。
- `GLOBAL_CORE_UNAVAILABLE` 先查询同一 `idempotency_key` 的执行状态；v0.9.4 进一步收紧为查不到时也必须具备两项显式 retry evidence。
- 原回归曾允许无证据的 `chat → lookup → same-key chat`，该行为已由 v0.9.4 撤销。

## [2026-09-16 13:40 UTC] v0.3.6 四服务架构的新错误码:NEEDS_REVIEW 只查不重发、safe_to_retry=false 不自动重试

**背景原因**: 老许 2026-09-16 把 Core 拆成俄罗斯 Market + 新加坡 Global Core(BC 协议 v3.1、边界规范 v2.2)。CLI 只调 Market,路径不变,但 §9 错误表多了几个稳定码:503 `GLOBAL_CORE_UNAVAILABLE`(GA/新加坡/Core 全不可达,同键稍后重试)、`NEEDS_REVIEW`(跨境结果不明,文档写 409/202,老许 09-16 执行记录 E2E-4S-009 实测是 503;"查询状态,不得改变请求重发")、409 `OPERATION_IN_PROGRESS`、409 `IDEMPOTENCY_CONFLICT`、429 `RATE_LIMITED`、404 `RESOURCE_NOT_FOUND`,以及 5xx 只在 `safe_to_retry=true` 时自动重试。此前 CLI:这几个码走通用分支(裸 code + 服务端 message);`NEEDS_REVIEW` 因为是 503 会被 `shouldRetry` 当成 `_UNAVAILABLE` 同键盲重发 3 次——正好违反"状态不明只查询";`safe_to_retry` 字段不看。

**修改范围**: `internal/protocol/errors.go`(`Friendly`/`ExitCode`/`StatusOfCode` 加六个码;`detailSuffix` 加 `operation_id`)、`internal/i18n/i18n.go`(`exec_review`/`err_503_core` RU/EN)、`internal/agent/loop.go`(`turn`:`NEEDS_REVIEW` 走 `recoverStream` lookup,查不出直接上抛不盲重发;`recoverStream`:`needs_review` 状态文案改 `exec_review`、缺省码 `NEEDS_REVIEW`;`shouldRetry`:`details.safe_to_retry=false` 一票否决,`RATE_LIMITED`/`OPERATION_IN_PROGRESS` 按 429/409 处理,`NEEDS_REVIEW` 不重试;`retryNotice` 加 `RATE_LIMITED`)、`internal/config/config.go`(0.3.6)。测试:`internal/protocol/errors_test.go`(`TestStatusOfCode` 六个新码;新增 `TestFriendlyFourServiceCodes`)、`internal/agent/loop_test.go`(新增 `TestRun_NeedsReviewLooksUpButNeverBlindlyRetries`、`TestRun_SafeToRetryFalseStopsAutoRetry`)。

**修改内容**:
- `NEEDS_REVIEW`(HTTP 503 或 409/202,按码不按状态):先 `POST …/executions/lookup {idempotency_key}` 最多 `MaxLookup` 次——`completed` 取结果、`failed` 报错、`needs_review`/查不到 → 把 `NEEDS_REVIEW` 原样交给用户(退出码 3),文案"Ответ не получен: запрос на проверке…повторного списания не будет",带 `request_id`/`operation_id`。**绝不**用同键再 POST 一次。
- `details.safe_to_retry === false` → `shouldRetry` 直接 false,不管状态码是 503/502/429;缺省或 true 时行为不变(`_UNAVAILABLE`/5xx 同键重试 ≤3 次)。
- `GLOBAL_CORE_UNAVAILABLE`:文案"Модельный сервис временно недоступен, средства не списаны",同键自动重试(它以 `_UNAVAILABLE` 结尾,老规则已覆盖),退出码 3。
- `RATE_LIMITED` = `RATE_LIMIT_EXCEEDED`(429,按 `Retry-After` 退避);`OPERATION_IN_PROGRESS` = `IDEMPOTENT_REQUEST_IN_PROGRESS`(409,2s 后同键重试);`IDEMPOTENCY_CONFLICT` = `IDEMPOTENCY_KEY_CONFLICT`(409,不重试,退出码 1);`RESOURCE_NOT_FOUND` = 404。
- `X-Client-Version` 随 `config.Version` 变成 `0.3.6`。

**验证**: `go build ./... && go vet ./... && go test ./...` 全绿。新用例:NEEDS_REVIEW → chat 只打 1 次、lookup 打 2 次、退出码 3、错误码原样;`safe_to_retry:false` 的 503 → chat 只打 1 次;六个新码 RU/EN 文案不含裸 code 和服务端原文、退出码正确、`operation_id` 进提示。

**影响 + 部署状态**: `scripts/release.sh` 重打 5 平台产物 `bt_0.3.6_*` + `SHA256SUMS.txt`,上传 bt.projectholo.us/dl/,下载页(brighttoken-demo v121)同步文件名/大小/SHA-256/版本日期(伙伴用例 E2E-CLI-018 要求页面 SHA 与文件逐字符一致)。老许侧不用改。

## [2026-09-11 20:22 UTC] v0.3.5 请求体输出上限字段改 max_tokens

**背景原因**: 老许 BC 接口协议 v2.3:Chat Completions 用 max_tokens,max_output_tokens 只属于 Responses API。CLI 走 chat/completions,此前发 max_output_tokens,Core 不认,等于没传。

**修改范围**: internal/protocol/types.go(json 标签)、config Version 0.3.5。

**验证**: go build / go test;对真实 Core 订阅通道发一条消息成功。

**影响 + 部署状态**: 未重打发布物(dist 仍是 0.3.4),下次连同其它改动一起打。

## [2026-09-09 03:05 UTC] v0.3.4 X-BT-Proof 的密钥改向我们的封控申请;对真实 Core 跑通 refresh

**背景原因**: CLI 的 bt-guard 证明一直是占位:令牌要 proof_key 签名,原方案等 Core 在 /cli/token 里下发,老许的 b14cb4f8 只透传 X-BT-Proof 不发密钥。封控 v0.10.0 开了 POST {web_base}/guard/key 自发密钥(bt.projectholo.us 反代到 SG 封控),令牌里带 kid,封控按 kid 校验。CLI 接上后 Core 不用改。

**修改范围**: internal/auth/guard.go(FetchGuardKey)、internal/auth/store.go(Creds 加 proof_kid / proof_key_expires_at)、internal/protocol/proof.go(令牌加 kid,签名原文不变;ProofKid)、internal/protocol/client.go(ProofKid 字段)、cmd/guard.go(ensureGuardKey:无钥或到期前 60 秒重申请,失败静默不带头)、cmd/login.go(登录后申请)、cmd/run.go(启动时检查、refresh 后 Core 若下发则优先)、cmd/auth.go(status 显示 guard_kid 与到期)、config Version 0.3.4、proof_test 加 kid 不改签名的用例。

**验证**: go vet + go test 7 包全过。对真实 Core(40.160.139.29:3000):access 已过期 → 自动 refresh 成功(新到期时间 +40 分钟)→ 向 bt.projectholo.us/guard/key 拿到 kid → 订阅通道推理回"Привет!";SG 封控该账号最新决策 ALLOW、0 分、无 proof_* 原因(此前同账号是 proof_missing(+25))。

**影响 + 部署状态**: dist/ 重打五平台 + SHA256SUMS(scripts/release.sh);GitHub Release 需要 gh 的 workflow 权限才能推标签,暂未发布,发布物在 dist/。

## [2026-09-09 00:10 UTC] v0.3.3 默认模型改为 deepseek-chat;对真实 Core 跑通 API key 模式

**背景原因**: 老许 staging 的模型目录是 `deepseek-chat` / `deepseek-reasoner`(`GET /api/models` 实测),我们默认的 `deepseek-v4-pro` 在 Core 上是 `model_not_found` 503。用注册出来的真实账号验证:`bt login --key` 存 key → `bt "…"` 走 `/v1/chat/completions` → 通过封控准入 → 余额 0 回 402 ACCOUNT_BALANCE_INSUFFICIENT,CLI 文案与 request_id 正确。设备流走到 `CLI_USER_INELIGIBLE`(账号无订阅,契约行为),token 交换要等有订阅的账号。

**修改范围**: internal/config/config.go(默认 model)、internal/ui/banner_test.go、README.md、config.Version 0.3.3。

**修改内容**: 默认模型 `deepseek-chat`;文档同步。另记两条 Core 事实:CLI 确认页的 `consent_version` 必须是常量 `cli-subscription-v1`(不是 /api/status 的 legal_consent_version),登录请求必须带 `Idempotency-Key` 头——这两条是前端要改的。

**验证**: `go vet`、`go test ./... -count=1` 全绿;真实 Core:key 模式 402 路径通。

**影响 + 部署状态**: 未发新 Release。

## [2026-09-07 04:20 UTC] v0.3.2 启动横幅:БТ 块字(用户从十个方向里选定方案 10)

**背景原因**: 用户要 CLI 左上角有一个"图形的、符合 BrightToken、Claude Code 欢迎框体量"的 logo。十个三行方案里选了西里尔块字 БТ:俄文界面显 БТ + Брайт Код,英文界面显 BT + Bright Code。

**修改范围**: 新增 internal/ui/{banner.go,banner_test.go};cmd/root.go(REPL 启动打横幅、accountLine)、cmd/tty.go(termWidth)、internal/i18n(repl_hints/acct_key/acct_id)、config.Version 0.3.2。

**修改内容**:
- 三行块字:Б `█▀▀▀/█▀▀▄/▀▀▀`、T `▀█▀/ █ / █ `;右侧三行:产品名 + 版本 / 账号 · 通道 · 模型 / 快捷键提示。终端窄于 48 列时信息行挪到字下面。
- 色彩三级:truecolor(COLORTERM、WT_SESSION、256color)→ 字白 T 琥珀 #F5B84A;16 色 → 白 + 黄;NO_COLOR / 非 TTY / TERM=dumb → 无转义。`BT_ASCII=1` → 单行 `[БТ] Брайт Код v0.3.2 · …`,不用块字。
- 只在交互 REPL 启动时打一次(stderr);`bt "prompt"` 单次模式和管道输入不打。
- 替换原来的一行 repl_hello 文案。

**验证**: `go vet`、`go build ./...`、`go test ./... -count=1` 全绿;banner 5 个用例(宽/窄/en 字形/无色/纯文本)。

**影响 + 部署状态**: 仅界面;未发新 Release(v0.3.1 资产不含此横幅,下次打 tag 时带上)。

## [2026-09-07 04:32 UTC] 发布工程:跨平台构建脚本 + CI/CD + 补上没打的版本号;README 加安装节

**背景原因**: 上一条 v0.3.1 commit(18f4cd9)的 CHANGELOG 和 commit message 都写了"cmd/version.go 0.3.1",但实际改动只碰了 `internal/auth/flow.go`/`cmd/login.go`/`internal/auth/flow_test.go`/`CHANGELOG.md` 四个文件——`internal/config/config.go` 的 `const Version` 压根没动,还停在 `"0.3.0"`。也就是说如果当时就直接打包发布,下载下来的"v0.3.1"跑 `bt version` 会自报 `0.3.0`,tag 和二进制对不上。本轮任务是让 CLI 变成可下载发布物(GitHub Releases + CI),顺手把这个版本号错配修掉,不能让错误的版本号被打进第一批发布产物里。

**修改范围**: `internal/config/config.go`(唯一的版本号来源,`cmd/version.go`/`cmd/root.go`/`internal/auth/http.go`/`internal/protocol/client.go` 都从这读)、新增 `scripts/release.sh`、新增 `scripts/ziptool/main.go`、新增 `.github/workflows/{ci.yml,release.yml}`、`README.md`(安装节 + 状态行版本号)。

**修改内容**:
- **版本号修正**: `const Version = "0.3.0"` → `"0.3.1"`,补上 18f4cd9 说了但没做的那一步。
- **`scripts/release.sh`**: 从 `internal/config/config.go` 里 `sed` 出版本号(不是硬编码),`git rev-parse --short HEAD` 拿 commit;`main.go` 目前没有 `commit` 这个变量,所以 ldflags 只给 `-s -w`,没加 `-X main.commit=...`(留了检测逻辑,以后 main 加了这个变量脚本会自动跟上)。`CGO_ENABLED=0` 交叉编译 5 个目标(windows/amd64、darwin/arm64、darwin/amd64、linux/amd64、linux/arm64),产物命名 `bt_<ver>_<os>_<arch>[.exe]`,windows 打 zip、其余 tar.gz,最后生成 `dist/SHA256SUMS.txt`。windows/amd64 是本机原生架构,脚本会自动拿构建出的 `.exe` 跑一次 `version` 冒烟测试。
- **`scripts/ziptool/main.go`**: 踩坑记录——这台机器的 Git Bash 没有 `zip` 命令,退而求其次调 PowerShell `Compress-Archive` 时,它认不出 bash 传过去的 MSYS 风格 `/tmp/...` 路径(`ArchiveCmdletPathNotFound`)。与其折腾路径转换(`cygpath` 在这台机器上还挂在 anaconda 自己的 MSYS 挂载表下,换算出来的路径是错的),不如用 Go 标准库 `archive/zip` 写个十几行的小工具,`go run` 调用,不依赖任何系统 zip 实现,windows/ubuntu/macos CI runner 上都一样能跑。
- **`.github/workflows/ci.yml`**: push/PR 触发,`go vet` + `gofmt -l .`(非空即失败)+ `go test ./... -count=1`,test job 在 ubuntu/windows/macos 三个 runner 上矩阵跑一遍。
- **`.github/workflows/release.yml`**: push `v*` tag 触发,跑 `scripts/release.sh`,`softprops/action-gh-release@v2` 把 `dist/*` 传成 Release 资产,正文取 `CHANGELOG.md` 顶部一条(`awk` 按 `^## ` 分段)。**去重策略选的是"工作流只在没有 release 时创建"这一支**:本轮 v0.3.1 是本机手动 `gh release create` 打的,`gh release create` 建 tag 这个动作本身会触发 `push tags v*`,如果不做检查,工作流会再构建一次、再传一次资产——所以加了一步 `gh release view "$TAG"`,查到已存在就直接跳过后续所有 step,不用 `--clobber` 抢跑。以后走 `git tag vX && git push origin vX` 这种没有手动建过 release 的正常流程,工作流会正常构建发布。
- **README.md**: 状态行 `v0.3.0` → `v0.3.1`;"## Установка" 里加三平台(Windows/macOS×2/Linux×2)`releases/latest/download/...` 直链表格、`SHA256SUMS.txt` 校验命令、Windows SmartScreen 提示(未签名 exe 的正常提示,不是中毒)、`bt login`/`bt auth status`/首次提问三行首次使用示例。

**验证**: `go vet ./...`、`gofmt -l .`(空)、`go test ./... -count=1`(全过,含新增的 `scripts/ziptool` 包)全绿;本机跑了一遍 `scripts/release.sh`,`dist/` 下 5 个包 + `SHA256SUMS.txt` 都出来了,`sha256sum -c SHA256SUMS.txt` 全部 OK,windows/amd64 的 `.exe version` 实跑输出 `bt-cli v0.3.1 (windows/amd64, go1.27.0)`。

**影响 + 部署状态**: 待 push GitHub(GitLab 由用户自己推);随后本机 `gh release create v0.3.1` 用本条之前(即上面 03:15 那条 v0.3.1 功能条目)作为 Release 正文,把这次构建出的 5 个包 + SHA256SUMS.txt 传成 Release 资产。



**背景原因**: 老许的 staging Core(http://40.160.139.29:3000)公网可达,用 `bt login` 直接跑设备流。`device/authorize` 通过(拿到 device_code/user_code/verification_uri),但 `cli/token` 轮询回 400 VALIDATION_ERROR:Core `controller/cli_auth.go` 的 `cliDeviceCodeGrant` 是 `urn:ietf:params:oauth:grant-type:device_code`,我们按 v1.5 文档发的是 `device_code`。这是只有真实联调才暴露的差异。

**修改范围**: internal/auth/flow.go(常量 deviceCodeGrant)、internal/auth/flow_test.go(fakeCore 同步)、cmd/login.go(`BT_NO_BROWSER` 非空时只打印 URL 不弹浏览器,给 CI/冒烟用)、cmd/version.go 0.3.1。

**修改内容**: grant_type 改为完整 URN;refresh 的 `refresh_token` 不变(Core 同名);无头开关。

**验证**: `go vet`、`go test ./... -count=1` 全绿;对真实 staging 再跑一次 `BT_BASE_URL=http://40.160.139.29:3000 BT_NO_BROWSER=1 bt login`:打印 code,轮询进入 pending 持续等待,无错误(未确认,因 staging 登录短信步尚未配通,网页侧无法批准)。

**影响 + 部署状态**: 已推 GitHub + GitLab。提案 §5 DPoP 行补上 grant_type URN。

## [2026-09-07 00:38 UTC] v0.3.0 DPoP 设备流对齐 api-core-service

**背景原因**: 上一轮 agent 按 BC v1.5 PDF 写了 internal/dpop/、auth/flow.go 等一批未提交改动,大方向(DPoP/ES256 device flow)对,但没有对照老许 upstream/api-core-service 的实际代码校对细节,导致多处会在联调时直接 400/401——例如 `public_jwk` 传成嵌套 JSON 对象而不是字符串(服务端 `parseCLIPublicJWK` 用 `UnmarshalJsonStr` 解一个字符串字段)、轮询把"pending"当成 200 里的一个 status 字段而实际永远是真实 HTTP 错误码、`verification_uri` 是相对路径却直接拿去 `xdg-open`/`rundll32` 打开。同时 `internal/agent/loop.go` 被删掉了 `turn()`/`consume()` 等方法但 `Run()` 还在调用,working tree 编译不过;`cmd/auth.go` 还引用着已被替换掉的 `Creds.Login`/`Creds.SessionToken` 字段。本轮任务是把这些逐条对照 `controller/cli_auth.go` + `service/cli_token.go` (VerifyDPoP) + `router/*.go` + `coreerror/errors.go` 校正,brief 与代码冲突处一律以代码为准,查出的分歧点见下方"遗留问题"。

**修改范围**: `internal/dpop/dpop.go`(+`Thumbprint`)、`internal/auth/{http,flow,store}.go`(重写)、`internal/config/config.go`(+`WebBase`)、`internal/protocol/{client,errors,exec}.go`、`internal/agent/loop.go`(补回 `turn`/`consume`/重试与断线续取逻辑,去掉已废弃的余额自动切换死代码)、`internal/i18n/i18n.go`、`cmd/{login,run,auth}.go`;测试:`internal/dpop/dpop_test.go`(+RFC 7638 指纹测试、复刻 `VerifyDPoP` 的 `verifyLikeCore`)、`internal/auth/flow_test.go`(重写 fakeCore,覆盖真实错误码与 refresh 的 ath 特例)、`internal/protocol/exec_test.go`(改成真实的 `result.{content_type,encoding,body}` 嵌套结构)、`internal/agent/{loop_test.go,toolloop_test.go}`(新增断线续取用例,去掉基于 `ChannelBalance`/`OnQuota` 返回值的旧配额切换用例)。版本常量 0.2.2 → 0.3.0。

**修改内容**:
- **`public_jwk` 是 JSON 字符串,不是嵌套对象**:`StartDevice` 现在 `json.Marshal(key.PublicJWK())` 后把结果**字符串**塞进 `{"public_jwk": "..."}`,和 `service/cli_token.go` `parseCLIPublicJWK` 的 `common.UnmarshalJsonStr` 对上。
- **轮询状态机改成真实错误码**:`POST /cli/token` 的 pending/限速/过期/已用/无资格永远是**真正的 HTTP 错误**(`coreerror.HTTPStatus`:400/429/400/400/403),`code` 字段是 `CLI_AUTHORIZATION_PENDING`/`CLI_POLLING_TOO_FAST`/`CLI_DEVICE_CODE_EXPIRED`/`CLI_DEVICE_CODE_CONSUMED`/`CLI_USER_INELIGIBLE`,不存在 200 里塞 `status:"authorization_pending"` 这种形态,也不存在独立的"拒绝"码——Core 里 `model.ErrCLIDeviceCodeDenied` 和过期共用同一个 `CLI_DEVICE_CODE_EXPIRED`,所以 `auth.ErrDenied` 直接删掉并入 `ErrExpired`。
- **`verification_uri` 是相对路径**:新增 `config.WebBase`(默认 `https://bt.projectholo.us`,`BT_WEB_BASE` 覆盖)和 `DeviceStart.AuthorizeURL(webBase)`,`bt login` 打开 `web_base + verification_uri + "?code=" + user_code`(demo_site/cli-auth.html 确认支持 `?code=` 预填),不再把相对路径直接丢给浏览器打开命令。
- **refresh 请求的 DPoP `ath` 是刷新的那个 refresh_token 的哈希,不是空、也不是 access_token**:读 `controller/cli_auth.go` `cliRotateRefreshToken` 发现它调用 `VerifyDPoP(..., accessToken=rawRefreshToken)`,与"CLI/token 端点 ath 必须缺省"的通用说法直接冲突——**代码为准**。`internal/auth/http.go` 的 `call()` 拆成 `bearer`(Authorization 头)和 `athOf`(proof 里 ath 的哈希对象)两个独立参数,`Refresh()` 传 `athOf=refreshToken`、`bearer=""`。
- **DPoP 401 分类重试**:`CLI_DPOP_INVALID`/`CLI_DPOP_REPLAY` 不再触发自动 reauth(证明本身坏了,换新 access token 也没用),其余 401(含 `CLI_ACCESS_TOKEN_INVALID`/`CLI_SESSION_REVOKED`)照常重试一次;`refreshCreds` 按返回码分流:`CLI_REFRESH_EXPIRED`/`CLI_REFRESH_REPLAY`/`CLI_SESSION_REVOKED` → 清本地会话提示 `bt login`,`CLI_USER_INELIGIBLE` → 清本地会话提示 `bt login --key`(服务端在这两种情况下都已经把整族 refresh token 吊销了,本地没必要留着),`CLI_DPOP_INVALID`/`CLI_DPOP_REPLAY` → 不清,只提示查时钟。
- **过期前 60 秒主动 refresh**:`Creds`/`protocol.Client` 新增 `AccessExpiresAt`,登录/刷新时按 `expires_in` 落盘,`Client.do()` 每次请求前检查,快到期就先 `Reauth` 再发请求,不必等 401。
- **`internal/agent/loop.go` 补全并对齐 v0.2.2 决策**:恢复被误删的 `turn()`/`consume()`/`shouldRetry()`/`sleepCtx()`(working tree 之前编译不过);`OnQuota` 签名去掉已经用不上的 `bool` 返回值和 `ChannelBalance` 切换分支(CHANGELOG v0.2.2 早就决定不切,只是没删代码——这轮删干净);新增断线续取:流中断且屏幕上还没打印任何字符时,先 `POST executions/lookup` + `GET executions/{id}` 查真实状态(`pending/completed/failed/needs_review`),`completed` 直接拿结果用、`failed`/`needs_review` 直接报错,都查不出来才退回原来的"同 idempotency_key 盲重"兜底。
- **`internal/protocol/exec.go` 对齐真实响应结构**:`Execution.Result` 是 `{content_type, encoding, body}`(`body` 是原始响应体,`encoding` 为 `utf-8`/`base64`),不是直接把模型响应摊平在 `result` 里;新增 `Bytes()` 按 `encoding` 解码,`Content()`/`ToolCalls()`/`Usage()` 先判断 `content_type` 是不是 JSON 再解析,非 JSON(流式结果的拼接文本)时优雅返回空而不是 panic。
- **修掉两处遗留死代码/半成品**:`cmd/auth.go` 里引用的 `Creds.Login`/`Creds.SessionToken` 字段早被上一轮改没了但没同步(编译期就会报错,这轮换成 `UserID`/`Scope`/`AccessExpiresAt`);i18n 表里 4 个被引用但没定义的 key(`err_403_calls`/`err_403_seat`/`err_409_version`/`err_503_payment`,命中时会把 key 名字原样显示给用户)补上,4 个已经没人用的 key(`login_denied`/`quota_ask`/`quota_switched`/`quota_no_balance`,配额自动切余额那套废弃设计的残留)删掉。

**验证**: `go build ./...`、`go vet ./...`、`go test ./... -count=1` 全过(38 个测试,含新增的 DPoP 复刻校验器 `verifyLikeCore`——覆盖 ath 有/无、htu 去 query、iat [-60s,+5s] 窗口边界、jti 长度/前后空白、指纹匹配——以及断线续取的两条路径:lookup 命中 completed 直接复用结果 / 一直查不出来兜底盲重同 key)。`gofmt -l .` 干净。

**遗留问题(brief 与代码的已知分歧,已按代码实现,供后续核对)**:
1. `POST /cli/token` grant_type=refresh_token 的 DPoP `ath` = hash(refresh_token),不是"token 端点永远不带 ath"——见上文,这是本轮抓到的最容易踩的坑。
2. `GET /api/user/self`(`controller/user.go` `GetSelf`)返回的字段里**没有** `account_type`——只有 `controller/auth_v1.go` 的登录/注册系响应才带这个数字字段(1=个人/2=团队管理员/3=团队子账号)。brief 里提到"用 `GET /api/user/self` 的字段判断 account_type"这条目前对不上代码;CLI 这版没有依赖 `account_type` 做任何客户端判断(资格检查完全交给 Core 的 401/403),所以不影响本轮交付,但如果以后要在 CLI 侧展示"个人/团队"这类信息,得换一个真正返回它的接口。
3. Core 目前**不会**在任何 CLI 响应里返回 `proof_key` 字段(`X-BT-Proof` 是我方独立扩展,和 DPoP 无关)。代码保留了 `TokenPair.ProofKey`/`SessionInfo.ProofKey` 字段并在为空时静默跳过,行为上完全没问题,只是目前永远是空字符串——不是 bug,备忘。
4. `CLI_DEVICE_CODE_EXPIRED` 同时对应"设备码真的过期"和"用户在网页上点了拒绝"(`model.ErrCLIDeviceCodeDenied` 复用同一个 core 错误码),CLI 没法区分,统一提示"过期或被拒绝,重新 bt login"。

## [2026-09-04 14:10 UTC] v0.2.2:402 按 v1.0 §08——提示改用 API Key,device-flow 会话不再走余额通道

**背景原因**: 09-04 用户拍板采用老许方案:CLI 订阅配额尽后提示改用 API Key,不走余额通道(我方 v85 的"征同意后烧余额"作废)。

**修改范围**: cmd/run.go、internal/i18n、README。

**修改内容**: `resolveChannel` 无生效订阅 → 直接退 2 并提示 `bt login --key sk-…`(不再落到 `/inference/balance/*`,device-flow access_token 不应有该权限);`OnQuota` 不再问 y/N,交互模式打印"改用 API 密钥"提示后退 4;agent 层的切换机制保留未用。README 通道表改为两行。

**验证**: build/vet/test 全过(25 用例;`TestRun_QuotaSwitch` 仍验证 agent 层机制)。

## [2026-09-04 12:40 UTC] v0.2.1 审查修复:v1.0 平铺错误信封、母号订阅通道、409 语义、时区符号

**背景原因**: 对 v1.1 提案 + 三份代码跑两轮对抗审查,CLI 侧抓到 5 处与 v1.0 / 定案不一致。

**修改范围**: internal/protocol/{client,errors,types}.go、internal/auth/{http,account}.go、internal/protocol/context.go、internal/agent/loop.go、internal/i18n。

**修改内容**:
- **错误信封**:v1.0 §02 的 HTTP 错误是平铺 `{success:false, code, message, details, request_id}`,原实现只认 `{"error":{…}}`(那是 SSE 流内形态)。现在两种都解(`decodeEnvelope`),auth 层同样。
- **通道选择**:`HasLiveSubscription` 由"仅 PERSONAL"改为"非 TEAM_MEMBER"——09-03 批注:升级母号后原订阅继续可用(提案 A2)。
- **401 码族**:加 v1.0 的 `CREDENTIAL_MISSING / CREDENTIAL_INVALID / SESSION_EXPIRED`(原来只有自造的 TOKEN_*);触发刷新一律看 HTTP 401。
- **409 语义拆分**:`IDEMPOTENCY_KEY_CONFLICT` = 参数变了,终态不重试(文案改);新 `IDEMPOTENT_REQUEST_IN_PROGRESS` = 等 `retry_after_ms` 再用同 key 重发。
- `MEMBER_BUDGET_EXHAUSTED` 归 403(限额非钱包,与 API_KEY_QUOTA_EXHAUSTED 同类),退出码仍 4;新 `CARD_NOT_BOUND` 文案;`usage.total_tokens`。
- **时区符号**:`timezone_offset_min` 改为 JS `getTimezoneOffset()` 约定(莫斯科 = −180),与前端、封控 BehaviorRule 一致。

**验证**: gofmt/vet 干净,25 单测全过。

## [2026-09-04 11:30 UTC] v0.2 完成:device flow、订阅通道 + 402 交互切余额、X-BT-Proof、工具循环 + 三层权限门、会话续接

**背景原因**: 用户"做完"。v0.1 只有 API-key 单发聊天;这版把 CLI 做成真正的终端代理,并接完 BC 协议 §09(CLI Device Flow)与 §08 订阅通道。TUI(Bubble Tea)留 v0.3。

**修改范围**: internal/auth/{http,flow,account}.go(新)、internal/protocol/{types,proof}.go(新)+ client/stream 重写、internal/tools(新:registry + bash/read/write/edit/glob/grep/ls)、internal/permission(新)、internal/session(新)、internal/agent/loop.go 重写、cmd/{root,run,login,logout,auth}.go、i18n;+11 个测试文件;依赖不变(x/term 已有)。

**修改内容**:
- **登录**:`bt login` = device flow(`POST /api/v1/cli/device/code` → 打开浏览器到 cli-auth 页 + 显示码 → 轮询 `/cli/device/token`,认 §10 错误码与 200-status 两种 pending 形态,slow_down +5 s,denied/expired 明确退出)→ 拿 access/refresh/proof_key/account → 立刻换 `cli_subscription` session-token → keychain(>2000 字节自动落文件 0600,并清掉旧 keychain 条目)。`bt login --key sk-…` / `--key -`(隐藏输入或 stdin)保留 key 模式。`bt logout` 先 `/cli/session/revoke`(best effort)再清本地。
- **通道选择**:session 模式启动时 `GET /account`(401 → `/cli/token/refresh` 换对后重试):PERSONAL 且订阅 ACTIVE|CANCEL_AT_PERIOD_END → 订阅通道 `/api/v1/inference/cli/subscription/chat/completions`,否则余额通道 `/api/v1/inference/balance/…`;key 模式 `/v1/chat/completions`。REPL 抬头显示当前通道。
- **402 交互切余额**:订阅通道收到 `SUBSCRIPTION_QUOTA_EXHAUSTED`(且未输出任何内容)→ 终端 y/N;同意 → 通道切余额,**新 idempotency_key** 重发同一业务内容,本进程后续都走余额;拒绝 → 退 4;`balance_mode_available:false` 直接提示充值,不问。
- **401 自动重认证**:流开始前 401 → `Reauth` 一次(订阅:重取 session-token,若 access 也 401 则先 refresh;余额:refresh)→ 同一请求同一 key 重发;第二次仍 401 → 退 2。刷新后的 token 对与 proof_key 落盘。
- **X-BT-Proof**:订阅/余额通道每请求签一次,格式与 bt-risk-service `signals/proof.go` 逐字一致(版本 `c1`,env{os,arch,cli},nonce UUID,TTL 由封控管);互通夹具已进封控仓 `TestCLITokenInterop`(bt-risk-service v0.1.2 默认白名单 g1,c1)。key 通道不发。
- **工具循环**:每轮请求带 `tools`(OpenAI function 形态)+ system prompt(cwd/OS/日期/规则);流内 `delta.tool_calls` 按 index 拼装,`finish_reason=tool_calls` → 执行 → `{role:tool, tool_call_id, name, content}` 回填 → 下一轮(新 key);上限 25 轮。工具:bash(bash→sh→PowerShell 探测,超时 120 s,非零退出码作为结果回传)、read(行号/offset/limit)、write、edit(唯一匹配或 replace_all)、glob(** 支持,跳过 .git/node_modules 等,新→旧)、grep(Go regexp 遍历,-i,glob 过滤,200 条上限)、ls。输出上限 30 KB。
- **三层权限门**(learn-claude-code s03 形态):①硬 deny 正则(rm -rf /、sudo、mkfs、dd of=/dev、shutdown、fork bomb、format/diskpart、`del /s /q C:\`、Remove-Item -Recurse C:\、`git push --force main`、`curl|sh`)——**`--yes` 也过不去**;②规则表:只读工具直接放行,bash 的每段管道首词都在安全表(ls/cat/git status|log|diff/go test|build/rg…)且无重定向/子 shell 才放行,危险子命令(git push/reset、go install、npm install、sed -i…)不算;③交互 y/N/a(a=本会话该工具免问);无终端且无 `--yes` → 拒绝并把原因回给模型。
- **会话**:`~/.config/bt/sessions/<id>.jsonl`(首行 meta),每条消息即时追加;`bt -c` 续上最近一次。`--no-tools` 纯聊天;`-y/--yes`;`--model`;`--lang`。
- **协议侧**:v1.1 提案追加 §12(§09 路径前缀、device/code 请求体、pending 形态、device/token 响应含 proof_key+account、通道选择规则、tools 在余额与 /v1 通道也要透传、per-turn 幂等口径)。

**验证**: gofmt/vet 干净;单测 25 个全过(新增:tool_calls 增量拼装、工具循环消息形态与 per-turn 新 key、402 切余额/拒绝、401→Reauth 同 key 重发 + 订阅通道必带 X-BT-Proof、硬 deny/安全命令/交互 y-N-a/非交互、read/write/edit/glob/grep/bash、proof 夹具);fake 上游(device flow pending×2、refresh、session-token、account、三通道)冒烟:登录→状态→订阅通道聊天(带 proof)→工具循环 echo 自动放行→touch 非交互被拒且未落盘→`--yes` 落盘→quota 非交互退 4→`-c`→logout revoke。

**已知/留 v0.3**: TUI(Bubble Tea 滚动视图 + 权限弹窗);REPL 内多行输入;上下文压缩(learn-cc s08);skills 装载(s07);`event:` 行 SSE(Anthropic 风格,§11 /v1/messages 时);hooks。**踩坑**:通过本工具写文件时 Go 原生字符串里的 `\\` 会被折成 `\`,正则里的反斜杠一律写 `\x5c`;`os.ModeCharDevice` 不是 TTY 判定(/dev/null 也是 char device),用 `term.IsTerminal`。

## [2026-09-04 09:20 UTC] v0.1.1 复盘修复:幂等键复用、部分输出不重试、Ctrl+C 语义、网关错误映射

**背景原因**: 用户要求 review 一遍 v0.1。通读全部 1074 行后发现 5 个真 bug + 一批打磨项,全部修完并补测试;fake 上游冒烟覆盖 503/402/HTML-502/流中断/拒连/`--model`。

**修改范围**: internal/agent/loop.go(重写)、internal/protocol/{client,errors,stream,context}.go、internal/config/config.go、internal/i18n/i18n.go、cmd/{root,login,auth,version,logout}.go、新增 loop_test.go / httperr_test.go、README;依赖 +golang.org/x/term(隐藏输入)。

**修的 bug**:
- **重试每次生成新 idempotency_key**(`req` 每轮重建)→ 503 中途重发可能双扣。现在一个 Run 一个 key,重试原样复用(§12.7);fake 日志实证同 key #1→#4。
- **已打印部分输出后仍重试** → 屏幕文本重复。现在只要收到过 delta 就不再重试,换行后原样报错。
- **失败的 user 消息留在 History** → REPL 下一轮送出 [user,user]。失败即回滚。
- **Ctrl+C**:单发返回 130 而不是 "context canceled"+1;REPL 里 Ctrl+C 只打断当前回答(每轮独立 ctx + 信号排空),Ctrl+D / /exit 退出。
- **非 BC 形状的网关错误**(nginx HTML 502、纯文本 401)整页 HTML 当 message,且不进重试/认证映射。现在按状态码补 code(401→UNAUTHORIZED、429→RATE_LIMIT_EXCEEDED、502/503/504→UPSTREAM_UNAVAILABLE),message 去标签截 160 字。
- **拒连/DNS 等 url.Error 退出码 1、原始 Go 错误** → 退 3,只显示传输层原因。
- 重试范围从"仅 503"扩到 502/503/504/*_UNAVAILABLE;无 Retry-After 时 5xx 等 2 s(原 5 s),重试前 stderr 提示"Повтор через N s…";最终失败文案不再带半截"Повтор через"。

**打磨**: Transport 加 dial 10 s / TLS 10 s / 响应头 60 s 超时(流本身不限);`bt login` 无参数时终端隐藏输入(x/term),管道走 stdin,`--key` 保留但提示会进 shell 历史;`auth status` 按模式显示(key/session);`--model` 全局旗标;cobra 自带 `-v/--version`;`Run` 返回值改为 `(usage, code, err)`;去掉 `AddConfigPath(".")`(陌生 config.yaml 不得改 base_url);locale 归一为 ru|en;device_fp 加入用户名(hostname|user|OS|arch),删掉永不触发的随机分支;错误返回改 `exitError` 统一在 Execute 出口,defer 正常执行;gofmt 全文件。

**验证**: gofmt/vet 干净;单测 12 个全过(新增:503 重试复用同 key、部分输出后不重试且 History 回滚、402 退 4、取消退 130、HTML 502 映射、泛 401 映射、snippet 截断);fake 上游冒烟 11 项全符合预期。REPL 交互路径(isatty)本机无法自动化,靠单测覆盖 Run 逻辑。

**已知/留 v0.2**: Windows Credential Manager 单条 blob 上限 2560 字节(UTF-16 后约 1280 字符)——v0.2 的 access+refresh+session+proof_key 四个 JWT 会超,届时按条目分存或直接走文件后备;`event:` 行(Anthropic 风格 SSE)当前忽略,§11 `/v1/messages` 接入时要处理。

## [2026-09-04 14:00 UTC] v0.1 骨架:API-key 模式 + BC 协议流式聊天

**背景原因**: 09-04 用户拍板 CLI 用 Go,顺序封控→前端→CLI。前端 v100 已上线;后端 `/auth/cli/*` 端点尚未 ready,先跑通 API-key 路径证明协议对接与错误处理形状,device flow 留 v0.2 与后端并行落地。

**修改范围**: 新仓库 bt-cli/。Go 1.23,依赖 spf13/cobra + spf13/viper + zalando/go-keyring + google/uuid;不引 openai-go(SSE 与错误形状自控,减依赖减攻击面)。

**修改内容**:
- 命令:`bt login/logout/auth status/version/chat`;`bt` 无参进 REPL,`bt "prompt"` 单发,`echo …| bt` 走 stdin。
- 存储:token 进系统 keychain(macOS Keychain / Windows Credential Manager / GNOME libsecret / KDE KWallet),不再明文 config;fallback 到 ~/.config/bt/token 时提示。
- 协议客户端:`/v1/chat/completions` OpenAI 兼容口,SSE 解析(delta.content / [DONE] / 流内 error),Accept-Language + X-Client-Version + X-Client-Context(base64 JSON,CLI 只填 device_fp/user_agent/timezone/locale)头,请求体自动注入 `idempotency_key`(UUID v4)与 `stream: true`。
- 错误码映射(BC §10):401/402(BALANCE_INSUFFICIENT)/403(API_KEY_QUOTA_EXHAUSTED / RISK_CHECK_DENIED)/409/429(带 retry-after 退避)/503(退避);每条都有 RU/EN 文案 + request_id 打印。
- i18n:双语字符串表 `msg(code, lang)`,`--lang` 或 `BT_LOCALE`,默认俄。
- Agent 循环:v0.1 只跑一次生成(无工具),已按 opencode-go 骨架预留工具循环结构(`FinishReasonToolUse` → 追加 tool_result → continue)。

**验证**: go build/vet 全过;单元测试(SSE 解析、错误映射、idempotency 注入);冒烟:自建 http.Server + text/event-stream 假上游 → `bt "hi"` 流式打印 → [DONE] 收尾 → 退出码 0;401 → 明确"key 已失效"退 2;429 → 提示 retry-after 退 3。

**影响 + 部署状态**: 未发布到 releases;GOPATH 用户可 `go install`。等后端 `/auth/cli/{code,token,approve}` 上线接 v0.2 device flow。仓库私有(gh CLI 已登 DrWikkZ)。
