Client Daemon du SDK TypeScript
Vue d’ensemble
packages/sdk-typescript/src/daemon/ est le client daemon du SDK TypeScript. C’est la méthode canonique pour se connecter à un daemon qwen serve en cours d’exécution depuis n’importe quel hôte TypeScript / JavaScript (l’adaptateur TUI du CLI, les backends de bots de canal, le compagnon IDE VS Code, les scripts personnalisés et les backends web côté serveur). Tous les autres adaptateurs en dépendent.
La structure du package est volontairement minimaliste :
| Fichier | Surface |
|---|---|
index.ts | Point d’entrée public (barrel) (DaemonClient, DaemonSessionClient, DaemonAuthFlow, parseSseStream, réducteurs d’événements, types). |
DaemonClient.ts | Facade HTTP/SSE de bas niveau — une méthode par route de qwen-serve-protocol.md. |
DaemonSessionClient.ts | Wrapper limité à une session avec suivi de la relecture SSE. |
DaemonAuthFlow.ts | Assistant de haut niveau pour le flux d’appareil OAuth (device-flow). |
sse.ts | parseSseStream (analyseur de tramage NDJSON / SSE). |
events.ts | asKnownDaemonEvent, reduceDaemonSessionEvent, reduceDaemonAuthEvent (voir 09-event-schema.md). |
types.ts | DaemonCapabilities, DaemonSession, DaemonEvent, PermissionResponse, PromptResult, types MCP / agent / mémoire / auth. |
L’exemple de prise en main se trouve dans ../examples/daemon-client-quickstart.md ; ce document est la référence de l’architecture et du contrat.
Responsabilités
- Fournir une méthode TypeScript par route HTTP du daemon.
- Appliquer correctement le bearer token et le
X-Qwen-Client-Idsur chaque requête. - Composer les timeouts par appel avec l’
AbortSignalfourni par l’appelant (sans interrompre les SSE de longue durée). - Streamer et analyser les trames SSE en
DaemonEventtypés. - Suivre le
lastSeenEventIdpar session afin que les reconnexions rejouent correctement. - Exposer une surface d’authentification par device-flow qui interroge à des intervalles fournis par le daemon.
Architecture
DaemonClient (DaemonClient.ts)
Constructeur :
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
});Groupes de méthodes (chaque méthode prend un clientId optionnel pour appliquer le X-Qwen-Client-Id) :
| Groupe | Méthodes |
|---|---|
| Plumbing | health(), capabilities(), auth (accesseur lazy DaemonAuthFlow) |
| Sessions | createOrAttachSession, loadSession, resumeSession, listSessions, closeSession, setSessionMetadata, getSessionContext, getSessionSupportedCommands, setSessionApprovalMode, setSessionModel |
| Prompting | prompt, cancel, heartbeat |
| Événements | subscribeEvents (générateur SSE), subscribeEventsStream (réponse brute) |
| Permissions | respondToPermission, respondToSessionPermission |
| Snapshots de workspace | getWorkspaceMcp, getWorkspaceSkills, getWorkspaceProviders, getWorkspaceEnv, getWorkspacePreflight |
| Mutations de workspace | writeWorkspaceMemory, readWorkspaceMemory, rememberWorkspaceMemory, getWorkspaceMemoryRememberTask, forgetWorkspaceMemory, getWorkspaceMemoryForgetTask, dreamWorkspaceMemory, getWorkspaceMemoryDreamTask, listWorkspaceAgents, getWorkspaceAgent, createWorkspaceAgent, updateWorkspaceAgent, deleteWorkspaceAgent, toggleWorkspaceTool, restartMcpServer, initializeWorkspace |
| Fichiers | readFile, readFileBytes, writeFile, editFile, listDirectory, globPaths, statPath |
| Auth | startDeviceFlow, pollDeviceFlow, cancelDeviceFlow, getAuthStatus |
fetchWithTimeout
Chaque requête passe par fetchWithTimeout. Détails critiques :
- La lecture du body se trouve dans le scope du timer. Les implémentations précédentes effaçaient le timer à l’arrivée des headers ; si un proxy stagnait au milieu du body,
await res.json()pouvait bloquer au-delà defetchTimeoutMs. La forme actuelle passe le code de lecture du body en callback afin que le timer couvre à la fois l’arrivée des headers ET la consommation du body. perCallTimeoutMspermet à un appel unique de surcharger le défaut global du client. L’appelant le plus visible estrestartMcpServer: le SDK utiliseMCP_RESTART_DEFAULT_TIMEOUT_MS = 330_000(5 min 30s). LeMCP_RESTART_TIMEOUT_MSpropre au daemon est exactement de 300s ; si le client correspondait à cette valeur, un redémarrage se terminant vers 300s pourrait perdre la course pendant que le daemon sérialise et envoie sa réponse structurée, provoquant uneTimeoutErrorfaussement positive. Les 30s supplémentaires couvrent la sérialisation, le transfert réseau et le décodage des deux côtés. Les appelants qui ont besoin d’un budget plus strict peuvent passertimeoutMs; passer0désactive le timeout.AbortSignal.anycompose le signal fourni par l’appelant avec le signal du timer par appel, de sorte que l’annulation par l’appelant et le timeout par appel interrompent proprement l’opération.AbortController+setTimeoutannulable au lieu deAbortSignal.timeout(), afin que les requêtes à résolution rapide ne fuient pas de timers en attente sur la boucle d’événements. Le timer est effacé dans lefinally.- Les endpoints de streaming (
subscribeEvents) contournent le timeout — les SSE de longue durée ne doivent pas être tuées par celui-ci.
DaemonSessionClient (DaemonSessionClient.ts)
Lie une session et suit automatiquement le lastSeenEventId afin que la relecture et la reconnexion SSE fonctionnent sans état supplémentaire de la part de l’appelant.
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() proxyfie client.subscribeEvents avec resume: true par défaut — il passe le lastSeenEventId suivi afin que les reconnexions rejouent depuis l’endroit où l’abonnement précédent s’est arrêté. Chaque événement émis incrémente le 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() interroge GET /workspace/auth/device-flow/:id à l’intervalle intervalMs fourni par le daemon jusqu’à ce que le flux devienne authorized, failed ou cancelled. Il est construit de manière paresseuse via client.auth afin que les clients qui ne touchent jamais à l’auth n’encourent aucun coût d’allocation.
parseSseStream (sse.ts)
Transforme un Response.body (ReadableStream<Uint8Array>) en AsyncIterable<DaemonEvent>. Gère :
- Le tramage LF et CRLF.
- La limite de débordement de buffer (16 MiB) — borne défensive contre un daemon émettant une trame absurdement grande.
- Le câblage de l’AbortSignal — l’abort ferme le stream et l’itérateur.
- Les trames de commentaires uniquement et les types d’événements inconnus (transmis en tant que
DaemonEvent; les consommateurs du SDK les restreignent en aval viaasKnownDaemonEvent).
Types (types.ts)
Exports notables : DaemonCapabilities, DaemonSession ({ sessionId, workspaceCwd, attached, clientId?, createdAt? }), DaemonEvent, DaemonSessionState, DaemonSessionContextStatus, DaemonSessionSupportedCommandsStatus, PermissionResponse, PromptResult, HeartbeatResult, SetModelResult, SessionMetadataResult, ainsi que les types de résultats MCP / agent / mémoire / auth. Les types de tâches de mémoire de workspace gérée incluent DaemonWorkspaceMemoryRememberTask, DaemonWorkspaceMemoryForgetTask et DaemonWorkspaceMemoryDreamTask.
Assistants de tâches de mémoire gérée du workspace :
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
Création ou rattachement + premier prompt
Abonnement avec rejeu
Authentification par device-flow
qwen-oauth est l’identifiant du fournisseur hérité v1. Le niveau gratuit de Qwen OAuth a été interrompu le 2026-04-15, les nouveaux clients devraient donc préférer un fournisseur d’authentification actuellement pris en charge lorsqu’il y en a un de disponible.
État et cycle de vie
DaemonClientest sans connexion persistante ; rien ne se passe lors de la construction. Chaque méthode ouvre un nouveaufetch.DaemonSessionClientconservelastSeenEventIdentre les appels àevents(); les reconnexions rejouent les événements depuis le dernier vu.DaemonAuthFlowest paresseux (lazy) —client.authle construit lors du premier accès.- L’itérateur SSE se ferme lorsque (a) le daemon termine le flux, (b)
AbortSignal.abort()est déclenché, (c) le consommateur sort de la bouclefor await, ou (d) la limite de débordement du tampon (16 MiB) est atteinte.
Dépendances
globalThis.fetch(intégré à Node 18+, navigateur, undici, etc.). Injectable parDaemonClientpour les tests.AbortController/AbortSignal.any/setTimeoutnatifs.- Aucune dépendance transitive sur
@qwen-code/qwen-code-coreou@qwen-code/acp-bridge— le package SDK est entièrement découplé afin que les consommateurs externes n’embarquent pas les composants internes du daemon.
Sous-package ui/* (#4328 + #4353 )
Le SDK exporte également packages/sdk-typescript/src/daemon/ui/, un ensemble de primitives indépendantes de l’hôte qui transforment les événements du daemon en blocs de transcription :
normalizeDaemonEvent(evt)mappe les 47 événements wire connus du daemon en 42 valeursDaemonUiEventTypeadaptées à l’UI ; les événements non modélisés ou malformés sont normalisés endebug.createDaemonTranscriptState()ainsi quereduceDaemonTranscriptEvents(state, events)projettent les événements UI dansDaemonTranscriptBlock[].createDaemonTranscriptStore()encapsule subscribe / dispatch.render.ts/terminal.tsfournissent des rendus de base pour HTML et le terminal, tandis quetoolPreview.tsproduit des résumés d’appels d’outils.- Les sélecteurs incluent
selectTranscriptBlocksOrderedByEventId,selectPendingPermissionBlocks,selectCurrentTool,selectApprovalMode,selectToolProgress,selectSubagentChildBlocks,formatMissedRangeetformatBlockTimestamp. - Les constantes publiques incluent
DAEMON_PLAN_TOOL_CALL_ID. conformance.tscontient la suite de tests de cohérence multi-hôtes.
Le premier consommateur en production est packages/webui/src/daemon/ via le DaemonSessionProvider de React. Consultez 14-cli-tui-adapter.md pour l’architecture détaillée, le glossaire, le tableau des sélecteurs et la relation avec l’ancien DaemonTuiAdapter.
Le sous-package est exporté depuis le sous-chemin @qwen-code/sdk/daemon. Le code existant qui fait import { DaemonClient } n’est pas affecté.
Reconnexion Last-Event-ID avec le SDK
Suivi automatique via DaemonSessionClient
DaemonSessionClient suit lastSeenEventId en interne. Chaque événement généré avec un id numérique fait avancer le curseur. Les appels suivants à events() transmettent automatiquement l’identifiant suivi en tant que Last-Event-ID, ce qui permet la reconnexion avec rejeu sans état supplémentaire de la part de l’appelant :
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);
}Reconnexion manuelle avec DaemonClient
Pour un contrôle de plus bas niveau, utilisez DaemonClient.subscribeEvents directement et gérez le curseur vous-même :
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;
}
}Reconnexion avec boucle de retry
Le SDK ne tente pas de retry automatiquement en cas d’échec réseau. Implémentez une boucle de retry autour de 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));
}
}
}Lors de la reconnexion, le daemon rejoue les événements avec id > lastSeenEventId depuis son ring borné (par défaut 8000 événements). Si l’écart dépasse la taille du ring, une trame state_resync_required signale au client d’appeler loadSession pour une reconstruction complète de l’état.
Initialisation de lastEventId à la construction
Les appelants qui persistent le curseur entre les redémarrages de processus peuvent l’initialiser :
const session = new DaemonSessionClient({
client,
session: { sessionId, workspaceCwd, attached: true },
lastEventId: persistedCursor, // resume from persisted position
});La valeur doit être un entier fini et non négatif (validé à la construction). Les valeurs invalides lèvent une erreur.
Configuration
| Paramètre | Où | Effet |
|---|---|---|
baseUrl | Constructeur DaemonClient | URL du daemon ; les slashes finaux sont supprimés. |
token | Constructeur DaemonClient | Injecté en tant que Authorization: Bearer. |
fetch | Constructeur DaemonClient | Point d’injection pour les tests. |
fetchTimeoutMs | Constructeur DaemonClient | Timeout par appel ; 0 = désactivé. |
clientId | Argument optionnel par méthode | En-tête X-Qwen-Client-Id (voir 08-session-lifecycle.md). |
lastEventId | Constructeur DaemonSessionClient | Initialise le curseur de rejeu. |
maxQueued | Option par abonnement | ?maxQueued=N pour la route SSE ; vérifiez préalablement caps.features.slow_client_warning. |
perCallTimeoutMs | Par méthode (ex. restartMcpServer) | Remplace le timeout global du client. |
Mises en garde et limites connues
fetchTimeoutMss’applique par appel, et non au niveau de la connexion. Les lectures longues du corps de la réponse partagent le même timer. Un daemon qui stream des réponses doit remplacer le timeout par appel ou définir le timeout à0.- SSE contourne le fetch timeout — les connexions SSE de longue durée ne sont pas tuées par
fetchTimeoutMs. UtilisezAbortSignalpour une annulation contrôlée par l’appelant. - La limite du tampon de
parseSseStreamest de 16 MiB par mesure de sécurité. Une seule trame plus grande que cela interrompt l’itérateur (le daemon n’émet jamais légitimement de telles trames). asKnownDaemonEventretourneundefinedpour les types d’événements non reconnus. Les consommateurs du SDK doivent gérer cette branche plutôt que de supposer que l’union est exhaustive ; c’est le contrat de compatibilité ascendante. Les événements non reconnus incrémententDaemonSessionViewState.unrecognizedKnownEventCount.client_evicted,slow_client_warning,stream_errorne font pas partie du ring de rejeu. Se reconnecter après une expulsion reprend depuis le ring du daemon ; vous ne reverrez pas la trame d’expulsion.DaemonClientne fait pas de retry automatique. Les échecs réseau se manifestent par des rejets ; la stratégie de reconnexion / rejeu relève de la responsabilité de l’appelant (DaemonSessionClient.events()facilite le rejeu, mais la reconnexion reste à faire par appel).
Références
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- Guide de bout en bout :
../examples/daemon-client-quickstart.md.