mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
* feat(cli): Enable multi-workspace session routing Implement the Phase 2a sessions closed loop for qwen serve multi-workspace mode. Multiple explicit workspaces now create registered runtimes while legacy workspace surfaces remain primary-only, and live session routes dispatch by owning runtime. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address phase2a session review feedback Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): cover remaining phase2a review gaps Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address phase2a session review feedback Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): satisfy phase2a lint checks Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): align multi-workspace status test limits Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address phase2a session review feedback Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6511) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
284 lines
11 KiB
Markdown
284 lines
11 KiB
Markdown
# DaemonClient quickstart (TypeScript)
|
|
|
|
A 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).
|
|
|
|
## Setup
|
|
|
|
In one terminal:
|
|
|
|
```bash
|
|
cd your-project/
|
|
qwen serve --port 4170
|
|
# → qwen serve listening on http://127.0.0.1:4170 (mode=http-bridge, workspace=/path/to/your-project)
|
|
```
|
|
|
|
By default the daemon binds to the current directory (or `--workspace /path/to/dir`). The primary path is advertised on `/capabilities.workspaceCwd` so clients can omit `cwd` from `POST /session`. Daemons that advertise `multi_workspace_sessions` also include `workspaces[]`; pass one of those trusted `cwd` values to create a session in a non-primary workspace.
|
|
|
|
In another:
|
|
|
|
```bash
|
|
npm install @qwen-code/sdk
|
|
```
|
|
|
|
## Hello daemon
|
|
|
|
```ts
|
|
import { DaemonClient, type DaemonEvent } from '@qwen-code/sdk';
|
|
|
|
const client = new DaemonClient({
|
|
baseUrl: 'http://127.0.0.1:4170',
|
|
// PR 27 (v0.16-alpha): when `token` is omitted, DaemonClient falls
|
|
// back to `process.env.QWEN_SERVER_TOKEN` automatically — same env
|
|
// var the daemon's `--token` CLI flag falls back to. So either:
|
|
// export QWEN_SERVER_TOKEN="$(openssl rand -hex 32)" # one-shot
|
|
// export QWEN_SERVER_TOKEN="$(cat ./my-token-file)" # user-managed file
|
|
// const client = new DaemonClient({ baseUrl: '...' });
|
|
// OR pass it explicitly when you have a different env-var name:
|
|
// token: process.env.MY_TOKEN,
|
|
});
|
|
|
|
// 1. Confirm we can reach the daemon, gate UI on its features, and
|
|
// read back the daemon's primary workspace.
|
|
const caps = await client.capabilities();
|
|
console.log('Daemon features:', caps.features);
|
|
console.log('Daemon workspace:', caps.workspaceCwd); // canonical primary path
|
|
|
|
// 2. Spawn-or-attach a session. Two equally-valid shapes:
|
|
// (a) pass `workspaceCwd: caps.workspaceCwd` to be explicit, or
|
|
// (b) omit `workspaceCwd` entirely — the SDK then sends no `cwd`
|
|
// field and the daemon route falls back to its primary
|
|
// workspace. The (b) shape is concise but assumes you trust
|
|
// `caps.workspaceCwd` to be whatever you intended.
|
|
// A non-empty `workspaceCwd` that doesn't canonicalize to the
|
|
// primary path, or to one of `caps.workspaces[].cwd` on a daemon with
|
|
// `multi_workspace_sessions`, yields `400 workspace_mismatch` (see
|
|
// "Workspace mismatch" below).
|
|
const session = await client.createOrAttachSession({
|
|
workspaceCwd: caps.workspaceCwd,
|
|
});
|
|
console.log(`session=${session.sessionId} attached=${session.attached}`);
|
|
|
|
// 3. Subscribe to the event stream. Pass `lastEventId: 0` so the daemon
|
|
// replays everything from the session's start — without it, there's
|
|
// a TOCTOU window between `subscribeEvents()` returning the iterator
|
|
// and the underlying SSE connection actually opening (one fetch
|
|
// round-trip), during which a fast-starting agent can emit events
|
|
// that go into the per-session ring but won't be streamed to a fresh
|
|
// no-cursor subscriber. `lastEventId: 0` makes the replay buffer
|
|
// cover that gap (and any reconnect later — see below).
|
|
const abort = new AbortController();
|
|
const subscription = (async () => {
|
|
for await (const event of client.subscribeEvents(session.sessionId, {
|
|
signal: abort.signal,
|
|
lastEventId: 0,
|
|
})) {
|
|
handleEvent(event);
|
|
}
|
|
})();
|
|
|
|
// 4. Send a prompt and wait for it to settle. (Order-of-operations
|
|
// note: even if `prompt()` fires before the SSE handshake
|
|
// completes, step 3's `lastEventId: 0` guarantees every event
|
|
// lands in the iterator.)
|
|
const result = await client.prompt(session.sessionId, {
|
|
prompt: [{ type: 'text', text: 'Summarize src/main.ts in one sentence.' }],
|
|
});
|
|
console.log('stop reason:', result.stopReason);
|
|
|
|
// 5. Tear down the subscription so the script can exit.
|
|
abort.abort();
|
|
await subscription;
|
|
|
|
function handleEvent(event: DaemonEvent): void {
|
|
switch (event.type) {
|
|
case 'session_update': {
|
|
const data = event.data as {
|
|
sessionUpdate: string;
|
|
content?: { text?: string };
|
|
};
|
|
if (data.sessionUpdate === 'agent_message_chunk' && data.content?.text) {
|
|
process.stdout.write(data.content.text);
|
|
}
|
|
break;
|
|
}
|
|
case 'permission_request':
|
|
// See "Voting on permissions" below for first-responder semantics.
|
|
console.log('\n[needs permission]', event.data);
|
|
break;
|
|
case 'permission_resolved':
|
|
console.log('\n[permission resolved]', event.data);
|
|
break;
|
|
case 'session_died':
|
|
console.error('\n[agent crashed]', event.data);
|
|
break;
|
|
default:
|
|
console.log(`\n[${event.type}]`, event.data);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Workspace file helpers
|
|
|
|
File routes are workspace-scoped, not session-scoped, so they live on
|
|
`DaemonClient` directly:
|
|
|
|
```ts
|
|
const file = await client.readWorkspaceFile('src/main.ts');
|
|
|
|
const updated = await client.editWorkspaceFile({
|
|
path: 'src/main.ts',
|
|
oldText: 'timeout: 30000',
|
|
newText: 'timeout: 60000',
|
|
expectedHash: file.hash!,
|
|
});
|
|
|
|
console.log(updated.hash);
|
|
```
|
|
|
|
`expectedHash` is SHA-256 over the raw on-disk bytes. `mode: "replace"` and
|
|
`editWorkspaceFile()` require it so stale clients do not overwrite a file they
|
|
did not just read. Write/edit require bearer-token configuration even on
|
|
loopback; start the daemon with `--token` or `QWEN_SERVER_TOKEN` before using
|
|
them.
|
|
|
|
## Reconnect with `Last-Event-ID`
|
|
|
|
If your client process restarts mid-session, replay events you missed:
|
|
|
|
```ts
|
|
let cursor: number | undefined;
|
|
|
|
for await (const event of client.subscribeEvents(session.sessionId, {
|
|
signal: abort.signal,
|
|
lastEventId: cursor, // resume from after this id; undefined = live only
|
|
})) {
|
|
if (typeof event.id === 'number') cursor = event.id;
|
|
handleEvent(event);
|
|
}
|
|
```
|
|
|
|
The daemon retains the last 8000 events per session in a ring buffer; gaps beyond that window won't be re-deliverable.
|
|
|
|
## Voting on permissions
|
|
|
|
When 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`.
|
|
|
|
```ts
|
|
case 'permission_request': {
|
|
const req = event.data as {
|
|
requestId: string;
|
|
options: Array<{ optionId: string; name: string; kind: string }>;
|
|
};
|
|
// Pick whichever option you want — `proceed_once`, `allow`, etc.
|
|
const choice = req.options.find((o) => o.kind === 'allow_once') ?? req.options[0];
|
|
const accepted = await client.respondToPermission(req.requestId, {
|
|
outcome: { outcome: 'selected', optionId: choice.optionId },
|
|
});
|
|
if (!accepted) {
|
|
console.log('Another client voted first; nothing to do.');
|
|
}
|
|
break;
|
|
}
|
|
```
|
|
|
|
## Shared-session collaboration
|
|
|
|
Two clients pointed at the **same daemon workspace** end up on the same session when they use the default `sessionScope: 'single'`. For a single-workspace daemon launched as `qwen serve --workspace /work/repo` (or `cd /work/repo && qwen serve`), both clients connect to that primary workspace:
|
|
|
|
```ts
|
|
// Daemon was launched as `qwen serve --workspace /work/repo` so
|
|
// `caps.workspaceCwd === '/work/repo'` for both clients.
|
|
|
|
// Client A (e.g. an IDE plugin)
|
|
const a = await clientA.createOrAttachSession({ workspaceCwd: '/work/repo' });
|
|
console.log(a.attached); // false — A spawned the agent
|
|
|
|
// Client B (e.g. a web UI on the same machine)
|
|
const b = await clientB.createOrAttachSession({ workspaceCwd: '/work/repo' });
|
|
console.log(b.attached); // true — B joined A's session
|
|
console.log(a.sessionId === b.sessionId); // true
|
|
```
|
|
|
|
Both 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.
|
|
|
|
## Workspace mismatch
|
|
|
|
If `workspaceCwd` doesn't match the daemon's primary workspace, or any trusted `workspaces[].cwd` on a daemon that advertises `multi_workspace_sessions`, `createOrAttachSession` rejects with `DaemonHttpError` carrying status `400` and a structured body:
|
|
|
|
```ts
|
|
import { DaemonHttpError } from '@qwen-code/sdk';
|
|
|
|
try {
|
|
await client.createOrAttachSession({ workspaceCwd: '/some/other/project' });
|
|
} catch (err) {
|
|
if (err instanceof DaemonHttpError && err.status === 400) {
|
|
const body = err.body as {
|
|
code?: string;
|
|
boundWorkspace?: string;
|
|
requestedWorkspace?: string;
|
|
};
|
|
if (body.code === 'workspace_mismatch') {
|
|
console.error(
|
|
`This daemon is bound to ${body.boundWorkspace}, ` +
|
|
`not ${body.requestedWorkspace}. Start a separate daemon ` +
|
|
`for that workspace, or route to the right one.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Multi-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.
|
|
|
|
## Authentication
|
|
|
|
When the daemon was started with a token (any non-loopback bind requires one):
|
|
|
|
```ts
|
|
const client = new DaemonClient({
|
|
baseUrl: 'https://your-host:4170',
|
|
token: process.env.QWEN_SERVER_TOKEN,
|
|
});
|
|
```
|
|
|
|
**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:
|
|
|
|
```ts
|
|
// Same effect as token: process.env.QWEN_SERVER_TOKEN, but without the boilerplate.
|
|
const client = new DaemonClient({ baseUrl: 'https://your-host:4170' });
|
|
```
|
|
|
|
The 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.
|
|
|
|
Wrong / missing tokens return `401` with a uniform body — the SDK throws `DaemonHttpError` on any 4xx/5xx from a route handler.
|
|
|
|
```ts
|
|
import { DaemonHttpError } from '@qwen-code/sdk';
|
|
|
|
try {
|
|
await client.health();
|
|
} catch (err) {
|
|
if (err instanceof DaemonHttpError) {
|
|
console.error(`Daemon error ${err.status}:`, err.body);
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Cancel an in-flight prompt
|
|
|
|
If your user hits Esc:
|
|
|
|
```ts
|
|
await client.cancel(session.sessionId);
|
|
// In the event stream you'll see the prompt resolve with stopReason: "cancelled"
|
|
```
|
|
|
|
Cancel 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.)
|
|
|
|
## What's next
|
|
|
|
- [HTTP protocol reference](../qwen-serve-protocol.md) — full route spec with status codes
|
|
- [Daemon mode user guide](../../users/qwen-serve.md) — operator-side docs
|
|
- Source: `packages/sdk-typescript/src/daemon/`
|