TypeScript SDK Daemon Client
Overview
packages/sdk-typescript/src/daemon/ is the TypeScript SDK’s daemon client. It is the canonical way to connect to a running qwen serve daemon from any TypeScript / JavaScript host (the CLI’s own TUI adapter, channel bot backends, the VS Code IDE companion, custom scripts, and server-side web backends). All other adapters depend on it.
The package layout is intentionally small:
| File | Surface |
|---|---|
index.ts | Public barrel (DaemonClient, DaemonSessionClient, DaemonAuthFlow, parseSseStream, event reducers, types). |
DaemonClient.ts | Low-level HTTP/SSE facade — one method per qwen-serve-protocol.md route. |
DaemonSessionClient.ts | Session-scoped wrapper with SSE replay tracking. |
DaemonAuthFlow.ts | High-level OAuth device-flow helper. |
sse.ts | parseSseStream (NDJSON / SSE framing parser). |
events.ts | asKnownDaemonEvent, reduceDaemonSessionEvent, reduceDaemonAuthEvent (see 09-event-schema.md). |
types.ts | DaemonCapabilities, DaemonSession, DaemonEvent, PermissionResponse, PromptResult, MCP / agent / memory / auth types. |
The walkthrough example is at ../examples/daemon-client-quickstart.md; this doc is the architecture and contract reference.
Responsibilities
- Provide one TypeScript method per daemon HTTP route.
- Stamp the bearer token +
X-Qwen-Client-Idcorrectly on every request. - Compose per-call timeouts with caller-supplied
AbortSignal(without killing long-lived SSE). - Stream and parse SSE frames into typed
DaemonEvents. - Track
lastSeenEventIdper session so reconnects replay correctly. - Expose a device-flow auth surface that polls at daemon-supplied intervals.
Architecture
DaemonClient (DaemonClient.ts)
Constructor:
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
});Method groups (every method takes an optional clientId to stamp X-Qwen-Client-Id):
| 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 | addWorkspace, updateWorkspace, writeWorkspaceMemory, readWorkspaceMemory, rememberWorkspaceMemory, getWorkspaceMemoryRememberTask, forgetWorkspaceMemory, getWorkspaceMemoryForgetTask, dreamWorkspaceMemory, getWorkspaceMemoryDreamTask, listWorkspaceAgents, getWorkspaceAgent, createWorkspaceAgent, updateWorkspaceAgent, deleteWorkspaceAgent, setWorkspaceToolEnabled, setWorkspaceSkillEnabled, restartMcpServer, initWorkspace |
| Files | readFile, readFileBytes, writeFile, editFile, listDirectory, globPaths, statPath |
| Auth | startDeviceFlow, pollDeviceFlow, cancelDeviceFlow, getAuthStatus |
fetchWithTimeout
Every request goes through fetchWithTimeout. Critical details:
- Body read is inside the timer scope. Previous implementations cleared the timer when headers arrived; if a proxy stalled mid-body,
await res.json()could hang pastfetchTimeoutMs. The current shape passes the body-reading code as a callback so the timer covers both header arrival AND body consumption. perCallTimeoutMslets a single call override the client-wide default. The most visible caller isrestartMcpServer: the SDK usesMCP_RESTART_DEFAULT_TIMEOUT_MS = 330_000(5 min 30s). The daemon’s ownMCP_RESTART_TIMEOUT_MSis exactly 300s; if the client matched that value, a restart that completes near 300s could lose the race while the daemon serializes and sends its structured response, causing a false-positiveTimeoutError. The extra 30s covers serialization, network transfer, and decode on both sides. Callers that need a tighter budget can passtimeoutMs; passing0disables the timeout.AbortSignal.anycomposes caller-supplied signal with the per-call timer signal, so caller cancellation and per-call timeout both abort cleanly.AbortController+ cancellablesetTimeoutinstead ofAbortSignal.timeout()so fast-resolving requests do not leak pending timers on the event loop. Timer is cleared infinally.- Streaming endpoints (
subscribeEvents) bypass the timeout — long-lived SSE must not be killed by it.
DaemonSessionClient (DaemonSessionClient.ts)
Binds one session and automatically tracks lastSeenEventId so SSE replay and reconnect work without extra caller state.
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() proxies client.subscribeEvents with resume: true by default — it passes the tracked lastSeenEventId so reconnects replay from where the previous subscription stopped. Every yielded event bumps 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() polls GET /workspace/auth/device-flow/:id at the daemon-supplied intervalMs until the flow becomes authorized, failed, or cancelled. It is lazily constructed via client.auth so clients that never touch auth incur no allocation cost.
parseSseStream (sse.ts)
Turns a Response.body (ReadableStream<Uint8Array>) into AsyncIterable<DaemonEvent>. Handles:
- LF and CRLF framing.
- Buffer overflow cap (16 MiB) — defensive bound against a daemon emitting a single absurdly large frame.
- AbortSignal wiring — abort closes the stream and the iterator.
- Comment-only frames and unknown event types (passed through as
DaemonEvent; SDK consumers narrow downstream viaasKnownDaemonEvent).
Types (types.ts)
Notable exports: DaemonCapabilities, DaemonSession ({ sessionId, workspaceCwd, attached, clientId?, createdAt? }), DaemonEvent, DaemonSessionState, DaemonSessionContextStatus, DaemonSessionSupportedCommandsStatus, PermissionResponse, PromptResult, HeartbeatResult, SetModelResult, SessionMetadataResult, plus MCP / agent / memory / auth result types. Managed workspace memory task types include DaemonWorkspaceMemoryRememberTask, DaemonWorkspaceMemoryForgetTask, and DaemonWorkspaceMemoryDreamTask.
Workspace managed-memory task helpers:
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-...');Workspace skill toggles are available on both client shapes:
await client.setWorkspaceSkillEnabled('review', false, {
clientId: 'dashboard-1',
});
await client
.workspaceByCwd('/work/secondary')
.setWorkspaceSkillEnabled('review', true, { clientId: 'dashboard-1' });Pre-flight capabilities.features.includes('workspace_skill_settings_toggle'). The typed DaemonSkillToggleResult reports the trimmed requested skillName, whether disk state changed, activation state (applied, deferred, reconciling, or partial), and refreshed/failed session counts. reconciling means the write was persisted and the workspace coordinator queued the runtime refresh. The write is settings-only and does not require the name to appear in DaemonWorkspaceSkillStatus; that status type’s optional false-only userInvocable field remains useful for rendering the live catalog but does not gate persistence. The retired workspace_skill_toggle tag described the earlier catalog-validated behavior and is not advertised for this contract.
For batch changes, pre-flight workspace_skill_settings_batch_toggle and call either client shape with the same contract. The routes and request bodies are unchanged:
await client.setWorkspaceSkillsEnabled(['review', 'deploy'], false, {
clientId: 'dashboard-1',
});
await client
.workspaceByCwd('/work/secondary')
.setWorkspaceSkillsEnabled(['review', 'deploy'], true);DaemonSkillBatchToggleResult contains ordered results, a compatibility errors array, and batch-level activation/session-refresh counts. Current daemons process every structurally valid name in request order, persist all resulting declaration changes together in at most one locked settings write, refresh active sessions once when anything changed, and return an empty errors array without consulting the loaded Skill catalog. Enabling records an explicit workspace skills.enabled opt-in even for names not yet installed, so it can override Extension-internal disablement; an identical repeated declaration remains a no-op. The error item types remain available so the SDK can still decode responses from older daemons. The method throws on a non-200 response.
V2 Extension batch activation retains the asynchronous Extension operation model. Pre-flight extension_batch_activation_v2, submit a global default batch or a selected-workspace override batch, then poll it with the existing operation helper:
const globalHandle = await client.setExtensionDefaultActivations(
['formatter', 'review-tools'],
'disabled',
'dashboard-1',
);
const workspaceHandle = await client
.workspaceByCwd('/work/secondary')
.setExtensionActivations(
['formatter', 'review-tools'],
'inherit',
'dashboard-1',
);
const operation = await client.waitForExtensionOperation(workspaceHandle);The terminal operation result contains ordered results. Targets do not need to be installed when setting enabled or disabled: the daemon stores a name declaration and preserves that activation policy when an Extension with that name is installed later. All changed targets share one Extension Store generation. When extension_activation_explicit_refresh is advertised, activation operations finish after the durable policy commit without refreshing active sessions. A caller that needs immediate application should then submit workspace.refreshExtensionRuntime() for each workspace whose sessions must apply the change immediately; the refresh is a separate operation and may be awaited or left in the background. A global default batch changes the default activation every registered workspace inherits unless that workspace holds an exact override for the name (or matches a legacy path rule), and it has no single refresh covering every runtime; a workspace batch changes only the selected trusted runtime. Older daemons already refresh inside the activation operation, so clients must not submit the extra refresh unless the capability is present. The 30-second generation reconciler remains an independent eventual-convergence path for workspaces the caller did not refresh. Workspace inherit clears the exact override but does not create a declaration for an unknown name; an all-unknown clear succeeds as a no-op. Singular activation methods remain installed-only.
For workspace-internal Extension Skill switches, preflight extension_state and use the resource-grouped REST methods. These do not write Skill settings or activate a disabled parent Extension:
const workspace = client.workspaceByCwd('/work/secondary');
const state = await workspace.extensionState(extensionId);
const handle = await workspace.setExtensionState(extensionId, {
skills: [
{ name: 'review', state: 'enabled' },
{ name: 'deploy', state: 'disabled' },
],
});
const updated = await client.waitForExtensionOperation(handle);WorkspaceExtensionState reports manifest defaults, exact workspace overrides and effective settings-aware state. The operation returns ordered resourceStates.skills and may succeed with refresh warnings. Only the skills group is supported. Do not downgrade these calls to setWorkspaceSkillEnabled, which writes higher-priority settings instead.
Workspace display names are optional presentation metadata. Pre-flight capabilities.features.includes('workspace_display_name'); workspace ids and canonical paths remain the only selectors, and duplicate display names are valid.
const workspace = await client.addWorkspace('/srv/repos/payments', {
persist: true,
displayName: 'Payments Production',
});
await client.updateWorkspace(workspace.id, {
displayName: 'Payments',
});
await client.updateWorkspace(workspace.id, { displayName: null });addWorkspace accepts displayName?: string and returns it when set. updateWorkspace accepts an ID or cwd selector and { displayName: string | null }; null clears the name. Names are limited to 256 characters after trimming and reject internal C0/DEL control characters. A process-local workspace keeps its name only for the current daemon process; matching persistent registrations are updated through the existing store. DaemonWorkspaceCapability.displayName remains optional so the SDK continues to interoperate with older daemons.
Workflow
Create-or-attach + first prompt
Subscribe with replay
Device-flow auth
qwen-oauth is the legacy v1 provider identifier. Qwen OAuth free tier was
discontinued on 2026-04-15, so new clients should prefer a currently supported
auth provider when one is available.
State & Lifecycle
DaemonClientis connection-less; nothing happens at construction. Every method opens a freshfetch.DaemonSessionClientretainslastSeenEventIdacrossevents()invocations; reconnects replay from the last seen.DaemonAuthFlowis lazy —client.authconstructs it on first access.- The SSE iterator closes when (a) the daemon ends the stream, (b)
AbortSignal.abort()fires, (c) the consumer breaks out of thefor await, or (d) the buffer overflow cap (16 MiB) is hit.
Dependencies
globalThis.fetch(Node 18+ built-in, browser, undici, etc.). Injectable perDaemonClientfor tests.- Native
AbortController/AbortSignal.any/setTimeout. - No transitive dependencies on
@qwen-code/qwen-code-coreor@qwen-code/acp-bridge— the SDK package is fully decoupled so external consumers do not pull in the daemon’s internals.
ui/* subpackage (#4328 + #4353 )
The SDK also exports packages/sdk-typescript/src/daemon/ui/, a host-neutral
set of primitives that turn daemon events into transcript blocks:
normalizeDaemonEvent(evt)maps the 53 known daemon wire events into 43 UI-friendlyDaemonUiEventTypevalues; unmodeled or malformed events normalize todebug.createDaemonTranscriptState()plusreduceDaemonTranscriptEvents(state, events)projects UI events intoDaemonTranscriptBlock[].createDaemonTranscriptStore()wraps subscribe / dispatch.render.ts/terminal.tsprovide HTML and terminal baseline renderers, whiletoolPreview.tsproduces tool-call summaries.- Selectors include
selectTranscriptBlocksOrderedByEventId,selectPendingPermissionBlocks,selectCurrentTool,selectApprovalMode,selectToolProgress,selectSubagentChildBlocks,formatMissedRange, andformatBlockTimestamp. - Public constants include
DAEMON_PLAN_TOOL_CALL_ID. conformance.tscontains the cross-host consistency test suite.
The first production consumer is packages/web-shell/client/daemon/ through React’s
DaemonSessionProvider. See 14-cli-tui-adapter.md
for the detailed architecture, glossary, selector table, and relationship to
the legacy DaemonTuiAdapter.
The subpackage is exported from the @qwen-code/sdk/daemon subpath. Existing
code that does import { DaemonClient } is unaffected.
Last-Event-ID Reconnect with the SDK
Automatic Tracking via DaemonSessionClient
DaemonSessionClient tracks lastSeenEventId internally. Each yielded event with a numeric id bumps the cursor. Subsequent events() calls automatically pass the tracked id as Last-Event-ID, so reconnect-with-replay works without extra caller state:
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);
}Manual Reconnect with DaemonClient
For lower-level control, use DaemonClient.subscribeEvents directly and manage the cursor yourself:
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 the daemon's bounded replay snapshot window.
await client.loadSession(sessionId);
continue;
}
if (event.type === 'history_truncated') {
// Informational only. Render a status notice, then continue applying
// the retained replay events; do not trigger another reload.
}
yield event;
}
}Reconnect with Retry Loop
The SDK does not auto-retry on network failure. Implement a retry loop around 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));
}
}
}On reconnect the daemon replays events with id > lastSeenEventId from its bounded ring (default 8000 events). If the gap exceeds the ring, a state_resync_required frame signals the client to call loadSession and rebuild from the current bounded replay snapshot window. That snapshot may begin with history_truncated; treat it as an operator-visible status marker, not as another resync request.
history_truncated.fullTranscriptAvailable is a boolean capability flag. When it is true, callers can page the full active persisted replay with DaemonClient.getSessionTranscriptPage(sessionId, { cursor, limit }); when it is false, clients should keep rendering the bounded replay normally.
When workspace_persisted_transcript is advertised, client.workspaceById(workspaceId).getSessionTranscriptPage(sessionId, { cursor, limit }) reads the selected registered workspace without attaching to ACP. The workspace-qualified method always uses native REST even if the client has a replaceable transport; its cursor expires when the daemon restarts.
When workspace_session_export is advertised, client.workspaceById(workspaceId).exportSession(sessionId, { format }) or client.workspaceByCwd(workspaceCwd).exportSession(...) exports the selected trusted workspace’s active persisted transcript. It returns the existing DaemonSessionExportResult, preserves optional client identity and client-wide fetch timeout behavior, and always uses native REST even if the client has a replaceable transport. Do not infer this method’s server support from session_export or workspace_qualified_rest_core; older daemons keep primary-only export.
When workspace_archived_session_export is advertised, use client.workspaceById(workspaceId).exportArchivedSession(sessionId, { format }) or the corresponding workspaceByCwd method to export only the selected workspace’s archived persisted transcript. The method uses the same result type and native REST behavior as active export, but it never falls back to an active session; support cannot be inferred from any active export capability.
When workspace_session_live_state is advertised, client.getWorkspaceSessionLiveState(workspaceCwd) or the scoped client.workspaceById(workspaceId).getSessionLiveState() / client.workspaceByCwd(workspaceCwd).getSessionLiveState() reads the selected trusted workspace’s memory-only live-session snapshot plus its catalog version, returning DaemonWorkspaceSessionLiveState ({ v: 1, catalogVersion: DaemonSessionCatalogVersion, sessions: DaemonSessionLiveState[] }). These methods always use native REST with bearer authentication and an encoded workspace selector, preserve optional client identity, and use the existing short-request timeout. They do not call requireCapability() — a capability probe on every poll would double request volume — so consumers pre-flight workspace_session_live_state once from their already-loaded capabilities and fall back to existing catalog polling when the tag is absent. Do not infer support from workspace_qualified_rest_core. Each DaemonSessionLiveState carries an optional updatedAt activity watermark that lets a consumer refresh the recency of a catalog row it already holds instead of reloading the catalog after a completed turn; it is absent before the first running-turn terminal in the current bridge and after a daemon or runtime replacement, so a consumer must keep its existing catalog fallback for a missing value rather than treating absence as unsupported.
Seeding lastEventId at Construction
Callers that persist the cursor across process restarts can seed it:
const session = new DaemonSessionClient({
client,
session: { sessionId, workspaceCwd, attached: true },
lastEventId: persistedCursor, // resume from persisted position
});The value must be a finite, non-negative integer (validated at construction). Invalid values throw.
Configuration
| Knob | Where | Effect |
|---|---|---|
baseUrl | DaemonClient constructor | Daemon URL; trailing slashes stripped. |
token | DaemonClient constructor | Stamped as Authorization: Bearer. |
fetch | DaemonClient constructor | Test injection point. |
fetchTimeoutMs | DaemonClient constructor | Per-call timeout; 0 = disabled. |
clientId | per-method optional arg | X-Qwen-Client-Id header (see 08-session-lifecycle.md). |
lastEventId | DaemonSessionClient constructor | Seed replay cursor. |
maxQueued | per-subscribe option | ?maxQueued=N for the SSE route; pre-flight caps.features.slow_client_warning first. |
perCallTimeoutMs | per-method (e.g. restartMcpServer) | Override client-wide timeout. |
Caveats & Known Limits
fetchTimeoutMsis per-call, not connection-level. Long body reads share the timer. A daemon that streams responses must override per-call or set the timeout to0.- SSE bypasses the fetch timeout — long-lived SSE connections are not killed by
fetchTimeoutMs. UseAbortSignalfor caller-controlled cancellation. parseSseStreambuffer cap is 16 MiB as a defensive bound. A single frame larger than this aborts the iterator (the daemon never legitimately emits such frames).asKnownDaemonEventreturnsundefinedfor unrecognized event types. SDK consumers must handle this branch rather than assuming the union is exhaustive; that is the forward-compatibility contract. Unrecognized events incrementDaemonSessionViewState.unrecognizedKnownEventCount.client_evicted,slow_client_warning,stream_errorare not in the replay ring. Reconnecting after eviction picks up from the daemon’s ring; you will not see the eviction frame again.DaemonClientdoes not auto-retry. Network failures surface as rejections; reconnect / replay strategy is the caller’s responsibility (DaemonSessionClient.events()makes replay easy but reconnect is still per-call).
References
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.