Process the 7 inline /review comments on PR #4113: - C1+C3 (SDK): make `DaemonCapabilities.workspaceCwd` and `CreateSessionRequest.workspaceCwd` optional in the SDK types. `workspaceCwd` is an additive field on the v=1 envelope per #3803 §02; the protocol's "bump v only on incompatible changes" stance is honored by leaving the field optional at the type level. `DaemonClient.createOrAttachSession` now omits `cwd` from the body when `workspaceCwd` isn't passed, matching the PR description's "SDK accepts bound path or none". Adds a unit test pinning the empty-body shape. - C2 (docs/users/qwen-serve.md): the `--http-bridge` row described the pre-§02 per-session model; updated to reflect one child per daemon with N sessions multiplexed via ACP `newSession()`. - C4 (server.ts): `WorkspaceMismatchError` was silently 400'ing without a stderr breadcrumb, leaving operators blind to cross-workspace routing drift. Mirrors the SessionLimitExceeded /InvalidPermissionOption observability pattern. - C5 (server.test.ts): the `/capabilities` fallback test compared `res.body.workspaceCwd` against raw `process.cwd()`; on macOS default tmpdir flows (`/var/folders/...` → `/private/var/...`) the canonicalize-once route value diverges. Use `realpathSync.native(process.cwd())` to match the route's canonicalization. - C6 (server.ts): the cwd-not-absolute error said "cwd is required and must be an absolute path" but cwd is now optional under §02. Tightened wording to "must be an absolute path when provided". - C7 (runQwenServe.ts): the `statSync` catch only wrapped ENOENT with a friendly diagnostic; EACCES / EPERM (typical for SIP-protected dirs on macOS or root-owned paths the daemon's UID can't traverse) re-threw as raw `SystemError`. Wrap both codes with a `--workspace`-context message so the boot failure points at the flag the operator set. Docs: quickstart shows the explicit-pass-or-omit options side by side; protocol reference notes `workspaceCwd` is additive to v=1.
8.6 KiB
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 and HTTP protocol reference.
Setup
In one terminal:
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)
Per #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.
In another:
npm install @qwen-code/sdk
Hello daemon
import { DaemonClient, type DaemonEvent } from '@qwen-code/sdk';
const client = new DaemonClient({
baseUrl: 'http://127.0.0.1:4170',
// token: process.env.QWEN_SERVER_TOKEN, // required for non-loopback binds
});
// 1. Confirm we can reach the daemon, gate UI on its features, and
// read back the daemon's bound workspace (#3803 §02).
const caps = await client.capabilities();
console.log('Daemon features:', caps.features);
console.log('Daemon workspace:', caps.workspaceCwd); // canonical bound 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 bound
// 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
// daemon's bound path 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);
}
}
Reconnect with Last-Event-ID
If your client process restarts mid-session, replay events you missed:
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 4000 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.
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 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:
// 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 bound workspace, createOrAttachSession rejects with DaemonHttpError carrying status 400 and a structured body:
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):
const client = new DaemonClient({
baseUrl: 'https://your-host:4170',
token: process.env.QWEN_SERVER_TOKEN,
});
Wrong / missing tokens return 401 with a uniform body — the SDK throws DaemonHttpError on any 4xx/5xx from a route handler.
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:
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 — full route spec with status codes
- Daemon mode user guide — operator-side docs
- Source:
packages/sdk-typescript/src/daemon/