CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes45downloads
daemon-client-quickstart.md284 linesDownload Raw Back to examples
1# DaemonClient quickstart (TypeScript)2 3A minimal end-to-end example: start a `qwen serve` daemon in another terminal, then drive it from a Node script with the SDK's `DaemonClient`. See also: [Daemon mode user guide](../../users/qwen-serve.md) and [HTTP protocol reference](../qwen-serve-protocol.md).4 5## Setup6 7In one terminal:8 9```bash10cd your-project/11qwen serve --port 417012# → qwen serve listening on http://127.0.0.1:4170 (mode=http-bridge, workspace=/path/to/your-project)13```14 15Per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02 each daemon binds to one workspace at boot (the current `cwd`, or override with `--workspace /path/to/dir`). The daemon's bound path is advertised on `/capabilities.workspaceCwd` so clients can pre-flight check + omit `cwd` from `POST /session`.16 17In another:18 19```bash20npm install @qwen-code/sdk21```22 23## Hello daemon24 25```ts26import { DaemonClient, type DaemonEvent } from '@qwen-code/sdk';27 28const client = new DaemonClient({29  baseUrl: 'http://127.0.0.1:4170',30  // PR 27 (v0.16-alpha): when `token` is omitted, DaemonClient falls31  // back to `process.env.QWEN_SERVER_TOKEN` automatically — same env32  // var the daemon's `--token` CLI flag falls back to. So either:33  //   export QWEN_SERVER_TOKEN="$(openssl rand -hex 32)"   # one-shot34  //   export QWEN_SERVER_TOKEN="$(cat ./my-token-file)"    # user-managed file35  //   const client = new DaemonClient({ baseUrl: '...' });36  // OR pass it explicitly when you have a different env-var name:37  //   token: process.env.MY_TOKEN,38});39 40// 1. Confirm we can reach the daemon, gate UI on its features, and41//    read back the daemon's bound workspace (#3803 §02).42const caps = await client.capabilities();43console.log('Daemon features:', caps.features);44console.log('Daemon workspace:', caps.workspaceCwd); // canonical bound path45 46// 2. Spawn-or-attach a session. Two equally-valid shapes:47//    (a) pass `workspaceCwd: caps.workspaceCwd` to be explicit, or48//    (b) omit `workspaceCwd` entirely — the SDK then sends no `cwd`49//        field and the daemon route falls back to its bound50//        workspace. The (b) shape is concise but assumes you trust51//        `caps.workspaceCwd` to be whatever you intended.52//    A non-empty `workspaceCwd` that doesn't canonicalize to the53//    daemon's bound path yields `400 workspace_mismatch` (see54//    "Workspace mismatch" below).55const session = await client.createOrAttachSession({56  workspaceCwd: caps.workspaceCwd,57});58console.log(`session=${session.sessionId} attached=${session.attached}`);59 60// 3. Subscribe to the event stream. Pass `lastEventId: 0` so the daemon61//    replays everything from the session's start — without it, there's62//    a TOCTOU window between `subscribeEvents()` returning the iterator63//    and the underlying SSE connection actually opening (one fetch64//    round-trip), during which a fast-starting agent can emit events65//    that go into the per-session ring but won't be streamed to a fresh66//    no-cursor subscriber. `lastEventId: 0` makes the replay buffer67//    cover that gap (and any reconnect later — see below).68const abort = new AbortController();69const subscription = (async () => {70  for await (const event of client.subscribeEvents(session.sessionId, {71    signal: abort.signal,72    lastEventId: 0,73  })) {74    handleEvent(event);75  }76})();77 78// 4. Send a prompt and wait for it to settle. (Order-of-operations79//    note: even if `prompt()` fires before the SSE handshake80//    completes, step 3's `lastEventId: 0` guarantees every event81//    lands in the iterator.)82const result = await client.prompt(session.sessionId, {83  prompt: [{ type: 'text', text: 'Summarize src/main.ts in one sentence.' }],84});85console.log('stop reason:', result.stopReason);86 87// 5. Tear down the subscription so the script can exit.88abort.abort();89await subscription;90 91function handleEvent(event: DaemonEvent): void {92  switch (event.type) {93    case 'session_update': {94      const data = event.data as {95        sessionUpdate: string;96        content?: { text?: string };97      };98      if (data.sessionUpdate === 'agent_message_chunk' && data.content?.text) {99        process.stdout.write(data.content.text);100      }101      break;102    }103    case 'permission_request':104      // See "Voting on permissions" below for first-responder semantics.105      console.log('\n[needs permission]', event.data);106      break;107    case 'permission_resolved':108      console.log('\n[permission resolved]', event.data);109      break;110    case 'session_died':111      console.error('\n[agent crashed]', event.data);112      break;113    default:114      console.log(`\n[${event.type}]`, event.data);115  }116}117```118 119## Workspace file helpers120 121File routes are workspace-scoped, not session-scoped, so they live on122`DaemonClient` directly:123 124```ts125const file = await client.readWorkspaceFile('src/main.ts');126 127const updated = await client.editWorkspaceFile({128  path: 'src/main.ts',129  oldText: 'timeout: 30000',130  newText: 'timeout: 60000',131  expectedHash: file.hash!,132});133 134console.log(updated.hash);135```136 137`expectedHash` is SHA-256 over the raw on-disk bytes. `mode: "replace"` and138`editWorkspaceFile()` require it so stale clients do not overwrite a file they139did not just read. Write/edit require bearer-token configuration even on140loopback; start the daemon with `--token` or `QWEN_SERVER_TOKEN` before using141them.142 143## Reconnect with `Last-Event-ID`144 145If your client process restarts mid-session, replay events you missed:146 147```ts148let cursor: number | undefined;149 150for await (const event of client.subscribeEvents(session.sessionId, {151  signal: abort.signal,152  lastEventId: cursor, // resume from after this id; undefined = live only153})) {154  if (typeof event.id === 'number') cursor = event.id;155  handleEvent(event);156}157```158 159The daemon retains the last 8000 events per session in a ring buffer; gaps beyond that window won't be re-deliverable.160 161## Voting on permissions162 163When the agent asks for permission to run a tool, every connected client sees the `permission_request` event. **First responder wins** — once one client votes, the rest get `404` if they try to vote on the same `requestId`.164 165```ts166case 'permission_request': {167  const req = event.data as {168    requestId: string;169    options: Array<{ optionId: string; name: string; kind: string }>;170  };171  // Pick whichever option you want — `proceed_once`, `allow`, etc.172  const choice = req.options.find((o) => o.kind === 'allow_once') ?? req.options[0];173  const accepted = await client.respondToPermission(req.requestId, {174    outcome: { outcome: 'selected', optionId: choice.optionId },175  });176  if (!accepted) {177    console.log('Another client voted first; nothing to do.');178  }179  break;180}181```182 183## Shared-session collaboration184 185Two clients pointed at the **same daemon** end up on the same session. Per #3803 §02 each daemon is bound to ONE workspace at boot, so the daemon launched as `qwen serve --workspace /work/repo` (or `cd /work/repo && qwen serve`) is what both clients connect to:186 187```ts188// Daemon was launched as `qwen serve --workspace /work/repo` so189// `caps.workspaceCwd === '/work/repo'` for both clients.190 191// Client A (e.g. an IDE plugin)192const a = await clientA.createOrAttachSession({ workspaceCwd: '/work/repo' });193console.log(a.attached); // false — A spawned the agent194 195// Client B (e.g. a web UI on the same machine)196const b = await clientB.createOrAttachSession({ workspaceCwd: '/work/repo' });197console.log(b.attached); // true — B joined A's session198console.log(a.sessionId === b.sessionId); // true199```200 201Both clients see the same `session_update` / `permission_request` stream. Either can send a prompt; they FIFO-queue per the agent's "one active prompt per session" guarantee.202 203## Workspace mismatch204 205If `workspaceCwd` doesn't match the daemon's bound workspace, `createOrAttachSession` rejects with `DaemonHttpError` carrying status `400` and a structured body:206 207```ts208import { DaemonHttpError } from '@qwen-code/sdk';209 210try {211  await client.createOrAttachSession({ workspaceCwd: '/some/other/project' });212} catch (err) {213  if (err instanceof DaemonHttpError && err.status === 400) {214    const body = err.body as {215      code?: string;216      boundWorkspace?: string;217      requestedWorkspace?: string;218    };219    if (body.code === 'workspace_mismatch') {220      console.error(221        `This daemon is bound to ${body.boundWorkspace}, ` +222          `not ${body.requestedWorkspace}. Start a separate daemon ` +223          `for that workspace, or route to the right one.`,224      );225    }226  }227}228```229 230Multi-workspace deployments run one daemon per workspace on separate ports — there's no intra-daemon routing under §02. An orchestrator (or the user's launcher) picks the right daemon based on the project the client wants to talk to.231 232## Authentication233 234When the daemon was started with a token (any non-loopback bind requires one):235 236```ts237const client = new DaemonClient({238  baseUrl: 'https://your-host:4170',239  token: process.env.QWEN_SERVER_TOKEN,240});241```242 243**SDK env fallback (PR 27, v0.16-alpha)** — `DaemonClient` reads `QWEN_SERVER_TOKEN` from the environment automatically when `token` is omitted, mirroring the daemon's own `--token` CLI fallback. So if your shell has `export QWEN_SERVER_TOKEN=...`, this is equivalent to the above:244 245```ts246// Same effect as token: process.env.QWEN_SERVER_TOKEN, but without the boilerplate.247const client = new DaemonClient({ baseUrl: 'https://your-host:4170' });248```249 250The fallback strips leading/trailing whitespace (handy for `export QWEN_SERVER_TOKEN="$(cat token.txt)"` where `cat` adds a newline) and treats empty / whitespace-only values as unset (a stale `export QWEN_SERVER_TOKEN=""` won't accidentally send `Authorization: Bearer ` with no token). The fallback runs once at construction; later `process.env` mutations don't affect already-built clients. Browser bundles (e.g. via `@qwen-code/webui`) get `undefined` cleanly because `globalThis.process` doesn't exist there.251 252Wrong / missing tokens return `401` with a uniform body — the SDK throws `DaemonHttpError` on any 4xx/5xx from a route handler.253 254```ts255import { DaemonHttpError } from '@qwen-code/sdk';256 257try {258  await client.health();259} catch (err) {260  if (err instanceof DaemonHttpError) {261    console.error(`Daemon error ${err.status}:`, err.body);262  } else {263    throw err;264  }265}266```267 268## Cancel an in-flight prompt269 270If your user hits Esc:271 272```ts273await client.cancel(session.sessionId);274// In the event stream you'll see the prompt resolve with stopReason: "cancelled"275```276 277Cancel only winds down the **active** prompt — anything you'd already POSTed and that's still queued behind it will continue to run. (See protocol reference for the rationale.)278 279## What's next280 281- [HTTP protocol reference](../qwen-serve-protocol.md) — full route spec with status codes282- [Daemon mode user guide](../../users/qwen-serve.md) — operator-side docs283- Source: `packages/sdk-typescript/src/daemon/`284 
basant307/AI_Governance_Project · CoolFace