diff --git a/docs/developers/daemon/08-session-lifecycle.md b/docs/developers/daemon/08-session-lifecycle.md index c60e7bdd7c2..47ba304e2ca 100644 --- a/docs/developers/daemon/08-session-lifecycle.md +++ b/docs/developers/daemon/08-session-lifecycle.md @@ -258,7 +258,7 @@ new session arrives. - `BridgeOptions.sessionScope` (default `'single'`; optional `'thread'`). - `BridgeOptions.initializeTimeoutMs` (default 10s) — ACP `initialize` handshake. - `BridgeOptions.channelIdleTimeoutMs` (default 0; reap the ACP child immediately). -- Capability tags: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume` (deprecated alias), `session_list`, `session_close`, `session_metadata`, `session_set_model`, `client_identity`, `client_heartbeat`, `session_recap`, `session_btw`, `session_context_usage`, `session_tasks`, `session_stats`, `session_lsp`, `non_blocking_prompt`. +- Capability tags: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume` (deprecated alias), `session_list`, `session_close`, `session_metadata`, `session_set_model`, `client_identity`, `client_heartbeat`, `session_recap`, `session_btw`, `session_context_usage`, `session_tasks`, `session_stats`, `session_lsp`, `session_status`, `non_blocking_prompt`. ## Caveats & Known Limits diff --git a/docs/developers/daemon/11-capabilities-versioning.md b/docs/developers/daemon/11-capabilities-versioning.md index c0424fe1b1d..e626f696d7e 100644 --- a/docs/developers/daemon/11-capabilities-versioning.md +++ b/docs/developers/daemon/11-capabilities-versioning.md @@ -89,7 +89,7 @@ Baseline tags are not present in the `Map` and are advertised unconditionally. T Foundation: `health`, `capabilities`. -Sessions: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_prompt`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_stats`, `session_lsp`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_shell_command`** (conditional), `session_language`, `session_rewind`, `session_hooks`, `session_branch`. +Sessions: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_prompt`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_stats`, `session_lsp`, `session_status`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_shell_command`** (conditional), `session_language`, `session_rewind`, `session_hooks`, `session_branch`. Streaming: `slow_client_warning`, `typed_event_schema`. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 6215f0b34be..5661284a961 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -129,7 +129,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design 'workspace_agents', 'workspace_agent_generate', 'workspace_env', 'workspace_preflight', 'session_context', 'session_context_usage', 'session_supported_commands', 'session_tasks', 'session_stats', - 'session_lsp', + 'session_lsp', 'session_status', 'session_close', 'session_metadata', 'mcp_guardrails', 'workspace_mcp_manage', 'mcp_guardrail_events', 'mcp_server_runtime_mutation', @@ -161,6 +161,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design `session_lsp` advertises `GET /session/:id/lsp`, the read-only structured LSP status snapshot for daemon clients. Older daemons return `404`; pre-flight this tag before exposing remote LSP status. +`session_status` advertises `GET /session/:id/status`, the live bridge summary for a single session by id (`clientCount` / `hasActivePrompt` and the core fields). Older daemons return `404`; pre-flight this tag before polling a single session's status instead of scanning the full session list. + `session_approval_mode_control`, `workspace_tool_toggle`, `workspace_init`, and `workspace_mcp_restart` (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 17) advertise the four mutation control routes documented under "Mutation: approval, tools, init, MCP restart" below. All four are strict-gated by the PR 15 mutation gate (a daemon configured without a bearer token rejects them with 401 `token_required`). Older daemons return `404`; pre-flight each tag before exposing the corresponding affordance. `mcp_guardrails` (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14) covers the MCP budget surface: the `clientCount` / `clientBudget` / `budgetMode` / `budgets[]` fields on `GET /workspace/mcp`, the `disabledReason` field on per-server cells, and the `--mcp-client-budget` / `--mcp-budget-mode` CLI flags. Older daemons omit the new fields entirely; SDK clients pre-flight this tag before relying on `budgets[]` semantics. The registry descriptor also carries `modes: ['warn', 'enforce']` for future feature-modes exposure — for now, clients infer mode from the snapshot's `budgetMode` field. Server refusal under `enforce` mode is deterministic by `Object.entries(mcpServers)` declaration order; a future scope-precedence layer (if qwen-code adopts one) would shift this to "lowest-precedence first" to mirror claude-code's `plugin < user < project < local` convention. @@ -349,6 +351,7 @@ Capability tags: - `session_context` → `GET /session/:id/context` - `session_supported_commands` → `GET /session/:id/supported-commands` - `session_tasks` → `GET /session/:id/tasks` +- `session_status` → `GET /session/:id/status` Common status cell: diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index e6eddea48cc..b59ea88282b 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -86,9 +86,31 @@ 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/status`, `GET /session/:id/context`, +`GET /session/:id/supported-commands`, and `GET /session/:id/tasks`, and `GET /session/:id/lsp`. +`GET /session/:id/status` returns the live bridge summary for a single session: +`sessionId`, `workspaceCwd`, `createdAt`, optional `displayName`, `clientCount`, +and `hasActivePrompt`. It answers `200` with the summary when the daemon holds a +live session with that id, and `404` (body `{ "error": …, "sessionId": … }`) +otherwise. Use it to poll whether one known session is still running +(`hasActivePrompt`) or how many clients are attached (`clientCount`) without +fetching and scanning the whole paginated session list: + +```bash +curl http://127.0.0.1:4170/session/$SESSION_ID/status +# → {"sessionId":"…","workspaceCwd":"…","createdAt":"…","clientCount":1,"hasActivePrompt":false} +``` + +This is the raw live-session view, so `clientCount` and `hasActivePrompt` match +the corresponding entry in `GET /workspace/:id/sessions` — but the two routes +are not byte-identical. The list endpoint enriches each item with persisted +session-store data: its `createdAt` is the persisted first-prompt time, and it +adds `updatedAt` plus a `displayName` derived from the stored title or first +prompt. `/status` instead reports the live session's own `createdAt`, omits +`updatedAt`, and returns `displayName` only when one is set on the live session. + `GET /session/:id/lsp` returns structured per-session LSP status. Start the daemon with `--experimental-lsp` to enable LSP in spawned agent sessions; otherwise the route returns `enabled: false` with no servers. diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index cac0bf8017a..f8af69abc10 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -268,6 +268,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_tasks', 'session_stats', 'session_lsp', + 'session_status', 'session_close', 'session_metadata', 'mcp_guardrails', diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index a3e3e557493..62156c04b1f 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -5236,6 +5236,39 @@ describe('createAcpSessionBridge', () => { }); }); + describe('getSessionSummary', () => { + it('returns the live summary for a known session id', async () => { + const factory: ChannelFactory = async () => makeChannel().channel; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const summary = bridge.getSessionSummary(session.sessionId); + expect(summary).toMatchObject({ + sessionId: session.sessionId, + workspaceCwd: WS_A, + hasActivePrompt: false, + }); + // Agrees with the list builder for the same session — same source, + // single item. + const fromList = bridge + .listWorkspaceSessions(WS_A) + .find((s) => s.sessionId === session.sessionId); + expect(summary).toEqual(fromList); + + await bridge.shutdown(); + }); + + it('throws SessionNotFoundError for an unknown session id', async () => { + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + }); + expect(() => bridge.getSessionSummary('missing')).toThrow( + SessionNotFoundError, + ); + await bridge.shutdown(); + }); + }); + describe('setSessionModel', () => { /** Set up a channel where the agent records setSessionModel calls. */ async function setup() { diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index ab6d9b73c04..b7509b49449 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -1008,6 +1008,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // daemon. Cleared in the `finally` of the creator. let inFlightChannelSpawn: Promise | undefined; const byId = new Map(); + const toSessionSummary = (entry: SessionEntry): BridgeSessionSummary => ({ + sessionId: entry.sessionId, + workspaceCwd: entry.workspaceCwd, + createdAt: entry.createdAt, + displayName: entry.displayName, + clientCount: entry.clientIds.size, + hasActivePrompt: entry.promptActive, + }); // Pending + resolved permission state lives in // `MultiClientPermissionMediator` (constructed below). The bridge // keeps `entry.pendingPermissionIds: Set` on each @@ -3558,19 +3566,18 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const out: BridgeSessionSummary[] = []; for (const entry of byId.values()) { if (entry.workspaceCwd === key) { - out.push({ - sessionId: entry.sessionId, - workspaceCwd: entry.workspaceCwd, - createdAt: entry.createdAt, - displayName: entry.displayName, - clientCount: entry.clientIds.size, - hasActivePrompt: entry.promptActive, - }); + out.push(toSessionSummary(entry)); } } return out; }, + getSessionSummary(sessionId) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + return toSessionSummary(entry); + }, + recordHeartbeat(sessionId, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 77e32cfd03b..280b26b32fd 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -392,6 +392,15 @@ export interface AcpSessionBridge { */ listWorkspaceSessions(workspaceCwd: string): BridgeSessionSummary[]; + /** + * Live status summary for a single session by id — the same shape + * `listWorkspaceSessions` produces per item. Throws + * `SessionNotFoundError` when no live session with that id exists on + * this daemon. Lets a caller that already holds a session id poll + * `hasActivePrompt` / `clientCount` without scanning the whole list. + */ + getSessionSummary(sessionId: string): BridgeSessionSummary; + /** * Record a client heartbeat for the session. Throws * `SessionNotFoundError` for unknown ids and `InvalidClientIdError` diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index fbe9d7da3ef..9f47881dfed 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -82,6 +82,7 @@ export const SERVE_CAPABILITY_REGISTRY = { session_tasks: { since: 'v1' }, session_stats: { since: 'v1' }, session_lsp: { since: 'v1' }, + session_status: { since: 'v1' }, session_close: { since: 'v1' }, session_metadata: { since: 'v1' }, // Daemon supports the MCP client guardrail surface: an in-process diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 2f58237e097..2631882e7ce 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -202,6 +202,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'session_tasks', 'session_stats', 'session_lsp', + 'session_status', 'session_close', 'session_metadata', // Issue #4175 PR 14. Always-on. Daemon supports the MCP client @@ -381,6 +382,7 @@ interface FakeBridgeOpts { context?: BridgeClientRequestContext, ) => boolean; listImpl?: (workspaceCwd: string) => BridgeSessionSummary[]; + summaryImpl?: (sessionId: string) => BridgeSessionSummary; workspaceMcpImpl?: () => Promise; workspaceMcpToolsImpl?: ( serverName: string, @@ -557,6 +559,7 @@ interface FakeBridge extends AcpSessionBridge { context?: BridgeClientRequestContext; }>; listCalls: string[]; + summaryCalls: string[]; workspaceMcpCalls: number; workspaceMcpToolsCalls: string[]; workspaceSkillsCalls: number; @@ -682,6 +685,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const permissionVotes: FakeBridge['permissionVotes'] = []; const sessionPermissionVotes: FakeBridge['sessionPermissionVotes'] = []; const listCalls: string[] = []; + const summaryCalls: string[] = []; let workspaceMcpCalls = 0; const workspaceMcpToolsCalls: string[] = []; let workspaceSkillsCalls = 0; @@ -740,6 +744,11 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const respondImpl = opts.respondImpl ?? (() => true); const sessionRespondImpl = opts.sessionRespondImpl ?? (() => true); const listImpl = opts.listImpl ?? (() => []); + const summaryImpl = + opts.summaryImpl ?? + ((sessionId: string): BridgeSessionSummary => { + throw new SessionNotFoundError(sessionId); + }); const workspaceMcpImpl = opts.workspaceMcpImpl ?? (async () => ({ @@ -1076,6 +1085,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { permissionVotes, sessionPermissionVotes, listCalls, + summaryCalls, workspaceMcpToolsCalls, extensionEvents, sessionContextCalls, @@ -1217,6 +1227,10 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { listCalls.push(workspaceCwd); return listImpl(workspaceCwd); }, + getSessionSummary(sessionId) { + summaryCalls.push(sessionId); + return summaryImpl(sessionId); + }, async getWorkspaceMcpStatus() { workspaceMcpCalls += 1; return workspaceMcpImpl(); @@ -6370,6 +6384,74 @@ describe('createServeApp', () => { }); }); + describe('GET /session/:id/status', () => { + it('200 with the live session summary', async () => { + const summary: BridgeSessionSummary = { + sessionId: 's-1', + workspaceCwd: WS_BOUND, + createdAt: '2026-05-17T12:00:00.000Z', + displayName: 'demo', + clientCount: 2, + hasActivePrompt: true, + }; + const bridge = fakeBridge({ summaryImpl: () => summary }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .get('/session/s-1/status') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual(summary); + expect(bridge.summaryCalls).toEqual(['s-1']); + }); + + it('200 omits displayName when the live session has none', async () => { + const summary: BridgeSessionSummary = { + sessionId: 's-2', + workspaceCwd: WS_BOUND, + createdAt: '2026-05-17T12:00:00.000Z', + clientCount: 0, + hasActivePrompt: false, + }; + const bridge = fakeBridge({ summaryImpl: () => summary }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .get('/session/s-2/status') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect('displayName' in res.body).toBe(false); + }); + + it('404 when the session id is unknown to the daemon', async () => { + // fakeBridge's default getSessionSummary throws SessionNotFoundError. + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .get('/session/ghost/status') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(404); + expect(res.body.sessionId).toBe('ghost'); + expect(bridge.summaryCalls).toEqual(['ghost']); + }); + }); + describe('POST /session/:id/model', () => { it('200 with the agent response on success', async () => { const bridge = fakeBridge({ diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 1c329d52c0b..95f1401c549 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -1074,6 +1074,7 @@ function advertisedMaxPendingPromptsPerSession( * - `POST /session/:id/load` * - `POST /session/:id/resume` * - `GET /workspace/:id/sessions` + * - `GET /session/:id/status` * - `GET /session/:id/context` * - `GET /session/:id/supported-commands` * - `GET /session/:id/tasks` @@ -3323,6 +3324,19 @@ export function createServeApp( } }); + app.get('/session/:id/status', (req, res) => { + const sessionId = requireSessionId(req, res); + if (sessionId === null) return; + try { + res.status(200).json(bridge.getSessionSummary(sessionId)); + } catch (err) { + sendBridgeError(res, err, { + route: 'GET /session/:id/status', + sessionId, + }); + } + }); + app.get('/session/:id/context', async (req, res) => { const sessionId = requireSessionId(req, res); if (sessionId === null) return;