mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* fix(serve): bound command snapshot replay memory Suppress unchanged command snapshots and retain only the latest full skill details so periodic refreshes cannot grow daemon heap indefinitely. Bound replay rings by serialized bytes while preserving ACP live delivery and reconnect IDs, and expose retention diagnostics. * fix(serve): avoid redundant replay seed serialization Size only retained complete command snapshots when seeding replay history, and forward the replay byte limit through the development daemon launcher. Pin snapshot accounting and lifetime eviction counters with independent regression assertions, and document the count/byte retention bounds and conditional command notifications. Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]>
332 lines
13 KiB
Markdown
332 lines
13 KiB
Markdown
# API-only DaemonClient quickstart (TypeScript)
|
|
|
|
A minimal end-to-end example: start an API-only `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
|
|
|
|
This walkthrough targets Qwen Code `v0.24.0` and
|
|
`@qwen-code/[email protected]`.
|
|
|
|
In one terminal:
|
|
|
|
```bash
|
|
qwen serve --no-web --port 4170 \
|
|
--workspace /path/to/project-a \
|
|
--workspace /path/to/project-b
|
|
# → qwen serve listening on http://127.0.0.1:4170 (mode=http-bridge, workspace=/path/to/project-a)
|
|
```
|
|
|
|
`--no-web` removes the Web Shell assets and the surfaces bound to them: `POST /workspace/local-control/enable` then fails closed with `409 local_control_web_shell_unavailable` on every platform, and on macOS the `/live/*` routes, the `/live/host` WebSocket and the `experimental.liveVoice.*` settings keys are not registered — so an integration that drives the SDK's Live methods must run without `--no-web`. It is not a feature-profile switch: the session, prompt, workspace, permission and SSE routes are unchanged. Each `--workspace` value must be an absolute directory. The first startup workspace is primary and remains the compatibility default for requests that omit `cwd`; `/capabilities.workspaces[]` is the catalog clients should use when selecting any runtime explicitly.
|
|
|
|
The token-less loopback default is intended for a single-user workstation. On a shared host, set `QWEN_SERVER_TOKEN` and add `--require-auth`; non-loopback binds require a token.
|
|
|
|
In another:
|
|
|
|
```bash
|
|
npm install @qwen-code/[email protected]
|
|
```
|
|
|
|
## 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
|
|
// select a trusted workspace from the advertised catalog.
|
|
const caps = await client.capabilities();
|
|
console.log('Daemon features:', caps.features);
|
|
const selectedWorkspace =
|
|
caps.workspaces?.find(
|
|
(workspace) => workspace.trusted && !workspace.primary,
|
|
) ?? caps.workspaces?.find((workspace) => workspace.trusted);
|
|
if (!selectedWorkspace) throw new Error('No trusted workspace is available');
|
|
console.log('Selected workspace:', selectedWorkspace.id, selectedWorkspace.cwd);
|
|
|
|
// 2. Spawn-or-attach inside that runtime. The SDK maps `workspaceCwd`
|
|
// to the wire-level POST /session `cwd` field. Omitting it is allowed
|
|
// only when the caller intentionally wants the legacy primary default.
|
|
const session = await client.createOrAttachSession({
|
|
workspaceCwd: selectedWorkspace.cwd,
|
|
});
|
|
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);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Restore, poll status, and read history
|
|
|
|
Closing a live session does not delete its persisted transcript. Save the id,
|
|
close the live owner, then restore it. Use `loadSession` when the client needs
|
|
persisted turns replayed into its SSE stream; use `resumeSession` when the
|
|
client already has those turns rendered and only needs the daemon-side handle
|
|
restored. Neither method continues an interrupted turn; call `continueSession`
|
|
separately when that is required.
|
|
|
|
```ts
|
|
const savedSessionId = session.sessionId;
|
|
await client.closeSession(savedSessionId, session.clientId);
|
|
|
|
const restored =
|
|
process.env.HISTORY_ALREADY_RENDERED === '1'
|
|
? await client.resumeSession(savedSessionId, {
|
|
workspaceCwd: selectedWorkspace.cwd,
|
|
})
|
|
: await client.loadSession(savedSessionId, {
|
|
workspaceCwd: selectedWorkspace.cwd,
|
|
historyPageSize: 100,
|
|
});
|
|
|
|
const status = await client.sessionStatus(
|
|
restored.sessionId,
|
|
restored.clientId,
|
|
);
|
|
console.log({
|
|
active: status.hasActivePrompt,
|
|
waitingForPermission: status.isWaitingForPermission,
|
|
});
|
|
|
|
const history = await client.getSessionTranscriptPage(restored.sessionId, {
|
|
limit: 100,
|
|
clientId: restored.clientId,
|
|
});
|
|
console.log(`history events=${history.events.length} more=${history.hasMore}`);
|
|
```
|
|
|
|
`sessionStatus` reads only a live owner. `getSessionTranscriptPage` reads
|
|
persisted history and returns an opaque `nextCursor` when another page is
|
|
available; pass that value back as `cursor` rather than constructing one.
|
|
|
|
## Workspace file helpers
|
|
|
|
File routes are workspace-scoped, not session-scoped. Bind a qualified helper
|
|
to the selected workspace id so every request remains inside that runtime:
|
|
|
|
```ts
|
|
const selected = client.workspaceById(selectedWorkspace.id);
|
|
const file = await selected.readWorkspaceFile('src/main.ts');
|
|
|
|
const updated = await selected.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 accept the token-less trusted-loopback primary
|
|
listener; non-trusted deployments require bearer or pairing credentials.
|
|
|
|
## 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 up to 8000 events per session, also constrained by the default 8 MiB ring byte budget including the latest full command snapshot. A single oversized newest event or snapshot is the exception. Gaps beyond the retained window require resync; increase both `--event-ring-size` and `--event-ring-max-bytes` when both limits constrain the required reconnect history.
|
|
|
|
## 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` does not match any registered advertised workspace, `createOrAttachSession` rejects with `DaemonHttpError` carrying status `400` and a structured body. A registered but untrusted secondary instead returns `403 untrusted_workspace` and must not be retried against primary:
|
|
|
|
```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(
|
|
`Workspace ${body.requestedWorkspace} is not registered. ` +
|
|
`Refresh capabilities and select an advertised workspace, ` +
|
|
`or register it before retrying.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Do not retry against the primary workspace after a mismatch. Refresh `/capabilities`, select the intended entry from `workspaces[]`, or register an eligible dynamic workspace through `POST /workspaces`. Use separate daemons only when authentication, rate-limit, or process fault boundaries must also be independent.
|
|
|
|
## 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/web-shell`) 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/`
|