Клиент демона TypeScript SDK
Обзор
packages/sdk-typescript/src/daemon/ — это клиент демона TypeScript SDK. Это каноничный способ подключения к работающему демону qwen serve из любого хоста на TypeScript / JavaScript (собственный TUI-адаптер CLI, бэкенды каналов ботов, IDE-компаньон для VS Code, пользовательские скрипты и серверные веб-бэкенды). Все остальные адаптеры зависят от него.
Структура пакета намеренно минималистична:
| Файл | Публичный интерфейс |
|---|---|
index.ts | Публичный barrel (DaemonClient, DaemonSessionClient, DaemonAuthFlow, parseSseStream, редьюсеры событий и типы). |
DaemonClient.ts | Низкоуровневый HTTP/SSE фасад — один метод на каждый маршрут из qwen-serve-protocol.md. |
DaemonSessionClient.ts | Обертка для сессии с отслеживанием повтора SSE. |
DaemonAuthFlow.ts | Высокоуровневый хелпер для OAuth device-flow. |
sse.ts | parseSseStream (парсер фрейминга NDJSON / SSE). |
events.ts | asKnownDaemonEvent, reduceDaemonSessionEvent, reduceDaemonAuthEvent (см. 09-event-schema.md). |
types.ts | DaemonCapabilities, DaemonSession, DaemonEvent, PermissionResponse, PromptResult, типы MCP / агента / памяти / auth. |
Пошаговый пример находится в ../examples/daemon-client-quickstart.md; этот документ служит справочником по архитектуре и контрактам.
Ответственность
- Предоставлять один TypeScript-метод для каждого HTTP-маршрута демона.
- Корректно проставлять bearer-токен и
X-Qwen-Client-Idв каждом запросе. - Компонировать таймауты для каждого вызова с переданным вызывающим кодом
AbortSignal(не прерывая долгоживущие SSE-соединения). - Потоково принимать и парсить SSE-фреймы в типизированные
DaemonEvent. - Отслеживать
lastSeenEventIdдля каждой сессии, чтобы переподключения корректно воспроизводили пропущенные события. - Предоставлять интерфейс аутентификации device-flow, который выполняет опрос с интервалами, заданными демоном.
Архитектура
DaemonClient (DaemonClient.ts)
Конструктор:
new DaemonClient({
baseUrl: string, // default 'http://127.0.0.1:4170'
token?: string,
fetch?: typeof globalThis.fetch, // injectable for tests
fetchTimeoutMs?: number, // 0 = disabled; default 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. Ключевые детали:
- Чтение тела находится в области действия таймера. В предыдущих реализациях таймер сбрасывался при получении заголовков; если прокси зависал в середине тела,
await res.json()мог зависнуть дольшеfetchTimeoutMs. Текущая реализация передает код чтения тела как колбэк, поэтому таймер покрывает как получение заголовков, так и чтение тела. perCallTimeoutMsпозволяет одному вызову переопределить дефолтное значение для всего клиента. Самый заметный вызывающий код —restartMcpServer: SDK используетMCP_RESTART_DEFAULT_TIMEOUT_MS = 330_000(5 мин 30 сек). СобственныйMCP_RESTART_TIMEOUT_MSдемона равен ровно 300 с; если бы клиент использовал то же значение, перезапуск, завершающийся около 300 с, мог бы проиграть гонку, пока демон сериализует и отправляет свой структурированный ответ, что привело бы к ложному срабатываниюTimeoutError. Дополнительные 30 с покрывают сериализацию, передачу по сети и декодирование на обеих сторонах. Вызывающий код, которому требуется более жесткий лимит, может передатьtimeoutMs; передача0отключает таймаут.AbortSignal.anyкомпонует сигнал, переданный вызывающим кодом, с сигналом таймера для конкретного вызова, поэтому и отмена вызывающим кодом, и таймаут вызова корректно прерывают операцию.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() проксирует client.subscribeEvents с resume: true по умолчанию — он передает отслеживаемый lastSeenEventId, чтобы при переподключении воспроизведение начиналось с того места, где остановилась предыдущая подписка. Каждое полученное событие увеличивает 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() опрашивает GET /workspace/auth/device-flow/:id с интервалом intervalMs, заданным демоном, пока поток не перейдет в состояние authorized, failed или cancelled. Он лениво создается через client.auth, поэтому клиенты, которые никогда не взаимодействуют с аутентификацией, не несут затрат на выделение памяти.
parseSseStream (sse.ts)
Преобразует Response.body (ReadableStream<Uint8Array>) в AsyncIterable<DaemonEvent>. Обрабатывает:
- Фрейминг LF и CRLF.
- Ограничение переполнения буфера (16 МиБ) — защитный лимит на случай, если демон отправит один абсурдно большой фрейм.
- Интеграция AbortSignal — прерывание закрывает поток и итератор.
- Фреймы только с комментариями и неизвестные типы событий (передаются дальше как
DaemonEvent; потребители SDK уточняют тип ниже по цепочке с помощьюasKnownDaemonEvent).
Типы (types.ts)
Основные экспорты: DaemonCapabilities, DaemonSession ({ sessionId, workspaceCwd, attached, clientId?, createdAt? }), DaemonEvent, DaemonSessionState, DaemonSessionContextStatus, DaemonSessionSupportedCommandsStatus, PermissionResponse, PromptResult, HeartbeatResult, SetModelResult, SessionMetadataResult, а также типы результатов MCP / агента / памяти / аутентификации. К типам задач управляемой памяти рабочего пространства относятся DaemonWorkspaceMemoryRememberTask, DaemonWorkspaceMemoryForgetTask и DaemonWorkspaceMemoryDreamTask.
Хелперы для задач управляемой памяти рабочего пространства:
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-...');Рабочий процесс
Create-or-attach + первый запрос
Подписка с повтором
Аутентификация через device-flow
qwen-oauth — это устаревший идентификатор провайдера v1. Бесплатный тариф Qwen OAuth был отменен 15 апреля 2026 года, поэтому новым клиентам следует использовать актуальный поддерживаемый провайдер аутентификации, если он доступен.
Состояние и жизненный цикл
DaemonClientне поддерживает постоянное соединение; при создании экземпляра ничего не происходит. Каждый метод выполняет новыйfetch-запрос.DaemonSessionClientсохраняетlastSeenEventIdмежду вызовамиevents(); при переподключении повтор (replay) начинается с последнего полученного события.DaemonAuthFlowинициализируется лениво —client.authсоздает его при первом обращении.- Итератор SSE закрывается, когда: (а) демон завершает поток, (б) срабатывает
AbortSignal.abort(), (в) потребитель прерывает циклfor awaitили (г) достигается лимит переполнения буфера (16 МиБ).
Зависимости
globalThis.fetch(встроен в Node 18+, браузер, undici и т.д.). Можно внедрить (inject) для каждогоDaemonClientв тестах.- Нативные
AbortController/AbortSignal.any/setTimeout. - Нет транзитивных зависимостей от
@qwen-code/qwen-code-coreили@qwen-code/acp-bridge— пакет SDK полностью изолирован, чтобы внешние потребители не тянули за собой внутренние компоненты демона.
Подпакет ui/* (#4328 + #4353 )
SDK также экспортирует packages/sdk-typescript/src/daemon/ui/ — независимый от хоста набор примитивов, которые преобразуют события демона в блоки транскрипта:
normalizeDaemonEvent(evt)сопоставляет 47 известных сетевых событий демона с 42 удобными для UI значениямиDaemonUiEventType; немоделированные или некорректные события нормализуются вdebug.createDaemonTranscriptState()вместе сreduceDaemonTranscriptEvents(state, events)проецируют UI-события вDaemonTranscriptBlock[].createDaemonTranscriptStore()оборачивает subscribe / dispatch.render.ts/terminal.tsпредоставляют базовые рендереры для HTML и терминала, аtoolPreview.tsформирует сводки вызовов инструментов.- Селекторы включают
selectTranscriptBlocksOrderedByEventId,selectPendingPermissionBlocks,selectCurrentTool,selectApprovalMode,selectToolProgress,selectSubagentChildBlocks,formatMissedRangeиformatBlockTimestamp. - Публичные константы включают
DAEMON_PLAN_TOOL_CALL_ID. conformance.tsсодержит набор тестов на кросс-хостовую согласованность.
Первый production-потребитель — это packages/webui/src/daemon/ через React-провайдер DaemonSessionProvider. Подробную архитектуру, глоссарий, таблицу селекторов и связь с устаревшим DaemonTuiAdapter см. в 14-cli-tui-adapter.md.
Подпакет экспортируется из подпути @qwen-code/sdk/daemon. Существующий код, использующий import { DaemonClient }, не затрагивается.
Переподключение с Last-Event-ID через SDK
Автоматическое отслеживание через DaemonSessionClient
DaemonSessionClient отслеживает lastSeenEventId внутри себя. Каждое возвращаемое событие с числовым id сдвигает курсор. Последующие вызовы 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);
// First subscription — starts live (or from ring start for new sessions).
for await (const event of session.events()) {
console.log(event.type, event.id);
// session.lastEventId is bumped on each id-bearing frame.
if (shouldStop(event)) break;
}
// Reconnect — automatically sends Last-Event-ID: <last seen id>.
// The daemon replays missed events from the ring, then goes live.
for await (const event of session.events()) {
// Replay frames arrive first, then a synthetic `replay_complete`,
// then live events.
handleEvent(event);
}Ручное переподключение с DaemonClient
Для более низкоуровневого контроля используйте DaemonClient.subscribeEvents напрямую и управляйте курсором самостоятельно:
const client = new DaemonClient({ baseUrl: 'http://127.0.0.1:4170', token });
let cursor: number | undefined; // undefined = live-only on first connect
async function* subscribe(sessionId: string, signal: AbortSignal) {
for await (const event of client.subscribeEvents(sessionId, {
lastEventId: cursor,
signal,
})) {
// Only id-bearing frames advance the cursor.
if (event.id !== undefined) {
cursor = event.id;
}
// Handle ring-eviction gap.
if (event.type === 'state_resync_required') {
// State is stale — reload full session state.
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` (default) passes the tracked lastSeenEventId.
for await (const event of session.events()) {
attempt = 0; // reset on successful event
handleEvent(event);
}
break; // clean stream end
} catch (err) {
const delay = BASE_DELAY_MS * 2 ** Math.min(attempt, 5);
await new Promise((r) => setTimeout(r, delay));
}
}
}При переподключении демон повторяет события с id > lastSeenEventId из своего ограниченного кольцевого буфера (по умолчанию 8000 событий). Если разрыв превышает размер буфера, фрейм state_resync_required сигнализирует клиенту о необходимости вызвать loadSession для полного восстановления состояния.
Инициализация lastEventId при создании
Вызывающий код, сохраняющий курсор между перезапусками процесса, может инициализировать его:
const session = new DaemonSessionClient({
client,
session: { sessionId, workspaceCwd, attached: true },
lastEventId: persistedCursor, // resume from persisted position
});Значение должно быть конечным неотрицательным целым числом (проверяется при создании). Неверные значения вызывают ошибку.
Конфигурация
| Параметр | Где | Эффект |
|---|---|---|
baseUrl | конструктор DaemonClient | URL демона; конечные слеши удаляются. |
token | конструктор DaemonClient | Добавляется как Authorization: Bearer. |
fetch | конструктор DaemonClient | Точка внедрения для тестов. |
fetchTimeoutMs | конструктор DaemonClient | Таймаут для каждого вызова; 0 = отключено. |
clientId | необязательный аргумент метода | Заголовок X-Qwen-Client-Id (см. 08-session-lifecycle.md). |
lastEventId | конструктор DaemonSessionClient | Начальное значение курсора для повтора. |
maxQueued | опция для каждой подписки | ?maxQueued=N для SSE-маршрута; сначала pre-flight caps.features.slow_client_warning. |
perCallTimeoutMs | для каждого метода (напр., restartMcpServer) | Переопределяет глобальный таймаут клиента. |
Важные замечания и известные ограничения
fetchTimeoutMsдействует для каждого вызова, а не на уровне соединения. Долгие чтения тела ответа используют общий таймер. Демон, потоково передающий ответы, должен переопределять таймаут для каждого вызова или устанавливать его в0.- SSE обходит таймаут fetch — долгоживущие SSE-соединения не прерываются по
fetchTimeoutMs. ИспользуйтеAbortSignalдля отмены на стороне вызывающего кода. - Лимит буфера
parseSseStreamсоставляет 16 МиБ в качестве защитного ограничения. Одиночный фрейм больше этого размера прерывает итератор (демон никогда легитимно не отправляет такие фреймы). asKnownDaemonEventвозвращаетundefinedдля нераспознанных типов событий. Потребители SDK должны обрабатывать эту ветку, а не предполагать, что объединение типов исчерпывающе; это контракт forward-compatibility. Нераспознанные события увеличиваютDaemonSessionViewState.unrecognizedKnownEventCount.client_evicted,slow_client_warning,stream_errorотсутствуют в кольцевом буфере повтора. Переподключение после выселения (eviction) начинается с текущего состояния кольцевого буфера демона; вы больше не увидите фрейм выселения.DaemonClientне выполняет автоматические повторные попытки. Сетевые сбои проявляются как отклонения (rejections); стратегия переподключения / повтора лежит на вызывающем коде (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.