Qwen Code 的 VS Code 扩展如何接入 qwen serve:IDE Daemon Adapter 设计与 DaemonIdeConnection 实现
在 Qwen Code(qwen-code)中,VS Code companion 扩展默认通过 ACP stdio 子进程(qwen --acp)与 Agent 通信。为了让 IDE 也能消费守护进程模式(Mode B)——即直接连接本地运行的 qwen serve HTTP/SSE 服务——仓库中定义了 IDE Daemon Adapter 的完整设计契约,并已在扩展侧落地了可本地验证的 DaemonIdeConnection 适配器骨架。读完本篇,你将掌握:扩展宿主(extension host)如何持有 daemon URL、token、session id 与 SSE 重放状态,并只把"净化后"的应用事件转发给 webview;daemon 事件到既有 webview 回调的映射契约;以及该适配器在循环地址校验、权限应答、断线恢复上的源码级实现细节。
设计目标:扩展宿主独占 daemon 连接,webview 不直连
设计文档 ide.md 开宗明义地提出了目标:让 VS Code companion 扩展通过 DaemonSessionClient 从扩展宿主连接到 qwen serve,完成 Mode B 的 dogfood(内部自测)。其中最关键的一条架构约束是:webview 绝不能直接调用 daemon。扩展宿主独占 daemon 的 URL、token、session id 和 SSE 重放(replay)状态,只把经过净化的应用事件转发给 webview。这样做的直接收益是 bearer token 永远不会出现在 webview 的 JavaScript 运行环境里——这一条在文档的 Merge Safety 一节中被再次强调为硬性合并安全条件("Daemon token never crosses into webview JavaScript")。
这一职责切分在实际代码中得到了印证。daemonIdeConnection.ts 的文件头注释说明它是一个 "Daemon-backed IDE connection spike",其目标是"镜像 ACP 进程连接的外在形状(shape),同时把本地子进程替换为 qwen serve 会话"。文件内注释也明确:它只做传输层(transport-only),真正的 webview 桥接逻辑放在 ChatWebviewViewProvider.ts 等 provider 里,provider 订阅连接类的回调并转换为 webview 的 postMessage 调用。
入口:实验性开关与连接参数
文档给出了两类入口配置。第一类是 VS Code 设置(文档中的提案形态):
{
"qwen-code.experimentalDaemon.enabled": true,
"qwen-code.experimentalDaemon.url": "http://127.0.0.1:4170",
"qwen-code.experimentalDaemon.token": ""
}
第二类是本地 dogfood 用的环境变量回退:
QWEN_IDE_DAEMON_URL=http://127.0.0.1:4170 code .
需要注意当前仓库的实际落地状态:DaemonIdeConnection 已经实现并通过单测验证,但尚未接线到默认的 QwenAgentManager 路径,因此现有 VS Code 行为仍然是 ACP 子进程模式。从源码结构看,上述 experimentalDaemon.* 设置项与 QWEN_IDE_DAEMON_URL 环境变量目前仍是设计文档中的提案形态,尚未在扩展代码中检索到对应的读取实现;当前扩展侧可编程入口是 DaemonIdeConnection.connect() 的选项对象,包含 baseUrl、token、workspaceCwd、modelServiceId、lastEventId 五个参数(外加可注入的 sessionFactory,用于测试时替换真实工厂)。
daemon 侧默认监听 http://127.0.0.1:4170,这正对应文档示例中的 URL。关于 daemon 的启动与安全模型,可参考用户文档 qwen-serve.md:默认的 loopback 部署是"trusted loopback mode",本地调用方无需 bearer 即可访问全部 API(包括以 daemon 用户执行代码),在共享主机上应使用 --require-auth 配合 QWEN_SERVER_TOKEN。
最小连接流程与 SDK 分层
文档定义的 8 步最小流程是:
- 扩展宿主创建
DaemonClient; - 拉取
/capabilities并校验工作区兼容性; - 通过
DaemonSessionClient.createOrAttach()创建或附着会话; - 在扩展宿主中订阅
session.events(); - 把 daemon 事件翻译成既有的 webview 消息;
- 通过
session.prompt()发送用户提示; - 把取消 / 切换模型路由到
session.cancel()与session.setModel(); - 把权限决定路由到
session.respondToPermission()。
这条流程的 SDK 支撑在 packages/sdk-typescript 中。DaemonClient 镜像原始 HTTP API,每个方法都需要显式传入 sessionId;DaemonSessionClient 则是面向适配器的上层——它的类注释明确写道,它是 "adapter-facing layer for TUI, channel, IDE, and web backends":绑定一个 daemon 会话、转发 Stage 1 路由、并保存 SSE 重放状态,同时刻意不解释事件载荷(类型化事件归约属于协议 schema 层)。
createOrAttach() 中有一个值得注意的细节:当会话是新创建的(session.attached === false),或者附着时携带了 modelServiceId / approvalMode 变更时,首次事件订阅会用 Last-Event-ID: 0 从 daemon 重放环(bounded ring)的起点开始消费。源码注释解释了原因:新会话创建窗口内子进程的 MCP 发现会同步执行,期间触发的 mcp_budget_warning 等推送事件会先落入 per-session 事件环;如果首个订阅不从这个窗口回溯,这些事件就永远到不了实时流。这也正是文档验证计划中"SSE reconnect uses tracked Last-Event-ID"一条的底层机制。
DaemonIdeConnection:连接、事件泵与断线恢复
扩展侧的核心类 DaemonIdeConnection(daemonIdeConnection.ts)对外暴露 connect()、disconnect()、sendPrompt()、cancelSession()、setModel() 五个方法,以及五个可赋值回调:onSessionUpdate、onPermissionRequest、onAskUserQuestion、onEndTurn、onDisconnected。它的设计要点包括:
循环地址硬校验。 connect() 内部先调用 validateDaemonBaseUrl()(见 L108-L122),强制要求:协议必须是 http: 或 https:;URL 中不得内嵌用户名/密码;主机名必须是 loopback(localhost、::1、[::1]、127.0.0.0/8,包括 ::ffff: 映射形式)。这是一条客户端侧硬约束,与 daemon 自身的 hostAllowlist 是两回事——即使运营者配置了远端 daemon,IDE 伴生扩展也绝不连接。其威胁模型假设是:工作区与 daemon 共享同一台主机,包括文件系统信任等前提。若坚持要连远端,正确姿势是 SSH 端口转发或本地代理。
连接串行化。 connect() 用内部 connectPromise 队列保证并发调用串行化:第二次调用会先等待(并容忍)第一次的结果再执行,避免"用户在手握中反复开关面板"导致的竞态,最终收敛到单一确定状态。
SSE 事件泵。 connectInternal() 建立会话后启动 pumpEvents(),对 session.events({ signal, lastEventId: resumeId, resume: true }) 做 for await 消费。resumeId 取"IDE 自己的权威重放游标 lastSeenEventId,否则取 SDK 侧游标"。每个事件处理后推进游标;若处理器抛错,游标不推进(shouldAdvanceLastSeenEventId 保持 false),使该事件在下次重连重放时能被重新处理——这在权限应答失败的场景下尤为关键。事件泵用 pumpGeneration 代数计数器防止旧泵在 finally 中误清掉新一代泵的状态。
终止事件的三路收敛。 会话终止有三种来源:daemon 推送 session_died(且载荷 sessionId 必须与当前会话匹配,否则忽略)、SSE 流自然结束(触发 onDisconnected(null, 'stream_ended'))、流异常(触发 onDisconnected(null, 'daemon_error'));手动 disconnect() 则触发 'disconnected'。收到 session_died 时,扩展宿主可携带上一次的 lastEventId 重新 connect() 完成恢复。
事件映射契约:daemon 事件到 IDE 回调
文档给出了如下事件映射契约表:
| Daemon 事件 | IDE 处理 |
|---|---|
session_update / agent_message_chunk |
既有的助手流式回调 |
session_update / agent_thought_chunk |
既有的思考流式回调 |
session_update / tool_call |
既有的工具调用更新回调 |
permission_request |
既有的审批 UI 回调 |
permission_resolved |
关闭/更新审批 UI |
model_switched |
尽可能映射到既有模型状态回调 |
session_died |
断连 UI + 重连入口 |
并规定:未知事件必须被忽略,或仅记录为 debug 元数据。
实际实现中的 handleEvent()(L373-L394)正是按这个契约落地的:session_update 转发给 onSessionUpdate;permission_request 进入权限应答流程;session_died 走断连处理;其余事件类型一律落 debug 日志后放行。session_update 的 data 载荷是 ACP 的 SessionNotification,其中 update.sessionUpdate 字段(agent_message_chunk、agent_thought_chunk、tool_call 等)由上层的 webview provider 进一步翻译成消息面板里的助手流、思考块与工具卡片——与 ACP 子进程路径喂给 webview 的回调保持同构,这正是文档"两条路径应尽可能喂给同一组高层 webview 回调"要求的落点。对于暂时无法忠实映射的事件,设计原则是显式暴露"不支持状态"的告警,而不是假装等价。
onEndTurn 有一个容易误读的点:它不是 SSE 分发产生的。sendPrompt() 会等待 daemon 的 HTTP prompt 响应,成功后以 response.stopReason 调用 onEndTurn;非 Abort 的异常路径则调用 onEndTurn('error') 后再抛出。
权限应答:从 quick-pick 到 respondToPermission
权限请求的处理链比映射表更复杂。handlePermissionRequest() 收到 permission_request 后:
- 先做载荷形状校验(必须有字符串
requestId、对象型toolCall、数组型options),畸形数据记录告警并放行,不推进会出错; - 根据
toolCall.kind分流:若是ask_user_question且rawInput.questions为数组,走onAskUserQuestion回调,用户回答后把answers放进RequestPermissionResponse的顶层透传字段(daemon 的 HTTP 权限路由保留顶层透传字段,ACP 会话从该位置读取answers);否则走普通onPermissionRequest(在 VS Code 中表现为原生 quick-pick 对话框); - 用户选择
cancel/reject/reject_*类选项时,统一转换为{ outcome: { outcome: 'cancelled' } }; - 最终通过
session.respondToPermission(requestId, response)回传 daemon;如果应答在回传前会话已切换、或 daemon 拒绝(返回false),只记录告警,并且保留重放游标——注释明确说明这是刻意为之的:权限应答失败时让事件可被重放,而不是静默吞掉。
单测 daemonIdeConnection.test.ts 用 EventQueue 假事件源 + 假会话工厂覆盖了这条链:连接后推送 session_update 验证转发、权限请求到 respondToPermission 的转发、session_died 的会话清空、以及 lastEventId 断点续传的游标行为。
与既有 ACP 连接的关系:兄弟路径,不是替换
文档用一张结构图说明了两条连接路径的关系:
QwenAgentManager
current default -> AcpConnection -> qwen --acp child
experimental -> DaemonIdeConnection -> qwen serve HTTP/SSE
第一版实现引入的是兄弟连接路径,不替换 AcpConnection。两条路径应尽可能汇入同一组高层 webview 回调;当前 daemon 路径尚未接入默认 QwenAgentManager,现有 VS Code 行为保持 ACP 子进程模式不变。
文档同时列出了明确的非目标(Explicit Non-Goals),值得完整保留,因为它划清了这条实验路径的边界:
- 不默认迁移离开
AcpConnection; - 不允许 webview 到 daemon 的直连传输;
- 在文件服务边界落地之前,不支持经 IDE 的 daemon 侧文件 CRUD;
- 暂不提供反向 RPC(编辑器/浏览器/剪贴板);
- 不做完整的远程控制集成。
合并安全(Merge Safety)的四条纪律是:默认关闭(受设置/环境变量控制)、纯增量的兄弟连接路径、既有 ACP 子进程路径不变、daemon token 绝不进入 webview JavaScript。
运行时局部性:daemon 在哪里,资源就在哪里
文档特别要求扩展必须让用户看得见 daemon 的局部性,这四点要如实呈现:
- 工作区/文件是 daemon 主机上的路径;
- MCP 服务器运行在 daemon 主机上;
- skills 从 daemon 的文件系统加载;
- 模型提供方凭据在 daemon 进程的环境中解析。
反过来,不能暗示本地的 VS Code 扩展、本地浏览器 profile、本地 localhost 服务、本地 SSH/kube 凭据会自动被 daemon 看到。这与 workspaceCwd 的语义直接相关:POST /session 时传入的 workspaceCwd 必须与 daemon 的主工作区或已注册的多工作区之一匹配,否则 daemon 返回 400 workspace_mismatch,扩展侧应把它呈现为清晰的配置错误而不是盲目重试。
验证计划与默认迁移前的阻塞项
文档的验证计划与当前测试布局一一对应:
- 单测 daemon 会话工厂连接与 SSE 事件消费 —— 已见 daemonIdeConnection.test.ts 与 DaemonSessionClient.test.ts;
- 单测 daemon 事件到既有扩展宿主回调的映射;
- 单测 prompt / cancel / 切换模型 / 权限应答的转发;
- 设置/环境变量在功能旗标接线时的解析单测(对应前文所述的待落地部分);
- 针对本地扩展宿主 +
qwen serve的冒烟测试:提示能流式进入聊天、取消可用、权限 UI 能解决请求、SSE 重连使用受跟踪的Last-Event-ID。
而在默认迁移之前,文档列出六个阻塞项(Blockers Before Default Migration):类型化 daemon 事件 schema、daemon 端加盖的客户端身份、会话级权限路由、只读运行时诊断、FileSystemService 边界与安全文件读取路由、面向 CLI/TUI 对等的输出汇聚层重构。
延伸阅读:同一适配体系的其他端
IDE 适配器只是 Qwen Code "daemon 客户端适配层"矩阵中的一格,同目录还有 web-shell.md(浏览器端消费 @qwen-code/sdk/daemon 的 normalizeDaemonEvent() / createDaemonTranscriptStore(),并区分同源本地 POC、BFF 远端部署、本地浏览器跨域三种部署形态)、tui.md 与 channel-web.md。更完整的实现参考在 16-vscode-ide-adapter.md,其中给出了 DaemonIdeConnection 的公共接口签名、事件分发表、以及初始化连接 / quick-pick 权限 / 断线恢复三张时序图;daemon 本身的 API 与部署说明见 qwen-serve.md 与 sdk-daemon-client。
小结
IDE Daemon Adapter 的核心可以浓缩为三句话:连接归宿主(扩展宿主独占 URL/token/session/SSE 状态,webview 只见净化事件)、传输可替换(DaemonIdeConnection 以与 ACP 子进程同形的回调表面,把 HTTP/SSE 包装成既有的 webview 消息流)、演进不破坏(默认关闭、兄弟路径、loopback 硬校验、事件映射契约 + 未知事件静默忽略)。当前它处于"已实现、已单测、未接线默认路径"的实验状态,等待文档所列的阻塞项(类型化事件 schema、会话级权限路由、文件服务边界等)落地后,才谈得上从 ACP 子进程路径的默认迁移。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust4.22 K638- DDeepSeek-V4.1-FlashDeepSeek-V4.1-Flash 是一个多模态混合专家(MoE)模型,拥有 5520 亿骨干参数,并支持最多一百万 token 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python400
cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端TypeScript2 K146
hello-agents📚 《从零开始构建智能体》——从零开始的智能体原理与实践教程Python48467
new-apiAI模型聚合管理中转分发系统,一个应用管理您的所有AI模型,支持将多种大模型转为统一格式调用,支持OpenAI、Claude、Gemini等格式,可供个人或者企业内部管理与分发渠道使用。🍥 A Unified AI Model Management & Distribution System. Aggregate all your LLMs into one app and access them via an OpenAI-compatible API, with native support for Claude (Messages) and Gemini formats.Go20843
JeecgBoot🔥企业级低代码平台集成了AI应用平台,帮助企业快速实现低代码开发和构建AI应用!前后端分离架构 SpringBoot,SpringCloud、Mybatis,Ant Design4、 Vue3.0、TS+vite!强大的代码生成器让前后端代码一键生成,无需写任何代码! 引领AI低代码开发模式: AI生成->OnlineCoding-> 代码生成-> 手工MERGE,显著的提高效率,又不失灵活~Java34451