* feat(sdk): support workspace display names * docs: add Web Shell screenshot * feat(web-shell): add workspace display names * fix(serve): harden workspace display name updates * refactor(serve): simplify workspace display names * fix(serve): validate trimmed workspace display names * feat(serve): add workspace update API * docs(serve): clarify workspace display name null handling * docs(sdk): list addWorkspace in daemon client methods
28 KiB
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_toggle'). The typed DaemonSkillToggleResult reports the canonical skillName, whether disk state changed, activation state (applied, deferred, or partial), and refreshed/failed session counts. DaemonWorkspaceSkillStatus.userInvocable is an optional false-only field; absence means the skill is user-invocable.
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
sequenceDiagram
autonumber
participant App as App code
participant SC as DaemonSessionClient
participant DC as DaemonClient
participant D as Daemon
App->>SC: DaemonSessionClient.createOrAttach(client, {clientId: 'alice'})
SC->>DC: client.createOrAttachSession({}, 'alice')
DC->>D: POST /session<br/>Authorization: Bearer ...<br/>X-Qwen-Client-Id: alice
D-->>DC: {sessionId, attached, clientId}
DC-->>SC: DaemonSession
SC-->>App: DaemonSessionClient
App->>SC: prompt({...})
SC->>DC: client.prompt(sessionId, req, 'alice')
DC->>D: POST /session/:id/prompt
D-->>DC: {result}
DC-->>SC: PromptResult
Subscribe with replay
sequenceDiagram
autonumber
participant App as App code
participant SC as DaemonSessionClient
participant DC as DaemonClient
participant D as Daemon
participant P as parseSseStream
App->>SC: for await (e of session.events())
SC->>DC: client.subscribeEvents(sessionId, {lastEventId: <tracked>}, 'alice')
DC->>D: GET /session/:id/events<br/>Last-Event-ID: 42
D-->>DC: SSE bytes (replay then live)
DC->>P: parseSseStream(res.body, signal)
loop per frame
P-->>SC: DaemonEvent
SC->>SC: bump lastSeenEventId
SC-->>App: DaemonEvent
App->>App: asKnownDaemonEvent + reduce
end
Device-flow auth
sequenceDiagram
autonumber
participant App as App
participant AF as DaemonAuthFlow
participant DC as DaemonClient
participant D as Daemon
App->>AF: start({providerId: 'qwen-oauth'})
AF->>DC: client.startDeviceFlow(...)
DC->>D: POST /workspace/auth/device-flow
D-->>DC: {deviceFlowId, verificationUrl, userCode, intervalMs, expiresAt}
DC-->>AF: handle
AF-->>App: handle (with awaitCompletion())
App->>AF: handle.awaitCompletion()
loop until done
AF->>D: GET /workspace/auth/device-flow/:id
D-->>AF: {status: 'pending' | 'authorized' | ...}
AF->>AF: setTimeout(intervalMs)
end
AF-->>App: final state
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/webui/src/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.
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.