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:
| File | Surface |
|---|---|
index.ts | Öffentlicher Barrel (DaemonClient, DaemonSessionClient, DaemonAuthFlow, parseSseStream, Event-Reducer, Types). |
DaemonClient.ts | Low-Level-HTTP/SSE-Facade – eine Methode pro qwen-serve-protocol.md-Route. |
DaemonSessionClient.ts | Session-bezogener Wrapper mit SSE-Replay-Tracking. |
DaemonAuthFlow.ts | High-Level-OAuth-Device-Flow-Helper. |
sse.ts | parseSseStream (NDJSON-/SSE-Framing-Parser). |
events.ts | asKnownDaemonEvent, reduceDaemonSessionEvent, reduceDaemonAuthEvent (siehe 09-event-schema.md). |
types.ts | DaemonCapabilities, 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-Idbei jeder Anfrage korrekt mitschicken. - Per-Call-Timeouts mit vom Aufrufer bereitgestellten
AbortSignalkombinieren (ohne langlebige SSE zu beenden). - SSE-Frames streamen und in typisierte
DaemonEvents parsen. lastSeenEventIdpro 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):
| Group | Methods |
|---|---|
| Plumbing | health(), capabilities(), auth (Lazy DaemonAuthFlow Accessor) |
| Sessions | createOrAttachSession, loadSession, resumeSession, listSessions, closeSession, setSessionMetadata, getSessionContext, getSessionSupportedCommands, setSessionApprovalMode, setSessionModel |
| Prompting | prompt, cancel, heartbeat |
| Events | subscribeEvents (SSE-Generator), subscribeEventsStream (Raw-Response) |
| Permissions | respondToPermission, respondToSessionPermission |
| Workspace snapshots | getWorkspaceMcp, getWorkspaceSkills, getWorkspaceProviders, getWorkspaceEnv, getWorkspacePreflight |
| Workspace mutations | writeWorkspaceMemory, readWorkspaceMemory, rememberWorkspaceMemory, getWorkspaceMemoryRememberTask, forgetWorkspaceMemory, getWorkspaceMemoryForgetTask, dreamWorkspaceMemory, getWorkspaceMemoryDreamTask, listWorkspaceAgents, getWorkspaceAgent, createWorkspaceAgent, updateWorkspaceAgent, deleteWorkspaceAgent, toggleWorkspaceTool, restartMcpServer, initializeWorkspace |
| Files | readFile, readFileBytes, writeFile, editFile, listDirectory, globPaths, statPath |
| Auth | startDeviceFlow, 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()überfetchTimeoutMshinaus 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. perCallTimeoutMsermöglicht es einem einzelnen Aufruf, den clientweiten Standardwert zu überschreiben. Der sichtbarste Aufrufer istrestartMcpServer: Das SDK verwendetMCP_RESTART_DEFAULT_TIMEOUT_MS = 330_000(5 Min. 30 Sek.). Das eigeneMCP_RESTART_TIMEOUT_MSdes 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-positivenTimeoutErrorführt. Die zusätzlichen 30 s decken Serialisierung, Netzwerktransfer und Decodierung auf beiden Seiten ab. Aufrufer, die ein strengeres Budget benötigen, könnentimeoutMsübergeben; die Übergabe von0deaktiviert den Timeout.AbortSignal.anykombiniert 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+ abbrechbaressetTimeoutanstelle vonAbortSignal.timeout(), damit sich schnell auflösende Anfragen keine ausstehenden Timer auf der Event-Loop leaken. Der Timer wird infinallygelö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
DaemonEventdurchgereicht; SDK-Consumer grenzen sie downstream überasKnownDaemonEventein).
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
DaemonClientist verbindungslos; bei der Konstruktion passiert nichts. Jede Methode öffnet ein neuesfetch.DaemonSessionClientbehältlastSeenEventIdüberevents()-Aufrufe hinweg bei; Reconnects führen ein Replay ab der zuletzt gesehenen ID aus.DaemonAuthFlowist lazy –client.authkonstruiert 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 demfor awaitausbricht oder (d) das Buffer-Overflow-Limit (16 MiB) erreicht wird.
Abhängigkeiten
globalThis.fetch(in Node 18+ integriert, Browser, undici, etc.). Kann proDaemonClientfür Tests injiziert werden.- Native
AbortController/AbortSignal.any/setTimeout. - Keine transitiven Abhängigkeiten zu
@qwen-code/qwen-code-coreoder@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-freundlicheDaemonUiEventType-Werte; nicht modellierte oder fehlerhafte Events werden aufdebugnormalisiert.createDaemonTranscriptState()plusreduceDaemonTranscriptEvents(state, events)projiziert UI-Events inDaemonTranscriptBlock[].createDaemonTranscriptStore()kapselt Subscribe / Dispatch.render.ts/terminal.tsstellen HTML- und Terminal-Baseline-Renderer bereit, währendtoolPreview.tsTool-Call-Zusammenfassungen erzeugt.- Zu den Selektoren gehören
selectTranscriptBlocksOrderedByEventId,selectPendingPermissionBlocks,selectCurrentTool,selectApprovalMode,selectToolProgress,selectSubagentChildBlocks,formatMissedRangeundformatBlockTimestamp. - Zu den öffentlichen Konstanten gehört
DAEMON_PLAN_TOOL_CALL_ID. conformance.tsenthä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
| Einstellung | Wo | Effekt |
|---|---|---|
baseUrl | DaemonClient-Konstruktor | Daemon-URL; nachgestellte Slashes werden entfernt. |
token | DaemonClient-Konstruktor | Wird als Authorization: Bearer gestempelt. |
fetch | DaemonClient-Konstruktor | Test-Injection-Point. |
fetchTimeoutMs | DaemonClient-Konstruktor | Timeout pro Aufruf; 0 = deaktiviert. |
clientId | optionaler Parameter pro Methode | X-Qwen-Client-Id-Header (siehe 08-session-lifecycle.md). |
lastEventId | DaemonSessionClient-Konstruktor | Replay-Cursor seeden. |
maxQueued | Option pro Subscribe | ?maxQueued=N für die SSE-Route; vorher caps.features.slow_client_warning prüfen. |
perCallTimeoutMs | pro Methode (z. B. restartMcpServer) | Überschreibt das client-weite Timeout. |
Einschränkungen & bekannte Limits
fetchTimeoutMsgilt 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 auf0setzen.- SSE umgeht den Fetch-Timeout – langlebige SSE-Verbindungen werden durch
fetchTimeoutMsnicht getrennt. VerwendeAbortSignalfür caller-gesteuertes Abbrechen. - Das Buffer-Limit von
parseSseStreamliegt bei 16 MiB als defensive Grenze. Ein einzelner Frame, der größer ist, bricht den Iterator ab (der Daemon emittiert niemals legitim solche Frames). asKnownDaemonEventgibtundefinedfü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öhenDaemonSessionViewState.unrecognizedKnownEventCount.client_evicted,slow_client_warning,stream_errorbefinden 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.DaemonClientfü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.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- End-to-End-Walkthrough:
../examples/daemon-client-quickstart.md.