Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8364f8c
feat(web-shell): add opt-in interactive browser terminal
Aug 25, 2026
14995bf
Merge remote-tracking branch 'origin/main' into worktree-terminal-panel
Aug 25, 2026
e009472
Merge branch 'main' into worktree-terminal-panel
ytahdn Aug 25, 2026
f5819dc
chore(vscode): update third-party notices
Aug 25, 2026
10f088e
test(cli): include web terminal capability
Aug 25, 2026
c0114c0
test(integration): include web terminal capability
Aug 25, 2026
50a15d0
fix(web-shell): harden terminal lifecycle
Aug 25, 2026
dec726f
Merge branch 'main' into worktree-terminal-panel
ytahdn Aug 25, 2026
161b7bd
Merge remote-tracking branch 'origin/main' into worktree-terminal-panel
Aug 25, 2026
36f7c43
docs(serve): document web terminal capability
Aug 26, 2026
759ac96
Merge remote-tracking branch 'origin/main' into worktree-terminal-panel
Aug 26, 2026
fdb8fbe
Merge branch 'main' into worktree-terminal-panel
wenshao Aug 26, 2026
cc54771
fix(web-shell): address terminal review findings
Aug 26, 2026
4b1f855
fix(web-shell): address terminal review findings
Aug 27, 2026
2dac41c
Merge branch 'main' into worktree-terminal-panel
ytahdn Aug 27, 2026
f41be7b
fix(web-shell): preserve terminal sessions and key events
Aug 27, 2026
7dca447
fix(web-shell): harden terminal session cleanup
Aug 28, 2026
c36212e
fix(web-shell): make terminal limits recoverable
Aug 28, 2026
34a5b0a
fix(web-shell): isolate terminal sessions
Aug 28, 2026
4960873
Merge branch 'main' into worktree-terminal-panel
ytahdn Aug 28, 2026
72d611b
Merge branch 'main' into worktree-terminal-panel
ytahdn Aug 28, 2026
da212e5
Merge branch 'main' into worktree-terminal-panel
wenshao Aug 28, 2026
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
20 changes: 20 additions & 0 deletions docs/design/web-terminal-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Web terminal lifecycle

The browser terminal is a manually opened Web Shell surface. The host must add
the terminal right-panel action and the daemon must advertise the
`web_terminal` capability before the UI exposes the entry. WebSocket requests
use the daemon's shared Host, Origin, authentication and workspace trust gates.

Each terminal is owned by one active, trusted workspace runtime. The daemon
resolves the client workspace selector against the live registry and supplies
that runtime's cwd and effective environment to the PTY. A terminal id cannot
be reclaimed through another workspace.

Closing a terminal tab sends an explicit release control frame and kills the
PTY. Hiding the panel, switching tabs or losing the WebSocket only detaches the
client, so reconnect can replay the same session. A detached session is killed
after the idle timeout even if its command is still running. Exited sessions
replay both scrollback and exit state. Binary WebSocket frames contain UTF-8
terminal input and output; NUL-prefixed text frames contain control messages.
Reloading the page loses the in-memory terminal id, so the detached session is
reclaimed after the idle timeout rather than restored into the new page.
1 change: 1 addition & 0 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,7 @@ operator diagnostic snapshot documented below.
| `browser_automation_mcp` | ACP HTTP is enabled, `cdp_tunnel_over_ws` is active, no bearer token blocks `/cdp`, and `QWEN_CDP_MCP_COMMAND` names an external stdio MCP adapter. The main CLI package does not bundle a browser automation adapter; without this tag, Chrome extension side-panel chat may still work, but console/network/screenshot/click tools are not registered by default. |
| `voice_transcribe` | the Voice WebSocket endpoint is mounted; a configured Voice model is still required for a successful transcription. |
| `realtime_voice` | the macOS WebShell daemon has Live Voice enabled and native Host integration active. `/live/status` reports readiness, but the capability is withdrawn until the feature is enabled. |
| `web_terminal` | ACP HTTP is enabled, so the authenticated Web Terminal endpoint is available. |

<!-- conditional-serve-features:end -->

Expand Down
9 changes: 8 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion packages/cli/src/serve/acp-http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,8 @@ export interface MountAcpHttpOptions {
*/
export interface ExtraWsRoute {
path: string;
/** The route resolves its own workspace lifecycle instead of using primary. */
bypassPrimaryDrain?: boolean;
onConnection: (ws: WebSocket, req: IncomingMessage) => void;
}

Expand Down Expand Up @@ -1852,7 +1854,7 @@ export function mountAcpHttp(
activeMount = resolvedMount;
}

if (activeMount.draining) {
if (extraRoute?.bypassPrimaryDrain !== true && activeMount.draining) {
logReject(`workspace-draining ${activeMount.routeLabel}`);
socket.write(
'HTTP/1.1 503 Service Unavailable\r\nRetry-After: 5\r\n\r\n',
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,17 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
HTTPS_PROXY: 'http://primary-proxy.example:8080',
},
workspaceRegistry,
extraWsRoutes: [
{
path: '/test-extra',
bypassPrimaryDrain: true,
onConnection: (ws) => {
ws.send('extra-ready');
ws.close(1000, 'done');
},
},
{ path: '/test-primary-extra', onConnection: () => {} },
],
deviceFlowRegistry,
cdpTunnelOverWs: true,
cdpTunnelRegistry: cdpRegistry,
Expand Down Expand Up @@ -1745,6 +1756,41 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
expect(response).toEqual({ status: 503, retryAfter: '5' });
});

it('does not apply primary ACP drain to an extra WebSocket route', async () => {
handle!.beginWorkspaceDrain('primary-id');

const message = await new Promise<string>((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/test-extra`, {
handshakeTimeout: 2000,
});
ws.on('message', (data: WebSocket.RawData) => resolve(data.toString()));
ws.on('error', reject);
});

expect(message).toBe('extra-ready');
});

it('keeps primary-scoped extra routes behind the primary drain gate', async () => {
handle!.beginWorkspaceDrain('primary-id');

const status = await new Promise<number | undefined>((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/test-primary-extra`, {
handshakeTimeout: 2000,
});
ws.on('unexpected-response', (_req, res) => {
resolve(res.statusCode);
ws.terminate();
});
ws.on('open', () => {
ws.close();
reject(new Error('primary-scoped extra route should not open'));
});
ws.on('error', reject);
});

expect(status).toBe(503);
});

it('rejects unowned and spoofed correlation frames during drain', async () => {
const replies = await new Promise<Array<Record<string, unknown>>>(
(resolve, reject) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/serve/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
// gate. `/live/status` remains the dynamic readiness surface for the Host,
// permissions, self-checks, and provider reachability.
realtime_voice: { since: 'v1' },
web_terminal: { since: 'v1' },
Comment thread
ytahdn marked this conversation as resolved.
} as const satisfies Record<string, ServeCapabilityDescriptor>;

export type ServeFeature = keyof typeof SERVE_CAPABILITY_REGISTRY;
Expand Down Expand Up @@ -678,6 +679,7 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap<
(toggles) =>
toggles.acpHttpEnabled === true && toggles.realtimeVoiceEnabled === true,
],
['web_terminal', (toggles) => toggles.acpHttpEnabled === true],
]);

export const SERVE_FEATURES = Object.freeze(
Expand Down
Loading
Loading