Skip to Content
EntwicklerhandbuchDaemonTypeScript SDK Daemon Client

TypeScript SDK Daemon Client

Overview

packages/sdk-typescript/src/daemon/ ist der Daemon-Client des TypeScript SDK. Er ist der kanonische Weg, um sich von einem beliebigen TypeScript-/JavaScript-Host (dem eigenen TUI-Adapter der CLI, Channel-Bot-Backends, dem VS Code IDE Companion, benutzerdefinierten Skripten und serverseitigen Web-Backends) mit einem laufenden qwen serve-Daemon zu verbinden. Alle anderen Adapter hängen von ihm ab.

Das Package-Layout ist bewusst klein gehalten:

FileSurface
index.tsÖffentlicher Barrel (DaemonClient, DaemonSessionClient, DaemonAuthFlow, parseSseStream, Event-Reducer, Types).
DaemonClient.tsLow-Level-HTTP/SSE-Facade – eine Methode pro qwen-serve-protocol.md-Route.
DaemonSessionClient.tsSession-bezogener Wrapper mit SSE-Replay-Tracking.
DaemonAuthFlow.tsHigh-Level-OAuth-Device-Flow-Helper.
sse.tsparseSseStream (NDJSON-/SSE-Framing-Parser).
events.tsasKnownDaemonEvent, reduceDaemonSessionEvent, reduceDaemonAuthEvent (siehe 09-event-schema.md).
types.tsDaemonCapabilities, DaemonSession, DaemonEvent, PermissionResponse, PromptResult, MCP-/Agent-/Memory-/Auth-Types.

Das Walkthrough-Beispiel befindet sich unter ../examples/daemon-client-quickstart.md; dieses Dokument ist die Architektur- und Contract-Referenz.

Responsibilities

  • Eine TypeScript-Methode pro Daemon-HTTP-Route bereitstellen.
  • Das Bearer-Token und die X-Qwen-Client-Id bei jeder Anfrage korrekt mitschicken.
  • Per-Call-Timeouts mit vom Aufrufer bereitgestellten AbortSignal kombinieren (ohne langlebige SSE zu beenden).
  • SSE-Frames streamen und in typisierte DaemonEvents parsen.
  • lastSeenEventId pro Session tracken, damit Reconnects korrekt replayen.
  • Eine Device-Flow-Auth-Surface bereitstellen, die in vom Daemon vorgegebenen Intervallen pollt.

Architecture

DaemonClient (DaemonClient.ts)

Konstruktor:

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 });

Methodengruppen (jede Methode akzeptiert eine optionale clientId, um X-Qwen-Client-Id zu setzen):

GroupMethods
Plumbinghealth(), capabilities(), auth (Lazy DaemonAuthFlow Accessor)
SessionscreateOrAttachSession, loadSession, resumeSession, listSessions, closeSession, setSessionMetadata, getSessionContext, getSessionSupportedCommands, setSessionApprovalMode, setSessionModel
Promptingprompt, cancel, heartbeat
EventssubscribeEvents (SSE-Generator), subscribeEventsStream (Raw-Response)
PermissionsrespondToPermission, respondToSessionPermission
Workspace snapshotsgetWorkspaceMcp, getWorkspaceSkills, getWorkspaceProviders, getWorkspaceEnv, getWorkspacePreflight
Workspace mutationswriteWorkspaceMemory, readWorkspaceMemory, rememberWorkspaceMemory, getWorkspaceMemoryRememberTask, forgetWorkspaceMemory, getWorkspaceMemoryForgetTask, dreamWorkspaceMemory, getWorkspaceMemoryDreamTask, listWorkspaceAgents, getWorkspaceAgent, createWorkspaceAgent, updateWorkspaceAgent, deleteWorkspaceAgent, toggleWorkspaceTool, restartMcpServer, initializeWorkspace
FilesreadFile, readFileBytes, writeFile, editFile, listDirectory, globPaths, statPath
AuthstartDeviceFlow, pollDeviceFlow, cancelDeviceFlow, getAuthStatus

fetchWithTimeout

Jede Anfrage durchläuft fetchWithTimeout. Wichtige Details:

  • Das Lesen des Body erfolgt innerhalb des Timer-Scopes. Frühere Implementierungen haben den Timer gelöscht, wenn die Header eintrafen; wenn ein Proxy mitten im Body stockte, konnte await res.json() über fetchTimeoutMs hinaus hängen. Die aktuelle Form übergibt den Body-Lese-Code als Callback, sodass der Timer sowohl das Eintreffen der Header ALS AUCH das Lesen des Body abdeckt.
  • perCallTimeoutMs ermöglicht es einem einzelnen Aufruf, den clientweiten Standardwert zu überschreiben. Der sichtbarste Aufrufer ist restartMcpServer: Das SDK verwendet MCP_RESTART_DEFAULT_TIMEOUT_MS = 330_000 (5 Min. 30 Sek.). Das eigene MCP_RESTART_TIMEOUT_MS des Daemons beträgt exakt 300 s; wenn der Client diesen Wert übernehmen würde, könnte ein Restart, der nahe 300 s abgeschlossen wird, das Rennen verlieren, während der Daemon seine strukturierte Antwort serialisiert und sendet, was zu einem falsch-positiven TimeoutError führt. Die zusätzlichen 30 s decken Serialisierung, Netzwerktransfer und Decodierung auf beiden Seiten ab. Aufrufer, die ein strengeres Budget benötigen, können timeoutMs übergeben; die Übergabe von 0 deaktiviert den Timeout.
  • AbortSignal.any kombiniert das vom Aufrufer bereitgestellte Signal mit dem Per-Call-Timer-Signal, sodass sowohl der Abbruch durch den Aufrufer als auch der Per-Call-Timeout sauber abgebrochen werden.
  • AbortController + abbrechbares setTimeout anstelle von AbortSignal.timeout(), damit sich schnell auflösende Anfragen keine ausstehenden Timer auf der Event-Loop leaken. Der Timer wird in finally gelöscht.
  • Streaming-Endpunkte (subscribeEvents) umgehen den Timeout – langlebige SSE dürfen dadurch nicht beendet werden.

DaemonSessionClient (DaemonSessionClient.ts)

Bindet eine Session und trackt automatisch lastSeenEventId, sodass SSE-Replay und Reconnect ohne zusätzlichen Aufrufer-State funktionieren.

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() fungiert standardmäßig als Proxy für client.subscribeEvents mit resume: true – es übergibt die getrackte lastSeenEventId, damit Reconnects dort replayen, wo das vorherige Abonnement aufgehört hat. Jedes gelieferte Event erhöht 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() pollt GET /workspace/auth/device-flow/:id im vom Daemon vorgegebenen intervalMs, bis der Flow den Status authorized, failed oder cancelled annimmt. Er wird lazy über client.auth konstruiert, sodass Clients, die Auth nie anfassen, keine Allokationskosten verursachen.

parseSseStream (sse.ts)

Wandelt einen Response.body (ReadableStream<Uint8Array>) in ein AsyncIterable<DaemonEvent> um. Verarbeitet:

  • LF- und CRLF-Framing.
  • Buffer-Overflow-Cap (16 MiB) – defensive Grenze gegen einen Daemon, der einen einzigen absurd großen Frame ausgibt.
  • AbortSignal-Wiring – Abbruch schließt den Stream und den Iterator.
  • Frames nur mit Kommentaren und unbekannte Event-Typen (werden als DaemonEvent durchgereicht; SDK-Consumer grenzen sie downstream über asKnownDaemonEvent ein).

Types (types.ts)

Erwähnenswerte Exports: DaemonCapabilities, DaemonSession ({ sessionId, workspaceCwd, attached, clientId?, createdAt? }), DaemonEvent, DaemonSessionState, DaemonSessionContextStatus, DaemonSessionSupportedCommandsStatus, PermissionResponse, PromptResult, HeartbeatResult, SetModelResult, SessionMetadataResult sowie MCP-/Agent-/Memory-/Auth-Result-Types. Zu den Managed-Workspace-Memory-Task-Types gehören DaemonWorkspaceMemoryRememberTask, DaemonWorkspaceMemoryForgetTask und DaemonWorkspaceMemoryDreamTask.

Workspace-Managed-Memory-Task-Helper:

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-...');

Workflow

Create-or-attach + erster Prompt

Subscribe mit Replay

Device-Flow-Authentifizierung

qwen-oauth ist der Legacy-v1-Provider-Identifier. Der kostenlose Qwen OAuth Free Tier wurde am 15.04.2026 eingestellt, daher sollten neue Clients nach Möglichkeit einen derzeit unterstützten Auth-Provider verwenden.

State & Lifecycle

  • DaemonClient ist verbindungslos; bei der Konstruktion passiert nichts. Jede Methode öffnet ein neues fetch.
  • DaemonSessionClient behält lastSeenEventId über events()-Aufrufe hinweg bei; Reconnects führen ein Replay ab der zuletzt gesehenen ID aus.
  • DaemonAuthFlow ist lazy – client.auth konstruiert es beim ersten Zugriff.
  • Der SSE-Iterator wird geschlossen, wenn (a) der Daemon den Stream beendet, (b) AbortSignal.abort() ausgelöst wird, (c) der Consumer aus dem for await ausbricht oder (d) das Buffer-Overflow-Limit (16 MiB) erreicht wird.

Abhängigkeiten

  • globalThis.fetch (in Node 18+ integriert, Browser, undici, etc.). Kann pro DaemonClient für Tests injiziert werden.
  • Native AbortController / AbortSignal.any / setTimeout.
  • Keine transitiven Abhängigkeiten zu @qwen-code/qwen-code-core oder @qwen-code/acp-bridge – das SDK-Paket ist vollständig entkoppelt, sodass externe Consumer nicht die Interna des Daemons hereinziehen.

ui/*-Subpaket (#4328  + #4353 )

Das SDK exportiert auch packages/sdk-typescript/src/daemon/ui/, ein host-neutrales Set an Primitiven, das Daemon-Events in Transcript-Blöcke umwandelt:

  • normalizeDaemonEvent(evt) mappt die 47 bekannten Daemon-Wire-Events auf 42 UI-freundliche DaemonUiEventType-Werte; nicht modellierte oder fehlerhafte Events werden auf debug normalisiert.
  • createDaemonTranscriptState() plus reduceDaemonTranscriptEvents(state, events) projiziert UI-Events in DaemonTranscriptBlock[].
  • createDaemonTranscriptStore() kapselt Subscribe / Dispatch.
  • render.ts / terminal.ts stellen HTML- und Terminal-Baseline-Renderer bereit, während toolPreview.ts Tool-Call-Zusammenfassungen erzeugt.
  • Zu den Selektoren gehören selectTranscriptBlocksOrderedByEventId, selectPendingPermissionBlocks, selectCurrentTool, selectApprovalMode, selectToolProgress, selectSubagentChildBlocks, formatMissedRange und formatBlockTimestamp.
  • Zu den öffentlichen Konstanten gehört DAEMON_PLAN_TOOL_CALL_ID.
  • conformance.ts enthält die Cross-Host-Konsistenz-Testsuite.

Der erste Produktions-Consumer ist packages/webui/src/daemon/ über Reacts DaemonSessionProvider. Siehe 14-cli-tui-adapter.md für die detaillierte Architektur, das Glossar, die Selektoren-Tabelle und die Beziehung zum Legacy DaemonTuiAdapter.

Das Subpaket wird über den Subpath @qwen-code/sdk/daemon exportiert. Bestehender Code, der import { DaemonClient } verwendet, ist davon nicht betroffen.

Last-Event-ID-Reconnect mit dem SDK

Automatisches Tracking über DaemonSessionClient

DaemonSessionClient trackt lastSeenEventId intern. Jedes yieldete Event mit einer numerischen id erhöht den Cursor. Nachfolgende events()-Aufrufe übergeben automatisch die getrackte ID als Last-Event-ID, sodass Reconnect-with-Replay ohne zusätzlichen Caller-State funktioniert:

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); // Erstes Subscription — startet live (oder ab Ring-Start für neue Sessions). for await (const event of session.events()) { console.log(event.type, event.id); // session.lastEventId wird bei jedem Frame mit ID erhöht. if (shouldStop(event)) break; } // Reconnect — sendet automatisch Last-Event-ID: <zuletzt gesehene ID>. // Der Daemon spielt verpasste Events aus dem Ring als Replay ab und geht dann auf live. for await (const event of session.events()) { // Replay-Frames kommen zuerst, dann ein synthetisches `replay_complete`, // dann Live-Events. handleEvent(event); }

Manueller Reconnect mit DaemonClient

Für Low-Level-Kontrolle verwende DaemonClient.subscribeEvents direkt und verwalte den Cursor selbst:

const client = new DaemonClient({ baseUrl: 'http://127.0.0.1:4170', token }); let cursor: number | undefined; // undefined = nur live beim ersten Connect async function* subscribe(sessionId: string, signal: AbortSignal) { for await (const event of client.subscribeEvents(sessionId, { lastEventId: cursor, signal, })) { // Nur Frames mit ID erhöhen den Cursor. if (event.id !== undefined) { cursor = event.id; } // Ring-Eviction-Gap behandeln. if (event.type === 'state_resync_required') { // State ist veraltet — vollständigen Session-State neu laden. await client.loadSession(sessionId); continue; } yield event; } }

Reconnect mit Retry-Loop

Das SDK führt bei Netzwerkfehlern kein automatisches Retry durch. Implementiere einen Retry-Loop um 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` (Standard) übergibt die getrackte lastSeenEventId. for await (const event of session.events()) { attempt = 0; // Reset bei erfolgreichem Event handleEvent(event); } break; // Sauberes Stream-Ende } catch (err) { const delay = BASE_DELAY_MS * 2 ** Math.min(attempt, 5); await new Promise((r) => setTimeout(r, delay)); } } }

Beim Reconnect spielt der Daemon Events mit id > lastSeenEventId aus seinem begrenzten Ring (Standard 8000 Events) als Replay ab. Wenn die Lücke den Ring überschreitet, signalisiert ein state_resync_required-Frame dem Client, loadSession für einen vollständigen State-Rebuild aufzurufen.

Seeding von lastEventId bei der Konstruktion

Caller, die den Cursor über Prozess-Neustarts hinweg persistieren, können ihn seeden:

const session = new DaemonSessionClient({ client, session: { sessionId, workspaceCwd, attached: true }, lastEventId: persistedCursor, // Resume von persistierter Position });

Der Wert muss eine endliche, nicht-negative Ganzzahl sein (wird bei der Konstruktion validiert). Ungültige Werte werfen einen Fehler.

Konfiguration

EinstellungWoEffekt
baseUrlDaemonClient-KonstruktorDaemon-URL; nachgestellte Slashes werden entfernt.
tokenDaemonClient-KonstruktorWird als Authorization: Bearer gestempelt.
fetchDaemonClient-KonstruktorTest-Injection-Point.
fetchTimeoutMsDaemonClient-KonstruktorTimeout pro Aufruf; 0 = deaktiviert.
clientIdoptionaler Parameter pro MethodeX-Qwen-Client-Id-Header (siehe 08-session-lifecycle.md).
lastEventIdDaemonSessionClient-KonstruktorReplay-Cursor seeden.
maxQueuedOption pro Subscribe?maxQueued=N für die SSE-Route; vorher caps.features.slow_client_warning prüfen.
perCallTimeoutMspro Methode (z. B. restartMcpServer)Überschreibt das client-weite Timeout.

Einschränkungen & bekannte Limits

  • fetchTimeoutMs gilt pro Aufruf, nicht auf Connection-Ebene. Lange Body-Reads teilen sich den Timer. Ein Daemon, der Antworten streamt, muss den Timeout pro Aufruf überschreiben oder auf 0 setzen.
  • SSE umgeht den Fetch-Timeout – langlebige SSE-Verbindungen werden durch fetchTimeoutMs nicht getrennt. Verwende AbortSignal für caller-gesteuertes Abbrechen.
  • Das Buffer-Limit von parseSseStream liegt bei 16 MiB als defensive Grenze. Ein einzelner Frame, der größer ist, bricht den Iterator ab (der Daemon emittiert niemals legitim solche Frames).
  • asKnownDaemonEvent gibt undefined für nicht erkannte Event-Typen zurück. SDK-Consumer müssen diesen Branch behandeln, anstatt anzunehmen, dass die Union exhaustiv ist; das ist der Forward-Compatibility-Vertrag. Nicht erkannte Events erhöhen DaemonSessionViewState.unrecognizedKnownEventCount.
  • client_evicted, slow_client_warning, stream_error befinden sich nicht im Replay-Ring. Ein Reconnect nach einer Eviction setzt beim Ring des Daemons an; du wirst den Eviction-Frame nicht noch einmal sehen.
  • DaemonClient führt kein automatisches Retry durch. Netzwerkfehler treten als Rejections auf; die Reconnect-/Replay-Strategie liegt in der Verantwortung des Callers (DaemonSessionClient.events() macht Replay einfach, aber Reconnect erfolgt weiterhin pro Aufruf).

Referenzen

  • 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
  • End-to-End-Walkthrough: ../examples/daemon-client-quickstart.md.
Last updated on