TypeScript SDK Daemon 客户端
概述
packages/sdk-typescript/src/daemon/ 是 TypeScript SDK 的 daemon 客户端。它是从任何 TypeScript / JavaScript 宿主(CLI 自身的 TUI 适配器、频道机器人后端、VS Code IDE 伴侣插件、自定义脚本以及服务端 Web 后端)连接到正在运行的 qwen serve daemon 的标准方式。所有其他适配器都依赖于它。
该包的布局刻意保持精简:
| 文件 | 导出的 API |
|---|---|
index.ts | 公共导出桶(DaemonClient、DaemonSessionClient、DaemonAuthFlow、parseSseStream、事件 reducer、类型)。 |
DaemonClient.ts | 底层 HTTP/SSE 门面 — 每个 qwen-serve-protocol.md 路由对应一个方法。 |
DaemonSessionClient.ts | 会话作用域的封装,带有 SSE 重放追踪。 |
DaemonAuthFlow.ts | 高级 OAuth 设备流辅助工具。 |
sse.ts | parseSseStream(NDJSON / SSE 帧解析器)。 |
events.ts | asKnownDaemonEvent、reduceDaemonSessionEvent、reduceDaemonAuthEvent(参见 09-event-schema.md)。 |
types.ts | DaemonCapabilities、DaemonSession、DaemonEvent、PermissionResponse、PromptResult、MCP / agent / memory / auth 类型。 |
演练示例位于 ../examples/daemon-client-quickstart.md;本文档是架构和契约参考。
职责
- 为每个 daemon HTTP 路由提供一个 TypeScript 方法。
- 在每个请求上正确附加 bearer token 和
X-Qwen-Client-Id。 - 将单次调用超时与调用方提供的
AbortSignal组合(不会中断长连接的 SSE)。 - 流式传输并将 SSE 帧解析为类型化的
DaemonEvent。 - 追踪每个会话的
lastSeenEventId,以便重连时能正确重放。 - 暴露设备流认证接口,按 daemon 提供的间隔进行轮询。
架构
DaemonClient (DaemonClient.ts)
构造函数:
new DaemonClient({
baseUrl: string, // 默认 'http://127.0.0.1:4170'
token?: string,
fetch?: typeof globalThis.fetch, // 可注入以用于测试
fetchTimeoutMs?: number, // 0 = 禁用;默认 DEFAULT_FETCH_TIMEOUT_MS
});方法分组(每个方法接受一个可选的 clientId 以附加 X-Qwen-Client-Id):
| 分组 | 方法 |
|---|---|
| 基础功能 | health()、capabilities()、auth(延迟初始化的 DaemonAuthFlow 访问器) |
| 会话 | createOrAttachSession、loadSession、resumeSession、listSessions、closeSession、setSessionMetadata、getSessionContext、getSessionSupportedCommands、setSessionApprovalMode、setSessionModel |
| 提示词交互 | prompt、cancel、heartbeat |
| 事件 | subscribeEvents(SSE 生成器)、subscribeEventsStream(原始响应) |
| 权限 | respondToPermission、respondToSessionPermission |
| 工作区快照 | getWorkspaceMcp、getWorkspaceSkills、getWorkspaceProviders、getWorkspaceEnv、getWorkspacePreflight |
| 工作区变更 | writeWorkspaceMemory、readWorkspaceMemory、rememberWorkspaceMemory、getWorkspaceMemoryRememberTask、forgetWorkspaceMemory、getWorkspaceMemoryForgetTask、dreamWorkspaceMemory、getWorkspaceMemoryDreamTask、listWorkspaceAgents、getWorkspaceAgent、createWorkspaceAgent、updateWorkspaceAgent、deleteWorkspaceAgent、toggleWorkspaceTool、restartMcpServer、initializeWorkspace |
| 文件 | readFile、readFileBytes、writeFile、editFile、listDirectory、globPaths、statPath |
| 认证 | startDeviceFlow、pollDeviceFlow、cancelDeviceFlow、getAuthStatus |
fetchWithTimeout
每个请求都会经过 fetchWithTimeout。关键细节如下:
- Body 读取在计时器作用域内。 以前的实现在收到 headers 时就会清除计时器;如果代理在 body 传输中途停滞,
await res.json()可能会挂起超过fetchTimeoutMs。当前的实现将 body 读取代码作为回调传入,因此计时器同时覆盖了 header 到达和 body 消费。 perCallTimeoutMs允许单次调用覆盖客户端的全局默认值。最典型的调用者是restartMcpServer:SDK 使用MCP_RESTART_DEFAULT_TIMEOUT_MS = 330_000(5 分 30 秒)。daemon 自身的MCP_RESTART_TIMEOUT_MS正好是 300 秒;如果客户端匹配该值,在接近 300 秒完成的重启中,可能会在 daemon 序列化并发送其结构化响应时输掉竞态,从而导致误报TimeoutError。额外的 30 秒用于覆盖两侧的序列化、网络传输和解码。需要更严格预算的调用者可以传入timeoutMs;传入0则禁用超时。AbortSignal.any将调用方提供的 signal 与单次调用计时器 signal 组合,因此调用方取消和单次调用超时都能干净地中止。- 使用
AbortController+ 可取消的setTimeout而不是AbortSignal.timeout(),这样快速完成的请求不会在事件循环上泄漏挂起的计时器。计时器在finally中被清除。 - 流式端点(
subscribeEvents)绕过超时 — 长连接的 SSE 绝不能被其终止。
DaemonSessionClient (DaemonSessionClient.ts)
绑定单个会话并自动追踪 lastSeenEventId,使得 SSE 重放和重连无需调用方提供额外的状态。
class DaemonSessionClient {
readonly client: DaemonClient;
readonly session: DaemonSession;
readonly state: DaemonSessionState;
private lastSeenEventId: number | undefined;
static createOrAttach(client, req?): Promise<DaemonSessionClient>;
static load(client, sessionId, req?): Promise<DaemonSessionClient>;
static resume(client, sessionId, req?): Promise<DaemonSessionClient>;
events(opts?: DaemonSessionSubscribeOptions): AsyncIterable<DaemonEvent>;
prompt(req: PromptRequest): Promise<PromptResult>;
cancel(): Promise<void>;
respondToPermission(...): Promise<PermissionResponse>;
setModel(modelServiceId): Promise<SetModelResult>;
heartbeat(): Promise<HeartbeatResult>;
setMetadata(metadata): Promise<SessionMetadataResult>;
close(): Promise<void>;
}events() 默认以 resume: true 代理 client.subscribeEvents — 它会传递追踪到的 lastSeenEventId,以便重连时从上一次订阅停止的地方开始重放。每个 yield 的事件都会递增 lastSeenEventId。
DaemonAuthFlow (DaemonAuthFlow.ts)
class DaemonAuthFlow {
start(opts: { providerId, ... }): Promise<DaemonAuthFlowHandle>;
}
interface DaemonAuthFlowHandle {
deviceFlowId: string;
providerId: string;
expiresAt: string;
verificationUrl: string;
userCode: string;
awaitCompletion(opts?): Promise<DaemonAuthDeviceFlowState>;
cancel(): Promise<void>;
}awaitCompletion() 按 daemon 提供的 intervalMs 轮询 GET /workspace/auth/device-flow/:id,直到流程变为 authorized、failed 或 cancelled。它通过 client.auth 延迟构造,因此从不涉及认证的客户端不会产生分配开销。
parseSseStream (sse.ts)
将 Response.body(ReadableStream<Uint8Array>)转换为 AsyncIterable<DaemonEvent>。处理以下情况:
- LF 和 CRLF 帧。
- 缓冲区溢出上限(16 MiB)— 防御性边界,防止 daemon 发出单个极其巨大的帧。
- AbortSignal 绑定 — 中止操作会关闭流和迭代器。
- 仅包含注释的帧和未知事件类型(作为
DaemonEvent透传;SDK 消费者在下游通过asKnownDaemonEvent进行类型收窄)。
Types (types.ts)
主要导出:DaemonCapabilities、DaemonSession({ sessionId, workspaceCwd, attached, clientId?, createdAt? })、DaemonEvent、DaemonSessionState、DaemonSessionContextStatus、DaemonSessionSupportedCommandsStatus、PermissionResponse、PromptResult、HeartbeatResult、SetModelResult、SessionMetadataResult,以及 MCP / agent / memory / auth 结果类型。托管工作区 memory 任务类型包括 DaemonWorkspaceMemoryRememberTask、DaemonWorkspaceMemoryForgetTask 和 DaemonWorkspaceMemoryDreamTask。
工作区托管 memory 任务辅助方法:
await client.rememberWorkspaceMemory('Use strict TypeScript.', {
contextMode: 'workspace',
});
await client.getWorkspaceMemoryRememberTask('remember-...');
await client.forgetWorkspaceMemory('old preference');
await client.getWorkspaceMemoryForgetTask('forget-...');
await client.dreamWorkspaceMemory();
await client.getWorkspaceMemoryDreamTask('dream-...');工作流
创建或附加 + 首次 prompt
订阅与回放
Device-flow 认证
qwen-oauth 是旧版 v1 提供商标识符。Qwen OAuth 免费套餐已于 2026-04-15 停止服务,因此新客户端应优先使用当前受支持的认证提供商(如果可用)。
状态与生命周期
DaemonClient是无连接的;构造时不会发生任何操作。每个方法都会发起一个新的fetch请求。DaemonSessionClient在多次调用events()期间会保留lastSeenEventId;重连时会从最后看到的事件开始回放。DaemonAuthFlow是延迟初始化的 ——client.auth会在首次访问时构造它。- SSE 迭代器会在以下情况关闭:(a) daemon 结束流,(b) 触发
AbortSignal.abort(),(c) 消费者跳出for await循环,或 (d) 达到缓冲区溢出上限(16 MiB)。
依赖
globalThis.fetch(Node 18+ 内置、浏览器、undici 等)。可在DaemonClient中注入以用于测试。- 原生
AbortController/AbortSignal.any/setTimeout。 - 不传递依赖
@qwen-code/qwen-code-core或@qwen-code/acp-bridge—— SDK 包完全解耦,因此外部使用者不会引入 daemon 的内部实现。
ui/* 子包 (#4328 + #4353 )
SDK 还导出了 packages/sdk-typescript/src/daemon/ui/,这是一组与宿主无关的基础组件,用于将 daemon 事件转换为 transcript blocks:
normalizeDaemonEvent(evt)将 47 种已知的 daemon 网络事件映射为 42 种对 UI 友好的DaemonUiEventType值;未建模或格式错误的事件会被规范化为debug。createDaemonTranscriptState()结合reduceDaemonTranscriptEvents(state, events)将 UI 事件投影为DaemonTranscriptBlock[]。createDaemonTranscriptStore()封装了 subscribe / dispatch。render.ts/terminal.ts提供 HTML 和终端基线渲染器,而toolPreview.ts生成工具调用摘要。- Selectors 包括
selectTranscriptBlocksOrderedByEventId、selectPendingPermissionBlocks、selectCurrentTool、selectApprovalMode、selectToolProgress、selectSubagentChildBlocks、formatMissedRange和formatBlockTimestamp。 - 公共常量包括
DAEMON_PLAN_TOOL_CALL_ID。 conformance.ts包含跨宿主一致性测试套件。
首个生产环境使用者是 packages/webui/src/daemon/,通过 React 的 DaemonSessionProvider 接入。有关详细架构、术语表、selector 表以及与旧版 DaemonTuiAdapter 的关系,请参阅 14-cli-tui-adapter.md。
该子包从 @qwen-code/sdk/daemon 子路径导出。现有使用 import { DaemonClient } 的代码不受影响。
使用 SDK 进行 Last-Event-ID 重连
通过 DaemonSessionClient 自动追踪
DaemonSessionClient 在内部追踪 lastSeenEventId。每个带有数字 id 的 yield 事件都会推进游标。后续的 events() 调用会自动将追踪到的 id 作为 Last-Event-ID 传递,因此带重放的重新连接无需调用方维护额外状态:
import { DaemonClient, DaemonSessionClient } from '@qwen-code/sdk/daemon';
const client = new DaemonClient({ baseUrl: 'http://127.0.0.1:4170', token });
const session = await DaemonSessionClient.createOrAttach(client);
// 首次订阅 —— 开始实时接收(或对于新会话从 ring 起始处开始)。
for await (const event of session.events()) {
console.log(event.type, event.id);
// session.lastEventId 会在每个包含 id 的帧处推进。
if (shouldStop(event)) break;
}
// 重连 —— 自动发送 Last-Event-ID: <最后看到的 id>。
// daemon 从 ring 中回放错过的事件,然后进入实时状态。
for await (const event of session.events()) {
// 回放帧首先到达,然后是合成的 `replay_complete`,
// 接着是实时事件。
handleEvent(event);
}使用 DaemonClient 手动重连
为了进行更底层的控制,可以直接使用 DaemonClient.subscribeEvents 并自行管理游标:
const client = new DaemonClient({ baseUrl: 'http://127.0.0.1:4170', token });
let cursor: number | undefined; // undefined = 首次连接时仅接收实时事件
async function* subscribe(sessionId: string, signal: AbortSignal) {
for await (const event of client.subscribeEvents(sessionId, {
lastEventId: cursor,
signal,
})) {
// 只有包含 id 的帧才会推进游标。
if (event.id !== undefined) {
cursor = event.id;
}
// 处理 ring 驱逐间隙。
if (event.type === 'state_resync_required') {
// 状态已过期 —— 重新加载完整的会话状态。
await client.loadSession(sessionId);
continue;
}
yield event;
}
}带重试循环的重连
SDK 不会在网络故障时自动重试。需要在 events() 周围实现一个重试循环:
async function resilientSubscribe(session: DaemonSessionClient) {
const MAX_RETRIES = 10;
const BASE_DELAY_MS = 1000;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
// `resume: true`(默认)传递追踪到的 lastSeenEventId。
for await (const event of session.events()) {
attempt = 0; // 成功接收事件时重置
handleEvent(event);
}
break; // 流正常结束
} catch (err) {
const delay = BASE_DELAY_MS * 2 ** Math.min(attempt, 5);
await new Promise((r) => setTimeout(r, delay));
}
}
}重连时,daemon 会从其有界 ring(默认 8000 个事件)中回放 id > lastSeenEventId 的事件。如果间隙超过 ring 的容量,state_resync_required 帧会通知客户端调用 loadSession 以进行完整的状态重建。
在构造时设置 lastEventId 初始值
跨进程重启持久化游标的调用方可以为其设置初始值:
const session = new DaemonSessionClient({
client,
session: { sessionId, workspaceCwd, attached: true },
lastEventId: persistedCursor, // 从持久化的位置恢复
});该值必须是有限的非负整数(在构造时进行验证)。无效的值会抛出异常。
配置
| 配置项 | 位置 | 作用 |
|---|---|---|
baseUrl | DaemonClient 构造函数 | Daemon URL;会去除尾随斜杠。 |
token | DaemonClient 构造函数 | 附加为 Authorization: Bearer 请求头。 |
fetch | DaemonClient 构造函数 | 测试注入点。 |
fetchTimeoutMs | DaemonClient 构造函数 | 每次调用的超时时间;0 = 禁用。 |
clientId | 每个方法的可选参数 | X-Qwen-Client-Id 请求头(参见 08-session-lifecycle.md)。 |
lastEventId | DaemonSessionClient 构造函数 | 设置回放游标的初始值。 |
maxQueued | 每次订阅的选项 | SSE 路由的 ?maxQueued=N;需先进行预检 caps.features.slow_client_warning。 |
perCallTimeoutMs | 每个方法(如 restartMcpServer) | 覆盖客户端全局超时时间。 |
注意事项与已知限制
fetchTimeoutMs是每次调用级别的,而非连接级别的。 长响应体读取共享该计时器。流式返回响应的 daemon 必须覆盖每次调用的超时时间或将超时时间设置为0。- SSE 会绕过 fetch 超时 —— 长连接的 SSE 不会被
fetchTimeoutMs终止。请使用AbortSignal进行调用方控制的取消操作。 parseSseStream的缓冲区上限为 16 MiB,作为防御性边界。单个大于此值的帧会中止迭代器(daemon 永远不会合法地发出此类帧)。- 对于无法识别的事件类型,
asKnownDaemonEvent会返回undefined。 SDK 使用者必须处理此分支,而不是假设联合类型是详尽的;这是前向兼容性契约。无法识别的事件会增加DaemonSessionViewState.unrecognizedKnownEventCount。 client_evicted、slow_client_warning、stream_error不在回放 ring 中。 被驱逐后重新连接会从 daemon 的 ring 中继续;你将不会再看到驱逐帧。DaemonClient不会自动重试。 网络故障会表现为 rejection;重连/回放策略由调用方负责(DaemonSessionClient.events()使回放变得简单,但重连仍是每次调用级别的)。
参考资料
packages/sdk-typescript/src/daemon/DaemonClient.tspackages/sdk-typescript/src/daemon/DaemonSessionClient.tspackages/sdk-typescript/src/daemon/DaemonAuthFlow.tspackages/sdk-typescript/src/daemon/sse.tspackages/sdk-typescript/src/daemon/events.tspackages/sdk-typescript/src/daemon/types.ts- 端到端演练:
../examples/daemon-client-quickstart.md。