refactor: 0.1.0-beta

This commit is contained in:
leokun
2026-08-13 22:01:11 +08:00
parent a3ec2a0dfc
commit 3e7a15017d
401 changed files with 8696 additions and 182189 deletions
-356
View File
@@ -1,356 +0,0 @@
---
name: coding-guidance
description: 本地模式实现指南
---
当用户在处理本地模式的时候,使用此指南
先遵守这个约束:
- 不要修改已安装的 Cursor 客户端代码、bundle 或 app 副本。
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、协议与仓库代码。
- 如果用户提到“临时 patch 客户端做 e2e”,也要改成只读排查:核对实际运行副本、采集证据、对照仓库实现,然后把修复落在本仓库代码或输出明确结论。
如果问题已经涉及以下任一事项,请同时读取 `../cursor-client-e2e-debugging/SKILL.md`
- 需要只读核对已安装的 Cursor 客户端 bundle、日志或运行副本
- 需要确认当前到底是哪一个 app 副本在运行
- 需要同时排查客户端 bundle 与本仓库 forwarder 的协同问题
- 需要对照已安装客户端行为与本仓库实现差异
本地模式协议需要优先核对这些文件:
- proto/agent_v1.proto
- proto/aiserver_v1.proto
客户端是:/Users/leokun/Library/Application\ Support/Cursor
客户端 bundle 是:/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js
## 可选抓包调试工具
仓库提供了独立的 Cursor 协议抓包调试器。开发者在手动排查协议问题时,可以运行:
```bash
go run ./cmd/cursor-proxy-debugger
```
默认代理地址是 `http://127.0.0.1:9090`,调试界面是 `http://127.0.0.1:9091`。该工具可以辅助查看:
- `agent.v1.AgentService/RunSSE`
- `aiserver.v1.BidiService/BidiAppend`
- Connect 帧、gzip 压缩内容、Protobuf 解码结果和原始二进制数据
- 同一 `request_id` 对应的上下行消息
开发者启动工具后,需要自行完成以下配置:
1. 在 Cursor 的代理设置中,将代理修改为工具启动时显示的代理地址,默认是 `http://127.0.0.1:9090`
2. 在 Cursor 的 Network 设置中开启 HTTP/1.1。
3.`http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
这只是供开发者手动使用的辅助工具,不属于自动化 Debug 流程。不要因为加载此指南就自动启动代理、修改 Cursor 或系统设置、安装证书,或操作 Cursor 发起请求。只有开发者明确表示已经启用抓包时,才把调试界面中的数据作为当前运行证据。调试结束后,提醒开发者恢复原来的 Cursor 代理和 Network 设置。
## Cursor 客户端格式化快照
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill。
- 如果本仓库存在 `.cursor-app-formatted/`,排查客户端 bundle 时优先读取这里的格式化副本。
- `.cursor-app-formatted/` 是从 `/Applications/Cursor.app/Contents/Resources/app` 只读提取后格式化生成的本地快照;它不应写回、替换或影响已安装的 Cursor.app。
- 常用格式化路径:
- `.cursor-app-formatted/extensions/cursor-always-local/dist/main.js`
- `.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js`
- `.cursor-app-formatted/extensions/cursor-agent-worker/dist/main.js`
- `.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js`
- `.cursor-app-formatted/out/vs/workbench/api/node/extensionHostProcess.js`
- 如果 `.cursor-app-formatted/` 不存在、明显过期,或需要核对真实安装包 hash,再只读读取 `/Applications/Cursor.app` 原始 bundle。
## 本仓库已固定的会话承接规则
- 下一轮真实请求给 LLM 的历史承接,以 `history/<conversationId>/state.json` + `history/<conversationId>/context.json` 为持久化事实源。
- `state.json` 保存会话元数据和当前状态,例如 `next_turn_seq``next_entry_seq``context_version``current_todos``current_plans``latest_request_prefix``last_provider_call`
- `context.json.items` 保存 append-only 的语义历史 entriesprovider messages 不是主存储事实,而是由 `ProjectPromptReplay()` 从 entries 投影出来。
- 模型渠道唯一性不再由 `modelID` 决定;当前规范化渠道 ID 是 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 `SHA-256` hashresolver 仍兼容 legacy `baseURL + modelID + apiKey + displayName`
- 可 replay 的历史应以 entry 顺序稳定追加,不能把已发送给模型且仍需保留的历史移动到新位置。
- 最新态、易变态,例如 active todo、current plan、最新编辑保护和动态 reminder,应优先作为 `state.json` 状态或本轮 latest-only suffix;不要无意持久化成会在后续轮次无限 replay 的历史。
- 新一轮 `run_request` 到来时,服务端应通过 `LoadConversation()` 读取 `state.json + context.json`,再由 projector 投影 prompt replay;客户端带回来的 checkpoint/replay 不参与历史承接真相判定。
- `summary.json``replay.json``runtime.json``request.json``conversation.json``entries.jsonl``turns/` 和数字 turn 目录都属于旧持久化产物,会被 history maintenance 当 legacy artifact 清理。
- 如果发现请求历史与本地状态不一致,优先检查 `context.json.items` 是否缺失、重复、顺序异常,以及 `state.json``next_entry_seq``next_turn_seq``context_version`、当前状态字段是否与 entries 派生结果一致;不要再按旧 `summary.json` 路径排查。
# 已确认结论
## 1. `AgentServerMessage` 不是统一都要“回复完成”
要按 `oneof message` 分类看:
- `exec_server_message`
- 这是服务端发给客户端的“执行请求”。
- 客户端需要显式回 `ExecClientMessage`
- 流式/异常场景下还会回 `ExecClientControlMessage`,常见是:
- `stream_close`
- `throw`
- `heartbeat`
- `interaction_query`
- 这是服务端发给客户端的“交互请求”。
- 客户端需要显式回 `InteractionResponse`
- `interaction_update`
- 这是展示/状态更新消息,通常不需要客户端回包。
- `conversation_checkpoint_update`
- 这是 checkpoint 同步消息,通常不需要客户端回包。
- `kv_server_message`
- 这是 KV 同步消息,通常不需要客户端回包。
- `exec_server_control_message`
- 这是服务端对执行桥的控制消息(例如 abort),客户端要按控制语义处理,但不是通用“完成 ack”。
## 2. Cursor 客户端没有“收到任意 `ServerMessage` 自动回 ack”的通用层
`cursor-always-local/dist/main.js` 里,`BidiTransport.startYieldingInputsToTheServer` 只会把“客户端主动产出的消息”送到 `BidiAppend`
- 它对输入 iterable 做 `p.value.toBinary()` 后 hex 编码,再发 `BidiAppendRequest.data`
- 说明只有客户端业务逻辑主动产出的 `AgentClientMessage` 才会上行
- 没有发现“收到一个 `AgentServerMessage` 就自动回 completed/ack”的统一机制
因此客户端是否回包,取决于上层业务逻辑有没有因为某个下行消息而主动构造新的 `AgentClientMessage`
## 2.1 更具体的客户端侧结论
`cursor-always-local/dist/main.js` 里能直接确认:
- `AgentServerMessage` 的下行类型里有:
- `interaction_update`
- `exec_server_message`
- `exec_server_control_message`
- `conversation_checkpoint_update`
- `interaction_query`
- `AgentClientMessage` 的上行类型里有:
- `run_request`
- `exec_client_message`
- `exec_client_control_message`
- `interaction_response`
这意味着本地模式不是“server message -> 通用 ack”模型,而是:
- `exec_server_message`
-> 客户端执行本地工具
-> 产出 `exec_client_message`
-> 以及可选 `exec_client_control_message`
- `interaction_query`
-> 客户端展示或处理交互
-> 产出 `interaction_response`
- 其他下行消息
-> 一般只更新 UI / checkpoint /流状态
-> 不会自然地产生一个“完成 ack”
## 2.2 `exec_server_message` 常见的客户端回包形态
客户端协议模型里已确认这些回包类型:
- `ExecClientMessage`
- 正常结果面
- 包括 `read_result` / `write_result` / `grep_result` / `ls_result` / `diagnostics_result` / `mcp_result` / `shell_stream`
- `ExecClientControlMessage`
- 控制面
- 包括:
- `stream_close`
- `throw`
- `heartbeat`
所以调查本地模式 exec 问题时,不要只盯 `ExecClientMessage`
- 有些工具只回一次结果面消息
- shell 之类的流式工具会混合回:
- 多次 `shell_stream`
- 以及控制消息(例如 `stream_close` / `heartbeat`
## 2.3 `exec_server_message` 的完整回包形态
事实依据:
- `proto/agent_v1.proto`
- `ExecServerMessage.oneof message`
- `ExecClientMessage.oneof message`
- `ExecClientControlMessage.oneof message`
- `cursor-always-local/dist/main.js`
- bundle 内含同名 proto 模型
- `BidiTransport.startYieldingInputsToTheServer` 说明客户端上行消息来自业务逻辑主动构造,不存在通用自动 ack
### 结果面回包:`ExecServerMessage` -> `ExecClientMessage`
`ExecServerMessage``message` 分支与 `ExecClientMessage``message` 分支是一一对应的:
- `shell_args`
-> `shell_result`
- `write_args`
-> `write_result`
- `delete_args`
-> `delete_result`
- `grep_args`
-> `grep_result`
- `read_args`
-> `read_result`
- `ls_args`
-> `ls_result`
- `diagnostics_args`
-> `diagnostics_result`
- `request_context_args`
-> `request_context_result`
- `mcp_args`
-> `mcp_result`
- `shell_stream_args`
-> `shell_stream`
- `background_shell_spawn_args`
-> `background_shell_spawn_result`
- `list_mcp_resources_exec_args`
-> `list_mcp_resources_exec_result`
- `read_mcp_resource_exec_args`
-> `read_mcp_resource_exec_result`
- `fetch_args`
-> `fetch_result`
- `record_screen_args`
-> `record_screen_result`
- `computer_use_args`
-> `computer_use_result`
- `write_shell_stdin_args`
-> `write_shell_stdin_result`
- `execute_hook_args`
-> `execute_hook_result`
- `subagent_args`
-> `subagent_result`
所有这些结果面回包都带:
- `id`
- `exec_id`
服务端匹配时通常优先用:
1. `exec_id`
2. `id`
### 控制面回包:`ExecServerMessage` -> `ExecClientControlMessage`
除了结果面回包外,客户端还可能回控制面消息:
- `stream_close`
- 表示当前 exec 流已关闭
- 只有 `id`
- `throw`
- 表示执行异常
- 只有 `id` + `error` + 可选 `stack_trace`
- `heartbeat`
- 表示执行过程中的心跳
- 只有 `id`
### 关键理解
- `ExecClientControlMessage` 不是某个单独 `ExecServerMessage` 分支的“专属结果类型”
- 它是跨 exec 通用的控制面回包
- 因此调查时必须同时看两类上行:
- `ExecClientMessage`
- `ExecClientControlMessage`
### 调查规则
对于任意 `exec_server_message`,至少要确认以下之一是否发生:
- 收到对应的 `ExecClientMessage`
- 或收到 `ExecClientControlMessage.throw`
- 对流式 exec,还要看:
- 是否有多次增量 `ExecClientMessage`
- 是否最终有 `stream_close`
如果只看到 started / pending,没有任何结果面或控制面回包,服务端 pending 大概率不会收口。
排查时优先搜索这些关键字:
## 3. forwarder 状态机实现规则
在本仓库修本地模式 forwarder 时,默认遵守下面这些稳定约束,避免再次引入“工具晚到污染当前轮”或“同一 request 在 `[DONE]` 后又续跑一轮”的问题。
### 3.1 resume 必须按 provider pass 隔离
- `request_id` 不是 provider 调用代次;同一个 request 可以合法包含多次 provider pass。
- `scheduleProviderResume` 不能只依赖 request 级布尔态(例如单个 `ResumePending`)。
- resume 请求必须带来源 pass,至少要能区分:
- 当前 pass 的结果触发的合法续跑
- 上一轮工具终态晚到造成的陈旧 resume
- `driveProvider` 开始与结束时都要显式清理上一轮的 resume 状态,不能让旧状态跨 pass 残留。
### 3.2 工具晚到是常态,只能影响所属 pass
- `ExecClientMessage` / `ExecClientControlMessage` 晚于 provider `[DONE]` 到达是正常现象。
- 晚到结果只能驱动其所属 pass 的 checkpoint / history / resume 判定,不能影响后续 pass。
- 非流式 exec 的 `stream_close` synthetic recovery 也必须沿用原工具的来源 pass,不能按 request 级全局状态续跑。
### 3.3 pending exec 必须严格按 id 匹配
- `selectPendingExec` / `selectPendingExecByControl` 只允许按:
- `exec_id`
- `message_id`
进行匹配。
- 不允许再用“当前只有一个 pending,就直接返回它”的兜底逻辑。
- 迟到的 result / `stream_close` / `throw` 如果 pending 已不存在:
- 优先看 `RecentCompletedExecs` 做幂等忽略
- 不要把它重新落到当前轮的 pending 上
### 3.4 看到这些现象时,优先怀疑 stale resume / stale exec
如果出现下面任一现象,先查 forwarder 状态机,不要先怪客户端:
- 同一个 `request_id``[DONE]` 后又出现新的 `model_call_id`
- `turns/<n+1>/request.json``turns/<n>/request.json` messages 几乎完全相同
- 上一轮工具 `grepResult/readResult/...` 晚于上一轮 `[DONE]`
- 晚到的 `stream_close` 恰好跨到下一轮 provider 已经启动之后
优先核对:
- `ProviderPassCount`
- resume 请求的来源 pass
- `PendingExec.ProviderPass`
- `selectPendingExec` 是否存在跨轮误匹配
- `startYieldingInputsToTheServer`
- `bidiAppend({requestId:A,appendSeqno`
- `ExecServerMessage`
- `ExecClientMessage`
- `ExecClientControlMessage`
- `InteractionQuery`
- `InteractionResponse`
## 3. 对本地模式最重要的协议理解
- `exec_server_message` / `interaction_query` 属于“请求型下行消息”
- 如果客户端不回对应结果,服务端 pending 不会收口
- 后续重连后可能出现 “No tool output found for function call ...” 这类 provider 400
- `interaction_update` / `conversation_checkpoint_update` 属于“通知型下行消息”
- 它们用于 UI 展示、同一 backend 进程内的 live checkpoint 同步、状态同步
- 一般不要求客户端再回一个“完成”消息
## 4. 调查本地模式时的优先顺序
1. 先确认收到的 `AgentServerMessage` 是哪一类
2. 如果是 `exec_server_message`
- 查客户端是否回了 `ExecClientMessage`
- 查是否只回了 `stream_close` 但没有真正结果
-`exec_id` / `id` 是否匹配
3. 如果是 `interaction_query`
- 查客户端是否回了 `InteractionResponse`
4. 如果是 `conversation_checkpoint_update`
- 重点查里面的 `pending_tool_calls` / `root_prompt_messages_json` / `turns`
- 不要误以为它本身需要回 ack
## 5. 对服务端实现的直接要求
- 服务端必须区分“请求型下行”和“通知型下行”
- 服务端不能把 `ServerMessage` 统一建模成“发出去就等一个完成 ack”
- 对请求型消息,必须在本地状态机里维护 pending:
- `PendingExec`
- `PendingInteraction`
- 同一 backend 进程内的 `RunSSE` 重连,要优先看 checkpoint / `pending_tool_calls` 里的 live pending
- backend 重启后,不要把 checkpoint 当持久恢复点;跨轮承接与持久恢复只看 `history/<conversationId>/state.json` + `history/<conversationId>/context.json`
### 5.1 checkpoint 投影必须幂等且只有一个事实源
- 把 checkpoint 当作 `state.json + context.json` 的纯投影,不要把它写成第二套语义历史。
- 不要创建或维护 `checkpoint.json`、checkpoint history、独立 checkpoint entry 序列等持久化事实源。
- 允许在当前 stream 内存中保留 latest checkpoint 供 retry/resume 使用;进程重启后必须能从唯一事实源重新投影。
- 对同一份 semantic history 重复投影时,要求 state、turn 顺序、blob ID 和 blob 内容在语义上完全一致;投影函数不得修改输入 history。
- 把重复发送视为同一快照的幂等覆盖,不要追加一条新的会话历史;内容寻址 blob 的重复写入必须可安全忽略。
-`turns` 投影为 UI 可恢复的完整结构,保留所有需要展示的 `ThinkingMessage``ToolCall` 和工具结果;不要为了模型 prompt 过滤而删除 UI step。
-`root_prompt_messages_json` 单独投影为模型 replay;只在这条投影上应用 provider/context 过滤,不能反向改变 `turns`
- 将工具完成结果合并回同一 `ToolCall`,保留开始态的 `args`、调用 ID 和开始时间,再补齐 `result` 与完成时间;不要制造协议不存在的独立 `ToolResult` step。
- 用 TDD 覆盖至少这些性质:重复投影相等、投影不修改 history、开始态字段在结果合并后仍存在、UI turns 保留思考/工具内容而模型 replay 仍遵守独立过滤规则。
@@ -1,4 +0,0 @@
interface:
display_name: "本地模式实现指南"
short_description: "当用户在尝试解决本地模式问题时,使用此技能"
default_prompt: "使用 $coding-guidance 来解决本地模式问题。"
@@ -1,107 +0,0 @@
---
name: cursor-app-formatted
description: Use when extracting, formatting, refreshing, or investigating a read-only formatted snapshot of the installed Cursor.app bundle under .cursor-app-formatted; includes git-ignore rules, snapshot generation workflow, and the rule to inspect formatted code without patching either the snapshot code or the installed app.
---
# Cursor App Formatted Snapshot
Use this skill whenever a task involves reading, searching, formatting, refreshing, or relying on a formatted copy of the installed Cursor client bundle.
## Invariants
- Never modify `/Applications/Cursor.app`, any installed app bundle, signatures, or app copies.
- Never patch bundled code under `.cursor-app-formatted/` as a fix target. It is an ignored investigation snapshot only.
- If `.cursor-app-formatted/` is stale or wrong, regenerate it from the installed app instead of hand-editing its code.
- Fixes should land in this repository's real source code, scripts, or docs, not in formatted snapshot code.
- Keep `.cursor-app-formatted/` git-ignored. Do not stage or commit generated snapshot contents.
## Preferred Investigation Flow
1. If `.cursor-app-formatted/` exists, search and read that formatted snapshot first.
2. Use `/Applications/Cursor.app` only for read-only authenticity checks, hash comparison, or when the snapshot is missing or stale.
3. Prefer stable formatted paths for line references and control-flow reading:
- `.cursor-app-formatted/extensions/cursor-always-local/dist/main.js`
- `.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js`
- `.cursor-app-formatted/extensions/cursor-agent-worker/dist/main.js`
- `.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js`
- `.cursor-app-formatted/out/vs/workbench/api/node/extensionHostProcess.js`
4. When investigating installed-client behavior, compare formatted findings back to original source hashes or original bundle content only as needed.
## Git Ignore Rule
Ensure `.gitignore` contains:
```gitignore
.cursor-app-formatted/
```
If the entry is missing and the user asked to create or refresh the snapshot, add it before generating the snapshot.
## Snapshot Generation Workflow
Run from the repository root. This workflow copies only from the installed app into the ignored snapshot, then formats the copy.
```bash
set -euo pipefail
SNAPSHOT=.cursor-app-formatted
SOURCE=/Applications/Cursor.app/Contents/Resources/app
rm -rf "$SNAPSHOT"
mkdir -p "$SNAPSHOT"
/usr/bin/ditto "$SOURCE/extensions" "$SNAPSHOT/extensions"
mkdir -p "$SNAPSHOT/out/vs/workbench/api/node"
/usr/bin/ditto "$SOURCE/out/vs/workbench/workbench.desktop.main.js" "$SNAPSHOT/out/vs/workbench/workbench.desktop.main.js"
/usr/bin/ditto "$SOURCE/out/vs/workbench/api/node/extensionHostProcess.js" "$SNAPSHOT/out/vs/workbench/api/node/extensionHostProcess.js"
/usr/bin/shasum -a 256 \
"$SOURCE/out/vs/workbench/workbench.desktop.main.js" \
"$SOURCE/out/vs/workbench/api/node/extensionHostProcess.js" \
> "$SNAPSHOT/source-sha256.txt"
/usr/bin/find "$SOURCE/extensions" -type f \( -name '*.js' -o -name '*.json' -o -name '*.css' \) -print \
| /usr/bin/sed "s#^$SOURCE/##" \
| while IFS= read -r rel; do
/usr/bin/shasum -a 256 "$SOURCE/$rel"
done >> "$SNAPSHOT/source-sha256.txt"
```
Format large JS bundles with `js-beautify`; Prettier can OOM on very large Cursor bundles and also skips ignored paths unless forced.
```bash
find .cursor-app-formatted -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \) -size +1M -print \
| while IFS= read -r file; do
npx --yes js-beautify --type js --indent-size 2 --end-with-newline --replace --quiet "$file"
done
find .cursor-app-formatted -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \) ! -size +1M -print \
| while IFS= read -r file; do
npx --yes js-beautify --type js --indent-size 2 --end-with-newline --replace --quiet "$file"
done
EMPTY_IGNORE="$(mktemp)"
trap 'rm -f "$EMPTY_IGNORE"' EXIT
find .cursor-app-formatted -type f \( -name '*.json' -o -name '*.css' \) -print0 \
| xargs -0 -n 25 npx --yes prettier --ignore-path "$EMPTY_IGNORE" --with-node-modules --write --log-level warn
```
Optionally add a small `.cursor-app-formatted/README.md` describing the source path, observed Cursor version, and that the snapshot is read-only.
## Validation
After generation, verify the snapshot is ignored and key files are readable:
```bash
git status --short --ignored | rg '\.cursor-app-formatted'
wc -l \
.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js \
.cursor-app-formatted/extensions/cursor-always-local/dist/main.js \
.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js
```
Useful investigation check:
```bash
rg -n 'localMode|runLocalAgent|localProvider|BidiTransport|startYieldingInputsToTheServer' .cursor-app-formatted
```
@@ -1,97 +0,0 @@
---
name: cursor-client-e2e-debugging
description: Use when debugging Cursor client agent/local-mode/tool/backend-store/provider-replay failures in this repo, especially after the state/context history-store refactor, when triaging installed app bundles read-only, correlating installed-client behavior with repo code, mapping a user-provided id to conversation/request/model-call evidence, replaying provider requests from debug logs, or locating the current client/backend/protocol/log files quickly.
---
当用户反馈 Cursor agent、本地模式、工具调用、协议桥接、客户端 bundle 行为异常,或需要只读核对已安装客户端与仓库实现/日志差异时,使用此技能。
当用户只给一个 UUID / id,希望反查它是 `conversationId``requestId``modelCallId``toolCallId` 还是其它运行期 id,并继续定位对应的会话 history、provider 调用状态或协议日志时,也使用此技能。
当用户遇到 provider 400/参数错误、SSE `event: error`、需要从 `debug/provider.jsonl` 抽取最终 provider body 并用 curl 独立复现时,也使用此技能。
## 首要约束
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、协议事件与本仓库实现。
- 如果本仓库存在 `.cursor-app-formatted/`,优先读取这个格式化快照来搜索和引用客户端 bundle;只有在快照缺失、过期或需要 hash/真实性核对时,才只读读取 `/Applications/Cursor.app`
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill;调查时可以读格式化代码,但不要 patch 格式化快照里的 bundle 代码。
- 如果用户要求 patch 客户端做 e2e,要改成只读证据采集与差异定位,不执行客户端修改。
- 当前本仓库已经重构为 `state.json + context.json` history-store;不要沿用旧 `data.sqlite``conversation.json``turns/<n>/request.json|sse.jsonl|summary.json` 排查路径。
## 先做路由判断
- `history + logs` 反查层
- 现象:用户发来一个 id,要判断它是 `conversationId``requestId``modelCallId``toolCallId`;需要从 `history/<conversationId>/state.json``history/<conversationId>/context.json` 追运行状态和语义历史。
- 先读 [references/backend-store-log-tracing.md](references/backend-store-log-tracing.md)
- `provider replay / debug`
- 现象:provider 返回 400/参数错误、SSE `event: error`、需要验证最终出站 provider body 是否能被独立 curl 复现。
- 先读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md),必要时使用 [scripts/provider-replay.sh](scripts/provider-replay.sh)
- `cursor-agent`
- 现象:`CursorAgentProvider``ClaudeSDKClient``AnthropicProxy``registerAgentProvider``InteractionUpdate` 映射、模型桥接异常。
- 先读 [references/file-map.md](references/file-map.md) 和 [references/search-patterns.md](references/search-patterns.md)
- `cursor-always-local` / 本地模式协议层
- 现象:`BidiAppend``RunSSE``AgentServerMessage``ExecClientMessage``InteractionResponse`、live checkpoint / pending 收口异常。
- 先读 [references/file-map.md](references/file-map.md) 和 [references/search-patterns.md](references/search-patterns.md)
- 客户端 bundle 只读定位层
- 现象:需要核对已安装 app bundle、确认实际运行副本、只读验证行为是否命中,并判断差异来自客户端还是本仓库。
- 先读 [references/installed-client-readonly-validation.md](references/installed-client-readonly-validation.md)
如果问题同时涉及多层,优先从最靠近故障表象的一层开始,不要一开始就同时追所有链路。
## 当前工作流
1. 如果用户给了一个 id,先用 `history/` 目录、`context.json.items``state.json``logs/app.log` 判断它属于哪类 id;不要假设它一定是 `requestId`
2. 一旦拿到 `conversationId`,同时看两份事实源:
- `history/<conversationId>/state.json`:会话元数据和当前状态,例如 loop、token、current todo/plan、`latest_request_prefix``last_provider_call`
- `history/<conversationId>/context.json`append-only 的语义历史 entriesprompt replay 由 `ProjectPromptReplay()` 从这里投影。
3. 不要去找旧 provider 调用工件:当前 `RecordLLMRequest` 不再落 `request.json``AppendLLMResponseChunk` 是 no-op`RecordLLMSummary` 只补齐内存态并更新 `state.latest_request_prefix` / usage。
4. 再确认故障主要落在 `cursor-agent``cursor-always-local`,还是本仓库 `internal/backend` 的协议兼容层。
5. 用 references 里的固定搜索词快速找到入口函数、协议消息和桥接点。
6. 如果 provider 返回 400/参数错误、SSE `event: error`,或需要验证最终出站 provider body
- 先读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md)。
- 通过 id 反查拿到 `conversationId``requestId``modelCallId`,再定位 `history/<conversationId>/debug/provider.jsonl`
- 必要时运行 [scripts/provider-replay.sh](scripts/provider-replay.sh),只保存 replay 产物,不把 API key 或完整 request body 写进技能/回复。
7. 如果用户要核对 prefix cache / cache hit
- 优先运行 `go run ./scripts/historymetrics [conversationId|path]`
- 它读取当前 `history/<conversationId>/state.json``history/<conversationId>/context.json`,并结合 `history/usage.json` 统计。
- 关注 `cache_read_tokens / prompt_tokens_total`,并检查 `context.json.items` 是否缺失、重复或顺序异常。
8. 如果需要对照已安装 app 与仓库行为:
- 只做只读核对与证据采集,不修改客户端 bundle / app 副本 / 签名。
- 优先用 `.cursor-app-formatted/` 中的格式化副本定位符号、行号和控制流;再按需只读核对 `/Applications/Cursor.app` 原始文件 hash 或运行副本。
- 先确认实际运行的 app 副本和目标 bundle 路径。
- 再读取 bundle 内容、日志、端口与 history 状态,并与本仓库实现对照。
9. 如果证据显示问题更像是客户端 bundle 行为差异:
- 记录具体文件、符号、日志和协议证据链。
- 继续判断本仓库是否可以兼容、绕过,或直接输出分析结论。
- 不要对已安装 Cursor 客户端做 patch、重签名、替换文件或写入式验证。
## 约束
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
- 不要默认复刻整套 Cursor backend;先确认是不是只需要改模型桥接层。
- 不要先假设用户给的是 `requestId`;必须同时考虑 `conversationId``requestId``modelCallId``toolCallId`
- 不要把 `history/<conversationId>/state.json``history/<conversationId>/context.json` 混为一谈:前者是元数据与当前状态,后者是 replayable 语义历史。
- 不要再依赖 `agent_request_runs``agent_conversations``agent_history_entries``protocol_traces``data.sqlite`;当前实现已经不支持 DB-backed store / trace debug UI。
- 不要把当前排查进度、临时结论、一次性的 request_id / 端口 / token 写进技能。
- 技能里只保留稳定流程、固定入口、可复用搜索词和只读验证规则。
## 模型渠道规则
- 模型渠道唯一性不再由 `modelID` 决定。
- 当前规范化渠道 ID 是 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 `SHA-256` hash(前 16 个十六进制字符)。
- resolver 仍兼容 legacy 渠道 ID`baseURL + modelID + apiKey + displayName`
- `modelID` 只表示 provider model;排查选择器、默认模型和命中渠道时,要优先看渠道 ID 和 `openAIEndpoint`
## 参考加载规则
- `history + logs` 路径、id 反查、state/context 生成链路:读 [references/backend-store-log-tracing.md](references/backend-store-log-tracing.md)
- provider 400/参数错误、SSE `event: error`、最终出站 provider body curl 重放:读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md)
- 文件地图:读 [references/file-map.md](references/file-map.md)
- 搜索词与判断树:读 [references/search-patterns.md](references/search-patterns.md)
- 已安装客户端的只读核对、进程确认、行为验证:读 [references/installed-client-readonly-validation.md](references/installed-client-readonly-validation.md)
## 自带脚本
- 统计 prefix cache / cache hit:运行 `go run ./scripts/historymetrics [conversationId|path]`
- 兼容壳脚本:运行 [scripts/cache-hit-rate.mjs](scripts/cache-hit-rate.mjs)
- provider curl 重放:运行 [scripts/provider-replay.sh](scripts/provider-replay.sh),必填 `REQUEST_LOG``REQUEST_ID``MODEL_CALL_ID`
@@ -1,177 +0,0 @@
# backend/store 日志反查
当用户只给一个 id,或明确让你从本地记录里反查一次请求、会话、模型调用、工具调用或 provider 错误时,优先读这份参考。
## 当前固定路径
当前用户机器上的固定助手根目录:
- `~/.cursor-local-assistant-v2`
当前最关键的是三类内容:
- `history/<conversationId>/state.json`
- 会话元数据与当前状态。
- 重点字段:`request_id` `conversation_id``root_conversation_id``parent_conversation_id``parent_tool_call_id``mode``current_loop_status``current_request_id``current_turn_seq``context_version``next_turn_seq``next_entry_seq``latest_request_prefix``last_provider_call``current_todos``current_plans`、token / compaction 字段。
- `history/<conversationId>/context.json`
- append-only 语义历史。
- 重点字段:`version``items[]`
- `items[]` 中每个 entry 通常有 `seq``turn_seq``request_id``role``kind``tool_call_id``parent_tool_call_id``payload``created_at`
- `history/usage.json`
- provider call 与 turn usage 聚合。
- 重点字段:`totals``daily``recent_events``event_index`
`logs/app.log` 是运行日志;它只用于补充运行时证据,不是会话事实源。
当前实现不再支持:
- DB-backed store / searchable conversation memory
- HTTP/protocol trace debug UI
- `data/data.sqlite` / `protocol_traces`
- `history/<conversationId>/conversation.json`
- `history/<conversationId>/turns/<n>/request.json|sse.jsonl|summary.json`
- 根目录或会话目录下的旧 `latest.json``summary.json``replay.json``runtime.json``request.json``recovery.json``entries.jsonl`、数字 turn 目录
这些旧产物会被 `internal/backend/forwarder/history_maintenance.go` 清理。不要把它们当成当前事实源。
## 用户发来一个 id 时的固定步骤
### 1. 先判断 id 类型
不要先假设它是 `requestId`。按这个顺序缩小范围:
1. 是否是 `conversationId`
- 检查 `history/<id>/state.json``history/<id>/context.json` 是否存在。
2. 是否是 `requestId`
-`history/*/state.json` 中查 `current_request_id``latest_request_prefix.request_id``last_provider_call.request_id`
-`history/*/context.json``items[].request_id` 中查。
-`logs/app.log` 中查。
3. 是否是 `modelCallId`
-`state.json` 中查 `latest_request_prefix.model_call_id``last_provider_call.model_call_id`
-`context.json.items[].payload` 中查 `model_call_id`
-`logs/app.log` 中查 `model_call_id=<id>`
4. 是否是 `toolCallId` / `exec_id`
-`context.json.items[].tool_call_id``items[].payload` 中查。
- 在协议/工具相关日志中查。
可以用本地脚本或 `rg` 做只读反查。不要再用 SQLite 查询模板。
### 2. 拿到 `conversationId` 后看两份事实源
```bash
HISTORY_ROOT="$HOME/.cursor-local-assistant-v2/history"
CONV_ID="<conversation-id>"
ls -la "$HISTORY_ROOT/$CONV_ID"
```
重点检查:
- `state.json`
- `current_loop_status``idle``running``waiting_tool``completed``canceled``provider_error``failed`
- `current_request_id``current_turn_seq`
- `latest_request_prefix`:最近一次 provider 请求的 provider/model/openai_endpoint/model_call_id/prompt token 摘要
- `last_provider_call`:最近 provider 状态与错误文本
- `next_entry_seq``next_turn_seq``context_version`
- `current_todos``current_plans`
- `context.json`
- `version` 是否与 `state.context_version` 对齐
- `items[]` 是否按 `seq` 稳定递增
- 同一 `turn_seq` 下是否有预期的 user/request_context/prompt_context/assistant/tool_result/metadata entries
- 是否有重复、缺失或顺序异常
- `usage.json`
- 通过 `event_index``recent_events` 查 request/model-call 相关 usage
- `totals.cache_read_tokens / (totals.cache_read_tokens + totals.input_tokens)` 可粗略看 cache hit
### 3. Provider 调用证据现在在哪里
当前 provider artifact recorder 的行为:
- `RecordLLMRequest(...)`
- 只缓存当前 provider call 的请求摘要。
- 如果 payload 可解析 provider/model/openai_endpoint,会更新 `state.latest_request_prefix`
- 不再写 `request.json`
- `AppendLLMResponseChunk(...)`
- 当前是 no-op。
- 不再写 `sse.jsonl`
- `RecordLLMSummary(...)`
- 只补齐当前 provider call summary,并更新 `state.latest_request_prefix.prompt_tokens_total`
- usage 聚合写入 `history/usage.json`
- 不再写 `summary.json`
所以 provider 错误排查应优先看:
- `state.last_provider_call`
- `state.latest_request_prefix`
- `context.json.items` 里的 `metadata/provider_error/turn_completed` 等 payload
- `history/usage.json`
- `logs/app.log`
- `internal/backend/agent/model/openai.go` / `anthropic.go` 的请求构造和错误解析
## 这些文件是怎么生成的
### 根路径
- `internal/appdata/paths.go`
- `RootDir()` 固定为 `~/.cursor-local-assistant-v2`
- `HistoryRootPath()``~/.cursor-local-assistant-v2/history`
- `UsageFilePath()``~/.cursor-local-assistant-v2/history/usage.json`
- `LogsRootPath()``~/.cursor-local-assistant-v2/logs`
### `state.json + context.json`
来源链路:
- `internal/backend/forwarder/file_store.go`
- `CreateConversation`
- `LoadConversation`
- `AppendEntries`
- `SaveConversationWithEntries`
- `UpdateConversationMeta`
- `ReplaceEntries`
- `internal/backend/forwarder/service.go`
- `handleRunIntent` 开始新 loop / turn
- `appendConversationEntries` 追加语义事件
- `internal/backend/forwarder/projector.go`
- `ProjectPromptReplay()``context.json.items` 投影为 provider messages
稳定结论:
- `state.json` 是当前状态和可变元数据。
- `context.json.items` 是 replayable 语义历史。
- 发给 LLM 的历史由 projector 从 `context.json.items` 投影,不是从 provider artifacts 重放。
- `state.json.entries` 只是内存结构 `ConversationFile` 的字段;落盘时可投影历史在 `context.json.items`
### `usage.json`
来源链路:
- `internal/backend/forwarder/usage_store.go`
- `UsageFileStore.UpsertEvent`
- `UsageFileStore.LookupEvent`
- `internal/backend/forwarder/token_usage.go`
- `internal/historymetrics/`
稳定结论:
- `usage.json` 是全局 usage 聚合,不属于单个 conversation 的语义历史。
- `recent_events` 只保留最近有限数量事件;长期总量看 `totals` / `daily`
### legacy 清理
来源链路:
- `internal/backend/forwarder/history_maintenance.go`
稳定结论:
- `turns/``conversation.json``entries.jsonl``request.json``summary.json` 等都是 legacy artifact。
- 启动后的 history maintenance 会清理这些旧产物。
## 快速判断规则
- 用户只发一个 id 时,先查 `history/<id>/state.json` 是否存在;不存在再扫 `state.json/context.json/logs`
- 请求失败时,先看 `state.last_provider_call``context.json.items` 的错误 metadata、`logs/app.log`;不要找 `turns/<n>/summary.json`
- pending / 工具不收口时,先看同一 `turn_seq` 的 tool call 和 tool result entries,再对照协议上行 `exec_client_message` / `exec_client_control_message` / `interaction_response`
- prefix cache 异常时,先看 `context.json.items` 的稳定追加顺序和 `usage.json` 的 cache token 字段。
- 如果 history 与日志冲突,优先相信当前仍在更新的 `state.json/context.json`,再用日志解释运行时经过了哪条路径。
@@ -1,147 +0,0 @@
# 文件地图
## 已安装客户端 bundle
优先核对这些实际运行中的客户端文件:
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js`
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/api/node/extensionHostProcess.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/gitWorker.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js`
当前安装包里 `cursor-agent` 已拆成 split bundle;旧路径 `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent/dist/main.js` 通常不存在。不要按旧路径下结论;需要先列出 `extensions/`,再确认实际存在的 `cursor-agent-exec``cursor-agent-worker``cursor-always-local` 及其 `dist/` 文件。
大致归属:
- `out/vs/workbench/workbench.desktop.main.js`:主 UI、agent window / titlebar、feature flag、用户可点击入口和只读/禁用态。
- `cursor-always-local/dist/main.js`:本地模式协议、`BidiAppend``RunSSE``AgentServerMessage` / `AgentClientMessage` 桥接。
- `cursor-agent-exec/dist/main.js` 与同目录数字 chunkagent 执行侧、SDK/canvas runtime、工具执行、proto 消息定义与拆分 chunk。当前构建中 `411.js` 可能包含 agent 执行链路关键片段,但 chunk 编号不是稳定接口,先用 `dist/*.js` 搜索。
- `cursor-agent-worker/dist/main.js`agent worker 侧后台逻辑。
用户机器上可能还存在其它 app 副本,例如:
- `~/Applications/Cursor Hooked.app`
- `/Applications/Cursor Patched.app`
不要假设哪一个在跑,先看进程路径。
## 当前 backend/store 与 history
当前用户机器上的固定助手目录:
- `~/.cursor-local-assistant-v2/`
重点看:
- `~/.cursor-local-assistant-v2/config.yaml`
- `~/.cursor-local-assistant-v2/data/ca.crt`
- `~/.cursor-local-assistant-v2/data/ads/`
- `~/.cursor-local-assistant-v2/history/usage.json`
- `~/.cursor-local-assistant-v2/history/<conversationId>/state.json`
- `~/.cursor-local-assistant-v2/history/<conversationId>/context.json`
- `~/.cursor-local-assistant-v2/history/<conversationId>/conversation.lock`
- `~/.cursor-local-assistant-v2/logs/app.log`
其中:
- `state.json` 是会话元数据、loop 状态、latest provider/request prefix、当前 todos/plans、token/compaction 状态。
- `context.json.items` 是 append-only 语义历史,也是 prompt replay 的事实源。
- `usage.json` 是全局 provider call / turn usage 聚合。
- `conversation.lock` 是会话级文件锁。
- checkpoint 只表示同一 backend 进程内的 live state,不是持久化恢复事实源。
- legacy artifacts`conversation.json``entries.jsonl``turns/``request.json``summary.json``sse.jsonl``replay.json``runtime.json``latest.json`、数字 turn 目录,当前会被 history maintenance 清理。
这些内容的生成入口主要在:
- `internal/appdata/paths.go`
- `internal/backend/host.go`
- `internal/backend/README.md`
- `internal/backend/forwarder/file_store.go`
- `internal/backend/forwarder/history_maintenance.go`
- `internal/backend/forwarder/usage_store.go`
- `internal/backend/forwarder/token_usage.go`
- `internal/backend/forwarder/artifacts.go`
## 本仓库协议与本地模式实现
协议定义:
- `proto/agent_v1.proto`
- `proto/aiserver_v1.proto`
- `proto/from_extensions/agent_v1.proto`
- `proto/from_extensions/aiserver_v1.proto`
扩展快照与提取:
- `proto/extensions-cursor-app/cursor-always-local/package.json`
- `proto/extract_extensions_proto.sh`
- `proto/ext_tool/main.go`
本地后端入口:
- `internal/backend/host.go`
- `internal/backend/server/route.go`
- `internal/backend/server/policy.go`
- `internal/backend/server/local.go`
- `internal/backend/server/config/types.go`
- `internal/backend/server/config/manager.go`
- `internal/backend/server/config/resolver.go`
forwarder 主链路:
- `internal/backend/forwarder/module.go`
- `internal/backend/forwarder/service.go`
- `internal/backend/forwarder/actor.go`
- `internal/backend/forwarder/broker.go`
- `internal/backend/forwarder/events.go`
- `internal/backend/forwarder/compiler.go`
- `internal/backend/forwarder/projector.go`
- `internal/backend/forwarder/provider.go`
- `internal/backend/forwarder/checkpoint_memory.go`
- `internal/backend/forwarder/runtime_summary.go`
协议解码:
- `internal/backend/agent/protocol/inbound.go`
执行桥 / 交互桥:
- `internal/backend/agent/bridge/exec/bridge.go`
- `internal/backend/agent/bridge/interaction/bridge.go`
模型适配:
- `internal/backend/agent/model/router.go`
- `internal/backend/agent/model/openai.go`
- `internal/backend/agent/model/anthropic.go`
- `internal/backend/agent/model/artifacts.go`
- `internal/backend/agent/model/http_error.go`
- `internal/backend/agent/model/tool_call_id.go`
- `internal/modelchannel/identity.go`
- `internal/runtime/local_runtime.go`
Prompt / replay
- `internal/backend/agent/prompt/engine.go`
- `internal/backend/agent/prompt/replay.go`
- `internal/backend/agent/prompt/content_parts.go`
- `internal/backend/forwarder/prompt_context.go`
- `internal/backend/forwarder/request_context.go`
- `internal/backend/forwarder/reminders.go`
- `internal/backend/forwarder/prompt_guard.go`
## 构建相关参考(只读)
仓库内已有 macOS 构建与签名相关文件,可用于理解产物结构或历史处理方式,但不要把它们当成修改已安装 Cursor 客户端的操作指南:
- `Taskfile.yml`
- `build/darwin/Taskfile.yml`
- `build/dmg-extras/提示损坏?点我.command`
重点看:
- `build/darwin/Taskfile.yml` 中的 `codesign:adhoc`
- `build/dmg-extras/提示损坏?点我.command` 中的 `xattr -cr`
@@ -1,87 +0,0 @@
# 已安装客户端只读核对与验证
首要原则:
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、端口和 history 状态。
- 目标是定位差异、收集证据、判断问题归属,而不是 patch 客户端。
## 1. 先确认实际运行的 app 副本
优先用非交互命令核对:
```bash
pgrep -fal 'Cursor Hooked|Cursor Patched|/Contents/MacOS/Cursor'
ps -axo pid,ppid,command | rg 'Cursor(.app)?/Contents/MacOS/Cursor|extension-host'
```
不要在没确认实际运行副本前就下结论,也不要修改客户端文件。
## 2. 只读定位目标 bundle 与关键文件
优先定位并读取这些文件,而不是改写它们:
```bash
ls -l "/absolute/path/Target.app/Contents/Resources/app/extensions"
shasum -a 256 "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
```
重点关注:
- `out/vs/workbench/workbench.desktop.main.js`
- `out/vs/workbench/api/node/extensionHostProcess.js`
- `cursor-always-local/dist/main.js`
- `cursor-always-local/dist/gitWorker.js`
- `cursor-agent-exec/dist/main.js`
- `cursor-agent-exec/dist/*.js`
- `cursor-agent-worker/dist/main.js`
当前安装包里旧路径 `cursor-agent/dist/main.js` 通常不存在;先确认 `extensions/` 里的实际扩展名和 `dist/` 文件,再选择 `workbench` / `cursor-agent-exec` / `cursor-agent-worker` / `cursor-always-local` 对应排查。
## 3. 只读读取 bundle 内容
常用定位关键词:
```bash
rg -n 'BidiTransport|ExecClientMessage|InteractionResponse|conversation_checkpoint_update' "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
rg -n 'CursorAgentProvider|AnthropicProxy|ANTHROPIC_BASE_URL|InteractionUpdate|checkpoint|agent window' "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js" "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist"/*.js "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js"
rg -n 'agent window|open_agent_window|NameAgent|UpdateConversationMetadata|shouldShowAgentWindowTitleHelperText' "/absolute/path/Target.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js" "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist"/*.js
```
读取具体文件内容时,优先用读取工具按需查看相关片段,不要修改 bundle。
如果需要和仓库实现对照,优先同时打开:
- `proto/agent_v1.proto`
- `proto/aiserver_v1.proto`
- `internal/backend/...`
- `internal/runtime/local_runtime.go`
## 4. 验证行为是否命中目标副本
至少做其中两项:
- 进程路径是否是目标 app
- 目标扩展 host 是否起来
- 本地监听端口是否存在
- `~/.cursor-local-assistant-v2/logs/app.log` 是否更新
- `~/.cursor-local-assistant-v2/history/<conversationId>/state.json` / `context.json` 是否更新
- 请求/协议事件是否真的经过你正在分析的 bundle 文件
常用验证:
```bash
pgrep -fal '/absolute/path/Target.app/Contents/MacOS/Cursor'
lsof -nP -iTCP -sTCP:LISTEN | rg 'Cursor|127.0.0.1'
```
## 5. 记录证据并输出归因
如果确认“已安装 app 行为”和“仓库代码理解”存在差异,优先记录:
1. 实际运行的 app 路径
2. 命中的 bundle 文件路径与关键符号
3. 对应日志、端口、`history/state.json``history/context.json``usage.json` 证据
4. 仓库里对应实现的位置
如果结论指向客户端侧,也停留在分析和归因,不要继续 patch、重签名、替换文件或做写入式验证。
@@ -1,153 +0,0 @@
# Provider replay / debug
当现象是 provider 返回错误、SSE 里只有 `event: error`、需要确认最终出站 provider body 是否能被独立复现时,优先读这份参考。
这套流程只用于还原“后端最终发给 provider 的请求形状”和“provider 对该请求的真实响应”。它不是语义 history,也不是客户端输入事实源。
## 证据边界
- `history/<conversationId>/debug/provider.jsonl`
- 最接近 provider 出站边界。
- `event=llm_request``payload.body` 是最终 provider request body。
- 用它做 curl replay,判断问题是否已经出现在出站请求形状。
- `history/<conversationId>/state.json`
- 当前状态和最近 provider 摘要。
- 重点看 `latest_request_prefix``last_provider_call`
- `history/<conversationId>/context.json`
- replayable 语义历史。
- 用来解释为什么会形成这次 prompt,不用来直接重放 provider HTTP 请求。
- `history/<conversationId>/debug/bidi.raw.jsonl`
- 客户端原始上行字节证据。
- `history/<conversationId>/debug/bidi.decoded.jsonl`
- 当前 known-schema 解码后的客户端上行证据。
- `history/<conversationId>/debug/runtime.jsonl`
- 后端把哪些字段挂到 active request / stream 上。
- `history/<conversationId>/debug/runsse.jsonl`
- 后端尝试发回客户端的消息。
不要把这些证据混用:provider replay 只能证明最终 provider HTTP 请求与响应,不能单独证明客户端原始上传了什么,也不能替代 `context.json` 的语义历史。
## 前置条件
- 请求发生时 `config.yaml``log: true`,或已有 `history/<conversationId>/debug/provider.jsonl`
- 已经通过 id 反查拿到:
- `conversationId`
- `requestId`
- `modelCallId`
- 已经确认要测的是 Anthropic-compatible `/v1/messages` 请求。
如果没有 debug 文件,先回到 `state.json``context.json``usage.json``logs/app.log` 做推断,并明确“没有直接 provider body 证据”。
## 最小流程
1. 定位 provider debug 文件:
```bash
ROOT="$HOME/.cursor-local-assistant-v2"
CONV="<conversationId>"
REQ="<requestId>"
MODEL_CALL="<modelCallId>"
REQUEST_LOG="$ROOT/history/$CONV/debug/provider.jsonl"
```
2. 确认 `llm_request` 存在:
```bash
jq -c --arg req "$REQ" --arg mc "$MODEL_CALL" '
select(.event == "llm_request" and .request_id == $req and .model_call_id == $mc)
| {at, conversation_id, request_id, model_call_id, provider: .payload.provider, model: .payload.body.model}
' "$REQUEST_LOG"
```
3. 执行通用重放脚本:
```bash
REQUEST_LOG="$REQUEST_LOG" \
REQUEST_ID="$REQ" \
MODEL_CALL_ID="$MODEL_CALL" \
CHANNEL_NAME="GLM" \
OUT_DIR="/tmp/cursor-provider-replay-$REQ" \
.agents/skills/cursor-client-e2e-debugging/scripts/provider-replay.sh
```
也可以直接传入 provider 配置,避免读取 `config.yaml`
```bash
REQUEST_LOG="$REQUEST_LOG" \
REQUEST_ID="$REQ" \
MODEL_CALL_ID="$MODEL_CALL" \
BASE_URL="<provider-base-url>" \
API_KEY="<provider-api-key>" \
OUT_DIR="/tmp/cursor-provider-replay-$REQ" \
.agents/skills/cursor-client-e2e-debugging/scripts/provider-replay.sh
```
4. 查看产物:
- `request.body.json`:抽取出的最终 provider body。
- `response.headers`HTTP 响应头。
- `response.sse`SSE 响应体。
- `replay.meta.json`:本次重放引用的 id 和 provider log 路径。
## 结果判断
- `curl_exit_code != 0`
- 网络、TLS、超时、连接或本机 curl 问题。
- 先看 stderr、`response.headers` 是否存在,再判断是否真的到达 provider。
- HTTP 非 2xx
- provider 网关或鉴权层拒绝。
- 优先看 `response.headers` 和 provider 错误体。
- HTTP 2xx 但 SSE 中有 `event: error`
- provider 已接受连接,但认为请求参数不合法或模型侧拒绝。
- 这种情况下重点比对 `request.body.json` 的消息结构、tool schema、thinking/reasoning 参数、model 名称和 endpoint 兼容性。
- SSE 正常流式输出
- 原始 provider 请求形状基本可用。
- 如果客户端仍失败,回到 `runsse.jsonl`、forwarder 状态机或客户端协议层继续查。
## 常见收敛方向
provider 参数错误时,优先检查:
- `model` 是否是目标 endpoint 支持的名称。
- `messages` 是否符合 Anthropic-compatible 形态。
- `system` 是否被目标 provider 支持,或需要改成 message。
- `tools` / `tool_choice` 是否符合目标 provider 方言。
- `thinking` / `reasoning` 字段是否被目标 provider 支持。
- 图片、文件、cache_control、metadata 等扩展字段是否超出 provider 兼容范围。
- `max_tokens``temperature``top_p``stop_sequences` 是否落在 provider 允许范围内。
## 敏感信息规则
- 不把 API key 写进 skill、reference、脚本默认值或提交内容。
- 回复用户时不要粘贴完整 API key;最多说明“已使用用户提供的 key / config 中的 key”。
- 不把完整 `request.body.json` 大段贴给用户;只摘和结论相关的字段形状。
- 不把一次性 `conversationId/requestId/modelCallId` 写进 skill 文档。
- 临时 replay 产物默认放 `/tmp`;如果需要保留,明确说明路径和原因。
## 脚本参数
`scripts/provider-replay.sh` 使用环境变量控制:
- 必填:
- `REQUEST_LOG`
- `REQUEST_ID`
- `MODEL_CALL_ID`
- 可选:
- `BASE_URL`
- `API_KEY`
- `GLM_BASE_URL`
- `GLM_API_KEY`
- `ANTHROPIC_BASE_URL`
- `ANTHROPIC_API_KEY`
- `CONFIG_FILE`,默认 `~/.cursor-local-assistant-v2/config.yaml`
- `CHANNEL_NAME`,默认 `GLM`
- `OUT_DIR`,默认 `/tmp/cursor-provider-replay-<requestId>`
- `MAX_TIME`,默认 `240`
- `ENDPOINT_PATH`,默认 `/v1/messages`
脚本输出四个稳定产物:
- `request.body.json`
- `response.headers`
- `response.sse`
- `replay.meta.json`
@@ -1,223 +0,0 @@
# 搜索词与判断树
## 先判断层级
### `history + logs` / id 反查层
当现象是“用户只给了一个 id”“需要判断它是 `conversationId``requestId``modelCallId``toolCallId`”“要从本地 history 和日志追状态”:
优先搜索:
- `state.json`
- `context.json`
- `usage.json`
- `logs/app.log`
- `conversation_id`
- `current_request_id`
- `request_id`
- `model_call_id`
- `tool_call_id`
- `latest_request_prefix`
- `last_provider_call`
- `current_loop_status`
- `context_version`
- `next_entry_seq`
- `next_turn_seq`
- `LoadConversation`
- `CreateConversation`
- `SaveConversationWithEntries`
- `AppendEntries`
- `UpdateConversationMeta`
- `ReplaceEntries`
- `ProjectPromptReplay`
- `UsageFileStore`
- `UpsertEvent`
- `LookupEvent`
不要再优先搜索或依赖:
- `data.sqlite`
- `protocol_traces`
- `agent_request_runs`
- `conversation.json`
- `entries.jsonl`
- `turns/<n>`
- `request.json`
- `sse.jsonl`
- `summary.json`
这些是旧实现或 legacy artifact 相关线索,只在排查迁移/清理逻辑时作为历史背景。
### `cursor-agent-exec` / `cursor-agent-worker` 层
当现象涉及 agent 主循环、模型桥接、`InteractionUpdate` 映射、工具 started/completed、session/provider 状态:
优先在已安装客户端 split bundle 中搜索:
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js`
优先搜索:
- `registerAgentProvider`
- `CursorAgentProvider`
- `CursorAgentProviderHandle`
- `ClaudeSDKClient`
- `streamInteractionUpdates`
- `handlePartialMessage`
- `AnthropicProxy`
- `getAnthropicProxyPort`
- `getAnthropicProxyAuthToken`
- `ANTHROPIC_BASE_URL`
- `ANTHROPIC_API_KEY`
- `InteractionUpdate`
- `checkpoint`
旧路径 `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent/dist/main.js` 可能不存在。先确认实际 `extensions/` 结构,再按 `cursor-agent-exec` / `cursor-agent-worker` / `cursor-always-local` 分层排查。
### agent window / conversation metadata UI 层
当现象涉及 agent window 标题、窗口信息、titlebar 按钮、是否可点击修改、会话名/metadata 更新:
优先在已安装客户端主 UI 和 split bundle 中搜索:
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js`
优先搜索:
- `shouldShowAgentWindowTitleHelperText`
- `glass_open_agents_titlebar_button`
- `open_agent_window_top`
- `open_agent_window_bottom_convo`
- `glass.enable_open_agent_in_window`
- `NameAgentRequest`
- `NameAgentResponse`
- `UpdateConversationMetadataRequest`
- `UpdateConversationMetadataResponse`
- `CreateTranscriptOverviewRequest`
- `createTranscriptOverview`
- `updateConversationMetadata`
- `conversation_checkpoint_update`
判断规则:
- `ConversationStateStructure` / `conversation_checkpoint_update` 是 UI 同步快照,不应作为持久化修改入口。
- 如果客户端调用 `UpdateConversationMetadata` / `NameAgent`,要继续确认本地后端是否显式注册对应 `/agent.v1.AgentService/*` 路由;不能只看 proto message 存在。
- 本地模式修改会话名/metadata 时,应落到 `history/<conversationId>/state.json` 或等价持久化会话元数据,再 publish checkpoint 同步 UI。
### `cursor-always-local` / 协议层
当现象涉及本地模式、客户端没有回包、pending 不收口、同一 backend 进程内的 live checkpoint 重连错乱:
优先搜索:
- `BidiTransport`
- `startYieldingInputsToTheServer`
- `BidiAppend`
- `RunSSE`
- `AgentServerMessage`
- `AgentClientMessage`
- `ExecServerMessage`
- `ExecClientMessage`
- `ExecClientControlMessage`
- `InteractionQuery`
- `InteractionResponse`
- `conversation_checkpoint_update`
### 本仓库 forwarder 层
当现象涉及本地后端收发、provider 继续/暂停、exec/interaction 桥接、history 投影:
优先搜索:
- `handleRunIntent`
- `driveProvider`
- `startStreamActor`
- `streamCommandEnvelope`
- `handleToolInvocation`
- `handleExecResult`
- `handleExecControl`
- `publishCheckpoint`
- `CheckpointConversation`
- `snapshotCheckpointConversation`
- `appendConversationEntries`
- `OpenExec`
- `OpenQuery`
- `StartStream`
- `deriveConversationLoopState`
- `historyEntryToolCallID`
- `recordProviderUsage`
- `recordTurnUsage`
### provider / 模型适配层
当现象是 provider 400/500、thinking/reasoning、tool_call_id、OpenAI/Anthropic 请求形状、usage/cache 不对:
优先搜索:
- `StartStream`
- `StreamRequest`
- `ResolvedChannelID`
- `ResolvedChannelName`
- `ProviderModelID`
- `ThinkingEnabled`
- `buildAnthropicThinkingConfig`
- `normalizeAnthropicProviderMessages`
- `normalizeOpenAIProviderMessages`
- `normalizeOpenAIResponsesInput`
- `reasoning_content`
- `ReasoningContent`
- `ReasoningSignature`
- `RecordLLMRequest`
- `RecordLLMSummary`
- `http_error`
- `namespaceToolCallID`
## 快速判断规则
- 如果问题是“给你一个 id,让你先判断是什么 ID,再找日志”,先看 `history/<id>/state.json` 是否存在,再扫 `history/*/state.json``history/*/context.json``logs/app.log`
- 如果问题是“模型输出语义不对”,先看 `context.json.items``ProjectPromptReplay()` 的投影,再看 provider request normalization。
- 如果问题是“provider 报 400/参数错误”,先看模型适配层请求构造、`state.latest_request_prefix``state.last_provider_call``logs/app.log`
- 如果问题是“客户端没回某个工具结果 / pending 不收口”,先看 `cursor-always-local` 与 forwarder,同时核对同一 `turn_seq` 是否有 `tool_result` 或控制面错误 entry。
- 如果问题是“backend 重启后为什么 checkpoint 没法继续恢复 pending”,不要找磁盘 checkpointcheckpoint 是 live state,重启后的事实源是 `state.json + context.json`
- 如果问题是“为什么同一个 `modelID` 还能出现多个渠道”,先检查渠道 ID:规范化后 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 SHA-256resolver 仍兼容 legacy `baseURL + modelID + apiKey + displayName`
- 如果问题是“只想桥接到其他 LLM”,优先看模型桥接层,不要默认深入整套 local runtime。
- 如果问题是“已安装 app 行为和仓库代码不一致”,先核对实际运行 bundle,再做只读比对;不要 patch 客户端。
## 协议关键词
上行:
- `run_request`
- `exec_client_message`
- `exec_client_control_message`
- `interaction_response`
下行:
- `interaction_update`
- `exec_server_message`
- `exec_server_control_message`
- `interaction_query`
- `conversation_checkpoint_update`
如果只看到下行请求,没有对应上行结果或控制消息,优先排查:
- `exec_id`
- `id`
- `tool_call_id`
- `request_id`
- `model_call_id`
- pending 收口逻辑
如果用户给的是一个裸 id,不要直接把它当成 `request_id`。先同时查:
- `history/<id>/state.json`
- `history/*/state.json``current_request_id``latest_request_prefix``last_provider_call`
- `history/*/context.json``items[].request_id``items[].tool_call_id``items[].payload`
- `history/usage.json``event_index` / `recent_events`
- `logs/app.log`
@@ -1,33 +0,0 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const scriptPath = fileURLToPath(import.meta.url);
const skillRoot = path.resolve(path.dirname(scriptPath), "..");
const repoRoot = path.resolve(skillRoot, "../../..");
const args = ["run", "./scripts/historymetrics", ...process.argv.slice(2)];
const child = spawn("go", args, {
cwd: repoRoot,
stdio: "inherit",
});
child.on("error", (error) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`cache-hit-rate.mjs failed: ${message}`);
process.exitCode = 1;
});
child.on("exit", (code, signal) => {
if (typeof code === "number") {
process.exitCode = code;
return;
}
if (signal) {
console.error(`cache-hit-rate.mjs terminated by signal: ${signal}`);
}
process.exitCode = 1;
});
@@ -1,194 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
REQUEST_LOG=<path/to/provider.jsonl> REQUEST_ID=<request-id> MODEL_CALL_ID=<model-call-id> \
[BASE_URL=<provider-base-url>] [API_KEY=<provider-api-key>] [CONFIG_FILE=<config.yaml>] \
[OUT_DIR=<output-dir>] [MAX_TIME=240] provider-replay.sh
Required:
REQUEST_LOG Path to history/<conversationId>/debug/provider.jsonl
REQUEST_ID Provider request_id to replay
MODEL_CALL_ID Provider model_call_id to replay
Optional:
BASE_URL Provider base URL. Falls back to GLM_BASE_URL or ANTHROPIC_BASE_URL.
API_KEY Provider API key. Falls back to ANTHROPIC_API_KEY or GLM_API_KEY.
CONFIG_FILE Defaults to ~/.cursor-local-assistant-v2/config.yaml.
CHANNEL_NAME Display name to read from config.yaml when BASE_URL/API_KEY is missing. Defaults to GLM.
OUT_DIR Output directory. Defaults to /tmp/cursor-provider-replay-<request-id>.
MAX_TIME curl max-time seconds. Defaults to 240.
ENDPOINT_PATH Provider path. Defaults to /v1/messages.
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
REQUEST_LOG="${REQUEST_LOG:-}"
REQUEST_ID="${REQUEST_ID:-}"
MODEL_CALL_ID="${MODEL_CALL_ID:-}"
CONFIG_FILE="${CONFIG_FILE:-$HOME/.cursor-local-assistant-v2/config.yaml}"
CHANNEL_NAME="${CHANNEL_NAME:-GLM}"
BASE_URL="${BASE_URL:-${GLM_BASE_URL:-${ANTHROPIC_BASE_URL:-}}}"
API_KEY="${API_KEY:-${ANTHROPIC_API_KEY:-${GLM_API_KEY:-}}}"
ENDPOINT_PATH="${ENDPOINT_PATH:-/v1/messages}"
MAX_TIME="${MAX_TIME:-240}"
OUT_DIR="${OUT_DIR:-/tmp/cursor-provider-replay-${REQUEST_ID:-unknown}}"
BODY_FILE="$OUT_DIR/request.body.json"
RESP_FILE="$OUT_DIR/response.sse"
HEADER_FILE="$OUT_DIR/response.headers"
META_FILE="$OUT_DIR/replay.meta.json"
require_value() {
local name="$1"
local value="$2"
if [[ -z "$value" ]]; then
echo "缺少 $name。运行 --help 查看用法。" >&2
exit 2
fi
}
read_channel_config() {
local field="$1"
python3 - "$CONFIG_FILE" "$CHANNEL_NAME" "$field" <<'PY'
import sys
from pathlib import Path
config_path = Path(sys.argv[1]).expanduser()
channel_name = sys.argv[2]
field = sys.argv[3]
if not config_path.exists():
raise SystemExit
lines = config_path.read_text(encoding="utf-8").splitlines()
in_channel = False
for line in lines:
stripped = line.strip()
if stripped.startswith("- displayName:"):
in_channel = stripped.split(":", 1)[1].strip().strip('"') == channel_name
continue
if in_channel and stripped.startswith(field + ":"):
print(stripped.split(":", 1)[1].strip().strip('"'))
raise SystemExit
PY
}
require_value "REQUEST_LOG" "$REQUEST_LOG"
require_value "REQUEST_ID" "$REQUEST_ID"
require_value "MODEL_CALL_ID" "$MODEL_CALL_ID"
if [[ ! -f "$REQUEST_LOG" ]]; then
echo "REQUEST_LOG 不存在: $REQUEST_LOG" >&2
exit 2
fi
if [[ -z "$BASE_URL" ]]; then
BASE_URL="$(read_channel_config baseURL || true)"
fi
if [[ -z "$API_KEY" ]]; then
API_KEY="$(read_channel_config apiKey || true)"
fi
require_value "BASE_URL/GLM_BASE_URL/ANTHROPIC_BASE_URL 或 config[$CHANNEL_NAME].baseURL" "$BASE_URL"
require_value "API_KEY/ANTHROPIC_API_KEY/GLM_API_KEY 或 config[$CHANNEL_NAME].apiKey" "$API_KEY"
mkdir -p "$OUT_DIR"
: > "$HEADER_FILE"
: > "$RESP_FILE"
python3 - "$REQUEST_LOG" "$REQUEST_ID" "$MODEL_CALL_ID" "$BODY_FILE" "$META_FILE" <<'PY'
import json
import sys
from pathlib import Path
log_path = Path(sys.argv[1]).expanduser()
request_id = sys.argv[2]
model_call_id = sys.argv[3]
body_path = Path(sys.argv[4])
meta_path = Path(sys.argv[5])
body = None
meta = None
with log_path.open(encoding="utf-8") as f:
for raw in f:
if not raw.strip():
continue
row = json.loads(raw)
if row.get("event") != "llm_request":
continue
if row.get("request_id") != request_id:
continue
if row.get("model_call_id") != model_call_id:
continue
payload = row.get("payload") or {}
body = payload.get("body")
meta = {
"at": row.get("at"),
"event": row.get("event"),
"conversation_id": row.get("conversation_id"),
"request_id": row.get("request_id"),
"model_call_id": row.get("model_call_id"),
"provider_log": str(log_path),
}
break
if body is None:
raise SystemExit(f"未找到 llm_request: request_id={request_id} model_call_id={model_call_id}")
body_path.write_text(json.dumps(body, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
PY
url="${BASE_URL%/}${ENDPOINT_PATH}"
set +e
curl --no-buffer --silent --show-error \
--connect-timeout 30 \
--max-time "$MAX_TIME" \
--request POST "$url" \
--header "content-type: application/json" \
--header "anthropic-version: 2023-06-01" \
--header "User-Agent: claude-cli/1.0.25" \
--header "x-api-key: $API_KEY" \
--header "Authorization: Bearer $API_KEY" \
--data-binary "@$BODY_FILE" \
--dump-header "$HEADER_FILE" \
--output "$RESP_FILE"
code=$?
set -e
echo "curl_exit_code=$code"
echo "body=$BODY_FILE"
echo "headers=$HEADER_FILE"
echo "response=$RESP_FILE"
echo "meta=$META_FILE"
echo "--- response headers ---"
if [[ -f "$HEADER_FILE" ]]; then
python3 - "$HEADER_FILE" <<'PY'
from pathlib import Path
import sys
for line in Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()[:40]:
print(line)
PY
fi
echo "--- response first 120 lines ---"
if [[ -f "$RESP_FILE" ]]; then
python3 - "$RESP_FILE" <<'PY'
from pathlib import Path
import sys
for line in Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()[:120]:
print(line)
PY
else
echo "响应文件不存在。"
fi
exit "$code"
-176
View File
@@ -1,176 +0,0 @@
---
name: cursor-debug-log
description: 当需要调查 Cursor 本地模式 debug/log 证据时使用:config.yaml 的 log 热加载、history/<conversationId>/debug JSONL 文件、Bidi 原始/解码记录、RunSSE 记录、runtime/provider debug 记录、debug 文件缺失原因,或解释这些 debug 文件如何生成与如何查询。
---
# Cursor Debug Log
使用这个技能来解释和检查本地 debug log 体系。目标是在不修改已安装 Cursor 客户端、不依赖旧版 legacy artifact 的前提下,还原一次请求附近发生了什么。
## 作用定位
debug log 是本地模式请求链路的可选证据层。它和模型可见历史是分开的:
- 用来回答“客户端到底发了什么”。
- 用来回答“后端解码后认为这是什么请求”。
- 用来回答“哪些字段被挂到了当前 active request 上”。
- 用来回答“最终 provider request body 是什么样”。
- 用来回答“RunSSE 实际给客户端发送了什么”。
- 不要把它当成 replay history、prompt 输入或状态事实源。
稳定事实源仍然是:
- `history/<conversationId>/state.json`
- `history/<conversationId>/context.json`
- `history/usage.json`
- `logs/app.log`
debug 文件是在这些事实源之外,补充原始或近原始链路证据。
## 固定路径
- 助手根目录:`~/.cursor-local-assistant-v2`
- 配置文件:`~/.cursor-local-assistant-v2/config.yaml`
- history 根目录:`~/.cursor-local-assistant-v2/history`
- app 日志:`~/.cursor-local-assistant-v2/logs/app.log`
- 会话 debug 目录:`history/<conversationId>/debug/`
- 孤儿 debug 目录:`history/_debug/orphan/<requestId>/`
通过配置开启 debug logging
```yaml
log: true
```
当前实现会用轻量文件快照检查热加载 `config.yaml`。改完 `log` 后,预留大约 500ms,再期待下一次请求事件使用新值。旧二进制可能仍然需要重启。
## 文件如何生成
debug 层随着请求穿过后端边界逐步落盘:
1. `BidiAppend` 收到客户端上行数据。
- 原始 hex 写入 `bidi.raw.jsonl`
- 解码后的 known-schema protobuf 与后端提取出的 intent 写入 `bidi.decoded.jsonl`
2. forwarder 把解码结果转成 active runtime state。
- stream/request 状态决策写入 `runtime.jsonl`
3. provider pass 被准备并执行。
- adapter 前的请求摘要、`model_call_id``provider_pass` 等写入 `provider.jsonl`
- provider artifact callback 追加最终 request/summary payload 到 `provider.jsonl`
4. `RunSSE` 把后端输出流式发送给客户端。
- 已发送消息、终态事件、发送错误、断连和 heartbeat 写入 `runsse.jsonl`
如果某条消息到达时后端还不知道 `conversationId`,早期事件可能写到 `_debug/orphan/<requestId>/`。后续一旦知道 `conversationId`,新事件应进入 `history/<conversationId>/debug/`。还原早期或乱序请求时,两处都要查。
## Debug 文件含义
`bidi.raw.jsonl`
- 方向:客户端到后端。
- 包含 `request_id`、可选 `conversation_id``append_seqno``status`、原始 `data_hex`
- 当需要精确确认客户端上传字节时先看它。
`bidi.decoded.jsonl`
- 方向:客户端到后端,protobuf 解码后。
- 当前 schema v2 包含完整的 known-schema `AgentClientMessage` protojson`message`
- 同时包含后端从上行包提取出的 intent:`intent`,其中会展开相关 proto 子对象,例如 `client_message``user_message``request_context``conversation_state`、exec/interaction/kv 回包等。
- 还包含 `message_case``requested_model``conversation_action` 等检索索引;这些索引只方便搜索,不是完整证据本体。
- 当需要确认后端如何理解客户端请求时看它。若要证明客户端原始上传字节,仍以 `bidi.raw.jsonl` 为准。
- 旧二进制或旧日志可能只有 schema v1 摘要,未必展开 `message``intent` 里的完整字段。
`runtime.jsonl`
- 方向:后端内部 runtime。
- 包含状态流转,以及挂到 active stream/request 上的字段。
- 当需要把 decoded input 和后续 provider 行为串起来时看它。
`provider.jsonl`
- 方向:后端到 provider adapter/provider。
- 包含 provider pass 元数据、`model_call_id`、request knobs、最终 provider request artifact、provider summary artifact。
- 当最终出站 provider body 或 provider summary 是关键证据时看它。
`runsse.jsonl`
- 方向:后端到客户端。
- 包含解码后的 `AgentServerMessage` 发送、终态事件、发送错误、断连和 heartbeat。
- 用来检查后端尝试返回给客户端的内容。它是解码后的消息证据,不是原始 HTTP/SSE framing。
## 查询流程
1. 先判断 id 类型。
- 先查 `history/<id>/state.json`,确认它是不是 `conversationId`
- 再在 `history/*/{state.json,context.json}``history/usage.json``logs/app.log` 里搜索 request/model-call/tool id。
2. 拿到 `conversationId` 后,列出 debug 目录。
- `ls -la "$HOME/.cursor-local-assistant-v2/history/<conversationId>/debug"`
3. 如果 debug 目录不存在,确认请求发生时 debug 是否已开启。
- 读取 `config.yaml`
- 对比 `config.yaml``state.json``context.json` 的 mtime。
- 搜索 `logs/app.log` 里的 config hot reload 或 provider start 记录。
4. 按时间顺序读 JSONL,并用这些字段串联:
- `request_id`
- `conversation_id`
- `model_call_id`
- `provider_pass`
- `append_seqno`
- event timestamp
5. 最终回复只总结结论所需字段。不要粘贴 secret、API key、完整 provider body 或大段原始 payload。
常用命令:
```bash
ROOT="$HOME/.cursor-local-assistant-v2"
REQ="<requestId>"
CONV="<conversationId>"
rg -n "$REQ" "$ROOT/history" "$ROOT/logs/app.log"
find "$ROOT/history" -path "*/debug/*" -type f | sort
rg -n "$REQ|model_call_id|provider_request_prepared|llm_request" "$ROOT/history/$CONV/debug"
```
紧凑查看 JSONL
```bash
jq -c 'select(.request_id == "<requestId>")' "$ROOT/history/$CONV/debug/provider.jsonl"
jq -c 'select(.request_id == "<requestId>") | {append_seqno, message_case, conversation_action, message, intent}' "$ROOT/history/$CONV/debug/bidi.decoded.jsonl"
```
## 证据怎么用
根据问题选择对应文件:
- 客户端原始上行问题:先看 `bidi.raw.jsonl`。这是精确原始包证据。
- 客户端 known-schema 字段问题:看 `bidi.decoded.jsonl``message`。例如 `user_message.message_id`、selected image、conversation state bytes 等字段是否在解码结果里。
- 后端如何理解请求:看 `bidi.decoded.jsonl``intent`,再接 `runtime.jsonl`
- provider request 问题:看 `provider.jsonl`,尤其是 `llm_request`
- UI/流式输出问题:看 `runsse.jsonl`
- 请求状态问题:先看 `state.json``context.json``usage.json`,再用 debug 文件补证。
- debug 缺失问题:看 `config.yaml`、mtime、app log、orphan debug 目录。
runtime model parameters,例如 thinking strength,只是 provider request 证据的一类例子:
- `bidi.raw.jsonl` 说明客户端原始上传了什么。
- `bidi.decoded.jsonl.message` 说明上行包按当前 known schema 解码出了什么。
- `bidi.decoded.jsonl.intent` 说明后端从 decoded input 里提取并准备使用了什么。
- `runtime.jsonl` 说明后端把什么挂到了请求状态上。
- `provider.jsonl` 说明最终为 provider 准备了什么。
只有普通 history 时不要过度断言。例如 `context.json` 里的 `reasoning_content` 能说明产生过 reasoning 文本,但不能单独证明是哪一个 runtime parameter value 导致的。
注意证据边界:
- `bidi.decoded.jsonl` 使用当前已知 proto schema 做解码。未知字段或原始 framing 差异不能靠 decoded 证明,必须回到 `bidi.raw.jsonl`
- `context.json` 仍是持久化历史事实源;debug 文件只能证明某次请求链路附近发生过什么。
- `provider.jsonl` 的 provider body 和 `bidi.raw.jsonl` / `bidi.decoded.jsonl` 都可能很大,回复用户时只摘必要字段,不粘贴完整图片、完整 body 或 secret。
## Debug 文件缺失
如果某个 request 没有 debug 文件,要明确说明“没有直接 debug 证据”。常见原因:
- 请求发生时 `log: false`
- 正在运行的二进制版本早于 debug logging 或 hot reload 实现。
- 事件发生时还没有解析到 conversation id,记录在 `_debug/orphan/<requestId>/`
- 请求在开启 `log` 前已经完成。
- 写文件失败;如果该版本有相关记录,app log 里可能有 warning。
debug 证据缺失时,回退到 `state.json``context.json``usage.json``logs/app.log`,并把结论标成推断,而不是直接证明。
@@ -0,0 +1,64 @@
---
name: cursor-proxy-debugger
description: Maintain, diagnose, extend, and validate the standalone Cursor HTTPS protocol debugger in cursor-proxy-debugger. Use when changing its command startup, MITM capture behavior, Connect streaming or protobuf decoding, SQLite persistence, local debugging API, embedded web UI, tests, documentation, or when investigating captured Cursor BidiAppend, RunSSE, Fork Chat, or model-discovery traffic.
---
# Cursor Proxy Debugger
Treat `cursor-proxy-debugger` as an independent Go module and executable project. Keep its command entry point and all debugger-specific assets in that directory.
## Respect the project boundary
- Keep every Go file in the project root in `package main`; do not recreate a command directory in the main repository.
- Reuse the shared CA and generated Cursor protobuf packages from `cursor-byok` rather than copying them.
- The canonical proto sources are `cursor-byok/internal/backend/cursor/proto`; update or regenerate them in the main repository when schemas change.
- Keep the tool observational: never modify Cursor settings, system proxy settings, or the installed client automatically.
- Bind the debugging UI to loopback addresses only. Continue passing non-target CONNECT traffic through without MITM.
- Preserve forwarded request and response bodies even when local capture limits truncate stored copies.
## Locate the responsibility
- `main.go`: flags, startup output, browser opening, signals, and graceful shutdown.
- `proxy.go` and `capture.go`: listeners, target matching, MITM, streaming capture, and forwarding.
- `decode.go`: Connect envelopes, compression, protobuf message selection, and JSON views.
- `decode_stored.go`: persisted payload hydration and stored protobuf/text views.
- `proxy_capture.go`: request/response body capture and frame event assembly.
- `store.go`: hot-memory state, SQLite persistence, subscriptions, and conversation queries.
- `store_queries.go`: persisted exchange queries, cloning, redaction helpers, and subscriptions.
- `types.go`: configuration and API-facing capture models.
- `web.go`: loopback API, SSE events, CA download, security headers, and embedded assets.
- `web/app.js`: page state, rendering, Monaco editor lifecycle, and bootstrap.
- `web/app_events.js`: UI event binding for filters, details, pause, and resizing.
- `web/view_helpers.js`: display formatting, HTML escaping, and copy-text helpers.
- `web/styles*.css`: split base, control, detail, and responsive stylesheets.
- `web/`: dependency-free debugging UI and its Chinese/English text.
## Follow the change workflow
1. Inspect `git status` and the relevant staged and unstaged diffs before editing; captures and debugger files may already contain user work.
2. Read the smallest responsible source files. This standalone temporary debugger intentionally does not carry a test suite; for backend, MITM, or routing changes in formal product modules, also follow `chinese-code-style` and its `MODULES.md` boundary rules.
3. For a new protocol endpoint, confirm the exact URL path, request/response direction, streaming mode, compression, and generated protobuf message type. Do not infer schemas from similar endpoints.
4. Decode incrementally across arbitrary read boundaries. Treat Connect flags and the five-byte frame header as protocol data, and keep malformed-frame errors visible without breaking upstream forwarding.
5. Redact sensitive headers in every newly exposed API or UI path. Never log or render authorization material by default.
6. When changing UI text, update both locale tables in `web/i18n.js`, keep `data-i18n` keys aligned, and verify the fallback language.
7. Update `README.md` and `README.en.md` together when commands, flags, supported traffic, storage, or setup steps change.
## TDD boundary and proportional validation
- Formal product modules must follow TDD: write or update a focused failing test first, implement the smallest change that makes it pass, then refactor while keeping the test green.
- This project is a temporary observational tool, so TDD is not mandatory and test files may be intentionally omitted. Validate it with formatting, build checks, the style checker, and targeted manual smoke checks instead.
- Format changed Go files with `gofmt`.
- Run `go build -o <temporary-path>/cursor-proxy-debugger .` from the standalone project after entry-point, dependency, embed, or build-task changes. Do not require tests while this temporary project has no tests.
- Run the Chinese style checker on changed handwritten source files.
- For UI changes, start with `go run . -open=false` when safe, query `/api/status`, and inspect the page in a browser if layout or interaction changed.
- For capture or decoding changes, perform focused manual checks for split reads, compressed frames, malformed input, endpoint direction, persistence, or pass-through behavior as applicable.
## Use the canonical commands
From `cursor-proxy-debugger`:
```bash
go run .
go build -o ./bin/cursor-proxy-debugger .
```
@@ -0,0 +1,4 @@
interface:
display_name: "Cursor Proxy Debugger"
short_description: "维护、诊断并验证独立的 Cursor HTTPS 协议调试代理"
default_prompt: "Use $cursor-proxy-debugger to diagnose or modify the standalone Cursor protocol debugging proxy at /Users/leokun/Documents/cursor-proxy-debugger."
-42
View File
@@ -1,42 +0,0 @@
---
name: i18n-requirements
description: Use when adding or changing frontend UI text, locale support, translation JSON, the static i18n scanner, language selection, or native tray labels in this repository; keeps source messages, generated catalogs, translations, and runtime locale registration consistent.
---
# I18n Requirements
## Source Messages
- Treat `zh-CN` as the only source locale.
- Write user-visible frontend text as Chinese source literals in scanned files under `frontend/src/`.
- Do not branch on locale or hard-code English, Japanese, Russian, or other translated UI text in components or state modules.
- Let `frontend/plugins/static-i18n-plugin.js` replace source literals with runtime helpers. Do not hand-write generated message IDs in application code.
- Keep internal matching tokens out of the catalog. Use a regex for Chinese protocol/error matching instead of a user-visible string literal when the text is not intended for display.
- Do not place ordinary user-visible source messages under `frontend/src/i18n/`; the scanner excludes that directory. Native language names in `LOCALE_OPTIONS` are an intentional exception.
## Generated Catalogs
- Treat `frontend/src/i18n/generated/catalog.json` and the source-locale entries as scanner output. Do not manually edit catalog references or message IDs.
- Run `npm run build` from `frontend/` after changing UI text. The build must run with `--scan` and update every locale file.
- Preserve every placeholder exactly across locales, including `{0}`, `{1}`, newlines, and formula fragments such as `${1}`.
- Provide a non-empty translation for every catalog key in every non-source locale. Do not rely on the Chinese fallback for completed locale support.
## Adding A Locale
Update all of these integration points together:
- `SUPPORTED_LOCALES` in `frontend/plugins/static-i18n-plugin.js`.
- `SUPPORTED_LOCALES` and `LOCALE_OPTIONS` in `frontend/src/i18n/config.js`.
- The locale JSON import, `localeMessages`, and primary-language mapping in `frontend/src/i18n/runtime.js`.
- `frontend/src/i18n/locales/<locale>.json` with the complete catalog key set.
- Native tray labels in `internal/app/runner.go`.
## Verification
After the scan build:
1. Confirm `npm run build` succeeds.
2. Confirm every locale JSON has the same keys as `catalog.json`.
3. Confirm non-source locale files contain no empty values.
4. Confirm translated placeholders match the source entry placeholders.
5. Run the build twice when scanner behavior changed and confirm generated files are stable.
@@ -1,4 +0,0 @@
interface:
display_name: "I18n Requirements"
short_description: "Keep UI translations and generated catalogs in sync"
default_prompt: "Use $i18n-requirements to update localized UI text safely."
@@ -1,35 +0,0 @@
---
name: prefix-cache-stability
description: Use when changing prompt compilation, history replay, persisted conversation state, model request construction, or dynamic reminders in this repo; protects prefix-cache hit rate by keeping model-visible history append-only and dynamic attention scoped to the latest request.
---
# Prefix Cache Stability
Use this skill before editing prompt, history replay, persisted conversation state, or provider request code.
## Hard Constraints
- Model-visible history is append-only. If a message was sent to the model and is meant to remain historical context, persist it and replay it at the same relative position.
- Do not move previously sent model-visible messages to a new position in later requests.
- Keep the largest stable prefix first: system prompt, imported replay, persisted user/request/tool history, then current-turn suffix context.
- Truly dynamic attention is latest-only. Current state blocks, latest edit guards, and other volatile reminders should be appended near the end of the current request and should not become long-lived prefix content unless they are intentionally persisted as historical facts.
- Persisted prompt context must be worded so it is safe as history. Avoid stale wording like "currently" unless the context is only latest-only.
- Never optimize cache by dropping correctness-critical context.
- Never remove, strip, reorder, or suppress historical `reasoning_content` replay merely to reduce repetitive thinking. Some providers need prior reasoning for valid continuation; optimize the latest tool guidance or current-turn prompt behavior instead.
## Implementation Pattern
1. Classify each prompt addition:
- Stable system policy: belongs in the fixed system prompt.
- Historical model-visible context: persist as replayable history.
- Latest-only attention: append as current suffix, do not persist.
2. For persisted context, store enough metadata to dedupe the same turn, usually `source` plus a content hash.
3. Replay persisted context from history/projector, not by regenerating and inserting it into old positions.
4. On provider retries or same-turn follow-up passes, do not duplicate an already persisted prompt context.
5. Persisted conversation state must include replayable prompt context so a restarted conversation preserves the same prefix. In this repo, use `context.json.items` for replayable semantic history and `state.json` for mutable latest state.
## Verification
- Compare adjacent provider request artifacts or captured canonical request bodies and compute the longest common prefix.
- Check final raw SSE usage fields before blaming local metrics. Some OpenAI-compatible providers do not return cached-token fields.
- A healthy change should make old request prefixes stable while allowing only the newest suffix to vary.
-9
View File
@@ -1,9 +0,0 @@
*
!go.mod
!go.sum
!cursor-tab-server/
!cursor-tab-server/**
!gen/
!gen/**
!internal/
!internal/**
-12
View File
@@ -1,12 +0,0 @@
## 变更说明 / What
<!-- 简要描述这个 PR 做了什么、为什么 / Briefly describe what this PR does and why -->
## 关联 Issue / Related Issue
<!-- 例如 / e.g. Closes #161 -->
## 测试方式 / How to Test
<!-- 描述如何验证这个变更 / Describe how to verify this change -->
+1 -8
View File
@@ -16,12 +16,5 @@ server-go/log/
.cursor-local-assistant-v2
.cursor-app-formatted/
proto/extensions-cursor-app/
ads-server-linux-amd64.tar
cmd/ads-server/*.db
cmd/ads-server/*.db-*
cmd/ads-server/*.sqlite
cmd/ads-server/*.sqlite-*
cmd/ads-server/data/
cmd/ads-server/ads-server
cmd/ads-server/ads-server-linux-amd64.tar
cursor-tab-server/cursor-tab-server-linux-amd64.tar
/cursor-proto/proto/
+9
View File
@@ -0,0 +1,9 @@
# AGENTS.md
- Do not preserve backward compatibility. Remove obsolete paths instead of adding compatibility layers, fallbacks, or migrations.
- Choose the simplest implementation that fully meets the current requirements. Avoid speculative abstractions, configuration, and indirection.
- Grow the system in layers. Start from the smallest version that works end to end, and add each new capability on top of a product that already works. Never trade a working product for unfinished complexity.
- Keep components modular and concerns clearly separated.
- Prefer established, well-maintained libraries when they reduce overall complexity or improve reliability. Do not reimplement common functionality without a clear reason.
- Lean on the dependencies already in the project before writing your own implementation or adding packages. Do not assume a library lacks a capability without checking its documentation and types.
- Make architectural decisions for the long term. Do not accept a stopgap that only works for now and is meant to be replaced later.
-90
View File
@@ -1,90 +0,0 @@
# 贡献指南
> English version: [CONTRIBUTING_EN.md](./CONTRIBUTING_EN.md)
感谢你考虑为 cursor-byok 做出贡献!
## 开发环境
| 依赖 | 版本要求 |
|------|---------|
| Go | >= 1.25 |
| Node.js | >= 20 |
| Yarn | 1.x (classic) |
| [Task](https://taskfile.dev) | >= 3 |
| [Wails v3 CLI](https://v3alpha.wails.dev) | alpha.74+ |
Linux 额外依赖:`libgtk-3-dev``libwebkit2gtk-4.1-dev`Wails 运行时需要)。
## 快速开始
```bash
# 安装前端依赖
cd frontend && yarn install --frozen-lockfile && cd ..
# 启动开发模式(热重载)
task dev
# 构建当前平台分发包
task build
```
## 项目结构
```
├── main.go # 入口
├── internal/ # Go 后端(代理、转发、客户端管理等)
├── frontend/ # Vue 3 + Vite + Tailwind 前端
│ ├── src/
│ │ ├── views/ # 页面
│ │ ├── components/ # 组件
│ │ ├── i18n/ # 国际化(zh-CN / en-US / ja-JP / ru-RU
│ │ └── state/ # 全局状态
│ └── plugins/ # Vite 插件(i18n 静态扫描等)
├── prompt/ # 内置 Agent prompt 模板
├── proto/ # Protobuf 定义
├── build/ # 构建配置与平台 Taskfile
├── scripts/ # 辅助脚本(release、metrics
└── Taskfile.yml # 顶层任务编排
```
## 开发规范
### 提交信息
采用 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/) 风格:
```
feat(proxy): 支持自定义 upstream 超时
fix(i18n): 补全日语翻译缺失 key
release: 0.0.42
```
### 代码风格
- Go:遵循 `gofmt` / `go vet`,不引入额外 linter 配置。
- 前端:Vue SFC + Composition APITailwind 工具类优先。
- 新增 UI 文案必须同步更新所有 locale 文件(`frontend/src/i18n/locales/`)。
### 分支与 PR
1. 从 `main` 创建功能分支:`feat/xxx``fix/xxx`
2. 保持 PR 小而聚焦,一个 PR 解决一个问题。
3. PR 描述中说明动机和测试方式。
## 构建与发布
```bash
# 构建全平台(仅 macOS 主机)
task build:all
# 准备发布资产
task release:prepare
# 发布到 GitHub Releases
task release:github
```
## 许可证
提交代码即表示你同意以 [MIT License](./LICENSE) 授权你的贡献。
-90
View File
@@ -1,90 +0,0 @@
# Contributing Guide
> 中文版本:[CONTRIBUTING.md](./CONTRIBUTING.md)
Thank you for considering contributing to cursor-byok!
## Prerequisites
| Dependency | Version |
|------------|---------|
| Go | >= 1.25 |
| Node.js | >= 20 |
| Yarn | 1.x (classic) |
| [Task](https://taskfile.dev) | >= 3 |
| [Wails v3 CLI](https://v3alpha.wails.dev) | alpha.74+ |
Additional Linux dependencies: `libgtk-3-dev`, `libwebkit2gtk-4.1-dev` (required by Wails runtime).
## Quick Start
```bash
# Install frontend dependencies
cd frontend && yarn install --frozen-lockfile && cd ..
# Start dev mode (hot reload)
task dev
# Build for current platform
task build
```
## Project Structure
```
├── main.go # Entry point
├── internal/ # Go backend (proxy, forwarding, client management)
├── frontend/ # Vue 3 + Vite + Tailwind frontend
│ ├── src/
│ │ ├── views/ # Pages
│ │ ├── components/ # Components
│ │ ├── i18n/ # Internationalization (zh-CN / en-US / ja-JP / ru-RU)
│ │ └── state/ # Global state
│ └── plugins/ # Vite plugins (i18n static scanner, etc.)
├── prompt/ # Built-in agent prompt templates
├── proto/ # Protobuf definitions
├── build/ # Build configs & platform Taskfiles
├── scripts/ # Helper scripts (release, metrics)
└── Taskfile.yml # Top-level task orchestration
```
## Development Guidelines
### Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
feat(proxy): support custom upstream timeout
fix(i18n): add missing Japanese translation keys
release: 0.0.42
```
### Code Style
- Go: follow `gofmt` / `go vet`; no additional linter config.
- Frontend: Vue SFC + Composition API, Tailwind utility-first.
- New UI strings must be added to ALL locale files (`frontend/src/i18n/locales/`).
### Branching & PRs
1. Create feature branches from `main`: `feat/xxx`, `fix/xxx`.
2. Keep PRs small and focused — one problem per PR.
3. Describe motivation and how to test in the PR description.
## Build & Release
```bash
# Build all platforms (macOS host only)
task build:all
# Prepare release assets
task release:prepare
# Publish to GitHub Releases
task release:github
```
## License
By contributing, you agree that your contributions will be licensed under the [MIT License](./LICENSE).
-107
View File
@@ -1,107 +0,0 @@
<div align="center">
# cursor-byok
cursor-byok 是 Cursor 后端的本地实现。
<br>
<br>
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
[使用教程](https://docs.leokun.cn) · [下载最新版](https://github.com/leookun/cursor-byok/releases/latest) · [问题反馈](https://github.com/leookun/cursor-byok/issues) · [English](./README.md)
[![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/leookun/cursor-byok/total?style=flat-square)](https://github.com/leookun/cursor-byok/releases)
[![License](https://img.shields.io/github/license/leookun/cursor-byok?style=flat-square)](./LICENSE)
[![Platforms](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
</div>
![cursor-byok 支持接入多种模型 API](./images/cn-brand.png)
![cursor-byok 主界面](./images/cn-home.png)
## 项目介绍
cursor-byok 是一个开源的 Cursor 本地模型接入工具。它通过运行在本机的服务连接 Cursor 与你配置的模型 API,让模型请求使用自己的渠道处理,同时保留 Cursor Agent 的工具调用、Skills 和 MCP 等能力。
你可以接入 OpenAI、Anthropic 及其兼容服务,自由配置接口地址、模型、密钥和请求参数,不再局限于平台预设的模型渠道。
> [!IMPORTANT]
> cursor-byok 本身免费开源,但你接入的模型 API 可能由对应服务商收费。本项目不是 Cursor 官方产品,与 Cursor 或其开发公司无隶属关系。
## 核心能力
- **自定义模型渠道**:配置自己的 API 地址、访问密钥和模型标识。
- **多种接口协议**:支持 OpenAI、Anthropic 兼容接口及自定义端点。
- **模型管理**:添加、复制、编辑、排序和批量测试多个模型配置。
- **连接性能测试**:查看首字延迟、生成速度与模型服务的原始响应。
- **Agent 工作流**:支持工具调用、Skills、MCP 和多轮会话。
- **会话统计**:查看 Token 消耗、缓存命中率、对话轮次和价值估算。
- **跨平台运行**:支持 macOS、Windows 和 Linux。
## 快速开始
1. 从 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载对应平台的最新版本。
2. 启动 cursor-byok,打开“模型配置”,填写接口地址、API Key 和模型标识。
3. 测试模型配置;测试通过后返回主界面启动服务。
4. 打开 Cursor,选择已配置的模型并开始使用 Agent。
更完整的安装、系统配置和常见问题说明,请查看 [详细使用教程](https://docs.leokun.cn)。
## 模型管理
模型配置支持 OpenAI 与 Anthropic 两类接口协议。每个模型渠道可以独立设置上下文窗口、最大输出 Token、推理强度、自定义请求头和额外请求参数。
![cursor-byok 模型配置](./images/cn-model.png)
## 工作原理
```text
Cursor 客户端
│ Agent 请求与工具结果
cursor-byok 本地服务
│ OpenAI / Anthropic 兼容请求
你配置的模型 API
```
cursor-byok 在本机负责协议适配、模型请求转发、工具调用衔接与会话状态管理。模型 API Key 和应用配置保存在本机;实际请求仍会发送到你所配置的模型服务商。
## 为什么做这个项目
很多 Agent 产品会将工具能力、模型选择、订阅方案和计费方式绑定在一起,用户只能使用平台提供的模型渠道。
我希望将模型选择权交还给用户:开发者可以充分利用已有的模型 API 和额度,自由选择适合自己的模型与服务商,也可以在需要时自托管相关服务。
## 路线图
项目将继续改进模型兼容性、Agent 工具链、本地运行稳定性和自托管体验,并探索更多 IDE、Chat 与 Agent 场景。
详细计划与进展请查看 [正式版路线图](https://github.com/leookun/cursor-byok/discussions/32)。
## 社区与支持
- [使用教程](https://docs.leokun.cn)
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
- [Telegram 交流群](https://t.me/cursor_byok)
- QQ 交流群:`1095916242``1094411438``1095918002``1094419321`
## 开发与贡献
欢迎提交 Issue 和 Pull Request。开发环境、构建命令、项目结构及提交规范请阅读 [贡献指南](./CONTRIBUTING.md)。
## 贡献者名单
<a href="https://github.com/leookun/cursor-byok/graphs/contributors">
<img src="https://contrib.rocks/image?repo=leookun/cursor-byok" />
</a>
## 许可证
本项目基于 [MIT License](./LICENSE) 开源。
-110
View File
@@ -1,110 +0,0 @@
<div align="center">
# cursor-byok
cursor-byok is a local implementation of Cursor's backend.
<br>
<br>
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
[User Guide](https://docs.leokun.cn) · [Download](https://github.com/leookun/cursor-byok/releases/latest) · [Report an Issue](https://github.com/leookun/cursor-byok/issues) · [中文版本说明](./README-CN.md)
[![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/leookun/cursor-byok/total?style=flat-square)](https://github.com/leookun/cursor-byok/releases)
[![License](https://img.shields.io/github/license/leookun/cursor-byok?style=flat-square)](./LICENSE)
[![Platforms](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
</div>
![Connect cursor-byok to a wide range of model APIs](./images/en-brand.png)
![cursor-byok dashboard](./images/en-home.png)
## About
cursor-byok is an open-source local model gateway for Cursor. It runs a service on your machine that connects Cursor to the model APIs you configure, routes model requests through your own providers, and preserves Cursor Agent capabilities such as tool calling, Skills, and MCP.
You can connect OpenAI- and Anthropic-compatible services, customize endpoints, model IDs, API keys, and request parameters, and use model channels beyond the options built into the platform.
> [!IMPORTANT]
> cursor-byok is free and open source, but the model APIs you connect may charge for usage. This is an independent project and is not affiliated with or endorsed by Cursor or its developers.
## Features
- **Bring your own model channels:** Configure your own API endpoint, credentials, and model IDs.
- **Multiple API protocols:** Use OpenAI- and Anthropic-compatible APIs or a custom endpoint.
- **Model management:** Add, duplicate, edit, reorder, and batch-test multiple model configurations.
- **Connection benchmarks:** Measure time to first token, generation speed, and inspect raw provider responses.
- **Agent workflows:** Keep tool calling, Skills, MCP, and multi-turn conversations available.
- **Session metrics:** Track token usage, cache hit rate, conversation turns, and estimated value.
- **Cross-platform:** Run on macOS, Windows, and Linux.
## Quick Start
1. Download the latest build for your platform from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest).
2. Launch cursor-byok, open **Model Settings**, and enter the endpoint, API key, and model ID.
3. Test the model configuration. Once it passes, return to the dashboard and start the service.
4. Open Cursor, select the configured model, and start using Agent.
For complete installation steps, system configuration, and troubleshooting, see the [User Guide](https://docs.leokun.cn).
## Model Management
Model configurations support both OpenAI and Anthropic API protocols. Each model channel can independently define its context window, maximum output tokens, reasoning effort, custom headers, and additional request parameters.
![cursor-byok model settings](./images/en-model.png)
## How It Works
```text
Cursor client
│ Agent requests and tool results
cursor-byok local service
│ OpenAI- / Anthropic-compatible requests
Your model API
```
cursor-byok handles protocol adaptation, model request forwarding, tool-call coordination, and conversation state on your machine. API keys and application settings are stored locally; requests are still sent to the model provider you configure.
## Why This Project
Many Agent products bundle their tool capabilities with a fixed set of models, subscriptions, and billing options, leaving users limited to the channels offered by the platform.
cursor-byok is built to return model choice to the user. Developers can make full use of the APIs and credits they already have, choose the models and providers that fit their needs, and self-host related services when required.
## Roadmap
The project will continue to improve model compatibility, Agent tooling, local runtime stability, and the self-hosting experience while exploring support for more IDE, chat, and Agent workflows.
See the [release roadmap](https://github.com/leookun/cursor-byok/discussions/32) for plans and progress.
## Community and Support
- [User Guide](https://docs.leokun.cn)
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
- [Telegram community](https://t.me/cursor_byok)
- QQ groups: `1095916242`, `1094411438`, `1095918002`, `1094419321`
## Development and Contributing
Issues and pull requests are welcome. See the [Contributing Guide](./CONTRIBUTING_EN.md) for prerequisites, build commands, project structure, and contribution guidelines.
## Contributors
<a href="https://github.com/leookun/cursor-byok/graphs/contributors">
<img src="https://contrib.rocks/image?repo=leookun/cursor-byok" />
</a>
## License
This project is open source under the [MIT License](./LICENSE).
-569
View File
@@ -1,569 +0,0 @@
version: "3"
includes:
common: ./build/Taskfile.yml
windows: ./build/windows/Taskfile.yml
darwin: ./build/darwin/Taskfile.yml
linux: ./build/linux/Taskfile.yml
vars:
APP_NAME: "Cursor助手"
BIN_DIR: "bin"
APP_VERSION:
sh: 'go run ./scripts/release version -config ./build/config.yml'
RELEASE_REPO: "leookun/cursor-byok"
RELEASE_BASE_NAME: "cursor-byok"
RELEASE_SOURCE_PATH: "release-notes.md"
RELEASE_DIR: '{{.BIN_DIR}}/release/{{.APP_VERSION}}'
RELEASE_NOTES_PATH: '{{.RELEASE_DIR}}/.release-notes.md'
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
SCAN: '{{.SCAN | default "false"}}'
CURRENT_WINDOWS_ARCH: '{{if eq ARCH "386"}}386{{else}}amd64{{end}}'
CURRENT_WINDOWS_NAME: '{{if eq .CURRENT_WINDOWS_ARCH "386"}}windows-32{{else}}windows-64{{end}}'
CURRENT_DARWIN_NAME: '{{if eq ARCH "arm64"}}macos-arm64{{else}}macos-intel{{end}}'
CURRENT_LINUX_ARCH: "amd64"
CURRENT_LINUX_NAME: "linux-amd64"
tasks:
build:
summary: 构建当前系统分发包
preconditions:
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "windows" ] || [ "{{OS}}" = "linux" ]'
msg: "仅支持在 macOS、Windows 或 Linux 上执行 task build"
cmds:
- task: clean:dist
- task: build:current
run:
summary: 运行当前系统版本
cmds:
- task: "{{OS}}:run"
build:all:
summary: 构建全部 macOS/Windows 分发包(Linux 需在原生 Linux 主机单独构建)
preconditions:
- sh: '[ "{{OS}}" = "darwin" ]'
msg: "task build:all 仅支持在 macOS 上执行;Linux 需在原生 Linux 主机构建。"
cmds:
- task: clean:dist
- task: build:darwin:arm64
- task: build:darwin:amd64
- task: build:windows:386
- task: build:windows:amd64
build:current:
internal: true
cmds:
- task: '{{if eq OS "darwin"}}build:darwin:current{{else if eq OS "linux"}}build:linux:current{{else}}build:windows:current{{end}}'
build:darwin:current:
internal: true
cmds:
- task: darwin:package:dmg
vars:
ARCH: '{{ARCH}}'
BINARY_NAME: '{{.CURRENT_DARWIN_NAME}}'
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_DARWIN_NAME}}'
APP_BUNDLE: '{{.CURRENT_DARWIN_NAME}}.app'
DMG_NAME: '{{.CURRENT_DARWIN_NAME}}.dmg'
SCAN: '{{.SCAN}}'
build:darwin:arm64:
internal: false
cmds:
- task: darwin:package:dmg
vars:
ARCH: arm64
BINARY_NAME: macos-arm64
OUTPUT: '{{.BIN_DIR}}/macos-arm64'
APP_BUNDLE: macos-arm64.app
DMG_NAME: macos-arm64.dmg
SCAN: '{{.SCAN}}'
build:darwin:amd64:
internal: true
cmds:
- task: darwin:package:dmg
vars:
ARCH: amd64
BINARY_NAME: macos-intel
OUTPUT: '{{.BIN_DIR}}/macos-intel'
APP_BUNDLE: macos-intel.app
DMG_NAME: macos-intel.dmg
SCAN: '{{.SCAN}}'
build:windows:current:
internal: true
cmds:
- task: windows:create:zip
vars:
ARCH: '{{.CURRENT_WINDOWS_ARCH}}'
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_WINDOWS_NAME}}.exe'
ZIP_NAME: '{{.CURRENT_WINDOWS_NAME}}.zip'
SCAN: '{{.SCAN}}'
build:windows:386:
internal: true
cmds:
- task: windows:create:zip
vars:
ARCH: 386
OUTPUT: '{{.BIN_DIR}}/windows-32.exe'
ZIP_NAME: windows-32.zip
SCAN: '{{.SCAN}}'
build:windows:amd64:
cmds:
- task: windows:create:zip
vars:
ARCH: amd64
OUTPUT: '{{.BIN_DIR}}/windows-64.exe'
ZIP_NAME: windows-64.zip
SCAN: '{{.SCAN}}'
build:linux:current:
internal: true
cmds:
- task: linux:package:archive
vars:
ARCH: '{{.CURRENT_LINUX_ARCH}}'
BINARY_NAME: '{{.CURRENT_LINUX_NAME}}'
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_LINUX_NAME}}'
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
ARCHIVE_PATH: '{{.BIN_DIR}}/{{.CURRENT_LINUX_NAME}}.tar.gz'
SCAN: '{{.SCAN}}'
build:linux:amd64:
cmds:
- task: linux:package:archive
vars:
ARCH: amd64
BINARY_NAME: linux-amd64
OUTPUT: '{{.BIN_DIR}}/linux-amd64'
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
ARCHIVE_PATH: '{{.BIN_DIR}}/linux-amd64.tar.gz'
SCAN: '{{.SCAN}}'
cursor-tab-server:docker:amd64:
summary: 构建 cursor-tab-server 的 linux/amd64 Docker 镜像并保存到 cursor-tab-server 目录
vars:
IMAGE_NAME: '{{.IMAGE_NAME | default "cursor-tab-server:linux-amd64"}}'
IMAGE_TAR: '{{.IMAGE_TAR | default "cursor-tab-server/cursor-tab-server-linux-amd64.tar"}}'
CONTEXT_DIR: cursor-tab-server
DOCKERFILE: cursor-tab-server/Dockerfile
preconditions:
- sh: docker info >/dev/null 2>&1
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
- sh: test -f "{{.DOCKERFILE}}"
msg: "未找到 cursor-tab-server/Dockerfile。"
- sh: test -f "{{.CONTEXT_DIR}}/go.mod"
msg: "未找到 cursor-tab-server/go.mod。"
cmds:
- mkdir -p "{{.CONTEXT_DIR}}"
- docker build --platform linux/amd64 -t "{{.IMAGE_NAME}}" -f "{{.DOCKERFILE}}" "{{.CONTEXT_DIR}}"
- docker save "{{.IMAGE_NAME}}" -o "{{.IMAGE_TAR}}"
- ls -lh "{{.IMAGE_TAR}}"
clean:dist:
internal: true
cmds:
- mkdir -p "{{.BIN_DIR}}"
- rm -f "{{.BIN_DIR}}/macos-arm64.dmg" "{{.BIN_DIR}}/macos-intel.dmg" "{{.BIN_DIR}}/windows-32.zip" "{{.BIN_DIR}}/windows-64.zip" "{{.BIN_DIR}}/linux-amd64.tar.gz"
- rm -f "{{.BIN_DIR}}/macos-arm64" "{{.BIN_DIR}}/macos-intel" "{{.BIN_DIR}}/windows-32.exe" "{{.BIN_DIR}}/windows-64.exe" "{{.BIN_DIR}}/linux-amd64"
- rm -f "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.exe"
- rm -rf "{{.BIN_DIR}}/macos-arm64.app" "{{.BIN_DIR}}/macos-intel.app" "{{.BIN_DIR}}/{{.APP_NAME}}.app" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
dev:
summary: 启动开发模式(前台,终端会保持占用)
cmds:
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
proxy-debugger:
summary: 启动独立 Cursor 协议调试代理
cmds:
- go run ./cmd/cursor-proxy-debugger
proxy-debugger:build:
summary: 构建独立 Cursor 协议调试代理
cmds:
- go build -o ./bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
ads:install:
summary: 安装广告页依赖
dir: '{{.TASKFILE_DIR}}/ads-page'
sources:
- package.json
- yarn.lock
generates:
- node_modules
cmds:
- yarn install --frozen-lockfile
ads:dev:
summary: 启动广告页开发服务(http://127.0.0.1:5174/ad/
dir: '{{.TASKFILE_DIR}}/ads-page'
deps:
- task: ads:install
cmds:
- yarn dev
ads:build:
summary: 构建广告 ZIP 产物
dir: '{{.TASKFILE_DIR}}/ads-page'
deps:
- task: ads:install
cmds:
- yarn build
setup:docker:
summary: 构建 Linux 交叉编译 Docker 镜像
cmds:
- task: common:setup:docker
release:clean:
summary: 清理当前版本的发布目录
cmds:
- rm -rf "{{.RELEASE_DIR}}"
- mkdir -p "{{.RELEASE_DIR}}"
release:ensure:dir:
internal: true
cmds:
- mkdir -p "{{.RELEASE_DIR}}"
release:notes:
summary: 从 release-notes.md 生成当前版本的发布说明文件(README.md 仅用于公开仓库同步)
internal: true
preconditions:
- sh: test -f "{{.RELEASE_SOURCE_PATH}}"
msg: "未找到 release-notes.md,请先创建发布日志文件。"
- sh: test -n "$(tr -d '[:space:]' < "{{.RELEASE_SOURCE_PATH}}")"
msg: "release-notes.md 不能为空,请先填写发布日志。"
cmds:
- go run ./scripts/release notes -config ./build/config.yml -out "{{.RELEASE_NOTES_PATH}}" -source "{{.RELEASE_SOURCE_PATH}}"
release:build:macos:arm64:
cmds:
- task: darwin:package:archive
vars:
ARCH: arm64
BINARY_NAME: cursor-release-macos-arm64
OUTPUT: '{{.BIN_DIR}}/cursor-release-macos-arm64'
APP_BUNDLE: '{{.APP_NAME}}.app'
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-arm64.tar.gz'
SCAN: '{{.SCAN}}'
release:build:macos:amd64:
internal: true
cmds:
- task: darwin:package:archive
vars:
ARCH: amd64
BINARY_NAME: cursor-release-macos-amd64
OUTPUT: '{{.BIN_DIR}}/cursor-release-macos-amd64'
APP_BUNDLE: '{{.APP_NAME}}.app'
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-amd64.tar.gz'
SCAN: '{{.SCAN}}'
release:build:windows:amd64:
cmds:
- task: windows:create:zip
vars:
ARCH: amd64
OUTPUT: '{{.BIN_DIR}}/{{.RELEASE_BASE_NAME}}-windows-amd64.exe'
ZIP_NAME: 'release/{{.APP_VERSION}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-windows-amd64.zip'
SCAN: '{{.SCAN}}'
release:build:linux:amd64:
cmds:
- task: linux:package:archive
vars:
ARCH: amd64
BINARY_NAME: cursor-release-linux-amd64
OUTPUT: '{{.BIN_DIR}}/cursor-release-linux-amd64'
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-linux-amd64.tar.gz'
SCAN: '{{.SCAN}}'
release:manifest:
summary: 生成 update.json
internal: true
cmds:
- go run ./scripts/release manifest -config ./build/config.yml -assets-dir "{{.RELEASE_DIR}}" -out "{{.RELEASE_DIR}}/update.json" -repo "{{.RELEASE_REPO}}" -base-name "{{.RELEASE_BASE_NAME}}" -notes "{{.RELEASE_NOTES_PATH}}"
release:prepare:
summary: 在当前主机上生成对应的发布资产
preconditions:
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "linux" ]'
msg: "release:prepare 仅支持在 macOS 或 Linux 上执行。"
- sh: wails3 version >/dev/null 2>&1
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds:
- task: '{{if eq OS "darwin"}}release:prepare:darwin{{else}}release:prepare:linux{{end}}'
release:prepare:darwin:
summary: 在 macOS 上生成 macOS/Windows/Linux 发布资产
preconditions:
- sh: '[ "{{OS}}" = "darwin" ]'
msg: "release:prepare:darwin 仅支持在 macOS 上执行。"
- sh: wails3 version >/dev/null 2>&1
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds:
- task: release:clean
- task: common:update:build-assets
- task: release:notes
- task: release:build:macos:arm64
- task: release:build:macos:amd64
- task: release:build:windows:amd64
- task: release:build:linux:amd64
release:prepare:linux:
summary: 在当前主机上生成 Linux 发布资产
preconditions:
- sh: '[ "{{OS}}" = "linux" ] || [ "{{OS}}" = "darwin" ]'
msg: "release:prepare:linux 当前仅支持在 Linux 或 macOS 上执行。"
- sh: wails3 version >/dev/null 2>&1
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds:
- task: release:ensure:dir
- task: common:update:build-assets
- task: release:notes
- task: release:build:linux:amd64
release:verify:assets:
summary: 验证当前版本发布资产是否齐全
preconditions:
- sh: test -f "{{.RELEASE_NOTES_PATH}}"
msg: "未找到发布说明,请先执行对应的 release:prepare 任务。"
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-arm64.tar.gz"
msg: "缺少 macOS arm64 发布资产。"
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-amd64.tar.gz"
msg: "缺少 macOS amd64 发布资产。"
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-windows-amd64.zip"
msg: "缺少 Windows amd64 发布资产。"
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-linux-amd64.tar.gz"
msg: "缺少 Linux amd64 发布资产。"
cmds:
- echo "release assets verified"
release:sync:readme:
summary: 同步 README 到公开发布仓库(发布日志来源固定为 release-notes.md
internal: true
env:
GH_PAGER: cat
GIT_PAGER: cat
PAGER: cat
preconditions:
- sh: gh --version >/dev/null 2>&1
msg: "未检测到 gh,请先安装 GitHub CLI。"
- sh: gh auth token >/dev/null 2>&1
msg: "gh 未登录,请先执行 gh auth login。"
cmds:
- |
python3 - <<'PY'
import base64
import json
import pathlib
import subprocess
import sys
import time
RETRYABLE_TOKENS = ("EOF", "timeout", "TLS", "temporarily unavailable", "connection reset", "connection refused")
def run_gh(cmd, *, input_text=None, allow_404=False, retries=4):
last = None
for attempt in range(1, retries + 1):
completed = subprocess.run(
cmd,
input=input_text,
text=True,
capture_output=True,
)
if completed.returncode == 0:
return completed
stderr = (completed.stderr or "").strip()
stdout = (completed.stdout or "").strip()
combined = f"{stderr}\n{stdout}".strip()
if allow_404 and "404" in combined:
return completed
last = completed
if not any(token.lower() in combined.lower() for token in RETRYABLE_TOKENS):
return completed
if attempt < retries:
time.sleep(min(2 ** (attempt - 1), 5))
return last
repo = "{{.RELEASE_REPO}}"
readme_path = pathlib.Path("README.md")
content = readme_path.read_bytes()
encoded = base64.b64encode(content).decode("ascii")
get_cmd = [
"gh", "api",
f"repos/{repo}/contents/README.md",
]
result = run_gh(get_cmd, allow_404=True)
payload = {
"message": "docs: sync README from source repo",
"content": encoded,
"branch": "main",
}
if result.returncode == 0:
existing = json.loads(result.stdout)
existing_content = base64.b64decode(existing["content"])
if existing_content == content:
sys.exit(0)
payload["sha"] = existing["sha"]
put_cmd = [
"gh", "api",
f"repos/{repo}/contents/README.md",
"--method", "PUT",
"--input", "-",
]
completed = run_gh(put_cmd, input_text=json.dumps(payload))
if completed.returncode != 0 and completed.stderr:
sys.stderr.write(completed.stderr)
sys.exit(completed.returncode)
PY
release:github:
summary: 发布当前版本到 GitHub Releases
env:
GH_PAGER: cat
GIT_PAGER: cat
PAGER: cat
preconditions:
- sh: gh --version >/dev/null 2>&1
msg: "未检测到 gh,请先安装 GitHub CLI。"
- sh: gh auth token >/dev/null 2>&1
msg: "gh 未登录,请先执行 gh auth login。"
cmds:
- task: release:verify:assets
- task: release:manifest
- task: release:sync:readme
- |
python3 - <<'PY'
import mimetypes
import json
import pathlib
import subprocess
import sys
import time
RETRYABLE_TOKENS = ("EOF", "timeout", "TLS", "temporarily unavailable", "connection reset", "connection refused")
def run_gh(cmd, *, input_text=None, allow_404=False, retries=4):
last = None
for attempt in range(1, retries + 1):
completed = subprocess.run(
cmd,
input=input_text,
text=True,
capture_output=True,
)
if completed.returncode == 0:
return completed
stderr = (completed.stderr or "").strip()
stdout = (completed.stdout or "").strip()
combined = f"{stderr}\n{stdout}".strip()
if allow_404 and "404" in combined:
return completed
last = completed
if not any(token.lower() in combined.lower() for token in RETRYABLE_TOKENS):
return completed
if attempt < retries:
time.sleep(min(2 ** (attempt - 1), 5))
return last
repo = "{{.RELEASE_REPO}}"
version = "{{.APP_VERSION}}"
tag = f"v{version}"
notes = pathlib.Path("{{.RELEASE_NOTES_PATH}}").read_text(encoding="utf-8")
assets = sorted([
pathlib.Path("{{.RELEASE_DIR}}/update.json"),
*pathlib.Path("{{.RELEASE_DIR}}").glob("*.tar.gz"),
*pathlib.Path("{{.RELEASE_DIR}}").glob("*.zip"),
])
get_cmd = [
"gh", "api",
f"repos/{repo}/releases/tags/{tag}",
]
result = run_gh(get_cmd, allow_404=True)
payload = {
"tag_name": tag,
"target_commitish": "main",
"name": tag,
"body": notes,
"draft": False,
"prerelease": False,
}
if result.returncode == 0:
release = json.loads(result.stdout)
release_id = release["id"]
edit_cmd = [
"gh", "api",
f"repos/{repo}/releases/{release_id}",
"--method", "PATCH",
"--input", "-",
]
completed = run_gh(edit_cmd, input_text=json.dumps(payload))
if completed.returncode != 0:
if completed.stderr:
sys.stderr.write(completed.stderr)
sys.exit(completed.returncode)
refreshed = run_gh(get_cmd)
if refreshed.returncode != 0:
if refreshed.stderr:
sys.stderr.write(refreshed.stderr)
sys.exit(refreshed.returncode)
release = json.loads(refreshed.stdout)
else:
create_cmd = [
"gh", "api",
f"repos/{repo}/releases",
"--method", "POST",
"--input", "-",
]
completed = run_gh(create_cmd, input_text=json.dumps(payload))
if completed.returncode != 0:
sys.stderr.write(completed.stderr)
sys.exit(completed.returncode)
release = json.loads(completed.stdout)
upload_url = release["upload_url"].split("{", 1)[0]
existing = {asset["name"]: asset["id"] for asset in release.get("assets", [])}
for asset in assets:
name = asset.name
if name in existing:
delete_cmd = [
"gh", "api",
f"repos/{repo}/releases/assets/{existing[name]}",
"--method", "DELETE",
]
deleted = run_gh(delete_cmd)
if deleted.returncode != 0:
if deleted.stderr:
sys.stderr.write(deleted.stderr)
sys.exit(deleted.returncode)
content_type = mimetypes.guess_type(name)[0] or "application/octet-stream"
upload_cmd = [
"gh", "api",
f"{upload_url}?name={name}",
"--method", "POST",
"--header", f"Content-Type: {content_type}",
"--input", str(asset),
]
uploaded = run_gh(upload_cmd)
if uploaded.returncode != 0:
if uploaded.stderr:
sys.stderr.write(uploaded.stderr)
sys.exit(uploaded.returncode)
PY
-3
View File
@@ -1,3 +0,0 @@
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -Command "$env:Path = [System.Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path','User') + ';$HOME\go\bin'; task build"
pause
-2
View File
@@ -1,2 +0,0 @@
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + ";$HOME\go\bin"
task build
-160
View File
@@ -1,160 +0,0 @@
version: "3"
vars:
ROOT_DIR:
sh: 'cd "{{.TASKFILE_DIR}}/.." && pwd'
BUILD_DIR:
sh: 'cd "{{.TASKFILE_DIR}}" && pwd'
tasks:
extract:proto:
summary: 从 Cursor 扩展快照提取 proto
dir: '{{.ROOT_DIR}}'
preconditions:
- sh: 'test -z "{{.PROTO_INPUT}}" || test -f "{{.PROTO_INPUT}}"'
msg: "PROTO_INPUT 指向的 Cursor 扩展 bundle 不存在。"
- sh: 'test -n "{{.PROTO_INPUT}}" || test -f /Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js'
msg: "未找到已安装 Cursor 的扩展 bundle;请传入 PROTO_INPUT=/path/to/cursor-always-local/dist/main.js。"
cmds:
- chmod +x ./proto/extract_extensions_proto.sh
- '{{if .PROTO_INPUT}}./proto/extract_extensions_proto.sh "{{.PROTO_INPUT}}"{{else}}./proto/extract_extensions_proto.sh{{end}}'
sync:proto:
summary: 从 Cursor 扩展快照同步 proto 并重新生成 Go 代码
dir: '{{.ROOT_DIR}}'
deps:
- task: extract:proto
cmds:
- cp ./proto/from_extensions/agent_v1.proto ./proto/agent_v1.proto
- cp ./proto/from_extensions/aiserver_v1.proto ./proto/aiserver_v1.proto
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/agent/v1;agentv1";|option go_package = "cursor/gen/agentv1;agentv1";|' ./proto/agent_v1.proto
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/aiserver/v1;aiserverv1";|option go_package = "cursor/gen/aiserverv1;aiserverv1";|' ./proto/aiserver_v1.proto
- ./proto/check_proto_sync.sh
- rm -rf ./gen/agentv1 ./gen/aiserverv1
- task: generate:proto
check:proto:
summary: 检查根 proto 与扩展提取快照是否一致
dir: '{{.ROOT_DIR}}'
cmds:
- ./proto/check_proto_sync.sh
generate:proto:
summary: 生成 proto Go/Connect 代码
dir: '{{.ROOT_DIR}}'
sources:
- proto/*.proto
generates:
- gen/agentv1/*.go
- gen/aiserverv1/*.go
- gen/aiserverv1/aiserverv1connect/*.go
preconditions:
- sh: protoc --version
msg: "未检测到 protoc,请先安装 Protocol Buffers 编译器。"
- sh: protoc-gen-go --version >/dev/null 2>&1 || which protoc-gen-go
msg: "未检测到 protoc-gen-go,请先安装。"
- sh: protoc-gen-connect-go --version >/dev/null 2>&1 || which protoc-gen-connect-go
msg: "未检测到 protoc-gen-connect-go,请先安装。"
cmds:
- rm -rf ./gen/agentv1 ./gen/aiserverv1
- protoc -I ./proto --go_out=. --go_opt=module=cursor --connect-go_out=. --connect-go_opt=module=cursor ./proto/agent_v1.proto ./proto/aiserver_v1.proto
- gofmt -w $(find ./gen -name '*.go' -type f)
go:mod:tidy:
summary: 整理 Go 依赖
internal: true
dir: '{{.ROOT_DIR}}'
cmds:
- go mod tidy
install:frontend:deps:
summary: 安装前端依赖(Yarn
dir: '{{.ROOT_DIR}}/frontend'
sources:
- package.json
- yarn.lock
generates:
- node_modules
preconditions:
- sh: yarn --version
msg: "未检测到 Yarn,请先安装 Yarn。"
cmds:
- yarn install --frozen-lockfile
generate:bindings:
label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
summary: 生成前端绑定
dir: '{{.ROOT_DIR}}'
deps:
- task: generate:proto
- task: go:mod:tidy
sources:
- "**/*.[jt]s"
- exclude: frontend/**/*
- frontend/bindings/**/*
- "**/*.go"
- go.mod
- go.sum
generates:
- frontend/bindings/**/*
cmds:
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true
build:frontend:
label: build:frontend (DEV={{.DEV}} SCAN={{.SCAN}})
summary: 构建前端资源
dir: '{{.ROOT_DIR}}/frontend'
sources:
- "**/*"
generates:
- dist/**/*
deps:
- task: install:frontend:deps
- task: generate:bindings
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
cmds:
- yarn run {{.BUILD_COMMAND}}{{if eq .SCAN "true"}} --scan{{end}}
env:
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
vars:
BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
SCAN: '{{.SCAN | default "false"}}'
dev:frontend:
summary: 启动前端开发服务
dir: '{{.ROOT_DIR}}/frontend'
deps:
- task: install:frontend:deps
cmds:
- yarn run dev --port {{.VITE_PORT}} --strictPort
generate:icons:
summary: 仅使用 appicon.png 生成 Windows 与 macOS 图标
dir: '{{.BUILD_DIR}}'
sources:
- "appicon.png"
generates:
- "darwin/icons.icns"
- "windows/icon.ico"
cmds:
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico
update:build-assets:
summary: 根据配置更新构建资产
dir: '{{.BUILD_DIR}}'
cmds:
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
- rm -rf ios
setup:docker:
summary: 构建 Linux 交叉编译 Docker 镜像
dir: '{{.ROOT_DIR}}'
preconditions:
- sh: docker info >/dev/null 2>&1
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
- sh: test -f ./build/docker/Dockerfile.cross
msg: "未找到 build/docker/Dockerfile.cross。"
cmds:
- docker build --platform linux/amd64{{if .BASE_IMAGE}} --build-arg BASE_IMAGE="{{.BASE_IMAGE}}"{{end}}{{if .GO_TARBALL_URL}} --build-arg GO_TARBALL_URL="{{.GO_TARBALL_URL}}"{{end}} -t cursor-linux-amd64-cross -f ./build/docker/Dockerfile.cross ./build/docker
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

-43
View File
@@ -1,43 +0,0 @@
version: "3"
# 构建元信息(修改后可执行:wails3 task common:update:build-assets
info:
companyName: "Cursor助手"
productName: "Cursor助手"
productIdentifier: "com.cursor.wuxianxubei"
description: "Cursor助手"
copyright: "© 2026, Cursor助手"
comments: "Cursor助手"
version: "0.0.48"
dev_mode:
root_path: .
log_level: warn
debounce: 1000
ignore:
dir:
- .git
- node_modules
- frontend
- bin
file:
- .DS_Store
- .gitignore
- .gitkeep
watched_extension:
- "*.go"
- "*.js"
- "*.ts"
git_ignore: true
executes:
- cmd: wails3 build DEV=true
type: blocking
- cmd: wails3 task common:dev:frontend
type: background
- cmd: wails3 task run
type: primary
fileAssociations:
other:
- name: 其他数据
-37
View File
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>Cursor助手</string>
<key>CFBundleGetInfoString</key>
<string>Cursor助手</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>CFBundleIconName</key>
<string>appicon</string>
<key>CFBundleIdentifier</key>
<string>com.cursor.wuxianxubei</string>
<key>CFBundleName</key>
<string>Cursor助手</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.48</string>
<key>CFBundleVersion</key>
<string>0.0.48</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>LSUIElement</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026, Cursor助手</string>
</dict>
</plist>
-32
View File
@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>Cursor助手</string>
<key>CFBundleGetInfoString</key>
<string>Cursor助手</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>CFBundleIconName</key>
<string>appicon</string>
<key>CFBundleIdentifier</key>
<string>com.cursor.wuxianxubei</string>
<key>CFBundleName</key>
<string>Cursor助手</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.48</string>
<key>CFBundleVersion</key>
<string>0.0.48</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026, Cursor助手</string>
</dict>
</plist>
-273
View File
@@ -1,273 +0,0 @@
version: "3"
includes:
common: ../Taskfile.yml
tasks:
build:
summary: 构建 macOS 程序
internal: true
cmds:
- task: build:native
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
build:native:
summary: 使用本机构建 macOS 程序
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: common:generate:icons
cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.BINARY_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: darwin
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
CGO_CFLAGS: "-mmacosx-version-min=10.15"
CGO_LDFLAGS: "-mmacosx-version-min=10.15"
MACOSX_DEPLOYMENT_TARGET: "10.15"
build:universal:
summary: 构建 macOS 通用包(amd64 + arm64
internal: true
deps:
- task: build
vars:
ARCH: amd64
BINARY_NAME: "{{.APP_NAME}}-amd64"
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64"
- task: build
vars:
ARCH: arm64
BINARY_NAME: "{{.APP_NAME}}-arm64"
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
cmds:
- lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
- rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
package:
summary: 打包 macOS .app(可通过 APP_BUNDLE 指定输出包名)
internal: true
cmds:
- task: build
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: create:app:bundle
vars:
APP_BUNDLE:
ref: .APP_BUNDLE
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
package:dmg:
summary: 打包 macOS DMG(可通过 APP_BUNDLE/DMG_NAME 指定输出文件名)
internal: true
cmds:
- task: package
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
APP_BUNDLE:
ref: .APP_BUNDLE
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: create:dmg
vars:
ARCH:
ref: .ARCH
APP_BUNDLE:
ref: .APP_BUNDLE
DMG_NAME:
ref: .DMG_NAME
- task: cleanup:package:artifacts
vars:
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
APP_BUNDLE:
ref: .APP_BUNDLE
package:archive:
summary: 打包 macOS updater 归档(.tar.gz
internal: true
cmds:
- task: package
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
APP_BUNDLE:
ref: .APP_BUNDLE
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: create:archive
vars:
APP_BUNDLE:
ref: .APP_BUNDLE
ARCHIVE_PATH:
ref: .ARCHIVE_PATH
- task: cleanup:package:artifacts
vars:
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
APP_BUNDLE:
ref: .APP_BUNDLE
package:universal:
summary: 打包 macOS 通用 .app
internal: true
cmds:
- task: build:universal
- task: create:app:bundle
create:app:bundle:
summary: 生成 .app 包
internal: true
vars:
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
EXECUTABLE_NAME: '{{.EXECUTABLE_NAME | default .APP_NAME}}'
cmds:
- rm -rf "{{.BIN_DIR}}/{{.APP_BUNDLE}}"
- mkdir -p "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/MacOS"
- mkdir -p "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/Resources"
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/Resources"
- cp "{{.BINARY_PATH}}" "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/MacOS/{{.EXECUTABLE_NAME}}"
- cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents"
- task: codesign:adhoc
vars:
APP_BUNDLE:
ref: .APP_BUNDLE
codesign:adhoc:
summary: 使用临时签名(开发测试)
internal: true
vars:
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
cmds:
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_BUNDLE}}"
create:dmg:
summary: 生成 macOS DMG 包(优先使用 create-dmg,不存在时回退 hdiutil
internal: true
vars:
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
DMG_NAME: '{{.DMG_NAME | default (printf "%s.dmg" .APP_NAME)}}'
STAGING_DIR: '{{.BIN_DIR}}/.dmg-{{.ARCH | default ARCH}}'
cmds:
- rm -rf "{{.STAGING_DIR}}"
- mkdir -p "{{.STAGING_DIR}}"
- cp -R "{{.BIN_DIR}}/{{.APP_BUNDLE}}" "{{.STAGING_DIR}}/{{.APP_NAME}}.app"
- |
if [ -f "build/dmg-extras/提示损坏?点我.command" ]; then
cp "build/dmg-extras/提示损坏?点我.command" "{{.STAGING_DIR}}/提示损坏?点我.command"
chmod +x "{{.STAGING_DIR}}/提示损坏?点我.command"
fi
- |
if command -v create-dmg >/dev/null 2>&1; then
rm -f "{{.BIN_DIR}}/{{.DMG_NAME}}"
create-dmg --volname "{{.APP_NAME}}" --window-size 600 400 --icon-size 100 --app-drop-link 450 200 "{{.BIN_DIR}}/{{.DMG_NAME}}" "{{.STAGING_DIR}}"
else
hdiutil create -volname "{{.APP_NAME}}" -srcfolder "{{.STAGING_DIR}}" -ov -format UDZO "{{.BIN_DIR}}/{{.DMG_NAME}}"
fi
- rm -rf "{{.STAGING_DIR}}"
create:archive:
summary: 生成 macOS updater tar.gz 归档
internal: true
vars:
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
ARCHIVE_PATH: '{{.ARCHIVE_PATH | default (printf "%s/%s.tar.gz" .BIN_DIR .APP_NAME)}}'
cmds:
- rm -f "{{.ARCHIVE_PATH}}"
- env LC_ALL=C tar -C "{{.BIN_DIR}}" -czf "{{.ARCHIVE_PATH}}" "{{.APP_BUNDLE}}"
cleanup:package:artifacts:
summary: 清理打包中间产物
internal: true
vars:
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
cmds:
- rm -f "{{.BINARY_PATH}}"
- rm -rf "{{.BIN_DIR}}/{{.APP_BUNDLE}}"
run:
summary: 运行本地开发包
vars:
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
EXECUTABLE_NAME: '{{.EXECUTABLE_NAME | default .APP_NAME}}'
cmds:
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
- cp "{{.BIN_DIR}}/{{.BINARY_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.EXECUTABLE_NAME}}"
- cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist"
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
- '{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.EXECUTABLE_NAME}}'
Binary file not shown.
@@ -1,13 +0,0 @@
#!/bin/bash
APP_PATH="/Applications/Cursor助手.app"
if [ ! -d "$APP_PATH" ]; then
echo "请先把 Cursor助手 拖到“应用程序”目录后再运行本脚本。"
read -r -p "按回车退出..."
exit 1
fi
echo "正在移除隔离属性: $APP_PATH"
xattr -cr "$APP_PATH"
echo "处理完成,现在可以正常打开 Cursor助手。"
read -r -p "按回车退出..."
-26
View File
@@ -1,26 +0,0 @@
# Docker image for building this app's Linux amd64 binary from macOS/Linux hosts.
ARG BASE_IMAGE=debian:trixie
FROM ${BASE_IMAGE}
ARG GO_TARBALL_URL=https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl git nodejs yarnpkg pkg-config gcc libc6-dev \
libgtk-3-dev libwebkit2gtk-4.1-dev \
&& curl -fsSL "${GO_TARBALL_URL}" -o /tmp/go.tar.gz \
&& rm -rf /usr/local/go \
&& tar -C /usr/local -xzf /tmp/go.tar.gz \
&& ln -sf /usr/local/go/bin/go /usr/local/bin/go \
&& ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt \
&& ln -sf /usr/bin/yarnpkg /usr/local/bin/yarn \
&& rm -f /tmp/go.tar.gz \
&& rm -rf /var/lib/apt/lists/*
COPY build-linux.sh /usr/local/bin/build-linux.sh
RUN chmod +x /usr/local/bin/build-linux.sh
WORKDIR /app
ENTRYPOINT ["/usr/local/bin/build-linux.sh"]
-81
View File
@@ -1,81 +0,0 @@
#!/bin/sh
set -eu
log() {
printf '[linux-build] %s\n' "$*"
}
export GOOS=linux
export GOARCH=amd64
export CGO_ENABLED=1
export CGO_CFLAGS="-w"
APP=${APP_NAME:-$(basename "$(pwd)")}
BIN_PATH="bin/${APP}-linux-amd64"
LOG_MODE=${GO_BUILD_LOG_MODE:-quiet}
case "$LOG_MODE" in
quiet|heartbeat)
;;
verbose|trace)
log "workspace: $(pwd)"
log "target: ${APP} (${GOOS}/${GOARCH})"
log "go version: $(go version)"
log "log mode: ${LOG_MODE}"
;;
*)
log "unknown GO_BUILD_LOG_MODE=${LOG_MODE}, falling back to quiet"
LOG_MODE="quiet"
;;
esac
if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then
log "frontend/dist missing, building frontend assets in container"
(
cd frontend
yarn install --frozen-lockfile
yarn run build
)
fi
mkdir -p bin
LDFLAGS="-s -w"
if [ -n "${EXTRA_LDFLAGS:-}" ]; then
LDFLAGS="$LDFLAGS $EXTRA_LDFLAGS"
fi
TAGS="production"
if [ -n "${EXTRA_TAGS:-}" ]; then
TAGS="${TAGS},${EXTRA_TAGS}"
fi
set -- go build
case "$LOG_MODE" in
quiet)
;;
heartbeat)
;;
verbose)
set -- "$@" -v
;;
trace)
set -- "$@" -x -v
;;
esac
set -- "$@" -tags "$TAGS" -trimpath -buildvcs=false -ldflags "$LDFLAGS" -o "$BIN_PATH" .
if "$@"; then
:
else
status="$?"
log "go build failed with exit code ${status}"
exit "$status"
fi
case "$LOG_MODE" in
verbose|trace)
log "built: ${BIN_PATH}"
;;
esac
@@ -1,9 +0,0 @@
//@ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
Object.freeze($Create.Events);
@@ -1,2 +0,0 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
-192
View File
@@ -1,192 +0,0 @@
version: "3"
includes:
common: ../Taskfile.yml
vars:
CROSS_IMAGE: cursor-linux-amd64-cross
PROJECT_ROOT:
sh: 'cd "{{.TASKFILE_DIR}}/../.." && pwd'
tasks:
build:
summary: 构建 Linux 程序
internal: true
vars:
TARGET_ARCH: '{{.ARCH | default ARCH}}'
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
HAS_CC:
sh: '(command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1) && echo "true" || echo "false"'
preconditions:
- sh: '[ "{{.TARGET_ARCH}}" = "amd64" ]'
msg: "当前仅支持 linux/amd64 构建。"
cmds:
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
build:native:
summary: 使用本机构建 Linux 程序
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
preconditions:
- sh: '[ "{{OS}}" = "linux" ]'
msg: "Linux 原生构建仅支持在 Linux 主机上执行。"
- sh: pkg-config --version >/dev/null 2>&1
msg: "未检测到 pkg-config,请先安装。"
- sh: 'command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1'
msg: "未检测到 gcc 或 clang,请先安装 C 编译器。"
- sh: pkg-config --exists gtk+-3.0 webkit2gtk-4.1 gdk-3.0 gio-unix-2.0
msg: "缺少 Linux GUI 构建依赖,请安装 gtk+-3.0、webkit2gtk-4.1、gdk-3.0 和 gio-unix-2.0 开发包。"
cmds:
- go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.BINARY_NAME}}'
OUTPUT: '{{.OUTPUT | default .DEFAULT_OUTPUT}}'
env:
GOOS: linux
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
build:docker:
summary: 使用 Docker 构建 Linux 程序
internal: true
dir: '{{.PROJECT_ROOT}}'
deps:
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
preconditions:
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "linux" ]'
msg: "Docker Linux 构建当前仅支持在 macOS 或 Linux 主机上执行。"
- sh: docker info >/dev/null 2>&1
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
- sh: docker image inspect {{.CROSS_IMAGE}} >/dev/null 2>&1
msg: "未找到 Docker 镜像 '{{.CROSS_IMAGE}}',请先执行 task setup:docker。"
cmds:
- docker run --rm --platform "{{.DOCKER_PLATFORM}}" --entrypoint /bin/sh -v "{{.PROJECT_ROOT}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e GOPATH=/go -e GOMODCACHE=/go/pkg/mod -e APP_NAME="{{.APP_NAME}}" -e EXTRA_LDFLAGS="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}" -e GO_BUILD_LOG_MODE="{{.GO_BUILD_LOG_MODE}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} "{{.CROSS_IMAGE}}" /app/build/docker/build-linux.sh
- docker run --rm --platform "{{.DOCKER_PLATFORM}}" --entrypoint chown -v "{{.PROJECT_ROOT}}:/app" "{{.CROSS_IMAGE}}" -R $(id -u):$(id -g) /app/bin
- mkdir -p "{{.BIN_DIR}}"
- mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
vars:
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
DOCKER_PLATFORM: 'linux/{{.DOCKER_ARCH}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
GO_BUILD_LOG_MODE: '{{.GO_BUILD_LOG_MODE | default "quiet"}}'
OUTPUT: '{{.OUTPUT | default .DEFAULT_OUTPUT}}'
GO_CACHE_MOUNT:
sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
REPLACE_MOUNTS:
sh: |
grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
if [ "${path#/}" = "$path" ]; then
path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
fi
if [ -d "$path" ]; then
echo "-v $path:$path:ro"
fi
done | tr '\n' ' '
package:archive:
summary: 打包 Linux updater 归档(.tar.gz
internal: true
cmds:
- task: build
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: create:archive
vars:
OUTPUT:
ref: .OUTPUT
ARCHIVE_BINARY_NAME:
ref: .ARCHIVE_BINARY_NAME
ARCHIVE_PATH:
ref: .ARCHIVE_PATH
ARCH:
ref: .ARCH
- task: cleanup:package:artifacts
vars:
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
create:archive:
summary: 生成 Linux updater tar.gz 归档
internal: true
vars:
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
ARCHIVE_BINARY_NAME: '{{.ARCHIVE_BINARY_NAME | default .APP_NAME}}'
ARCHIVE_PATH: '{{.ARCHIVE_PATH | default (printf "%s/%s.tar.gz" .BIN_DIR .APP_NAME)}}'
STAGING_DIR: '{{.BIN_DIR}}/.linux-archive-{{.ARCH | default ARCH}}'
cmds:
- rm -rf "{{.STAGING_DIR}}"
- mkdir -p "{{.STAGING_DIR}}"
- cp "{{.BINARY_PATH}}" "{{.STAGING_DIR}}/{{.ARCHIVE_BINARY_NAME}}"
- chmod +x "{{.STAGING_DIR}}/{{.ARCHIVE_BINARY_NAME}}"
- rm -f "{{.ARCHIVE_PATH}}"
- env LC_ALL=C tar -C "{{.STAGING_DIR}}" -czf "{{.ARCHIVE_PATH}}" "{{.ARCHIVE_BINARY_NAME}}"
- rm -rf "{{.STAGING_DIR}}"
cleanup:package:artifacts:
summary: 清理 Linux 打包中间产物
internal: true
vars:
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
cmds:
- rm -f "{{.BINARY_PATH}}"
run:
summary: 运行 Linux 可执行文件
vars:
OUTPUT: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .APP_NAME)}}'
preconditions:
- sh: '[ "{{OS}}" = "linux" ]'
msg: "Linux 运行任务仅支持在 Linux 主机上执行。"
cmds:
- '{{.OUTPUT}}'
-13
View File
@@ -1,13 +0,0 @@
[Desktop Entry]
Version=1.0
Name=Cursor助手
Comment=Cursor助手
# The Exec line includes %u to pass the URL to the application
Exec=/usr/local/bin/Cursor助手 %u
Terminal=false
Type=Application
Icon=Cursor助手
Categories=Utility;
StartupWMClass=Cursor助手
-80
View File
@@ -1,80 +0,0 @@
# Feel free to remove those if you don't want/need to use them.
# Make sure to check the documentation at https://nfpm.goreleaser.com
#
# The lines below are called `modelines`. See `:help modeline`
name: "Cursor助手"
arch: ${GOARCH}
platform: "linux"
version: "0.0.48"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
description: "Cursor助手"
vendor: "Cursor助手"
homepage: "https://wails.io"
license: "MIT"
release: "1"
contents:
- src: "./bin/Cursor助手"
dst: "/usr/local/bin/Cursor助手"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/Cursor助手.png"
- src: "./build/linux/Cursor助手.desktop"
dst: "/usr/share/applications/Cursor助手.desktop"
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
depends:
- libgtk-4-1
- libwebkitgtk-6.0-4
# Distribution-specific overrides for different package formats
overrides:
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
rpm:
depends:
- gtk4
- webkitgtk6.0
# Arch Linux packages
archlinux:
depends:
- gtk4
- webkitgtk-6.0
# scripts section to ensure desktop database is updated after install
scripts:
postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
# You can also add preremove, postremove if needed
# preremove: "./build/linux/nfpm/scripts/preremove.sh"
# postremove: "./build/linux/nfpm/scripts/postremove.sh"
# If you build your app with -tags gtk3 (legacy WebKit2GTK 4.1 stack — supported through v3.0.x, removed in v3.1),
# replace the depends/overrides above with these:
#
# depends:
# - libgtk-3-0
# - libwebkit2gtk-4.1-0
# overrides:
# rpm:
# depends:
# - gtk3
# - webkit2gtk4.1
# archlinux:
# depends:
# - gtk3
# - webkit2gtk-4.1
#
# replaces:
# - foobar
# provides:
# - bar
# recommends:
# - whatever
# suggests:
# - something-else
# conflicts:
# - not-foo
# - not-bar
# changelog: "changelog.yaml"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

-156
View File
@@ -1,156 +0,0 @@
version: "3"
includes:
common: ../Taskfile.yml
tasks:
build:
summary: 构建 Windows 程序
internal: true
cmds:
- task: build:native
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
build:native:
summary: 使用 Go 原生交叉编译构建 Windows 程序
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: common:generate:icons
cmds:
- task: generate:syso
vars:
ARCH:
ref: .ARCH
- go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"
- cmd: powershell Remove-item *.syso
platforms: [windows]
- cmd: rm -f *.syso
platforms: [darwin, linux]
vars:
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
env:
GOOS: windows
CGO_ENABLED: 0
GOARCH: '{{.ARCH | default ARCH}}'
package:
summary: 打包 Windows 安装程序
internal: true
cmds:
- task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}'
vars:
FORMAT: '{{.FORMAT | default "nsis"}}'
generate:syso:
summary: 生成 Windows 图标与版本信息资源
internal: true
dir: build
cmds:
- wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
vars:
ARCH: '{{.ARCH | default ARCH}}'
create:nsis:installer:
summary: 生成 NSIS 安装包
internal: true
dir: build/windows/nsis
deps:
- task: build
vars:
SCAN:
ref: .SCAN
cmds:
- wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis"
- |
{{if eq OS "windows"}}
makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
{{else}}
makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi
{{end}}
vars:
ARCH: '{{.ARCH | default ARCH}}'
ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
create:msix:package:
summary: 生成 MSIX 安装包
internal: true
deps:
- task: build
vars:
SCAN:
ref: .SCAN
cmds:
- |-
wails3 tool msix \
--config "{{.ROOT_DIR}}/wails.json" \
--name "{{.APP_NAME}}" \
--executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \
--arch "{{.ARCH}}" \
--out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \
{{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \
{{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \
{{if .USE_MSIX_TOOL}}--use-msix-tool{{else}}--use-makeappx{{end}}
vars:
ARCH: '{{.ARCH | default ARCH}}'
CERT_PATH: '{{.CERT_PATH | default ""}}'
PUBLISHER: '{{.PUBLISHER | default ""}}'
USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}'
create:zip:
summary: 生成 Windows ZIP 包(包含 exe 与 certs
internal: true
vars:
ARCH: '{{.ARCH | default ARCH}}'
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
ZIP_NAME: '{{.ZIP_NAME | default (printf "%s-windows-%s.zip" .APP_NAME .ARCH)}}'
STAGING_DIR: '{{.BIN_DIR}}/.zip-{{.ARCH}}'
cmds:
- task: build
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- rm -rf "{{.STAGING_DIR}}"
- mkdir -p "{{.STAGING_DIR}}"
- cp "{{.OUTPUT}}" "{{.STAGING_DIR}}/"
- (cd "{{.STAGING_DIR}}" && zip -qry "../{{.ZIP_NAME}}" .)
- rm -rf "{{.STAGING_DIR}}"
- rm -f "{{.OUTPUT}}"
run:
summary: 运行 Windows 可执行文件(仅在 Windows 可用)
vars:
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
cmds:
- '{{.OUTPUT}}'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

-15
View File
@@ -1,15 +0,0 @@
{
"fixed": {
"file_version": "0.0.48"
},
"info": {
"0000": {
"ProductVersion": "0.0.48",
"CompanyName": "Cursor助手",
"FileDescription": "Cursor助手",
"LegalCopyright": "© 2026, Cursor助手",
"ProductName": "Cursor助手",
"Comments": "Cursor助手"
}
}
}
-55
View File
@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
IgnorableNamespaces="uap3">
<Identity
Name="com.cursor.wuxianxubei"
Publisher="CN=Cursor助手"
Version="0.1.0.0"
ProcessorArchitecture="x64" />
<Properties>
<DisplayName>Cursor助手</DisplayName>
<PublisherDisplayName>Cursor助手</PublisherDisplayName>
<Description>Cursor助手</Description>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="zh-cn" />
</Resources>
<Applications>
<Application Id="com.cursor.wuxianxubei" Executable="Cursor助手" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="Cursor助手"
Description="本地 MITM 代理客户端,支持中转转发与托盘控制"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
<Extensions>
<desktop:Extension Category="windows.fullTrustProcess" Executable="Cursor助手" />
</Extensions>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>
-54
View File
@@ -1,54 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<MsixPackagingToolTemplate
xmlns="http://schemas.microsoft.com/msix/packaging/msixpackagingtool/template/2022">
<Settings
AllowTelemetry="false"
ApplyACLsToPackageFiles="true"
GenerateCommandLineFile="true"
AllowPromptForPassword="false">
</Settings>
<Installer
Path="Cursor助手"
Arguments=""
InstallLocation="C:\Program Files\Cursor助手\Cursor助手">
</Installer>
<PackageInformation
PackageName="Cursor助手"
PackageDisplayName="Cursor助手"
PublisherName="CN=Cursor助手"
PublisherDisplayName="Cursor助手"
Version="0.1.0.0"
PackageDescription="Cursor助手">
<Capabilities>
<Capability Name="runFullTrust" />
</Capabilities>
<Applications>
<Application
Id="com.cursor.wuxianxubei"
Description="Cursor助手"
DisplayName="Cursor助手"
ExecutableName="Cursor助手"
EntryPoint="Windows.FullTrustApplication">
</Application>
</Applications>
<Resources>
<Resource Language="en-us" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Properties>
<Framework>false</Framework>
<DisplayName>Cursor助手</DisplayName>
<PublisherDisplayName>Cursor助手</PublisherDisplayName>
<Description>Cursor助手</Description>
<Logo>Assets\AppIcon.png</Logo>
</Properties>
</PackageInformation>
<SaveLocation PackagePath="Cursor助手.msix" />
<PackageIntegrity>
<CertificatePath></CertificatePath>
</PackageIntegrity>
</MsixPackagingToolTemplate>
Binary file not shown.
-116
View File
@@ -1,116 +0,0 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the wails_tools.nsh file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "my-project" # Default "cursorclient"
## !define INFO_COMPANYNAME "My Company" # Default "My Company"
## !define INFO_PRODUCTNAME "My Product Name" # Default "My Product"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.1.0"
## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© 2026, My Company"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
SetOutPath $INSTDIR
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro wails.deleteUninstaller
SectionEnd
-261
View File
@@ -1,261 +0,0 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "Cursor助手"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "Cursor助手"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "Cursor助手"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.0.48"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2026, Cursor助手"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef WAILS_INSTALL_SCOPE
!define WAILS_INSTALL_SCOPE "machine"
!endif
!ifndef REQUEST_EXECUTION_LEVEL
!if "${WAILS_INSTALL_SCOPE}" == "user"
!define REQUEST_EXECUTION_LEVEL "user"
!else
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
!if "${WAILS_INSTALL_SCOPE}" == "user"
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0"
!else
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!endif
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
!if "${WAILS_INSTALL_SCOPE}" == "user"
DeleteRegKey HKCU "${UNINST_KEY}"
!else
DeleteRegKey HKLM "${UNINST_KEY}"
!endif
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
!macroend
!macro wails.unassociateFiles
; Delete app associations
!macroend
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
!macroend
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
!macroend
!macro wails.associateCustomProtocols
; Create custom protocols associations
!macroend
!macro wails.unassociateCustomProtocols
; Delete app custom protocol associations
!macroend
-22
View File
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.48" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
-52
View File
@@ -1,52 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
proxydebugger "cursor/cursor-proxy-debugger"
"github.com/pkg/browser"
)
func main() {
config := proxydebugger.Config{}
openBrowser := true
flag.StringVar(&config.ProxyAddr, "proxy-addr", "127.0.0.1:9090", "HTTP/HTTPS 代理监听地址")
flag.StringVar(&config.UIAddr, "ui-addr", "127.0.0.1:9091", "调试界面监听地址")
flag.StringVar(&config.TargetHost, "target-host", "api2.cursor.sh", "需要解密和抓取的目标主机")
flag.IntVar(&config.MaxExchanges, "max-exchanges", 200, "内存中保留的最大请求数")
flag.BoolVar(&openBrowser, "open", true, "启动后打开浏览器")
flag.Parse()
server, err := proxydebugger.New(config)
if err != nil {
log.Fatal(err)
}
if err := server.Start(); err != nil {
log.Fatal(err)
}
fmt.Printf("Cursor 协议调试代理已启动\n")
fmt.Printf("代理地址: http://%s\n", server.ProxyAddr())
fmt.Printf("调试界面: %s\n", server.UIURL())
if openBrowser {
_ = browser.OpenURL(server.UIURL())
}
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
<-signals
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Close(shutdownContext); err != nil {
log.Printf("关闭调试代理失败:%v", err)
}
}
+48
View File
@@ -0,0 +1,48 @@
# cursor-proto
`cursor-proto` 从已安装 Cursor 的 JavaScript bundle 中提取 Protobuf 定义。
## 目录
- `extractor/`Go 提取器。
- `scripts/extract.sh`:扫描 Cursor 安装目录并安全更新输出。
- `proto/`:提取结果,是项目内唯一的 Proto 输出目录;由脚本重新生成,不提交到 Git。
- `scripts/generate.sh`:根据提取结果生成可导入的 Go 消息包。
- `gen/`:供其他 Go module 使用的 Go 消息包;由脚本重新生成,不提交到 Git。
## 使用
默认从 `/Applications/Cursor.app` 提取:
```bash
./scripts/extract.sh
```
也可以指定 Cursor 应用、bundle 文件和输出目录:
```bash
./scripts/extract.sh /path/to/Cursor.app
./scripts/extract.sh /path/to/workbench.desktop.main.js /path/to/output
```
直接运行 Go 提取器时,可重复传入多个 bundle:
```bash
go run ./extractor \
-input /path/to/workbench.desktop.main.js \
-input /path/to/extensionHostProcess.js \
-output ./proto \
-strict
```
提取完成后重新生成 Go 消息包:
```bash
./scripts/generate.sh
```
## 验证
```bash
go test ./...
```
+192
View File
@@ -0,0 +1,192 @@
// extractor_test.go 验证压缩 bundle 的字段、别名、服务和合并提取行为。
package main
import "testing"
// TestParseFieldObjectSupportsShorthandType 验证字段类型简写可以解析。
func TestParseFieldObjectSupportsShorthandType(t *testing.T) {
field, err := parseFieldObject(`{no:4,name:"file_not_found",kind:"message",T,oneof:"result"}`)
if err != nil {
t.Fatalf("parse shorthand T: %v", err)
}
if field.T != "T" {
t.Fatalf("parsed shorthand T as %#v, want T", field.T)
}
}
// TestWebpackExportAliasResolvesServiceMessageType 验证 Webpack 导出别名可解析服务消息。
func TestWebpackExportAliasResolvesServiceMessageType(t *testing.T) {
const bundle = `
1:(e,t,n)=>{
n.d(t,{KS:()=>T,_B:()=>r});
var r;
class T {}
T.typeName="agent.v1.AgentClientMessage";
n.proto3.util.setEnumType(r,"agent.v1.DiagnosticSeverity",[]);
},
2:(e,t,n)=>{
var r=n(1);
const service={typeName:"agent.v1.AgentService",methods:{run:{name:"Run",I:r.KS,O:r.KS,kind:n.MethodKind.BiDiStreaming}}};
}`
moduleStarts := buildModuleStarts(bundle)
messages := []Message{{
TypeName: "agent.v1.AgentClientMessage",
VarName: "T",
InternalName: "T",
Package: "agent.v1",
Pos: 35,
ModuleStart: moduleStartForPos(moduleStarts, 35),
}}
enums := []Enum{{
TypeName: "agent.v1.DiagnosticSeverity",
VarName: "r",
Package: "agent.v1",
Pos: 100,
ModuleStart: moduleStartForPos(moduleStarts, 100),
}}
resolver := newTypeResolver(messages, enums, buildAliasIndex(bundle, moduleStarts), buildWebpackExportAliasIndex(bundle, moduleStarts))
resolver.moduleImports = buildModuleImportIndex(bundle, moduleStarts)
typeName, ok := resolver.ResolveTypeName("r.KS", len(bundle)-1, moduleStartForPos(moduleStarts, len(bundle)-1), "agent.v1", "message")
if !ok {
t.Fatal("expected webpack export alias to resolve")
}
if typeName != "agent.v1.AgentClientMessage" {
t.Fatalf("resolved r.KS to %q, want agent.v1.AgentClientMessage", typeName)
}
}
// TestResolverPrefersExpectedKindOverCurrentPackage 验证类型类别优先于当前包候选。
func TestResolverPrefersExpectedKindOverCurrentPackage(t *testing.T) {
resolver := &TypeResolver{bySymbol: map[string][]symbolDef{
"nt": {
{TypeName: "git_forge.v1.GetTagResponse", Kind: "message", Pos: 10, ModuleStart: 1},
{TypeName: "origin.v1.TeamGroupKind", Kind: "enum", Pos: 20, ModuleStart: 1},
},
}}
typeName, ok := resolver.ResolveTypeName("nt", 30, 1, "origin.v1", "message")
if !ok {
t.Fatal("expected cross-package message type to resolve")
}
if typeName != "git_forge.v1.GetTagResponse" {
t.Fatalf("resolved nt to %q, want git_forge.v1.GetTagResponse", typeName)
}
}
// TestModernFactorySyntaxExtractsInAppAdServiceTypes 验证现代工厂语法提取完整服务类型。
func TestModernFactorySyntaxExtractsInAppAdServiceTypes(t *testing.T) {
const bundle = `
42:(e,t,n)=>{
var HasSeenAdRequest=n.makeMessageType("aiserver.v1.HasSeenAdRequest",()=>[{no:1,name:"ad_id",kind:"scalar",T:9}]),
HasSeenAdResponse=n.makeMessageType("aiserver.v1.HasSeenAdResponse",()=>[{no:1,name:"has_seen",kind:"scalar",T:8}]),
MarkAdAsSeenResponse=n.makeMessageType("aiserver.v1.MarkAdAsSeenResponse",[]),
Placement=n.makeEnum("aiserver.v1.InAppAdPlacement",[{no:0,name:"IN_APP_AD_PLACEMENT_UNSPECIFIED",localName:"UNSPECIFIED"}]),
InAppAdService={typeName:"aiserver.v1.InAppAdService",methods:{hasSeenAd:{name:"HasSeenAd",I:HasSeenAdRequest,O:HasSeenAdResponse,kind:n.MethodKind.Unary},markAdAsSeen:{name:"MarkAdAsSeen",I:HasSeenAdRequest,O:MarkAdAsSeenResponse,kind:n.MethodKind.Unary}}};
}`
moduleStarts := buildModuleStarts(bundle)
messages := extractMessages(bundle, moduleStarts)
enums := extractEnums(bundle, moduleStarts)
services := extractServices(bundle, moduleStarts)
if len(messages) != 3 {
t.Fatalf("extracted %d messages, want 3", len(messages))
}
if len(messages[0].Fields) != 1 || messages[0].Fields[0].Name != "ad_id" {
t.Fatalf("unexpected request fields: %#v", messages[0].Fields)
}
if len(enums) != 1 || enums[0].TypeName != "aiserver.v1.InAppAdPlacement" {
t.Fatalf("unexpected enums: %#v", enums)
}
if len(services) != 1 || len(services[0].Methods) != 2 {
t.Fatalf("unexpected services: %#v", services)
}
resolver := newTypeResolver(messages, enums, buildAliasIndex(bundle, moduleStarts), buildWebpackExportAliasIndex(bundle, moduleStarts))
method := services[0].Methods[0]
input, inputOK := resolver.ResolveTypeName(method.InputType, services[0].Pos, services[0].ModuleStart, services[0].Package, "message")
output, outputOK := resolver.ResolveTypeName(method.OutputType, services[0].Pos, services[0].ModuleStart, services[0].Package, "message")
if !inputOK || input != "aiserver.v1.HasSeenAdRequest" {
t.Fatalf("resolved input to %q (ok=%v)", input, inputOK)
}
if !outputOK || output != "aiserver.v1.HasSeenAdResponse" {
t.Fatalf("resolved output to %q (ok=%v)", output, outputOK)
}
}
// TestAssignmentAliasResolvesStandardProtobufType 验证赋值别名解析标准协议类型。
func TestAssignmentAliasResolvesStandardProtobufType(t *testing.T) {
const bundle = `
1:(e,t,n)=>{
var Timestamp=class TimestampMessage extends Base{};
Timestamp.typeName="google.protobuf.Timestamp",Timestamp.fields=n.proto3.util.newFieldList(()=>[]),ua=Timestamp;
var Request=n.makeMessageType("aiserver.v1.Request",()=>[{no:1,name:"created_at",kind:"message",T:ua}]);
}`
moduleStarts := buildModuleStarts(bundle)
messages := extractMessages(bundle, moduleStarts)
resolver := newTypeResolver(messages, nil, buildAliasIndex(bundle, moduleStarts), nil)
typeName, ok := resolver.ResolveTypeName("ua", len(bundle)-1, moduleStartForPos(moduleStarts, len(bundle)-1), "aiserver.v1", "message")
if !ok || typeName != "google.protobuf.Timestamp" {
t.Fatalf("resolved ua to %q (ok=%v), want google.protobuf.Timestamp", typeName, ok)
}
}
// TestDeclarationCoverageReportsUnparsedTypesAndIgnoresGoogleTypes 验证覆盖率忽略标准类型并报告遗漏。
func TestDeclarationCoverageReportsUnparsedTypesAndIgnoresGoogleTypes(t *testing.T) {
const bundle = `
var Request=n.makeMessageType("aiserver.v1.Request",()=>[]);
var Missing=n.makeMessageType("aiserver.v1.Missing",()=>[]);
var Timestamp=n.makeMessageType("google.protobuf.Timestamp",()=>[]);
var Service={typeName:"aiserver.v1.TestService",methods:{}};
`
messages := []Message{{TypeName: "aiserver.v1.Request"}}
services := []Service{{TypeName: "aiserver.v1.TestService"}}
declared, extracted, missing := declarationCoverage(bundle, messages, nil, services)
if declared != 3 || extracted != 2 {
t.Fatalf("coverage=%d/%d, want 2/3", extracted, declared)
}
if len(missing) != 1 || missing[0] != "aiserver.v1.Missing" {
t.Fatalf("unexpected missing declarations: %#v", missing)
}
}
// TestExtractServicesSupportsAnonymousDescriptors 验证匿名服务描述符可以提取。
func TestExtractServicesSupportsAnonymousDescriptors(t *testing.T) {
const bundle = `services.push({typeName:"aiserver.v1.FileSyncService",methods:{sync:{name:"Sync",I:Request,O:Response,kind:n.MethodKind.Unary}}})`
services := extractServices(bundle, nil)
if len(services) != 1 || services[0].TypeName != "aiserver.v1.FileSyncService" {
t.Fatalf("unexpected services: %#v", services)
}
if len(services[0].Methods) != 1 || services[0].Methods[0].Name != "Sync" {
t.Fatalf("unexpected methods: %#v", services[0].Methods)
}
}
// TestMergeMessagesPrefersPrimaryBundleAndKeepsSupplementalTypes 验证合并优先主 bundle 并保留补充类型。
func TestMergeMessagesPrefersPrimaryBundleAndKeepsSupplementalTypes(t *testing.T) {
primary := Message{
TypeName: "aiserver.v1.Shared",
Fields: []Field{{No: 1, Name: "primary", Kind: "scalar", T: 9}},
}
supplemental := Message{
TypeName: "aiserver.v1.Shared",
Fields: []Field{{No: 1, Name: "supplemental", Kind: "scalar", T: 9}},
}
legacy := Message{TypeName: "aiserver.v1.LegacyOnly"}
merged := mergeMessagesByTypeName([]Message{primary, supplemental, legacy})
if len(merged) != 2 {
t.Fatalf("merged %d messages, want 2", len(merged))
}
if merged[0].Fields[0].Name != "primary" {
t.Fatalf("duplicate type did not preserve primary definition: %#v", merged[0])
}
if merged[1].TypeName != "aiserver.v1.LegacyOnly" {
t.Fatalf("supplemental-only type missing: %#v", merged)
}
}
+341
View File
@@ -0,0 +1,341 @@
// generator.go 计算跨包依赖并为各协议包准备完整声明集合。
package main
import (
"fmt"
"os"
)
// generateProtos 按协议包聚合声明并生成对应文件。
func generateProtos(messages []Message, enums []Enum, services []Service, resolver *TypeResolver, outputDir string) {
os.MkdirAll(outputDir, 0755)
// 按协议包聚合声明。
packages := make(map[string]struct {
messages []Message
enums []Enum
services []Service
})
for _, msg := range messages {
pkg := packages[msg.Package]
pkg.messages = append(pkg.messages, msg)
packages[msg.Package] = pkg
}
for _, enum := range enums {
pkg := packages[enum.Package]
pkg.enums = append(pkg.enums, enum)
packages[enum.Package] = pkg
}
for _, svc := range services {
pkg := packages[svc.Package]
pkg.services = append(pkg.services, svc)
packages[svc.Package] = pkg
}
// 建立跨包复制使用的全局类型索引。
allMessages := make(map[string]*Message)
allEnums := make(map[string]*Enum)
for pkgName, pkg := range packages {
if isGooglePkg(pkgName) {
continue
}
for i := range pkg.messages {
msg := &pkg.messages[i]
allMessages[msg.TypeName] = msg
}
for i := range pkg.enums {
enum := &pkg.enums[i]
allEnums[enum.TypeName] = enum
}
}
// 每轮生成前重置已复制类型索引。
copiedTypes = make(map[string]map[string]string)
for pkgName, pkg := range packages {
// Google 标准包直接使用官方协议文件。
if isGooglePkg(pkgName) {
fmt.Printf("跳过: %s (使用官方 proto 文件)\n", pkgName)
continue
}
// 把当前包引用的外部类型复制到本地。
augmentedPkg := copyAllExternalTypes(pkgName, pkg, resolver, allMessages, allEnums)
generateProtoFile(pkgName, augmentedPkg.messages, augmentedPkg.enums, pkg.services, resolver, outputDir)
}
}
// copyAllExternalTypes 递归复制当前包引用的全部外部类型。
func copyAllExternalTypes(pkgName string, pkg struct {
messages []Message
enums []Enum
services []Service
}, resolver *TypeResolver, allMessages map[string]*Message, allEnums map[string]*Enum) struct {
messages []Message
enums []Enum
services []Service
} {
if copiedTypes[pkgName] == nil {
copiedTypes[pkgName] = make(map[string]string)
}
// 建立当前包已有类型集合,并登记本地名称供字段解析使用。
localTypes := make(map[string]bool)
for _, msg := range pkg.messages {
localTypes[msg.ShortName] = true
// 空来源名表示该类型原本就在当前包。
if copiedTypes[pkgName][msg.ShortName] == "" {
copiedTypes[pkgName][msg.ShortName] = "local:" + msg.TypeName
}
}
for _, enum := range pkg.enums {
localTypes[enum.ShortName] = true
if copiedTypes[pkgName][enum.ShortName] == "" {
copiedTypes[pkgName][enum.ShortName] = "local:" + enum.TypeName
}
}
// 结果先保留当前包原始声明。
result := struct {
messages []Message
enums []Enum
services []Service
}{
messages: append([]Message{}, pkg.messages...),
enums: append([]Enum{}, pkg.enums...),
services: pkg.services,
}
totalCopied := 0
// 持续迭代,直到不再发现新的外部依赖。
for round := 1; ; round++ {
// 收集当前消息中的外部类型引用。
neededTypes := make(map[string]bool)
for _, msg := range result.messages {
preferredPkg, _ := parseTypeName(msg.TypeName)
for _, f := range msg.Fields {
collectFieldRefsSimple(f, pkgName, preferredPkg, msg.Pos, msg.ModuleStart, resolver, neededTypes, localTypes)
}
}
for _, svc := range result.services {
for _, m := range svc.Methods {
collectMethodRefsSimple(m.InputType, pkgName, svc.Pos, svc.ModuleStart, resolver, neededTypes, localTypes)
collectMethodRefsSimple(m.OutputType, pkgName, svc.Pos, svc.ModuleStart, resolver, neededTypes, localTypes)
}
}
// 复制本轮新增依赖类型。
copiedThisRound := 0
for typeName := range neededTypes {
refPkg, shortName := parseTypeName(typeName)
if refPkg == pkgName || isGooglePkg(refPkg) {
continue
}
// 已存在于本地时无需重复复制。
if localTypes[shortName] {
continue
}
// 复制消息声明。
if msg, ok := allMessages[typeName]; ok {
msgCopy := *msg
msgCopy.Package = pkgName
// 保留原始完整类型名,用于生成来源注释。
result.messages = append(result.messages, msgCopy)
copiedTypes[pkgName][shortName] = typeName // 保存原始完整类型名。
localTypes[shortName] = true
copiedThisRound++
fmt.Printf(" [%s] 轮%d 复制: %s\n", pkgName, round, typeName)
} else if enum, ok := allEnums[typeName]; ok {
// 复制枚举声明。
enumCopy := *enum
enumCopy.Package = pkgName
result.enums = append(result.enums, enumCopy)
copiedTypes[pkgName][shortName] = typeName
localTypes[shortName] = true
copiedThisRound++
fmt.Printf(" [%s] 轮%d 复制枚举: %s\n", pkgName, round, typeName)
} else {
// 未找到声明时仍登记本地引用,兼容提取结果缺少但 bundle 实际存在的类型。
copiedTypes[pkgName][shortName] = typeName
localTypes[shortName] = true
fmt.Printf(" [%s] 轮%d 警告: 类型未找到 %s,标记为本地引用\n", pkgName, round, typeName)
}
}
totalCopied += copiedThisRound
if copiedThisRound == 0 {
break // 没有新增依赖时结束迭代。
}
if round > 20 {
fmt.Printf(" [%s] 警告: 复制轮次超过20,可能存在问题\n", pkgName)
break
}
}
if totalCopied > 0 {
fmt.Printf(" [%s] 共复制 %d 个外部类型\n", pkgName, totalCopied)
}
return result
}
// collectFieldRefsSimple 收集单个字段直接引用的外部类型。
func collectFieldRefsSimple(f Field, currentPkg string, preferredPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver,
neededTypes map[string]bool, localTypes map[string]bool) {
type refWithKind struct {
ref string
kind string
}
var refs []refWithKind
if f.Kind == "message" || f.Kind == "enum" {
if v, ok := f.T.(string); ok {
refs = append(refs, refWithKind{ref: v, kind: f.Kind})
}
}
if f.Kind == "map" && (f.MapValueKind == "message" || f.MapValueKind == "enum") {
if v, ok := f.MapValueT.(string); ok {
refs = append(refs, refWithKind{ref: v, kind: f.MapValueKind})
}
}
for _, item := range refs {
typeName, ok := resolver.ResolveTypeName(item.ref, contextPos, contextModuleStart, preferredPkg, item.kind)
if !ok {
continue
}
refPkg, shortName := parseTypeName(typeName)
if refPkg == "" || refPkg == currentPkg || isGooglePkg(refPkg) {
continue
}
// 已在当前包中的类型无需收集。
if localTypes[shortName] {
continue
}
neededTypes[typeName] = true
}
}
// collectMethodRefsSimple 收集服务方法输入或输出引用的外部类型。
func collectMethodRefsSimple(ref string, currentPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver,
neededTypes map[string]bool, localTypes map[string]bool) {
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, "message")
if !ok {
return
}
refPkg, shortName := parseTypeName(typeName)
if refPkg == "" || refPkg == currentPkg || isGooglePkg(refPkg) {
return
}
if localTypes[shortName] {
return
}
neededTypes[typeName] = true
}
// copiedTypes 按目标包和短名称记录被复制类型的原始全限定名。
var copiedTypes = make(map[string]map[string]string)
// TypeNode 表示嵌套消息与枚举组成的类型树节点。
type TypeNode struct {
// Name 是当前嵌套层级的类型名。
Name string
// Message 保存当前节点的消息声明。
Message *Message
// Enum 保存当前节点的枚举声明。
Enum *Enum
// Children 保存下一层嵌套类型。
Children map[string]*TypeNode
}
// collectImports 只收集 Google 标准依赖,其余类型会复制到本地。
func collectImports(currentPkg string, messages []Message, services []Service, resolver *TypeResolver) map[string]bool {
imports := make(map[string]bool)
addImport := func(ref string, contextPos int, contextModuleStart int, expectedKind string) {
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, expectedKind)
if !ok {
return
}
refPkg, shortName := parseTypeName(typeName)
// 仅导入 Google 标准类型。
if refPkg == "google.protobuf" {
var importFile string
switch shortName {
case "Struct", "Value", "ListValue", "NullValue":
importFile = "google/protobuf/struct.proto"
case "Timestamp":
importFile = "google/protobuf/timestamp.proto"
case "Duration":
importFile = "google/protobuf/duration.proto"
case "Any":
importFile = "google/protobuf/any.proto"
case "Empty":
importFile = "google/protobuf/empty.proto"
case "FieldMask":
importFile = "google/protobuf/field_mask.proto"
case "BoolValue", "BytesValue", "DoubleValue", "FloatValue",
"Int32Value", "Int64Value", "StringValue", "UInt32Value", "UInt64Value":
importFile = "google/protobuf/wrappers.proto"
default:
importFile = "google/protobuf/descriptor.proto"
}
imports[importFile] = true
} else if refPkg == "google.rpc" {
var importFile string
switch shortName {
case "Status":
importFile = "google/rpc/status.proto"
case "Code":
importFile = "google/rpc/code.proto"
default:
importFile = "google/rpc/status.proto"
}
imports[importFile] = true
}
}
for _, msg := range messages {
for _, f := range msg.Fields {
if f.Kind == "message" || f.Kind == "enum" {
if ref, ok := f.T.(string); ok {
addImport(ref, msg.Pos, msg.ModuleStart, f.Kind)
}
}
// map 值类型也可能引用标准包。
if f.Kind == "map" && (f.MapValueKind == "message" || f.MapValueKind == "enum") {
if ref, ok := f.MapValueT.(string); ok {
addImport(ref, msg.Pos, msg.ModuleStart, f.MapValueKind)
}
}
}
}
for _, svc := range services {
for _, m := range svc.Methods {
addImport(m.InputType, svc.Pos, svc.ModuleStart, "message")
addImport(m.OutputType, svc.Pos, svc.ModuleStart, "message")
}
}
return imports
}
+134
View File
@@ -0,0 +1,134 @@
// main.go 提供协议提取命令的参数解析、输入保护和输出调度。
package main
import (
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
)
// inputPaths 支持命令行重复传入 bundle 路径。
type inputPaths []string
// String 返回已经登记的输入路径列表。
func (paths *inputPaths) String() string {
return fmt.Sprint([]string(*paths))
}
// Set 追加一个去除空白后的输入路径。
func (paths *inputPaths) Set(value string) error {
*paths = append(*paths, value)
return nil
}
// bailIf 在不可恢复错误时打印信息并退出。
func bailIf(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// findPrettier 定位可用的 prettier 命令。
func findPrettier() (string, error) {
// 尝试常见的 prettier 命令名
names := []string{"prettier", "prettier.cmd", "npx"}
for _, name := range names {
if path, err := exec.LookPath(name); err == nil {
return path, nil
}
}
return "", fmt.Errorf("prettier not found in PATH, please install: npm install -g prettier")
}
// main 解析参数、保护原始输入并执行协议提取。
func main() {
// 命令行参数
var inputs inputPaths
flag.Var(&inputs, "input", "Path to a JS bundle; repeat to merge multiple bundles")
outputDir := flag.String("output", "", "Output directory for proto files (default: ./cursor_proto)")
skipFormat := flag.Bool("skip-format", false, "Skip prettier formatting")
strict := flag.Bool("strict", true, "Fail when extraction validation detects unresolved/placeholder output")
flag.Parse()
// 如果没有 -input 参数,尝试从位置参数获取
if len(inputs) == 0 && flag.NArg() > 0 {
inputs = append(inputs, flag.Args()...)
}
if len(inputs) == 0 {
fmt.Fprintln(os.Stderr, "Usage: ext -input <path-to-js-file> [-input <another-js-file>] [-output <dir>] [-skip-format]")
fmt.Fprintln(os.Stderr, " ext <path-to-js-file>")
fmt.Fprintln(os.Stderr, "\nExample:")
fmt.Fprintln(os.Stderr, " ext -input /path/to/extensionHostProcess.js")
fmt.Fprintln(os.Stderr, " ext C:\\Users\\xxx\\AppData\\Local\\Programs\\cursor\\resources\\app\\out\\vs\\workbench\\api\\node\\extensionHostProcess.js")
os.Exit(1)
}
for _, inputPath := range inputs {
info, err := os.Stat(inputPath)
bailIf(err)
if info.IsDir() {
bailIf(fmt.Errorf("expected %s to be file, is dir", inputPath))
}
}
// 设置输出目录
if *outputDir == "" {
wd, err := os.Getwd()
bailIf(err)
*outputDir = filepath.Join(wd, "cursor_proto")
}
// 复制到临时文件后再格式化,避免修改 Cursor 安装目录。
fmt.Printf("Copying %d source bundle(s) to temp directory...\n", len(inputs))
tempFileNames := make([]string, 0, len(inputs))
for _, inputPath := range inputs {
originalFile, err := os.Open(inputPath)
bailIf(err)
tempFile, err := os.CreateTemp(os.TempDir(), "cursor-source-*.js")
bailIf(err)
_, err = io.Copy(tempFile, originalFile)
bailIf(err)
bailIf(originalFile.Close())
bailIf(tempFile.Close())
tempFileNames = append(tempFileNames, tempFile.Name())
fmt.Printf("Source: %s\n", inputPath)
}
if *skipFormat {
fmt.Println("Skipping formatting (--skip-format)")
} else if prettierBin, err := findPrettier(); err != nil {
fmt.Printf("Warning: %v\n", err)
fmt.Println("Skipping formatting, extraction may be less accurate...")
} else {
fmt.Println("Formatting source bundles (this may take a while)...")
for _, tempFileName := range tempFileNames {
var prettierCmd *exec.Cmd
if filepath.Base(prettierBin) == "npx" {
prettierCmd = exec.Command(prettierBin, "prettier", "--write", tempFileName)
} else {
prettierCmd = exec.Command(prettierBin, "--write", tempFileName)
}
out, formatErr := prettierCmd.CombinedOutput()
if formatErr != nil {
fmt.Printf("Prettier output: %s\n", string(out))
fmt.Println("Warning: formatting failed for one bundle, continuing anyway...")
}
}
}
// 运行提取器
fmt.Println("Extracting Proto definitions...")
SetStrictMode(*strict)
ExtractProtosFromFiles(tempFileNames, *outputDir)
for _, tempFileName := range tempFileNames {
_ = os.Remove(tempFileName)
}
fmt.Printf("\nOutput directory: %s\n", *outputDir)
}
+343
View File
@@ -0,0 +1,343 @@
// messages.go 解析消息声明、字段数组和字段类型信息。
package main
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
)
// extractMessages 从多种 bundle 语法中提取消息声明。
func extractMessages(text string, moduleStarts []int) []Message {
var messages []Message
messageExists := func(typeName, varName string) bool {
for _, existing := range messages {
if existing.TypeName == typeName && existing.VarName == varName {
return true
}
}
return false
}
// 形式一:变量引用继承基类并在类体中声明 typeName 和 fields。
// 先找所有 "变量名 = class 内部类名" 定义
// JS 变量名可以包含 $ 符号,如 B$e, qg 等
// 需要同时捕获外部变量名和内部类名,因为字段引用可能用任一个
classDefRe := regexp.MustCompile(`([\w$]+)\s*=\s*class\s+([\w$]+)\s+extends\s+[\w$.]+\s*\{`)
classMatches := classDefRe.FindAllStringSubmatchIndex(text, -1)
// 从任意包的 this.typeName 字段读取完整类型名。
typeNameRe := regexp.MustCompile(`this\.typeName\s*=\s*"([\w.]+)"`)
// 从 this.fields 的 newFieldList 回调读取字段数组。
fieldsRe := regexp.MustCompile(`this\.fields\s*=\s*\w+(?:\.proto3)?\.util\.newFieldList\s*\(\s*\(\s*\)\s*=>\s*\[`)
for _, classMatch := range classMatches {
varName := text[classMatch[2]:classMatch[3]]
internalName := text[classMatch[4]:classMatch[5]]
classStart := classMatch[0]
// 找到类的结束位置(匹配大括号)
classEnd := findClassEnd(text, classMatch[1]-1)
if classEnd == -1 {
continue
}
classBody := text[classStart:classEnd]
// 在类体内查找 typeName
typeMatch := typeNameRe.FindStringSubmatch(classBody)
if typeMatch == nil {
continue
}
typeName := typeMatch[1]
// 在类体内查找 fields
fieldsMatch := fieldsRe.FindStringIndex(classBody)
if fieldsMatch == nil {
continue
}
// 找到 fields 数组的开始位置
bracketPos := classStart + fieldsMatch[1] - 1
fields := extractFieldArray(text, bracketPos)
pkg, shortName := parseTypeName(typeName)
msg := Message{
TypeName: typeName,
VarName: varName,
InternalName: internalName,
Fields: fields,
Package: pkg,
ShortName: shortName,
Pos: classStart,
ModuleStart: moduleStartForPos(moduleStarts, classStart),
}
messages = append(messages, msg)
}
// 形式二:匹配转译或压缩 bundle 中连续赋值的消息声明。
// 例如 i.runtime=n.proto3,i.typeName="agent.v1.McpArgs",i.fields=n.proto3.util.newFieldList(()=>[{...}])。
assignmentRe := regexp.MustCompile(`([\w$]+)\.typeName\s*=\s*"([\w.]+)"\s*,\s*[\w$]+\.fields\s*=\s*\w+(?:\.\w+)*\.util\.newFieldList\s*\(\s*\(\s*\)\s*=>\s*\[`)
assignmentMatches := assignmentRe.FindAllStringSubmatchIndex(text, -1)
for _, m := range assignmentMatches {
varName := text[m[2]:m[3]]
typeName := text[m[4]:m[5]]
// 跳过已经由类体形式提取的重复消息。
if messageExists(typeName, varName) {
continue
}
// 正则停在左方括号之前,从匹配尾部定位数组起点。
start := m[1] - 1
if start < 0 || start >= len(text) || text[start] != '[' {
continue
}
fields := extractFieldArray(text, start)
pkg, shortName := parseTypeName(typeName)
messages = append(messages, Message{
TypeName: typeName,
VarName: varName,
InternalName: "",
Fields: fields,
Package: pkg,
ShortName: shortName,
Pos: m[0],
ModuleStart: moduleStartForPos(moduleStarts, m[0]),
})
}
// 形式三:匹配现代 @bufbuild/protobuf 工厂调用。
// 例如 Req=A.makeMessageType("aiserver.v1.HasSeenAdRequest",()=>[{...}])。
messageFactoryRe := regexp.MustCompile(`([\w$]+)\s*=\s*[\w$.]+\.makeMessageType\s*\(\s*["']([\w.]+)["']\s*,\s*\(\s*\)\s*=>\s*\[`)
factoryMatches := messageFactoryRe.FindAllStringSubmatchIndex(text, -1)
for _, m := range factoryMatches {
varName := text[m[2]:m[3]]
typeName := text[m[4]:m[5]]
if messageExists(typeName, varName) {
continue
}
bracketStart := m[1] - 1
if bracketStart < 0 || bracketStart >= len(text) || text[bracketStart] != '[' {
continue
}
pkg, shortName := parseTypeName(typeName)
messages = append(messages, Message{
TypeName: typeName,
VarName: varName,
Fields: extractFieldArray(text, bracketStart),
Package: pkg,
ShortName: shortName,
Pos: m[0],
ModuleStart: moduleStartForPos(moduleStarts, m[0]),
})
}
// 空消息直接传字段数组,不使用延迟回调。
// 例如 Res=A.makeMessageType("aiserver.v1.MarkAdAsSeenResponse",[])。
emptyMessageFactoryRe := regexp.MustCompile(`([\w$]+)\s*=\s*[\w$.]+\.makeMessageType\s*\(\s*["']([\w.]+)["']\s*,\s*\[`)
emptyFactoryMatches := emptyMessageFactoryRe.FindAllStringSubmatchIndex(text, -1)
for _, m := range emptyFactoryMatches {
varName := text[m[2]:m[3]]
typeName := text[m[4]:m[5]]
if messageExists(typeName, varName) {
continue
}
bracketStart := m[1] - 1
if bracketStart < 0 || bracketStart >= len(text) || text[bracketStart] != '[' {
continue
}
pkg, shortName := parseTypeName(typeName)
messages = append(messages, Message{
TypeName: typeName,
VarName: varName,
Fields: extractFieldArray(text, bracketStart),
Package: pkg,
ShortName: shortName,
Pos: m[0],
ModuleStart: moduleStartForPos(moduleStarts, m[0]),
})
}
return messages
}
// findClassEnd 查找类定义的配对右花括号。
func findClassEnd(text string, openBrace int) int {
depth := 0
for i := openBrace; i < len(text); i++ {
if text[i] == '{' {
depth++
} else if text[i] == '}' {
depth--
if depth == 0 {
return i + 1
}
}
}
return -1
}
// extractFieldArray 从左方括号位置解析完整字段数组。
func extractFieldArray(text string, start int) []Field {
// 查找字段数组的配对右方括号。
depth := 0
end := start
for i := start; i < len(text); i++ {
if text[i] == '[' {
depth++
} else if text[i] == ']' {
depth--
if depth == 0 {
end = i + 1
break
}
}
}
arrayText := text[start:end]
// 按每个花括号块解析独立字段对象。
var fields []Field
// 依次查找字段对象。
fieldObjects := extractFieldObjects(arrayText)
for _, fieldObj := range fieldObjects {
field, parseErr := parseFieldObject(fieldObj)
if parseErr != nil {
activeDiagnostics.addSkippedField(fieldObj, parseErr)
continue
}
activeDiagnostics.addParsedField()
fields = append(fields, *field)
}
return fields
}
// extractFieldObjects 从数组文本中提取独立字段对象。
func extractFieldObjects(arrayText string) []string {
var objects []string
depth := 0
start := -1
for i := 0; i < len(arrayText); i++ {
if arrayText[i] == '{' {
if depth == 0 {
start = i
}
depth++
} else if arrayText[i] == '}' {
depth--
if depth == 0 && start >= 0 {
objects = append(objects, arrayText[start:i+1])
start = -1
}
}
}
return objects
}
// parseFieldObject 解析包含编号、名称、类型和修饰符的单个字段对象。
func parseFieldObject(obj string) (*Field, error) {
// 提取字段编号。
noMatch := noRe.FindStringSubmatch(obj)
if noMatch == nil {
return nil, errors.New("missing field no")
}
no, _ := strconv.Atoi(noMatch[1])
// 提取字段名称。
nameMatch := nameRe.FindStringSubmatch(obj)
if nameMatch == nil {
return nil, errors.New("missing field name")
}
name := strings.TrimSpace(nameMatch[1])
if !fieldNameRe.MatchString(name) {
return nil, fmt.Errorf("invalid field name: %s", name)
}
// 提取字段类别。
kindMatch := kindRe.FindStringSubmatch(obj)
if kindMatch == nil {
return nil, errors.New("missing field kind")
}
kind := strings.TrimSpace(kindMatch[1])
field := &Field{
No: no,
Name: name,
Kind: kind,
}
// 类型 T 可以是标量编号、变量名或 getEnumType 枚举调用。
// 枚举优先匹配 getEnumType 调用。
if enumMatch := enumTypeRe.FindStringSubmatch(obj); enumMatch != nil {
field.T = enumMatch[1]
} else {
// 其余类型匹配普通 T 属性值。
if tMatch := tRe.FindStringSubmatch(obj); tMatch != nil {
if t, err := strconv.Atoi(tMatch[1]); err == nil {
field.T = t
} else {
field.T = tMatch[1]
}
} else if shorthandTRe.MatchString(obj) {
field.T = "T"
}
}
// 仅在当前字段对象内检查 oneof 分组。
if oneofMatch := oneofRe.FindStringSubmatch(obj); oneofMatch != nil {
candidate := strings.TrimSpace(oneofMatch[1])
if oneofNameRe.MatchString(candidate) {
field.Oneof = candidate
}
}
// 仅在当前字段对象内检查 repeated;压缩 JS 中 !0 表示真。
if repeatedRe.MatchString(obj) {
field.Repeated = true
}
// 仅在当前字段对象内检查 optional。
if optRe.MatchString(obj) {
field.Opt = true
}
// map 字段通过 K 键类型和 V 值描述共同表示。
if field.Kind == "map" {
// 提取 map 键类型。
if keyMatch := keyRe.FindStringSubmatch(obj); keyMatch != nil {
field.MapKey, _ = strconv.Atoi(keyMatch[1])
}
// 提取 map 值类型,兼容属性顺序变化。
if valueMatch := mapValueRe.FindStringSubmatch(obj); valueMatch != nil {
valueObj := valueMatch[1]
if kindMatch := mapValueKRe.FindStringSubmatch(valueObj); kindMatch != nil {
field.MapValueKind = kindMatch[1]
}
if tMatch := mapValueTRe.FindStringSubmatch(valueObj); tMatch != nil {
if t, err := strconv.Atoi(tMatch[1]); err == nil {
field.MapValueT = t
} else {
field.MapValueT = tMatch[1]
}
}
}
}
return field, nil
}
+430
View File
@@ -0,0 +1,430 @@
// modules.go 扫描模块边界、合并声明并执行提取结果校验。
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/jhump/protoreflect/desc"
"github.com/jhump/protoreflect/desc/protoparse"
)
// moduleStartRe 匹配 Webpack 数字模块的函数起点。
var moduleStartRe = regexp.MustCompile(`(?:^|,)\s*(\d+)\s*:\s*(?:function\s*\(\s*[\w$,\s]*\s*\)|\(\s*[\w$,\s]*\s*\)\s*=>)\s*\{`)
// buildModuleStarts 收集 bundle 内全部模块起始位置。
func buildModuleStarts(text string) []int {
matches := moduleStartRe.FindAllStringSubmatchIndex(text, -1)
starts := make([]int, 0, len(matches))
for _, match := range matches {
starts = append(starts, match[0])
}
return starts
}
// moduleStartForPos 查找指定源码位置所属的模块起点。
func moduleStartForPos(moduleStarts []int, pos int) int {
if len(moduleStarts) == 0 {
return 0
}
index := sort.Search(len(moduleStarts), func(i int) bool {
return moduleStarts[i] > pos
}) - 1
if index < 0 {
return 0
}
return moduleStarts[index]
}
// buildModuleImportIndex 建立模块局部变量到导入模块编号的映射。
func buildModuleImportIndex(text string, moduleStarts []int) map[int]map[string]int {
if len(moduleStarts) == 0 {
return nil
}
moduleMatches := moduleStartRe.FindAllStringSubmatchIndex(text, -1)
moduleStartByID := make(map[string]int, len(moduleMatches))
for _, match := range moduleMatches {
moduleStartByID[text[match[2]:match[3]]] = match[0]
}
importsByModule := make(map[int]map[string]int)
for index, moduleStart := range moduleStarts {
moduleEnd := len(text)
if index+1 < len(moduleStarts) {
moduleEnd = moduleStarts[index+1]
}
body := text[moduleStart:moduleEnd]
for _, match := range moduleImportRe.FindAllStringSubmatch(body, -1) {
targetModuleStart, ok := moduleStartByID[match[2]]
if !ok {
continue
}
if importsByModule[moduleStart] == nil {
importsByModule[moduleStart] = make(map[string]int)
}
importsByModule[moduleStart][match[1]] = targetModuleStart
}
}
return importsByModule
}
// ExtractProtosFromFiles 分别提取各 bundle,规范化类型引用后按全限定名合并。
// 多个 bundle 出现同名声明时优先保留靠前输入。
func ExtractProtosFromFiles(inputFiles []string, outputDir string) {
activeDiagnostics = newExtractionDiagnostics()
defer func() {
activeDiagnostics = nil
}()
var allMessages []Message
var allEnums []Enum
var allServices []Service
for _, inputFile := range inputFiles {
content, err := os.ReadFile(inputFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading file %s: %v\n", inputFile, err)
os.Exit(1)
}
text := string(content)
moduleStarts := buildModuleStarts(text)
aliases := buildAliasIndex(text, moduleStarts)
exportAliases := buildWebpackExportAliasIndex(text, moduleStarts)
messages := extractMessages(text, moduleStarts)
enums := extractEnums(text, moduleStarts)
services := extractServices(text, moduleStarts)
declared, extracted, missing := declarationCoverage(text, messages, enums, services)
activeDiagnostics.declaredTypes += declared
activeDiagnostics.extractedTypes += extracted
activeDiagnostics.missingDeclarations = append(activeDiagnostics.missingDeclarations, missing...)
resolver := newTypeResolver(messages, enums, aliases, exportAliases)
resolver.moduleImports = buildModuleImportIndex(text, moduleStarts)
normalizeTypeReferences(messages, services, resolver)
allMessages = append(allMessages, messages...)
allEnums = append(allEnums, enums...)
allServices = append(allServices, services...)
}
messages := mergeMessagesByTypeName(allMessages)
enums := mergeEnumsByTypeName(allEnums)
services := mergeServicesByTypeName(allServices)
for _, msg := range messages {
if len(msg.Fields) == 0 {
activeDiagnostics.emptyMessages = append(activeDiagnostics.emptyMessages, msg.TypeName)
}
}
sort.Strings(activeDiagnostics.missingDeclarations)
activeDiagnostics.missingDeclarations = compactStrings(activeDiagnostics.missingDeclarations)
resolver := newTypeResolver(messages, enums, nil, nil)
generateProtos(messages, enums, services, resolver, outputDir)
validateErr := validateGeneratedProtos(outputDir, activeDiagnostics)
printDiagnosticsSummary(activeDiagnostics)
if strictExtractionValidation && hasValidationFailure(activeDiagnostics, validateErr) {
if validateErr != nil {
fmt.Fprintf(os.Stderr, "Validation failed: %v\n", validateErr)
}
os.Exit(1)
}
if validateErr != nil {
fmt.Fprintf(os.Stderr, "Validation warning: %v\n", validateErr)
}
fmt.Printf("提取完成: %d 个消息, %d 个枚举, %d 个服务\n", len(messages), len(enums), len(services))
}
// normalizeTypeReferences 把字段和方法引用统一转换为全限定类型名。
func normalizeTypeReferences(messages []Message, services []Service, resolver *TypeResolver) {
resolve := func(ref any, contextPos int, moduleStart int, pkg string, kind string) any {
symbol, ok := ref.(string)
if !ok || strings.TrimSpace(symbol) == "" {
return ref
}
if typeName, resolved := resolver.ResolveTypeName(symbol, contextPos, moduleStart, pkg, kind); resolved {
return typeName
}
return ref
}
for messageIndex := range messages {
message := &messages[messageIndex]
for fieldIndex := range message.Fields {
field := &message.Fields[fieldIndex]
if field.Kind == "message" || field.Kind == "enum" {
field.T = resolve(field.T, message.Pos, message.ModuleStart, message.Package, field.Kind)
}
if field.Kind == "map" && (field.MapValueKind == "message" || field.MapValueKind == "enum") {
field.MapValueT = resolve(field.MapValueT, message.Pos, message.ModuleStart, message.Package, field.MapValueKind)
}
}
}
for serviceIndex := range services {
service := &services[serviceIndex]
for methodIndex := range service.Methods {
method := &service.Methods[methodIndex]
if typeName, ok := resolve(method.InputType, service.Pos, service.ModuleStart, service.Package, "message").(string); ok {
method.InputType = typeName
}
if typeName, ok := resolve(method.OutputType, service.Pos, service.ModuleStart, service.Package, "message").(string); ok {
method.OutputType = typeName
}
}
}
}
// mergeMessagesByTypeName 按全限定名合并消息并保留首次声明。
func mergeMessagesByTypeName(messages []Message) []Message {
seen := make(map[string]bool)
merged := make([]Message, 0, len(messages))
for _, message := range messages {
if seen[message.TypeName] {
continue
}
seen[message.TypeName] = true
merged = append(merged, message)
}
return merged
}
// mergeEnumsByTypeName 按全限定名合并枚举并保留首次声明。
func mergeEnumsByTypeName(enums []Enum) []Enum {
seen := make(map[string]bool)
merged := make([]Enum, 0, len(enums))
for _, enum := range enums {
if seen[enum.TypeName] {
continue
}
seen[enum.TypeName] = true
merged = append(merged, enum)
}
return merged
}
// mergeServicesByTypeName 按全限定名合并服务并保留首次声明。
func mergeServicesByTypeName(services []Service) []Service {
seen := make(map[string]bool)
merged := make([]Service, 0, len(services))
for _, service := range services {
if seen[service.TypeName] {
continue
}
seen[service.TypeName] = true
merged = append(merged, service)
}
return merged
}
// compactStrings 清理、去重并排序诊断字符串。
func compactStrings(values []string) []string {
if len(values) == 0 {
return nil
}
compacted := values[:1]
for _, value := range values[1:] {
if value != compacted[len(compacted)-1] {
compacted = append(compacted, value)
}
}
return compacted
}
// hasValidationFailure 判断诊断结果是否达到失败条件。
func hasValidationFailure(diag *extractionDiagnostics, validateErr error) bool {
if validateErr != nil {
return true
}
if diag == nil {
return false
}
if diag.skippedFieldObjects > 0 {
return true
}
if len(diag.unresolvedTypeRefs) > 0 {
return true
}
if len(diag.placeholderHits) > 0 {
return true
}
if len(diag.missingDeclarations) > 0 {
return true
}
return false
}
// printDiagnosticsSummary 输出提取覆盖率和异常样本摘要。
func printDiagnosticsSummary(diag *extractionDiagnostics) {
if diag == nil {
return
}
fmt.Printf(
"诊断汇总: fields %d/%d 解析成功, declarations %d/%d 已提取, skipped=%d, unresolved=%d, placeholders=%d, empty_messages=%d\n",
diag.parsedFieldObjects,
diag.totalFieldObjects,
diag.extractedTypes,
diag.declaredTypes,
diag.skippedFieldObjects,
len(diag.unresolvedTypeRefs),
len(diag.placeholderHits),
len(diag.emptyMessages),
)
if diag.skippedFieldObjects > 0 && len(diag.skippedFieldSamples) > 0 {
fmt.Println("字段解析失败样例:")
for _, sample := range diag.skippedFieldSamples {
fmt.Printf(" - %s\n", sample)
}
}
if len(diag.unresolvedTypeRefs) > 0 {
keys := make([]string, 0, len(diag.unresolvedTypeRefs))
for key := range diag.unresolvedTypeRefs {
keys = append(keys, key)
}
sort.Strings(keys)
fmt.Println("未解析类型引用:")
for _, key := range keys {
fmt.Printf(" - %s (%d)\n", key, diag.unresolvedTypeRefs[key])
}
}
if len(diag.placeholderHits) > 0 {
fmt.Println("占位字段命中:")
for i, hit := range diag.placeholderHits {
if i >= 20 {
fmt.Printf(" - ... and %d more\n", len(diag.placeholderHits)-20)
break
}
fmt.Printf(" - %s\n", hit)
}
}
if len(diag.missingDeclarations) > 0 {
fmt.Println("未提取的 Proto 声明:")
for i, typeName := range diag.missingDeclarations {
if i >= 20 {
fmt.Printf(" - ... and %d more\n", len(diag.missingDeclarations)-20)
break
}
fmt.Printf(" - %s\n", typeName)
}
}
}
// declarationCoverage 比较 bundle 声明数量与实际提取数量。
func declarationCoverage(text string, messages []Message, enums []Enum, services []Service) (int, int, []string) {
declared := make(map[string]bool)
collect := func(re *regexp.Regexp) {
for _, match := range re.FindAllStringSubmatch(text, -1) {
typeName := strings.TrimSpace(match[1])
pkg, _ := parseTypeName(typeName)
if typeName != "" && !isGooglePkg(pkg) {
declared[typeName] = true
}
}
}
collect(typeNameDeclarationRe)
collect(serviceDeclarationRe)
collect(messageDeclarationRe)
collect(enumDeclarationRe)
collect(legacyEnumDeclarationRe)
extracted := make(map[string]bool)
for _, message := range messages {
extracted[message.TypeName] = true
}
for _, enum := range enums {
extracted[enum.TypeName] = true
}
for _, service := range services {
extracted[service.TypeName] = true
}
matched := 0
missing := make([]string, 0)
for typeName := range declared {
if extracted[typeName] {
matched++
continue
}
missing = append(missing, typeName)
}
sort.Strings(missing)
return len(declared), matched, missing
}
// validateGeneratedProtos 检查生成文件语法占位和关键 Agent 结构。
func validateGeneratedProtos(outputDir string, diag *extractionDiagnostics) error {
entries, err := os.ReadDir(outputDir)
if err != nil {
return fmt.Errorf("read output dir failed: %w", err)
}
protoFiles := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasSuffix(name, ".proto") {
protoFiles = append(protoFiles, name)
}
}
if len(protoFiles) == 0 {
return errors.New("no generated proto files found")
}
sort.Strings(protoFiles)
for _, file := range protoFiles {
body, readErr := os.ReadFile(filepath.Join(outputDir, file))
if readErr != nil {
return fmt.Errorf("read generated proto failed: %s: %w", file, readErr)
}
lines := strings.Split(string(body), "\n")
for idx, line := range lines {
if placeholderRe.MatchString(line) && diag != nil {
hit := fmt.Sprintf("%s:%d: %s", file, idx+1, strings.TrimSpace(line))
diag.placeholderHits = append(diag.placeholderHits, hit)
}
}
if err := validateRequiredAgentShapes(file, string(body)); err != nil {
return err
}
}
parser := protoparse.Parser{
ImportPaths: []string{outputDir},
LookupImport: desc.LoadFileDescriptor,
}
if _, parseErr := parser.ParseFiles(protoFiles...); parseErr != nil {
return fmt.Errorf("parse generated proto failed: %w", parseErr)
}
return nil
}
// validateRequiredAgentShapes 校验 Agent 流控消息的必要字段形状。
func validateRequiredAgentShapes(file string, body string) error {
if strings.Contains(body, "message ExecClientControlMessage") && !streamCloseRe.MatchString(body) {
return fmt.Errorf("%s: ExecClientControlMessage.stream_close must be ExecClientStreamClose", file)
}
if strings.Contains(body, "message ShellStream") && !shellStdoutRe.MatchString(body) {
return fmt.Errorf("%s: ShellStream.stdout must be ShellStreamStdout", file)
}
return nil
}
+395
View File
@@ -0,0 +1,395 @@
// renderer.go 把协议声明树渲染为稳定的 proto 文本。
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// generateProtoFile 把单个协议包的声明渲染并写入文件。
func generateProtoFile(pkgName string, messages []Message, enums []Enum, services []Service, resolver *TypeResolver, outputDir string) {
// 先收集全部跨包标准依赖。
imports := collectImports(pkgName, messages, services, resolver)
var sb strings.Builder
sb.WriteString(`syntax = "proto3";` + "\n\n")
sb.WriteString(fmt.Sprintf("package %s;\n\n", pkgName))
// 按稳定顺序写入 import。
if len(imports) > 0 {
sortedImports := make([]string, 0, len(imports))
for imp := range imports {
sortedImports = append(sortedImports, imp)
}
sort.Strings(sortedImports)
for _, imp := range sortedImports {
sb.WriteString(fmt.Sprintf("import \"%s\";\n", imp))
}
sb.WriteString("\n")
}
goPackagePath := strings.ReplaceAll(pkgName, ".", "/")
goPackageName := strings.ReplaceAll(pkgName, ".", "")
sb.WriteString(fmt.Sprintf(`option go_package = "github.com/leookun/cursor-byok/cursor-proto/gen/%s;%s";`+"\n\n", goPackagePath, goPackageName))
// 建立嵌套类型树。
root := &TypeNode{Children: make(map[string]*TypeNode)}
for i := range messages {
msg := &messages[i]
path := getNestedPath(msg.ShortName)
insertMessage(root, path, msg)
}
for i := range enums {
enum := &enums[i]
path := getNestedPath(enum.ShortName)
insertEnum(root, path, enum)
}
// 写入全部顶层类型。
writeTypeTree(root, &sb, resolver, 0, pkgName)
// 写入服务声明。
sort.Slice(services, func(i, j int) bool {
return services[i].ShortName < services[j].ShortName
})
for _, svc := range services {
// 写入服务来源注释。
sb.WriteString(fmt.Sprintf("// Source: %s (var: %s)\n", svc.TypeName, svc.VarName))
sb.WriteString(fmt.Sprintf("service %s {\n", svc.ShortName))
for _, m := range svc.Methods {
inputType := resolveMethodType(m.InputType, resolver, pkgName, svc.Pos, svc.ModuleStart)
outputType := resolveMethodType(m.OutputType, resolver, pkgName, svc.Pos, svc.ModuleStart)
switch m.Kind {
case "ServerStreaming":
sb.WriteString(fmt.Sprintf(" rpc %s(%s) returns (stream %s) {}\n", m.Name, inputType, outputType))
case "ClientStreaming":
sb.WriteString(fmt.Sprintf(" rpc %s(stream %s) returns (%s) {}\n", m.Name, inputType, outputType))
case "BiDiStreaming":
sb.WriteString(fmt.Sprintf(" rpc %s(stream %s) returns (stream %s) {}\n", m.Name, inputType, outputType))
default: // 默认为一元调用。
sb.WriteString(fmt.Sprintf(" rpc %s(%s) returns (%s) {}\n", m.Name, inputType, outputType))
}
}
sb.WriteString("}\n\n")
}
// 每个协议包写入扁平输出目录中的单个文件。
fileName := strings.ReplaceAll(pkgName, ".", "_") + ".proto"
filePath := filepath.Join(outputDir, fileName)
os.WriteFile(filePath, []byte(sb.String()), 0644)
fmt.Printf("Generated: %s (%d messages, %d enums, %d services)\n", filePath, len(messages), len(enums), len(services))
}
// resolveMethodType 解析方法消息类型并处理本地复制类型。
func resolveMethodType(ref string, resolver *TypeResolver, currentPkg string, contextPos int, contextModuleStart int) string {
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, "message")
if !ok {
activeDiagnostics.addUnresolvedType("method:" + ref)
return fallbackTypeToken(ref)
}
refPkg, shortName := parseTypeName(typeName)
if refPkg == currentPkg || refPkg == "" {
return shortName
}
// 检查类型是否由其他包复制到当前包。
if copied := copiedTypes[currentPkg]; copied != nil {
if _, isCopied := copied[shortName]; isCopied {
return shortName
}
}
return refPkg + "." + shortName
}
// insertMessage 把消息插入嵌套类型树。
func insertMessage(node *TypeNode, path []string, msg *Message) {
if len(path) == 0 {
return
}
name := path[0]
if node.Children == nil {
node.Children = make(map[string]*TypeNode)
}
child, exists := node.Children[name]
if !exists {
child = &TypeNode{Name: name, Children: make(map[string]*TypeNode)}
node.Children[name] = child
}
if len(path) == 1 {
child.Message = msg
} else {
insertMessage(child, path[1:], msg)
}
}
// insertEnum 把枚举插入嵌套类型树。
func insertEnum(node *TypeNode, path []string, enum *Enum) {
if len(path) == 0 {
return
}
name := path[0]
if node.Children == nil {
node.Children = make(map[string]*TypeNode)
}
child, exists := node.Children[name]
if !exists {
child = &TypeNode{Name: name, Children: make(map[string]*TypeNode)}
node.Children[name] = child
}
if len(path) == 1 {
child.Enum = enum
} else {
insertEnum(child, path[1:], enum)
}
}
// writeTypeTree 按名称稳定输出嵌套消息和枚举。
func writeTypeTree(node *TypeNode, sb *strings.Builder, resolver *TypeResolver, indent int, currentPkg string) {
// 对子节点排序以保证输出稳定。
var names []string
for name := range node.Children {
names = append(names, name)
}
sort.Strings(names)
indentStr := strings.Repeat(" ", indent)
for _, name := range names {
child := node.Children[name]
if child.Enum != nil {
// 检查枚举是否来自其他包。
originalType := ""
if copied := copiedTypes[currentPkg]; copied != nil {
if orig, ok := copied[child.Enum.ShortName]; ok {
originalType = orig
}
}
// 写入枚举来源注释。
if originalType != "" {
sb.WriteString(fmt.Sprintf("%s// Copied from: %s (var: %s)\n", indentStr, originalType, child.Enum.VarName))
} else {
sb.WriteString(fmt.Sprintf("%s// Source: %s (var: %s)\n", indentStr, child.Enum.TypeName, child.Enum.VarName))
}
// 写入枚举声明。
sb.WriteString(fmt.Sprintf("%senum %s {\n", indentStr, name))
for _, v := range child.Enum.Values {
sb.WriteString(fmt.Sprintf("%s %s = %d;\n", indentStr, v.Name, v.No))
}
sb.WriteString(fmt.Sprintf("%s}\n\n", indentStr))
} else if child.Message != nil || len(child.Children) > 0 {
// 写入消息来源注释。
if child.Message != nil {
varInfo := child.Message.VarName
if child.Message.InternalName != "" && child.Message.InternalName != child.Message.VarName {
varInfo = fmt.Sprintf("%s, class: %s", child.Message.VarName, child.Message.InternalName)
}
// 检查消息是否来自其他包。
originalType := ""
if copied := copiedTypes[currentPkg]; copied != nil {
if orig, ok := copied[child.Message.ShortName]; ok {
originalType = orig
}
}
if originalType != "" {
sb.WriteString(fmt.Sprintf("%s// Copied from: %s (var: %s)\n", indentStr, originalType, varInfo))
} else {
sb.WriteString(fmt.Sprintf("%s// Source: %s (var: %s)\n", indentStr, child.Message.TypeName, varInfo))
}
}
// 即使节点只承载嵌套类型,也要写入消息容器。
sb.WriteString(fmt.Sprintf("%smessage %s {\n", indentStr, name))
// 先写入嵌套类型。
writeTypeTree(child, sb, resolver, indent+1, currentPkg)
// 当前节点有消息声明时再写字段。
if child.Message != nil {
writeMessageFields(child.Message, sb, resolver, indent+1)
}
sb.WriteString(fmt.Sprintf("%s}\n\n", indentStr))
}
}
}
// writeMessageFields 输出普通字段和 oneof 分组。
func writeMessageFields(msg *Message, sb *strings.Builder, resolver *TypeResolver, indent int) {
indentStr := strings.Repeat(" ", indent)
// 获取当前消息路径,用于解析相对嵌套类型。
msgPath := msg.ShortName
currentPkg := msg.Package
preferredPkg, _ := parseTypeName(msg.TypeName)
// 按 oneof 分组字段。
oneofGroups := make(map[string][]Field)
var regularFields []Field
for _, f := range msg.Fields {
if f.Oneof != "" {
oneofGroups[f.Oneof] = append(oneofGroups[f.Oneof], f)
} else {
regularFields = append(regularFields, f)
}
}
// 先写普通字段。
for _, f := range regularFields {
fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, preferredPkg, msg.Pos, msg.ModuleStart)
prefix := ""
if f.Repeated {
prefix = "repeated "
} else if f.Opt {
prefix = "optional "
}
sb.WriteString(fmt.Sprintf("%s%s%s %s = %d;\n", indentStr, prefix, fieldType, f.Name, f.No))
}
// 再写 oneof 字段组。
var oneofNames []string
for name := range oneofGroups {
oneofNames = append(oneofNames, name)
}
sort.Strings(oneofNames)
for _, oneofName := range oneofNames {
fields := oneofGroups[oneofName]
sb.WriteString(fmt.Sprintf("%soneof %s {\n", indentStr, oneofName))
for _, f := range fields {
fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, preferredPkg, msg.Pos, msg.ModuleStart)
sb.WriteString(fmt.Sprintf("%s %s %s = %d;\n", indentStr, fieldType, f.Name, f.No))
}
sb.WriteString(fmt.Sprintf("%s}\n", indentStr))
}
}
// parseTypeName 从全限定类型名拆出协议包和完整嵌套路径。
func parseTypeName(typeName string) (pkg, shortName string) {
// 优先匹配 xxx.vN.Rest 形式的版本化协议包。
versionRe := regexp.MustCompile(`^([\w.]+\.v\d+)\.(.+)$`)
if match := versionRe.FindStringSubmatch(typeName); match != nil {
return match[1], match[2]
}
// 单独处理 google.protobuf 标准类型。
if strings.HasPrefix(typeName, "google.protobuf.") {
rest := strings.TrimPrefix(typeName, "google.protobuf.")
return "google.protobuf", rest
}
// 单独处理 google.rpc 标准类型。
if strings.HasPrefix(typeName, "google.rpc.") {
rest := strings.TrimPrefix(typeName, "google.rpc.")
return "google.rpc", rest
}
// 无法识别包版本时按最后一个点回退拆分。
parts := strings.Split(typeName, ".")
if len(parts) > 1 {
return strings.Join(parts[:len(parts)-1], "."), parts[len(parts)-1]
}
return "", typeName
}
// getNestedPath 把嵌套类型名拆成逐级路径。
func getNestedPath(shortName string) []string {
return strings.Split(shortName, ".")
}
// resolveFieldTypeWithPkg 结合当前包和父消息路径解析字段类型。
func resolveFieldTypeWithPkg(f Field, resolver *TypeResolver, parentPath string, currentPkg string, preferredPkg string, contextPos int, contextModuleStart int) string {
resolveNamedType := func(ref string, expectedKind string) string {
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, preferredPkg, expectedKind)
if !ok {
activeDiagnostics.addUnresolvedType(expectedKind + ":" + ref)
return fallbackTypeToken(ref)
}
refPkg, shortName := parseTypeName(typeName)
// 类型位于同一父消息下时使用相对路径。
if parentPath != "" && strings.HasPrefix(shortName, parentPath+".") {
// 例如消息内部将 ConversationMessage.CodeChunk 缩短为 CodeChunk。
return strings.TrimPrefix(shortName, parentPath+".")
}
// 同包类型只使用短名称。
if refPkg == currentPkg || refPkg == "" {
return shortName
}
// 循环依赖中优先使用已经复制到当前包的类型。
if copied := copiedTypes[currentPkg]; copied != nil {
if _, isCopied := copied[shortName]; isCopied {
// 本地存在复制类型时使用短名称。
return shortName
}
}
// 其余跨包引用保留全限定类型名。
return refPkg + "." + shortName
}
if f.Kind == "scalar" {
if t, ok := f.T.(int); ok {
return scalarTypes[t]
}
if t, ok := f.T.(float64); ok {
return scalarTypes[int(t)]
}
}
if f.Kind == "message" || f.Kind == "enum" {
if ref, ok := f.T.(string); ok {
return resolveNamedType(ref, f.Kind)
}
}
if f.Kind == "map" {
// map 字段分别解析键和值类型。
keyType := scalarTypes[f.MapKey]
if keyType == "" {
keyType = "string" // 未知标量默认使用字符串。
}
var valueType string
if f.MapValueKind == "scalar" {
if t, ok := f.MapValueT.(int); ok {
valueType = scalarTypes[t]
} else if t, ok := f.MapValueT.(float64); ok {
valueType = scalarTypes[int(t)]
}
} else if f.MapValueKind == "message" || f.MapValueKind == "enum" {
if ref, ok := f.MapValueT.(string); ok {
valueType = resolveNamedType(ref, f.MapValueKind)
}
}
if valueType == "" {
valueType = "bytes"
}
return fmt.Sprintf("map<%s, %s>", keyType, valueType)
}
return "bytes" // 未识别字段类型时回退为字节串。
}
+423
View File
@@ -0,0 +1,423 @@
// resolver.go 解析压缩 bundle 中的局部符号、模块别名和导出别名。
package main
import (
"regexp"
"sort"
"strings"
)
// newTypeResolver 建立消息、枚举和模块别名的统一索引。
func newTypeResolver(messages []Message, enums []Enum, aliases aliasIndex, exportAliases aliasIndex) *TypeResolver {
resolver := &TypeResolver{
bySymbol: make(map[string][]symbolDef),
byAlias: make(map[string][]symbolDef),
byShort: make(map[string][]symbolDef),
}
add := func(symbol, typeName string, pos int, moduleStart int, kind string) {
symbol = strings.TrimSpace(symbol)
typeName = strings.TrimSpace(typeName)
if symbol == "" || typeName == "" {
return
}
def := symbolDef{TypeName: typeName, Pos: pos, ModuleStart: moduleStart, Kind: kind}
resolver.bySymbol[symbol] = append(resolver.bySymbol[symbol], def)
_, shortName := parseTypeName(typeName)
if shortName != "" {
resolver.byShort[shortName] = append(resolver.byShort[shortName], def)
underscoreAlias := strings.ReplaceAll(shortName, ".", "_")
if underscoreAlias != shortName {
resolver.byShort[underscoreAlias] = append(resolver.byShort[underscoreAlias], def)
}
if idx := strings.LastIndex(shortName, "."); idx > 0 && idx+1 < len(shortName) {
resolver.byShort[shortName[idx+1:]] = append(resolver.byShort[shortName[idx+1:]], def)
}
if idx := strings.LastIndex(underscoreAlias, "_"); idx > 0 && idx+1 < len(underscoreAlias) {
resolver.byShort[underscoreAlias[idx+1:]] = append(resolver.byShort[underscoreAlias[idx+1:]], def)
}
}
}
addAlias := func(symbol, typeName string, pos int, moduleStart int, kind string) {
symbol = strings.TrimSpace(symbol)
typeName = strings.TrimSpace(typeName)
if symbol == "" || typeName == "" {
return
}
resolver.byAlias[symbol] = append(resolver.byAlias[symbol], symbolDef{
TypeName: typeName, Pos: pos, ModuleStart: moduleStart, Kind: kind,
})
}
for _, msg := range messages {
add(msg.VarName, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
if msg.InternalName != "" && msg.InternalName != msg.VarName {
add(msg.InternalName, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
}
for _, alias := range aliasesForSymbols(aliases[msg.ModuleStart], msg.VarName, msg.InternalName) {
addAlias(alias, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
}
}
for _, enum := range enums {
add(enum.VarName, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
for _, alias := range aliasesForSymbols(aliases[enum.ModuleStart], enum.VarName) {
addAlias(alias, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
}
}
for _, msg := range messages {
for _, alias := range aliasesForSymbols(exportAliases[msg.ModuleStart], msg.VarName, msg.InternalName) {
addAlias(alias, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
}
}
for _, enum := range enums {
for _, alias := range aliasesForSymbols(exportAliases[enum.ModuleStart], enum.VarName) {
addAlias(alias, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
}
}
return resolver
}
// buildAliasIndex 提取变量声明和赋值形成的局部别名。
func buildAliasIndex(text string, moduleStarts []int) aliasIndex {
directByModule := make(map[int]map[string]string)
addMatches := func(matches [][]int) {
for _, match := range matches {
alias := strings.TrimSpace(text[match[2]:match[3]])
target := strings.TrimSpace(text[match[4]:match[5]])
if alias == "" || target == "" || alias == target {
continue
}
moduleStart := moduleStartForPos(moduleStarts, match[0])
if directByModule[moduleStart] == nil {
directByModule[moduleStart] = make(map[string]string)
}
directByModule[moduleStart][alias] = target
}
}
addMatches(varAliasRe.FindAllStringSubmatchIndex(text, -1))
addMatches(assignmentAliasRe.FindAllStringSubmatchIndex(text, -1))
resolveRoot := func(direct map[string]string, symbol string) string {
seen := make(map[string]bool)
current := symbol
for {
if seen[current] {
return symbol
}
seen[current] = true
next := direct[current]
if next == "" {
return current
}
current = next
}
}
aliasSets := make(map[int]map[string]map[string]bool)
addAlias := func(moduleStart int, root string, alias string) {
root = strings.TrimSpace(root)
alias = strings.TrimSpace(alias)
if root == "" || alias == "" || root == alias {
return
}
if aliasSets[moduleStart] == nil {
aliasSets[moduleStart] = make(map[string]map[string]bool)
}
if aliasSets[moduleStart][root] == nil {
aliasSets[moduleStart][root] = make(map[string]bool)
}
aliasSets[moduleStart][root][alias] = true
}
for moduleStart, direct := range directByModule {
for alias := range direct {
root := resolveRoot(direct, alias)
addAlias(moduleStart, root, alias)
}
}
if len(aliasSets) == 0 {
return nil
}
aliases := make(aliasIndex, len(aliasSets))
for moduleStart, roots := range aliasSets {
aliases[moduleStart] = make(map[string][]string, len(roots))
for root, set := range roots {
for alias := range set {
aliases[moduleStart][root] = append(aliases[moduleStart][root], alias)
}
sort.Strings(aliases[moduleStart][root])
}
}
return aliases
}
// buildWebpackExportAliasIndex 提取 Webpack 导出表中的符号别名。
func buildWebpackExportAliasIndex(text string, moduleStarts []int) aliasIndex {
aliasSets := make(map[int]map[string]map[string]bool)
addAlias := func(moduleStart int, root string, alias string) {
root = strings.TrimSpace(root)
alias = strings.TrimSpace(alias)
if root == "" || alias == "" || root == alias {
return
}
if aliasSets[moduleStart] == nil {
aliasSets[moduleStart] = make(map[string]map[string]bool)
}
if aliasSets[moduleStart][root] == nil {
aliasSets[moduleStart][root] = make(map[string]bool)
}
aliasSets[moduleStart][root][alias] = true
}
// Webpack 通过 n.d(t, { KS: () => T }) 暴露成员;服务使用 r.KS,消息定义使用局部符号 T。
for _, blockMatch := range webpackExportBlockRe.FindAllStringIndex(text, -1) {
moduleStart := moduleStartForPos(moduleStarts, blockMatch[0])
blockStart := blockMatch[1] - 1
blockEnd := findMatchingBrace(text, blockStart)
if blockEnd == -1 {
continue
}
block := text[blockStart:blockEnd]
for _, entry := range webpackExportEntryRe.FindAllStringSubmatch(block, -1) {
addAlias(moduleStart, entry[2], entry[1])
}
}
if len(aliasSets) == 0 {
return nil
}
aliases := make(aliasIndex, len(aliasSets))
for moduleStart, roots := range aliasSets {
aliases[moduleStart] = make(map[string][]string, len(roots))
for root, set := range roots {
for alias := range set {
aliases[moduleStart][root] = append(aliases[moduleStart][root], alias)
}
sort.Strings(aliases[moduleStart][root])
}
}
return aliases
}
// aliasesForSymbols 返回目标符号集合对应的去重别名。
func aliasesForSymbols(aliases map[string][]string, symbols ...string) []string {
if len(aliases) == 0 {
return nil
}
seen := make(map[string]bool)
var result []string
for _, symbol := range symbols {
for _, alias := range aliases[strings.TrimSpace(symbol)] {
if alias == "" || seen[alias] {
continue
}
seen[alias] = true
result = append(result, alias)
}
}
sort.Strings(result)
return result
}
// looksLikeFullTypeName 判断引用是否已经是全限定协议类型名。
func looksLikeFullTypeName(ref string) bool {
trimmed := strings.TrimSpace(ref)
if strings.HasPrefix(trimmed, "google.protobuf.") || strings.HasPrefix(trimmed, "google.rpc.") {
return true
}
matched, _ := regexp.MatchString(`^[\w.]+\.v\d+\.[\w.]+$`, trimmed)
return matched
}
// pickBestDefinition 按模块、类别、首选包和源码距离选择定义。
func pickBestDefinition(candidates []symbolDef, contextPos int, contextModuleStart int, preferredPkg string, expectedKind string) (symbolDef, bool) {
if len(candidates) == 0 {
return symbolDef{}, false
}
filtered := candidates
if strings.TrimSpace(expectedKind) != "" {
tmp := make([]symbolDef, 0, len(candidates))
for _, item := range candidates {
if item.Kind == expectedKind {
tmp = append(tmp, item)
}
}
if len(tmp) > 0 {
filtered = tmp
}
}
if strings.TrimSpace(preferredPkg) != "" {
tmp := make([]symbolDef, 0, len(filtered))
for _, item := range filtered {
pkg, _ := parseTypeName(item.TypeName)
if pkg == preferredPkg {
tmp = append(tmp, item)
}
}
if len(tmp) > 0 {
filtered = tmp
}
}
if contextModuleStart > 0 {
tmp := make([]symbolDef, 0, len(filtered))
for _, item := range filtered {
if item.ModuleStart == contextModuleStart {
tmp = append(tmp, item)
}
}
if len(tmp) > 0 {
filtered = tmp
}
}
// 选择绝对距离最近的定义,距离相同时优先前向定义。
bestIndex := -1
bestDistance := 0
bestIsFuture := false
for index, item := range filtered {
distance := absInt(item.Pos - contextPos)
isFuture := item.Pos > contextPos
if bestIndex == -1 {
bestIndex = index
bestDistance = distance
bestIsFuture = isFuture
continue
}
if distance < bestDistance {
bestIndex = index
bestDistance = distance
bestIsFuture = isFuture
continue
}
if distance == bestDistance {
// 距离相同时优先当前位置之前的定义。
if bestIsFuture && !isFuture {
bestIndex = index
bestIsFuture = isFuture
}
}
}
if bestIndex < 0 {
return symbolDef{}, false
}
return filtered[bestIndex], true
}
// ResolveTypeName 把局部变量、别名或短名称解析为全限定类型名。
func (resolver *TypeResolver) ResolveTypeName(ref string, contextPos int, contextModuleStart int, preferredPkg string, expectedKind string) (string, bool) {
if resolver == nil {
return "", false
}
trimmed := strings.TrimSpace(ref)
if trimmed == "" {
return "", false
}
if looksLikeFullTypeName(trimmed) {
return trimmed, true
}
resolveBySymbol := func(symbol string, preferSameModule bool) (string, bool) {
candidates := resolver.bySymbol[symbol]
if len(candidates) == 0 {
return "", false
}
moduleStart := 0
if preferSameModule {
moduleStart = contextModuleStart
}
best, ok := pickBestDefinition(candidates, contextPos, moduleStart, preferredPkg, expectedKind)
if !ok {
return "", false
}
return best.TypeName, true
}
resolveByAlias := func(symbol string, targetModuleStart int) (string, bool) {
candidates := resolver.byAlias[symbol]
if len(candidates) == 0 {
return "", false
}
best, ok := pickBestDefinition(candidates, contextPos, targetModuleStart, preferredPkg, expectedKind)
if !ok {
return "", false
}
return best.TypeName, true
}
resolveByShort := func(symbol string, preferSameModule bool) (string, bool) {
candidates := resolver.byShort[symbol]
if len(candidates) == 0 {
return "", false
}
moduleStart := 0
if preferSameModule {
moduleStart = contextModuleStart
}
best, ok := pickBestDefinition(candidates, contextPos, moduleStart, preferredPkg, expectedKind)
if !ok {
return "", false
}
return best.TypeName, true
}
if typeName, ok := resolveBySymbol(trimmed, !strings.Contains(trimmed, ".")); ok {
return typeName, true
}
if typeName, ok := resolveByAlias(trimmed, 0); ok {
return typeName, true
}
if typeName, ok := resolveByShort(trimmed, !strings.Contains(trimmed, ".")); ok {
return typeName, true
}
if strings.Contains(trimmed, ".") {
parts := strings.Split(trimmed, ".")
first := parts[0]
last := parts[len(parts)-1]
targetModuleStart := 0
if imports := resolver.moduleImports[contextModuleStart]; imports != nil {
targetModuleStart = imports[first]
}
if typeName, ok := resolveByAlias(last, targetModuleStart); ok {
return typeName, true
}
if typeName, ok := resolveBySymbol(last, false); ok {
return typeName, true
}
if typeName, ok := resolveByShort(last, false); ok {
return typeName, true
}
if typeName, ok := resolveBySymbol(first, false); ok {
return typeName, true
}
}
return "", false
}
// fallbackTypeToken 从无法解析的引用生成合法类型占位名。
func fallbackTypeToken(ref string) string {
token := strings.TrimSpace(ref)
if token == "" {
return token
}
if strings.Contains(token, ".") {
parts := strings.Split(token, ".")
return parts[len(parts)-1]
}
return token
}
// absInt 返回整数绝对值。
func absInt(value int) int {
if value < 0 {
return -value
}
return value
}
+190
View File
@@ -0,0 +1,190 @@
// services.go 解析枚举、服务方法和压缩对象的配对括号。
package main
import (
"regexp"
"strconv"
)
// extractEnums 从旧式和工厂式声明中提取枚举。
func extractEnums(text string, moduleStarts []int) []Enum {
var enums []Enum
enumExists := func(typeName, varName string) bool {
for _, existing := range enums {
if existing.TypeName == typeName && existing.VarName == varName {
return true
}
}
return false
}
// 匹配任意包中的 setEnumType(XXX, "xxx.v1.EnumName", [...]) 枚举声明。
// JS 变量名可以包含 $ 符号
enumRe := regexp.MustCompile(`setEnumType\s*\(\s*([\w$]+)\s*,\s*"([\w.]+)"\s*,\s*\[`)
matches := enumRe.FindAllStringSubmatchIndex(text, -1)
for _, match := range matches {
varName := text[match[2]:match[3]]
typeName := text[match[4]:match[5]]
// 提取枚举值数组。
bracketStart := match[1] - 1
values := extractEnumValues(text, bracketStart)
pkg, shortName := parseTypeName(typeName)
enum := Enum{
TypeName: typeName,
VarName: varName,
Values: values,
Package: pkg,
ShortName: shortName,
Pos: match[0],
ModuleStart: moduleStartForPos(moduleStarts, match[0]),
}
enums = append(enums, enum)
}
// 匹配现代 @bufbuild/protobuf 工厂形式,例如 Role=A.makeEnum("aiserver.v1.InferenceMessageRole",[{...}])。
enumFactoryRe := regexp.MustCompile(`([\w$]+)\s*=\s*[\w$.]+\.makeEnum\s*\(\s*["']([\w.]+)["']\s*,\s*\[`)
factoryMatches := enumFactoryRe.FindAllStringSubmatchIndex(text, -1)
for _, match := range factoryMatches {
varName := text[match[2]:match[3]]
typeName := text[match[4]:match[5]]
if enumExists(typeName, varName) {
continue
}
bracketStart := match[1] - 1
if bracketStart < 0 || bracketStart >= len(text) || text[bracketStart] != '[' {
continue
}
pkg, shortName := parseTypeName(typeName)
enums = append(enums, Enum{
TypeName: typeName,
VarName: varName,
Values: extractEnumValues(text, bracketStart),
Package: pkg,
ShortName: shortName,
Pos: match[0],
ModuleStart: moduleStartForPos(moduleStarts, match[0]),
})
}
return enums
}
// extractServices 从命名或匿名描述符中提取服务。
func extractServices(text string, moduleStarts []int) []Service {
var services []Service
seenTypeNames := make(map[string]bool)
appendService := func(varName, typeName string, pos, methodsStart int) {
if seenTypeNames[typeName] {
return
}
methodsEnd := findMatchingBrace(text, methodsStart)
if methodsEnd == -1 {
return
}
pkg, shortName := parseTypeName(typeName)
services = append(services, Service{
TypeName: typeName,
VarName: varName,
Methods: extractMethods(text[methodsStart:methodsEnd]),
Package: pkg,
ShortName: shortName,
Pos: pos,
ModuleStart: moduleStartForPos(moduleStarts, pos),
})
seenTypeNames[typeName] = true
}
// 匹配 VarName = { typeName: "xxx.v1.ServiceName", methods: { ... } } 服务对象。
serviceRe := regexp.MustCompile(`([\w$]+)\s*=\s*\{\s*typeName:\s*"([\w.]+)"\s*,\s*methods:\s*\{`)
matches := serviceRe.FindAllStringSubmatchIndex(text, -1)
for _, match := range matches {
varName := text[match[2]:match[3]]
typeName := text[match[4]:match[5]]
appendService(varName, typeName, match[0], match[1]-1)
}
// 部分 bundle 把服务描述符直接放入数组,不预先赋给变量。
anonymousServiceRe := regexp.MustCompile(`\{\s*typeName:\s*["']([\w.]+)["']\s*,\s*methods:\s*\{`)
for _, match := range anonymousServiceRe.FindAllStringSubmatchIndex(text, -1) {
typeName := text[match[2]:match[3]]
appendService("", typeName, match[0], match[1]-1)
}
return services
}
// extractMethods 解析服务对象中的 RPC 方法列表。
func extractMethods(methodsText string) []Method {
var methods []Method
// 匹配包含方法名、输入、输出和调用类型的方法对象。
methodRe := regexp.MustCompile(`\w+:\s*\{\s*name:\s*"([^"]+)"\s*,\s*I:\s*([\w$.]+)\s*,\s*O:\s*([\w$.]+)\s*,\s*kind:\s*[\w$.]+\.(Unary|ServerStreaming|ClientStreaming|BiDiStreaming)`)
matches := methodRe.FindAllStringSubmatch(methodsText, -1)
for _, m := range matches {
method := Method{
Name: m[1],
InputType: m[2],
OutputType: m[3],
Kind: m[4],
}
methods = append(methods, method)
}
return methods
}
// findMatchingBrace 查找花括号块的结束位置。
func findMatchingBrace(text string, start int) int {
depth := 0
for i := start; i < len(text); i++ {
if text[i] == '{' {
depth++
} else if text[i] == '}' {
depth--
if depth == 0 {
return i + 1
}
}
}
return -1
}
// extractEnumValues 从数组起点解析枚举值。
func extractEnumValues(text string, start int) []EnumValue {
// 查找数组的配对结束括号。
depth := 0
end := start
for i := start; i < len(text); i++ {
if text[i] == '[' {
depth++
} else if text[i] == ']' {
depth--
if depth == 0 {
end = i + 1
break
}
}
}
arrayText := text[start:end]
var values []EnumValue
valueRe := regexp.MustCompile(`\{\s*no:\s*(\d+)\s*,\s*name:\s*"([^"]+)"`)
matches := valueRe.FindAllStringSubmatch(arrayText, -1)
for _, m := range matches {
no, _ := strconv.Atoi(m[1])
values = append(values, EnumValue{No: no, Name: m[2]})
}
return values
}
+260
View File
@@ -0,0 +1,260 @@
// types.go 定义协议提取器的领域结构、诊断状态和基础类型映射。
package main
import (
"fmt"
"regexp"
"strings"
)
// isGooglePkg 判断是否为无需重复生成的 Google 标准包。
func isGooglePkg(pkg string) bool {
return pkg == "google.protobuf" || pkg == "google.rpc"
}
// scalarTypes 把运行时标量编号映射为 proto 类型。
var scalarTypes = map[int]string{
1: "double",
2: "float",
3: "int64",
4: "uint64",
5: "int32",
6: "fixed64",
7: "fixed32",
8: "bool",
9: "string",
12: "bytes",
13: "uint32",
15: "sfixed32",
16: "sfixed64",
17: "sint32",
18: "sint64",
}
// strictExtractionValidation 控制校验失败是否终止提取。
var strictExtractionValidation = true
// extractionDiagnostics 汇总字段解析和类型解析诊断。
type extractionDiagnostics struct {
totalFieldObjects int
parsedFieldObjects int
skippedFieldObjects int
skippedFieldSamples []string
unresolvedTypeRefs map[string]int
emptyMessages []string
placeholderHits []string
declaredTypes int
extractedTypes int
missingDeclarations []string
}
// newExtractionDiagnostics 创建一次提取任务的诊断容器。
func newExtractionDiagnostics() *extractionDiagnostics {
return &extractionDiagnostics{
unresolvedTypeRefs: make(map[string]int),
}
}
// addSkippedField 记录未能解析的字段样本和原因。
func (d *extractionDiagnostics) addSkippedField(fieldObject string, reason error) {
if d == nil {
return
}
d.totalFieldObjects++
d.skippedFieldObjects++
if len(d.skippedFieldSamples) < 20 {
trimmed := strings.TrimSpace(fieldObject)
if len(trimmed) > 140 {
trimmed = trimmed[:140] + "..."
}
if reason != nil {
d.skippedFieldSamples = append(d.skippedFieldSamples, fmt.Sprintf("%s | %s", reason.Error(), trimmed))
} else {
d.skippedFieldSamples = append(d.skippedFieldSamples, trimmed)
}
}
}
// addParsedField 累计成功解析的字段数量。
func (d *extractionDiagnostics) addParsedField() {
if d == nil {
return
}
d.totalFieldObjects++
d.parsedFieldObjects++
}
// addUnresolvedType 按引用名称累计类型解析失败次数。
func (d *extractionDiagnostics) addUnresolvedType(ref string) {
if d == nil {
return
}
key := strings.TrimSpace(ref)
if key == "" {
key = "<empty>"
}
d.unresolvedTypeRefs[key]++
}
// SetStrictMode 设置校验失败是否终止提取。
func SetStrictMode(enabled bool) {
strictExtractionValidation = enabled
}
// activeDiagnostics 指向当前提取任务的诊断状态。
var activeDiagnostics *extractionDiagnostics
// 字段解析正则覆盖压缩 bundle 的各类声明形式。
var (
noRe = regexp.MustCompile(`(?:^|[,{]\s*)no:\s*(\d+)`)
nameRe = regexp.MustCompile(`(?:^|[,{]\s*)name:\s*["']([^"']+)["']`)
kindRe = regexp.MustCompile(`(?:^|[,{]\s*)kind:\s*["']([^"']+)["']`)
enumTypeRe = regexp.MustCompile(`[,\s]T:\s*[\w$.]+\.getEnumType\s*\(\s*([\w$.]+)\s*\)`)
tRe = regexp.MustCompile(`[,\s]T:\s*([\w$.]+)`)
oneofRe = regexp.MustCompile(`oneof:\s*["']([^"']+)["']`)
repeatedRe = regexp.MustCompile(`repeated:\s*(!0|true)`)
optRe = regexp.MustCompile(`opt:\s*(!0|true)`)
keyRe = regexp.MustCompile(`[,\s]K:\s*(\d+)`)
mapValueRe = regexp.MustCompile(`V:\s*\{([^}]*)\}`)
mapValueKRe = regexp.MustCompile(`(?:^|[,{]\s*)kind:\s*["'](\w+)["']`)
mapValueTRe = regexp.MustCompile(`[,\s]T:\s*([\w$.]+)`)
shorthandTRe = regexp.MustCompile(`(?:^|[,\{])\s*T\s*(?:[,\}])`)
oneofNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
fieldNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
placeholderRe = regexp.MustCompile(`^\s*(optional\s+|repeated\s+)?[A-Za-z_][A-Za-z0-9_.<>]*\s+(field_\d+|unknown(?:_[A-Za-z0-9_]+)?)\s*=\s*\d+\s*;`)
varAliasRe = regexp.MustCompile(`\b(?:let|const|var)\s+([\w$]+)\s*=\s*([\w$]+)\s*(?:[,;])`)
assignmentAliasRe = regexp.MustCompile(`(?:^|[;,({])\s*([\w$]+)\s*=\s*([\w$]+)\s*([,;}])`)
webpackExportBlockRe = regexp.MustCompile(`[\w$]+\.d\(\s*[\w$]+\s*,\s*\{`)
webpackExportEntryRe = regexp.MustCompile(`(?:^|[,\{])\s*([\w$]+)\s*:\s*\(\s*\)\s*=>\s*([\w$]+)`)
moduleImportRe = regexp.MustCompile(`(?:\b(?:var|let|const)\s+|,)\s*([\w$]+)\s*=\s*[\w$]+\(\s*(\d+)\s*\)`)
typeNameDeclarationRe = regexp.MustCompile(`(?:\bthis|[\w$]+)\.typeName\s*=\s*["']([\w.]+)["']`)
serviceDeclarationRe = regexp.MustCompile(`\{\s*typeName\s*:\s*["']([\w.]+)["']\s*,\s*methods\s*:`)
messageDeclarationRe = regexp.MustCompile(`\.makeMessageType\s*\(\s*["']([\w.]+)["']`)
enumDeclarationRe = regexp.MustCompile(`\.makeEnum\s*\(\s*["']([\w.]+)["']`)
legacyEnumDeclarationRe = regexp.MustCompile(`\.setEnumType\s*\(\s*[\w$]+\s*,\s*["']([\w.]+)["']`)
streamCloseRe = regexp.MustCompile(`(?s)message\s+ExecClientControlMessage\s*\{.*?ExecClientStreamClose\s+stream_close\s*=\s*1\s*;`)
shellStdoutRe = regexp.MustCompile(`(?s)message\s+ShellStream\s*\{.*?ShellStreamStdout\s+stdout\s*=\s*1\s*;`)
)
// Field 描述一个待渲染的 protobuf 字段。
type Field struct {
// No 是字段编号。
No int `json:"no"`
// Name 是字段名称。
Name string `json:"name"`
// Kind 是标量、消息、枚举或映射类别。
Kind string `json:"kind"`
// T 保存标量编号或消息引用变量。
T any `json:"T"`
// Oneof 是字段所属的互斥分组。
Oneof string `json:"oneof"`
// Repeated 表示字段可以重复。
Repeated bool `json:"repeated"`
// Opt 表示字段为显式可选。
Opt bool `json:"opt"`
// MapKey 是映射键的标量编号。
MapKey int `json:"K"`
// MapValueKind 是映射值的标量或消息类别。
MapValueKind string
// MapValueT 保存映射值的标量编号或消息引用。
MapValueT any
}
// Message 描述提取出的消息及其源码位置。
type Message struct {
// TypeName 是消息的全限定类型名。
TypeName string
// VarName 是 JS 外部变量名。
VarName string
// InternalName 是 JS 内部类名。
InternalName string
// Fields 是消息字段列表。
Fields []Field
// Package 是消息所属协议包。
Package string
// ShortName 是包内嵌套类型名。
ShortName string
// Pos 是消息在 bundle 中的字节位置。
Pos int
// ModuleStart 是消息所在模块的起始位置。
ModuleStart int
}
// Enum 描述提取出的枚举及其源码位置。
type Enum struct {
// TypeName 是枚举的全限定类型名。
TypeName string
// VarName 是枚举对应的 JS 变量名。
VarName string
// Values 是枚举值列表。
Values []EnumValue
// Package 是枚举所属协议包。
Package string
// ShortName 是包内嵌套类型名。
ShortName string
// Pos 是枚举在 bundle 中的字节位置。
Pos int
// ModuleStart 是枚举所在模块的起始位置。
ModuleStart int
}
// EnumValue 描述单个枚举编号和名称。
type EnumValue struct {
// No 是枚举编号。
No int
// Name 是枚举名称。
Name string
}
// Service 描述提取出的服务及其源码位置。
type Service struct {
// TypeName 是服务的全限定类型名。
TypeName string
// VarName 是服务对应的 JS 变量名。
VarName string
// Methods 是服务方法列表。
Methods []Method
// Package 是服务所属协议包。
Package string
// ShortName 是服务包内名称。
ShortName string
// Pos 是服务在 bundle 中的字节位置。
Pos int
// ModuleStart 是服务所在模块的起始位置。
ModuleStart int
}
// Method 描述一个 RPC 方法的输入、输出和流模式。
type Method struct {
// Name 是 RPC 方法名。
Name string
// InputType 是输入消息引用变量。
InputType string
// OutputType 是输出消息引用变量。
OutputType string
// Kind 是一元或不同方向的流式调用类型。
Kind string
}
// symbolDef 保存符号对应的类型、类别和模块位置。
type symbolDef struct {
// TypeName 是符号对应的全限定类型名。
TypeName string
// Pos 是符号定义位置。
Pos int
// Kind 是消息或枚举类别。
Kind string
// ModuleStart 是符号所在模块起点。
ModuleStart int
}
// TypeResolver 通过局部符号、别名和短名称解析协议类型。
type TypeResolver struct {
bySymbol map[string][]symbolDef
byAlias map[string][]symbolDef
byShort map[string][]symbolDef
moduleImports map[int]map[string]int
}
// aliasIndex 按模块和目标符号保存别名集合。
type aliasIndex map[int]map[string][]string
+15
View File
@@ -0,0 +1,15 @@
module github.com/leookun/cursor-byok/cursor-proto
go 1.25.8
require (
github.com/jhump/protoreflect v1.18.0
google.golang.org/protobuf v1.36.11
)
require (
github.com/golang/protobuf v1.5.4 // indirect
github.com/jhump/protoreflect/v2 v2.0.0-beta.1 // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
golang.org/x/sync v0.8.0 // indirect
)
+34
View File
@@ -0,0 +1,34 @@
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/jhump/protoreflect v1.18.0 h1:TOz0MSR/0JOZ5kECB/0ufGnC2jdsgZ123Rd/k4Z5/2w=
github.com/jhump/protoreflect v1.18.0/go.mod h1:ezWcltJIVF4zYdIFM+D/sHV4Oh5LNU08ORzCGfwvTz8=
github.com/jhump/protoreflect/v2 v2.0.0-beta.1 h1:Dw1rslK/VotaUGYsv53XVWITr+5RCPXfvvlGrM/+B6w=
github.com/jhump/protoreflect/v2 v2.0.0-beta.1/go.mod h1:D9LBEowZyv8/iSu97FU2zmXG3JxVTmNw21mu63niFzU=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# extract.sh 从 Cursor 安装目录安全提取 Proto 文件。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
INSTALLED_CURSOR_DEFAULT="/Applications/Cursor.app"
INPUT_DEFAULT="$INSTALLED_CURSOR_DEFAULT"
OUTPUT_DEFAULT="$PROJECT_DIR/proto"
INPUT_ROOT="${1:-$INPUT_DEFAULT}"
OUTPUT_DIR="${2:-$OUTPUT_DEFAULT}"
canonicalize_path() {
local path="$1"
local parent
local base
if [[ -d "$path" ]]; then
(cd "$path" && pwd -P)
return
fi
parent="$(dirname "$path")"
base="$(basename "$path")"
if [[ ! -d "$parent" ]]; then
echo "Parent directory does not exist: $parent" >&2
return 1
fi
printf '%s/%s\n' "$(cd "$parent" && pwd -P)" "$base"
}
# 文件输入只提取自身;目录输入扫描工作台、扩展宿主和扩展产物。
INPUT_PATHS=()
add_input() {
local candidate="$1"
local existing
if [[ ! -f "$candidate" ]]; then
return 0
fi
for existing in "${INPUT_PATHS[@]-}"; do
[[ "$existing" == "$candidate" ]] && return
done
INPUT_PATHS+=("$candidate")
}
if [[ -f "$INPUT_ROOT" ]]; then
add_input "$INPUT_ROOT"
elif [[ -d "$INPUT_ROOT" ]]; then
CANDIDATES=(
"$INPUT_ROOT/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js"
"$INPUT_ROOT/Resources/app/out/vs/workbench/workbench.desktop.main.js"
"$INPUT_ROOT/out/vs/workbench/workbench.desktop.main.js"
"$INPUT_ROOT/workbench.desktop.main.js"
"$INPUT_ROOT/Contents/Resources/app/out/vs/workbench/api/node/extensionHostProcess.js"
"$INPUT_ROOT/Resources/app/out/vs/workbench/api/node/extensionHostProcess.js"
"$INPUT_ROOT/out/vs/workbench/api/node/extensionHostProcess.js"
"$INPUT_ROOT/extensionHostProcess.js"
"$INPUT_ROOT/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
"$INPUT_ROOT/Resources/app/extensions/cursor-always-local/dist/main.js"
"$INPUT_ROOT/extensions/cursor-always-local/dist/main.js"
"$INPUT_ROOT/cursor-always-local/dist/main.js"
)
for CANDIDATE in "${CANDIDATES[@]}"; do
add_input "$CANDIDATE"
done
while IFS= read -r JS_FILE; do
add_input "$JS_FILE"
done < <(find "$INPUT_ROOT" -type f ! -path "*/node_modules/*" \( -name "workbench.desktop.main.js" -o -name "extensionHostProcess.js" -o -path "*/extensions/*/dist/main.js" \) | sort)
fi
if [[ -z "${INPUT_PATHS[*]-}" ]]; then
echo "No supported Cursor JS bundle found under: $INPUT_ROOT" >&2
echo "Install/update Cursor, or pass an explicit input bundle:" >&2
echo " $0 /path/to/Cursor.app [output-dir]" >&2
exit 1
fi
for INDEX in "${!INPUT_PATHS[@]}"; do
INPUT_PATHS[$INDEX]="$(canonicalize_path "${INPUT_PATHS[$INDEX]}")"
done
OUTPUT_DIR="$(canonicalize_path "$OUTPUT_DIR")"
CURRENT_DIR="$(pwd -P)"
case "$OUTPUT_DIR" in
"/"|"$HOME"|"$PROJECT_DIR"|"$SCRIPT_DIR"|"$CURRENT_DIR")
echo "Refusing unsafe output directory: $OUTPUT_DIR" >&2
exit 1
;;
esac
for INPUT_PATH in "${INPUT_PATHS[@]}"; do
case "$INPUT_PATH" in
"$OUTPUT_DIR"|"$OUTPUT_DIR"/*)
echo "Refusing output directory that contains an input bundle: $OUTPUT_DIR" >&2
exit 1
;;
esac
done
OUTPUT_PARENT="$(dirname "$OUTPUT_DIR")"
OUTPUT_BASENAME="$(basename "$OUTPUT_DIR")"
TEMP_DIR="$(mktemp -d "$OUTPUT_PARENT/.${OUTPUT_BASENAME}.tmp.XXXXXX")"
BACKUP_DIR=""
cleanup() {
if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then
rm -rf "$TEMP_DIR"
fi
if [[ -n "$BACKUP_DIR" && -e "$BACKUP_DIR" ]]; then
if [[ ! -e "$OUTPUT_DIR" ]]; then
mv "$BACKUP_DIR" "$OUTPUT_DIR"
else
rm -rf "$BACKUP_DIR"
fi
fi
}
trap cleanup EXIT
EXTRACT_ARGS=()
for INPUT_PATH in "${INPUT_PATHS[@]}"; do
EXTRACT_ARGS+=( -input "$INPUT_PATH" )
done
(
cd "$PROJECT_DIR"
go run ./extractor \
"${EXTRACT_ARGS[@]}" \
-output "$TEMP_DIR" \
-skip-format \
-strict
)
if [[ -e "$OUTPUT_DIR" ]]; then
BACKUP_DIR="$(mktemp -d "$OUTPUT_PARENT/.${OUTPUT_BASENAME}.backup.XXXXXX")"
rmdir "$BACKUP_DIR"
mv "$OUTPUT_DIR" "$BACKUP_DIR"
fi
mv "$TEMP_DIR" "$OUTPUT_DIR"
TEMP_DIR=""
if [[ -n "$BACKUP_DIR" ]]; then
rm -rf "$BACKUP_DIR"
BACKUP_DIR=""
fi
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# generate.sh 根据提取的 Proto 定义生成可供其他 Go module 使用的消息包。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
PROTO_DIR="$PROJECT_DIR/proto"
MODULE_PATH="github.com/leookun/cursor-byok/cursor-proto"
command -v protoc >/dev/null 2>&1 || {
echo "protoc is required" >&2
exit 1
}
command -v protoc-gen-go >/dev/null 2>&1 || {
echo "protoc-gen-go is required" >&2
exit 1
}
for PROTO_FILE in agent_v1.proto aiserver_v1.proto; do
if [[ ! -f "$PROTO_DIR/$PROTO_FILE" ]]; then
echo "Missing Proto source: $PROTO_DIR/$PROTO_FILE" >&2
exit 1
fi
done
protoc \
--proto_path="$PROTO_DIR" \
--go_out="$PROJECT_DIR" \
--go_opt="module=$MODULE_PATH" \
"$PROTO_DIR/agent_v1.proto" \
"$PROTO_DIR/aiserver_v1.proto"
echo "Generated Go packages under: $PROJECT_DIR/gen"
-60
View File
@@ -1,60 +0,0 @@
# Cursor Protocol Debugger
[中文](README.md) | [English](README.en.md)
This standalone local HTTPS debugging proxy captures Cursor's `BidiAppend`, `RunSSE`, and Fork Chat traffic. It does not modify Cursor, the system proxy, or the installed client.
## Start
Run the following command from the repository root:
```bash
go run ./cmd/cursor-proxy-debugger
```
Default addresses:
- HTTP/HTTPS proxy: `127.0.0.1:9090`
- Debugging UI: `http://127.0.0.1:9091`
- MITM target: `api2.cursor.sh`
The debugging UI opens automatically after startup.
## Configure Cursor
The tool does not modify Cursor automatically. After starting it, configure Cursor manually:
1. Open Cursor's proxy settings and set the proxy to the address printed by the tool. The default is `http://127.0.0.1:9090`.
2. Open Cursor's Network settings and enable HTTP/1.1.
3. Download the proxy CA certificate from `http://127.0.0.1:9091/api/ca.crt` and make sure Cursor trusts it.
Restore the original Cursor proxy and Network settings after debugging to avoid affecting normal network requests.
## Build
```bash
go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
```
## Options
```text
-proxy-addr Proxy listen address; default: 127.0.0.1:9090
-ui-addr Debugging UI listen address; default: 127.0.0.1:9091
-target-host Host to decrypt and capture; default: api2.cursor.sh
-max-exchanges Maximum number of exchanges retained in memory; default: 200
-open Open the browser after startup; default: true
```
## Data Handling
- HTTPS MITM is applied only to `target-host`; other CONNECT traffic passes through unchanged.
- `RunSSE` is decoded incrementally using the 5-byte Connect frame header and supports per-frame gzip decompression.
- `BidiAppendRequest.data` is further decoded as `agent.v1.AgentClientMessage`.
- Fork Chat's `ForkBackgroundComposer`, `NotifyConversationClone`, and `UploadConversationBlobs` traffic is decoded bidirectionally as protobuf JSON.
- Local Fork Chat is primarily client-side and only emits `NotifyConversationClone` and `UploadConversationBlobs` when clone blob synchronization is enabled and privacy settings allow it.
- Requests can be sorted chronologically or in reverse chronological order and filtered by protocol `request_id`.
- The UI supports Simplified Chinese and English, follows the browser language, and remembers a manual selection.
- Captured traffic is stored only in process memory and is discarded when the process exits.
- Sensitive HTTP headers such as `Authorization`, `Cookie`, and `Set-Cookie` are hidden in the UI by default.
- Raw bodies are retained up to 2 MiB per side by default; forwarded traffic is never truncated.
-60
View File
@@ -1,60 +0,0 @@
# Cursor 协议调试器
[中文](README.md) | [English](README.en.md)
这是一个独立运行的本地 HTTPS 调试代理,用于观察 Cursor 的 `BidiAppend``RunSSE` 和 Fork Chat 相关通信。它不会修改 Cursor、系统代理或已安装客户端。
## 启动
在仓库根目录运行:
```bash
go run ./cmd/cursor-proxy-debugger
```
默认监听:
- HTTP/HTTPS 代理:`127.0.0.1:9090`
- 调试界面:`http://127.0.0.1:9091`
- MITM 目标:`api2.cursor.sh`
启动后会自动打开调试界面。
## 配置 Cursor
工具不会自动修改 Cursor。启动后需要手动完成以下配置:
1. 打开 Cursor 的代理设置,将代理地址修改为工具启动时显示的地址,默认是 `http://127.0.0.1:9090`
2. 打开 Cursor 的 Network 设置,启用 HTTP/1.1。
3. 从 `http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
调试结束后,请恢复原来的 Cursor 代理和 Network 设置,以免影响正常网络请求。
## 构建
```bash
go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
```
## 参数
```text
-proxy-addr 代理监听地址,默认 127.0.0.1:9090
-ui-addr 调试界面监听地址,默认 127.0.0.1:9091
-target-host 需要解密的目标主机,默认 api2.cursor.sh
-max-exchanges 内存中保留的最大请求数,默认 200
-open 启动后是否打开浏览器,默认 true
```
## 数据处理
- 仅对 `target-host` 执行 HTTPS MITM,其他 CONNECT 流量直接透传。
- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。
- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`
- Fork Chat 相关的 `ForkBackgroundComposer``NotifyConversationClone``UploadConversationBlobs` 会双向解码为 protobuf JSON。
- 本地 Fork Chat 主要在客户端完成,只有启用克隆 blob 同步且隐私设置允许时才会产生 `NotifyConversationClone``UploadConversationBlobs` 流量。
- 请求列表支持按抓包时间正序/倒序排列,并可按协议中的 `request_id` 过滤。
- 调试界面支持简体中文和英文,可跟随浏览器语言并记住手动选择。
- 抓包只保留在当前进程内存中;关闭进程后消失。
- `Authorization``Cookie``Set-Cookie` 等 HTTP 头在界面中默认隐藏。
- 单侧原始正文默认最多保留 2 MiB;代理转发的数据不会被截断。
-88
View File
@@ -1,88 +0,0 @@
package proxydebugger
import (
"bytes"
"encoding/hex"
"io"
"sync"
)
type captureReadCloser struct {
source io.ReadCloser
mu sync.Mutex
buffer bytes.Buffer
limit int
size int64
truncated bool
done bool
onChunk func([]byte)
onDone func(captured []byte, size int64, truncated bool, readErr error)
}
func newCaptureReadCloser(
source io.ReadCloser,
limit int,
onChunk func([]byte),
onDone func(captured []byte, size int64, truncated bool, readErr error),
) *captureReadCloser {
return &captureReadCloser{
source: source,
limit: limit,
onChunk: onChunk,
onDone: onDone,
}
}
func (reader *captureReadCloser) Read(payload []byte) (int, error) {
read, err := reader.source.Read(payload)
if read > 0 {
chunk := payload[:read]
reader.mu.Lock()
reader.size += int64(read)
remaining := reader.limit - reader.buffer.Len()
if remaining > 0 {
captured := read
if captured > remaining {
captured = remaining
}
_, _ = reader.buffer.Write(chunk[:captured])
}
if reader.buffer.Len() >= reader.limit && reader.size > int64(reader.buffer.Len()) {
reader.truncated = true
}
reader.mu.Unlock()
if reader.onChunk != nil {
reader.onChunk(append([]byte(nil), chunk...))
}
}
if err != nil {
reader.finish(err)
}
return read, err
}
func (reader *captureReadCloser) Close() error {
err := reader.source.Close()
reader.finish(err)
return err
}
func (reader *captureReadCloser) finish(readErr error) {
reader.mu.Lock()
if reader.done {
reader.mu.Unlock()
return
}
reader.done = true
captured := append([]byte(nil), reader.buffer.Bytes()...)
size := reader.size
truncated := reader.truncated
reader.mu.Unlock()
if reader.onDone != nil {
reader.onDone(captured, size, truncated, readErr)
}
}
func rawHex(payload []byte) string {
return hex.EncodeToString(payload)
}
-294
View File
@@ -1,294 +0,0 @@
package proxydebugger
import (
"bytes"
"compress/gzip"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"strings"
"cursor/gen/agentv1"
"cursor/gen/aiserverv1"
agentprotocol "cursor/internal/backend/agent/protocol"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
const maxConnectFrameBytes = 64 << 20
const (
bidiAppendPath = "/aiserver.v1.BidiService/BidiAppend"
forkBackgroundComposerPath = "/aiserver.v1.BackgroundComposerService/ForkBackgroundComposer"
notifyConversationClonePath = "/agent.v1.AgentService/NotifyConversationClone"
uploadConversationBlobsPath = "/agent.v1.AgentService/UploadConversationBlobs"
)
type connectFrameDecoder struct {
buffer []byte
messageType string
codec string
maxFrames int
frameCount int
onFrame func(FrameView)
}
func newConnectFrameDecoder(messageType string, codec string, maxFrames int, onFrame func(FrameView)) *connectFrameDecoder {
return &connectFrameDecoder{
messageType: messageType,
codec: strings.TrimSpace(codec),
maxFrames: maxFrames,
onFrame: onFrame,
}
}
func (decoder *connectFrameDecoder) Write(payload []byte) {
if len(payload) == 0 || decoder.frameCount >= decoder.maxFrames {
return
}
decoder.buffer = append(decoder.buffer, payload...)
for len(decoder.buffer) >= 5 && decoder.frameCount < decoder.maxFrames {
flags := decoder.buffer[0]
length := int(binary.BigEndian.Uint32(decoder.buffer[1:5]))
if length < 0 || length > maxConnectFrameBytes {
decoder.emit(FrameView{Flags: flags, Length: length, Error: "Connect 帧长度异常"})
decoder.buffer = nil
return
}
if len(decoder.buffer) < 5+length {
return
}
framePayload := append([]byte(nil), decoder.buffer[5:5+length]...)
decoder.buffer = decoder.buffer[5+length:]
decoder.emit(decoder.decode(flags, framePayload))
}
}
func (decoder *connectFrameDecoder) Close() {
if len(decoder.buffer) > 0 && decoder.frameCount < decoder.maxFrames {
decoder.emit(FrameView{
Length: len(decoder.buffer),
RawHex: clippedHex(decoder.buffer, 4096),
Error: "流结束时仍有不完整的 Connect 帧",
})
}
decoder.buffer = nil
}
func (decoder *connectFrameDecoder) emit(frame FrameView) {
frame.Index = decoder.frameCount
decoder.frameCount++
if decoder.onFrame != nil {
decoder.onFrame(frame)
}
}
func (decoder *connectFrameDecoder) decode(flags uint8, payload []byte) FrameView {
frame := FrameView{
Flags: flags,
Length: len(payload),
Compressed: flags&0x01 != 0,
EndStream: flags&0x02 != 0,
RawHex: clippedHex(payload, 4096),
}
decoded := payload
if frame.Compressed {
var err error
decoded, err = decompressPayload(payload, decoder.codec)
if err != nil {
frame.Error = err.Error()
return frame
}
}
if frame.EndStream {
frame.Kind = "end_stream"
frame.MessageType = "connect.error.v1.EndStreamResponse"
frame.JSON = prettyJSON(decoded)
return frame
}
message := newMessage(decoder.messageType)
if message == nil {
frame.Error = "未知的 protobuf 消息类型"
return frame
}
if err := proto.Unmarshal(decoded, message); err != nil {
frame.Error = fmt.Sprintf("protobuf 解码失败:%v", err)
return frame
}
frame.MessageType = decoder.messageType
frame.Kind = activeOneofName(message)
if requestID, ok := message.(*aiserverv1.BidiRequestId); ok {
frame.RequestID = strings.TrimSpace(requestID.GetRequestId())
}
frame.JSON = marshalProtoJSON(message)
return frame
}
func decompressPayload(payload []byte, codec string) ([]byte, error) {
if codec != "" && !strings.EqualFold(codec, "gzip") {
return nil, fmt.Errorf("暂不支持压缩算法 %q", codec)
}
reader, err := gzip.NewReader(bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("gzip 解压失败:%w", err)
}
defer reader.Close()
decoded, err := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1))
if err != nil {
return nil, fmt.Errorf("读取 gzip 内容失败:%w", err)
}
if len(decoded) > maxConnectFrameBytes {
return nil, fmt.Errorf("gzip 解压后超过 %d 字节限制", maxConnectFrameBytes)
}
return decoded, nil
}
func decodeUnaryRequest(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) {
switch path {
case bidiAppendPath:
request := &aiserverv1.BidiAppendRequest{}
if err := proto.Unmarshal(payload, request); err != nil {
return "", "", "", err
}
requestID := strings.TrimSpace(request.GetRequestId().GetRequestId())
outer := marshalProtoJSON(request)
clientMessage, clientKind, decodeErr := agentprotocol.DecodeAgentClientMessage(request.GetData())
if decodeErr != nil || clientMessage == nil {
return outer, "bidi_append", requestID, decodeErr
}
combined := struct {
BidiAppendRequest json.RawMessage `json:"bidi_append_request"`
AgentClientKind string `json:"agent_client_kind"`
AgentClient json.RawMessage `json:"agent_client_message"`
}{
BidiAppendRequest: json.RawMessage(outer),
AgentClientKind: clientKind,
AgentClient: json.RawMessage(marshalProtoJSON(clientMessage)),
}
formatted, marshalErr := json.MarshalIndent(combined, "", " ")
return string(formatted), clientKind, requestID, marshalErr
}
message, kind := unaryRequestMessage(path)
if message == nil {
return "", "", "", nil
}
if err := proto.Unmarshal(payload, message); err != nil {
return "", "", "", err
}
return marshalProtoJSON(message), kind, "", nil
}
func decodeUnaryResponse(path string, payload []byte) (decodedJSON string, kind string, err error) {
message, kind := unaryResponseMessage(path)
if message == nil {
return "", "", nil
}
if err := proto.Unmarshal(payload, message); err != nil {
return "", "", err
}
return marshalProtoJSON(message), kind, nil
}
func unaryRequestMessage(path string) (proto.Message, string) {
switch path {
case forkBackgroundComposerPath:
return &aiserverv1.ForkBackgroundComposerRequest{}, "fork_background_composer_request"
case notifyConversationClonePath:
return &agentv1.NotifyConversationCloneRequest{}, "notify_conversation_clone_request"
case uploadConversationBlobsPath:
return &agentv1.UploadConversationBlobsRequest{}, "upload_conversation_blobs_request"
default:
return nil, ""
}
}
func unaryResponseMessage(path string) (proto.Message, string) {
switch path {
case forkBackgroundComposerPath:
return &aiserverv1.ForkBackgroundComposerResponse{}, "fork_background_composer_response"
case notifyConversationClonePath:
return &agentv1.NotifyConversationCloneResponse{}, "notify_conversation_clone_response"
case uploadConversationBlobsPath:
return &agentv1.UploadConversationBlobsResponse{}, "upload_conversation_blobs_response"
default:
return nil, ""
}
}
func decodesUnaryRequest(path string) bool {
if path == bidiAppendPath {
return true
}
message, _ := unaryRequestMessage(path)
return message != nil
}
func decodesUnaryResponse(path string) bool {
message, _ := unaryResponseMessage(path)
return message != nil
}
func newMessage(messageType string) proto.Message {
switch messageType {
case "aiserver.v1.BidiRequestId":
return &aiserverv1.BidiRequestId{}
case "agent.v1.AgentServerMessage":
return &agentv1.AgentServerMessage{}
default:
return nil
}
}
func marshalProtoJSON(message proto.Message) string {
if message == nil {
return ""
}
payload, err := (protojson.MarshalOptions{
UseProtoNames: true,
EmitUnpopulated: false,
Indent: " ",
}).Marshal(message)
if err != nil {
return ""
}
return string(payload)
}
func activeOneofName(message proto.Message) string {
if message == nil {
return ""
}
reflected := message.ProtoReflect()
oneofs := reflected.Descriptor().Oneofs()
for index := 0; index < oneofs.Len(); index++ {
oneof := oneofs.Get(index)
field := reflected.WhichOneof(oneof)
if field != nil {
return string(field.Name())
}
}
return string(reflected.Descriptor().Name())
}
func prettyJSON(payload []byte) string {
var target any
if err := json.Unmarshal(payload, &target); err != nil {
return string(payload)
}
formatted, err := json.MarshalIndent(target, "", " ")
if err != nil {
return string(payload)
}
return string(formatted)
}
func clippedHex(payload []byte, max int) string {
if len(payload) > max {
return hex.EncodeToString(payload[:max]) + "..."
}
return hex.EncodeToString(payload)
}
-242
View File
@@ -1,242 +0,0 @@
package proxydebugger
import (
"bytes"
"compress/gzip"
"encoding/json"
"strings"
"testing"
"time"
"cursor/gen/agentv1"
"cursor/gen/aiserverv1"
"google.golang.org/protobuf/proto"
)
func TestDecodeForkTrafficRequests(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
message proto.Message
kind string
contains []string
}{
{
name: "fork background composer",
path: forkBackgroundComposerPath,
message: &aiserverv1.ForkBackgroundComposerRequest{
SourceBcId: "bc-source",
Mode: aiserverv1.ForkBackgroundComposerMode_FORK_BACKGROUND_COMPOSER_MODE_CONVERSATION,
Name: proto.String("forked chat"),
TurnCount: proto.Uint32(4),
},
kind: "fork_background_composer_request",
contains: []string{`"source_bc_id":"bc-source"`, `"turn_count":4`},
},
{
name: "notify conversation clone",
path: notifyConversationClonePath,
message: &agentv1.NotifyConversationCloneRequest{
ConversationId: "new-conversation",
SourceConversationId: "source-conversation",
SourceRequestId: "source-request",
},
kind: "notify_conversation_clone_request",
contains: []string{`"conversation_id":"new-conversation"`, `"source_conversation_id":"source-conversation"`},
},
{
name: "upload conversation blobs",
path: uploadConversationBlobsPath,
message: &agentv1.UploadConversationBlobsRequest{
ConversationId: "new-conversation",
Blobs: []*agentv1.BlobEntry{{
Id: []byte{1, 2},
Value: []byte("blob-value"),
}},
ChunkIndex: 1,
TotalChunks: 2,
},
kind: "upload_conversation_blobs_request",
contains: []string{`"conversation_id":"new-conversation"`, `"total_chunks":2`, `"value":"YmxvYi12YWx1ZQ=="`},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
payload, err := proto.Marshal(test.message)
if err != nil {
t.Fatal(err)
}
decoded, kind, requestID, err := decodeUnaryRequest(test.path, payload)
if err != nil {
t.Fatalf("decode request: %v", err)
}
if kind != test.kind {
t.Fatalf("kind = %q, want %q", kind, test.kind)
}
if requestID != "" {
t.Fatalf("request ID = %q, want empty", requestID)
}
compact := compactJSON(t, decoded)
for _, expected := range test.contains {
if !strings.Contains(compact, expected) {
t.Errorf("decoded JSON does not contain %q:\n%s", expected, decoded)
}
}
})
}
}
func TestDecodeForkTrafficResponses(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
message proto.Message
kind string
contains string
}{
{
name: "fork background composer",
path: forkBackgroundComposerPath,
message: &aiserverv1.ForkBackgroundComposerResponse{
BcId: "bc-fork",
SourceBcId: "bc-source",
Mode: aiserverv1.ForkBackgroundComposerMode_FORK_BACKGROUND_COMPOSER_MODE_CONVERSATION,
},
kind: "fork_background_composer_response",
contains: `"bc_id":"bc-fork"`,
},
{
name: "notify conversation clone",
path: notifyConversationClonePath,
message: &agentv1.NotifyConversationCloneResponse{},
kind: "notify_conversation_clone_response",
contains: `{}`,
},
{
name: "upload conversation blobs",
path: uploadConversationBlobsPath,
message: &agentv1.UploadConversationBlobsResponse{},
kind: "upload_conversation_blobs_response",
contains: `{}`,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
payload, err := proto.Marshal(test.message)
if err != nil {
t.Fatal(err)
}
decoded, kind, err := decodeUnaryResponse(test.path, payload)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if kind != test.kind {
t.Fatalf("kind = %q, want %q", kind, test.kind)
}
if !strings.Contains(compactJSON(t, decoded), test.contains) {
t.Errorf("decoded JSON does not contain %q:\n%s", test.contains, decoded)
}
})
}
}
func TestFinishResponseBodyDecodesCompressedForkResponse(t *testing.T) {
t.Parallel()
payload, err := proto.Marshal(&aiserverv1.ForkBackgroundComposerResponse{
BcId: "bc-fork",
SourceBcId: "bc-source",
})
if err != nil {
t.Fatal(err)
}
var compressed bytes.Buffer
writer := gzip.NewWriter(&compressed)
if _, err := writer.Write(payload); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
server := &Server{store: newExchangeStore(1)}
server.store.create(&Exchange{
ExchangeSummary: ExchangeSummary{ID: "1", StartedAt: time.Now()},
})
server.finishResponseBody("1", forkBackgroundComposerPath, "gzip", compressed.Bytes(), int64(compressed.Len()), false, nil)
exchange, ok := server.store.get("1")
if !ok {
t.Fatal("exchange was not stored")
}
if exchange.ResponseKind != "fork_background_composer_response" {
t.Fatalf("response kind = %q", exchange.ResponseKind)
}
if !strings.Contains(compactJSON(t, exchange.Response.DecodedJSON), `"bc_id":"bc-fork"`) {
t.Fatalf("unexpected decoded response:\n%s", exchange.Response.DecodedJSON)
}
if exchange.Response.DecodeError != "" {
t.Fatalf("decode error = %q", exchange.Response.DecodeError)
}
}
func TestFinishRequestBodyDecodesCompressedCloneRequest(t *testing.T) {
t.Parallel()
payload, err := proto.Marshal(&agentv1.NotifyConversationCloneRequest{
ConversationId: "new-conversation",
SourceConversationId: "source-conversation",
SourceRequestId: "source-request",
})
if err != nil {
t.Fatal(err)
}
var compressed bytes.Buffer
writer := gzip.NewWriter(&compressed)
if _, err := writer.Write(payload); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
server := &Server{store: newExchangeStore(1)}
server.store.create(&Exchange{
ExchangeSummary: ExchangeSummary{ID: "1", StartedAt: time.Now()},
})
server.finishRequestBody("1", notifyConversationClonePath, "gzip", compressed.Bytes(), int64(compressed.Len()), false, nil)
exchange, ok := server.store.get("1")
if !ok {
t.Fatal("exchange was not stored")
}
if exchange.RequestKind != "notify_conversation_clone_request" {
t.Fatalf("request kind = %q", exchange.RequestKind)
}
if !strings.Contains(compactJSON(t, exchange.Request.DecodedJSON), `"source_conversation_id":"source-conversation"`) {
t.Fatalf("unexpected decoded request:\n%s", exchange.Request.DecodedJSON)
}
if exchange.Request.DecodeError != "" {
t.Fatalf("decode error = %q", exchange.Request.DecodeError)
}
}
func compactJSON(t *testing.T, value string) string {
t.Helper()
var compact bytes.Buffer
if err := json.Compact(&compact, []byte(value)); err != nil {
t.Fatalf("compact JSON: %v\n%s", err, value)
}
return compact.String()
}
-449
View File
@@ -1,449 +0,0 @@
package proxydebugger
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"cursor/internal/certs"
"github.com/elazarl/goproxy"
)
type exchangeContext struct {
id string
}
// Server runs the HTTPS debugging proxy and its local web UI.
type Server struct {
config Config
certManager *certs.Manager
caCertPEM []byte
store *exchangeStore
counter atomic.Uint64
proxyServer *http.Server
uiServer *http.Server
proxyLn net.Listener
uiLn net.Listener
runMu sync.Mutex
}
// New creates a standalone Cursor protocol debugger.
func New(config Config) (*Server, error) {
config = config.normalized()
if err := validateLoopbackAddress(config.UIAddr); err != nil {
return nil, err
}
manager, caCertPEM, err := certs.NewGeneratedManager()
if err != nil {
return nil, fmt.Errorf("加载 MITM CA 失败:%w", err)
}
server := &Server{
config: config,
certManager: manager,
caCertPEM: caCertPEM,
store: newExchangeStore(config.MaxExchanges),
}
proxyHandler, err := server.newProxyHandler()
if err != nil {
return nil, err
}
server.proxyServer = &http.Server{
Handler: proxyHandler,
ErrorLog: log.New(io.Discard, "", 0),
}
server.uiServer = &http.Server{Handler: server.newUIHandler()}
return server, nil
}
// Start starts both listeners without modifying Cursor or system proxy settings.
func (server *Server) Start() error {
server.runMu.Lock()
defer server.runMu.Unlock()
if server.proxyLn != nil || server.uiLn != nil {
return errors.New("调试代理已经启动")
}
proxyListener, err := net.Listen("tcp", server.config.ProxyAddr)
if err != nil {
return fmt.Errorf("启动代理监听失败:%w", err)
}
uiListener, err := net.Listen("tcp", server.config.UIAddr)
if err != nil {
_ = proxyListener.Close()
return fmt.Errorf("启动调试界面失败:%w", err)
}
server.proxyLn = proxyListener
server.uiLn = uiListener
go func() { _ = server.proxyServer.Serve(proxyListener) }()
go func() { _ = server.uiServer.Serve(uiListener) }()
return nil
}
// Close stops both listeners.
func (server *Server) Close(ctx context.Context) error {
server.runMu.Lock()
proxyServer := server.proxyServer
uiServer := server.uiServer
server.proxyLn = nil
server.uiLn = nil
server.runMu.Unlock()
var errorsList []error
if proxyServer != nil {
if err := proxyServer.Shutdown(ctx); err != nil {
errorsList = append(errorsList, err)
}
}
if uiServer != nil {
if err := uiServer.Shutdown(ctx); err != nil {
errorsList = append(errorsList, err)
}
}
return errors.Join(errorsList...)
}
func (server *Server) ProxyAddr() string { return server.config.ProxyAddr }
func (server *Server) UIAddr() string { return server.config.UIAddr }
func (server *Server) UIURL() string { return "http://" + browserAddress(server.config.UIAddr) }
func (server *Server) newProxyHandler() (*goproxy.ProxyHttpServer, error) {
proxy := goproxy.NewProxyHttpServer()
proxy.Verbose = false
proxy.AllowHTTP2 = true
proxy.Logger = log.New(io.Discard, "", 0)
proxy.ConnectionErrHandler = func(_ io.Writer, context *goproxy.ProxyCtx, connectionErr error) {
id := exchangeID(context)
if id == "" {
return
}
server.store.update(id, func(exchange *Exchange) {
exchange.State = "error"
exchange.Error = connectionErr.Error()
exchange.DurationMS = elapsedMS(exchange.StartedAt)
})
}
proxy.Tr = &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 200,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
caCertificate, err := server.certManager.CATLSCertificate()
if err != nil {
return nil, fmt.Errorf("读取 MITM CA 失败:%w", err)
}
baseTLSConfig := goproxy.TLSConfigFromCA(caCertificate)
mitmAction := &goproxy.ConnectAction{
Action: goproxy.ConnectMitm,
TLSConfig: func(host string, context *goproxy.ProxyCtx) (*tls.Config, error) {
return baseTLSConfig(host, context)
},
}
proxy.OnRequest().HandleConnectFunc(func(host string, _ *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
if server.matchesTargetHost(host) {
return mitmAction, host
}
return goproxy.OkConnect, host
})
proxy.OnRequest().DoFunc(server.captureRequest)
proxy.OnResponse().DoFunc(server.captureResponse)
return proxy, nil
}
func (server *Server) captureRequest(request *http.Request, context *goproxy.ProxyCtx) (*http.Request, *http.Response) {
if request == nil || request.Method == http.MethodConnect || !server.matchesHTTPRequest(request) {
return request, nil
}
id := strconv.FormatUint(server.counter.Add(1), 10)
path := request.URL.Path
requestCodec := requestContentCodec(path, request.Header)
exchange := &Exchange{
ExchangeSummary: ExchangeSummary{
ID: id,
StartedAt: time.Now(),
Method: request.Method,
URL: request.URL.String(),
Host: request.URL.Host,
Path: path,
State: "pending",
},
Request: Payload{
Headers: sortedHeaders(request.Header),
ContentType: request.Header.Get("Content-Type"),
ContentCodec: requestCodec,
Frames: make([]FrameView, 0),
},
Response: Payload{Headers: make([]Header, 0), Frames: make([]FrameView, 0)},
}
server.store.create(exchange)
context.UserData = exchangeContext{id: id}
if request.Body == nil {
server.finishRequestBody(id, path, requestCodec, nil, 0, false, nil)
return request, nil
}
var frameDecoder *connectFrameDecoder
if path == "/agent.v1.AgentService/RunSSE" {
frameDecoder = newConnectFrameDecoder(
"aiserver.v1.BidiRequestId",
requestCodec,
server.config.MaxFrames,
func(frame FrameView) { server.appendRequestFrame(id, frame) },
)
}
request.Body = newCaptureReadCloser(
request.Body,
server.config.MaxCaptureBytes,
func(chunk []byte) {
if frameDecoder != nil {
frameDecoder.Write(chunk)
}
},
func(captured []byte, size int64, truncated bool, readErr error) {
if frameDecoder != nil {
frameDecoder.Close()
}
server.finishRequestBody(id, path, requestCodec, captured, size, truncated, readErr)
},
)
return request, nil
}
func (server *Server) captureResponse(response *http.Response, context *goproxy.ProxyCtx) *http.Response {
id := exchangeID(context)
if id == "" || response == nil {
return response
}
path := ""
if response.Request != nil && response.Request.URL != nil {
path = response.Request.URL.Path
}
responseCodec := responseContentCodec(path, response.Header)
server.store.update(id, func(exchange *Exchange) {
exchange.Status = response.StatusCode
exchange.State = "streaming"
exchange.DurationMS = elapsedMS(exchange.StartedAt)
exchange.Response.Headers = sortedHeaders(response.Header)
exchange.Response.ContentType = response.Header.Get("Content-Type")
exchange.Response.ContentCodec = responseCodec
})
if response.Body == nil {
server.finishResponseBody(id, path, responseCodec, nil, 0, false, nil)
return response
}
var frameDecoder *connectFrameDecoder
if path == "/agent.v1.AgentService/RunSSE" {
frameDecoder = newConnectFrameDecoder(
"agent.v1.AgentServerMessage",
responseCodec,
server.config.MaxFrames,
func(frame FrameView) { server.appendResponseFrame(id, frame) },
)
}
response.Body = newCaptureReadCloser(
response.Body,
server.config.MaxCaptureBytes,
func(chunk []byte) {
if frameDecoder != nil {
frameDecoder.Write(chunk)
}
},
func(captured []byte, size int64, truncated bool, readErr error) {
if frameDecoder != nil {
frameDecoder.Close()
}
server.finishResponseBody(id, path, responseCodec, captured, size, truncated, readErr)
},
)
return response
}
func (server *Server) finishRequestBody(id, path string, codec string, captured []byte, size int64, truncated bool, readErr error) {
decodePayload := captured
var contentDecodeErr error
if decodesUnaryRequest(path) && truncated {
contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
} else if decodesUnaryRequest(path) && codec != "" && !strings.EqualFold(codec, "identity") {
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
}
decodedJSON, kind, requestID, decodeErr := "", "", "", contentDecodeErr
if decodeErr == nil {
decodedJSON, kind, requestID, decodeErr = decodeUnaryRequest(path, decodePayload)
}
server.store.update(id, func(exchange *Exchange) {
exchange.RequestBytes = size
exchange.Request.Size = size
exchange.Request.RawHex = rawHex(captured)
exchange.Request.RawTruncated = truncated
if decodedJSON != "" {
exchange.Request.DecodedJSON = decodedJSON
}
if kind != "" {
exchange.RequestKind = kind
}
if requestID != "" {
exchange.RequestID = requestID
}
if decodeErr != nil {
exchange.Request.DecodeError = decodeErr.Error()
}
if readErr != nil && !errors.Is(readErr, io.EOF) {
exchange.Error = readErr.Error()
}
})
}
func requestContentCodec(path string, headers http.Header) string {
if path == "/agent.v1.AgentService/RunSSE" {
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
}
return strings.TrimSpace(headers.Get("Content-Encoding"))
}
func responseContentCodec(path string, headers http.Header) string {
if path == "/agent.v1.AgentService/RunSSE" {
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
}
if !decodesUnaryResponse(path) {
if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
return codec
}
}
return strings.TrimSpace(headers.Get("Content-Encoding"))
}
func (server *Server) finishResponseBody(id, path, codec string, captured []byte, size int64, truncated bool, readErr error) {
decodePayload := captured
var contentDecodeErr error
if decodesUnaryResponse(path) && truncated {
contentDecodeErr = errors.New("响应正文超过抓取上限,无法完整解码")
} else if decodesUnaryResponse(path) && codec != "" && !strings.EqualFold(codec, "identity") {
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
}
decodedJSON, kind, decodeErr := "", "", contentDecodeErr
if decodeErr == nil {
decodedJSON, kind, decodeErr = decodeUnaryResponse(path, decodePayload)
}
server.store.update(id, func(exchange *Exchange) {
exchange.ResponseBytes = size
exchange.Response.Size = size
exchange.Response.RawHex = rawHex(captured)
exchange.Response.RawTruncated = truncated
if decodedJSON != "" {
exchange.Response.DecodedJSON = decodedJSON
}
if kind != "" {
exchange.ResponseKind = kind
}
if decodeErr != nil {
exchange.Response.DecodeError = decodeErr.Error()
}
exchange.DurationMS = elapsedMS(exchange.StartedAt)
exchange.State = "completed"
if readErr != nil && !errors.Is(readErr, io.EOF) {
exchange.State = "error"
exchange.Error = readErr.Error()
}
})
}
func (server *Server) appendRequestFrame(id string, frame FrameView) {
server.store.update(id, func(exchange *Exchange) {
if len(exchange.Request.Frames) < server.config.MaxFrames {
exchange.Request.Frames = append(exchange.Request.Frames, frame)
}
if frame.Kind != "" {
exchange.RequestKind = frame.Kind
}
if frame.RequestID != "" {
exchange.RequestID = frame.RequestID
}
})
}
func (server *Server) appendResponseFrame(id string, frame FrameView) {
server.store.update(id, func(exchange *Exchange) {
if len(exchange.Response.Frames) < server.config.MaxFrames {
exchange.Response.Frames = append(exchange.Response.Frames, frame)
}
exchange.FrameCount = len(exchange.Response.Frames)
if frame.Kind != "" && frame.Kind != "end_stream" {
exchange.ResponseKind = frame.Kind
}
if frame.Error != "" {
exchange.Response.DecodeError = frame.Error
}
})
}
func (server *Server) matchesHTTPRequest(request *http.Request) bool {
if request == nil {
return false
}
host := request.Host
if request.URL != nil && request.URL.Host != "" {
host = request.URL.Host
}
return server.matchesTargetHost(host)
}
func (server *Server) matchesTargetHost(host string) bool {
host = strings.TrimSpace(strings.ToLower(host))
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
host = parsedHost
}
target := strings.TrimSpace(strings.ToLower(server.config.TargetHost))
return host == target
}
func exchangeID(context *goproxy.ProxyCtx) string {
if context == nil {
return ""
}
value, ok := context.UserData.(exchangeContext)
if !ok {
return ""
}
return value.id
}
func browserAddress(address string) string {
host, port, err := net.SplitHostPort(address)
if err != nil {
return address
}
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
func validateLoopbackAddress(address string) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("调试界面监听地址无效:%w", err)
}
if strings.EqualFold(host, "localhost") {
return nil
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return errors.New("调试界面只能监听本机回环地址")
}
return nil
}
-162
View File
@@ -1,162 +0,0 @@
package proxydebugger
import (
"sort"
"sync"
"time"
)
type exchangeStore struct {
mu sync.RWMutex
max int
order []string
exchanges map[string]*Exchange
subscribers map[chan storeEvent]struct{}
}
func newExchangeStore(max int) *exchangeStore {
return &exchangeStore{
max: max,
exchanges: make(map[string]*Exchange),
subscribers: make(map[chan storeEvent]struct{}),
}
}
func (store *exchangeStore) create(exchange *Exchange) {
store.mu.Lock()
store.exchanges[exchange.ID] = exchange
store.order = append([]string{exchange.ID}, store.order...)
for len(store.order) > store.max {
oldest := store.order[len(store.order)-1]
store.order = store.order[:len(store.order)-1]
delete(store.exchanges, oldest)
}
store.mu.Unlock()
store.publish(storeEvent{Type: "created", ID: exchange.ID})
}
func (store *exchangeStore) update(id string, apply func(*Exchange)) {
store.mu.Lock()
if exchange := store.exchanges[id]; exchange != nil {
apply(exchange)
}
store.mu.Unlock()
store.publish(storeEvent{Type: "updated", ID: id})
}
func (store *exchangeStore) summaries() []ExchangeSummary {
store.mu.RLock()
defer store.mu.RUnlock()
result := make([]ExchangeSummary, 0, len(store.order))
for _, id := range store.order {
if exchange := store.exchanges[id]; exchange != nil {
result = append(result, exchange.ExchangeSummary)
}
}
return result
}
func (store *exchangeStore) get(id string) (Exchange, bool) {
store.mu.RLock()
defer store.mu.RUnlock()
exchange := store.exchanges[id]
if exchange == nil {
return Exchange{}, false
}
return cloneExchange(*exchange), true
}
func (store *exchangeStore) clear() {
store.mu.Lock()
store.order = nil
store.exchanges = make(map[string]*Exchange)
store.mu.Unlock()
store.publish(storeEvent{Type: "cleared"})
}
func (store *exchangeStore) subscribe() (<-chan storeEvent, func()) {
updates := make(chan storeEvent, 32)
store.mu.Lock()
store.subscribers[updates] = struct{}{}
store.mu.Unlock()
return updates, func() {
store.mu.Lock()
if _, ok := store.subscribers[updates]; ok {
delete(store.subscribers, updates)
close(updates)
}
store.mu.Unlock()
}
}
func (store *exchangeStore) publish(event storeEvent) {
store.mu.RLock()
defer store.mu.RUnlock()
for subscriber := range store.subscribers {
select {
case subscriber <- event:
default:
}
}
}
func cloneExchange(exchange Exchange) Exchange {
exchange.Request = clonePayload(exchange.Request)
exchange.Response = clonePayload(exchange.Response)
return exchange
}
func clonePayload(payload Payload) Payload {
payload.Headers = append([]Header(nil), payload.Headers...)
payload.Frames = append([]FrameView(nil), payload.Frames...)
return payload
}
func elapsedMS(startedAt time.Time) int64 {
if startedAt.IsZero() {
return 0
}
return time.Since(startedAt).Milliseconds()
}
func sortedHeaders(headers map[string][]string) []Header {
result := make([]Header, 0, len(headers))
for name, values := range headers {
value := ""
for index, item := range values {
if index > 0 {
value += ", "
}
value += item
}
if isSensitiveHeader(name) && value != "" {
value = "[已隐藏]"
}
result = append(result, Header{Name: name, Value: value})
}
sort.Slice(result, func(left, right int) bool {
return result[left].Name < result[right].Name
})
return result
}
func isSensitiveHeader(name string) bool {
switch httpCanonicalLower(name) {
case "authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key":
return true
default:
return false
}
}
func httpCanonicalLower(value string) string {
buffer := make([]byte, len(value))
for index := range value {
character := value[index]
if character >= 'A' && character <= 'Z' {
character += 'a' - 'A'
}
buffer[index] = character
}
return string(buffer)
}
-110
View File
@@ -1,110 +0,0 @@
package proxydebugger
import "time"
const (
defaultProxyAddr = "127.0.0.1:9090"
defaultUIAddr = "127.0.0.1:9091"
defaultTargetHost = "api2.cursor.sh"
defaultMaxExchanges = 200
defaultMaxCaptureBytes = 2 << 20
defaultMaxFrames = 2000
)
// Config controls the standalone proxy debugger.
type Config struct {
ProxyAddr string
UIAddr string
TargetHost string
MaxExchanges int
MaxCaptureBytes int
MaxFrames int
}
func (config Config) normalized() Config {
if config.ProxyAddr == "" {
config.ProxyAddr = defaultProxyAddr
}
if config.UIAddr == "" {
config.UIAddr = defaultUIAddr
}
if config.TargetHost == "" {
config.TargetHost = defaultTargetHost
}
if config.MaxExchanges <= 0 {
config.MaxExchanges = defaultMaxExchanges
}
if config.MaxCaptureBytes <= 0 {
config.MaxCaptureBytes = defaultMaxCaptureBytes
}
if config.MaxFrames <= 0 {
config.MaxFrames = defaultMaxFrames
}
return config
}
// ExchangeSummary is the compact request-list representation.
type ExchangeSummary struct {
ID string `json:"id"`
StartedAt time.Time `json:"startedAt"`
Method string `json:"method"`
URL string `json:"url"`
Host string `json:"host"`
Path string `json:"path"`
Status int `json:"status"`
State string `json:"state"`
DurationMS int64 `json:"durationMs"`
RequestBytes int64 `json:"requestBytes"`
ResponseBytes int64 `json:"responseBytes"`
RequestID string `json:"requestId,omitempty"`
RequestKind string `json:"requestKind,omitempty"`
ResponseKind string `json:"responseKind,omitempty"`
FrameCount int `json:"frameCount"`
Error string `json:"error,omitempty"`
}
// Exchange contains the request and response detail shown by the debugger.
type Exchange struct {
ExchangeSummary
Request Payload `json:"request"`
Response Payload `json:"response"`
}
// Payload contains headers, captured raw bytes, and decoded protobuf frames.
type Payload struct {
Headers []Header `json:"headers"`
ContentType string `json:"contentType,omitempty"`
ContentCodec string `json:"contentCodec,omitempty"`
Size int64 `json:"size"`
RawHex string `json:"rawHex,omitempty"`
RawTruncated bool `json:"rawTruncated,omitempty"`
DecodedJSON string `json:"decodedJson,omitempty"`
DecodeError string `json:"decodeError,omitempty"`
Frames []FrameView `json:"frames,omitempty"`
}
// Header is a stable, sorted HTTP header pair.
type Header struct {
Name string `json:"name"`
Value string `json:"value"`
}
// FrameView describes one Connect streaming envelope.
type FrameView struct {
Index int `json:"index"`
Flags uint8 `json:"flags"`
Length int `json:"length"`
Compressed bool `json:"compressed"`
EndStream bool `json:"endStream"`
Kind string `json:"kind,omitempty"`
MessageType string `json:"messageType,omitempty"`
RequestID string `json:"requestId,omitempty"`
JSON string `json:"json,omitempty"`
RawHex string `json:"rawHex,omitempty"`
Error string `json:"error,omitempty"`
}
type storeEvent struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
}
-110
View File
@@ -1,110 +0,0 @@
package proxydebugger
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"strings"
"time"
)
//go:embed web/*
var webAssets embed.FS
func (server *Server) newUIHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/status", server.handleStatus)
mux.HandleFunc("GET /api/exchanges", server.handleExchangeList)
mux.HandleFunc("GET /api/exchanges/{id}", server.handleExchangeDetail)
mux.HandleFunc("DELETE /api/exchanges", server.handleClearExchanges)
mux.HandleFunc("GET /api/events", server.handleEvents)
mux.HandleFunc("GET /api/ca.crt", server.handleCACertificate)
assets, _ := fs.Sub(webAssets, "web")
fileServer := http.FileServer(http.FS(assets))
mux.Handle("/", fileServer)
return securityHeaders(mux)
}
func (server *Server) handleStatus(writer http.ResponseWriter, _ *http.Request) {
writeJSON(writer, http.StatusOK, map[string]any{
"proxyAddr": server.config.ProxyAddr,
"uiAddr": server.config.UIAddr,
"targetHost": server.config.TargetHost,
"running": true,
})
}
func (server *Server) handleExchangeList(writer http.ResponseWriter, _ *http.Request) {
writeJSON(writer, http.StatusOK, server.store.summaries())
}
func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *http.Request) {
id := strings.TrimSpace(request.PathValue("id"))
exchange, ok := server.store.get(id)
if !ok {
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请求记录不存在"})
return
}
writeJSON(writer, http.StatusOK, exchange)
}
func (server *Server) handleClearExchanges(writer http.ResponseWriter, _ *http.Request) {
server.store.clear()
writer.WriteHeader(http.StatusNoContent)
}
func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Request) {
flusher, ok := writer.(http.Flusher)
if !ok {
http.Error(writer, "当前响应不支持流式刷新", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "text/event-stream")
writer.Header().Set("Cache-Control", "no-cache")
writer.Header().Set("Connection", "keep-alive")
updates, unsubscribe := server.store.subscribe()
defer unsubscribe()
fmt.Fprint(writer, "event: ready\ndata: {}\n\n")
flusher.Flush()
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-request.Context().Done():
return
case event, open := <-updates:
if !open {
return
}
payload, _ := json.Marshal(event)
fmt.Fprintf(writer, "event: update\ndata: %s\n\n", payload)
flusher.Flush()
case <-heartbeat.C:
fmt.Fprint(writer, ": heartbeat\n\n")
flusher.Flush()
}
}
}
func (server *Server) handleCACertificate(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "application/x-x509-ca-cert")
writer.Header().Set("Content-Disposition", `attachment; filename="cursor-local-proxy-ca.crt"`)
_, _ = writer.Write(server.caCertPEM)
}
func writeJSON(writer http.ResponseWriter, status int, payload any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(payload)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("Referrer-Policy", "no-referrer")
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'")
next.ServeHTTP(writer, request)
})
}
-460
View File
@@ -1,460 +0,0 @@
import { getLocale, setLocale, t, translateDocument } from "./i18n.js";
const state = {
status: null,
exchanges: [],
selectedId: null,
selected: null,
search: "",
requestId: "",
endpoint: "all",
sortOrder: "desc",
paused: false,
pendingRefresh: false,
connection: { connected: false, key: "status.connecting", values: {} },
tabs: {
request: "body",
response: "frames",
},
};
const elements = {
statusDot: document.querySelector("#status-dot"),
statusText: document.querySelector("#status-text"),
proxyAddress: document.querySelector("#proxy-address"),
targetHost: document.querySelector("#target-host"),
connectionLabel: document.querySelector("#connection-label"),
trafficSummary: document.querySelector("#traffic-summary"),
searchInput: document.querySelector("#search-input"),
requestIdInput: document.querySelector("#request-id-input"),
endpointFilter: document.querySelector("#endpoint-filter"),
sortOrder: document.querySelector("#sort-order"),
requestCount: document.querySelector("#request-count"),
requestList: document.querySelector("#request-list"),
emptyState: document.querySelector("#empty-state"),
selectionSummary: document.querySelector("#selection-summary"),
requestContent: document.querySelector("#request-content"),
responseContent: document.querySelector("#response-content"),
pauseButton: document.querySelector("#pause-button"),
clearButton: document.querySelector("#clear-button"),
localeSelect: document.querySelector("#locale-select"),
workspace: document.querySelector("#workspace"),
splitter: document.querySelector("#horizontal-splitter"),
};
async function fetchJSON(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
if (response.status === 204) return null;
return response.json();
}
async function loadStatus() {
state.status = await fetchJSON("/api/status");
elements.statusDot.classList.toggle("online", Boolean(state.status.running));
renderRuntimeStatus();
elements.proxyAddress.textContent = `http://${state.status.proxyAddr}`;
elements.targetHost.textContent = state.status.targetHost;
}
async function refreshList() {
state.exchanges = await fetchJSON("/api/exchanges");
renderList();
renderTrafficSummary();
if (state.selectedId && state.exchanges.some((item) => item.id === state.selectedId)) {
await refreshDetail(state.selectedId);
} else if (state.selectedId) {
state.selectedId = null;
state.selected = null;
renderDetail();
}
}
async function refreshDetail(id) {
if (!id) return;
try {
const detail = await fetchJSON(`/api/exchanges/${encodeURIComponent(id)}`);
if (state.selectedId !== id) return;
state.selected = detail;
renderDetail();
} catch (error) {
if (state.selectedId === id) {
state.selected = null;
renderDetailError(error);
}
}
}
function scheduleRefresh() {
if (state.paused) {
state.pendingRefresh = true;
return;
}
if (state.pendingRefresh) return;
state.pendingRefresh = true;
window.setTimeout(async () => {
state.pendingRefresh = false;
try {
await refreshList();
} catch (error) {
setConnectionState(false, "connection.refreshFailed", { message: error.message });
}
}, 90);
}
function connectEvents() {
const events = new EventSource("/api/events");
events.addEventListener("open", () => setConnectionState(true, "connection.live"));
events.addEventListener("update", scheduleRefresh);
events.addEventListener("error", () => setConnectionState(false, "connection.retrying"));
}
function setConnectionState(connected, key, values = {}) {
state.connection = { connected, key, values };
renderConnectionState();
}
function renderRuntimeStatus() {
if (!state.status) {
elements.statusText.textContent = t("status.connecting");
return;
}
elements.statusText.textContent = t(state.status.running ? "status.running" : "status.stopped");
}
function renderConnectionState() {
const { connected, key, values } = state.connection;
elements.connectionLabel.textContent = t(key, values);
elements.statusDot.classList.toggle("online", connected && Boolean(state.status?.running));
}
function filteredExchanges() {
const query = state.search.trim().toLowerCase();
const requestId = state.requestId.trim().toLowerCase();
const direction = state.sortOrder === "asc" ? 1 : -1;
return state.exchanges
.filter((item) => {
if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false;
if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false;
if (state.endpoint === "fork" && !isForkTrafficPath(item.path)) return false;
if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false;
if (!query) return true;
return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query));
})
.sort((left, right) => {
const startedAtDelta = new Date(left.startedAt).getTime() - new Date(right.startedAt).getTime();
if (startedAtDelta !== 0) return startedAtDelta * direction;
return left.id.localeCompare(right.id, undefined, { numeric: true }) * direction;
});
}
function isForkTrafficPath(path) {
const normalized = String(path || "").toLowerCase();
return ["forkbackgroundcomposer", "notifyconversationclone", "uploadconversationblobs"].some((endpoint) =>
normalized.includes(endpoint),
);
}
function renderList() {
const exchanges = filteredExchanges();
elements.requestCount.textContent = t("count.requests", { count: exchanges.length });
elements.emptyState.classList.toggle("hidden", exchanges.length > 0);
elements.requestList.innerHTML = exchanges
.map((item) => {
const selected = item.id === state.selectedId ? " selected" : "";
const statusClass = item.status >= 400 ? "error" : item.status ? "success" : "";
const kind = item.responseKind || item.requestKind || "-";
return `<tr class="${selected.trim()}" data-id="${escapeHTML(item.id)}">
<td><span class="row-state ${escapeHTML(item.state)}"></span></td>
<td><code>${escapeHTML(item.id)}</code></td>
<td title="${escapeHTML(item.url)}"><code>${escapeHTML(item.url)}</code></td>
<td title="${escapeHTML(item.requestId || "")}"><code class="request-id-text">${escapeHTML(item.requestId || "-")}</code></td>
<td><span class="kind-text">${escapeHTML(kind)}</span></td>
<td><span class="method-text">${escapeHTML(item.method)}</span></td>
<td><span class="status-text ${statusClass}">${item.status || "-"}</span></td>
<td>${formatBytes(item.responseBytes)}</td>
<td>${formatDuration(item.durationMs)}</td>
</tr>`;
})
.join("");
}
function renderTrafficSummary() {
const totals = state.exchanges.reduce(
(result, item) => {
result.up += item.requestBytes || 0;
result.down += item.responseBytes || 0;
return result;
},
{ up: 0, down: 0 },
);
elements.trafficSummary.textContent = `${formatBytes(totals.up)} ↓ ${formatBytes(totals.down)}`;
}
function renderDetail() {
if (!state.selected) {
elements.selectionSummary.innerHTML = `<span class="method-badge">POST</span><span class="status-badge">${escapeHTML(t("selection.waiting"))}</span><code>${escapeHTML(t("selection.prompt"))}</code>`;
elements.requestContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noRequest"))}</div>`;
elements.responseContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noResponse"))}</div>`;
return;
}
const item = state.selected;
const statusClass = item.status >= 200 && item.status < 400 ? "success" : "";
elements.selectionSummary.innerHTML = `<span class="method-badge">${escapeHTML(item.method)}</span><span class="status-badge ${statusClass}">${escapeHTML(item.status || formatState(item.state))}</span><code>${escapeHTML(item.url)}</code>`;
elements.requestContent.innerHTML = renderPayload(item.request, state.tabs.request);
elements.responseContent.innerHTML = renderPayload(item.response, state.tabs.response);
}
function renderDetailError(error) {
elements.requestContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
elements.responseContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
}
function renderPayload(payload, tab) {
if (!payload) return `<div class="notice">${escapeHTML(t("notices.noContent"))}</div>`;
if (tab === "headers") return renderHeaders(payload.headers);
if (tab === "frames") return renderFrames(payload.frames);
if (tab === "raw") {
const body = payload.rawHex ? formatHex(payload.rawHex) : t("notices.noRaw");
return `<pre class="hex-view">${escapeHTML(body)}</pre>${renderTruncated(payload.rawTruncated)}`;
}
if (payload.decodedJson) {
return `<pre class="code-view">${escapeHTML(payload.decodedJson)}</pre>${renderDecodeError(payload.decodeError)}${renderTruncated(payload.rawTruncated)}`;
}
if (payload.frames?.length) return renderFrames(payload.frames);
if (payload.decodeError) return `<div class="notice error">${escapeHTML(payload.decodeError)}</div>`;
return `<div class="notice">${escapeHTML(t("notices.noBody"))}</div>`;
}
function renderHeaders(headers = []) {
const items = Array.isArray(headers) ? headers : [];
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noHeaders"))}</div>`;
return `<table class="headers-table"><tbody>${items
.map((header) => `<tr><th>${escapeHTML(header.name)}</th><td>${escapeHTML(header.value)}</td></tr>`)
.join("")}</tbody></table>`;
}
function renderFrames(frames = []) {
const items = Array.isArray(frames) ? frames : [];
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noFrames"))}</div>`;
return `<div class="frame-list">${items
.map((frame) => {
const kind = frame.kind || frame.messageType || t("notices.unknown");
const flags = `0x${Number(frame.flags || 0).toString(16).padStart(2, "0")}`;
const content = frame.json
? `<pre class="code-view">${escapeHTML(frame.json)}</pre>`
: `<pre class="hex-view">${escapeHTML(formatHex(frame.rawHex || ""))}</pre>`;
return `<details class="frame-item"${frame.index === items.length - 1 ? " open" : ""}>
<summary>
<span class="frame-index">#${frame.index}</span>
<span class="frame-kind" title="${escapeHTML(kind)}">${escapeHTML(kind)}</span>
<span class="frame-size">${formatBytes(frame.length)}</span>
<span class="frame-flags">${flags}${frame.compressed ? " gzip" : ""}</span>
</summary>
${frame.error ? `<div class="frame-error">${escapeHTML(frame.error)}</div>` : content}
</details>`;
})
.join("")}</div>`;
}
function renderDecodeError(error) {
return error ? `<div class="frame-error">${escapeHTML(error)}</div>` : "";
}
function renderTruncated(truncated) {
return truncated ? `<div class="truncated-notice">${escapeHTML(t("notices.truncated"))}</div>` : "";
}
function formatState(value) {
const key = {
pending: "state.pending",
streaming: "state.streaming",
completed: "state.completed",
error: "state.error",
}[value];
return key ? t(key) : value || "-";
}
function currentCopyText(side) {
const payload = state.selected?.[side];
if (!payload) return "";
const tab = state.tabs[side];
if (tab === "headers") return (payload.headers || []).map((item) => `${item.name}: ${item.value}`).join("\n");
if (tab === "raw") return payload.rawHex || "";
if (tab === "frames") return (payload.frames || []).map((frame) => frame.json || frame.rawHex || frame.error || "").join("\n\n");
return payload.decodedJson || "";
}
function formatHex(value) {
const hex = String(value || "").replace(/[^0-9a-f]/gi, "");
const lines = [];
for (let index = 0; index < hex.length; index += 32) {
const chunk = hex.slice(index, index + 32);
const bytes = chunk.match(/.{1,2}/g) || [];
lines.push(`${(index / 2).toString(16).padStart(8, "0")} ${bytes.join(" ")}`);
}
return lines.join("\n");
}
function formatBytes(value) {
const bytes = Number(value || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatDuration(value) {
const milliseconds = Number(value || 0);
if (milliseconds < 1000) return `${milliseconds} ms`;
return `${(milliseconds / 1000).toFixed(1)} s`;
}
function escapeHTML(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
elements.requestList.addEventListener("click", async (event) => {
const row = event.target.closest("tr[data-id]");
if (!row) return;
state.selectedId = row.dataset.id;
state.selected = null;
renderList();
renderDetail();
await refreshDetail(state.selectedId);
});
elements.searchInput.addEventListener("input", (event) => {
state.search = event.target.value;
renderList();
});
elements.requestIdInput.addEventListener("input", (event) => {
state.requestId = event.target.value;
renderList();
});
elements.endpointFilter.addEventListener("click", (event) => {
const button = event.target.closest("button[data-value]");
if (!button) return;
state.endpoint = button.dataset.value;
for (const item of elements.endpointFilter.querySelectorAll("button")) {
item.classList.toggle("active", item === button);
}
renderList();
});
elements.sortOrder.addEventListener("click", (event) => {
const button = event.target.closest("button[data-value]");
if (!button) return;
state.sortOrder = button.dataset.value;
for (const item of elements.sortOrder.querySelectorAll("button")) {
item.classList.toggle("active", item === button);
}
renderList();
});
document.querySelectorAll(".payload-panel").forEach((panel) => {
panel.querySelector(".tabs").addEventListener("click", (event) => {
const button = event.target.closest("button[data-tab]");
if (!button) return;
const side = panel.dataset.side;
state.tabs[side] = button.dataset.tab;
panel.querySelectorAll(".tabs button").forEach((item) => item.classList.toggle("active", item === button));
renderDetail();
});
});
document.querySelectorAll("[data-copy-side]").forEach((button) => {
button.addEventListener("click", async () => {
const text = currentCopyText(button.dataset.copySide);
if (!text) return;
await navigator.clipboard.writeText(text);
button.textContent = t("actions.copied");
window.setTimeout(() => {
button.textContent = t("actions.copy");
}, 900);
});
});
function renderPauseState() {
elements.pauseButton.textContent = state.paused ? "▶" : "Ⅱ";
const actionKey = state.paused ? "actions.resume" : "actions.pause";
elements.pauseButton.title = t(actionKey);
elements.pauseButton.setAttribute("aria-label", t(actionKey));
}
elements.pauseButton.addEventListener("click", async () => {
state.paused = !state.paused;
elements.pauseButton.classList.toggle("active", state.paused);
renderPauseState();
setConnectionState(!state.paused, state.paused ? "connection.paused" : "connection.live");
if (!state.paused && state.pendingRefresh) {
state.pendingRefresh = false;
await refreshList();
}
});
function applyLocale() {
translateDocument();
elements.localeSelect.value = getLocale();
renderRuntimeStatus();
renderConnectionState();
renderPauseState();
renderList();
renderTrafficSummary();
renderDetail();
}
elements.localeSelect.addEventListener("change", (event) => {
setLocale(event.target.value);
applyLocale();
});
elements.clearButton.addEventListener("click", async () => {
await fetchJSON("/api/exchanges", { method: "DELETE" });
state.selectedId = null;
state.selected = null;
await refreshList();
renderDetail();
});
let draggingSplitter = false;
elements.splitter.addEventListener("pointerdown", (event) => {
draggingSplitter = true;
elements.splitter.classList.add("dragging");
elements.splitter.setPointerCapture(event.pointerId);
});
elements.splitter.addEventListener("pointermove", (event) => {
if (!draggingSplitter) return;
const bounds = elements.workspace.getBoundingClientRect();
const top = Math.max(180, Math.min(bounds.height - 225, event.clientY - bounds.top));
elements.workspace.style.gridTemplateRows = `${top}px 5px minmax(220px, 1fr)`;
});
elements.splitter.addEventListener("pointerup", () => {
draggingSplitter = false;
elements.splitter.classList.remove("dragging");
});
async function bootstrap() {
applyLocale();
renderDetail();
try {
await Promise.all([loadStatus(), refreshList()]);
connectEvents();
} catch (error) {
setConnectionState(false, "connection.connectFailed", { message: error.message });
}
}
void bootstrap();
-180
View File
@@ -1,180 +0,0 @@
const SOURCE_LOCALE = "zh-CN";
const DEFAULT_LOCALE = "en-US";
const STORAGE_KEY = "cursor-proxy-debugger:locale:v1";
const SUPPORTED_LOCALES = [SOURCE_LOCALE, DEFAULT_LOCALE];
const messages = {
"zh-CN": {
"app.title": "Cursor 协议调试器",
"status.connecting": "正在连接",
"status.running": "代理运行中",
"status.stopped": "代理已停止",
"actions.downloadCA": "下载代理 CA 证书",
"actions.caCertificate": "CA 证书",
"actions.pause": "暂停界面更新",
"actions.resume": "继续界面更新",
"actions.clear": "清空",
"actions.copy": "复制",
"actions.copied": "已复制",
"language.label": "界面语言",
"filters.region": "请求过滤器",
"filters.urlPlaceholder": "过滤 URL、请求类型或状态",
"filters.requestIdPlaceholder": "按 Request ID 过滤",
"filters.endpoint": "接口过滤",
"filters.all": "全部",
"filters.fork": "Fork",
"filters.sort": "排序方向",
"filters.ascending": "正序",
"filters.descending": "倒序",
"count.requests": "{count} 条",
"table.url": "网址",
"table.message": "消息",
"table.method": "方法",
"table.status": "状态",
"table.response": "响应",
"table.duration": "耗时",
"empty.waitingForCursor": "等待来自 Cursor 的请求",
"splitter.resize": "调整详情区域高度",
"selection.waiting": "等待选择",
"selection.prompt": "选择一条请求查看详情",
"panel.request": "请求",
"panel.response": "响应",
"panel.requestDetails": "请求详情",
"panel.responseDetails": "响应详情",
"tabs.headers": "标头",
"tabs.body": "正文",
"tabs.frames": "帧",
"tabs.raw": "原始",
"notices.noRequest": "暂无请求内容",
"notices.noResponse": "暂无响应内容",
"notices.noContent": "暂无内容",
"notices.noRaw": "暂无原始数据",
"notices.noBody": "暂无可显示的正文",
"notices.noHeaders": "暂无标头",
"notices.noFrames": "尚未收到完整帧",
"notices.unknown": "未识别",
"notices.truncated": "原始正文已达到本地抓取上限,转发内容未被截断",
"connection.live": "实时连接中",
"connection.retrying": "实时连接正在重试",
"connection.refreshFailed": "刷新失败:{message}",
"connection.paused": "界面更新已暂停",
"connection.connectFailed": "连接失败:{message}",
"state.pending": "等待中",
"state.streaming": "传输中",
"state.completed": "已完成",
"state.error": "错误",
},
"en-US": {
"app.title": "Cursor Protocol Debugger",
"status.connecting": "Connecting",
"status.running": "Proxy running",
"status.stopped": "Proxy stopped",
"actions.downloadCA": "Download proxy CA certificate",
"actions.caCertificate": "CA Certificate",
"actions.pause": "Pause UI updates",
"actions.resume": "Resume UI updates",
"actions.clear": "Clear",
"actions.copy": "Copy",
"actions.copied": "Copied",
"language.label": "Interface language",
"filters.region": "Request filters",
"filters.urlPlaceholder": "Filter by URL, message type, or status",
"filters.requestIdPlaceholder": "Filter by Request ID",
"filters.endpoint": "Endpoint filter",
"filters.all": "All",
"filters.fork": "Fork",
"filters.sort": "Sort order",
"filters.ascending": "Oldest first",
"filters.descending": "Newest first",
"count.requests": "{count} requests",
"table.url": "URL",
"table.message": "Message",
"table.method": "Method",
"table.status": "Status",
"table.response": "Response",
"table.duration": "Duration",
"empty.waitingForCursor": "Waiting for requests from Cursor",
"splitter.resize": "Resize details area",
"selection.waiting": "No selection",
"selection.prompt": "Select a request to inspect its details",
"panel.request": "Request",
"panel.response": "Response",
"panel.requestDetails": "Request details",
"panel.responseDetails": "Response details",
"tabs.headers": "Headers",
"tabs.body": "Body",
"tabs.frames": "Frames",
"tabs.raw": "Raw",
"notices.noRequest": "No request content",
"notices.noResponse": "No response content",
"notices.noContent": "No content",
"notices.noRaw": "No raw data",
"notices.noBody": "No body available",
"notices.noHeaders": "No headers",
"notices.noFrames": "No complete frames received yet",
"notices.unknown": "Unknown",
"notices.truncated": "Raw body reached the local capture limit; forwarded data was not truncated",
"connection.live": "Live connection",
"connection.retrying": "Reconnecting live updates",
"connection.refreshFailed": "Refresh failed: {message}",
"connection.paused": "UI updates paused",
"connection.connectFailed": "Connection failed: {message}",
"state.pending": "Pending",
"state.streaming": "Streaming",
"state.completed": "Completed",
"state.error": "Error",
},
};
function matchLocale(locale) {
const normalized = String(locale || "").trim().replaceAll("_", "-").toLowerCase();
if (!normalized) return "";
const exact = SUPPORTED_LOCALES.find((candidate) => candidate.toLowerCase() === normalized);
if (exact) return exact;
return normalized.split("-")[0] === "zh" ? SOURCE_LOCALE : normalized.split("-")[0] === "en" ? DEFAULT_LOCALE : "";
}
function resolveInitialLocale() {
const stored = matchLocale(window.localStorage.getItem(STORAGE_KEY));
if (stored) return stored;
for (const candidate of navigator.languages || [navigator.language]) {
const matched = matchLocale(candidate);
if (matched) return matched;
}
return DEFAULT_LOCALE;
}
let currentLocale = resolveInitialLocale();
export function getLocale() {
return currentLocale;
}
export function t(key, values = {}) {
const template = messages[currentLocale]?.[key] || messages[SOURCE_LOCALE][key] || key;
return template.replace(/\{(\w+)\}/g, (_match, name) => String(values[name] ?? ""));
}
export function translateDocument(root = document) {
document.documentElement.lang = currentLocale;
document.title = t("app.title");
for (const element of root.querySelectorAll("[data-i18n]")) {
element.textContent = t(element.dataset.i18n);
}
for (const [attribute, dataAttribute] of [
["aria-label", "i18nAriaLabel"],
["placeholder", "i18nPlaceholder"],
["title", "i18nTitle"],
]) {
for (const element of root.querySelectorAll(`[data-${dataAttribute.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}]`)) {
element.setAttribute(attribute, t(element.dataset[dataAttribute]));
}
}
}
export function setLocale(locale) {
currentLocale = matchLocale(locale) || DEFAULT_LOCALE;
window.localStorage.setItem(STORAGE_KEY, currentLocale);
translateDocument();
return currentLocale;
}
-126
View File
@@ -1,126 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Cursor 协议调试器</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div id="app" class="app-shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<strong data-i18n="app.title">Cursor 协议调试器</strong>
</div>
<div class="runtime-status" aria-live="polite">
<span id="status-dot" class="status-dot"></span>
<span id="status-text" data-i18n="status.connecting">正在连接</span>
<code id="proxy-address"></code>
</div>
<div class="toolbar-actions">
<label class="locale-picker">
<span class="visually-hidden" data-i18n="language.label">界面语言</span>
<select id="locale-select" aria-label="界面语言" title="界面语言" data-i18n-aria-label="language.label" data-i18n-title="language.label">
<option value="zh-CN">中文</option>
<option value="en-US">EN</option>
</select>
</label>
<a class="button secondary" href="/api/ca.crt" title="下载代理 CA 证书" data-i18n="actions.caCertificate" data-i18n-title="actions.downloadCA">CA 证书</a>
<button id="pause-button" class="icon-button" type="button" title="暂停界面更新" aria-label="暂停界面更新" data-i18n-title="actions.pause" data-i18n-aria-label="actions.pause"></button>
<button id="clear-button" class="button danger" type="button" data-i18n="actions.clear">清空</button>
</div>
</header>
<section class="filterbar" aria-label="请求过滤器" data-i18n-aria-label="filters.region">
<div class="search-box url-filter">
<span aria-hidden="true"></span>
<input id="search-input" type="search" placeholder="过滤 URL、请求类型或状态" data-i18n-placeholder="filters.urlPlaceholder" autocomplete="off" />
</div>
<div class="search-box request-id-filter">
<span aria-hidden="true"></span>
<input id="request-id-input" type="search" placeholder="按 Request ID 过滤" data-i18n-placeholder="filters.requestIdPlaceholder" autocomplete="off" />
</div>
<div id="endpoint-filter" class="segmented-control" role="group" aria-label="接口过滤" data-i18n-aria-label="filters.endpoint">
<button class="active" type="button" data-value="all" data-i18n="filters.all">全部</button>
<button type="button" data-value="runsse">RunSSE</button>
<button type="button" data-value="bidiappend">BidiAppend</button>
<button type="button" data-value="fork" data-i18n="filters.fork">Fork</button>
</div>
<div id="sort-order" class="segmented-control sort-control" role="group" aria-label="排序方向" data-i18n-aria-label="filters.sort">
<button type="button" data-value="asc" data-i18n="filters.ascending">正序</button>
<button class="active" type="button" data-value="desc" data-i18n="filters.descending">倒序</button>
</div>
<span id="request-count" class="request-count">0 条</span>
</section>
<main id="workspace" class="workspace">
<section class="request-list-pane">
<table class="request-table">
<thead>
<tr>
<th class="status-column"></th>
<th class="index-column">#</th>
<th data-i18n="table.url">网址</th>
<th class="request-id-column">Request ID</th>
<th class="kind-column" data-i18n="table.message">消息</th>
<th class="method-column" data-i18n="table.method">方法</th>
<th class="code-column" data-i18n="table.status">状态</th>
<th class="size-column" data-i18n="table.response">响应</th>
<th class="time-column" data-i18n="table.duration">耗时</th>
</tr>
</thead>
<tbody id="request-list"></tbody>
</table>
<div id="empty-state" class="empty-state" data-i18n="empty.waitingForCursor">等待来自 Cursor 的请求</div>
</section>
<div id="horizontal-splitter" class="horizontal-splitter" role="separator" aria-label="调整详情区域高度" data-i18n-aria-label="splitter.resize"></div>
<section id="detail-pane" class="detail-pane">
<div id="selection-summary" class="selection-summary">
<span class="method-badge">POST</span>
<span class="status-badge" data-i18n="selection.waiting">等待选择</span>
<code data-i18n="selection.prompt">选择一条请求查看详情</code>
</div>
<div class="detail-columns">
<section class="payload-panel" data-side="request">
<div class="panel-header">
<strong data-i18n="panel.request">请求</strong>
<nav class="tabs" aria-label="请求详情" data-i18n-aria-label="panel.requestDetails">
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
<button type="button" data-tab="body" class="active" data-i18n="tabs.body">正文</button>
<button type="button" data-tab="frames" data-i18n="tabs.frames"></button>
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
</nav>
<button class="copy-button" type="button" data-copy-side="request" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
</div>
<div id="request-content" class="panel-content"></div>
</section>
<section class="payload-panel" data-side="response">
<div class="panel-header">
<strong data-i18n="panel.response">响应</strong>
<nav class="tabs" aria-label="响应详情" data-i18n-aria-label="panel.responseDetails">
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
<button type="button" data-tab="body" data-i18n="tabs.body">正文</button>
<button type="button" data-tab="frames" class="active" data-i18n="tabs.frames"></button>
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
</nav>
<button class="copy-button" type="button" data-copy-side="response" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
</div>
<div id="response-content" class="panel-content"></div>
</section>
</div>
</section>
</main>
<footer class="statusbar">
<span id="connection-label" data-i18n="connection.live">实时连接中</span>
<span id="traffic-summary">↑ 0 B ↓ 0 B</span>
<span id="target-host"></span>
</footer>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>
-921
View File
@@ -1,921 +0,0 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #171818;
color: #dedfdd;
font-synthesis: none;
--surface-0: #171818;
--surface-1: #1d1f1f;
--surface-2: #242626;
--surface-3: #2c2f2f;
--border: #343737;
--border-strong: #454949;
--muted: #8d9390;
--text: #dedfdd;
--accent: #4ea58b;
--accent-soft: #25473d;
--cyan: #55a8ba;
--orange: #c88762;
--danger: #c56d65;
--selection: #245b73;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: var(--surface-0);
}
button,
input,
select,
a {
font: inherit;
letter-spacing: 0;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: -1px;
}
.app-shell {
display: grid;
grid-template-rows: 48px 46px minmax(0, 1fr) 26px;
min-width: 760px;
background: var(--surface-0);
}
.topbar,
.filterbar,
.statusbar {
display: flex;
align-items: center;
border-color: var(--border);
background: var(--surface-1);
}
.topbar {
justify-content: space-between;
gap: 18px;
padding: 0 14px;
border-bottom: 1px solid var(--border);
}
.brand,
.runtime-status,
.toolbar-actions {
display: flex;
align-items: center;
min-width: 0;
}
.brand {
gap: 9px;
white-space: nowrap;
}
.brand strong {
font-size: 14px;
font-weight: 650;
}
.brand-mark {
width: 12px;
height: 12px;
border: 2px solid var(--accent);
border-radius: 50%;
box-shadow: inset 0 0 0 2px var(--surface-1);
background: var(--accent);
}
.runtime-status {
justify-content: center;
gap: 7px;
min-width: 240px;
color: #bec3c0;
font-size: 12px;
}
.runtime-status code {
overflow: hidden;
max-width: 260px;
color: var(--muted);
font-family: var(--mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #7d8380;
}
.status-dot.online {
background: #42bd79;
box-shadow: 0 0 0 3px rgb(66 189 121 / 14%);
}
.toolbar-actions {
justify-content: flex-end;
gap: 7px;
}
.locale-picker {
display: flex;
}
.locale-picker select {
width: 58px;
height: 29px;
border: 1px solid var(--border-strong);
border-radius: 5px;
padding: 0 6px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
font-size: 12px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.button,
.icon-button,
.copy-button {
height: 29px;
border: 1px solid var(--border-strong);
border-radius: 5px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
text-decoration: none;
}
.button {
display: inline-flex;
align-items: center;
padding: 0 10px;
font-size: 12px;
}
.button:hover,
.icon-button:hover,
.copy-button:hover {
background: var(--surface-3);
}
.button.danger:hover {
border-color: #744640;
color: #f2b0aa;
}
.icon-button {
width: 31px;
padding: 0;
font-family: var(--mono);
font-weight: 700;
}
.icon-button.active {
border-color: var(--orange);
color: #f1bb98;
}
.filterbar {
gap: 10px;
padding: 7px 14px;
border-bottom: 1px solid var(--border);
}
.search-box {
display: flex;
align-items: center;
flex: 1;
min-width: 260px;
max-width: 640px;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
background: #191b1b;
color: var(--muted);
}
.search-box > span {
padding-left: 9px;
font-size: 17px;
}
.search-box input {
flex: 1;
min-width: 0;
height: 100%;
border: 0;
padding: 0 9px;
outline: 0;
background: transparent;
color: var(--text);
font-size: 12px;
}
.search-box input::placeholder {
color: #6f7572;
}
.url-filter {
min-width: 280px;
max-width: 420px;
}
.request-id-filter {
flex: 0 1 320px;
min-width: 220px;
max-width: 340px;
}
.segmented-control {
display: flex;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
overflow: hidden;
}
.segmented-control button {
min-width: 62px;
border: 0;
border-right: 1px solid var(--border);
padding: 0 10px;
background: #1b1d1d;
color: var(--muted);
cursor: pointer;
font-size: 12px;
}
.segmented-control button:last-child {
border-right: 0;
}
.segmented-control button.active {
background: var(--accent-soft);
color: #bce8d9;
}
.sort-control button {
min-width: 52px;
}
.request-count {
margin-left: auto;
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
.workspace {
display: grid;
grid-template-rows: minmax(180px, 52%) 5px minmax(220px, 48%);
min-height: 0;
overflow: hidden;
}
.request-list-pane {
position: relative;
min-height: 0;
overflow: auto;
background: #181a1a;
}
.request-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
}
.request-table thead {
position: sticky;
top: 0;
z-index: 2;
background: #202222;
}
.request-table th,
.request-table td {
height: 30px;
border-right: 1px solid #2c2f2f;
border-bottom: 1px solid #292c2c;
padding: 0 9px;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-table th {
color: #9da29f;
font-weight: 550;
}
.request-table tbody tr {
cursor: default;
}
.request-table tbody tr:hover {
background: #222525;
}
.request-table tbody tr.selected {
background: var(--selection);
color: #f3f8f8;
}
.request-table code {
font-family: var(--mono);
}
.status-column {
width: 30px;
}
.index-column {
width: 54px;
}
.request-id-column {
width: 250px;
}
.kind-column {
width: 180px;
}
.method-column {
width: 72px;
}
.code-column {
width: 66px;
}
.size-column {
width: 86px;
}
.time-column {
width: 74px;
}
.row-state {
display: block;
width: 8px;
height: 8px;
margin: auto;
border-radius: 50%;
background: #7c8380;
}
.row-state.streaming {
background: #45ba77;
}
.row-state.completed {
background: var(--cyan);
}
.row-state.error {
background: var(--danger);
}
.method-text {
color: #61b9df;
font-family: var(--mono);
font-weight: 650;
}
.status-text.success {
color: #68c991;
}
.status-text.error {
color: #e18b83;
}
.kind-text {
color: #d3a17f;
font-family: var(--mono);
}
.request-id-text {
color: #8bc2cc;
}
.empty-state {
position: absolute;
inset: 34px 0 0;
display: grid;
place-items: center;
color: #686e6b;
font-size: 13px;
}
.empty-state.hidden {
display: none;
}
.horizontal-splitter {
cursor: row-resize;
background: #343737;
}
.horizontal-splitter:hover,
.horizontal-splitter.dragging {
background: var(--cyan);
}
.detail-pane {
display: grid;
grid-template-rows: 38px minmax(0, 1fr);
min-height: 0;
background: var(--surface-0);
}
.selection-summary {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 0 14px;
border-bottom: 1px solid var(--border);
background: #1b1d1d;
}
.selection-summary code {
overflow: hidden;
color: #aeb4b1;
font-family: var(--mono);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.method-badge,
.status-badge,
.frame-badge {
display: inline-flex;
align-items: center;
height: 22px;
border: 1px solid var(--border-strong);
border-radius: 4px;
padding: 0 7px;
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
.method-badge {
border-color: #34667a;
color: #74c8e8;
}
.status-badge.success {
border-color: #3f7157;
color: #83d5a5;
}
.detail-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
}
.payload-panel {
display: grid;
grid-template-rows: 36px minmax(0, 1fr);
min-width: 0;
min-height: 0;
border-right: 1px solid var(--border);
}
.payload-panel:last-child {
border-right: 0;
}
.panel-header {
display: flex;
align-items: center;
min-width: 0;
border-bottom: 1px solid var(--border);
background: #202222;
}
.panel-header > strong {
padding: 0 10px;
color: #c9cdca;
font-size: 12px;
}
.tabs {
display: flex;
align-self: stretch;
}
.tabs button {
position: relative;
min-width: 46px;
border: 0;
padding: 0 9px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 12px;
}
.tabs button:hover {
color: #d7dad8;
}
.tabs button.active {
color: #71c7e2;
}
.tabs button.active::after {
position: absolute;
right: 8px;
bottom: 0;
left: 8px;
height: 2px;
background: var(--cyan);
content: "";
}
.copy-button {
width: 48px;
height: 24px;
margin-right: 7px;
margin-left: auto;
font-size: 11px;
}
.panel-content {
min-height: 0;
overflow: auto;
background: #181a1a;
}
.code-view,
.hex-view {
min-width: 100%;
min-height: 100%;
margin: 0;
padding: 12px 14px 30px;
color: #ccd1ce;
font: 11px/1.55 var(--mono);
tab-size: 2;
white-space: pre;
}
.hex-view {
color: #b6c2bd;
}
.headers-table {
width: 100%;
border-collapse: collapse;
font: 11px/1.4 var(--mono);
}
.headers-table th,
.headers-table td {
border-bottom: 1px solid #292c2c;
padding: 7px 10px;
text-align: left;
vertical-align: top;
}
.headers-table th {
width: 38%;
color: #62b3cd;
font-weight: 500;
overflow-wrap: anywhere;
}
.headers-table td {
color: #c5c9c6;
overflow-wrap: anywhere;
}
.frame-list {
min-width: 480px;
}
.frame-item {
border-bottom: 1px solid #292c2c;
}
.frame-item summary {
display: grid;
grid-template-columns: 58px minmax(150px, 1fr) 90px 82px;
align-items: center;
height: 32px;
padding: 0 10px;
color: #c5cac7;
cursor: pointer;
font: 11px var(--mono);
list-style: none;
}
.frame-item summary::-webkit-details-marker {
display: none;
}
.frame-item summary:hover {
background: #222525;
}
.frame-item[open] summary {
background: #242727;
}
.frame-index {
color: #747b77;
}
.frame-kind {
overflow: hidden;
color: #dfaa85;
text-overflow: ellipsis;
white-space: nowrap;
}
.frame-size,
.frame-flags {
color: #7faeb7;
text-align: right;
}
.frame-error {
margin: 10px 14px;
color: #ec968e;
font: 11px/1.5 var(--mono);
}
.notice {
padding: 14px;
color: #7d8581;
font: 12px/1.6 var(--mono);
}
.notice.error {
color: #df8b83;
}
.truncated-notice {
position: sticky;
bottom: 0;
padding: 5px 10px;
border-top: 1px solid #674f3f;
background: #3d3028;
color: #e5b28e;
font-size: 11px;
}
.statusbar {
justify-content: flex-end;
gap: 16px;
padding: 0 10px;
border-top: 1px solid var(--border);
color: #848b87;
font: 10px var(--mono);
}
.statusbar span:first-child {
margin-right: auto;
}
@media (max-width: 920px) {
.app-shell {
grid-template-rows: 48px 84px minmax(0, 1fr) 26px;
min-width: 0;
}
.filterbar {
align-content: center;
flex-wrap: wrap;
gap: 6px;
}
.url-filter,
.request-id-filter {
flex: 1 1 300px;
max-width: none;
}
.runtime-status code,
.kind-column,
.request-table td:nth-child(5) {
display: none;
}
.detail-columns {
grid-template-columns: 1fr;
grid-template-rows: minmax(180px, 1fr) minmax(180px, 1fr);
overflow: auto;
}
.payload-panel {
min-height: 260px;
border-right: 0;
border-bottom: 1px solid var(--border);
}
}
@media (max-width: 640px) {
.app-shell {
grid-template-rows: 82px 122px minmax(0, 1fr) 26px;
}
.topbar {
position: relative;
align-content: center;
flex-wrap: wrap;
gap: 4px 10px;
padding: 8px 10px;
}
.brand {
flex: 1;
overflow: hidden;
}
.brand strong {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
}
.runtime-status {
order: 3;
justify-content: flex-start;
width: 100%;
min-width: 0;
}
.runtime-status code {
display: block;
max-width: none;
}
.toolbar-actions {
gap: 4px;
}
.toolbar-actions .button {
padding: 0 7px;
}
.filterbar {
align-content: center;
flex-wrap: wrap;
gap: 6px;
padding: 7px 10px;
}
.url-filter,
.request-id-filter {
flex: 0 0 100%;
width: 100%;
min-width: 0;
max-width: none;
}
.request-count {
order: 5;
margin-left: auto;
}
#endpoint-filter {
order: 3;
flex: 1;
}
.sort-control {
order: 4;
flex: 0 0 104px;
}
.segmented-control button {
flex: 1;
min-width: 0;
}
.workspace {
grid-template-rows: minmax(150px, 40%) 5px minmax(260px, 60%);
}
.request-table th,
.request-table td {
padding: 0 6px;
}
.request-table .index-column,
.request-table th:nth-child(2),
.request-table td:nth-child(2),
.size-column,
.request-table th:nth-child(8),
.request-table td:nth-child(8),
.time-column,
.request-table th:nth-child(9),
.request-table td:nth-child(9) {
display: none;
}
.request-id-column {
width: 130px;
}
.method-column {
width: 58px;
}
.code-column {
width: 50px;
}
.selection-summary {
padding: 0 8px;
}
.detail-columns {
grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr);
}
.panel-header > strong {
width: 72px;
padding: 0 7px;
font-size: 11px;
}
.tabs {
overflow-x: auto;
}
.tabs button {
min-width: 42px;
padding: 0 6px;
}
.copy-button {
width: 42px;
margin-right: 4px;
}
.frame-list {
min-width: 0;
}
.frame-item summary {
grid-template-columns: 42px minmax(100px, 1fr) 62px 72px;
padding: 0 7px;
}
.statusbar {
gap: 8px;
}
#target-host {
display: none;
}
}
Submodule cursor-proxy-server added at 9ab33eb5ad
-2
View File
@@ -1,2 +0,0 @@
cursor-tab-server-linux-amd64.tar
*.tar
-24
View File
@@ -1,24 +0,0 @@
FROM golang:1.25 AS build
WORKDIR /src
ARG GOPROXY=https://goproxy.cn,direct
ENV GOPROXY=${GOPROXY}
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/cursor-tab-server .
FROM scratch
WORKDIR /app
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /out/cursor-tab-server /app/cursor-tab-server
COPY config.yaml /app/config.yaml
EXPOSE 8041
ENTRYPOINT ["/app/cursor-tab-server"]
-13
View File
@@ -1,13 +0,0 @@
### 如何获取 token
**macos**
```bash
sqlite3 "$HOME/Library/Application Support/Cursor/User/globalStorage/state.vscdb" \
"SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken';"
```
**windows 获取方式**
```bash
sqlite3 "$env:APPDATA\Cursor\User\globalStorage\state.vscdb" "SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken';"
```
-1
View File
@@ -1 +0,0 @@
token: "eyJ..."
-5
View File
@@ -1,5 +0,0 @@
module cursor-tab-server
go 1.25
require gopkg.in/yaml.v3 v3.0.1
-4
View File
@@ -1,4 +0,0 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-289
View File
@@ -1,289 +0,0 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"math/big"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const (
defaultConfigPath = "./config.yaml"
defaultListenAddr = ":8041"
)
var hopByHopHeaders = map[string]struct{}{
"connection": {},
"proxy-connection": {},
"keep-alive": {},
"proxy-authenticate": {},
"proxy-authorization": {},
"te": {},
"trailer": {},
"transfer-encoding": {},
"upgrade": {},
}
var defaultUpstreamTargets = map[string]string{
"/aiserver.v1.AiService/StreamCpp": "https://api4.cursor.sh:443/aiserver.v1.AiService/StreamCpp",
"/aiserver.v1.AiService/StreamNextCursorPrediction": "https://api4.cursor.sh:443/aiserver.v1.AiService/StreamNextCursorPrediction",
"/aiserver.v1.AiService/GetCppEditClassification": "https://api4.cursor.sh:443/aiserver.v1.AiService/GetCppEditClassification",
"/aiserver.v1.AiService/RefreshTabContext": "https://api2.cursor.sh:443/aiserver.v1.AiService/RefreshTabContext",
"/aiserver.v1.AiService/CppConfig": "https://api4.cursor.sh:443/aiserver.v1.AiService/CppConfig",
"/aiserver.v1.AiService/CppEditHistoryStatus": "https://api2.cursor.sh:443/aiserver.v1.AiService/CppEditHistoryStatus",
"/aiserver.v1.AiService/CppAppend": "https://api3.cursor.sh:443/aiserver.v1.AiService/CppAppend",
"/aiserver.v1.AiService/CppEditHistoryAppend": "https://api3.cursor.sh:443/aiserver.v1.AiService/CppEditHistoryAppend",
"/aiserver.v1.CppService/AvailableModels": "https://api3.cursor.sh:443/aiserver.v1.CppService/AvailableModels",
"/aiserver.v1.CppService/RecordCppFate": "https://api2.cursor.sh:443/aiserver.v1.CppService/RecordCppFate",
"/aiserver.v1.AiService/ReportAiCodeChangeMetrics": "https://api2.cursor.sh:443/aiserver.v1.AiService/ReportAiCodeChangeMetrics",
"/aiserver.v1.AiService/WriteGitCommitMessage": "https://api2.cursor.sh:443/aiserver.v1.AiService/WriteGitCommitMessage",
"/aiserver.v1.AiService/WriteGitBranchName": "https://api2.cursor.sh:443/aiserver.v1.AiService/WriteGitBranchName",
"/aiserver.v1.FileSyncService/FSSyncFile": "https://api4.cursor.sh:443/aiserver.v1.FileSyncService/FSSyncFile",
"/aiserver.v1.FileSyncService/FSIsEnabledForUser": "https://api4.cursor.sh:443/aiserver.v1.FileSyncService/FSIsEnabledForUser",
"/aiserver.v1.FileSyncService/FSConfig": "https://api4.cursor.sh:443/aiserver.v1.FileSyncService/FSConfig",
"/aiserver.v1.FileSyncService/FSUploadFile": "https://api4.cursor.sh:443/aiserver.v1.FileSyncService/FSUploadFile",
"/aiserver.v1.DashboardService/GetEffectiveUserPlugins": "https://api2.cursor.sh:443/aiserver.v1.DashboardService/GetEffectiveUserPlugins",
}
type appConfig struct {
Token string
}
type serverApp struct {
config appConfig
client *http.Client
upstreamTargets map[string]string
}
func main() {
cfg, err := loadConfig(defaultConfigPath)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err)
os.Exit(1)
}
log.Printf("cursor-tab-server 启动 listen_addr=%s config_path=%s", defaultListenAddr, defaultConfigPath)
server := &http.Server{
Addr: defaultListenAddr,
Handler: newServerApp(cfg, newHTTPClient(), defaultUpstreamTargets),
ReadHeaderTimeout: 10 * time.Second,
}
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
_, _ = fmt.Fprintf(os.Stderr, "监听失败: %v\n", err)
os.Exit(1)
}
}
func newServerApp(cfg appConfig, client *http.Client, upstreamTargets map[string]string) http.Handler {
app := &serverApp{
config: cfg,
client: client,
upstreamTargets: cloneUpstreamTargets(upstreamTargets),
}
if app.client == nil {
app.client = newHTTPClient()
}
return app
}
func (app *serverApp) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if err := app.handleProxy(writer, request); err != nil {
http.Error(writer, err.Error(), http.StatusBadGateway)
}
}
func (app *serverApp) handleProxy(writer http.ResponseWriter, request *http.Request) error {
if app == nil {
return fmt.Errorf("服务实例为空")
}
rawTarget, ok := app.upstreamTargets[strings.TrimSpace(request.URL.Path)]
if !ok {
http.NotFound(writer, request)
return nil
}
targetURL, err := url.Parse(rawTarget)
if err != nil {
return fmt.Errorf("解析上游地址失败: %w", err)
}
targetURL.RawQuery = request.URL.RawQuery
requestBody := []byte{}
if shouldRequestCarryBody(request.Method) {
requestBody, err = io.ReadAll(request.Body)
if err != nil {
return fmt.Errorf("读取请求体失败: %w", err)
}
}
upstreamRequest, err := http.NewRequestWithContext(request.Context(), request.Method, targetURL.String(), bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("构建上游请求失败: %w", err)
}
copyRequestHeaders(upstreamRequest.Header, request.Header)
authorization := formatBearerAuthorization(app.config.Token)
upstreamRequest.Header.Set("Authorization", authorization)
upstreamRequest.Header.Set("x-cursor-checksum", buildCursorChecksum(authorization))
if !shouldRequestCarryBody(request.Method) {
upstreamRequest.Header.Del("content-length")
} else {
upstreamRequest.Header.Set("content-length", strconv.Itoa(len(requestBody)))
}
upstreamRequest.Host = targetURL.Host
response, err := app.client.Do(upstreamRequest)
if err != nil {
log.Printf("上游转发失败 method=%s path=%s target=%s err=%v", request.Method, request.URL.Path, targetURL.String(), err)
return fmt.Errorf("上游请求失败: %w", err)
}
defer response.Body.Close()
log.Printf("上游响应 method=%s path=%s target_host=%s status=%d", request.Method, request.URL.Path, targetURL.Host, response.StatusCode)
copyResponseHeaders(writer.Header(), response.Header)
writer.WriteHeader(response.StatusCode)
_, err = copyStream(writer, response.Body)
return err
}
func loadConfig(path string) (appConfig, error) {
contents, err := os.ReadFile(path)
if err != nil {
return appConfig{}, err
}
token, err := parseTokenYAML(contents)
if err != nil {
return appConfig{}, err
}
return appConfig{Token: token}, nil
}
func parseTokenYAML(contents []byte) (string, error) {
var cfg appConfig
if err := yaml.Unmarshal(contents, &cfg); err != nil {
return "", fmt.Errorf("解析配置失败: %w", err)
}
token := strings.TrimSpace(cfg.Token)
if token == "" {
return "", fmt.Errorf("token 不能为空")
}
return token, nil
}
func copyRequestHeaders(target http.Header, source http.Header) {
for key, values := range source {
lowerKey := strings.ToLower(key)
if _, exists := hopByHopHeaders[lowerKey]; exists {
continue
}
for _, value := range values {
target.Add(key, value)
}
}
}
func copyResponseHeaders(target http.Header, source http.Header) {
for key, values := range source {
lowerKey := strings.ToLower(key)
if _, exists := hopByHopHeaders[lowerKey]; exists {
continue
}
for _, value := range values {
target.Add(key, value)
}
}
}
func copyStream(writer io.Writer, reader io.Reader) (int64, error) {
buffer := make([]byte, 32*1024)
var total int64
for {
readCount, readErr := reader.Read(buffer)
if readCount > 0 {
chunk := buffer[:readCount]
written, writeErr := writer.Write(chunk)
total += int64(written)
if writeErr != nil {
return total, writeErr
}
if written < len(chunk) {
return total, io.ErrShortWrite
}
if flusher, ok := writer.(http.Flusher); ok {
flusher.Flush()
}
}
if readErr != nil {
if errors.Is(readErr, io.EOF) {
return total, nil
}
return total, readErr
}
}
}
func shouldRequestCarryBody(method string) bool {
switch strings.ToUpper(strings.TrimSpace(method)) {
case http.MethodGet, http.MethodHead, http.MethodDelete:
return false
default:
return true
}
}
func formatBearerAuthorization(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(value), "bearer ") {
return value
}
return "Bearer " + value
}
func buildCursorChecksum(authorization string) string {
const (
checksumTimestampDivisor = 1_000_000
checksumInitialSeed = 165
)
timestamp := time.Now().UnixMilli() / checksumTimestampDivisor
timestampBytes := make([]byte, 6)
timestampBigInt := big.NewInt(timestamp)
for index := 0; index < len(timestampBytes); index++ {
shift := uint((len(timestampBytes) - 1 - index) * 8)
timestampBytes[index] = byte(new(big.Int).Rsh(timestampBigInt, shift).Uint64() & 0xff)
}
seed := checksumInitialSeed
for index := 0; index < len(timestampBytes); index++ {
current := int(timestampBytes[index]^byte(seed)) + (index % 256)
current &= 0xff
timestampBytes[index] = byte(current)
seed = current
}
prefix := strings.TrimRight(base64.StdEncoding.EncodeToString(timestampBytes), "=")
hashBytes := sha256.Sum256([]byte(strings.TrimSpace(authorization)))
hash := fmt.Sprintf("%x", hashBytes)
return prefix + hash[:32]
}
func newHTTPClient() *http.Client {
return &http.Client{}
}
func cloneUpstreamTargets(input map[string]string) map[string]string {
output := make(map[string]string, len(input))
for key, value := range input {
output[key] = value
}
return output
}
+435
View File
@@ -0,0 +1,435 @@
# BidiAppend / RunSSE 原服务架构推断
本文根据实际抓包、已提取的 protobuf 定义以及同一 `conversation_id` 下的消息关联关系,分析 Cursor Agent 原服务采用的通信技术、运行方式和可能的服务架构。
本文只描述协议事实和架构推断,不描述当前项目的实现方案。
## 1. 分析样本
本次分析使用以下会话:
```text
conversation_id: 9b31772c-35fe-4b51-a862-c177749854af
```
最初分析快照包含两个独立 turn;随后该会话又新增第三个 turn。以下表格保留最初两轮的详细帧统计,第三轮的 KV 细节在 KV 专项文档中单独记录:
| Turn | `request_id` | RunSSE 时长 | RunSSE 帧数 | 上行消息 |
| --- | --- | ---: | ---: | --- |
| 1 | `2faaa6b5-6ad7-4428-85f7-8cfc0cb3e52e` | 24,128 ms | 24 | 1 个 `run_request`、4 个 heartbeat、9 个 KV 响应 |
| 2 | `feac27a2-3baf-4153-b009-2809dc9d4cf2` | 3,294 ms | 24 | 1 个 `run_request`、8 个 KV 响应 |
两个 turn 使用相同的 `conversation_id`,但分别使用新的 `request_id`。这说明 `conversation_id` 表示跨 turn 的持久会话,而 `request_id` 表示一次活动请求流或一次 turn 的运行实例。
## 2. 核心结论
该通信方式可以概括为:
> Connect RPC + Protobuf 实现的 split-duplex streaming,上层运行 request 维度的 Agent Actor / Workflow 状态机。
它不是 WebSocket,也不是标准 gRPC 双向流。虽然 RunSSE 响应头使用 `text/event-stream`,但正文不是传统 SSE 的 `data:` 文本事件,而是 Connect 的二进制流式帧。
从应用语义看,它将逻辑双向流拆成两个方向相反的 HTTP 通道:
- `BidiAppend`:客户端通过多个 unary RPC 向服务端发送命令、心跳和本地执行结果。
- `RunSSE`:服务端通过一条 server-streaming RPC 向客户端发送增量事件、工具请求、状态 checkpoint 和流终态。
两条通道通过相同的 `request_id` 关联,共同构成应用层的双向通信。
## 3. 传输技术
### 3.1 Connect RPC
抓包中的请求头包含:
```text
Connect-Protocol-Version: 1
User-Agent: connect-es/1.6.1
```
这说明桌面客户端的协议调用层使用 Connect-ES。它很可能运行在 Cursor 的 Electron / VS Code JavaScript 环境中。
`BidiAppend` 使用:
```text
Content-Type: application/proto
```
这是一个 protobuf unary RPC。每次请求只追加一条 `AgentClientMessage`,服务端返回空的 `BidiAppendResponse` 作为接收确认。
`RunSSE` 请求使用:
```text
Content-Type: application/connect+proto
Connect-Accept-Encoding: gzip
Connect-Content-Encoding: gzip
```
响应使用:
```text
Content-Type: text/event-stream
Connect-Content-Encoding: gzip
```
`text/event-stream` 在这里是兼容性响应类型,实际正文仍使用 Connect 二进制 envelope。因此不能使用标准 EventSource 文本解析器处理该响应。
### 3.2 Connect 流式帧
每个 RunSSE 消息使用以下帧结构:
```text
+------------+----------------------+--------------------+
| flags: 1B | length: uint32 BE | payload: length B |
+------------+----------------------+--------------------+
```
已观察到的 flags
| flags | 功能 |
| --- | --- |
| `0x00` | 未压缩的 protobuf 数据帧 |
| `0x01` | 压缩的数据帧 |
| `0x02` | EndStream 终态帧 |
小型 heartbeat 和 token 增量通常使用 `0x00`,体积较大的 KV 或 checkpoint 消息可能使用 `0x01`。两个样本的最后一帧都是 `0x02`
底层连接可以运行在 HTTP/1.1 chunked response 或 HTTP/2 stream 上。当前抓包不足以确定客户端到原服务实际使用了哪一个 HTTP 版本。
## 4. 一次 Turn 的运行时序
抓包顺序表明客户端通常先建立 RunSSE,再通过 BidiAppend 发送 `run_request`。这样可以在启动 Agent Run 之前准备好下行订阅,避免遗漏早期事件。
```mermaid
sequenceDiagram
participant Client as Cursor Client
participant Gateway as API Gateway
participant Actor as Request Actor
participant Provider as Model Provider
Client->>Gateway: RunSSE(request_id)
Gateway->>Actor: Subscribe(request_id)
Client->>Gateway: BidiAppend(run_request)
Gateway->>Actor: Start turn
Actor->>Provider: Start model call
Provider-->>Actor: Thinking / token / tool deltas
Actor-->>Client: AgentServerMessage stream
Actor-->>Client: KV / Exec / Interaction request
Client->>Actor: BidiAppend(result, append_seqno)
Actor->>Provider: Resume with external result
Actor-->>Client: Conversation checkpoint
Actor-->>Client: EndStream
```
完整生命周期为:
1. 客户端生成本次 turn 的 `request_id`
2. 客户端使用 `BidiRequestId` 建立 RunSSE 下行流。
3. 客户端通过 BidiAppend 发送 `run_request`
4. 服务端启动模型调用并持续发送 `thinking_delta``text_delta``token_delta` 和 step 状态。
5. 服务端需要客户端能力时,通过 RunSSE 发送 KV、Exec 或 Interaction 请求。
6. 客户端执行本地操作,并通过 BidiAppend 返回对应结果。
7. 服务端根据外部结果继续模型循环,或者进入 turn 收口阶段。
8. 服务端同步 checkpoint 及其 blob。
9. 服务端发送 EndStream,结束本次 `request_id` 对应的流。
## 5. 上行顺序与幂等语义
`BidiAppendRequest.append_seqno` 是同一个 `request_id` 内的有序序号。
第一个 turn 中观察到:
```text
run_request append_seqno = 0(字段使用默认值)
client_heartbeat append_seqno = 1..4
kv_client_message append_seqno = 5..13
```
KV 响应对应的 HTTP 请求在抓包记录中并不完全按照序号排列,说明客户端可能并发发起多个 BidiAppend 请求。服务端必须按 `append_seqno` 排序、串行处理或拒绝过期消息,不能依赖 HTTP 请求的到达顺序。
因此 `append_seqno` 至少承担以下功能:
- 确定同一个请求流内的命令顺序。
- 识别重复提交或重试。
- 在多个并发 unary 请求之间恢复确定性处理顺序。
它不是整个 conversation 的全局序号。新的 `request_id` 可以重新从较小的序号开始。
## 6. 标识符与状态边界
### 6.1 `conversation_id`
`conversation_id` 是跨 turn 的持久会话标识。它关联历史消息、checkpoint、token 状态、模式以及 workspace 元数据。
样本中的第二个 `run_request` 已携带第一轮产生的 `conversation_state`,证明 conversation 状态会跨 `request_id` 延续。
### 6.2 `request_id`
`request_id` 是活动流、一次 turn 或一次运行尝试的路由键。它同时出现在:
- RunSSE 订阅请求中。
- BidiAppend 外层请求中。
- `X-Request-Id` HTTP 请求头中。
- 本次 turn 的服务端事件和客户端结果关联关系中。
服务端需要以 `request_id` 找到正在运行的 Actor、事件 backlog、订阅者以及待处理的工具调用。
### 6.3 `run_id`
本次两个样本中的 `run_id` 与各自的 `request_id` 相同,但协议中它们是独立字段。架构设计不应假定两者永久等值:
- `request_id` 偏向传输和活动流路由。
- `run_id` 偏向 Agent 执行实例。
### 6.4 KV `id`
`KvServerMessage.id``KvClientMessage.id` 构成一次服务端到客户端 RPC 的关联键。它与 `append_seqno` 的职责不同:
- KV `id` 关联某个具体请求和响应。
- `append_seqno` 规定所有上行消息的处理顺序。
## 7. Checkpoint 与 Blob 同步
协议中的 KV 虽然以 Key-Value 命名,但它表达的不是普通配置项或业务数据库。它更接近一个由客户端提供的内容寻址 Blob StoreContent-Addressable StoreCAS),用于保存和恢复 conversation checkpoint 的组成部分。
### 7.1 KV 消息语义
服务端通过 RunSSE 发起 KV 操作:
| 消息 | 参数 | 功能 |
| --- | --- | --- |
| `get_blob_args` | `blob_id` | 要求客户端返回此前保存的 Blob。 |
| `set_blob_args` | `blob_id``blob_data` | 要求客户端保存指定 Blob。 |
客户端通过 BidiAppend 返回操作结果:
| 消息 | 参数 | 功能 |
| --- | --- | --- |
| `get_blob_result` | `blob_data``error` | 返回 Blob 内容或读取错误。 |
| `set_blob_result` | 可选 `error` | 确认保存成功,或返回写入错误。 |
KV 消息中存在两类用途不同的 ID
- `KvServerMessage.id`:本次 KV 操作的临时流水号,客户端使用相同值返回 `KvClientMessage`
- `blob_id`:Blob 内容的稳定地址,用来在 checkpoint 和其他协议消息中引用内容。
对该会话中全部 16 个 `set_blob_args` 进行校验后,每一个 `blob_id` 都精确等于对应 `blob_data` 的 SHA-256。由此可以确认这里使用的是内容寻址,而不是随机生成的 KV key:
```text
blob_id = SHA-256(blob_data)
```
相同内容必然得到相同 `blob_id`,内容发生任何改变都会生成新的 ID。因此 Blob 可以被视为不可变对象,重复写入同一 Blob 也天然具有幂等性。
### 7.2 Blob 表达的内容
Blob 主要承载 conversation checkpoint 中体积较大、可以独立复用的 protobuf 节点,例如:
- 用户消息。
- Thinking、Assistant Message 和 ToolCall 等 conversation step。
- Conversation turn。
- Prompt context usage snapshot。
- Rules、Skills、Subagents、MCP 等大型请求上下文。
- 其他通过 `blob_id``data_blob_id``content_blob_id` 引用的二进制内容。
Checkpoint 本身更接近一个引用清单。会话历史可以形成如下内容寻址对象图:
```text
ConversationStateStructure
└─ turns[]: blob_id
└─ ConversationTurnStructure
├─ user_message: blob_id
└─ steps[]: blob_id
├─ ThinkingMessage
├─ AssistantMessage
└─ ToolCall
```
顶层 checkpoint 不必反复内嵌完整历史,只需要保存根引用。Turn Blob 再引用 UserMessage Blob 和多个 Step Blob。这种结构类似一棵由 SHA-256 连接的不可变 Merkle DAG。
### 7.3 写入与读取流程
Turn 结束或状态发生重要变化时,Blob 写入流程为:
1. 服务端将用户消息、conversation step 和 turn 等节点分别序列化。
2. 服务端对每个序列化结果计算 SHA-256,得到 `blob_id`
3. 服务端通过 RunSSE 发送 `set_blob_args`
4. 客户端保存 Blob,并通过 BidiAppend 返回 `set_blob_result`
5. 必要 Blob 全部确认后,服务端发送引用这些 Blob 的 `conversation_checkpoint_update`
6. 服务端完成本次 turn 并发送 EndStream。
下一轮恢复状态时,Blob 读取流程通常为:
1. 客户端将上一轮 checkpoint 随 `run_request` 发回。
2. 服务端读取 checkpoint 和 `request_context_parts` 中的 Blob 引用。
3. 服务端按需通过 RunSSE 发送 `get_blob_args` 请求自己当前缺少的内容。
4. 客户端通过 BidiAppend 返回 `get_blob_result`
5. 服务端使用已持有或刚读取的 Blob 恢复所需上下文并继续运行。
注意:本次样本中的 `get_blob_args` 实际读取的是 `request_context_parts.mcps_blob_id`,不是 `conversation_state.turns[]` 的 Turn Blob。样本没有直接证明服务端会在每个新 turn 中重新读取历史 Turn Blob;服务端可能已经保存或缓存了这些内容。
### 7.4 当前会话中的证据
第一个 turn
- 服务端通过 RunSSE 发送 9 个 `set_blob_args`
- 客户端通过 BidiAppend 返回 9 个 `set_blob_result`
- 服务端随后发送 `conversation_checkpoint_update`
第二个 turn
- `run_request` 已携带上一轮 `conversation_state`
- 服务端先读取 29,974 字节的 `mcps_blob_id`,客户端返回 `get_blob_result`
- 服务端再发送 7 个 `set_blob_args`,客户端逐一确认。
- 服务端发送新的 checkpoint,然后结束流。
后续第三个 turn
- 服务端读取 59,145 字节的新 `mcps_blob_id`
- 服务端发送 14 个 `set_blob_args`,客户端逐一确认。
这两次 `get_blob_result` 的返回数据都与请求的 `blob_id` 通过 SHA-256 校验一致。
该顺序说明 KV 同步不是与 conversation 无关的后台缓存。它直接参与 checkpoint 提交和 turn 收口:服务端先确保必要内容能够被客户端读取,再发布引用这些内容的状态清单。
### 7.5 KV 的架构作用
该设计提供以下能力:
- **缩小 checkpoint**:主状态只携带引用,不必每轮重复传输完整历史。
- **内容去重**:未变化的消息、step 或 turn 使用相同 SHA-256,只需保存一次。
- **幂等写入**:相同 `blob_id` 永远对应相同内容,重复 `set_blob` 不会产生语义冲突。
- **按需加载**:服务端可以只读取当前恢复流程需要的 Blob;当前样本明确观察到的是 MCP 请求上下文按需读取。
- **跨 Worker 恢复**:新的 Agent Worker 可以根据客户端携带的 checkpoint 和 Blob 恢复上下文,不必依赖原进程内存。
- **避免悬空引用**:客户端确认 Blob 已保存后,服务端才发布最终 checkpoint。
- **客户端状态参与**:本地客户端不仅执行工具,也充当 Agent 会话对象存储协议的一部分。
由此可以确认:
- checkpoint 元数据可以由客户端携带到下一轮。
- 较大的 checkpoint 内容使用内容寻址 Blob 拆分。
- 客户端至少承担 Blob 存取接口或本地 Blob 缓存的角色。
- 服务端会等待必要 Blob 写入得到确认,再完成 checkpoint 和 turn 收口。
KV 的本质因此不是“保存几个键值”,而是客户端侧的 Agent 会话对象存储协议。它与 checkpoint 一起构成“客户端携带状态 + 内容寻址 Blob 同步”的混合状态模型。
抓包不能证明服务端完全不保存这些数据,也不能证明其设计目的包含隐私或数据本地化;它只能证明客户端是状态协议中的实际参与者,而不是薄 UI。
## 8. 原服务的逻辑架构
```mermaid
flowchart LR
Client["Cursor Desktop<br/>UI / Local Tools / KV Blob"]
Gateway["API Gateway<br/>Auth / Route / Affinity"]
Actor["Request Actor<br/>request_id"]
Broker["Stream Broker<br/>Backlog / Subscribers"]
Conversation["Conversation State<br/>conversation_id"]
Provider["Model Provider"]
Client -->|"BidiAppend commands/results"| Gateway
Gateway --> Actor
Actor --> Provider
Provider --> Actor
Actor --> Broker
Broker -->|"RunSSE events"| Gateway
Gateway --> Client
Actor <--> Conversation
```
### 8.1 客户端:本地执行面
客户端负责:
- IDE 和 UI 交互。
- 本地文件、终端、编辑器及其他环境能力。
- 接收服务端的 Exec、KV 和 Interaction 请求。
- 执行本地操作并回传结果。
- 携带 conversation checkpoint,并参与 blob 存取。
- 维护上行 `append_seqno` 和连接心跳。
### 8.2 云端:控制面与推理编排器
服务端负责:
- 接收 `run_request` 并创建或恢复 turn。
- 编排模型 provider 调用。
- 将 provider 增量转换为 `AgentServerMessage`
- 管理等待中的本地工具、KV 和用户交互请求。
- 根据外部结果恢复模型循环。
- 生成 checkpoint,并协调 blob 写入确认。
- 发布终态并结束 RunSSE。
因此原服务更接近 Agent workflow orchestrator,而不是一个简单的聊天补全 API。
### 8.3 Request Actor / Workflow
每个活动 `request_id` 很可能对应一个串行状态实例,可抽象为 Actor 或 workflow
```text
created
-> provider_running
-> waiting_external / awaiting_user
-> provider_running
-> checkpointing
-> completed / failed / canceled
```
BidiAppend 是该 Actor 的 command inboxRunSSE 是该 Actor 的 event stream。这个结构具有明显的 CQRS 形态,但仅凭协议不能断言原服务使用了某个具体 Actor 或事件溯源框架。
## 9. 心跳与连接恢复
第一个 turn 中,客户端约每 5 秒通过 BidiAppend 发送一次 `client_heartbeat`。RunSSE 中也出现服务端 heartbeat。
双向心跳分别解决不同问题:
- 客户端 heartbeat 告诉服务端本地控制通道仍存活。
- 服务端 heartbeat 保持 RunSSE 活跃,并帮助客户端发现下行连接异常。
由于业务事件与 `request_id``append_seqno` 和 checkpoint 分离,协议具备处理短暂重连、请求重试和重复 append 的基础。不过,抓包中尚未出现实际断线重连样本,无法确认原服务的 backlog 保留时长和精确恢复策略。
## 10. 水平扩展约束
BidiAppend 与 RunSSE 是两个独立 HTTP 请求。在多副本部署中,它们可能被负载均衡器分配到不同实例,但必须访问同一个 `request_id` 状态。
因此原服务至少需要满足以下一种条件:
1. API Gateway 按 `request_id` 或会话信息执行粘性路由。
2. 所有实例共享活动流存储、消息 Broker 或分布式 Actor runtime。
3. RunSSE 实例只负责订阅共享事件流,实际 workflow 在独立 worker 中运行。
从协议上无法确定原服务具体采用哪一种。更可能的生产形态是“网关 + request workflow worker + 共享状态/事件基础设施”,但这仍属于部署推测。
## 11. 可以确认与不能确认的内容
### 11.1 可以直接确认
- 客户端使用 Connect-ES 1.6.1。
- 业务消息使用 protobuf。
- BidiAppend 是 unary 上行,RunSSE 是 server-streaming 下行。
- RunSSE 使用 Connect 二进制 envelope,而非标准文本 SSE。
- 同一 conversation 的不同 turn 使用不同 `request_id`
- 上行消息通过 `append_seqno` 排序。
- 客户端参与 KV/blob 存取和 checkpoint 延续。
- 每个成功样本最终都收到 EndStream 帧。
### 11.2 由协议必然产生的架构约束
- 服务端必须将两个独立 HTTP 通道汇合到同一个活动请求状态。
- 服务端必须处理并发、乱序、重复或重试的 BidiAppend。
- 服务端需要维护等待中的工具、KV 和 Interaction 关联状态。
- RunSSE 断开时,服务端必须决定取消、保留或允许恢复活动 run。
### 11.3 当前不能确认
- 原服务使用的编程语言和服务框架。
- 客户端到服务端实际使用 HTTP/1.1 还是 HTTP/2。
- 是否使用 Redis、Kafka、Temporal、Orleans、Akka 或其他具体基础设施。
- 是否依赖负载均衡粘性会话。
- 服务端是否也持久保存完整 checkpoint blob。
- RunSSE 重连时 backlog 的保留期限和恢复游标协议。
## 12. 总结
原 Agent 系统可以概括为一个分布式状态机:云端持有推理控制和 workflow,客户端持有 IDE 执行能力并参与会话状态存取。Connect RPC 提供传输封装,BidiAppend 和 RunSSE 共同模拟逻辑双向流,`request_id` 绑定一次活动运行,`conversation_id` 绑定跨 turn 的持久会话,checkpoint 与内容寻址 blob 负责状态延续。
这种设计的主要目的不是单纯流式输出文本,而是在浏览器兼容的 HTTP RPC 上承载可恢复、可排序、可调用本地工具的远程 Agent runtime。
+338
View File
@@ -0,0 +1,338 @@
# KV 协议详细分析
本文专门分析 Agent 协议中的 `KvServerMessage` / `KvClientMessage`。分析依据包括当前 `agent_v1.proto`、Connect 帧结构和本地 SQLite 抓包。
本文中的 KV 不指普通业务配置表,而指服务端通过 RunSSE 调用客户端 Blob Store 的协议。
## 1. 一句话结论
KV 是一个**客户端参与的内容寻址 Blob RPC**:
- 服务端请求客户端按 `blob_id` 保存或读取二进制内容。
- `blob_id` 是 Blob 内容的稳定地址,而不是随机数据库主键。
- Blob 主要用于 conversation checkpoint、prompt context 和其他大型上下文。
- KV 操作发生在 Agent 流内部,不是独立的 HTTP KV 服务。
更准确的技术名称是:
> Client-side content-addressed Blob Store over an application-level reverse RPC.
## 2. 协议分层
KV 不是直接出现在 HTTP body 顶层,而是嵌套在两条 Connect RPC 中。
### 2.1 服务端到客户端
```text
Connect server stream
-> AgentServerMessage
-> KvServerMessage
-> GetBlobArgs / SetBlobArgs
```
对应的 protobuf
```protobuf
message KvServerMessage {
uint32 id = 1;
optional SpanContext span_context = 4;
oneof message {
GetBlobArgs get_blob_args = 2;
SetBlobArgs set_blob_args = 3;
}
}
```
### 2.2 客户端到服务端
```text
Connect unary BidiAppend
-> BidiAppendRequest
-> data: hex(AgentClientMessage)
-> KvClientMessage
-> GetBlobResult / SetBlobResult
```
对应的 protobuf
```protobuf
message KvClientMessage {
uint32 id = 1;
oneof message {
GetBlobResult get_blob_result = 2;
SetBlobResult set_blob_result = 3;
}
}
```
因此,KV 的“请求方向”是 RunSSE,下行;KV 的“响应方向”是 BidiAppend,上行。这是应用层反向 RPC,不是客户端直接向某个 `/kv` HTTP endpoint 发请求。
## 3. 消息和参数
### 3.1 `GetBlobArgs`
```protobuf
message GetBlobArgs {
bytes blob_id = 1;
}
```
功能:要求客户端返回指定 Blob。
`blob_id` 是二进制字段。当前抓包中长度为 32 字节,显示为 Base64 时通常是 44 个字符。
### 3.2 `GetBlobResult`
```protobuf
message GetBlobResult {
optional bytes blob_data = 1;
optional Error error = 2;
}
```
成功时返回 `blob_data`;读取失败时返回 `error.message`。协议没有单独定义 `not_found` 枚举,缺失、损坏和存储错误都需要通过 Error 文本表达。
### 3.3 `SetBlobArgs`
```protobuf
message SetBlobArgs {
bytes blob_id = 1;
bytes blob_data = 2;
}
```
功能:要求客户端按指定地址保存一段完整的 Blob。
KV 本身没有分片字段。一个 Blob 必须在一条 `SetBlobArgs` 中完整传输;大型内容依靠 Connect 的压缩和多个 Blob 拆分,而不是依靠 KV 内部的 chunk 序号。
### 3.4 `SetBlobResult`
```protobuf
message SetBlobResult {
optional Error error = 1;
}
```
没有 `error` 表示写入成功;有 `error` 表示客户端拒绝或无法保存。
### 3.5 `SpanContext`
`KvServerMessage.span_context` 可携带 `trace_id``span_id``trace_flags``trace_state`。它用于分布式追踪,不参与 Blob 寻址、版本控制或响应关联。
## 4. 三个 ID 的区别
KV 运行时同时存在三种容易混淆的 ID:
| ID | 所属 | 作用 | 生命周期 |
| --- | --- | --- | --- |
| `request_id` | Bidi / RunSSE | 绑定一条 Agent 活动流 | 一次 turn 或运行实例 |
| `KvServerMessage.id` | KV 操作 | 关联服务端操作和客户端结果 | 当前 `request_id` 内的一次操作 |
| `blob_id` | Blob 内容 | 内容寻址和引用 | 只要内容或 checkpoint 仍可达就有效 |
此外还有 `BidiAppendRequest.append_seqno`
- `KvServerMessage.id` 解决“哪个 KV 响应对应哪个 KV 请求”。
- `append_seqno` 解决“所有客户端上行消息应按什么顺序处理”。
- 两者不能互相替代。
本地样本中每个新的 `request_id` 都将 KV 操作 ID 从 0 重新开始,而 Bidi 上行序号还会被 heartbeat、Exec 和其他客户端消息占用。
## 5. 内容寻址规则
当前样本明确验证出:
```text
blob_id = SHA-256(blob_data)
```
验证结果:
| 检查项 | 结果 |
| --- | ---: |
| 三个 turn 中观察到的 `set_blob_args` | 30 |
| `blob_id == SHA-256(blob_data)` | 30 / 30 |
| 已观察的 `get_blob_result` | 2 |
| 读取结果通过请求 ID 的 SHA-256 校验 | 2 / 2 |
协议字段本身没有声明哈希算法或版本字段,因此 SHA-256 是根据实际数据推断出来的协议约定。实现时仍应把算法视为可配置或保留版本扩展空间,而不应只依赖“32 字节”这一表象。
内容寻址带来三个直接性质:
1. 相同内容得到相同 ID,可以去重。
2. 内容变化必然得到新 ID,Blob 可以视为不可变对象。
3. 客户端和服务端都能通过重新计算哈希校验传输是否损坏。
空内容也有对应的内容地址。样本中的空 rules 和 subagents 使用 SHA-256 空串值:
```text
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```
## 6. Blob 引用的两类主要用途
### 6.1 Conversation checkpoint
`ConversationStateStructure` 的多个 bytes 字段实际可以承载 Blob 引用,例如:
- `turns[]`
- `root_prompt_messages_json[]`
- `conversation_state_blob_id`
- `prompt_context_usage_snapshot_blob_id`
在当前样本中,Turn Blob 可以解码为 `ConversationTurnStructure`,其结构为:
```text
ConversationTurnStructure
└─ AgentConversationTurnStructure
├─ user_message: blob_id
├─ steps[]: blob_id
└─ request_id
```
UserMessage Blob 可以解码为 `UserMessage`,其中又包含 `conversation_state_blob_id`。这个状态 Blob 继续引用根 Prompt Blob 和其他 checkpoint 数据。
因此 checkpoint 不是一个扁平 JSON,而是一个由多个 protobuf Blob 组成的引用图。
### 6.2 Request context
`ConversationAction.request_context_parts` 使用专门的引用结构:
```protobuf
message RequestContextPartReferences {
bytes rules_blob_id = 1;
uint32 rules_byte_length = 2;
bytes skills_blob_id = 3;
uint32 skills_byte_length = 4;
bytes subagents_blob_id = 5;
uint32 subagents_byte_length = 6;
bytes mcps_blob_id = 7;
uint32 mcps_byte_length = 8;
RequestContext dynamic_context = 9;
}
```
这些 Blob 用来传输较大的 rules、skills、subagents 和 MCP 定义;小型、动态字段继续放在 `dynamic_context` 内。
样本中第二、第三个 turn 的 `get_blob_args` 分别读取:
| Turn | 引用类型 | Blob 大小 |
| --- | --- | ---: |
| 2 | `request_context_parts.mcps_blob_id` | 29,974 字节 |
| 3 | `request_context_parts.mcps_blob_id` | 59,145 字节 |
因此,KV 不只服务于 conversation history,也服务于每轮模型调用需要的大型上下文。
## 7. 当前会话的真实时序
### 7.1 第一个 turn
- 发送 9 个 `set_blob_args`
- 客户端返回 9 个 `set_blob_result`
- 其中包括 UserMessage、ConversationStep、ConversationTurn 和 Prompt/State 相关 Blob。
- 最终 checkpoint 的 `turns[]` 引用本轮的 Turn Blob。
### 7.2 第二个 turn
- `run_request` 携带上一轮 conversation state 和新的 request context 引用。
- 服务端读取 1 个 MCP context Blob,返回数据 29,974 字节。
- 服务端发送 7 个新 Blob,包括本轮消息、步骤、turn 和新的 context 状态。
- 服务端发布新的 checkpoint。
### 7.3 第三个 turn
- 服务端读取新的 MCP context Blob,返回数据 59,145 字节。
- 服务端发送 14 个新 Blob。
- 该 turn 还出现了 Exec 请求和结果,说明 KV 与本地工具协议可以在同一个 request actor 中并行存在。
一个重要结论是:当前样本中的 `get_blob` 不应简单解释为“服务端从客户端读取上一轮对话历史”。实际观察到的 `get_blob` 是 MCP request context。历史 Turn Blob 可能由服务端缓存,也可能在其他未捕获的路径同步;本样本不足以证明其读取路径。
## 8. 并发、顺序和幂等
### 8.1 多个 KV 请求可以并发
服务端可以在一条 RunSSE 中连续发送多个 `set_blob_args`。客户端随后并发发起多个 BidiAppend。
当前样本中,KV 操作 ID 和 HTTP 到达顺序不一致。例如一个 turn 中操作 ID 3、4、5 的响应在抓包记录里并非严格按 3、4、5 排列。这说明服务端不能按 HTTP 请求到达顺序匹配 KV 结果,必须按 `KvClientMessage.id` 关联。
### 8.2 `append_seqno` 是全局上行顺序
KV 结果的 BidiAppend 还会与 heartbeat、Exec 结果共享同一个 `append_seqno` 序列。因此:
- KV 操作 ID 只在 KV 子协议中使用。
- append 序号覆盖所有 `AgentClientMessage`
- 服务端需要先按 append 序号处理上行消息,再按 KV ID 将结果交给对应的等待状态。
### 8.3 写入幂等和 ACK
一次成功的 KV 写入有两层确认:
1. HTTP/Connect 层返回 `BidiAppendResponse`,表示上行 append 被接收。
2. `KvClientMessage.set_blob_result` 没有错误,表示客户端 Blob Store 确实完成写入。
只有第二层确认才代表 Blob 可被后续 checkpoint 引用。重复发送同一个 `blob_id` 不会改变内容,但服务端仍需要处理重复的操作 ID、过期结果和客户端重试。
## 9. 失败语义和边界
KV 协议没有独立的错误枚举、删除、列举、TTL 或批量操作。当前可表达的失败主要是:
- `get_blob_result.error`:客户端找不到或无法读取 Blob。
- `set_blob_result.error`:客户端无法保存 Blob。
- BidiAppend 本身失败:上行 append 未被服务端接受。
- RunSSE 断开或 EndStream 失败:下行 KV 请求可能尚未完成。
因此服务端需要维护 pending KV 操作表:
```text
(request_id, KvServerMessage.id)
-> blob_id
-> waiting checkpoint / turn completion
```
当必要 Blob 写入失败或超时,服务端不能发布引用该 Blob 的成功 checkpoint;应选择重试、降级为未完成状态或结束当前 turn。
## 10. 安全与存储含义
KV 内容通过 HTTPS/Connect 传输,但协议本身没有声明 Blob 的存储加密、租户命名空间或访问权限。生产实现至少应考虑:
- 按用户、workspace 或 conversation 做访问隔离,不能只依赖公开的 SHA-256 值。
- 对 `blob_data` 做大小限制和哈希校验。
- 不把 Blob 正文写入普通请求日志。
- 对未知或过期 `KvClientMessage.id` 做幂等处理。
- 防止通过任意 `get_blob` 探测其他会话的内容。
- 明确客户端 Blob 的持久化、清理和迁移策略。
由于协议没有 delete 或 garbage-collection RPCBlob 生命周期很可能由客户端本地存储策略、checkpoint 可达性或服务端外部存储策略负责。具体实现无法从当前抓包确认。
## 11. 对重写服务的直接启示
KV 不应被建模成一个简单的 `map[string][]byte` API。更合适的抽象是:
```text
BlobStore
Put(content) -> content_hash
Get(content_hash) -> content
Has(content_hash) -> bool
```
上层再增加一次 request-scoped 的 RPC 编排:
```text
BlobOperation
operation_id
request_id
blob_id
kind: get | set
status: pending | succeeded | failed | timed_out
```
Checkpoint 只保存 Blob 引用和小型元数据;Blob 本体由可替换的客户端存储适配器或共享存储适配器负责。KV 操作完成后,必须通过明确的 barrier 通知 checkpoint/turn 状态机继续收口。
## 12. 最终结论
KV 是 Agent 协议中的状态同步层,承担三个角色:
1. **checkpoint 的内容存储**:把历史消息、步骤和 turn 拆成不可变 Blob。
2. **大型上下文传输**:通过引用传递 Rules、Skills、Subagents 和 MCP 数据。
3. **客户端能力桥接**:云端通过 RunSSE 请求本地客户端保存或读取 Blob,再通过 BidiAppend 获得结果。
所以它不是普通的 KV 缓存,而是连接云端 Agent workflow、客户端本地状态和可恢复 conversation 的关键协议层。
+889
View File
@@ -0,0 +1,889 @@
# Loop + Dialect 完整落地方案
## 1. 设计目标
这是一个从零设计的服务端方案,不依赖当前项目的服务端实现。
目标只有四个:
1. Loop 的状态决策是纯函数,外层运行器用递归驱动它,直到得到最终答案。
2. LLM 使用原生请求和原生流事件,不再造一套平行的模型消息结构。
3. Cursor 的 Bidi、RunSSE、protobuf 只存在于 Dialect 和 Transport 中。
4. `messages` 永远是完整、顺序固定、只追加的历史,保证前缀缓存稳定。
模型调用是无状态的。每一次调用都发送完整的 `RequestMessages`,而不是向模型发送“上一次请求的差异”。
## 2. 顶层结构
代码目录只保留四个模块:
```text
server/
loop/ 纯函数状态转换、消息历史和下一步决定
llm/ 原生请求、原生响应流、供应商适配器
transport/ Connect、Bidi、RunSSE、CursorDialect、运行器
store/ SQLite 状态、调用记录、输入和工具去重
```
`CursorDialect``transport/` 里的协议翻译文件,不单独形成目录。客户端是远端能力:服务端向它发工具请求,它经 Bidi 返回工具结果;因此也不在服务端拆出 `client/` 模块。
`store/` 只是基础设施适配器:它保存状态和提交记录,不决定下一步动作。
依赖方向固定为:
```text
transport -> loop
transport -> llm
transport -> store
CursorDialect (inside transport) -> loop / llm
```
`transport/runner` 是很薄的组装代码:它执行 `Command`,把外部结果再送回 `loop``loop` 不依赖 protobuf、HTTP、SSE、连接对象、Store、客户端或具体 LLM 供应商。
## 3. 三类核心数据
### 3.1 模型请求
直接使用 `internal/backend/cursor/llm/request.go` 中的结构:
```text
RequestMessages {
SystemPrompt
Messages []Message
Tools []ToolDefinition
}
```
这里有一个重要边界:
- `Messages` 是会话历史,必须只追加。
- `SystemPrompt``Tools` 是本次请求构建出来的请求部分。
- 前缀缓存约束只针对 `Messages`
- 不能通过合并、去重、重排或“修正上一条消息”来构建历史。
每次请求的模型上下文都是:
```text
RequestMessages {
SystemPrompt: buildPrompt(input, state)
Messages: state.messages
Tools: buildTools(input, state)
}
```
`buildPrompt``buildTools` 可以每次重新计算,但不能修改 `state.messages`
### 3.2 模型响应
直接使用 `internal/backend/cursor/llm/response.go` 中的结构:
```text
ResponseEvent {
Start
TextStart / TextDelta / TextEnd
ThinkingStart / ThinkingDelta / ThinkingEnd
ToolCallStart / ToolCallDelta / ToolCallEnd
Done
Error
}
```
完整响应使用 `AssistantMessage`。工具结果使用 `ToolResultMessage`。用户输入使用 `UserMessage`
`internal/backend/cursor/llm/stream.go` 中的接口是 LLM 边界:
```text
ResponseStream.Recv(context) -> (ResponseEvent, error)
```
LLM 适配器可以将 OpenAI、Anthropic、Gemini 或其他供应商的响应转换为这些原生中间结构,但不能把供应商私有的流格式泄漏到 Loop。
### 3.3 Loop 输入
Loop 只接收有语义的输入,不接收网络数据:
```text
Input =
Start {
userMessage: UserMessage
context: []ContextSupplement
}
| LLMEvent {
callID: string
event: ResponseEvent
}
| ToolResult {
message: ToolResultMessage
}
| UserMessage {
message: UserMessage
}
| Cancel {
reason: string
}
```
`BidiAppend` 解码后只能生成这些输入。Loop 不需要知道输入原来来自 Bidi、HTTP 还是测试代码。
上下文补充如果要被模型看到,必须转换成新的消息追加到历史;不能回写旧消息:
```text
旧 messages + 新 UserMessage(context supplement)
```
## 4. 状态:已提交部分与正在生成部分
运行中的状态分为两部分:
```text
RuntimeState {
committed ConversationState
pendingResponse *PendingResponse
}
ConversationState {
conversationID
turnID
messages []llm.Message
waiting *WaitingClient
status Ready | WaitingLLM | WaitingClient | Final | Failed | Canceled
lastCommitID string
}
```
### 4.1 `messages`
`messages` 是唯一的模型历史:
- 只能在完整的 `UserMessage``AssistantMessage``ToolResultMessage` 完成后追加。
- 已经追加的消息永远不变。
- 顺序永远按照发生顺序排列。
- 不使用 map 作为模型消息容器。
- 不在重放时重新生成时间戳、随机 ID 或不稳定字段。
- 工具调用的签名、思考签名和供应商响应 ID 原样保留。
### 4.2 `pendingResponse`
`pendingResponse` 是本次 LLM 流的临时聚合器,不属于模型历史:
```text
PendingResponse {
callID
partialMessage
openContentBlocks
openToolCalls
usage
}
```
它只接收流中的增量事件。只有 `Done` 才能把完整的 `AssistantMessage` 追加到 `messages`
`pendingResponse` 只存在于内存。每个 delta 都可以被实时发送给 RunSSE,也可以写入诊断日志,但不会被提交到 `ConversationState`
如果进程在 `Done` 前重启:
```text
丢弃 pendingResponse
保留本次 callID、requestHash 和调用状态
从最后一份已提交的 ConversationState 重试相同请求
```
不恢复半截文本,不把旧 delta 与新响应拼接,也不把旧 delta 当作模型消息。这样即使上游流不可续传,模型历史仍然一致。
## 5. 纯函数转换接口
Loop 的核心函数固定为:
```text
transition(state, input) -> Transition
```
返回值:
```text
Transition {
state
emit []llm.ResponseEvent
command Command
}
```
`emit` 使用 LLM 原生 `ResponseEvent`,不创建 `AssistantTextDelta``ToolCallOutput` 等第二套事件。
`Command` 只有几种:
```text
Command =
ContinueLLM
| CallLLM {
callID
request RequestMessages
messagesHash string
}
| CallClient {
operationID
toolCall ToolCall
}
| WaitInput
| Final {
message AssistantMessage
}
| Failed {
message AssistantMessage
}
| Canceled {
reason string
}
```
`Command` 是普通数据,不能携带闭包、连接、channel 或函数指针。这样它可以记录、重放和比较。
## 6. Loop 的递归规则
核心判断是纯函数:
```text
transition(runtimeState, input) -> Transition
```
它不执行 I/O,也不自行取得下一条输入。递归发生在外层运行器:
```text
run(state, input) {
result = transition(state, input)
publish(result.emit)
commitWhenNeeded(result)
if result.command is Final or Failed or Canceled or WaitInput {
return result
}
return runCommand(result.state, result.command)
}
```
`run` 是递归入口,`transition` 是唯一的状态判断函数。网络和客户端调用不能放进纯函数,因此 `runCommand` 是外层执行器:
```text
runCommand(state, command) {
switch command {
case CallLLM:
return consumeLLM(state, command)
case CallClient:
sendClientCommand(command)
return { state, emit: [], command: WaitInput }
case WaitInput, Final, Failed, Canceled:
return { state, emit: [], command }
case ContinueLLM:
return invalidState("ContinueLLM without stream")
}
}
```
`CallClient` 不能同步等待工具返回。它被编码为外层协议消息后,`runCommand` 立即结束本次调用;之后客户端通过 BidiAppend 提交 `ToolResultMessage`Transport 再次调用 `run(loadedState, ToolResult)`
这里不是从旧调用栈继续等待。工具结果是一个新的外部输入,也是递归的下一层。
实现时可以使用异步尾递归、trampoline 或任务调度器避免实际调用栈无限增长,但不能把业务逻辑改成一个可随意修改历史的可变状态循环。
## 7. LLM 流的处理
### 7.1 启动一次调用
`CallLLM` 携带完整请求和请求快照信息:
```text
CallLLM {
callID
request
messagesHash
}
```
`messagesHash` 是发送前按消息顺序对完整 `Messages` 序列做的稳定哈希,用来确认重试时没有改变历史。重试实际使用已保存的 `exactRequest`,不重新 build prompt、tools 或 messages。
执行器:
```text
consumeLLM(state, command) {
stream = llm.call(command.request)
return readLLM(state, command.callID, stream)
}
```
### 7.2 逐个接收事件
```text
readLLM(state, callID, stream) {
event = stream.Recv()
result = transition(state, LLMEvent(callID, event))
publish(result.emit)
commitWhenNeeded(result)
switch result.command {
case ContinueLLM:
return readLLM(result.state, callID, stream)
case CallClient, CallLLM, WaitInput, Final, Failed, Canceled:
return runCommand(result.state, result.command)
}
}
```
上面的 `CallClient` 分支会发送一个客户端请求并返回 `WaitInput`;它不会占用 LLM 流或阻塞 HTTP handler。下一个 Bidi 输入是另一次 `run(loadedState, input)` 调用。
### 7.3 各事件的状态变化
```text
Start
-> 创建 pendingResponse
-> 原样发布 ResponseEvent.Start
TextStart / ThinkingStart / ToolCallStart
-> 打开对应内容块
-> 原样发布事件
TextDelta / ThinkingDelta / ToolCallDelta
-> 追加到 pendingResponse
-> 原样发布事件
TextEnd / ThinkingEnd / ToolCallEnd
-> 关闭对应内容块
-> 原样发布事件
Done(stop)
-> 校验完整 AssistantMessage
-> 将它追加到 messages
-> 清空 pendingResponse
-> command = Final
Done(toolUse)
-> 追加完整 AssistantMessage
-> 清空 pendingResponse
-> 取第一项未完成工具调用
-> command = CallClient
Error / Aborted
-> 丢弃未完成 pendingResponse
-> 保存错误记录
-> 不把半截响应追加到 messages
-> command = Failed 或 Canceled
```
无论模型返回多少个 delta`messages` 最终只追加一条完整的 `AssistantMessage`
`ResponseEvent.Partial` 是 LLM 适配器提供的当前累计视图。Loop 可以用它校验 `pendingResponse` 或供 RunSSE 重连时显示,但不能用它覆盖、修改或合并任何已提交的 `messages`。唯一允许提交到 `messages` 的助手响应来自 `ResponseEvent.Done.Message`
## 8. 外层流协议的对接
外层协议分两层:
```text
Transport
负责连接、读写、framing、断开、heartbeat
Dialect
负责 protobuf 消息与原生语义结构之间的翻译
```
Loop 只产生 `ResponseEvent``Command`,不直接写 RunSSE。
### 8.1 输入方向
```text
BidiAppend request
-> Transport 解 Connect body
-> Dialect.decodeClientMessage
-> Input
-> transition(state, input)
```
`Dialect.decodeClientMessage` 的映射:
```text
run_request.user_message
-> Input.Start 或 Input.UserMessage
exec_client_message.tool_result
-> Input.ToolResult
interaction_response
-> Input.UserMessage 或对应 ClientInput
conversation_action.cancel
-> Input.Cancel
```
Bidi 的 `request_id``append_seqno``conversation_id` 属于 Transport/Dialect 的关联信息,不进入模型消息文本。
### 8.2 输出方向
```text
transition.emit: ResponseEvent
-> Dialect.encodeServerEvent
-> AgentServerMessage
-> RunSSE writer
```
推荐映射:
```text
ResponseEvent.Start
-> 不写协议消息;只初始化本次流的内部关联状态
ResponseEvent.TextStart / ResponseEvent.TextEnd
-> 不写协议消息;Cursor 由 text_delta 表达可见文本
ResponseEvent.TextDelta
-> interaction_update.text_delta
ResponseEvent.ThinkingDelta
-> interaction_update.thinking_delta
ResponseEvent.ThinkingEnd
-> interaction_update.thinking_completed
ResponseEvent.ToolCallStart
-> interaction_update.tool_call_started
ResponseEvent.ToolCallDelta
-> interaction_update.tool_call_delta
ResponseEvent.ToolCallEnd
-> interaction_update.tool_call_completed
Command.CallClient
-> exec_server_message
ResponseEvent.Done(stop)
-> interaction_update.turn_ended
-> RunSSE end-stream
ResponseEvent.Error
-> 协议错误消息或 RunSSE 结构化错误
-> RunSSE end-stream
```
这里的映射只是协议表达方式改变,事件的文本、工具调用 ID、工具名、参数、停止原因和响应 ID 都必须保留。
### 8.3 LLM 流与 RunSSE 的时序
```text
RunSSE 建立
-> 注册 request_id
-> 接收 Start
-> 写出 TextDelta / ThinkingDelta
-> 写出 ToolCallDelta
-> 写出工具请求
-> 等待 BidiAppend 工具结果
-> 继续下一次 LLM 流
-> 写出 Done
-> 关闭 RunSSE
```
RunSSE writer 必须顺序写出事件。不能让多个 goroutine 直接写同一个连接;所有输出先进入一个有序发送队列。
heartbeat 属于 Transport,不属于 LLM `ResponseEvent`,也不进入 `messages`
## 9. Dialect 的边界
Dialect 只包含三类代码:
### 9.1 解码
将 Cursor protobuf 转换为内部输入:
```text
decodeBidiAppend(request) -> InputEnvelope
decodeExecClientMessage(message) -> ToolResult
decodeInteractionResponse(message) -> ClientInput
```
### 9.2 编码
将原生 LLM 事件和客户端命令转换为 Cursor protobuf
```text
encodeResponseEvent(event) -> AgentServerMessage
encodeClientCommand(command) -> ExecServerMessage / InteractionQuery
```
### 9.3 协议关联
Dialect 可以补充协议必需的:
- `request_id`
- `conversation_id`
- `interaction_id`
- `turn_seq`
- `exec_id`
- `tool_call_id`
- Bidi 的 `append_seqno`
Dialect 不可以做以下事情:
- 拼接或修改模型历史。
- 根据文本猜测工具调用。
- 决定是否重试 LLM。
- 执行工具。
- 保存 Loop 状态。
- 把 delta 合并成另一套公共事件。
如果以后增加 WebSocket 方言,只需新增一个编码/解码实现,Loop、LLM 和 Client 不变。
## 10. 工具调用和客户端等待
模型完成一次响应并返回 `StopReasonToolUse` 时:
```text
AssistantMessage(ToolCall)
-> append 到 messages
-> command = CallClient
```
`CallClient` 是一个可持久化的普通数据:
```text
CallClient {
operationID
toolCall {
id
name
arguments
}
}
```
Dialect 将其变成 `exec_server_message`,Transport 发送给客户端。此时 Loop 状态是 `WaitingClient`
客户端返回结果后:
```text
ToolResultMessage
-> append 到 messages
-> 当前工具调用标记完成
-> 仍有未完成工具调用时,command = CallClient(下一项)
-> 全部完成时,清空 waiting,重新 build RequestMessagescommand = CallLLM
```
工具结果只能通过 `ToolCallID` 关联,不能根据消息顺序猜测对应关系。
一条 `AssistantMessage` 可以包含多个 `ToolCall`。第一版固定按该消息中 `Content` 的顺序逐个派发;一个工具结果提交完成后才派发下一个。这样工具结果追加到 `messages` 的顺序是确定的,连续 LLM 请求的前缀也稳定。未来若必须并行执行,也必须等全部结果完成后按原始工具调用顺序统一追加,不能按到达顺序追加。
## 11. 幂等和重试
Loop 的幂等规则如下:
### 11.1 输入去重
每个输入带有 `inputSeq` 或外部稳定 ID
```text
inputID = requestID + appendSeqno
```
已经提交过的输入再次到达时,返回之前记录的 Transition 结果,不重复执行工具或追加消息。
### 11.2 工具调用去重
`operationID``conversationID + turnID + toolCallID` 生成。
执行前查询提交记录:
```text
已完成 -> 直接返回已保存的 ToolResultMessage
执行中 -> 等待原操作结果
未执行 -> 执行一次
```
### 11.3 LLM 重试
LLM 重试必须使用:
```text
同一个 callID
相同的 messagesHash
完全相同的 RequestMessages 序列化结果
```
不合并两次响应,不把第一次的半截文本和第二次的文本拼接起来。只有一个完整、合法的 `Done` 结果可以提交到 `messages`
如果某次响应已经提交,再收到同一 `callID` 的重复流,整次流丢弃,不追加第二条助手消息。
### 11.4 同一会话的顺序
同一个 `conversationID` 的输入和 LLM 流事件必须串行进入 `transition`。这是执行顺序,不是另一套业务状态机:
```text
conversation_id
-> 一条顺序执行链
-> transition
-> SQLite version compare-and-swap
```
可在进程内用按 `conversationID` 的短锁或任务队列减少竞争;SQLite 的 `version` 是最终裁决。任何提交发现版本已变化,就重新加载状态并重新处理尚未提交的输入。不能让两个 LLM 流同时向同一个会话追加消息。
## 12. 前缀缓存保证
每次 LLM 请求满足:
```text
request[n].Messages = request[n-1].Messages + newlyCommittedMessages
```
禁止:
- 修改历史消息内容。
- 合并相邻消息。
- 把多条 tool result 重排。
- 在旧消息中插入新的 context。
- 每次重放重新生成随机 ID 或时间戳。
- 把流式 delta 直接写入历史。
动态 prompt 和 Tools 每次可以重新 build,但 `Messages` 的字节序列必须只增加,不回退、不重写。
这里的“前缀”指每个已存在消息的语义内容和确定性序列化都不变,新增消息只排在末尾。完整 HTTP JSON body 本身不要求是字节前缀,因为 `SystemPrompt``Tools` 可以在本次请求重新 build;供应商适配器的责任是确保既有消息对应的请求片段不发生变化。
建议在每次 `CallLLM` 记录:
```text
messagesHash
messageCount
lastMessageHash
serializedRequestHash
```
测试必须确认连续请求满足前缀关系,而不是只比较消息数量。
## 13. 持久化边界
Store 至少提供以下能力:
```text
load(conversationID) -> ConversationState
loadInputResult(inputID) -> PreviousCommit?
commitInput(inputID, beforeVersion, nextState) -> CommitResult
saveLLMCall(callID, exactRequest, requestHash, status)
saveClientOperation(operationID, request, result)
```
提交顺序固定:
```text
1. transition 得到新状态和 command
2. 对会话状态有变化时,在一个事务中保存 state、inputID 和调用记录
3. 对 CallLLM,先保存 exactRequest 和 callID,再打开上游流
4. 对 CallClient,先保存 waiting 和 operationID,再写出客户端请求
5. LLM delta 实时写入 RunSSE,但不提交到 ConversationState
6. 外部结果作为新的 Input 再进入 transition
```
流中的 delta 默认不落入会话历史,也不需要进入 SQLite outbox。可以单独保存为诊断日志,但不能把诊断日志当作下一次 LLM 的 `Messages`
`AssistantMessage``ToolResultMessage``UserMessage`、未完成的 `CallLLM` 和未完成的 `CallClient` 必须在进程重启后可恢复。RunSSE 连接和未完成 delta 不需要持久化;重连时可以重新打开当前 turn 的 RunSSE,恢复调用后重新流式展示。模型历史不受影响,因为旧 delta 从未提交。
### 13.1 SQLite 最小表结构
第一版不需要事件溯源库。五张表足够:
```text
conversations
conversation_id primary key
version integer -- 每次已提交状态递增
status text
turn_id text
messages_json blob -- 按顺序的 llm.Message 数组
waiting_json blob nullable -- 未完成 CallClient
updated_at_ms integer
input_commits
conversation_id
input_id
committed_version
result_json blob -- 重复 Bidi 输入的返回结果
primary key (conversation_id, input_id)
llm_calls
call_id primary key
conversation_id
request_json blob -- exactRequest
request_hash text
messages_hash text
status text -- planned, streaming, committed, failed, canceled
assistant_hash text nullable
client_operations
operation_id primary key
conversation_id
turn_id
tool_call_id
tool_index integer
request_json blob
result_json blob nullable
status text -- planned, sent, completed, canceled
stream_diagnostics
call_id
event_index
event_json blob
primary key (call_id, event_index)
```
`stream_diagnostics` 是可选表,只用于调试和抓包分析。它绝不能被读取后回填成 `messages`
每次提交使用 SQLite 事务和乐观版本条件:
```text
update conversations
set version = version + 1, ...
where conversation_id = ? and version = ?
```
没有更新到一行说明发生竞争;重新加载后再处理。`input_commits` 的唯一键负责 Bidi 重放去重,`llm_calls.call_id``client_operations.operation_id` 分别负责 LLM 与工具调用去重。
## 14. 取消、断线和错误
### 14.1 用户取消
```text
conversation_action.cancel
-> Input.Cancel
-> transition 返回 Canceled
-> cancel LLM stream / client operation
-> 发布协议取消事件
-> 关闭 RunSSE
```
### 14.2 RunSSE 断线
RunSSE 断开不等于用户取消。只停止当前发送连接,Loop 继续运行一段重连宽限时间。Bidi 仍可提交工具结果或取消命令。
### 14.3 LLM 流错误
```text
Recv error
-> 生成 ResponseEvent.Error
-> 丢弃 pendingResponse
-> 保存 call failure
-> 根据策略 Failed 或重新发起同一 callID
```
不得把网络错误文本写成正常 `AssistantMessage`
### 14.4 客户端工具错误
工具失败仍然生成 `ToolResultMessage{IsError: true}`,追加后交给下一次 LLM。只有协议连接错误、取消或系统不可恢复错误才终止 Loop。
## 15. 推荐执行时序
```text
1. Transport 收到 RunSSE 或 BidiAppend
2. Dialect 验证 request_id、seqno 和 protobuf oneof
3. Store 加载 conversation 的 ConversationState
4. Dialect 将客户端消息解码成 Input
5. transition(state, input)
6. Store 在同一事务中提交新的 state、inputID 和必要的调用记录
7. 将 `ResponseEvent` 编码后按顺序写入 RunSSE;delta 不写入模型历史
8. 执行 commandCallClient 发出请求后返回等待态
9. LLM 流逐事件回到第 5 步
10. 客户端工具结果回到第 4 步
11. Done(stop) 后写出 turn ended,并关闭 RunSSE
```
## 16. 最小接口集合
实现第一版只需要这些接口:
```text
type LLM interface {
Call(context, RequestMessages) -> ResponseStream
}
type Dialect interface {
DecodeBidi(bytes) -> InputEnvelope
EncodeResponse(ResponseEvent, ProtocolContext) -> AgentServerMessage
EncodeClientCommand(Command, ProtocolContext) -> AgentServerMessage
}
type Store interface {
Load(conversationID) -> ConversationState
FindInputCommit(conversationID, inputID) -> PreviousCommit?
CommitInput(inputID, expectedVersion, nextState) -> CommitResult
SaveLLMCall(callID, exactRequest, hashes, status)
SaveClientOperation(operationID, request, status)
}
type Transport interface {
ReceiveBidi()
OpenRunSSE()
Send(AgentServerMessage)
}
```
客户端工具结果由 Bidi 适配器解码并再次送入 `run`,不需要一个阻塞式的 `Client.Execute` 服务端接口。接口名称可以调整,但职责不能跨层移动。
## 17. 测试要求
### 17.1 Loop 纯函数测试
给定相同的 `state + input`,必须得到完全相同的:
- 新状态。
- `emit` 顺序。
- `command` 内容。
- `messagesHash`
覆盖:文本流、思考流、工具调用流、正常完成、长度停止、错误、中断、重复输入。
### 17.2 前缀测试
连续三次调用的 `Messages` 必须满足:
```text
M1 是 M2 的严格前缀
M2 是 M3 的严格前缀
```
测试序列化后的消息字节,而不是只比较对象字段。
### 17.3 Dialect 测试
每一种协议消息都测试:
```text
protobuf -> Input
Input/ResponseEvent -> protobuf
```
重点验证 ID、seqno、工具参数、错误码、停止原因和 oneof 分支没有丢失。
### 17.4 流集成测试
使用假的 `ResponseStream` 依次返回:
```text
Start -> TextDelta* -> ToolCall* -> Done
```
断言:
- 每个 delta 都按顺序发到 RunSSE。
- 只有 Done 后才追加 AssistantMessage。
- 工具结果到达后才启动下一次 LLM。
- 重复 Done 不产生第二条消息。
## 18. 第一版落地顺序
1. 固定 `llm.RequestMessages``ResponseEvent``ResponseStream` 为核心契约。
2. 实现 `ConversationState``Input``Command` 和纯函数 `transition`
3. 实现 `consumeLLM`,验证流事件和 `pendingResponse` 聚合。
4. 实现 `CallClient` 命令、工具请求发送和 `ToolResultMessage` 回传。
5. 实现 Dialect 的 Bidi 解码和 RunSSE 编码。
6. 加入 Store 的状态提交、输入去重和工具操作去重。
7. 最后接入真实 Connect transport、heartbeat、重连和取消。
完成后,新增一种客户端协议只需要新增 Dialect;新增一种 LLM 供应商只需要新增 LLM 适配器;新增一种工具只需要新增工具能力描述和对应的客户端协议映射。Loop 本身不需要增加状态分支。
@@ -0,0 +1,553 @@
# 前后端 ConnectRPC 重构完整方案
## 1. 目标
本方案重构桌面端、前端、本机控制面、代理层和具体服务端实现之间的边界。
最终目标如下:
1. 前端业务通信全部使用 ConnectRPC,不再使用任何 Wails 业务 IPC。
2. `internal/startup` 只负责依赖组装、启动顺序、运行时注册和优雅退出。
3. 操作系统与 Wails 能力统一收敛到 `internal/platform`
4. `internal/backend/app` 只负责产品级本机控制面。
5. Cursor 协议、Agent 和 Prompt 全部归 `internal/backend/cursor`
6. Runtime 是通用服务运行时,不绑定 Cursor,也不使用“Cursor backend”作为领域名称。
7. 当前 Cursor Host 与 MITM 只是一个 Runtime 实现,未来可以并列注册 Devin 等实现。
8. Cursor Host 未处理的接口返回 `404`;代理层未命中的请求原样转发到原始上游。
## 2. 强制边界
### 2.1 禁止业务 IPC
前端禁止继续使用以下能力:
```text
@bindings
Call.ByName
Events.On
Events.Emit
application.NewService
```
Wails 只负责桌面应用生命周期和 WebView,不再承载配置、运行时、模型或事件等业务接口。
### 2.2 Runtime 不绑定 Cursor
`backend/app/runtime.go` 表达的是通用运行时用例:
- 列出可用运行时;
- 启动、停止和重启指定运行时;
- 查询状态和最近一次错误;
- 向前端发布运行时状态变化。
它不能出现以下设计:
```text
CursorBackend
StartCursor
StopCursor
CursorMITMStatus
```
Cursor Host、MITM 和系统代理的组合只存在于启动装配阶段,不进入 App 的通用 DTO。
## 3. 总体架构
```mermaid
flowchart LR
UI["Frontend"] -->|"ConnectRPC"| APP["app.v1.AppService"]
APP --> APPDOMAIN["backend/app"]
APPDOMAIN -->|"RuntimeController"| SUPERVISOR["startup.Supervisor"]
SUPERVISOR --> CURSORRT["Cursor Runtime"]
SUPERVISOR --> DEVINRT["Devin Runtime"]
SUPERVISOR --> FUTURERT["Future Runtime"]
CURSORRT --> CURSORHOST["Cursor Host"]
CURSORRT --> MITM["MITM"]
CURSORRT --> SYSPROXY["platform/network"]
CURSORIDE["Cursor IDE"] --> MITM
MITM -->|"模型和 Agent 路由"| CURSORHOST
MITM -->|"其他请求原样转发"| UPSTREAM["原始上游"]
```
## 4. 目标目录
```text
internal/
├── startup/
│ ├── bootstrap.go
│ ├── wiring.go
│ └── supervisor.go
├── platform/
│ ├── desktop/
│ │ ├── app.go
│ │ ├── window.go
│ │ ├── tray.go
│ │ └── browser.go
│ ├── filesystem/
│ │ ├── paths.go
│ │ └── migrate.go
│ ├── network/
│ │ └── system_proxy.go
│ └── update/
│ └── installer.go
├── backend/
│ ├── app/
│ │ ├── host.go
│ │ ├── module.go
│ │ ├── service.go
│ │ ├── snapshot.go
│ │ ├── events.go
│ │ ├── config.go
│ │ ├── runtime.go
│ │ ├── model.go
│ │ ├── update.go
│ │ ├── desktop.go
│ │ ├── repository.go
│ │ ├── proto/
│ │ │ ├── app_v1.proto
│ │ │ └── types_v1.proto
│ │ └── gen/appv1/
│ │
│ ├── cursor/
│ │ ├── module.go
│ │ ├── host.go
│ │ ├── prompt/
│ │ ├── llm/
│ │ ├── loop/
│ │ ├── store/
│ │ ├── transport/
│ │ ├── proto/
│ │ │ ├── agent_v1.proto
│ │ │ ├── aiserver_v1.proto
│ │ │ ├── from_extensions/
│ │ │ ├── extractor/
│ │ │ └── scripts/
│ │ └── gen/
│ │ ├── agentv1/
│ │ └── aiserverv1/
│ │
│ └── devin/
│ └── .gitkeep
└── proxy/
├── server.go
├── router.go
├── passthrough.go
└── certificate.go
```
前端目标目录如下:
```text
frontend/src/rpc/
├── transport.js
├── appClient.js
├── watch.js
└── gen/
└── appv1/
```
`backend/app` 保持单一扁平 Go package,不按配置、模型等功能继续拆子目录。只有 protobuf 源文件和生成代码保留独立目录。
## 5. 模块职责
### 5.1 `internal/startup`
`startup` 是唯一组合根,负责:
- 创建数据库连接;
- 执行各模块声明的迁移;
- 创建 App、Cursor、Proxy 和 Platform 实例;
- 注入模块依赖;
- 注册所有 Runtime 实现;
- 确定启动和停止顺序;
- 捕获退出信号并等待资源释放。
`startup` 不负责窗口、托盘、浏览器、系统代理命令等具体平台操作,这些能力必须通过 `platform` 注入。
### 5.2 `internal/platform`
`platform` 只包装本机和操作系统能力:
- `desktop`Wails、窗口、托盘和浏览器;
- `filesystem`:数据目录、配置目录、日志目录和文件迁移;
- `network`:系统代理读取、设置和恢复;
- `update`:安装包验证与执行。
只有 `platform/desktop` 可以直接导入 Wails application API。`platform` 不依赖 protobuf、AppService 或 Cursor 协议。
### 5.3 `internal/backend/app`
App 是产品级本机控制面,负责:
- 产品配置;
- 通用 Runtime 控制;
- BYOK 模型配置和连通性测试;
- 应用更新状态;
- 受控桌面动作;
- App 快照和 App 事件流。
App 不负责:
- Cursor IDE 协议;
- MITM 实现;
- 具体 Runtime 的启停细节;
- 操作系统命令。
### 5.4 `internal/backend/cursor`
Cursor 模块拥有所有 Cursor 专属语义:
- Cursor Host 路由;
- Cursor Connect、Bidi 和 RunSSE 协议;
- Agent Loop、Prompt、LLM 和会话存储;
- Cursor 提取的 protobuf 和生成代码。
Cursor 不导入 `backend/app`。需要模型目录等通用数据时,由 Cursor 自己声明小接口,再由 `startup` 注入实现。
### 5.5 `internal/proxy`
Proxy 是通用代理基础设施,不导入 Cursor package。
Cursor 模块提供它能处理的路由集合,`startup` 将路由匹配器和目标地址注入 Proxy。未命中的请求必须保留原请求的 method、path、query、header、body 和流式响应语义,并发送到原始上游。
## 6. Runtime 设计
### 6.1 App 侧端口
`backend/app/runtime.go` 定义通用端口:
```go
// RuntimeController 管理已注册的服务运行时。
type RuntimeController interface {
List(context.Context) ([]RuntimeDescriptor, error)
Start(context.Context, string) error
Stop(context.Context, string) error
Restart(context.Context, string) error
Status(context.Context, string) (RuntimeStatus, error)
}
```
Runtime DTO 只包含通用字段:
```text
RuntimeDescriptor {
id
kind
state
capabilities
endpoint
last_error
revision
}
```
其中 `id` 标识一个配置实例,`kind` 标识实现类型,例如 `cursor``devin`。App 和前端不能根据 Cursor 专属字段决定运行时流程。
### 6.2 Supervisor
`startup/supervisor.go` 实现 `RuntimeController`,维护 Runtime 注册表和状态机:
```text
Stopped -> Starting -> Running -> Stopping -> Stopped
-> Failed
```
必须满足:
- 同一个 Runtime 的启停操作串行执行;
- 重复 Start 和 Stop 具有幂等语义;
- 启动中途失败时回滚已经启动的组件;
- Stop 按 Start 的逆序执行;
- 状态变化携带递增 revision;
- 应用退出时统一停止所有已启动 Runtime。
### 6.3 当前 Cursor Runtime
当前在 `startup/wiring.go` 注册一个 `kind=cursor` 的 Runtime。它的启动顺序为:
1. 校验 Cursor 和模型配置;
2. 启动 Cursor Host
3. 启动 MITM
4. 根据配置启用系统代理;
5. 发布 Running 状态。
停止时按相反顺序恢复系统代理、停止 MITM、停止 Cursor Host。
Cursor Host 和 MITM 是当前实现的内部组件,不应被命名为整个 Runtime。未来接入 Devin 时,只需注册新的 Runtime 实现,不修改 AppService 协议。
## 7. ConnectRPC 服务面
### 7.1 `app.v1.AppService`
第一阶段使用明确方法,不提供通用 JSON Invoke
```text
Bootstrap
Watch
GetConfig
UpdateConfig
ListRuntimes
GetRuntime
StartRuntime
StopRuntime
RestartRuntime
ListModels
SaveModel
DeleteModel
TestModel
GetAds
GetUpdate
CheckUpdate
InstallUpdate
OpenWindow
OpenExternal
```
App 的 `Watch` 第一条消息是完整 App 快照,后续发送带 revision 的增量事件:
```text
snapshot
config_changed
runtime_changed
model_changed
ads_changed
update_changed
```
### 7.2 Cursor IDE 协议服务
Cursor IDE 使用独立 Host。该 Host 只注册:
- 模型列表接口;
- Agent BidiAppend 接口;
- Agent RunSSE 接口。
其他路径全部返回 `404`,不能做代理兜底。代理兜底只能发生在 Proxy 层。
## 8. 两个本地 Host
### 8.1 App Host
App Host 绑定随机回环地址 `127.0.0.1:0`,负责:
- 提供前端静态资源;
- 注册 AppService
- 校验 Origin 和本地会话;
- 提供 ConnectRPC 流式响应。
桌面启动时生成一次性 bootstrap token。WebView 首次访问 bootstrap 地址后,Host 写入 `HttpOnly``SameSite=Strict` Cookie,并重定向到普通首页。前端代码不长期保存 token。
### 8.2 Cursor Host
Cursor Host 绑定 Cursor 配置要求的本机地址,只服务 Cursor IDE 协议。它与 App Host 使用不同的路由表和认证规则。
## 9. 前端架构
前端只保留 `appClient`,负责配置、Runtime、模型、更新和桌面动作。
启动流程如下:
1. 创建同源 Connect-Web transport
2. 调用 AppService `Bootstrap`
3. 启动 `Watch`
4. 按 revision 丢弃重复或乱序事件;
5. 流断开后退避重连,并重新取得完整快照。
前端不导入 Wails runtime,也不通过全局事件总线传递后端状态。
## 10. 数据库与依赖注入
启动过程固定为:
1. `platform/filesystem` 解析数据路径;
2. `startup` 打开数据库连接;
3. App 和 Cursor 分别提供自己的迁移集合;
4. `startup` 按版本执行迁移;
5. 创建 App Repository 和 Cursor Repository
6. 将接口注入对应 Service
7. 注册 ConnectRPC Handler
8. 启动 App Host 和桌面窗口。
模块只能访问自己拥有的表。跨模块调用使用接口,不共享数据库 DTO。
## 11. Proto 和生成代码
新建的产品控制协议位于:
```text
internal/backend/app/proto
internal/backend/app/gen
```
所有 Cursor 专属协议位于:
```text
internal/backend/cursor/proto
internal/backend/cursor/gen
```
当前根目录的 `proto``gen` 以及协议提取器都要迁入 Cursor 模块。提取器必须按 parser、symbols、renderer 等职责拆分,单文件禁止超过 500 行。
前端只生成 AppService 所需的 Web 客户端,不把 Cursor IDE 上游协议暴露给 UI。
## 12. 依赖方向
允许的依赖方向如下:
```text
main -> startup
startup -> platform
startup -> backend/app
startup -> backend/cursor
startup -> proxy
frontend -> app.v1
backend/app -> 自己声明的端口
backend/cursor -> 自己声明的端口
proxy -> 注入的路由和目标接口
```
禁止以下依赖:
```text
backend/app -> backend/cursor
backend/cursor -> backend/app
platform -> backend
platform -> protobuf
proxy -> backend/cursor
任何业务包 -> startup
```
## 13. 现有代码迁移映射
```text
internal/app/runner.go
-> startup/bootstrap.go
-> startup/wiring.go
-> platform/desktop/*
internal/bridge/*
-> 删除
internal/client 中的产品配置、模型、更新
-> backend/app 对应文件
internal/appdata
-> platform/filesystem
系统代理操作
-> platform/network
更新状态与检查
-> backend/app/update.go
安装命令
-> platform/update/installer.go
根 proto、gen 和提取器
-> backend/cursor/proto
-> backend/cursor/gen
```
## 14. TDD 实施顺序
### 阶段一:建立架构守卫
先写失败测试,检查:
- 前端禁止的 Wails IPC 标识;
- App 与 Cursor 禁止互相导入;
- Wails application API 只能出现在 `platform/desktop`
- 根目录不再存在 Cursor `proto``gen`
- 所有手写源码不超过 500 行。
### 阶段二:建立 App ConnectRPC Host
先测试再实现:
- loopback 随机端口;
- bootstrap token 换取 Cookie
- AppService unary 调用;
- Watch 首包快照和 revision
- 非法 Origin 和无会话请求拒绝。
### 阶段三:实现通用 Runtime
使用两个 Fake Runtime 先验证:
- 注册和列出多个 kind
- 幂等 Start 和 Stop
- 并发操作串行化;
- 部分启动失败回滚;
- 逆序停止;
- 状态 revision
- Cursor Runtime 和 Devin Runtime 不需要修改 AppService。
然后再把 Cursor Host、MITM 和系统代理接入 Cursor Runtime。
### 阶段四:切换前端
先为 RPC 状态层编写测试,再替换现有 bindings 和 Events。切换完成后删除 `internal/bridge` 与所有 Wails 业务服务注册。
### 阶段五:迁移 Cursor Proto
迁移 Cursor IDE 协议源文件、生成代码和提取器,并使用协议 fixture 验证迁移前后字节结果一致。
### 阶段六:清理和集成验证
删除旧接口、旧事件、旧生成代码和空目录,运行完整单元测试、集成测试、静态检查与编码风格检查。
## 15. 必须覆盖的测试
### App Host
- 首次 bootstrap 成功且 token 只能使用一次;
- Connect unary 和 server stream 可用;
- 重连后重新获得完整快照;
- Host 停止后连接和 goroutine 全部退出。
### Runtime
- 多种 Runtime 并存;
- 状态转换合法;
- Cursor 启动顺序正确;
- Cursor 停止顺序与启动相反;
- MITM 启动失败时 Cursor Host 被回滚;
- 应用退出时所有 Runtime 被停止。
### Cursor Host 与 Proxy
- 模型列表和 Agent 接口可访问;
- Cursor Host 的其他路径返回 `404`
- Proxy 只拦截 Cursor Host 明确支持的路由;
- 其他请求的 method、path、query、header、body、status 和响应流保持透传语义。
### 前端
- 不存在 Wails bindings 和业务 Events
- App 状态订阅只通过 AppService
- 重复 revision 不会重复更新状态;
- 断流后可以恢复快照和订阅。
## 16. 完成标准
满足以下条件才算重构完成:
1. 前端业务链路全部经过 ConnectRPC。
2. `internal/bridge` 已删除。
3. `backend/app` 只包含产品级配置、Runtime 和桌面控制能力。
4. Runtime API、DTO、状态和测试均不绑定 Cursor。
5. Cursor Host 与 MITM 只作为已注册 Runtime 的当前实现。
6. Cursor 专属 proto、gen 和提取器全部位于 `backend/cursor`
7. Cursor Host 未注册路径稳定返回 `404`
8. Proxy 未命中请求稳定透传到原始上游。
9. 只有 `platform/desktop` 直接使用 Wails application API。
10. 所有新增和调整的源码、测试均使用简洁中文注释,单文件不超过 500 行。
+140
View File
@@ -0,0 +1,140 @@
以下只基于当前代码。
**1. 当前请求 `AgentClientMessage.oneof message` 类型**
协议定义了 8 类上行消息,[agent_v1.proto](/Users/leokun/Documents/cursor-byok/internal/backend/cursor/proto/agent_v1.proto:57)
1. `run_request`
- 新建/恢复一次 Agent 执行。
- 当前提取 `conversation_id`、conversation state、action、用户消息、request context、模型、thinking effort、mode、subagent 信息。
2. `prewarm_request`
- 建立运行态和 checkpoint,但不启动 provider。
3. `conversation_action`
- 会启动 Run`user_message``resume``summarize``start_plan``execute_plan`
- 会取消:`cancel`
- 其他 action 当前基本按 metadata 处理。
4. `exec_client_message`
- 客户端工具执行数据或结果。
- 当前主要处理 Read、Write、Delete、Glob/Grep、Diagnostics、Ls、ShellStream、MCP、Subagent、WriteShellStdin、ForceBackgroundShell、ExecuteHook。
5. `exec_client_control_message`
- `stream_close``throw``heartbeat`
6. `interaction_response`
- 当前处理 AskQuestion、CreatePlan、WebSearch、WebFetch、SwitchMode 的客户端响应。
7. `kv_client_message`
- proto 支持 `get_blob_result``set_blob_result`;当前业务主要消费 `set_blob_result`,用于 checkpoint blob 确认。
8. `client_heartbeat`
- 当前归为 metadata,不推进执行状态。
识别和内部 intent 映射集中在 [inbound.go](/Users/leokun/Documents/cursor-byok/internal/backend/agent/protocol/inbound.go:60) 与 [service.go](/Users/leokun/Documents/cursor-byok/internal/backend/forwarder/service.go:543)。
---
**2. 当前返回 `AgentServerMessage.oneof message` 类型**
外层 6 类全部有实际使用,[agent_v1.proto](/Users/leokun/Documents/cursor-byok/internal/backend/cursor/proto/agent_v1.proto:129)
1. `interaction_update`
- `text_delta`
- `thinking_delta`
- `thinking_completed`
- `summary_started`
- `summary`
- `summary_completed`
- `tool_call_started`
- `partial_tool_call`
- `tool_call_delta`
- `tool_call_completed`
- `shell_output_delta`
- `heartbeat`
- `turn_ended`
2. `exec_server_message`
- 服务端要求客户端执行工具。
- 当前包括 Read、Write、Delete、Grep、Ls、Diagnostics、ShellStream、WriteShellStdin、ForceBackgroundShell、MCP、MCP resource、Subagent、ExecuteHook。
3. `exec_server_control_message`
- 当前只有 `abort`,取消尚未完成的客户端执行。
4. `conversation_checkpoint_update`
- 返回完整的 `ConversationStateStructure` 投影。
5. `kv_server_message`
- 当前主要发送 `set_blob_args`,要求客户端保存 checkpoint blob。
6. `interaction_query`
- 当前包括 AskQuestion、CreatePlan、WebSearch、WebFetch、SwitchMode。
构造入口分别在 [events.go](/Users/leokun/Documents/cursor-byok/internal/backend/forwarder/events.go:17)、[exec bridge](/Users/leokun/Documents/cursor-byok/internal/backend/agent/bridge/exec/bridge.go:66) 和 [interaction bridge](/Users/leokun/Documents/cursor-byok/internal/backend/agent/bridge/interaction/bridge.go:66)。
另外,成功、取消、provider 错误不一定表现为 `oneMessage`:最终通过 `StreamEvent.End` 转换成 Connect end-stream 或结构化错误。
---
**3. 当前需要处理的协议信息**
传输层:
- `POST /aiserver.v1.BidiService/BidiAppend`Connect unary。
- `POST /agent.v1.AgentService/RunSSE`Connect server stream。
- RunSSE 响应头被强制兼容成 `text/event-stream`
- 实际消息仍由 Connect handler 负责 framing。
Bidi 外层:
- `request_id`:整条活动流的主键。
- `append_seqno`:同一 request 上行消息排序和去重。
- `data`:十六进制字符串,解码后才是 `AgentClientMessage protobuf`
- `data_binary`:proto 中存在,但当前实现没有使用。
- `BidiAppendResponse`:始终是空 ACK。
业务关联标识:
- `conversation_id`:持久化会话与历史。
- `request_id`:一次活跃请求以及 Bidi/RunSSE 配对。
- `turn_seq`:会话中的轮次。
- `model_call_id`:一次 provider pass。
- `tool_call_id`:模型工具调用。
- `ExecServerMessage.id + exec_id`:客户端执行请求和回包关联。
- `InteractionQuery.id`:交互查询和响应关联。
- `KvServerMessage.id`checkpoint blob 请求与确认关联。
还需要解析 conversation state、action、mode、requested model、thinking effort、request context、workspace/MCP/skill 信息。当前归一化后的协议载体是 [InboundIntent](/Users/leokun/Documents/cursor-byok/internal/backend/forwarder/types.go:418)。
---
**4. 当前怎样维护 Bidi 和 RunSSE 状态**
Bidi 顺序状态:
- `appendSequenceTracker``request_id` 建立状态。
- 维护 `next``processing``ready`
- 小于 `next` 的消息视为重复并忽略。
- 大于 `next` 的消息等待前序完成。
- Cursor 复用 `request_id` 且重新从 `append_seqno=1` 开始时,会在空闲状态重置序列。
- 状态空闲十分钟后清理。
- `append_seqno <= 0` 会绕过这个顺序机制。
见 [append_seq.go](/Users/leokun/Documents/cursor-byok/internal/backend/forwarder/append_seq.go:11)。
运行状态:
- `StreamBroker` 使用 `map[requestID]*ActiveStream`
- 每个 `ActiveStream` 保存 provider、phase、backlog、subscriber、pending exec、pending interaction、checkpoint 和工具运行状态。
- Bidi、provider event、timer 和 compaction event 都投递到该 stream 的单一 actor mailbox 串行处理。
- Phase 包括 `idle``provider_running``waiting_external``awaiting_user``compacting``checkpointing``completed/failed/canceled`
见 [types.go](/Users/leokun/Documents/cursor-byok/internal/backend/forwarder/types.go:126) 和 [actor.go](/Users/leokun/Documents/cursor-byok/internal/backend/forwarder/actor.go:18)。
RunSSE 状态:
- RunSSE 可以先于 Bidi 到达,此时 Broker 创建只有 `request_id` 的占位 stream。
- 每个 RunSSE 连接注册独立 subscriber,但数据事实源是共享 `Backlog []StreamEvent`
- `Publish` 先追加 backlog,再用容量为 1 的 signal 唤醒订阅者;signal 可以合并,但事件不会丢,因为客户端重新读取 backlog。
- 每个连接从本地 `cursor=0` 开始,所以重新连接会从头回放当前内存 backlog。
- backlog 暂时为空时,每 5 秒直接发送 heartbeatheartbeat 不进入 backlog。
- 最后一个订阅者断开后,给活跃请求 30 秒重连宽限期,之后 actor 执行取消。
- 终态 stream 在无订阅者时保留 30 秒,然后从 Broker 删除。
见 [broker.go](/Users/leokun/Documents/cursor-byok/internal/backend/forwarder/broker.go:131) 和 [service.go](/Users/leokun/Documents/cursor-byok/internal/backend/forwarder/service.go:419)。
关键结论:**Bidi/RunSSE 的活动状态、backlog、cursor、pending exec/interaction 都是内存态;持久化的是 conversation history/checkpoint,不是活动流本身。进程重启后无法恢复原 RunSSE backlog 和正在等待的桥接请求。**
+654
View File
@@ -0,0 +1,654 @@
# Agent Bidi / RunSSE 协议消息参考
本文描述 Agent Bidi / RunSSE 链路中的消息功能、字段语义和消息之间的关联关系。
本文是协议参考,不描述服务端或客户端的内部实现。字段定义以当前 `internal/backend/cursor/proto/agent_v1.proto``internal/backend/cursor/proto/aiserver_v1.proto` 为准。
范围包括 BidiAppend / RunSSE 传输封装、`AgentClientMessage` 的全部顶层分支、`AgentServerMessage` 的全部顶层分支,以及这些分支直接关联的主要请求、响应和控制消息。工具专属的 Args / Result 类型按功能归类,不展开为具体执行流程。
## 1. 协议概览
该协议将一次 Agent 通信拆成两条方向相反的通道:
- `BidiAppend`:客户端向服务端追加消息。
- `RunSSE`:服务端持续向客户端返回消息。
两条通道通过同一个 `request_id` 关联。
```text
客户端 服务端
| |
| BidiAppend(request_id, append_seqno, data)|
|------------------------------------------>|
| |
| RunSSE(request_id) |
|------------------------------------------>|
| |
| stream AgentServerMessage |
|<------------------------------------------|
```
### 1.1 主要关联标识
| 标识 | 范围 | 功能 |
| --- | --- | --- |
| `conversation_id` | 会话 | 标识一个可持续多轮的 Agent 会话。 |
| `request_id` | 请求流 | 关联 BidiAppend、RunSSE 和一次活跃请求。 |
| `run_id` | 运行 | 独立标识一次 Agent Run;不得假定它与 `request_id` 等值。 |
| `message_id` | 用户消息 | 标识一条用户输入。 |
| `model_call_id` | 模型调用 | 标识一次具体的模型调用或 provider pass。 |
| `call_id` / `tool_call_id` | 工具调用 | 标识模型发起的一次工具调用。 |
| `id` | 桥接消息 | 关联 Exec、Interaction 或 KV 的请求和响应。 |
| `exec_id` | 客户端执行 | 标识一次客户端执行任务,可跨多个流式消息。 |
| `append_seqno` | Bidi 请求流 | 表示同一 `request_id` 下客户端上行消息的顺序。 |
### 1.2 Connect 流式帧封装
RunSSE 中的每条消息都位于 Connect 流式帧中。帧由固定 5 字节帧头和消息载荷组成:
| 部分 | 长度 | 功能 |
| --- | --- | --- |
| `flags` | 1 字节 | 描述压缩和流结束状态。 |
| `length` | 4 字节 | 使用大端序表示后续载荷的字节数,不包含 5 字节帧头。 |
| `payload` | `length` 字节 | 普通帧中是 protobuf 消息;流结束帧中是结束状态。 |
`flags` 属于 Connect 传输层,不是 `AgentServerMessage` 或其他 protobuf 消息的字段。当前使用的标志位为:
| 标志 | 含义 |
| --- | --- |
| `0x00` | 普通、未压缩的数据帧。 |
| `0x01` | 压缩的数据帧,载荷需要按照流声明的压缩算法解压后再解析。 |
| `0x02` | 流结束帧,载荷表示 EndStream 状态,不应按业务 protobuf 消息解析。 |
这些值按 bit 表达:最低位 `0x01` 表示压缩,次低位 `0x02` 表示流结束,其余 bit 为保留位。因此,判断帧类型时应读取标志位,而不是把 `flags` 当作 protobuf 枚举。
例如下面的 RunSSE 帧:
```json
{
"kind": "interaction_update",
"messageType": "agent.v1.AgentServerMessage",
"flags": "0x00",
"length": 4,
"compressed": false,
"endStream": false,
"message": {
"interaction_update": {
"heartbeat": {}
}
}
}
```
它表示载荷是一个长度为 4 字节、未压缩且尚未结束流的 `AgentServerMessage``heartbeat` 消息很小,使用 `0x00` 是正常情况;较大的业务消息可能使用 `0x01`
## 2. BidiAppend 传输消息
### 2.1 `BidiAppendRequest`
功能:向指定请求流追加一条客户端消息。
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `data` | `string` | 十六进制编码的 `AgentClientMessage` protobuf 数据。 |
| `request_id` | `BidiRequestId` | 指定消息所属的请求流。 |
| `append_seqno` | `int64` | 指定消息在当前请求流中的追加顺序。 |
| `data_binary` | `bytes` | 二进制形式的消息载荷。 |
约束:
- `data``data_binary` 表达的是消息载荷,不应同时承载语义不同的消息。
- `append_seqno` 只在同一个 `request_id` 内比较。
- 解码后的根消息必须是 `AgentClientMessage`
### 2.2 `BidiAppendResponse`
功能:确认本次 append 请求已经被接收。
该消息没有业务字段。它只确认 unary 请求本身,不代表 Agent Run 已经完成。
### 2.3 `BidiRequestId`
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `request_id` | `string` | 标识 BidiAppend 与 RunSSE 共享的请求流。 |
## 3. 客户端上行根消息
### 3.1 `AgentClientMessage`
功能:封装一条客户端到服务端的 Agent 消息。
`message``oneof`,一条消息只能选择以下一个分支:
| 分支 | 消息类型 | 功能 |
| --- | --- | --- |
| `run_request` | `AgentRunRequest` | 启动或恢复一次 Agent Run。 |
| `exec_client_message` | `ExecClientMessage` | 返回客户端工具执行的数据或结果。 |
| `kv_client_message` | `KvClientMessage` | 返回 blob 读取或写入结果。 |
| `conversation_action` | `ConversationAction` | 追加会话动作,例如继续、取消或执行计划。 |
| `exec_client_control_message` | `ExecClientControlMessage` | 返回客户端执行通道的控制事件。 |
| `interaction_response` | `InteractionResponse` | 回答服务端发起的用户交互请求。 |
| `client_heartbeat` | `ClientHeartbeat` | 表示客户端连接仍然活跃。 |
| `prewarm_request` | `PrewarmRequest` | 提前准备会话、模型和上下文。 |
## 4. `AgentRunRequest`
功能:携带启动或恢复 Agent Run 所需的会话状态、动作、模型与能力信息。
### 4.1 核心字段
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `conversation_state` | `ConversationStateStructure` | 客户端掌握的会话 checkpoint。 |
| `action` | `ConversationAction` | 本次 Run 要执行的会话动作。 |
| `model_details` | `ModelDetails` | 旧式或展示用途的模型信息。 |
| `requested_model` | `RequestedModel` | 本次实际请求的模型、参数和凭据。 |
| `conversation_id` | `string?` | 本次 Run 所属会话。 |
| `run_id` | `string?` | 客户端分配的 Run 标识。 |
### 4.2 工具和上下文字段
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `mcp_tools` | `McpTools` | 本次 Run 可用的 MCP 工具定义。 |
| `mcp_file_system_options` | `McpFileSystemOptions?` | MCP 文件系统能力和描述符。 |
| `skill_options` | `SkillOptions?` | 可用技能及技能加载选项。 |
| `custom_system_prompt` | `string?` | 调用方提供的自定义系统提示。 |
| `exclude_workspace_context` | `bool?` | 是否排除工作区上下文。 |
| `pre_fetched_blobs` | `PreFetchedBlob[]` | 调用前已经取得的 blob 内容。 |
### 4.3 模式和子 Agent 字段
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `subagent_type_name` | `string?` | 当前 Run 使用的子 Agent 类型。 |
| `selected_subagent_models` | `RequestedModel[]` | 为子 Agent 选择的模型。 |
| `selected_subagent_model_details` | `ModelDetails[]` | 子 Agent 模型的展示信息。 |
| `subagent_model_overrides` | `SubagentModelOverride[]` | 按子 Agent 类型覆盖模型选择。 |
| `can_create_cloud_subagents` | `bool?` | 客户端是否允许创建云端子 Agent。 |
| `suppress_subagent_progress_update_tool` | `bool?` | 是否隐藏子 Agent 进度更新工具。 |
| `conversation_group_id` | `string?` | 将多个相关会话归入同一组。 |
### 4.4 客户端能力字段
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `suggest_next_prompt` | `bool?` | 是否请求生成下一条提示建议。 |
| `harness` | `string?` | 标识调用方所使用的 Agent harness。 |
| `dev_raw_model_slug` | `string?` | 开发模式下使用的原始模型标识。 |
| `client_supports_inline_images` | `bool?` | 客户端是否支持内联图片。 |
| `client_supports_send_to_user` | `bool?` | 客户端是否支持 send-to-user 能力。 |
| `computer_use_coordinate_mode` | `string?` | Computer Use 坐标系模式。 |
## 5. `PrewarmRequest`
功能:提前提供模型、会话状态和能力声明,使后续正式 Run 可以复用已经准备好的上下文。
其主要字段与 `AgentRunRequest` 相同,但没有直接携带 `ConversationAction`
| 字段组 | 字段 |
| --- | --- |
| 模型 | `model_details``requested_model` |
| 会话 | `conversation_id``conversation_state``conversation_group_id` |
| 工具 | `mcp_tools``mcp_file_system_options` |
| Prompt | `custom_system_prompt``exclude_workspace_context` |
| 子 Agent | `subagent_type_name``selected_subagent_models``selected_subagent_model_details``subagent_model_overrides` |
| 客户端能力 | `suggest_next_prompt``client_supports_inline_images``client_supports_send_to_user``computer_use_coordinate_mode` |
| 预取 | `pre_fetched_blobs` |
| 候选选择 | `best_of_n_group_id``try_use_best_of_n_promotion` |
## 6. 模型选择消息
### 6.1 `RequestedModel`
功能:描述调用方实际希望使用的模型和运行参数。
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `model_id` | `string` | Provider 模型标识。 |
| `max_mode` | `bool` | 是否启用该模型的 max 模式。 |
| `parameters` | `ModelParameterValue[]` | 额外模型参数,每项包含字符串 `id``value`。 |
| `built_in_model` | `bool` | 是否为内建模型。 |
| `is_variant_string_representation` | `bool` | `model_id` 是否表示模型变体字符串。 |
| `credentials` | `oneof` | `api_key_credentials``azure_credentials``bedrock_credentials`。 |
### 6.2 `ModelDetails`
功能:提供模型展示信息、别名、思考能力和凭据。
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `model_id` | `string` | 模型标识。 |
| `display_model_id` | `string` | 面向 UI 的模型标识。 |
| `display_name` | `string` | 完整展示名称。 |
| `display_name_short` | `string` | 短展示名称。 |
| `aliases` | `string[]` | 可识别的模型别名。 |
| `thinking_details` | `ThinkingDetails?` | 模型思考能力声明。 |
| `max_mode` | `bool?` | 是否启用 max 模式。 |
| `credentials` | `oneof` | API Key、Azure 或 Bedrock 凭据。 |
凭据字段属于敏感信息,不应写入普通日志、错误消息或会话记录。
## 7. `ConversationAction`
功能:描述一次会话级动作。`action``oneof`
### 7.1 公共字段
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `triggering_auth_id` | `string?` | 触发动作的认证主体。 |
| `triggering_user_info` | `TriggeringUserInfo?` | 触发用户的信息。 |
| `request_context_parts` | `RequestContextPartReferences?` | 通过 blob 引用传递的大型上下文部分。 |
### 7.2 动作分支
| 分支 | 主要参数 | 功能 |
| --- | --- | --- |
| `user_message_action` | `user_message``request_context``prepend_user_messages``conversation_history` | 提交新用户消息并开始或继续会话。 |
| `resume_action` | `request_context` | 从已有 checkpoint 或等待点继续会话。 |
| `cancel_action` | `reason``interrupted_pending_tool_call_resolutions` | 取消当前 Run,并可携带未完成工具的解决结果。 |
| `summarize_action` | 无字段 | 请求生成或刷新会话摘要。 |
| `shell_command_action` | `shell_command``exec_id` | 将一次显式 Shell 命令写入会话。 |
| `start_plan_action` | `user_message``request_context``is_spec` | 进入计划编制流程。 |
| `execute_plan_action` | `request_context``plan`、计划文件字段、`execution_mode``plan_id` | 执行已有计划。 |
| `async_ask_question_completion_action` | `original_tool_call_id``original_args``result` | 回填异步 AskQuestion 的结果。 |
| `cancel_subagent_action` | `subagent_id` | 取消指定子 Agent。 |
| `background_task_completion_action` | `completions[]` | 上报后台 Shell 或子 Agent 的进度和终态。 |
| `background_shell_action` | `tool_call_id` | 将指定 Shell 工具调用切换到后台语义。 |
| `background_subagent_action` | `tool_call_id` | 将指定子 Agent 工具调用切换到后台语义。 |
| `subscription_notification_action` | `notifications[]``request_context` | 将订阅系统产生的消息注入会话。 |
| `goal_continuation_action` | 无字段 | 继续当前长期目标。 |
| `inject_context_action` | `injection_id``expected_run_id``user_context/system_context` | 向仍在运行的 Run 注入上下文。 |
## 8. `UserMessage`
功能:描述用户输入及其选择的上下文和运行模式。
| 字段组 | 字段 | 功能 |
| --- | --- | --- |
| 内容 | `text``rich_text``text_blob_id``rich_text_blob_id` | 用户输入的纯文本、富文本或 blob 引用。 |
| 身份 | `message_id``thread_id``prompt_reference_id` | 消息、线程和提示引用标识。 |
| 上下文 | `selected_context``conversation_state_blob_id` | 用户选择的文件、代码或会话状态。 |
| 模式 | `mode``custom_mode_intent` | Agent、Ask、Plan、Debug、Multitask 或自定义模式。 |
| 计划 | `execute_plan_info` | 当前消息关联的计划。 |
| 子 Agent | `subagent_system_reminder``project_details` | 子 Agent 或项目相关信息。 |
| 模拟消息 | `is_simulated_msg``simulated_msg_reason``simulated_message_metadata` | 标记系统代用户生成的输入。 |
| Hook | `hook_additional_contexts` | Hook 产生的附加上下文。 |
## 9. `RequestContext`
功能:描述本次请求可见的工作区、规则、工具和运行环境。
字段较多,按语义分组如下:
| 字段组 | 代表字段 | 功能 |
| --- | --- | --- |
| 环境 | `env` | OS、Shell、工作区路径、时区、终端目录、sandbox 与 Computer Use 能力。 |
| 规则 | `rules``non_file_rules``cloud_rule``disabled_team_rules` | 本次请求适用的规则集合。 |
| 仓库 | `repository_info``git_repos``project_layouts`、完整性标记 | 仓库索引、Git 和项目布局。 |
| MCP | `tools``mcp_instructions``mcp_file_system_options``mcp_meta_tool_options` | MCP 工具与文件系统能力。 |
| 技能 | `skill_options``agent_skills` | 可用技能和技能内容。 |
| 子 Agent | `custom_subagents` | 自定义子 Agent 声明。 |
| 文件 | `file_contents` | 已预取的路径到文件内容映射。 |
| Web | `web_search_enabled``web_fetch_enabled` | Web Search 和 Web Fetch 能力开关。 |
| Hook | `hooks_additional_context``hooks_config` | Hook 配置和附加上下文。 |
| 权限 | `user_permissions_auto_run``project_permissions_auto_run``admin_permissions_auto_run``admin_command_denylist` | 自动执行许可和禁止命令。 |
| 功能能力 | `supports_mcp_auth``read_lints_enabled``search_conversations_enabled``send_message_enabled` | 客户端可提供的附加能力。 |
大型上下文也可以通过 `RequestContextPartReferences` 传递。该结构为 rules、skills、subagents 和 MCP 分别携带 `blob_id` 与字节长度,并用 `dynamic_context` 继续携带小型动态字段。
## 10. `ExecClientMessage`
功能:返回 `ExecServerMessage` 所请求的客户端执行数据或结果。
### 10.1 公共字段
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `id` | `uint32` | 与对应 `ExecServerMessage.id` 相同。 |
| `exec_id` | `string` | 与对应 `ExecServerMessage.exec_id` 相同。 |
| `local_execution_time_ms` | `int32?` | 客户端本地执行耗时。 |
| `hook_additional_contexts` | `HookAdditionalContext[]` | 执行 Hook 返回的附加上下文。 |
| `message` | `oneof` | 工具特定的结果或流式事件。 |
### 10.2 主要结果分支
| 分类 | 分支 | 功能 |
| --- | --- | --- |
| 文件 | `read_result``redacted_read_result` | 返回文件读取结果。 |
| 文件 | `write_result``delete_result` | 返回文件写入或删除结果。 |
| 搜索 | `grep_result``ls_result` | 返回文本搜索、Glob 或目录列表结果。 |
| 诊断 | `diagnostics_result``canvas_diagnostics_result` | 返回代码或 Canvas 诊断结果。 |
| Shell | `shell_result``shell_stream` | 返回一次性 Shell 结果或流式 Shell 事件。 |
| Shell | `background_shell_spawn_result``write_shell_stdin_result``force_background_shell_result` | 返回后台 Shell 创建、输入和后台切换结果。 |
| 上下文 | `request_context_result` | 返回动态构建的 RequestContext。 |
| MCP | `mcp_result``list_mcp_resources_exec_result``read_mcp_resource_exec_result``mcp_state_exec_result` | 返回 MCP 调用和资源操作结果。 |
| Hook | `execute_hook_result` | 返回 Hook 执行结果。 |
| 子 Agent | `subagent_result``force_background_subagent_result``subagent_await_result` | 返回子 Agent 运行、后台切换和等待结果。 |
| Web/Computer | `fetch_result``record_screen_result``computer_use_result` | 返回网页、录屏或 Computer Use 结果。 |
| 权限预检 | `shell_allowlist_precheck_result``mcp_allowlist_precheck_result``web_fetch_allowlist_precheck_result` | 返回 allowlist 检查结果。 |
| Git | `git_diff_response` | 返回 Git diff。 |
| Pi 工具 | `pi_read_result``pi_bash_result``pi_edit_result``pi_write_result``pi_grep_result``pi_find_result``pi_ls_result` | 返回 Pi 工具族的执行结果。 |
| 其他 | `smart_mode_classifier_result``conversation_search_result``agent_store_conflict_result` | 返回模式分类、会话搜索或 Agent Store 冲突处理结果。 |
对于 `shell_stream`,其内部 `event` 也是 `oneof`,常见事件包括:
- `start`:进程已经启动。
- `stdout`:标准输出增量。
- `stderr`:标准错误增量。
- `exit`:进程已经退出。
- `rejected`:执行请求被拒绝。
- `permission_denied`:缺少执行权限。
- `backgrounded`:进程已经转入后台。
## 11. `ExecClientControlMessage`
功能:描述客户端执行通道本身的状态,不承载正常工具结果。
`message``oneof`
| 分支 | 参数 | 功能 |
| --- | --- | --- |
| `stream_close` | `id` | 表示指定 Exec 数据流已经关闭。 |
| `throw` | `id``error``stack_trace?``error_code?` | 表示客户端执行通道异常终止。 |
| `heartbeat` | `id` | 表示指定 Exec 仍然存活。 |
## 12. `InteractionResponse`
功能:返回客户端或用户对 `InteractionQuery` 的响应。
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `id` | `uint32` | 与对应 `InteractionQuery.id` 相同。 |
| `result` | `oneof` | 交互类型对应的响应。 |
当前主要响应分支:
| 分支 | 主要内容 | 功能 |
| --- | --- | --- |
| `ask_question_interaction_response` | `AskQuestionResult` | 返回问题答案、拒绝、错误或异步状态。 |
| `create_plan_request_response` | `CreatePlanResult` | 返回计划 URI以及成功或错误。 |
| `web_search_request_response` | `approved/rejected` | 批准或拒绝 Web Search。 |
| `web_fetch_request_response` | `approved/rejected` | 批准或拒绝 Web Fetch。 |
| `switch_mode_request_response` | `approved/rejected` | 批准或拒绝模式切换。 |
协议还定义 VM 环境、PR 管理、MCP Auth、图片生成、环境替换和 SCM 连接等响应分支。
## 13. `KvClientMessage`
功能:返回 `KvServerMessage` 发起的 blob 操作结果。
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `id` | `uint32` | 与对应 `KvServerMessage.id` 相同。 |
| `get_blob_result` | `GetBlobResult` | 返回 `blob_data``error`。 |
| `set_blob_result` | `SetBlobResult` | 返回可选的写入错误;无错误表示写入成功。 |
## 14. `ClientHeartbeat`
功能:表示客户端 Agent 通道仍然存活。
该消息没有业务字段,也不与 `ExecClientHeartbeat` 混用:
- `ClientHeartbeat` 面向整个 Agent 请求通道。
- `ExecClientHeartbeat` 面向某个具体 `ExecServerMessage.id`
## 15. RunSSE 请求与服务端根消息
### 15.1 RunSSE 请求
RunSSE 请求体是 `BidiRequestId`,只包含:
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `request_id` | `string` | 订阅指定请求流的服务端消息。 |
### 15.2 `AgentServerMessage`
功能:封装一条服务端到客户端的 Agent 消息。
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `ttft_breakdown` | `TtftBreakdown` | 可选的首 token 延迟分解。 |
| `message` | `oneof` | 本条消息的业务载荷。 |
`message` 可以选择以下一个分支:
| 分支 | 消息类型 | 功能 |
| --- | --- | --- |
| `interaction_update` | `InteractionUpdate` | 返回文本、思考、工具和 turn 生命周期更新。 |
| `exec_server_message` | `ExecServerMessage` | 请求客户端执行本地工具。 |
| `exec_server_control_message` | `ExecServerControlMessage` | 控制已经发出的客户端执行。 |
| `conversation_checkpoint_update` | `ConversationStateStructure` | 更新客户端持有的会话 checkpoint。 |
| `kv_server_message` | `KvServerMessage` | 请求客户端读取或写入 blob。 |
| `interaction_query` | `InteractionQuery` | 请求用户或客户端作出交互决策。 |
## 16. `InteractionUpdate`
功能:承载模型输出和一次 turn 中的增量状态。
`message``oneof`。当前主要消息如下。
### 16.1 文本和思考
| 分支 | 参数 | 功能 |
| --- | --- | --- |
| `text_delta` | `text``is_server_notice` | 返回可展示文本增量。 |
| `thinking_delta` | `text``thinking_style?` | 返回思考文本增量及展示样式。 |
| `thinking_completed` | `thinking_duration_ms` | 表示思考阶段结束。 |
### 16.2 工具调用
| 分支 | 参数 | 功能 |
| --- | --- | --- |
| `tool_call_started` | `call_id``tool_call``model_call_id` | 宣布工具调用已经建立。 |
| `partial_tool_call` | `call_id``tool_call``args_text_delta``model_call_id` | 在参数尚未完整时返回部分 ToolCall。 |
| `tool_call_delta` | `call_id``tool_call_delta``model_call_id` | 返回 Shell、Task、Edit 或环境替换的增量。 |
| `tool_call_completed` | `call_id``tool_call``model_call_id` | 表示工具调用已经得到终态结果。 |
| `shell_output_delta` | `stdout/stderr/start/exit` | 返回 Shell 进程输出和生命周期增量。 |
同一次工具调用的这些消息必须使用相同的 `call_id`;同一次模型调用产生的工具事件应使用相同的 `model_call_id`
### 16.3 摘要和结束
| 分支 | 参数 | 功能 |
| --- | --- | --- |
| `summary_started` | 无字段 | 表示摘要阶段开始。 |
| `summary` | `summary` | 返回摘要文本。 |
| `summary_completed` | `hook_message?` | 表示摘要阶段完成,并可携带后续 Hook 信息。 |
| `turn_ended` | token 统计字段 | 表示当前 turn 正常结束。 |
`turn_ended` 的 token 字段包括:
- `input_tokens`
- `output_tokens`
- `cache_read_tokens`
- `cache_write_tokens`
- `reasoning_tokens`
### 16.4 保活
| 分支 | 参数 | 功能 |
| --- | --- | --- |
| `heartbeat` | 无字段 | 保持 RunSSE 活跃,不表示业务状态变化。 |
协议还定义 `user_message_appended``token_delta`、step 生命周期、prompt suggestion、branch change、feedback、response comparison 和 context injection state 等更新。
## 17. `ExecServerMessage`
功能:要求客户端执行一项本地能力。
### 17.1 公共字段
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `id` | `uint32` | 执行桥消息编号,客户端回包必须原样携带。 |
| `exec_id` | `string` | 执行任务标识,流式消息应保持一致。 |
| `span_context` | `SpanContext?` | 可选的分布式追踪上下文。 |
| `accept_hook_additional_contexts` | `bool?` | 是否接受客户端在结果中返回 Hook 附加上下文。 |
| `message` | `oneof` | 工具特定的执行参数。 |
### 17.2 主要执行分支
| 分类 | 分支 | 功能 |
| --- | --- | --- |
| 文件 | `read_args``write_args``delete_args` | 读取、写入或删除文件。 |
| 搜索 | `grep_args``ls_args` | 搜索文本、匹配路径或列出目录。 |
| 诊断 | `diagnostics_args` | 获取编辑器或项目诊断。 |
| Shell | `shell_stream_args` | 启动流式 Shell 命令。 |
| Shell | `write_shell_stdin_args``force_background_shell_args` | 向 Shell 写入输入或切换后台执行。 |
| MCP | `mcp_args``list_mcp_resources_exec_args``read_mcp_resource_exec_args` | 调用 MCP 工具或读取 MCP 资源。 |
| Hook | `execute_hook_args` | 请求客户端执行 Agent Hook。 |
| 子 Agent | `subagent_args` | 请求客户端启动子 Agent。 |
协议还定义普通 Shell、后台 Shell 创建、RequestContext、Fetch、Computer Use、allowlist 预检、Git diff、Pi 工具、会话搜索和 Agent Store 冲突等执行分支。
### 17.3 回包规则
客户端返回 `ExecClientMessage``ExecClientControlMessage` 时:
- `id` 必须与请求一致。
- 如果存在 `exec_id`,应与请求一致。
- 流式执行可以返回多条数据消息。
- 最终结果、`throw` 或明确终态用于结束本次执行关联。
## 18. `ExecServerControlMessage`
功能:控制此前已经发出的 Exec 请求。
当前协议分支:
| 分支 | 参数 | 功能 |
| --- | --- | --- |
| `abort` | `id` | 请求客户端终止对应的 Exec。 |
`abort.id` 对应 `ExecServerMessage.id`,不是 `tool_call_id`
## 19. `InteractionQuery`
功能:请求用户或客户端完成不能由模型单独决定的交互。
### 19.1 公共字段
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `id` | `uint32` | 交互编号,响应必须原样携带。 |
| `query` | `oneof` | 具体交互内容。 |
### 19.2 主要查询分支
| 分支 | 主要参数 | 功能 |
| --- | --- | --- |
| `ask_question_interaction_query` | `args``tool_call_id` | 向用户展示一个或多个问题。 |
| `create_plan_request_query` | `args``tool_call_id` | 请求客户端创建或保存计划。 |
| `web_search_request_query` | `args` | 请求批准 Web Search。 |
| `web_fetch_request_query` | `args``skip_approval``smart_mode_approval` | 请求批准或执行 Web Fetch。 |
| `switch_mode_request_query` | `args.target_mode_id``explanation?``tool_call_id` | 请求切换 Agent 模式。 |
协议还定义 VM 环境、PR 管理、MCP Auth、图片生成、环境替换和 SCM 连接查询。
### 19.3 响应规则
客户端必须用 `InteractionResponse` 返回结果:
- `InteractionResponse.id` 与查询 `id` 相同。
- `result` 分支必须与原查询类型匹配。
- 批准/拒绝型响应应明确选择对应的 `oneof` 分支,不能用空消息代替拒绝。
## 20. `KvServerMessage`
功能:请求客户端提供或保存较大的二进制数据。
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `id` | `uint32` | KV 操作编号。 |
| `span_context` | `SpanContext?` | 可选追踪上下文。 |
| `get_blob_args` | `GetBlobArgs` | 按 `blob_id` 读取数据。 |
| `set_blob_args` | `SetBlobArgs` | 按 `blob_id` 保存 `blob_data`。 |
客户端使用相同 `id` 返回 `KvClientMessage`
Blob 字段是原始 bytes。协议使用 `blob_id` 引用它们,以避免在主要会话消息中重复传输大型上下文。
## 21. `ConversationStateStructure`
功能:表示可跨请求传递的会话 checkpoint。
该结构既可以由客户端随 `AgentRunRequest` 上传,也可以由服务端通过 `conversation_checkpoint_update` 返回。
| 字段组 | 代表字段 | 功能 |
| --- | --- | --- |
| Prompt | `root_prompt_messages_json` | 根 Prompt 消息,元素以 bytes 保存。 |
| Turn | `turns``turn_timings` | 历史 turn 和耗时。 |
| 工具 | `pending_tool_calls` | 尚未解决的工具调用。 |
| 状态 | `todos``plan``plans` | Todo 和计划状态。 |
| Token | `token_details` | 已用 token、最大 token 和上下文分解。 |
| 摘要 | `summary``summary_archive``summary_archives``self_summary_count` | 当前摘要和历史摘要。 |
| 文件 | `file_states``file_states_v2``read_paths` | 会话涉及的文件状态。 |
| 工作区 | `previous_workspace_uris``tracked_git_repo_branches``active_branch_name` | 工作区和 Git 状态。 |
| 模式 | `mode``agent_type` | 当前 Agent 模式和类型。 |
| 子 Agent | `subagent_states``subagent_threads``subagent_runs_by_parent_tool_call_id``subagent_state_refs` | 子 Agent checkpoint。 |
| 通信进度 | `communicate_update_*` | 长任务进度和最终摘要。 |
| 会话时间 | `conversation_started_timestamp_ms``conversation_started_time_zone` | 会话开始时间。 |
| Goal | `goal_state` | 长期目标状态。 |
注意:多个字段使用 `bytes`,其内部内容通常仍是另一种 protobuf 或 JSON 编码。消费者必须依据字段定义解码,不能把所有 bytes 都当作 UTF-8 文本。
## 22. 消息配对关系
### 22.1 Run
```text
AgentClientMessage.run_request
-> AgentServerMessage.interaction_update (...多条)
-> AgentServerMessage.conversation_checkpoint_update
-> AgentServerMessage.interaction_update.turn_ended
-> stream end
```
### 22.2 Exec
```text
AgentServerMessage.exec_server_message(id, exec_id)
-> AgentClientMessage.exec_client_message(id, exec_id) (...可多条)
-> AgentClientMessage.exec_client_control_message(id) (...可选)
```
### 22.3 Interaction
```text
AgentServerMessage.interaction_query(id, query)
-> AgentClientMessage.interaction_response(id, matching_result)
```
### 22.4 KV
```text
AgentServerMessage.kv_server_message(id, get/set)
-> AgentClientMessage.kv_client_message(id, matching_result)
```
## 23. `oneof` 与可选字段规则
- 同一个 `oneof` 在一条 protobuf 消息中只能设置一个分支。
- 未设置 `optional` 字段和设置为默认值在业务语义上可能不同,消费者需要保留 presence 信息。
- 未识别的 protobuf 字段应按 protobuf 兼容规则保留或忽略,不应导致整条消息无法解析。
- 请求与响应的类型必须匹配,不能只依赖相同的 `id`
- `request_id``conversation_id``model_call_id``tool_call_id``exec_id` 和桥接 `id` 属于不同命名空间,不应互相替代。
- 增量消息只表达追加内容;接收方不应把 delta 当作完整快照覆盖已有内容。
- checkpoint 表达完整状态视图;同类的新 checkpoint 可以替代旧 checkpoint。
## 24. 错误与终止语义
协议需要区分三类结束:
1. 正常业务结束
- 典型信号是 `InteractionUpdate.turn_ended`,随后流结束。
2. 用户或系统取消
- 可能先出现 Exec `abort`,随后 RunSSE 以 canceled 状态结束。
3. 协议、provider 或服务错误
- 可以通过 Connect end-stream error 返回,不一定存在对应的 `AgentServerMessage.oneof` 分支。
因此,客户端不能仅凭“流关闭”判断正常完成;还需要结合最后一条业务消息和流终止状态。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+414
View File
@@ -0,0 +1,414 @@
# cursor-byok 当前架构文档
> 基线:当前工作树,而不是历史提交或产品宣传文档。
>
> 更新时间:2026-08-10
## 1. 结论先行
当前项目是一个单进程、本地优先的 Wails 桌面应用。Go 进程同时承担三类职责:
1. 通过本机 App Host 向 Vue WebView 提供产品控制面;
2. 启动一个 Cursor Runtime,包含 Cursor Host、MITM 代理和 Cursor IDE 系统设置注入;
3. 把 Cursor 的本地协议请求转换为用户配置的 OpenAI/Anthropic 兼容模型请求。
当前主进程只注册一个 `cursor-default` Runtime。旧架构中的 Cursor 账号控制面、广告服务和广告资源路由已经从当前工作树移除,不应再作为现状组件绘制。`cursor-tab-server` 仍是独立的命令行程序,不由桌面进程创建。
## 2. 系统上下文
```mermaid
flowchart LR
User["用户"] --> UI["Vue 3 WebView"]
Cursor["Cursor IDE"] --> Proxy["本机 MITM Proxy"]
UI -->|同源 Connect-Web| Host["App Host\n127.0.0.1:随机端口"]
Proxy -->|四条白名单接口| CursorHost["Cursor Host\n本机 BackendListenAddr"]
Proxy -->|非白名单请求原样回源| CursorCloud["Cursor 官方服务"]
CursorHost -->|统一消息与流事件| Provider["Provider Router"]
Provider -->|兼容 HTTP/SSE| ModelAPI["用户配置的模型 API"]
Host --> AppService["AppService ConnectRPC"]
AppService --> Runtime["Supervisor / Runtime"]
AppService --> Test["Model Test Manager"]
AppService --> Metrics["Usage JSON 读取"]
AppService --> Desktop["Wails Desktop Controller"]
```
### 2.1 进程内边界
```text
main.go
└─ startup.Run(组合根)
├─ platform/desktop Wails 窗口、托盘和系统动作
├─ backend/app 产品控制面、快照和 Watch
├─ backend/cursor Cursor Runtime、协议循环和模型链路
├─ proxy MITM、证书和请求转发
├─ modeltest 模型列表与连通性测试
├─ historymetrics usage.json 统计读取
├─ updater 更新检查、下载和安装
└─ platform/* 文件系统、系统代理、证书与平台适配
```
`backend/app` 只依赖产品级端口(配置、Runtime、模型测试、指标、更新、桌面动作);具体实现由 `internal/startup` 的适配器注入。Cursor 协议实现位于 `backend/cursor`,不能让前端或 AppService 直接依赖其 Protobuf/Provider DTO。
## 3. 组合根与启动顺序
`internal/startup/bootstrap.go` 是唯一组合根,拥有长期资源并决定释放顺序。
```mermaid
sequenceDiagram
autonumber
participant M as main.go
participant S as startup.Run
participant FS as filesystem
participant CR as Cursor Runtime
participant AP as AppService
participant H as App Host
participant D as Wails Controller
participant SV as Supervisor
participant U as Updater
M->>S: Run(嵌入的前端资源和图标)
S->>FS: EnsureDataRoot()
S->>S: 初始化统一 HTTP Transport 和 CA Manager
S->>CR: newCursorRuntime()
Note over CR: config.yaml + Config Manager\nagent-state.db + Runner + Provider Factory\nCursor Host + Proxy 工厂
S->>SV: Register(cursor-default)
S->>AP: NewService(配置/Runtime/模型/指标/更新/桌面端口)
S->>H: NewHost(前端资源 + AppService Handler)
S->>H: Start() 监听回环随机端口
S->>D: Run(BootstrapURL, ModelConfigURL, 托盘动作)
D-->>S: ApplicationStarted
S->>U: Start()
S->>SV: Start(cursor-default)
SV->>CR: StartProxy()
CR->>CR: 启动 Cursor Host
CR->>CR: 健康检查,最长 15 秒
CR->>CR: 创建/启动 MITM Proxy
CR->>CR: 安装 CA 并写入 Cursor IDE 代理设置
CR-->>SV: 返回代理 endpoint
D-->>S: OnShutdown
S->>U: Shutdown()
S->>AP: Close(),取消产品 Watcher
S->>SV: Shutdown(),逆序停止 Runtime
SV->>CR: StopProxy()
CR->>CR: 停止 Proxy、清理 IDE 设置、停止 Cursor Host
S->>CR: Close(),关闭 Agent Module 和 SQLite
S->>H: Stop()
```
启动过程失败时,Runtime 按已完成步骤逆序回滚。Supervisor 对同一个 Runtime 使用串行操作锁,重复 Start/Stop 不会并发交错。桌面事件循环退出后,`Run` 仍会再次调用幂等 `shutdown`,确保命令行异常退出和窗口退出都能释放资源。
## 4. 产品控制面
### 4.1 App Host
`internal/backend/app/host.go` 把静态前端和 AppService 放在同一个回环 HTTP 服务中:
- 默认监听 `127.0.0.1:0`,实际端口由系统分配;
- `/bootstrap?token=...` 只允许一次 GET,用一次性 token 换取会话 Cookie
- Cookie 为 `HttpOnly``SameSite=Strict`,后续请求必须携带;
- 请求还要通过 Host Origin 校验,只允许空 Origin 或当前回环 Host
- SPA 未命中静态文件时回退到 `/`
- 当前只挂载 `/app.v1.AppService/`
### 4.2 AppService API 分组
定义文件为 `internal/backend/app/proto/app_v1.proto`,当前 API 可按职责分为:
| 分组 | RPC |
| --- | --- |
| 首屏与状态 | `Bootstrap``Watch` |
| 配置 | `LoadConfig``SaveConfig` |
| Runtime | `ListRuntimes``GetRuntime``StartRuntime``StopRuntime``RestartRuntime` |
| 模型 | `TestModelAdapter``GetModelAdapterTestResults``FetchModelAdapterModels` |
| 指标与应用信息 | `GetHomeMetrics``GetAppInfo` |
| 桌面动作 | `OpenPath``OpenExternal``SetLocale``ControlWindow` |
| 更新 | `CheckForUpdates``InstallReadyUpdate` |
Service 层只做请求校验、端口调用、领域错误到 Connect 错误的映射和 DTO 转换。配置、Runtime、测速和更新的并发/持久化责任分别留在对应实现中。
### 4.3 快照与 Watch
服务端 `eventHub` 为事件分配全局递增 `revision``Watch` 每次连接先发送完整 `BootstrapResponse` 快照,随后发送配置、Runtime、模型测试和更新事件。慢订阅者无法及时消费时会被断开,客户端通过重连重新获取快照,而不是在服务端保留无限事件队列。
```mermaid
sequenceDiagram
participant V as Vue 状态层
participant W as watchCore
participant T as Connect Transport
participant H as App Host
participant A as AppService
participant E as eventHub
V->>W: subscribeAppEvents(listener)
W->>T: Watch(afterRevision=0)
T->>H: 同源二进制 Connect 请求
H->>A: Watch()
A-->>W: Snapshot(revision=N)
W->>V: 应用完整快照
A->>E: 配置/Runtime/测速/更新变化
E-->>W: AppEvent(revision>N)
W->>V: 应用增量事件
Note over W: 断流后按 250ms~5s 指数退避重连;\nBigInt revision 去重,重连首包强制视为快照
```
前端另外保留 `localStorage` 作为启动缓存,但后端配置和 Runtime 快照才是运行时事实来源。当前 `bootstrapAppState` 仍会分别调用配置、测速、版本、Runtime 和指标 RPC;Watch 流用于持续同步变化。
## 5. 前端结构
```text
frontend/src/
main.js Vue、路由、i18n 和初始状态启动
layouts/MainLayout.vue 桌面主布局
views/Home.vue 服务状态、首页指标和更新入口
views/Config.vue 通用应用配置
views/ModelConfig.vue 模型渠道管理
components/ 模型编辑、测速卡片、指标卡和 UI 基础组件
rpc/ Connect-Web Transport、App Client、Watch 重连
services/clientApi.js 页面语义到 RPC 的薄封装
state/ 响应式状态、配置规范化、用户动作和派生视图
i18n/ zh-CN/en-US/ja-JP/ru-RU 运行时国际化
```
模型配置编辑器支持 OpenAI/Anthropic 类型、端点、密钥、模型 ID、推理/思考参数、额外 JSON 参数、自定义请求头、排序、复制、删除、批量测速和供应商模型列表拉取。保存前由前端校验,再通过 `SaveConfig` 完整提交;后端 `configAdapter` 只合并 AppService 定义的字段。
## 6. Runtime 与 MITM
### 6.1 Supervisor 状态
通用 Runtime 状态为 `stopped → starting → running → stopping → stopped`,失败进入 `failed`。当前注册项为:
```text
id: cursor-default
kind: cursor
capabilities: agent, models, proxy
```
`RuntimeDescriptor.Endpoint` 对前端只暴露代理 endpoint。前端的 `runtimeToLegacyState` 把它投影为 `backendRunning/proxyRunning/serviceRunning` 等旧页面字段,因此 UI 看到的是兼容视图,而不是 Cursor Runtime 的全部内部状态。
### 6.2 Cursor Runtime 内部步骤
```mermaid
stateDiagram-v2
[*] --> stopped
stopped --> starting: Supervisor.Start
starting --> host_ready: Cursor Host Start + HealthCheck
host_ready --> proxy_ready: 创建并启动 MITM Proxy
proxy_ready --> running: CA/IDE 设置 Apply 成功
starting --> failed: 配置、监听或健康检查失败
host_ready --> failed: Proxy 创建/启动失败
proxy_ready --> failed: IDE 设置失败
running --> stopping: Supervisor.Stop
stopping --> stopped: 清理 Proxy + IDE + Host 成功
stopping --> failed: 清理失败
```
Host 使用配置中的 `BackendListenAddr`Proxy 使用 `ProxyListenAddr`。代理目标固定为本机 Cursor Host;Runtime 明确拒绝通过外部接口绕过 Host 修改 `baseURL`。若 Proxy 监听地址发生变化,必须先停止运行中的服务,再创建新代理实例。
### 6.3 请求分流
`internal/backend/cursor/routes/routes.go` 是当前路由事实源。仅当请求同时满足以下条件时,MITM 才把请求转发到本机 Cursor Host
- CONNECT 目标是 `cursor.sh` 或其子域名;
- 请求方法是 POST
- 路径属于四个白名单接口:
```text
/aiserver.v1.AiService/AvailableModels
/aiserver.v1.AiService/GetUsableModels
/aiserver.v1.BidiService/BidiAppend
/agent.v1.AgentService/RunSSE
```
其余请求保持原请求回源,代理不读取 body、不改写 URL/headers。MITM 使用内置 CA 动态签发目标站点证书,并缓存按主机生成的证书;HTTP 客户端经过 `netproxy.NewTransport`,统一遵守环境变量和系统代理设置,同时绕过 localhost/127.0.0.1/::1。
## 7. Cursor Agent 执行链
```mermaid
sequenceDiagram
autonumber
participant C as Cursor IDE
participant P as MITM Proxy
participant H as Cursor Host
participant D as CursorDialect
participant R as Agent Runner
participant L as loop 状态机
participant DB as SQLite Store
participant F as Provider Factory
participant A as Provider Adapter
participant API as 模型 API
participant B as Broker
C->>P: POST BidiAppend(request_id, append_seqno)
P->>H: 转发本机白名单接口
H->>D: DecodeBidi
D-->>R: InputEnvelope(Start / Cancel)
R->>DB: FindInputCommit + Load(conversation)
R->>L: TransitionState(Start)
L-->>R: CommandCallLLM + 新消息历史
R->>DB: CommitTransition(CAS + planned llm_call)
R->>F: ForModel(model, conversation, request)
F-->>R: 统一 LLM 客户端
R->>A: Call(RequestMessages)
A->>API: OpenAI/Anthropic 流式请求
API-->>A: SSE/chunk
A-->>R: ResponseEvent(当前桥接主要是文本/usage/Done/Error
R->>L: TransitionState(LLMEvent)
R->>B: Publish(request_id, 编码前的事件)
C->>P: POST RunSSE(request_id)
P->>H: 转发本机白名单接口
H->>B: Next(request_id, index)
B-->>C: AgentServerMessage 流
R->>DB: CommitTransition(最终助手消息 + llm_call)
B-->>C: end=true
```
### 7.1 协议入口实际支持范围
- `BidiAppend` 解码 `AgentClientMessage`;当前 Dialect 接受用户消息启动回合和取消动作,但取消消息没有携带 `ConversationID`,进入 Runner 后会落到缺少会话标识的错误路径。
- `RunSSE``request_id` 从 Broker 顺序消费,直到结束事件或错误。
- `AvailableModels`/`GetUsableModels` 从配置渠道生成 Cursor 需要的模型目录。
- Runner 对同一 `conversationID` 加互斥锁;输入按 `conversationID + inputID` 去重。
- 状态提交使用 SQLite version CAS,模型调用的 planned/final 事实与会话提交放在同一事务边界。
### 7.2 当前工具链边界
当前代码已经定义了工具、thinking、图片、usage 和供应商 tool-call 事件的数据模型,OpenAI/Anthropic 适配器也包含 thinking/工具调用流解析与参数累积逻辑。但桌面主链路仍有两个明确限制:
1. `internal/backend/cursor/provider/provider.go` 创建 `StreamRequest` 时把 `Tools` 固定为 `nil`,并且只把文本、思考内容和工具结果文本投影到 Provider Message,助手 ToolCall 不会进入上游请求;
2. Provider bridge 当前只消费 `ModelEventKindTextDelta``ModelEventKindTurnFinished`,没有把 thinking/tool 事件转换成 `ResponseEvent``CursorDialect` 虽定义了对应编码分支,实际主链路拿不到这些事件,`Runner.runCommand` 也没有执行 `CommandCallClient`
因此当前“有效运行闭环”是用户文本生成、usage、完成和错误收口;thinking、工具调用和取消属于已建模或已接入口但尚未形成可靠端到端闭环的扩展面,不能在架构图中标成已完成能力。
## 8. 模型 Provider 层
```text
Runner
→ provider.Factory.ForModel
→ llm/adapter.Router
→ store/config.Manager.SelectChannelForModel
→ OpenAIAdapter 或 AnthropicAdapter
→ 用户配置的 BaseURL + API Key
```
Router 根据 Cursor 请求中的模型 ID 选择渠道,并注入:
- provider 类型、BaseURL、API Key、真实上游模型 ID
- OpenAI Responses/Chat Completions 端点和推理强度;
- Anthropic thinking/max_tokens/额外参数;
- 自定义请求头、provider 流空闲超时、上下文窗口和输出限制。
适配器负责请求体构造、SSE 解码、thinking 标签/签名处理、工具调用参数增量、usage 归一化、重试和空闲超时。`modeltest.Manager` 是独立的测试通道,不复用 Agent Runner 的会话状态。
## 9. 配置、存储和数据流
### 9.1 配置边界
配置文件为 `~/.cursor-local-assistant-v2/config.yaml``store/config.Manager` 负责规范化、原子快照、保存通知和热加载;`startup/config_adapter.go` 把内部配置投影为 AppService 的 `UserConfig`
AppService 公开的配置包括日志开关、provider 流空闲超时、模型渠道和首页缓存命中率口径。监听地址、运行时私有字段由 Cursor 配置管理器保留;前端仍有少量旧字段缓存,但它们不属于当前 AppService Protobuf 合同。
### 9.2 SQLite 事实模型
数据库路径为 `~/.cursor-local-assistant-v2/history/agent-state.db`,当前迁移创建:
| 表 | 作用 |
| --- | --- |
| `conversations` | 版本化会话状态、消息历史和等待客户端信息 |
| `input_commits` | 输入幂等提交和可重放结果 |
| `llm_calls` | 精确请求、请求哈希、消息哈希和调用状态 |
| `client_operations` | 工具客户端操作事实模型(当前执行链尚未完整使用) |
| `stream_diagnostics` | 可选流诊断事件,不回写模型历史 |
模型流期间的 delta 保存在内存中的 `PendingResponse` 和 Broker;只有回合收口后的助手消息才追加到 `ConversationState.Messages`。这是保持历史 append-only 和 provider prompt cache 稳定性的关键约束。
### 9.3 文件与平台资源
| 路径 | 内容 |
| --- | --- |
| `config.yaml` | 产品/模型配置 |
| `history/agent-state.db` | Agent 会话和调用事实 |
| `history/usage.json` | 首页用量摘要 |
| `logs/app-YYYY-MM-DD.log` | 按本地日期切换的结构化日志文件,权限 0600 |
| `data/ca.crt` | 注入系统和 Cursor 的 CA 文件 |
| Cursor `state.vscdb` | Runtime 启动时同步本地模拟用户信息并关闭绕过代理的实验开关 |
## 10. 桌面外壳、更新与观测
Wails `Controller` 只负责窗口、托盘、外部浏览器、白名单目录和语言同步,不承载模型业务。主窗口默认加载 App Host 的 bootstrap URL;模型配置窗口加载同一 Host 的 `/model-config` 路由。
更新管理器在 Wails 应用启动后开始后台检查,状态通过 AppService Watch 的 `UpdateChanged` 事件到达前端;安装动作委托平台 Installer,完成后通过桌面控制器退出应用。
日志层当前使用 `slog` + `charm.land/log/v2`:控制台输出彩色信息日志,文件输出 logfmt;标准库 `log` 被转接到统一门面。代理连接/TLS/转发错误带有按错误特征的时间窗口限流,避免大量重复错误淹没日志。
## 11. 生命周期和失败处理
```mermaid
stateDiagram-v2
[*] --> stopped
stopped --> starting: StartRuntime
starting --> running: Host ready + Proxy ready + IDE Apply
starting --> failed: 任一步失败
running --> stopping: StopRuntime / App shutdown
stopping --> stopped: Proxy stop + IDE Clear + Host stop
stopping --> failed: 清理失败
failed --> starting: 重试启动
failed --> stopped: 后续停止完成清理
```
启动预算和回滚策略:
- Cursor Host 单次健康检查超时 1 秒,整体等待预算 15 秒;
- Runtime 启动失败时停止已经启动的 Proxy 和 Host;
- 停止顺序为 Proxy → 清理 Cursor IDE/system proxy → Cursor Host
- 进程退出时再关闭 Transport Module、SQLite、Supervisor、Updater 和 App Host
- Provider 流、Agent 任务和 Broker 在 Module.Close 时通过运行域 context 统一取消。
## 12. 当前工作树中必须关注的事实与风险
### 已确认的现状
- 当前主进程只有 AppService 控制面;账号、广告相关 Go 包、Proto、前端组件和 RPC 客户端均已删除。
- 当前 Runtime 注册表仍为可扩展的多 Runtime 抽象,但实际只注册 Cursor 一个实例。
- Provider 适配器覆盖 OpenAI Responses/Chat Completions 和 Anthropic Messages,支持 thinking、usage、部分工具事件解析。
- Agent Dialect/Runner 仍是受限 MVP:主要闭环为用户消息 → 模型流 → 文本下行 → usage/完成或错误;thinking、工具结果和取消仍有桥接缺口。
- 首页指标适配器只读取 `history/usage.json`;当前仓库没有对应写入器,新安装环境会得到空指标,除非该文件由外部或尚未合入的链路生成。
- `cursor-tab-server` 是独立 Go module,使用固定 Cursor Tab 上游路径和 YAML token,不共享桌面进程的 Host、Cookie 或 Runtime。
### 当前验证阻塞
本次分析执行了后端相关测试,但当前工作树的 `go.mod` 已移除 `charm.land/log/v2` 直接依赖,而 `internal/logger/logger.go` 仍导入该包,因此 `go test` 在编译 cursor/proxy/startup 相关包时失败。该依赖不一致属于工作树现状,文档没有擅自修改。
## 13. 演进建议
1. 先打通 Provider → Runner → Dialect 的工具闭环:传递 `Tools`、保留助手 ToolCall、发布 Cursor 工具事件、接受工具结果,再驱动下一轮 `CommandCallLLM`
2. 在 `AppService` 错误边界引入稳定领域错误类型,减少当前基于错误文本的 Connect code 判断。
3. 将前端旧的监听地址缓存字段从状态合同中清理,避免用户误以为可以通过产品配置修改 Runtime 拓扑。
4. 为 Runtime、Proxy、Runner、Provider 调用统一注入 request/conversation/call trace ID,打通结构化日志、SQLite 和性能测试结果。
5. 修复依赖锁定后再执行 `go test ./...`、前端 RPC 测试和跨平台构建;协议生成任务继续以 `build/Taskfile.yml` 为唯一入口。
## 14. 关键源码索引
| 主题 | 入口 |
| --- | --- |
| 进程入口 | [`main.go`](../main.go) |
| 组合根 | [`internal/startup/bootstrap.go`](../internal/startup/bootstrap.go) |
| Runtime 管理 | [`internal/startup/supervisor.go`](../internal/startup/supervisor.go) |
| AppService 合同 | [`internal/backend/app/proto/app_v1.proto`](../internal/backend/app/proto/app_v1.proto) |
| AppService 实现 | [`internal/backend/app/service.go`](../internal/backend/app/service.go) |
| App Host 认证与 SPA | [`internal/backend/app/host.go`](../internal/backend/app/host.go) |
| App 事件与快照 | [`internal/backend/app/events.go`](../internal/backend/app/events.go)、[`internal/backend/app/snapshot.go`](../internal/backend/app/snapshot.go) |
| Cursor Host | [`internal/backend/cursor/host.go`](../internal/backend/cursor/host.go) |
| Cursor Runtime 生命周期 | [`internal/backend/cursor/runtime_lifecycle.go`](../internal/backend/cursor/runtime_lifecycle.go) |
| MITM 分流 | [`internal/proxy/router.go`](../internal/proxy/router.go)、[`internal/proxy/passthrough.go`](../internal/proxy/passthrough.go) |
| 路由事实源 | [`internal/backend/cursor/routes/routes.go`](../internal/backend/cursor/routes/routes.go) |
| Agent 传输 | [`internal/backend/cursor/transport/handler.go`](../internal/backend/cursor/transport/handler.go)、[`internal/backend/cursor/transport/cursor_dialect.go`](../internal/backend/cursor/transport/cursor_dialect.go) |
| Agent 状态机 | [`internal/backend/cursor/loop/transition.go`](../internal/backend/cursor/loop/transition.go) |
| Agent 协调器 | [`internal/backend/cursor/agentrun/runner.go`](../internal/backend/cursor/agentrun/runner.go) |
| Provider 路由 | [`internal/backend/cursor/llm/adapter/router.go`](../internal/backend/cursor/llm/adapter/router.go) |
| Provider 桥接 | [`internal/backend/cursor/provider/provider.go`](../internal/backend/cursor/provider/provider.go) |
| SQLite 持久化 | [`internal/backend/cursor/store/sqlite.go`](../internal/backend/cursor/store/sqlite.go)、[`internal/backend/cursor/store/conversations.go`](../internal/backend/cursor/store/conversations.go) |
| 前端 RPC 与重连 | [`frontend/src/rpc/watchCore.js`](../frontend/src/rpc/watchCore.js)、[`frontend/src/services/clientApi.js`](../frontend/src/services/clientApi.js) |
| 前端状态 | [`frontend/src/state/appState.js`](../frontend/src/state/appState.js)、[`frontend/src/state/appActions.js`](../frontend/src/state/appActions.js) |
| 日志与按日文件 | [`internal/logger/logger.go`](../internal/logger/logger.go)、[`internal/logger/daily_file.go`](../internal/logger/daily_file.go) |
| 构建与协议生成 | [`Taskfile.yml`](../Taskfile.yml)、[`build/Taskfile.yml`](../build/Taskfile.yml) |
-2
View File
@@ -1,2 +0,0 @@
registry "https://registry.npmmirror.com"
network-timeout 120000
-12
View File
@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Cursor助手</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More