* feat(serve): add workspace file write/edit routes Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(serve): bind file hashes to text snapshots Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(serve): tighten read-bytes snapshot and create-mode publish - readBytesWindow: re-stat the open fd after read and require unchanged ino+size+mtime before emitting the response. Mirrors the hardened text-snapshot path so the full-window hash can no longer pair with bytes that drifted under in-place rewrite or append. Surface drift as retryable hash_mismatch. - atomicWriteTextResolvedFile: reject a symlinked parent up-front as defense-in-depth ahead of the parent-fd publish follow-up referenced by assertInodeStableAfterRead. - atomicWriteTextResolvedFile: publish create-mode writes via link()+unlink() instead of rename(). POSIX rename() overwrites an existing regular file, so a racing external process could break the public create contract; link() returns EEXIST atomically and is portable across POSIX/NTFS. The early assertCreateTargetAbsent check stays for friendlier errors on the non-racing path. --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
9.3 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);
}
}
Workspace file helpers
File routes are workspace-scoped, not session-scoped, so they live on
DaemonClient directly:
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:
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/