Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 87 additions & 1 deletion docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ The write tag means the route contract exists; it does not mean the current
deployment is open for anonymous mutation. Write/edit are strict mutation
routes and require a configured bearer token even on loopback.

`daemon_status` advertises `GET /daemon/status`, the consolidated read-only
operator diagnostic snapshot documented below.

**Conditional tags.** A small number of feature tags are advertised only when the matching deployment toggle is on. Tag presence = behavior is on; absence = either an older daemon predating the tag, OR a current daemon where the operator did not opt in. Currently:

| Tag | Advertised when … |
Expand Down Expand Up @@ -213,6 +216,89 @@ Pass `?deep=1` (also accepts `?deep=true` or bare `?deep`) for a probe that expo

**Auth:** required **only on non-loopback binds**. On loopback (`127.0.0.1`, `::1`, `[::1]`) `/health` is registered before the bearer middleware so k8s/Compose probes inside the pod don't need to carry the token. On non-loopback (`--hostname 0.0.0.0` etc.) the route is registered after the bearer middleware and returns 401 without a valid token — otherwise an unauthenticated caller could probe arbitrary addresses to confirm a `qwen serve` exists, a low-severity info leak that combines poorly with port scanning. CORS deny + Host allowlist still apply on the loopback exemption.

### `GET /daemon/status`

Read-only operator diagnostics. Unlike `/health`, this is a normal daemon API:
it is registered after bearer auth and rate limiting, including on loopback
binds. Query parameter:

- `detail=summary` (default) reads only in-memory daemon state.
- `detail=full` also includes live session diagnostics, ACP connection
diagnostics, auth device-flow counts, and workspace status sections.
- any other `detail` returns `400 { "code": "invalid_detail" }`.

`summary` intentionally does not query workspace status methods, start an ACP
child, or spawn a session. `full` queries each workspace section independently;
a timeout or exception marks only that section as `unavailable` and adds a
`workspace_status_unavailable` issue.

Response shape:

```json
{
"v": 1,
"detail": "summary",
"generatedAt": "2026-06-16T00:00:00.000Z",
"status": "ok",
"issues": [],
"daemon": {
"pid": 12345,
"uptimeMs": 3600000,
"mode": "http-bridge",
"workspaceCwd": "/repo",
"qwenCodeVersion": "0.18.1",
"daemonId": "serve-..."
},
"security": {
"tokenConfigured": true,
"requireAuth": false,
"loopbackBind": true,
"allowOriginConfigured": false,
"allowOriginMode": "none",
"sessionShellCommandEnabled": false
},
"limits": {
"maxSessions": 20,
"maxPendingPromptsPerSession": 5,
"listenerMaxConnections": 256,
"eventRingSize": 8000,
"promptDeadlineMs": null,
"writerIdleTimeoutMs": null,
"channelIdleTimeoutMs": 0,
"sessionIdleTimeoutMs": 1800000,
"acpConnectionCap": 64
},
"runtime": {
"sessions": { "active": 0 },
"permissions": { "pending": 0, "policy": "first-responder" },
"channel": { "live": false },
"transport": {
"restSseActive": 0,
"acp": {
"enabled": true,
"connections": 0,
"connectionStreams": 0,
"sessionStreams": 0,
"sseStreams": 0,
"wsStreams": 0,
"pendingClientRequests": 0
}
}
}
}
```

`status` is `error` if any issue has error severity, `warning` if any issue has
warning severity, otherwise `ok`. Issue codes are stable and include
`session_capacity_high`, `connection_capacity_high`, `pending_permissions`,
`acp_channel_down`, `preflight_error`, `mcp_budget_warning`,
`mcp_budget_exhausted`, `rate_limit_hits`, and
`workspace_status_unavailable`.

Security: the response never includes bearer tokens, client ids, full ACP
connection ids, device-flow user codes, or verification URLs. `summary` omits
the daemon log path; `full` may include it for authenticated operators.

### `GET /capabilities`

```json
Expand All @@ -223,7 +309,7 @@ Pass `?deep=1` (also accepts `?deep=true` or bare `?deep`) for a probe that expo
"supported": ["v1"]
},
"mode": "http-bridge",
"features": ["health", "capabilities", "..."],
"features": ["health", "daemon_status", "capabilities", "..."],
"modelServices": [],
"workspaceCwd": "/canonical/path/to/workspace"
}
Expand Down
25 changes: 18 additions & 7 deletions docs/users/qwen-serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,18 +70,29 @@ curl http://127.0.0.1:4170/health
# → {"status":"ok"}

curl http://127.0.0.1:4170/capabilities
# → {"v":1,"mode":"http-bridge","features":["health","capabilities","session_create",...],"workspaceCwd":"/path/to/your-project"}
# → {"v":1,"mode":"http-bridge","features":["health","daemon_status","capabilities","session_create",...],"workspaceCwd":"/path/to/your-project"}

curl http://127.0.0.1:4170/daemon/status
# → {"v":1,"detail":"summary","status":"ok","runtime":{...}}
```

The `workspaceCwd` field surfaces the bound workspace so clients can pre-flight check + omit `cwd` on `POST /session`.
The `limits.maxPendingPromptsPerSession` field advertises the active per-session prompt admission cap; `null` means the cap is disabled.

The daemon also exposes read-only runtime snapshots for client UIs:
`GET /workspace/mcp`, `GET /workspace/skills`, `GET /workspace/providers`,
`GET /workspace/env`, `GET /workspace/preflight`,
The daemon also exposes read-only runtime snapshots for client UIs and
operators: `GET /daemon/status`, `GET /workspace/mcp`,
`GET /workspace/skills`, `GET /workspace/providers`, `GET /workspace/env`,
`GET /workspace/preflight`,
`GET /session/:id/context`, `GET /session/:id/supported-commands`, and
`GET /session/:id/tasks`.

`GET /daemon/status` is the consolidated troubleshooting snapshot. The default
`detail=summary` reads only in-memory daemon state (sessions, permissions,
SSE/ACP transport counts, rate limit rejects, process memory, resolved limits)
and does not start the ACP child. Use `GET /daemon/status?detail=full` for
per-session diagnostics, ACP connection details, auth device-flow counts, and
workspace status sections when you are actively investigating a problem.

`GET /workspace/mcp`, `GET /workspace/skills`, and `GET /workspace/providers`
report the live ACP runtime and do not start the ACP child when idle; an
idle daemon returns `initialized: false` with an empty snapshot. Once a
Expand Down Expand Up @@ -320,9 +331,9 @@ To handle multiple **users** (each with their own quota, audit log, sandbox) or

The daemon exposes ACP's `session/load` and resume flow over HTTP via two routes:

| Route | Use when |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /session/:id/load` | The client has **no** history rendered (cold reconnect, picker-then-open). The daemon replays every persisted turn through SSE so subscribers see the full transcript. Capability tag: `session_load`. |
| Route | Use when |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /session/:id/load` | The client has **no** history rendered (cold reconnect, picker-then-open). The daemon replays every persisted turn through SSE so subscribers see the full transcript. Capability tag: `session_load`. |
| `POST /session/:id/resume` | The client already has the turns on screen and only needs the daemon-side handle back. Model context is restored on the agent side without UI replay — the SSE stream stays clean. Capability tag: `session_resume` (`unstable_session_resume` remains a deprecated alias for older clients). |

The TypeScript SDK exposes both as static factories on `DaemonSessionClient`:
Expand Down
42 changes: 42 additions & 0 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import type {
BridgeClientRequestContext,
CloseSessionOpts,
AcpSessionBridge,
BridgeDaemonStatusSnapshot,
} from './bridgeTypes.js';
import type { BridgeOptions, BridgeTelemetry } from './bridgeOptions.js';
import { MCP_RESTART_SERVER_DEADLINE_MS } from './mcpTimeouts.js';
Expand Down Expand Up @@ -2478,6 +2479,47 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
startSessionReaper();

return {
getDaemonStatusSnapshot(): BridgeDaemonStatusSnapshot {
return {
limits: {
maxSessions: maxSessions === Infinity ? null : maxSessions,
maxPendingPromptsPerSession:
maxPendingPromptsPerSession === Infinity
? null
: maxPendingPromptsPerSession,
eventRingSize,
channelIdleTimeoutMs: resolvedChannelIdleTimeoutMs(),
sessionIdleTimeoutMs,
},
sessionCount: byId.size,
pendingPermissionCount: permissionMediator.pendingCount,
channelLive: !!liveChannelInfo(),
permissionPolicy: permissionMediator.policy,
sessions: [...byId.values()].map((entry) => ({
sessionId: entry.sessionId,
workspaceCwd: entry.workspaceCwd,
createdAt: entry.createdAt,
...(entry.displayName ? { displayName: entry.displayName } : {}),
clientCount: entry.clientIds.size,
subscriberCount: entry.events.subscriberCount,
attachCount: entry.attachCount,
pendingPromptCount: entry.pendingPromptCount,
pendingPermissionCount: entry.pendingPermissionIds.size,
hasActivePrompt: entry.promptActive,
lastEventId: entry.events.lastEventId,
...(entry.sessionLastSeenAt !== undefined
? { lastSeenAt: entry.sessionLastSeenAt }
: {}),
...(entry.currentModelId
? { currentModelId: entry.currentModelId }
: {}),
...(entry.currentApprovalMode
? { currentApprovalMode: entry.currentApprovalMode }
: {}),
})),
};
},

get sessionCount() {
return byId.size;
},
Expand Down
37 changes: 37 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,44 @@ export interface BridgeHeartbeatState {
clientLastSeenAt: ReadonlyMap<string, number>;
}

export interface BridgeDaemonStatusLimits {
maxSessions: number | null;
maxPendingPromptsPerSession: number | null;
eventRingSize: number;
channelIdleTimeoutMs: number;
sessionIdleTimeoutMs: number;
}

export interface BridgeDaemonSessionDiagnostic {
sessionId: string;
workspaceCwd: string;
createdAt: string;
displayName?: string;
clientCount: number;
subscriberCount: number;
attachCount: number;
pendingPromptCount: number;
pendingPermissionCount: number;
hasActivePrompt: boolean;
lastEventId: number;
lastSeenAt?: number;
currentModelId?: string;
currentApprovalMode?: string;
}

export interface BridgeDaemonStatusSnapshot {
limits: BridgeDaemonStatusLimits;
sessionCount: number;
pendingPermissionCount: number;
channelLive: boolean;
permissionPolicy: PermissionPolicy;
sessions: BridgeDaemonSessionDiagnostic[];
}

export interface AcpSessionBridge {
/** Read-only daemon diagnostics for status endpoints. */
getDaemonStatusSnapshot(): BridgeDaemonStatusSnapshot;

/**
* Create a new session, or — under `sessionScope: 'single'` — attach to an
* existing session for the same workspace.
Expand Down
93 changes: 93 additions & 0 deletions packages/cli/src/serve/acpHttp/connectionRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from 'vitest';
import { ConnectionRegistry } from './connectionRegistry.js';
import type { TransportStream } from './transportStream.js';

class FakeStream implements TransportStream {
isClosed = false;

constructor(readonly kind: 'sse' | 'ws') {}

async send(_message: unknown): Promise<void> {}

close(): void {
this.isClosed = true;
}
}

describe('ConnectionRegistry.getSnapshot', () => {
it('counts SSE streams and redacts full connection ids', () => {
const registry = new ConnectionRegistry(undefined, undefined, 2);
try {
const conn = registry.create(true);
expect(conn).toBeDefined();
if (!conn) return;

conn.attachConnStream(new FakeStream('sse'));
conn.ownSession('sess-1');
conn.attachSessionStream(
'sess-1',
new FakeStream('sse'),
new AbortController(),
);
conn.pending.set('request-1', {
sessionId: 'sess-1',
bridgeRequestId: 'permission-1',
kind: 'permission',
});

const snapshot = registry.getSnapshot();

expect(snapshot).toMatchObject({
connectionCount: 1,
connectionCap: 2,
connectionStreams: 1,
sessionStreams: 1,
sseStreams: 2,
wsStreams: 0,
pendingClientRequests: 1,
});
expect(snapshot.connections[0]).toMatchObject({
connectionIdPrefix: conn.connectionId.slice(0, 8),
fromLoopback: true,
ownedSessionCount: 1,
sessionBindingCount: 1,
pendingClientRequests: 1,
});
expect(snapshot.connections[0]?.connectionIdPrefix).toHaveLength(8);
expect(JSON.stringify(snapshot)).not.toContain(conn.connectionId);
} finally {
registry.dispose();
}
});

it('counts a shared WebSocket stream once while tracking session bindings', () => {
const registry = new ConnectionRegistry();
try {
const conn = registry.create(false);
expect(conn).toBeDefined();
if (!conn) return;

const stream = new FakeStream('ws');
conn.attachConnStream(stream);
conn.ownSession('sess-1');
conn.attachSessionStream('sess-1', stream, new AbortController());
conn.ownSession('sess-2');
conn.attachSessionStream('sess-2', stream, new AbortController());

const snapshot = registry.getSnapshot();

expect(snapshot.connectionStreams).toBe(1);
expect(snapshot.sessionStreams).toBe(2);
expect(snapshot.wsStreams).toBe(1);
expect(snapshot.sseStreams).toBe(0);
} finally {
registry.dispose();
}
});
});
Loading
Loading