Skip to Content
Руководство для разработчиковDaemonКлиент демона TypeScript SDK

Клиент демона 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.tsparseSseStream (парсер фрейминга NDJSON / SSE).
events.tsasKnownDaemonEvent, reduceDaemonSessionEvent, reduceDaemonAuthEvent (см. 09-event-schema.md).
types.tsDaemonCapabilities, 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конструктор DaemonClientURL демона; конечные слеши удаляются.
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.ts
  • packages/sdk-typescript/src/daemon/DaemonSessionClient.ts
  • packages/sdk-typescript/src/daemon/DaemonAuthFlow.ts
  • packages/sdk-typescript/src/daemon/sse.ts
  • packages/sdk-typescript/src/daemon/events.ts
  • packages/sdk-typescript/src/daemon/types.ts
  • Пошаговое руководство: ../examples/daemon-client-quickstart.md.
Last updated on