Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e27fc56
feat(web-shell): tmux-backed interactive terminal sub-agent
wenshao Aug 5, 2026
7ccff50
fix(web-shell): satisfy tool-drift guards for the tmux tool
wenshao Aug 6, 2026
5580cbc
docs(web-shell): add real-scenario screenshots for the tmux terminal
wenshao Aug 6, 2026
5845759
Merge branch 'main' into feat/web-shell-tmux-terminal
qwen-code-dev-bot Aug 6, 2026
ce929f8
Merge branch 'main' into feat/web-shell-tmux-terminal
qwen-code-dev-bot Aug 6, 2026
b1ecfea
chore: merge main into feat/web-shell-tmux-terminal
qwen-code-dev-bot Aug 7, 2026
938e078
Merge branch 'main' into feat/web-shell-tmux-terminal
wenshao Aug 7, 2026
ec1eb61
Merge branch 'main' into feat/web-shell-tmux-terminal
wenshao Aug 8, 2026
32ea5d4
Merge branch 'main' into feat/web-shell-tmux-terminal
wenshao Aug 8, 2026
2a07389
Merge branch 'main' into feat/web-shell-tmux-terminal
wenshao Aug 8, 2026
5ecbc40
fix(cli): register tmux English display key
wenshao Aug 8, 2026
cdc802d
fix(web-shell): keep lib artifact single-file and localize tmux badge
wenshao Aug 8, 2026
79369ac
chore: merge main into feat/web-shell-tmux-terminal
qwen-code-dev-bot Aug 10, 2026
662a8f8
fix(web-shell): address tmux terminal review findings (#8613)
qwen-code-dev-bot Aug 11, 2026
698c0a2
test(core): pin remain-on-exit ordering before pane respawn (#8613)
qwen-code-dev-bot Aug 11, 2026
c501daf
Merge remote-tracking branch 'origin/main' into feat/web-shell-tmux-t…
qwen-code-dev-bot Aug 11, 2026
8183e69
Merge branch 'main' into feat/web-shell-tmux-terminal
qwen-code-dev-bot Aug 11, 2026
1a882bf
fix(web-shell): stabilize empty session artifacts to stop render loop…
qwen-code-dev-bot Aug 11, 2026
b8d5a4f
fix(web-shell): address round-5 tmux terminal review findings (#8613)
qwen-code-dev-bot Aug 11, 2026
82d4268
Merge branch 'main' into feat/web-shell-tmux-terminal
qwen-code-dev-bot Aug 12, 2026
3b84149
Merge branch 'main' into feat/web-shell-tmux-terminal
wenshao Aug 13, 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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
252 changes: 252 additions & 0 deletions docs/design/web-shell-tmux-terminal.md

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions package-lock.json

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

13 changes: 13 additions & 0 deletions packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,19 @@ export interface ServeSessionShellTaskStatus {
pid?: number;
exitCode?: number;
error?: string;
/**
* Present when the shell task is backed by an interactive tmux session
* (created by the tmux tool). The daemon terminal endpoint uses this to
* locate the attachable tmux session.
*/
terminal?: {
/** tmux server socket name (`tmux -L <socket>`). */
socket: string;
/** tmux session name on that socket. */
tmuxSession: string;
/** Pane the terminal's command runs in. */
paneId?: string;
};
}

export interface ServeSessionMonitorTaskStatus {
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9154,7 +9154,8 @@ export class Session implements SessionContext {
const isPlanShellCall =
isPlanMode &&
(policyToolName === ToolNames.SHELL ||
policyToolName === ToolNames.MONITOR);
policyToolName === ToolNames.MONITOR ||
policyToolName === ToolNames.TMUX);

if (finalPermission === 'deny') {
return earlyErrorResponse(
Expand All @@ -9170,14 +9171,19 @@ export class Session implements SessionContext {

let planShellAmbientWorkingDirectory: string | undefined;
if (isPlanShellCall) {
const directory = toolParams['directory'];
// tmux carries its working directory under `cwd`, not
// `directory`; inject the ambient value under the key the
// tool actually reads.
const directoryKey =
policyToolName === ToolNames.TMUX ? 'cwd' : 'directory';
const directory = toolParams[directoryKey];
planShellAmbientWorkingDirectory =
typeof directory === 'string' && directory.length > 0
? undefined
: this.config.getTargetDir();
invocation.params = {
...structuredClone(invocation.params),
directory:
[directoryKey]:
typeof directory === 'string' && directory.length > 0
? directory
: planShellAmbientWorkingDirectory,
Expand Down
63 changes: 61 additions & 2 deletions packages/cli/src/acp-integration/session/tasksSnapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@
*/

import { describe, expect, it } from 'vitest';
import type { AgentTask, Config, MonitorTask } from '@qwen-code/qwen-code-core';
import type {
AgentTask,
Config,
MonitorTask,
ShellTask,
} from '@qwen-code/qwen-code-core';
import { buildSessionTasksStatus } from './tasksSnapshot.js';
import type { ServeSessionAgentTaskStatus } from '@qwen-code/acp-bridge/status';
import type {
ServeSessionAgentTaskStatus,
ServeSessionShellTaskStatus,
} from '@qwen-code/acp-bridge/status';

function agentTask(overrides: Partial<AgentTask> = {}): AgentTask {
return {
Expand Down Expand Up @@ -107,6 +115,57 @@ describe('buildSessionTasksStatus agent lineage', () => {
});
});

function shellTask(overrides: Partial<ShellTask> = {}): ShellTask {
return {
kind: 'shell',
id: 'bg_0123456789abcdef',
shellId: 'bg_0123456789abcdef',
command: 'python3 app.py',
description: 'python3 app.py',
status: 'running',
startTime: 1_000,
outputFile: '/tmp/shell-bg.output',
cwd: '/work',
abortController: new AbortController(),
...overrides,
} as unknown as ShellTask;
}

function serializedShell(shell: ShellTask): ServeSessionShellTaskStatus {
const config = {
getBackgroundTaskRegistry: () => ({ getAll: () => [] }),
getBackgroundShellRegistry: () => ({ getAll: () => [shell] }),
getMonitorRegistry: () => ({ getAll: () => [] }),
} as unknown as Config;
return buildSessionTasksStatus('session-1', config, 2_000).tasks.find(
(task) => task.kind === 'shell',
) as ServeSessionShellTaskStatus;
}

describe('buildSessionTasksStatus shell tasks', () => {
it('passes terminal metadata through for tmux-backed tasks', () => {
const task = serializedShell(
shellTask({
terminal: {
socket: 'qwen-serve',
tmuxSession: 'qsh-bg_x',
paneId: '%1',
},
}),
);
expect(task.terminal).toEqual({
socket: 'qwen-serve',
tmuxSession: 'qsh-bg_x',
paneId: '%1',
});
});

it('omits terminal for plain shell tasks', () => {
const task = serializedShell(shellTask());
expect('terminal' in task).toBe(false);
});
});

describe('buildSessionTasksStatus monitor correlation', () => {
it('exposes the tool call that launched a monitor', () => {
const task = serializedMonitor({
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/acp-integration/session/tasksSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ function serializeShellTask(
...optionalField('pid', entry.pid),
...optionalField('exitCode', entry.exitCode),
...optionalField('error', entry.error),
...optionalField('terminal', entry.terminal),
};
}

Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2494,6 +2494,7 @@ describe('mergeExcludeTools', () => {
const defaultExcludes = [
ToolNames.SHELL,
ToolNames.MONITOR,
ToolNames.TMUX,
ToolNames.EDIT,
ToolNames.WRITE_FILE,
];
Expand Down Expand Up @@ -2655,6 +2656,7 @@ describe('Approval mode tool exclusion logic', () => {
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).toContain(ToolNames.SHELL);
expect(excludedTools).toContain(ToolNames.MONITOR);
expect(excludedTools).toContain(ToolNames.TMUX);
expect(excludedTools).toContain(ToolNames.EDIT);
expect(excludedTools).toContain(ToolNames.WRITE_FILE);
});
Expand All @@ -2675,6 +2677,7 @@ describe('Approval mode tool exclusion logic', () => {
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).toContain(ToolNames.SHELL);
expect(excludedTools).toContain(ToolNames.MONITOR);
expect(excludedTools).toContain(ToolNames.TMUX);
expect(excludedTools).toContain(ToolNames.EDIT);
expect(excludedTools).toContain(ToolNames.WRITE_FILE);
});
Expand All @@ -2696,6 +2699,26 @@ describe('Approval mode tool exclusion logic', () => {
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).toContain(ToolNames.SHELL);
expect(excludedTools).toContain(ToolNames.MONITOR);
expect(excludedTools).toContain(ToolNames.TMUX);
expect(excludedTools).toContain(ToolNames.EDIT);
expect(excludedTools).toContain(ToolNames.WRITE_FILE);
});

it('should not exclude tmux when explicitly allowed in tools.allowed', async () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments();
const settings: Settings = {
tools: {
allowed: [ToolNames.TMUX],
},
};

const config = await loadCliConfig(settings, argv, undefined, []);

const excludedTools = config.getPermissionsDeny();
expect(excludedTools).toContain(ToolNames.SHELL);
expect(excludedTools).toContain(ToolNames.MONITOR);
expect(excludedTools).not.toContain(ToolNames.TMUX);
expect(excludedTools).toContain(ToolNames.EDIT);
expect(excludedTools).toContain(ToolNames.WRITE_FILE);
});
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1869,6 +1869,7 @@ export async function loadCliConfig(
// Deny all write/execute tools unless explicitly allowed.
denyUnlessAllowed(ToolNames.SHELL as ToolName);
denyUnlessAllowed(ToolNames.MONITOR as ToolName);
denyUnlessAllowed(ToolNames.TMUX as ToolName);
denyUnlessAllowed(ToolNames.EDIT as ToolName);
denyUnlessAllowed(ToolNames.WRITE_FILE as ToolName);
break;
Expand All @@ -1880,13 +1881,15 @@ export async function loadCliConfig(
// toward YOLO behavior).
denyUnlessAllowed(ToolNames.SHELL as ToolName);
denyUnlessAllowed(ToolNames.MONITOR as ToolName);
denyUnlessAllowed(ToolNames.TMUX as ToolName);
denyUnlessAllowed(ToolNames.EDIT as ToolName);
denyUnlessAllowed(ToolNames.WRITE_FILE as ToolName);
break;
case ApprovalMode.AUTO_EDIT:
// Shell-like execute tools still require a prompt in auto-edit mode.
denyUnlessAllowed(ToolNames.SHELL as ToolName);
denyUnlessAllowed(ToolNames.MONITOR as ToolName);
denyUnlessAllowed(ToolNames.TMUX as ToolName);
break;
case ApprovalMode.YOLO:
// No extra denials for YOLO mode.
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/i18n/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,4 +208,19 @@ describe('localizeToolDisplayName', () => {
);
expect(untranslated).toEqual([]);
});

it('has a zh-TW translation for every core tool display name', async () => {
const { setLanguageAsync, localizeToolDisplayName } = await import(
'./index.js'
);
const { ToolDisplayNames } = await import('@qwen-code/qwen-code-core');
await setLanguageAsync('zh-TW');

const KEEP_ENGLISH = new Set(['Agent', 'Grep', 'Glob']);
const untranslated = Object.values(ToolDisplayNames).filter(
(name) =>
!KEEP_ENGLISH.has(name) && localizeToolDisplayName(name) === name,
);
expect(untranslated).toEqual([]);
});
});
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export default {
'toolDisplayName.SendMessage': 'toolDisplayName.SendMessage',
'toolDisplayName.StructuredOutput': 'toolDisplayName.StructuredOutput',
'toolDisplayName.Monitor': 'toolDisplayName.Monitor',
'toolDisplayName.Tmux': 'toolDisplayName.Tmux',
'toolDisplayName.NotebookEdit': 'toolDisplayName.NotebookEdit',
'toolDisplayName.ToolSearch': 'toolDisplayName.ToolSearch',
'toolDisplayName.EnterWorktree': 'toolDisplayName.EnterWorktree',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/zh-TW.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ export default {
'toolDisplayName.SendMessage': '傳送訊息',
'toolDisplayName.StructuredOutput': '結構化輸出',
'toolDisplayName.Monitor': '監控',
'toolDisplayName.Tmux': '終端',
'toolDisplayName.NotebookEdit': '編輯 Notebook',
'toolDisplayName.ToolSearch': '工具搜尋',
'toolDisplayName.EnterWorktree': '進入 Worktree',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ export default {
'toolDisplayName.SendMessage': '发送消息',
'toolDisplayName.StructuredOutput': '结构化输出',
'toolDisplayName.Monitor': '监控',
'toolDisplayName.Tmux': '终端',
'toolDisplayName.NotebookEdit': '编辑 Notebook',
'toolDisplayName.ToolSearch': '工具搜索',
'toolDisplayName.EnterWorktree': '进入 Worktree',
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/serve/acp-http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,13 @@ export interface MountAcpHttpOptions {
export interface ExtraWsRoute {
path: string;
onConnection: (ws: WebSocket, req: IncomingMessage) => void;
/**
* Dispatch before the ACP-mount drain gate. For routes that own their own
* protocol and resolve their own scope (the handler validates the target
* itself) — otherwise a primary-runtime drain 503s connections that the
* handler would have resolved to a healthy runtime.
*/
bypassMountDrainGate?: boolean;
}

/**
Expand Down Expand Up @@ -1761,6 +1768,17 @@ export function mountAcpHttp(
activeMount = resolvedMount;
}

if (extraRoute?.bypassMountDrainGate) {
wss!.handleUpgrade(req, socket, head, (ws: WebSocket) => {
if (disposed) {
ws.close(1012, 'Server shutting down');
return;
}
extraRoute.onConnection(ws, req);
});
return;
}

if (activeMount.draining) {
logReject(`workspace-draining ${activeMount.routeLabel}`);
socket.write(
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/serve/process-env-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,14 @@ const allowedProcessEnvAccesses = normalizeAllowances([
},
},
],
[
'packages/cli/src/serve/terminal/terminal-ws.ts',
{
reason:
'The terminal attach pty uses the daemon environment as its base so `tmux` resolves on PATH and the tmux socket location is reachable.',
accesses: { whole: 1 },
},
],
]);

function listTypeScriptFiles(dir: string): string[] {
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/serve/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
type ExtraWsRoute,
} from './acp-http/index.js';
import { createVoiceWsConnectionHandler } from './voice/voice-ws.js';
import { createTerminalWsConnectionHandler } from './terminal/terminal-ws.js';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined for this round with evidence: this repo's house pattern tests the WS handlers at handler level — voice-ws.test.ts covers the voice handler the same way, and no app-level wiring test exists for /voice/stream either. The /terminal wiring in createServeApp mirrors the voice block 1:1 (same extraWsRoutes array), and the handler itself now has 23 tests covering auth resolution, task lookup, caps, and lifecycle. Happy to add a serve-app-level wiring test in a follow-up if maintainers want one for both routes together.

本轮暂不处理并附证据:本仓库的既有模式是在 handler 层测试 WS 处理器——voice-ws.test.ts 对 voice 处理器也是如此,/voice/stream 同样没有应用级接线测试。createServeApp 中的 /terminal 接线与 voice 代码块 1:1 镜像(同一个 extraWsRoutes 数组),且 handler 本身已有 23 个测试覆盖鉴权解析、任务查找、连接上限与生命周期。若维护者希望为两个路由统一补充应用级接线测试,可在后续 PR 中进行。

import {
ClientMcpSenderRegistry,
createClientMcpServerProvider,
Expand Down Expand Up @@ -2729,6 +2730,21 @@ export function createServeApp(
acquireVoiceLease: acquirePrimaryVoiceLease,
}),
},
// Browser live-terminal attach for tmux-backed shell tasks (the core
// tmux tool). Shares the upgrade listener's loopback/CSRF/bearer
// checks; the handler validates the task against the live registry
// before attaching. It resolves the owning runtime per session, so a
// primary-runtime drain must not gate it (healthy secondary workspaces
// stay attachable); hence bypassMountDrainGate.
{
path: '/terminal',
bypassMountDrainGate: true,
onConnection: createTerminalWsConnectionHandler({
workspaceRegistry,
daemonLog,
resolveAttachEnv: (runtime) => getRuntimeEffectiveEnv(runtime.env),
}),
},
],
workspaceVoiceConnection: (runtime, ws, req) =>
createVoiceWsConnectionHandler(runtime.workspaceCwd, {
Expand Down
Loading
Loading